From c8b5d270784281c99f49f9effb26264e2819e1ff Mon Sep 17 00:00:00 2001 From: wxs Date: Tue, 31 Mar 2026 18:52:34 +0800 Subject: [PATCH] feat: add yuntu report filling automation --- .gitignore | 1 + .../plans/2026-03-31-yuntu-report-server.md | 69 ++ yuntu/yuntuReportFilling/PRD.md | 405 +++++++ yuntu/yuntuReportFilling/server/.env.example | 3 + yuntu/yuntuReportFilling/server/README.md | 18 + .../server/package-lock.json | 1014 +++++++++++++++++ yuntu/yuntuReportFilling/server/package.json | 18 + .../yuntuReportFilling/server/sql/schema.sql | 55 + yuntu/yuntuReportFilling/server/src/db.js | 15 + yuntu/yuntuReportFilling/server/src/index.js | 36 + .../server/src/report-repository.js | 86 ++ .../server/src/report-service.js | 115 ++ yuntu/yuntuReportFilling/server/src/server.js | 70 ++ .../server/test/report-service.test.js | 95 ++ .../server/test/server.test.js | 146 +++ .../yuntuReportFilling.test.js | 134 +++ .../yuntuReportFilling.user.js | 716 ++++++++++++ 17 files changed, 2996 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-31-yuntu-report-server.md create mode 100644 yuntu/yuntuReportFilling/PRD.md create mode 100644 yuntu/yuntuReportFilling/server/.env.example create mode 100644 yuntu/yuntuReportFilling/server/README.md create mode 100644 yuntu/yuntuReportFilling/server/package-lock.json create mode 100644 yuntu/yuntuReportFilling/server/package.json create mode 100644 yuntu/yuntuReportFilling/server/sql/schema.sql create mode 100644 yuntu/yuntuReportFilling/server/src/db.js create mode 100644 yuntu/yuntuReportFilling/server/src/index.js create mode 100644 yuntu/yuntuReportFilling/server/src/report-repository.js create mode 100644 yuntu/yuntuReportFilling/server/src/report-service.js create mode 100644 yuntu/yuntuReportFilling/server/src/server.js create mode 100644 yuntu/yuntuReportFilling/server/test/report-service.test.js create mode 100644 yuntu/yuntuReportFilling/server/test/server.test.js create mode 100644 yuntu/yuntuReportFilling/yuntuReportFilling.test.js create mode 100644 yuntu/yuntuReportFilling/yuntuReportFilling.user.js diff --git a/.gitignore b/.gitignore index 2fcecf3..5ed5f18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ **/node_modules/ **/.DS_Store +**/.env diff --git a/docs/superpowers/plans/2026-03-31-yuntu-report-server.md b/docs/superpowers/plans/2026-03-31-yuntu-report-server.md new file mode 100644 index 0000000..0e8d458 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-yuntu-report-server.md @@ -0,0 +1,69 @@ +# Yuntu Report Server Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a local Node.js backend that exposes `POST /api/reports` for the browser script and persists validated report records into PostgreSQL. + +**Architecture:** Use a small Express server with a thin HTTP layer, isolated validation/mapping utilities, and a PostgreSQL repository backed by parameterized SQL. Keep report validation and database row mapping in pure functions so they can be covered with fast `node:test` tests before wiring the HTTP server. + +**Tech Stack:** Node.js, Express, PostgreSQL (`pg`), `dotenv`, built-in `node:test` + +--- + +### Task 1: Bootstrap the local server package + +**Files:** +- Create: `yuntu/yuntuReportFilling/server/package.json` +- Create: `yuntu/yuntuReportFilling/server/.env.example` +- Create: `yuntu/yuntuReportFilling/server/README.md` + +- [ ] **Step 1: Create the package manifest** +- [ ] **Step 2: Add local development scripts for start, dev, and test** +- [ ] **Step 3: Add `.env.example` documenting required database and server variables** +- [ ] **Step 4: Add a minimal README with startup instructions** + +### Task 2: Write failing tests for validation and mapping + +**Files:** +- Create: `yuntu/yuntuReportFilling/server/test/report-service.test.js` +- Test: `yuntu/yuntuReportFilling/server/test/report-service.test.js` + +- [ ] **Step 1: Write a test for accepting a valid manual report payload** +- [ ] **Step 2: Run the test and verify it fails because the module does not exist yet** +- [ ] **Step 3: Write a test for rejecting `AUTO_COPY` without `sourceReportId`** +- [ ] **Step 4: Write a test for mapping API payload fields into database-ready column values** + +### Task 3: Implement the pure report service module + +**Files:** +- Create: `yuntu/yuntuReportFilling/server/src/report-service.js` +- Test: `yuntu/yuntuReportFilling/server/test/report-service.test.js` + +- [ ] **Step 1: Implement payload validation helpers** +- [ ] **Step 2: Implement normalization/mapping helpers for database insertion** +- [ ] **Step 3: Re-run the focused test suite and verify it passes** + +### Task 4: Implement the database repository and HTTP server + +**Files:** +- Create: `yuntu/yuntuReportFilling/server/src/db.js` +- Create: `yuntu/yuntuReportFilling/server/src/report-repository.js` +- Create: `yuntu/yuntuReportFilling/server/src/server.js` +- Create: `yuntu/yuntuReportFilling/server/src/index.js` +- Modify: `yuntu/yuntuReportFilling/PRD.md` + +- [ ] **Step 1: Add PostgreSQL pool creation from environment variables** +- [ ] **Step 2: Add repository insert logic with idempotent `report_id` handling** +- [ ] **Step 3: Add Express routes for `GET /health` and `POST /api/reports`** +- [ ] **Step 4: Add CORS and JSON parsing suitable for local browser-script access** +- [ ] **Step 5: Re-run tests and add any missing focused coverage** + +### Task 5: Verify the local backend end-to-end + +**Files:** +- Verify: `yuntu/yuntuReportFilling/server/*` + +- [ ] **Step 1: Install dependencies** +- [ ] **Step 2: Run `npm test` and confirm all tests pass** +- [ ] **Step 3: Start the server locally and confirm `GET /health` returns success** +- [ ] **Step 4: Summarize required next step for integrating the browser script** diff --git a/yuntu/yuntuReportFilling/PRD.md b/yuntu/yuntuReportFilling/PRD.md new file mode 100644 index 0000000..4c1a4ad --- /dev/null +++ b/yuntu/yuntuReportFilling/PRD.md @@ -0,0 +1,405 @@ +# 云图报告自动创建 PRD + +## 一、项目基本信息 + +- 项目路径:`/Users/wxs/projects/browser_script/scriptCat/yuntu/yuntuReportFilling` +- 项目类型:ScriptCat 浏览器脚本 + Node.js 后端服务 + PostgreSQL +- 业务目标:在云图页面中复用最近一次成功创建报告的请求参数,自动创建一份同比报告,并将关键数据持久化到数据库 +- 核心价值: + - 降低人工重复创建报告的成本 + - 自动将时间范围回退一年,方便做同比分析 + - 为后续追溯和排查保留完整 payload 与 report_id + +## 二、业务背景 + +当前用户在云图页面手动创建报告后,还需要再次输入或复制相同配置,才能创建同比报告。该过程重复且容易出错。 + +本项目通过监听用户首次手动创建报告时的请求与响应,缓存最近一次成功的 payload;当用户点击右侧按钮后,脚本自动将顶层 `startTime` 与 `endTime` 各回退一年,并再次调用云图创建接口完成报告复制。 + +## 三、范围说明 + +### 本期范围 + +- 监听并识别云图“创建报告”接口 +- 记录最近一次成功创建报告的完整 payload +- 在页面右侧提供“一键复制同比报告”按钮 +- 自动回退顶层 `startTime` / `endTime` 一年 +- 调用后端 API 持久化成功创建的报告数据 +- 将手动创建报告和脚本自动创建报告统一写入 PostgreSQL + +### 本期不做 + +- 不从数据库反向同步最新 payload 到浏览器本地 +- 不支持多条历史 payload 管理 +- 不支持用户手动编辑复制前的字段 +- 不修改除顶层 `startTime` / `endTime` 外的其他字段 + +## 四、目标页面与第三方接口 + +### 4.1 目标页面 + +- 页面域名:`https://yuntu.oceanengine.com` +- 目标页面路径:`/yuntu_brand/ecom/product/segmentedMarketcreation` +- 页面示例:`https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation?aadvid=1648829117232140` + +### 4.2 需监听与复用的云图接口 + +- 请求方法:`POST` +- 接口路径:`/product_node/v2/api/segmentedMarket/createSegmentedMarket` +- 完整示例: + - `https://yuntu.oceanengine.com/product_node/v2/api/segmentedMarket/createSegmentedMarket?aadvid=1648829117232140` +- 关键识别条件: + - 请求方法必须为 `POST` + - 路径必须匹配 `createSegmentedMarket` + - 查询参数中包含 `aadvid` + +### 4.3 第三方接口约束 + +- 浏览器脚本调用该接口时,必须复用当前用户浏览器会话中的登录态 +- 不允许在脚本或后端中硬编码 Cookie、CSRF Token 等敏感信息 +- 只允许浏览器脚本直接调用云图接口;后端不直接代调云图接口 +- 接口成功后,从响应体 `data.reportId` 中提取业务 `report_id` + +## 五、核心流程 + +### 5.1 手动创建报告并记录 + +触发时机:用户在页面中手动点击“创建报告”并且云图接口返回成功。 + +执行流程: + +1. 监听 `createSegmentedMarket` 请求与响应。 +2. 提取请求 payload 与请求 URL 中的 `aadvid`。 +3. 校验响应成功,且响应体存在 `data.reportId`。 +4. 将以下内容写入浏览器本地: + - `lastReportPayload`:最近一次成功创建报告的完整 payload + - `lastReportMeta`:最近一次成功创建报告的元信息,至少包含 `reportId`、`aadvid`、`sourceType`、`capturedAt` +5. 如果本地写入失败,立即报错,并保持按钮不可用状态;本次不调用后端持久化接口。 +6. 如果本地写入成功,调用后端 API 将本次手动创建报告写入数据库。 + +### 5.2 一键复制同比报告 + +触发时机:用户点击页面右侧“一键复制同比报告”按钮。 + +执行流程: + +1. 读取 `lastReportPayload` 与 `lastReportMeta`。 +2. 若本地不存在有效 payload,则按钮保持置灰,不允许点击。 +3. 深拷贝 payload,仅修改顶层 `startTime` 和 `endTime`。 +4. 使用修改后的 payload 调用云图创建接口。 +5. 接口成功后,从响应体中读取新的 `data.reportId`。 +6. 先将新的 payload 和元信息覆盖写入本地存储。 +7. 本地写入成功后,调用后端 API 持久化本次自动复制创建的报告数据。 +8. 持久化成功后,给出成功提示,并显示新的 `report_id`。 + +失败处理: + +- 云图接口调用失败:提示“同比报告创建失败”,保留旧的本地 payload,不调用后端 API。 +- 本地覆盖写入失败:提示“本地缓存更新失败”,不调用后端 API,按钮恢复可点击。 +- 后端持久化失败:提示“报告已创建成功,但数据库记录失败”,保留新的本地 payload,按钮恢复可点击。 + +## 六、时间处理规则 + +### 6.1 处理目标 + +将顶层 `startTime` 与 `endTime` 各回退一个自然年,用于生成同比报告。 + +### 6.2 处理原则 + +- 只处理 payload 顶层字段: + - `startTime` + - `endTime` +- 其他字段保持原值不变 +- 日期格式统一为 `YYYY-MM-DD` +- 使用“自然年回退”规则,而不是简单减去 365 天 + +### 6.3 具体算法 + +1. 将 `startTime`、`endTime` 解析为日期对象。 +2. 分别将年份减 1,尽量保持原始月与日不变。 +3. 如果目标年份不存在该日期,则回退到目标月份最后一天。 + +示例: + +```javascript +// 正常日期 +2025-03-01 -> 2024-03-01 +2026-02-28 -> 2025-02-28 + +// 闰年特殊情况 +2024-02-29 -> 2023-02-28 +``` + +该规则可以最大限度保持同比区间的自然日语义。 + +## 七、页面交互设计 + +### 7.1 按钮位置 + +- 按钮固定显示在页面右侧中部 +- 文案建议:`一键复制同比报告` + +### 7.2 按钮状态 + +- 默认状态: + - 当本地存在有效 `lastReportPayload` 时可点击 + - 当本地不存在有效 `lastReportPayload` 时置灰且不可点击 +- 加载状态: + - 点击后立即进入 loading + - loading 期间禁止再次点击,防止重复创建 +- 完成状态: + - 成功后 toast 提示创建成功,并展示新 `report_id` + - 成功后不自动跳转页面,保持用户留在当前页面,避免打断连续操作 + +### 7.3 状态恢复规则 + +- 用户再次手动成功创建报告后,按钮状态根据本地缓存重新计算 +- 任何失败场景都需要正确退出 loading 状态 + +## 八、浏览器本地存储设计 + +### 8.1 存储方式 + +- 使用 `GM_setValue` / `GM_getValue` + +### 8.2 存储键 + +- `lastReportPayload` +- `lastReportMeta` + +### 8.3 存储内容 + +`lastReportPayload`: + +```json +{ + "endTime": "2026-02-28", + "name": "测试", + "periodType": "MONTH", + "startTime": "2025-03-01" +} +``` + +`lastReportMeta`: + +```json +{ + "reportId": "123456789", + "aadvid": "1648829117232140", + "sourceType": "MANUAL_CAPTURE", + "capturedAt": "2026-03-31T15:00:00+08:00" +} +``` + +### 8.4 更新规则 + +- 每次成功创建报告后覆盖写入 +- 始终只保留最近一次成功创建报告的数据 +- 脚本启动时不从数据库同步数据 + +## 九、后端 API 设计 + +后端职责仅为持久化成功创建的报告数据,不直接调用云图接口。 + +### 9.1 保存报告记录 + +- 方法:`POST` +- 路径:`/api/reports` +- 用途:保存一条已创建成功的报告记录 + +请求体: + +```json +{ + "reportId": "123456789", + "sourceType": "AUTO_COPY", + "sourceReportId": "987654321", + "aadvid": "1648829117232140", + "name": "测试", + "price": ["1,100", "101,100000"], + "rules": [ + { + "keywords": ["奶粉"], + "op": "INCLUDE" + } + ], + "analysisDims": [ + "MARKETOVERVIEW", + "PRODUCTINSIGHT", + "MARKETCOMPOSING", + "CONSUMERINSIGHT" + ], + "categories": [], + "channels": ["ALL"], + "startTime": "2024-03-01", + "endTime": "2025-02-28", + "periodType": "MONTH", + "userName": "qichumiaosi@163.com", + "payload": {} +} +``` + +字段说明: + +- `reportId`:云图响应中的 `data.reportId` +- `sourceType`:取值为 `MANUAL_CAPTURE` 或 `AUTO_COPY` +- `sourceReportId`: + - `MANUAL_CAPTURE` 时可为空 + - `AUTO_COPY` 时必填,表示复制来源报告 ID +- `aadvid`:从页面 URL 或请求 URL 中提取 +- `payload`:完整请求体,原样保存 + +成功响应: + +```json +{ + "success": true, + "data": { + "id": 1, + "reportId": "123456789" + } +} +``` + +失败响应: + +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "sourceReportId is required when sourceType is AUTO_COPY" + } +} +``` + +### 9.2 后端校验规则 + +- `reportId` 必填 +- `sourceType` 必须为 `MANUAL_CAPTURE` 或 `AUTO_COPY` +- `AUTO_COPY` 场景下 `sourceReportId` 必填 +- `payload` 必须为合法 JSON +- `startTime`、`endTime` 必须符合 `YYYY-MM-DD` +- 数据库以 `report_id` 做唯一约束,重复写入时返回成功并提示已存在,保证接口幂等 + +## 十、数据库设计 + +### 10.1 表设计说明 + +表名:`public.yuntu_report_info` + +字段设计目标: + +- 记录云图返回的业务 `report_id` +- 区分手动创建和自动复制 +- 能追溯自动复制来源 +- 保存结构化字段与完整 payload + +### 10.2 建表 SQL + +```sql +DROP TABLE IF EXISTS public.yuntu_report_info; + +CREATE TABLE public.yuntu_report_info ( + id BIGSERIAL PRIMARY KEY, + report_id VARCHAR(64) NOT NULL, + source_type VARCHAR(32) NOT NULL, + source_report_id VARCHAR(64) NULL, + aadvid VARCHAR(32) NOT NULL, + name TEXT NOT NULL, + price JSONB NOT NULL DEFAULT '[]'::jsonb, + rules JSONB NOT NULL DEFAULT '[]'::jsonb, + analysis_dims JSONB NOT NULL DEFAULT '[]'::jsonb, + categories JSONB NOT NULL DEFAULT '[]'::jsonb, + transaction_channels JSONB NOT NULL DEFAULT '[]'::jsonb, + start_time DATE NOT NULL, + end_time DATE NOT NULL, + period_type VARCHAR(32) NULL, + user_name TEXT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_yuntu_report_info_report_id UNIQUE (report_id), + CONSTRAINT chk_yuntu_report_info_source_type + CHECK (source_type IN ('MANUAL_CAPTURE', 'AUTO_COPY')), + CONSTRAINT chk_yuntu_report_info_copy_source + CHECK ( + (source_type = 'MANUAL_CAPTURE' AND source_report_id IS NULL) + OR + (source_type = 'AUTO_COPY' AND source_report_id IS NOT NULL) + ) +); + +CREATE INDEX idx_yuntu_report_info_source_report_id + ON public.yuntu_report_info (source_report_id); + +CREATE INDEX idx_yuntu_report_info_created_at + ON public.yuntu_report_info (created_at DESC); + +CREATE INDEX idx_yuntu_report_info_aadvid_created_at + ON public.yuntu_report_info (aadvid, created_at DESC); + +CREATE OR REPLACE FUNCTION public.set_yuntu_report_info_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_yuntu_report_info_updated_at ON public.yuntu_report_info; + +CREATE TRIGGER trg_yuntu_report_info_updated_at +BEFORE UPDATE ON public.yuntu_report_info +FOR EACH ROW +EXECUTE FUNCTION public.set_yuntu_report_info_updated_at(); +``` + +### 10.3 字段映射 + +| 数据库字段 | 来源字段 | 说明 | +| --- | --- | --- | +| `report_id` | `response.data.reportId` | 云图报告 ID | +| `source_type` | 脚本内部标记 | 手动捕获或自动复制 | +| `source_report_id` | `lastReportMeta.reportId` | 自动复制来源报告 ID | +| `aadvid` | query 参数 `aadvid` | 广告主 ID | +| `name` | `payload.name` | 报告名称 | +| `price` | `payload.price` | 价格区间 | +| `rules` | `payload.rules` | 规则配置 | +| `analysis_dims` | `payload.analysisDims` | 分析维度 | +| `categories` | `payload.categories` | 类目树 | +| `transaction_channels` | `payload.channels` | 渠道信息 | +| `start_time` | `payload.startTime` | 报告开始日期 | +| `end_time` | `payload.endTime` | 报告结束日期 | +| `period_type` | `payload.periodType` | 周期类型 | +| `user_name` | `payload.userName` | 用户名 | +| `payload` | 完整 payload | 原始请求体 | + +## 十一、错误处理 + +### 11.1 浏览器脚本侧 + +- 拦截不到目标接口:不报错,不显示成功态 +- 响应中缺少 `data.reportId`:提示“未获取到 report_id,无法记录” +- 本地存储失败:立即报错,并终止当前脚本链路 +- 用户重复点击:忽略后续点击并保持 loading +- 请求超时:提示失败并恢复按钮 + +### 11.2 后端侧 + +- 参数校验失败:返回 4xx 与明确错误信息 +- 数据库写入失败:返回 5xx 与错误码 +- 重复 `report_id` 写入:按幂等成功处理,不重复插入 + +## 十二、验收标准 + +1. 用户在目标页面手动成功创建报告后,脚本能够识别目标接口,并从响应体中正确提取 `data.reportId`。 +2. 手动创建成功后,本地必须成功写入 `lastReportPayload` 和 `lastReportMeta`;若写入失败,页面明确报错且按钮不可点击。 +3. 手动创建成功且本地写入成功后,后端数据库新增一条 `source_type = 'MANUAL_CAPTURE'` 的记录,且 `report_id` 与云图响应一致。 +4. 当本地不存在有效 payload 时,右侧按钮必须置灰且无法点击。 +5. 点击“一键复制同比报告”后,脚本只能修改顶层 `startTime` 和 `endTime`,其他字段与来源 payload 保持一致。 +6. 自动复制成功后,新报告的时间范围必须按自然年回退一年的规则生成;闰年日期按目标月最后一天处理。 +7. 自动复制成功后,本地缓存必须被新 payload 和新 `reportId` 覆盖;数据库新增一条 `source_type = 'AUTO_COPY'` 的记录,且 `source_report_id` 正确指向来源报告。 +8. 点击按钮进入 loading 后,用户无法重复触发创建;无论成功还是失败,流程结束后按钮状态必须恢复正确。 +9. 后端接口重复接收同一个 `report_id` 时,不得插入重复数据。 +10. 整个方案不得在代码或配置中硬编码用户 Cookie、Token 等敏感登录信息。 diff --git a/yuntu/yuntuReportFilling/server/.env.example b/yuntu/yuntuReportFilling/server/.env.example new file mode 100644 index 0000000..703da49 --- /dev/null +++ b/yuntu/yuntuReportFilling/server/.env.example @@ -0,0 +1,3 @@ +PORT=3000 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/yuntu_report +ALLOWED_ORIGIN=https://yuntu.oceanengine.com diff --git a/yuntu/yuntuReportFilling/server/README.md b/yuntu/yuntuReportFilling/server/README.md new file mode 100644 index 0000000..23683ad --- /dev/null +++ b/yuntu/yuntuReportFilling/server/README.md @@ -0,0 +1,18 @@ +# Yuntu Report Local Server + +## Setup + +1. Copy `.env.example` to `.env`. +2. Update `DATABASE_URL` to point at your local PostgreSQL instance. +3. Optionally adjust `ALLOWED_ORIGIN` if you need to call the server from another origin. +4. Install dependencies with `npm install`. +5. Start the server with `npm run dev` or `npm start`. + +## Endpoints + +- `GET /health` +- `POST /api/reports` + +## Default URL + +- `http://localhost:3000` diff --git a/yuntu/yuntuReportFilling/server/package-lock.json b/yuntu/yuntuReportFilling/server/package-lock.json new file mode 100644 index 0000000..12d7420 --- /dev/null +++ b/yuntu/yuntuReportFilling/server/package-lock.json @@ -0,0 +1,1014 @@ +{ + "name": "yuntu-report-filling-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yuntu-report-filling-server", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "pg": "^8.13.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/yuntu/yuntuReportFilling/server/package.json b/yuntu/yuntuReportFilling/server/package.json new file mode 100644 index 0000000..5c23558 --- /dev/null +++ b/yuntu/yuntuReportFilling/server/package.json @@ -0,0 +1,18 @@ +{ + "name": "yuntu-report-filling-server", + "version": "1.0.0", + "private": true, + "description": "Local backend for persisting Yuntu report creation records", + "main": "src/index.js", + "scripts": { + "dev": "node --watch src/index.js", + "start": "node src/index.js", + "test": "node --test" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.2", + "pg": "^8.13.1" + } +} diff --git a/yuntu/yuntuReportFilling/server/sql/schema.sql b/yuntu/yuntuReportFilling/server/sql/schema.sql new file mode 100644 index 0000000..faba221 --- /dev/null +++ b/yuntu/yuntuReportFilling/server/sql/schema.sql @@ -0,0 +1,55 @@ +DROP TABLE IF EXISTS public.yuntu_report_info; + +CREATE TABLE public.yuntu_report_info ( + id BIGSERIAL PRIMARY KEY, + report_id VARCHAR(64) NOT NULL, + source_type VARCHAR(32) NOT NULL, + source_report_id VARCHAR(64) NULL, + aadvid VARCHAR(32) NOT NULL, + name TEXT NOT NULL, + price JSONB NOT NULL DEFAULT '[]'::jsonb, + rules JSONB NOT NULL DEFAULT '[]'::jsonb, + analysis_dims JSONB NOT NULL DEFAULT '[]'::jsonb, + categories JSONB NOT NULL DEFAULT '[]'::jsonb, + transaction_channels JSONB NOT NULL DEFAULT '[]'::jsonb, + start_time DATE NOT NULL, + end_time DATE NOT NULL, + period_type VARCHAR(32) NULL, + user_name TEXT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_yuntu_report_info_report_id UNIQUE (report_id), + CONSTRAINT chk_yuntu_report_info_source_type + CHECK (source_type IN ('MANUAL_CAPTURE', 'AUTO_COPY')), + CONSTRAINT chk_yuntu_report_info_copy_source + CHECK ( + (source_type = 'MANUAL_CAPTURE' AND source_report_id IS NULL) + OR + (source_type = 'AUTO_COPY' AND source_report_id IS NOT NULL) + ) +); + +CREATE INDEX idx_yuntu_report_info_source_report_id + ON public.yuntu_report_info (source_report_id); + +CREATE INDEX idx_yuntu_report_info_created_at + ON public.yuntu_report_info (created_at DESC); + +CREATE INDEX idx_yuntu_report_info_aadvid_created_at + ON public.yuntu_report_info (aadvid, created_at DESC); + +CREATE OR REPLACE FUNCTION public.set_yuntu_report_info_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_yuntu_report_info_updated_at ON public.yuntu_report_info; + +CREATE TRIGGER trg_yuntu_report_info_updated_at +BEFORE UPDATE ON public.yuntu_report_info +FOR EACH ROW +EXECUTE FUNCTION public.set_yuntu_report_info_updated_at(); diff --git a/yuntu/yuntuReportFilling/server/src/db.js b/yuntu/yuntuReportFilling/server/src/db.js new file mode 100644 index 0000000..94ccb39 --- /dev/null +++ b/yuntu/yuntuReportFilling/server/src/db.js @@ -0,0 +1,15 @@ +const { Pool } = require('pg'); + +function createPool(env = process.env) { + if (!env.DATABASE_URL) { + throw new Error('DATABASE_URL is required'); + } + + return new Pool({ + connectionString: env.DATABASE_URL, + }); +} + +module.exports = { + createPool, +}; diff --git a/yuntu/yuntuReportFilling/server/src/index.js b/yuntu/yuntuReportFilling/server/src/index.js new file mode 100644 index 0000000..90c8bfc --- /dev/null +++ b/yuntu/yuntuReportFilling/server/src/index.js @@ -0,0 +1,36 @@ +const dotenv = require('dotenv'); + +const { createPool } = require('./db'); +const { createRepository } = require('./report-repository'); +const { createApp } = require('./server'); + +dotenv.config(); + +const port = Number.parseInt(process.env.PORT || '3000', 10); +const allowedOrigin = process.env.ALLOWED_ORIGIN || 'https://yuntu.oceanengine.com'; + +async function start() { + const pool = createPool(process.env); + const repository = createRepository(pool); + const app = createApp({ repository, allowedOrigin }); + + const server = app.listen(port, () => { + console.log(`Yuntu report server listening on http://localhost:${port}`); + }); + + const shutdown = async () => { + server.close(async () => { + await pool.end(); + process.exit(0); + }); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +start().catch((error) => { + console.error('Failed to start Yuntu report server'); + console.error(error); + process.exit(1); +}); diff --git a/yuntu/yuntuReportFilling/server/src/report-repository.js b/yuntu/yuntuReportFilling/server/src/report-repository.js new file mode 100644 index 0000000..ca5add8 --- /dev/null +++ b/yuntu/yuntuReportFilling/server/src/report-repository.js @@ -0,0 +1,86 @@ +const INSERT_REPORT_SQL = ` + INSERT INTO public.yuntu_report_info ( + report_id, + source_type, + source_report_id, + aadvid, + name, + price, + rules, + analysis_dims, + categories, + transaction_channels, + start_time, + end_time, + period_type, + user_name, + payload + ) + VALUES ( + $1, $2, $3, $4, $5, + $6::jsonb, $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb, + $11, $12, $13, $14, $15::jsonb + ) + ON CONFLICT (report_id) DO NOTHING + RETURNING id, report_id +`; + +const FIND_REPORT_SQL = ` + SELECT id, report_id + FROM public.yuntu_report_info + WHERE report_id = $1 +`; + +function createRepository(pool) { + return { + async save(record) { + const insertValues = [ + record.report_id, + record.source_type, + record.source_report_id, + record.aadvid, + record.name, + JSON.stringify(record.price), + JSON.stringify(record.rules), + JSON.stringify(record.analysis_dims), + JSON.stringify(record.categories), + JSON.stringify(record.transaction_channels), + record.start_time, + record.end_time, + record.period_type, + record.user_name, + JSON.stringify(record.payload), + ]; + + const insertResult = await pool.query(INSERT_REPORT_SQL, insertValues); + + if (insertResult.rows.length > 0) { + const row = insertResult.rows[0]; + return { + id: row.id, + reportId: row.report_id, + created: true, + }; + } + + const existingResult = await pool.query(FIND_REPORT_SQL, [record.report_id]); + const existingRow = existingResult.rows[0]; + + if (!existingRow) { + throw new Error(`report_id ${record.report_id} was not found after conflict`); + } + + return { + id: existingRow.id, + reportId: existingRow.report_id, + created: false, + }; + }, + }; +} + +module.exports = { + createRepository, + FIND_REPORT_SQL, + INSERT_REPORT_SQL, +}; diff --git a/yuntu/yuntuReportFilling/server/src/report-service.js b/yuntu/yuntuReportFilling/server/src/report-service.js new file mode 100644 index 0000000..40f9e44 --- /dev/null +++ b/yuntu/yuntuReportFilling/server/src/report-service.js @@ -0,0 +1,115 @@ +const ALLOWED_SOURCE_TYPES = new Set(['MANUAL_CAPTURE', 'AUTO_COPY']); +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +class ValidationError extends Error { + constructor(message) { + super(message); + this.name = 'ValidationError'; + this.code = 'VALIDATION_ERROR'; + } +} + +function ensureNonEmptyString(value, fieldName) { + if (typeof value !== 'string' || value.trim() === '') { + throw new ValidationError(`${fieldName} is required`); + } + + return value.trim(); +} + +function ensureOptionalString(value, fieldName) { + if (value == null || value === '') { + return null; + } + + return ensureNonEmptyString(value, fieldName); +} + +function ensureArray(value, fieldName) { + if (!Array.isArray(value)) { + throw new ValidationError(`${fieldName} must be an array`); + } + + return value; +} + +function ensureObject(value, fieldName) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ValidationError(`${fieldName} must be an object`); + } + + return value; +} + +function ensureDateString(value, fieldName) { + const normalized = ensureNonEmptyString(value, fieldName); + + if (!DATE_PATTERN.test(normalized)) { + throw new ValidationError(`${fieldName} must match YYYY-MM-DD`); + } + + return normalized; +} + +function validateAndNormalizeReportInput(input) { + const payload = ensureObject(input, 'body'); + const sourceType = ensureNonEmptyString(payload.sourceType, 'sourceType'); + + if (!ALLOWED_SOURCE_TYPES.has(sourceType)) { + throw new ValidationError('sourceType must be MANUAL_CAPTURE or AUTO_COPY'); + } + + const sourceReportId = ensureOptionalString(payload.sourceReportId, 'sourceReportId'); + + if (sourceType === 'MANUAL_CAPTURE' && sourceReportId !== null) { + throw new ValidationError('sourceReportId must be empty for MANUAL_CAPTURE'); + } + + if (sourceType === 'AUTO_COPY' && sourceReportId === null) { + throw new ValidationError('sourceReportId is required when sourceType is AUTO_COPY'); + } + + return { + reportId: ensureNonEmptyString(payload.reportId, 'reportId'), + sourceType, + sourceReportId, + aadvid: ensureNonEmptyString(payload.aadvid, 'aadvid'), + name: ensureNonEmptyString(payload.name, 'name'), + price: ensureArray(payload.price, 'price'), + rules: ensureArray(payload.rules, 'rules'), + analysisDims: ensureArray(payload.analysisDims, 'analysisDims'), + categories: ensureArray(payload.categories, 'categories'), + channels: ensureArray(payload.channels, 'channels'), + startTime: ensureDateString(payload.startTime, 'startTime'), + endTime: ensureDateString(payload.endTime, 'endTime'), + periodType: ensureOptionalString(payload.periodType, 'periodType'), + userName: ensureOptionalString(payload.userName, 'userName'), + payload: ensureObject(payload.payload, 'payload'), + }; +} + +function toDatabaseRecord(normalizedReport) { + return { + report_id: normalizedReport.reportId, + source_type: normalizedReport.sourceType, + source_report_id: normalizedReport.sourceReportId, + aadvid: normalizedReport.aadvid, + name: normalizedReport.name, + price: normalizedReport.price, + rules: normalizedReport.rules, + analysis_dims: normalizedReport.analysisDims, + categories: normalizedReport.categories, + transaction_channels: normalizedReport.channels, + start_time: normalizedReport.startTime, + end_time: normalizedReport.endTime, + period_type: normalizedReport.periodType, + user_name: normalizedReport.userName, + payload: normalizedReport.payload, + }; +} + +module.exports = { + ValidationError, + validateAndNormalizeReportInput, + toDatabaseRecord, +}; diff --git a/yuntu/yuntuReportFilling/server/src/server.js b/yuntu/yuntuReportFilling/server/src/server.js new file mode 100644 index 0000000..ff3bb7d --- /dev/null +++ b/yuntu/yuntuReportFilling/server/src/server.js @@ -0,0 +1,70 @@ +const cors = require('cors'); +const express = require('express'); + +const { + ValidationError, + toDatabaseRecord, + validateAndNormalizeReportInput, +} = require('./report-service'); + +function createApp({ repository, allowedOrigin }) { + const app = express(); + + app.use( + cors({ + origin: allowedOrigin, + }), + ); + app.use(express.json({ limit: '1mb' })); + + app.get('/health', (_request, response) => { + response.json({ + success: true, + data: { + status: 'ok', + }, + }); + }); + + app.post('/api/reports', async (request, response) => { + try { + const normalizedReport = validateAndNormalizeReportInput(request.body); + const record = toDatabaseRecord(normalizedReport); + const saved = await repository.save(record); + + response.status(saved.created ? 201 : 200).json({ + success: true, + data: { + id: saved.id, + reportId: saved.reportId, + created: saved.created, + }, + }); + } catch (error) { + if (error instanceof ValidationError) { + response.status(400).json({ + success: false, + error: { + code: error.code, + message: error.message, + }, + }); + return; + } + + response.status(500).json({ + success: false, + error: { + code: 'INTERNAL_ERROR', + message: error.message || 'Unexpected server error', + }, + }); + } + }); + + return app; +} + +module.exports = { + createApp, +}; diff --git a/yuntu/yuntuReportFilling/server/test/report-service.test.js b/yuntu/yuntuReportFilling/server/test/report-service.test.js new file mode 100644 index 0000000..d0db1d2 --- /dev/null +++ b/yuntu/yuntuReportFilling/server/test/report-service.test.js @@ -0,0 +1,95 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + validateAndNormalizeReportInput, + toDatabaseRecord, +} = require('../src/report-service'); + +function createManualPayload() { + return { + reportId: 'report-001', + sourceType: 'MANUAL_CAPTURE', + sourceReportId: null, + aadvid: '1648829117232140', + name: '测试报告', + price: ['1,100', '101,100000'], + rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }], + analysisDims: ['MARKETOVERVIEW'], + categories: [{ id: '20028', name: '奶粉类目' }], + channels: ['ALL'], + startTime: '2025-03-01', + endTime: '2026-02-28', + periodType: 'MONTH', + userName: 'tester@example.com', + payload: { + name: '测试报告', + startTime: '2025-03-01', + endTime: '2026-02-28', + }, + }; +} + +test('validateAndNormalizeReportInput accepts a valid manual report payload', () => { + const input = createManualPayload(); + + const result = validateAndNormalizeReportInput(input); + + assert.equal(result.reportId, 'report-001'); + assert.equal(result.sourceType, 'MANUAL_CAPTURE'); + assert.equal(result.sourceReportId, null); + assert.equal(result.aadvid, '1648829117232140'); + assert.equal(result.startTime, '2025-03-01'); + assert.equal(result.endTime, '2026-02-28'); +}); + +test('validateAndNormalizeReportInput rejects AUTO_COPY without sourceReportId', () => { + const input = { + ...createManualPayload(), + reportId: 'report-002', + sourceType: 'AUTO_COPY', + sourceReportId: '', + }; + + assert.throws( + () => validateAndNormalizeReportInput(input), + (error) => { + assert.equal(error.code, 'VALIDATION_ERROR'); + assert.match(error.message, /sourceReportId/i); + return true; + }, + ); +}); + +test('toDatabaseRecord maps API payload fields into database-ready values', () => { + const normalized = validateAndNormalizeReportInput({ + ...createManualPayload(), + reportId: 'report-003', + sourceType: 'AUTO_COPY', + sourceReportId: 'report-001', + }); + + const record = toDatabaseRecord(normalized); + + assert.deepEqual(record, { + report_id: 'report-003', + source_type: 'AUTO_COPY', + source_report_id: 'report-001', + aadvid: '1648829117232140', + name: '测试报告', + price: ['1,100', '101,100000'], + rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }], + analysis_dims: ['MARKETOVERVIEW'], + categories: [{ id: '20028', name: '奶粉类目' }], + transaction_channels: ['ALL'], + start_time: '2025-03-01', + end_time: '2026-02-28', + period_type: 'MONTH', + user_name: 'tester@example.com', + payload: { + name: '测试报告', + startTime: '2025-03-01', + endTime: '2026-02-28', + }, + }); +}); diff --git a/yuntu/yuntuReportFilling/server/test/server.test.js b/yuntu/yuntuReportFilling/server/test/server.test.js new file mode 100644 index 0000000..187ae94 --- /dev/null +++ b/yuntu/yuntuReportFilling/server/test/server.test.js @@ -0,0 +1,146 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { createApp } = require('../src/server'); + +function createManualPayload() { + return { + reportId: 'report-001', + sourceType: 'MANUAL_CAPTURE', + sourceReportId: null, + aadvid: '1648829117232140', + name: '测试报告', + price: ['1,100', '101,100000'], + rules: [{ keywords: ['奶粉'], op: 'INCLUDE' }], + analysisDims: ['MARKETOVERVIEW'], + categories: [{ id: '20028', name: '奶粉类目' }], + channels: ['ALL'], + startTime: '2025-03-01', + endTime: '2026-02-28', + periodType: 'MONTH', + userName: 'tester@example.com', + payload: { + name: '测试报告', + startTime: '2025-03-01', + endTime: '2026-02-28', + }, + }; +} + +async function withServer(repository, callback) { + const app = createApp({ + repository, + allowedOrigin: 'https://yuntu.oceanengine.com', + }); + + const server = await new Promise((resolve) => { + const instance = app.listen(0, '127.0.0.1', () => resolve(instance)); + }); + + const address = server.address(); + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + await callback(baseUrl); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + } +} + +test('GET /health returns ok status', async () => { + await withServer( + { + async save() { + throw new Error('save should not be called'); + }, + }, + async (baseUrl) => { + const response = await fetch(`${baseUrl}/health`); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.deepEqual(body, { + success: true, + data: { + status: 'ok', + }, + }); + }, + ); +}); + +test('POST /api/reports returns 400 for invalid auto copy payload', async () => { + await withServer( + { + async save() { + throw new Error('save should not be called'); + }, + }, + async (baseUrl) => { + const response = await fetch(`${baseUrl}/api/reports`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + ...createManualPayload(), + sourceType: 'AUTO_COPY', + sourceReportId: '', + }), + }); + const body = await response.json(); + + assert.equal(response.status, 400); + assert.equal(body.success, false); + assert.equal(body.error.code, 'VALIDATION_ERROR'); + }, + ); +}); + +test('POST /api/reports persists a valid payload and returns created response', async () => { + let savedRecord = null; + + await withServer( + { + async save(record) { + savedRecord = record; + return { + id: 12, + reportId: record.report_id, + created: true, + }; + }, + }, + async (baseUrl) => { + const response = await fetch(`${baseUrl}/api/reports`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(createManualPayload()), + }); + const body = await response.json(); + + assert.equal(response.status, 201); + assert.deepEqual(body, { + success: true, + data: { + id: 12, + reportId: 'report-001', + created: true, + }, + }); + assert.equal(savedRecord.report_id, 'report-001'); + assert.equal(savedRecord.source_type, 'MANUAL_CAPTURE'); + }, + ); +}); diff --git a/yuntu/yuntuReportFilling/yuntuReportFilling.test.js b/yuntu/yuntuReportFilling/yuntuReportFilling.test.js new file mode 100644 index 0000000..865d90b --- /dev/null +++ b/yuntu/yuntuReportFilling/yuntuReportFilling.test.js @@ -0,0 +1,134 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const api = require("./yuntuReportFilling.user.js"); + +test("isTargetCreateReportRequest matches the segmented market create endpoint", () => { + assert.equal( + api.isTargetCreateReportRequest( + "https://yuntu.oceanengine.com/product_node/v2/api/segmentedMarket/createSegmentedMarket?aadvid=1648829117232140", + "POST", + ), + true, + ); + + assert.equal( + api.isTargetCreateReportRequest( + "https://yuntu.oceanengine.com/product_node/v2/api/industry/insightBrandStats?aadvid=1648829117232140", + "POST", + ), + false, + ); +}); + +test("isSupportedPageUrl covers both creation and detail pages", () => { + assert.equal( + api.isSupportedPageUrl( + "https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation?aadvid=1648829117232140", + ), + true, + ); + + assert.equal( + api.isSupportedPageUrl( + "https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/marketOverview?aadvid=1710507483282439&reportId=441518", + ), + true, + ); + + assert.equal( + api.isSupportedPageUrl( + "https://yuntu.oceanengine.com/yuntu_brand/ecom/product/otherPage?aadvid=1710507483282439", + ), + false, + ); +}); + +test("getLocalApiBaseCandidates prefers localhost and falls back to 127.0.0.1", () => { + assert.deepEqual(api.getLocalApiBaseCandidates(), [ + "http://localhost:3000", + "http://127.0.0.1:3000", + ]); +}); + +test("extractReportId returns response.data.reportId when present", () => { + assert.equal( + api.extractReportId({ + data: { + reportId: "987654321", + }, + }), + "987654321", + ); + + assert.equal(api.extractReportId({ data: {} }), null); +}); + +test("shiftDateBackOneYear keeps month/day and clamps leap day", () => { + assert.equal(api.shiftDateBackOneYear("2025-03-01"), "2024-03-01"); + assert.equal(api.shiftDateBackOneYear("2024-02-29"), "2023-02-28"); +}); + +test("buildAutoCopyPayload deep clones payload, shifts top-level dates, and appends the year range to name", () => { + const original = { + name: "测试(近一年)", + startTime: "2025-03-01", + endTime: "2026-02-28", + nested: { + startTime: "SHOULD_NOT_CHANGE", + }, + }; + + const copied = api.buildAutoCopyPayload(original); + + assert.deepEqual(copied, { + name: "测试(近一年)2024-2025", + startTime: "2024-03-01", + endTime: "2025-02-28", + nested: { + startTime: "SHOULD_NOT_CHANGE", + }, + }); + assert.notEqual(copied, original); + assert.notEqual(copied.nested, original.nested); +}); + +test("buildPersistRequest maps manual capture payload into backend request format", () => { + const payload = { + name: "测试", + price: ["1,100", "101,100000"], + rules: [{ keywords: ["奶粉"], op: "INCLUDE" }], + analysisDims: ["MARKETOVERVIEW"], + categories: [{ id: "20028" }], + channels: ["ALL"], + startTime: "2025-03-01", + endTime: "2026-02-28", + periodType: "MONTH", + userName: "tester@example.com", + }; + const request = api.buildPersistRequest({ + payload, + aadvid: "1648829117232140", + reportId: "report-001", + sourceType: "MANUAL_CAPTURE", + sourceReportId: null, + }); + + assert.deepEqual(request, { + reportId: "report-001", + sourceType: "MANUAL_CAPTURE", + sourceReportId: null, + aadvid: "1648829117232140", + name: "测试", + price: ["1,100", "101,100000"], + rules: [{ keywords: ["奶粉"], op: "INCLUDE" }], + analysisDims: ["MARKETOVERVIEW"], + categories: [{ id: "20028" }], + channels: ["ALL"], + startTime: "2025-03-01", + endTime: "2026-02-28", + periodType: "MONTH", + userName: "tester@example.com", + payload, + }); +}); diff --git a/yuntu/yuntuReportFilling/yuntuReportFilling.user.js b/yuntu/yuntuReportFilling/yuntuReportFilling.user.js new file mode 100644 index 0000000..666952e --- /dev/null +++ b/yuntu/yuntuReportFilling/yuntuReportFilling.user.js @@ -0,0 +1,716 @@ +// ==UserScript== +// @name 云图报告同比复制助手 +// @namespace https://yuntu.oceanengine.com/ +// @version 0.1.0 +// @description 记录最近一次成功创建的云图报告,并一键复制同比报告 +// @author wangxi +// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation* +// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/* +// @grant GM_getValue +// @grant GM_setValue +// @grant GM_xmlhttpRequest +// @grant GM_addStyle +// @grant unsafeWindow +// @connect localhost +// @connect 127.0.0.1 +// ==/UserScript== + +(function bootstrap(root, factory) { + const api = factory(root); + if (typeof module === "object" && module.exports) { + module.exports = api; + return; + } + api.init(); +})(typeof globalThis !== "undefined" ? globalThis : this, function factory(root) { + const TARGET_PATH = "/product_node/v2/api/segmentedMarket/createSegmentedMarket"; + const LOCAL_API_BASES = ["http://localhost:3000", "http://127.0.0.1:3000"]; + const SUPPORTED_PAGE_PATTERNS = [ + /^\/yuntu_brand\/ecom\/product\/segmentedMarketcreation(?:\/.*)?$/, + /^\/yuntu_brand\/ecom\/product\/segmentedMarketDetail\/.*$/, + ]; + const STORAGE_KEYS = { + payload: "lastReportPayload", + meta: "lastReportMeta", + }; + const EVENT_NAME = "yuntu-report-filling:capture"; + const BUTTON_ID = "yuntu-report-filling-action"; + const STYLE_ID = "yuntu-report-filling-style"; + const INJECT_FLAG = "__yuntuReportFillingHooked__"; + + const runtimeState = { + isSubmitting: false, + suppressedRequestBody: null, + clearSuppressionTimer: null, + }; + + function log(message, extra) { + if (extra === undefined) { + console.log("[YuntuReportFilling]", message); + return; + } + console.log("[YuntuReportFilling]", message, extra); + } + + function isTargetCreateReportRequest(url, method) { + if (String(method || "").toUpperCase() !== "POST") { + return false; + } + + try { + const parsed = new URL(url, root.location && root.location.href ? root.location.href : "https://yuntu.oceanengine.com"); + return parsed.pathname === TARGET_PATH && parsed.searchParams.has("aadvid"); + } catch (_error) { + return false; + } + } + + function isSupportedPageUrl(url) { + try { + const parsed = new URL( + url, + root.location && root.location.href + ? root.location.href + : "https://yuntu.oceanengine.com", + ); + return SUPPORTED_PAGE_PATTERNS.some((pattern) => + pattern.test(parsed.pathname), + ); + } catch (_error) { + return false; + } + } + + function getLocalApiBaseCandidates() { + return [...LOCAL_API_BASES]; + } + + function extractReportId(responseJson) { + if (!responseJson || typeof responseJson !== "object") { + return null; + } + + const reportId = responseJson.data && responseJson.data.reportId; + if (reportId === null || reportId === undefined || reportId === "") { + return null; + } + + return String(reportId); + } + + function shiftDateBackOneYear(dateString) { + const match = String(dateString).match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + throw new Error(`Invalid date string: ${dateString}`); + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const targetYear = year - 1; + const lastDay = new Date(Date.UTC(targetYear, month, 0)).getUTCDate(); + const safeDay = Math.min(day, lastDay); + + return `${targetYear}-${String(month).padStart(2, "0")}-${String(safeDay).padStart(2, "0")}`; + } + + function deepCloneJson(value) { + return JSON.parse(JSON.stringify(value)); + } + + function extractYear(dateString) { + return String(dateString).slice(0, 4); + } + + function buildAutoCopyName(originalName, startTime, endTime) { + const baseName = originalName || ""; + return `${baseName}${extractYear(startTime)}-${extractYear(endTime)}`; + } + + function buildAutoCopyPayload(payload) { + const cloned = deepCloneJson(payload); + cloned.startTime = shiftDateBackOneYear(payload.startTime); + cloned.endTime = shiftDateBackOneYear(payload.endTime); + cloned.name = buildAutoCopyName(payload.name, cloned.startTime, cloned.endTime); + return cloned; + } + + function buildPersistRequest({ + payload, + aadvid, + reportId, + sourceType, + sourceReportId, + }) { + return { + reportId: String(reportId), + sourceType, + sourceReportId: sourceReportId ? String(sourceReportId) : null, + aadvid: String(aadvid), + name: payload.name || "", + price: Array.isArray(payload.price) ? payload.price : [], + rules: Array.isArray(payload.rules) ? payload.rules : [], + analysisDims: Array.isArray(payload.analysisDims) ? payload.analysisDims : [], + categories: Array.isArray(payload.categories) ? payload.categories : [], + channels: Array.isArray(payload.channels) ? payload.channels : [], + startTime: payload.startTime || "", + endTime: payload.endTime || "", + periodType: payload.periodType || null, + userName: payload.userName || null, + payload, + }; + } + + function parseJson(text) { + return JSON.parse(text); + } + + function getAadvidFromUrl(url) { + try { + const parsed = new URL(url, root.location.href); + return parsed.searchParams.get("aadvid"); + } catch (_error) { + return null; + } + } + + function getCreateRequestUrl(meta) { + if (meta && meta.requestUrl) { + return meta.requestUrl; + } + + const aadvid = (meta && meta.aadvid) || getAadvidFromUrl(root.location.href); + if (!aadvid) { + throw new Error("未获取到 aadvid"); + } + + return `https://yuntu.oceanengine.com${TARGET_PATH}?aadvid=${encodeURIComponent(aadvid)}`; + } + + function gmGetValueSafe(key, fallbackValue) { + if (typeof GM_getValue !== "function") { + return fallbackValue; + } + return GM_getValue(key, fallbackValue); + } + + function gmSetValueSafe(key, value) { + if (typeof GM_setValue !== "function") { + throw new Error("GM_setValue 不可用"); + } + GM_setValue(key, value); + } + + function readStoredPayload() { + const rawValue = gmGetValueSafe(STORAGE_KEYS.payload, ""); + if (!rawValue) { + return null; + } + + return parseJson(rawValue); + } + + function readStoredMeta() { + const rawValue = gmGetValueSafe(STORAGE_KEYS.meta, ""); + if (!rawValue) { + return null; + } + + return parseJson(rawValue); + } + + function writeStoredData(payload, meta) { + gmSetValueSafe(STORAGE_KEYS.payload, JSON.stringify(payload)); + gmSetValueSafe(STORAGE_KEYS.meta, JSON.stringify(meta)); + } + + function clearSuppression() { + runtimeState.suppressedRequestBody = null; + if (runtimeState.clearSuppressionTimer) { + root.clearTimeout(runtimeState.clearSuppressionTimer); + runtimeState.clearSuppressionTimer = null; + } + } + + function suppressNextCapture(requestBodyText) { + runtimeState.suppressedRequestBody = requestBodyText; + if (runtimeState.clearSuppressionTimer) { + root.clearTimeout(runtimeState.clearSuppressionTimer); + } + runtimeState.clearSuppressionTimer = root.setTimeout(() => { + clearSuppression(); + }, 10000); + } + + function showToast(message, type) { + const toast = root.document.createElement("div"); + toast.className = `yuntu-report-filling-toast is-${type || "info"}`; + toast.textContent = message; + root.document.body.appendChild(toast); + + root.requestAnimationFrame(() => { + toast.classList.add("is-visible"); + }); + + root.setTimeout(() => { + toast.classList.remove("is-visible"); + root.setTimeout(() => { + toast.remove(); + }, 240); + }, 2200); + } + + function updateButtonState() { + const button = root.document.getElementById(BUTTON_ID); + if (!button) { + return; + } + + const hasPayload = Boolean(readStoredPayload()); + button.disabled = runtimeState.isSubmitting || !hasPayload; + button.textContent = runtimeState.isSubmitting + ? "创建中..." + : "一键复制同比报告"; + button.dataset.enabled = String(hasPayload); + } + + async function persistToLocalServer(payload) { + const requestBody = JSON.stringify(payload); + const bases = getLocalApiBaseCandidates(); + let lastError = null; + + for (const base of bases) { + try { + if (typeof GM_xmlhttpRequest !== "function") { + const response = await fetch(`${base}/api/reports`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: requestBody, + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error((data.error && data.error.message) || "本地服务请求失败"); + } + return data; + } + + const data = await new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: "POST", + url: `${base}/api/reports`, + headers: { + "Content-Type": "application/json", + }, + data: requestBody, + onload(response) { + try { + const json = parseJson(response.responseText); + if (response.status < 200 || response.status >= 300 || !json.success) { + reject(new Error((json.error && json.error.message) || "本地服务请求失败")); + return; + } + resolve(json); + } catch (error) { + reject(error); + } + }, + onerror() { + reject(new Error(`无法连接本地服务: ${base}`)); + }, + }); + }); + + return data; + } catch (error) { + lastError = error; + } + } + + throw new Error( + `本地服务未启动,请先在 server 目录执行 npm start。${lastError ? ` 原始错误: ${lastError.message}` : ""}`, + ); + } + + async function createReportThroughPage(url, payload) { + const pageFetch = + typeof unsafeWindow !== "undefined" && unsafeWindow.fetch + ? unsafeWindow.fetch.bind(unsafeWindow) + : root.fetch.bind(root); + + const requestBodyText = JSON.stringify(payload); + suppressNextCapture(requestBodyText); + + const response = await pageFetch(url, { + method: "POST", + headers: { + accept: "application/json, text/plain, */*", + "content-type": "application/json", + }, + credentials: "include", + body: requestBodyText, + }); + + const data = await response.json(); + if (!response.ok) { + clearSuppression(); + throw new Error((data && data.message) || `创建报告失败: ${response.status}`); + } + + return { + responseJson: data, + requestBodyText, + }; + } + + async function handleButtonClick() { + if (runtimeState.isSubmitting) { + return; + } + + const payload = readStoredPayload(); + const meta = readStoredMeta(); + + if (!payload || !meta) { + updateButtonState(); + showToast("请先手动成功创建一次报告", "error"); + return; + } + + runtimeState.isSubmitting = true; + updateButtonState(); + + try { + const nextPayload = buildAutoCopyPayload(payload); + const requestUrl = getCreateRequestUrl(meta); + const sourceReportId = meta.reportId || null; + const { responseJson } = await createReportThroughPage(requestUrl, nextPayload); + const reportId = extractReportId(responseJson); + + if (!reportId) { + throw new Error("创建成功,但未获取到 reportId"); + } + + const nextMeta = { + reportId, + aadvid: meta.aadvid || getAadvidFromUrl(requestUrl), + sourceType: "AUTO_COPY", + capturedAt: new Date().toISOString(), + requestUrl, + }; + + writeStoredData(nextPayload, nextMeta); + await persistToLocalServer( + buildPersistRequest({ + payload: nextPayload, + aadvid: nextMeta.aadvid, + reportId, + sourceType: "AUTO_COPY", + sourceReportId, + }), + ); + + showToast(`同比报告创建成功:${reportId}`, "success"); + } catch (error) { + log("自动创建同比报告失败", error); + showToast(error.message || "同比报告创建失败", "error"); + } finally { + runtimeState.isSubmitting = false; + updateButtonState(); + } + } + + async function handleCapturedRequest(detail) { + if (!detail || !detail.requestBody || !detail.responseText) { + return; + } + + if (runtimeState.suppressedRequestBody && runtimeState.suppressedRequestBody === detail.requestBody) { + clearSuppression(); + return; + } + + try { + const payload = parseJson(detail.requestBody); + const responseJson = parseJson(detail.responseText); + const reportId = extractReportId(responseJson); + if (!reportId) { + return; + } + + const aadvid = getAadvidFromUrl(detail.url) || getAadvidFromUrl(root.location.href); + if (!aadvid) { + throw new Error("未从请求中识别到 aadvid"); + } + + const meta = { + reportId, + aadvid, + sourceType: "MANUAL_CAPTURE", + capturedAt: new Date().toISOString(), + requestUrl: detail.url, + }; + + writeStoredData(payload, meta); + updateButtonState(); + + await persistToLocalServer( + buildPersistRequest({ + payload, + aadvid, + reportId, + sourceType: "MANUAL_CAPTURE", + sourceReportId: null, + }), + ); + + showToast(`已记录报告配置:${reportId}`, "success"); + } catch (error) { + log("记录手动创建报告失败", error); + showToast(error.message || "记录报告配置失败", "error"); + updateButtonState(); + } + } + + function ensureStyles() { + if (root.document.getElementById(STYLE_ID)) { + return; + } + + const css = ` + #${BUTTON_ID} { + position: fixed; + right: 24px; + top: 50%; + transform: translateY(-50%); + z-index: 99999; + width: 148px; + min-height: 48px; + padding: 10px 14px; + border: none; + border-radius: 14px; + background: linear-gradient(135deg, #0f766e, #115e59); + color: #ffffff; + font-size: 14px; + font-weight: 600; + line-height: 1.4; + box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28); + cursor: pointer; + transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease; + } + + #${BUTTON_ID}:hover:not(:disabled) { + transform: translateY(-50%) translateX(-2px); + box-shadow: 0 14px 32px rgba(15, 118, 110, 0.34); + } + + #${BUTTON_ID}:disabled { + cursor: not-allowed; + opacity: 0.52; + box-shadow: none; + } + + .yuntu-report-filling-toast { + position: fixed; + top: 24px; + right: 24px; + z-index: 100000; + max-width: 360px; + padding: 12px 16px; + border-radius: 12px; + color: #ffffff; + font-size: 13px; + line-height: 1.5; + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22); + opacity: 0; + transform: translateY(-8px); + transition: opacity 180ms ease, transform 180ms ease; + } + + .yuntu-report-filling-toast.is-visible { + opacity: 1; + transform: translateY(0); + } + + .yuntu-report-filling-toast.is-success { + background: #166534; + } + + .yuntu-report-filling-toast.is-error { + background: #b91c1c; + } + + .yuntu-report-filling-toast.is-info { + background: #1d4ed8; + } + `; + + if (typeof GM_addStyle === "function") { + GM_addStyle(css); + return; + } + + const style = root.document.createElement("style"); + style.id = STYLE_ID; + style.textContent = css; + root.document.head.appendChild(style); + } + + function ensureButton() { + if (root.document.getElementById(BUTTON_ID)) { + return; + } + + const button = root.document.createElement("button"); + button.id = BUTTON_ID; + button.type = "button"; + button.textContent = "一键复制同比报告"; + button.addEventListener("click", handleButtonClick); + root.document.body.appendChild(button); + updateButtonState(); + } + + function injectCaptureHook() { + if (!root.document || root.document.documentElement.getAttribute(INJECT_FLAG) === "true") { + return; + } + + const script = root.document.createElement("script"); + script.textContent = ` + (() => { + if (window.${INJECT_FLAG}) { + return; + } + window.${INJECT_FLAG} = true; + + const eventName = ${JSON.stringify(EVENT_NAME)}; + const targetPath = ${JSON.stringify(TARGET_PATH)}; + + const isTarget = (url, method) => { + try { + const parsed = new URL(url, window.location.href); + return String(method || "").toUpperCase() === "POST" + && parsed.pathname === targetPath + && parsed.searchParams.has("aadvid"); + } catch (_error) { + return false; + } + }; + + const serializeBody = (body) => { + if (typeof body === "string") { + return body; + } + if (!body) { + return ""; + } + if (body instanceof URLSearchParams) { + return body.toString(); + } + if (body instanceof FormData) { + return ""; + } + try { + return JSON.stringify(body); + } catch (_error) { + return ""; + } + }; + + const dispatchCapture = (detail) => { + window.dispatchEvent(new CustomEvent(eventName, { detail })); + }; + + const originalFetch = window.fetch; + window.fetch = async function patchedFetch(input, init) { + const requestUrl = input instanceof Request ? input.url : String(input); + const method = (init && init.method) || (input instanceof Request ? input.method : "GET"); + const requestBody = init && "body" in init ? serializeBody(init.body) : ""; + const response = await originalFetch.apply(this, arguments); + + if (isTarget(requestUrl, method)) { + try { + const cloned = response.clone(); + const responseText = await cloned.text(); + dispatchCapture({ + url: requestUrl, + method, + requestBody, + responseText, + status: response.status, + }); + } catch (_error) {} + } + + return response; + }; + + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function patchedOpen(method, url) { + this.__yuntuReportMethod = method; + this.__yuntuReportUrl = url; + return originalOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function patchedSend(body) { + this.__yuntuReportBody = serializeBody(body); + this.addEventListener("loadend", () => { + if (!isTarget(this.__yuntuReportUrl, this.__yuntuReportMethod)) { + return; + } + dispatchCapture({ + url: this.__yuntuReportUrl, + method: this.__yuntuReportMethod, + requestBody: this.__yuntuReportBody || "", + responseText: typeof this.responseText === "string" ? this.responseText : "", + status: this.status, + }); + }, { once: true }); + return originalSend.apply(this, arguments); + }; + })(); + `; + + root.document.documentElement.appendChild(script); + script.remove(); + root.document.documentElement.setAttribute(INJECT_FLAG, "true"); + } + + function attachCaptureListener() { + root.addEventListener(EVENT_NAME, (event) => { + handleCapturedRequest(event.detail); + }); + } + + function init() { + if (!isSupportedPageUrl(root.location && root.location.href ? root.location.href : "")) { + return; + } + + if (!root.document || !root.document.body) { + root.addEventListener("DOMContentLoaded", init, { once: true }); + return; + } + + ensureStyles(); + ensureButton(); + attachCaptureListener(); + injectCaptureHook(); + updateButtonState(); + } + + return { + buildAutoCopyPayload, + buildAutoCopyName, + buildPersistRequest, + extractReportId, + getLocalApiBaseCandidates, + init, + isSupportedPageUrl, + isTargetCreateReportRequest, + shiftDateBackOneYear, + }; +});