feat: add yuntu report filling automation
This commit is contained in:
parent
c0a531fd1d
commit
c8b5d27078
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
**/node_modules/
|
||||
**/.DS_Store
|
||||
**/.env
|
||||
|
||||
69
docs/superpowers/plans/2026-03-31-yuntu-report-server.md
Normal file
69
docs/superpowers/plans/2026-03-31-yuntu-report-server.md
Normal file
@ -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**
|
||||
405
yuntu/yuntuReportFilling/PRD.md
Normal file
405
yuntu/yuntuReportFilling/PRD.md
Normal file
@ -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 等敏感登录信息。
|
||||
3
yuntu/yuntuReportFilling/server/.env.example
Normal file
3
yuntu/yuntuReportFilling/server/.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
PORT=3000
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/yuntu_report
|
||||
ALLOWED_ORIGIN=https://yuntu.oceanengine.com
|
||||
18
yuntu/yuntuReportFilling/server/README.md
Normal file
18
yuntu/yuntuReportFilling/server/README.md
Normal file
@ -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`
|
||||
1014
yuntu/yuntuReportFilling/server/package-lock.json
generated
Normal file
1014
yuntu/yuntuReportFilling/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
yuntu/yuntuReportFilling/server/package.json
Normal file
18
yuntu/yuntuReportFilling/server/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
55
yuntu/yuntuReportFilling/server/sql/schema.sql
Normal file
55
yuntu/yuntuReportFilling/server/sql/schema.sql
Normal file
@ -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();
|
||||
15
yuntu/yuntuReportFilling/server/src/db.js
Normal file
15
yuntu/yuntuReportFilling/server/src/db.js
Normal file
@ -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,
|
||||
};
|
||||
36
yuntu/yuntuReportFilling/server/src/index.js
Normal file
36
yuntu/yuntuReportFilling/server/src/index.js
Normal file
@ -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);
|
||||
});
|
||||
86
yuntu/yuntuReportFilling/server/src/report-repository.js
Normal file
86
yuntu/yuntuReportFilling/server/src/report-repository.js
Normal file
@ -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,
|
||||
};
|
||||
115
yuntu/yuntuReportFilling/server/src/report-service.js
Normal file
115
yuntu/yuntuReportFilling/server/src/report-service.js
Normal file
@ -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,
|
||||
};
|
||||
70
yuntu/yuntuReportFilling/server/src/server.js
Normal file
70
yuntu/yuntuReportFilling/server/src/server.js
Normal file
@ -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,
|
||||
};
|
||||
95
yuntu/yuntuReportFilling/server/test/report-service.test.js
Normal file
95
yuntu/yuntuReportFilling/server/test/report-service.test.js
Normal file
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
146
yuntu/yuntuReportFilling/server/test/server.test.js
Normal file
146
yuntu/yuntuReportFilling/server/test/server.test.js
Normal file
@ -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');
|
||||
},
|
||||
);
|
||||
});
|
||||
134
yuntu/yuntuReportFilling/yuntuReportFilling.test.js
Normal file
134
yuntu/yuntuReportFilling/yuntuReportFilling.test.js
Normal file
@ -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,
|
||||
});
|
||||
});
|
||||
716
yuntu/yuntuReportFilling/yuntuReportFilling.user.js
Normal file
716
yuntu/yuntuReportFilling/yuntuReportFilling.user.js
Normal file
@ -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,
|
||||
};
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user