Initial public PDD edition without market, Claw, skills or platform switching
This commit is contained in:
commit
ec8949cfbf
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
.vs/
|
||||
.vscode/
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.user
|
||||
|
||||
# .NET build outputs
|
||||
/dotnet/**/bin/
|
||||
/dotnet/**/obj/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Local runtime / cache artifacts
|
||||
backend/.cubestore/
|
||||
frontend/dist-backups/
|
||||
backend/.env.bak*
|
||||
.gateway-sync.env
|
||||
/backend/database/system-db.config.json
|
||||
/backups/
|
||||
/tmp/
|
||||
/tmp-ai-buyer-show.png
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Local analysis outputs
|
||||
tmall-board-speed-check.json
|
||||
/tmall-*-readstore.png
|
||||
|
||||
# Job runner local artifacts
|
||||
/job-runner/buildcheck*/
|
||||
/job-runner/tmp_*
|
||||
/.tmp/
|
||||
|
||||
# Keep the job-runner solution tracked
|
||||
!/job-runner/src/Shujuku2.JobRunner.sln
|
||||
|
||||
# Private local configuration and generated files
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
**/uploads/
|
||||
.workbuddy/
|
||||
**/node_modules
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 lunanting
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
46
README.md
Normal file
46
README.md
Normal file
@ -0,0 +1,46 @@
|
||||
# 拼多多自动化运营平台
|
||||
|
||||
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
|
||||
|
||||
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、店铺体检、客服质检、上架测算、AI 买家秀和客户端管理等现有模块。部分菜单仍沿用原项目的占位页面。
|
||||
|
||||
已删除市场数据整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
|
||||
|
||||
## 本地启动
|
||||
|
||||
需要 Node.js 22、MySQL 系统库,以及业务实际使用的数据库和外部服务。仓库不包含生产环境配置、密钥、业务数据或原仓库历史。
|
||||
|
||||
```bash
|
||||
npm --prefix backend ci
|
||||
npm --prefix frontend ci
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
填写 `backend/.env` 中的连接信息与密钥,分别在两个终端启动:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端默认访问 http://localhost:5173,API 默认代理到 http://localhost:4001,可用 `VITE_API_PROXY_TARGET` 覆盖。首次启动按照安装与设置向导初始化系统。
|
||||
|
||||
## 验证与构建
|
||||
|
||||
```bash
|
||||
npm --prefix backend run check
|
||||
npm --prefix frontend run check
|
||||
```
|
||||
|
||||
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
|
||||
|
||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限和专属设置;业务数据及历史聊天记录不作数据库删表操作。迁移仅作用于实际配置给此实例的系统数据库。
|
||||
|
||||
## 参与修改
|
||||
|
||||
所有用户均可 Fork 仓库、创建分支并提交 Pull Request。直接推送本仓库需要仓库所有者授予协作者写权限。提交时请说明修改和验证结果,勿提交 `.env`、密码、令牌、数据库备份或真实业务数据。
|
||||
262
backend/.env.example
Normal file
262
backend/.env.example
Normal file
@ -0,0 +1,262 @@
|
||||
# ============================================
|
||||
# 云数据库 (业务数据 - Cube.js 数据源)
|
||||
# ============================================
|
||||
# 首次部署时可留空,先启动系统并在“系统设置 -> 业务数据库”中配置
|
||||
# 如需让业务数据库优先读取 .env,请先填写 CLOUD_DB_*,再到“系统设置 -> 业务数据库”开启“优先使用 .env 业务数据库配置”
|
||||
CLOUD_DB_TYPE=mssql
|
||||
CLOUD_DB_HOST=
|
||||
CLOUD_DB_PORT=1433
|
||||
CLOUD_DB_NAME=JSReportReplica
|
||||
CLOUD_DB_USER=
|
||||
CLOUD_DB_PASSWORD=
|
||||
CLOUD_DB_SSL=false
|
||||
# JSReportReplica 迁移约定:同步报表数据读 rpt,自建/临时维护表写 app。
|
||||
# CLOUD_DB_REPORT_REWRITE=auto 表示仅当当前业务库是 JSReportReplica 时自动把旧 dbo/JSDataWarehouse/JSDataMiddlePlatform 查询映射到 rpt。
|
||||
CLOUD_DB_REPORT_DATABASE=JSReportReplica
|
||||
CLOUD_DB_REPORT_SCHEMA=rpt
|
||||
CLOUD_DB_APP_SCHEMA=app
|
||||
CLOUD_DB_REPORT_REWRITE=auto
|
||||
|
||||
# Product analysis source and JSReportReplica destination schema.
|
||||
JIAN_SUN_DB_HOST=
|
||||
JIAN_SUN_DB_PORT=1433
|
||||
JIAN_SUN_DB_NAME=JianSun
|
||||
JIAN_SUN_DB_USER=
|
||||
JIAN_SUN_DB_PASSWORD=
|
||||
|
||||
# 拼多多开放平台草稿发布(服务端专用,禁止暴露给前端)
|
||||
PDD_OPEN_PLATFORM_GATEWAY_URL=https://gw-api.pinduoduo.com/api/router
|
||||
PDD_OPEN_PLATFORM_TIMEOUT_MS=60000
|
||||
# 默认从 JianSun.dbo.sys_PddBill_Service 按店铺匹配授权。
|
||||
# 数据库不可用时可临时配置 JSON 数组;不要将真实值提交到 Git。
|
||||
PDD_OPEN_PLATFORM_SHOPS_JSON=
|
||||
# 表内未维护应用凭证时,可由部署环境提供统一服务商应用凭证。
|
||||
PDD_OPEN_PLATFORM_CLIENT_ID=
|
||||
PDD_OPEN_PLATFORM_CLIENT_SECRET=
|
||||
PRODUCT_ANALYSIS_REPLICA_SCHEMA=app
|
||||
PRODUCT_ANALYSIS_SYNC_BATCH_SIZE=5000
|
||||
PRODUCT_ANALYSIS_SYNC_ENABLED=false
|
||||
PRODUCT_ANALYSIS_SYNC_CRON=* * * * *
|
||||
PRODUCT_ANALYSIS_SYNC_TIMEZONE=Asia/Shanghai
|
||||
PRODUCT_ANALYSIS_SYNC_STARTUP_DELAY_MS=10000
|
||||
|
||||
# ============================================
|
||||
# 系统数据库 (系统配置 + 用户数据 + 汇总结果 - 读写)
|
||||
# ============================================
|
||||
# 可以先留空。留空时系统会进入 /install 安装模式,由页面引导创建系统数据库和首个管理员账号
|
||||
LOCAL_DB_TYPE=mysql
|
||||
LOCAL_DB_HOST=localhost
|
||||
LOCAL_DB_PORT=3306
|
||||
LOCAL_DB_NAME=shujuku2_system
|
||||
LOCAL_DB_USER=root
|
||||
LOCAL_DB_PASSWORD=
|
||||
# 防止慢查询长期占满系统库连接。页面查询超过该时间会中断,单位毫秒
|
||||
LOCAL_DB_QUERY_TIMEOUT_MS=60000
|
||||
# 生产环境由部署脚本强制使用 /www/wwwroot/shujuku3/shared/image-search-secrets.json
|
||||
# 本地留空时使用 ~/.config/shujuku3/secrets.json
|
||||
SHUJUKU3_SECRET_STORE_PATH=
|
||||
# 生产环境默认开启;公司图搜或 1688 图搜凭据缺失时拒绝启动
|
||||
REQUIRE_PERSISTED_IMAGE_SEARCH_SECRETS=false
|
||||
# 系统库连接池建议保持小而稳,避免单个应用实例吃满 MySQL 连接
|
||||
LOCAL_DB_CONNECTION_LIMIT=6
|
||||
LOCAL_DB_QUEUE_LIMIT=50
|
||||
|
||||
# ============================================
|
||||
# Cube.js 配置
|
||||
# ============================================
|
||||
# 首次安装时如留空,系统会自动生成并写入 .env
|
||||
CUBEJS_SECRET=your-cubejs-secret-key
|
||||
CUBEJS_API_SECRET=your-cubejs-api-secret
|
||||
CUBEJS_DEV_MODE=true
|
||||
|
||||
# ============================================
|
||||
# OAuth / 应用服务
|
||||
# ============================================
|
||||
# 说明:
|
||||
# 1. 钉钉 AppKey/AppSecret、Google Client ID/Secret 已改为保存在本地数据库 auth_configs 表中
|
||||
# 2. 这里不再需要配置 Casdoor
|
||||
# 3. OAuth 回调基础地址请在系统设置 -> 登录配置 中填写
|
||||
# 4. 默认管理员账号来自 init.sql: admin / admin123
|
||||
|
||||
# ============================================
|
||||
# 企业微信配置
|
||||
# ============================================
|
||||
WEIXIN_CORP_ID=your-corp-id
|
||||
WEIXIN_AGENT_ID=your-agent-id
|
||||
WEIXIN_SECRET=your-secret
|
||||
|
||||
# ============================================
|
||||
# 钉钉配置
|
||||
# ============================================
|
||||
# 仅用于预警通知机器人,不是钉钉登录配置
|
||||
DINGTALK_TOKEN=your-robot-token
|
||||
|
||||
# ============================================
|
||||
# 邮件配置
|
||||
# ============================================
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email
|
||||
SMTP_PASSWORD=your-password
|
||||
|
||||
# ============================================
|
||||
# S3 配置
|
||||
# ============================================
|
||||
S3_PROVIDER=aliyun
|
||||
S3_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
S3_BUCKET=your-bucket
|
||||
S3_ACCESS_KEY_ID=your-access-key
|
||||
S3_SECRET_ACCESS_KEY=your-secret-key
|
||||
|
||||
# ============================================
|
||||
# Redis 缓存配置
|
||||
# ============================================
|
||||
# 首次部署时可留空,后续可在“系统设置 -> Redis 缓存”中配置默认/常州两套缓存库。
|
||||
# 运行时优先使用系统库配置:识别到 192.168.150.234 服务器优先常州缓存库,其他服务器优先默认缓存库;
|
||||
# 两套系统库 Redis 都不可用时,才使用下面的 .env 配置兜底。
|
||||
REDIS_HOST=
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
# 默认 stored 表示自动选择系统库 Redis;也可临时指定 default/changzhou/env。
|
||||
REDIS_RUNTIME_PROFILE=stored
|
||||
CHANGZHOU_REDIS_SERVER_IPS=192.168.150.234
|
||||
# 企划功能页 GET 响应缓存:默认 23 小时,命中后可避免重复扫企划库/业务库。
|
||||
PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS=82800
|
||||
# 企划 MAY 商品数据实体快照:每天 08:30 生成,页面优先读取 planning_may_product_snapshot_di。
|
||||
PLANNING_MAY_PRODUCT_SNAPSHOT_ENABLED=true
|
||||
PLANNING_MAY_PRODUCT_SNAPSHOT_CRON=30 8 * * *
|
||||
PLANNING_MAY_PRODUCT_SNAPSHOT_CHUNK_SIZE=200
|
||||
|
||||
# ============================================
|
||||
# 企划数据库双接口配置
|
||||
# ============================================
|
||||
# 默认 auto:识别到 PLANNING_DB_LOCAL_SERVER_IPS 中的本机 IP 时使用系统设置里的企划数据库;
|
||||
# 非本机时优先使用下面的公网企划数据库配置,避免不同服务器继续连本机内网接口。
|
||||
PLANNING_DB_RUNTIME_PROFILE=auto
|
||||
PLANNING_DB_LOCAL_SERVER_IPS=192.168.150.234
|
||||
PLANNING_PUBLIC_DB_HOST=
|
||||
PLANNING_PUBLIC_DB_PORT=60023
|
||||
PLANNING_PUBLIC_DB_NAME=planning_database
|
||||
PLANNING_PUBLIC_DB_USER=
|
||||
PLANNING_PUBLIC_DB_PASSWORD=
|
||||
PLANNING_PUBLIC_DB_SSL=false
|
||||
PLANNING_PUBLIC_DB_CHARSET=utf8mb4
|
||||
|
||||
# ============================================
|
||||
# 市场洞察专用镜像库
|
||||
# ============================================
|
||||
# 配置后,仅市场洞察/市场复刻相关页面读取该库;未配置时仍回退到企划数据库。
|
||||
MARKET_REPLICA_DB_HOST=
|
||||
MARKET_REPLICA_DB_PORT=3306
|
||||
MARKET_REPLICA_DB_NAME=yuce_market_local
|
||||
MARKET_REPLICA_DB_USER=
|
||||
MARKET_REPLICA_DB_PASSWORD=
|
||||
MARKET_REPLICA_DB_SSL=false
|
||||
MARKET_REPLICA_DB_CHARSET=utf8mb4
|
||||
|
||||
# 新 yuce_market -> 本机镜像库同步脚本配置。真实账号建议放到 backend/runtime/market-sync/.env。
|
||||
MARKET_SYNC_SOURCE_HOST=
|
||||
MARKET_SYNC_SOURCE_PORT=3306
|
||||
MARKET_SYNC_SOURCE_DATABASE=yuce_market
|
||||
MARKET_SYNC_SOURCE_USER=
|
||||
MARKET_SYNC_SOURCE_PASSWORD=
|
||||
MARKET_SYNC_TARGET_HOST=127.0.0.1
|
||||
MARKET_SYNC_TARGET_PORT=3306
|
||||
MARKET_SYNC_TARGET_DATABASE=yuce_market_local
|
||||
MARKET_SYNC_TARGET_USER=
|
||||
MARKET_SYNC_TARGET_PASSWORD=
|
||||
|
||||
# ============================================
|
||||
# 淘宝详情 Plus 对外查询 API
|
||||
# ============================================
|
||||
# 多个 key 用英文逗号分隔。请求时建议使用 Header: x-api-key: <key>
|
||||
TAOBAO_DETAIL_PLUS_API_KEYS=
|
||||
|
||||
# ============================================
|
||||
# 工单通知系统连接配置
|
||||
# ============================================
|
||||
# 系统设置中保存的配置优先;下面仅作为首次部署或系统库不可用时的兜底。
|
||||
WORK_ORDER_BASE_URL=
|
||||
WORK_ORDER_SIGN_KEY=
|
||||
WORK_ORDER_APP_KEY=
|
||||
WORK_ORDER_APP_SECRET=
|
||||
WORK_ORDER_TOKEN_PATH=/api/auth/accessToken
|
||||
WORK_ORDER_CREATE_PATH=/api/workorder/create
|
||||
|
||||
# ============================================
|
||||
|
||||
# ============================================
|
||||
# 公司商品编码对外接口(仅后端保存,禁止下发到前端)
|
||||
# ============================================
|
||||
PRODUCT_CODE_PUBLIC_API_KEY=
|
||||
PRODUCT_CODE_PUBLIC_API_BASE_URL=https://yyd.jiansuntuzhi.cn
|
||||
PRODUCT_CODE_PUBLIC_API_TIMEOUT_MS=30000
|
||||
|
||||
# ============================================
|
||||
# 服务器配置
|
||||
# ============================================
|
||||
# ============================================
|
||||
# 1688 ???????
|
||||
# ============================================
|
||||
# Linux ?????????????? /www/wwwroot/script_1688
|
||||
ALI1688_BRIDGE_ROOT=/www/wwwroot/script_1688
|
||||
ALI1688_BRIDGE_ENV_PATH=/www/wwwroot/script_1688/.env
|
||||
ALI1688_BRIDGE_ACCOUNTS_PATH=/www/wwwroot/script_1688/.local/auth/1688_accounts.json
|
||||
ALI1688_BRIDGE_PYTHON=/www/wwwroot/script_1688/.venv/bin/python
|
||||
ALI1688_CONTROL_CENTER_STORE_PATH=./database/ali1688_control_center.json
|
||||
|
||||
# nanting-pro 测款商品编码接口。API Key 只能配置在后端,不得下发前端。
|
||||
PRODUCT_CODE_API_BASE_URL=https://yyd.jiansuntuzhi.cn
|
||||
PRODUCT_CODE_API_KEY=
|
||||
PRODUCT_CODE_API_TIMEOUT_MS=300000
|
||||
# 编码请求异常中断后,超过该时间可使用同一 1688 商品 ID 协调重试。
|
||||
PRODUCT_CODE_CLAIM_TTL_SECONDS=360
|
||||
|
||||
# ============================================
|
||||
# 1688 以图搜款 Key 到期通知
|
||||
# ============================================
|
||||
# 仅作为数据库尚未配置时的迁移/故障兜底。系统设置保存后优先读取数据库。
|
||||
# 通知中心会为本项目单独分配 project code / secret,不得复用其他项目密钥。
|
||||
YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center
|
||||
YYD_NOTIFICATION_PROJECT_CODE=
|
||||
YYD_NOTIFICATION_PROJECT_SECRET=
|
||||
# 兼容通知中心开通结果使用的变量名:
|
||||
# YYD_NOTIFICATION_CENTER_PROJECT_CODE=
|
||||
# YYD_NOTIFICATION_CENTER_PROJECT_SECRET=
|
||||
YYD_NOTIFICATION_TIMEOUT_MS=15000
|
||||
ALIBABA_1688_IMAGE_SEARCH_EXPIRY_RECIPIENT_ALIAS=苏适
|
||||
ALIBABA_1688_IMAGE_SEARCH_SETTINGS_URL=https://pddg.jiansuntuzhi.cn/settings?tab=alibaba1688ImageSearch
|
||||
ALIBABA_1688_IMAGE_SEARCH_EXPIRY_CRON=0 9 * * *
|
||||
DISABLE_ALIBABA_1688_IMAGE_SEARCH_EXPIRY_NOTIFICATION=false
|
||||
|
||||
# External image generation service. One image request may run for up to
|
||||
# 15 minutes; main-image batches send at most five requests concurrently.
|
||||
SELECTION_IMAGE_GEN_BASE_URL=https://ai.yydd.ai
|
||||
SELECTION_IMAGE_GEN_API_KEY=
|
||||
SELECTION_IMAGE_GEN_TIMEOUT_MS=900000
|
||||
|
||||
PORT=4000
|
||||
NODE_ENV=development
|
||||
|
||||
# ============================================
|
||||
# 火山向量定时任务
|
||||
# ============================================
|
||||
ARK_API_KEY=
|
||||
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
EMBEDDING_MODEL=doubao-embedding-vision-250615
|
||||
TAOBAO_PRODUCT_EMBEDDING_ENABLED=true
|
||||
TAOBAO_PRODUCT_EMBEDDING_CRON=0 2 * * *
|
||||
TAOBAO_PRODUCT_EMBEDDING_BATCH_SIZE=100
|
||||
TAOBAO_PRODUCT_EMBEDDING_CONCURRENCY=2
|
||||
TAOBAO_PRODUCT_EMBEDDING_REQUEST_TIMEOUT_MS=120000
|
||||
TAOBAO_PRODUCT_EMBEDDING_LOCK_WAIT_SECONDS=1
|
||||
|
||||
# 榜单数据分析快照。不同项目共用原始企划库时,应配置独立表名与任务锁。
|
||||
PLANNING_RANK_BOARD_ITEM_SNAPSHOT_TABLE=planning_rank_board_item_snapshot_di
|
||||
PLANNING_RANK_BOARD_DIMENSION_SNAPSHOT_TABLE=planning_rank_board_dimension_snapshot_di
|
||||
PLANNING_RANK_BOARD_AGGREGATE_SNAPSHOT_TABLE=planning_rank_board_aggregate_snapshot_di
|
||||
PLANNING_RANK_BOARD_SNAPSHOT_LOG_TABLE=planning_rank_board_snapshot_sync_log
|
||||
PLANNING_RANK_BOARD_SNAPSHOT_LOCK_KEY=planning_rank_board_snapshot_daily
|
||||
PLANNING_RANK_BOARD_SNAPSHOT_CRON=0 0 * * *
|
||||
PLANNING_RANK_BOARD_SNAPSHOT_TIMEZONE=Asia/Shanghai
|
||||
26
backend/.gitignore
vendored
Normal file
26
backend/.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
# 生成项
|
||||
dist/
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
.DS_Store
|
||||
|
||||
# 日志记录
|
||||
*.log
|
||||
|
||||
# 运行缓存
|
||||
src/runtime-cache/
|
||||
runtime/
|
||||
database.sqlite
|
||||
tmp_*
|
||||
|
||||
# 回溯脚本
|
||||
scripts/backfill_*
|
||||
!scripts/backfill_taobao_appearance_codes.js
|
||||
|
||||
# 环境变量文件
|
||||
.env
|
||||
3
backend/README.md
Normal file
3
backend/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# 后端
|
||||
|
||||
安装、启动、配置与贡献说明见[项目文档](../README.md)。
|
||||
36
backend/cubejs/datasources.js
Normal file
36
backend/cubejs/datasources.js
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Cube.js 数据源配置
|
||||
*
|
||||
* 使用云数据库作为业务数据源
|
||||
* 配置文件中的变量来自 .env
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
driver: process.env.CLOUD_DB_TYPE || 'mysql',
|
||||
|
||||
// 云数据库连接配置 (业务数据)
|
||||
database: process.env.CLOUD_DB_NAME,
|
||||
host: process.env.CLOUD_DB_HOST,
|
||||
port: parseInt(process.env.CLOUD_DB_PORT || '3306'),
|
||||
user: process.env.CLOUD_DB_USER,
|
||||
password: process.env.CLOUD_DB_PASSWORD,
|
||||
|
||||
// SSL 配置(云数据库必需)
|
||||
ssl: process.env.CLOUD_DB_SSL === 'true',
|
||||
sslOptions: {
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
|
||||
// 连接池优化 (云数据库通常有限制)
|
||||
maxPoolSize: 10,
|
||||
idleTimeout: 30000,
|
||||
|
||||
// 预聚合配置
|
||||
preAggregations: {
|
||||
// 可以在此配置预聚合刷新策略
|
||||
refreshKeyRefreshInterval: '5 minute',
|
||||
},
|
||||
|
||||
// 查询超时
|
||||
queryTimeout: 300,
|
||||
};
|
||||
117
backend/cubejs/schema/Orders.js
Normal file
117
backend/cubejs/schema/Orders.js
Normal file
@ -0,0 +1,117 @@
|
||||
// Cube.js 数据模型 - 订单示例
|
||||
// 请根据实际数据库表结构修改
|
||||
|
||||
cube(`Orders`, {
|
||||
sql: `SELECT * FROM orders WHERE \${this.enabled}`,
|
||||
|
||||
// measures(指标)
|
||||
measures: {
|
||||
count: {
|
||||
sql: `id`,
|
||||
type: `count`,
|
||||
},
|
||||
|
||||
totalAmount: {
|
||||
sql: `amount`,
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
},
|
||||
|
||||
avgAmount: {
|
||||
sql: `amount`,
|
||||
type: `avg`,
|
||||
format: `currency`,
|
||||
},
|
||||
|
||||
cancelRate: {
|
||||
sql: `CASE WHEN COUNT(*) > 0 THEN SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) / COUNT(*) ELSE 0 END`,
|
||||
type: `number`,
|
||||
format: `percent`,
|
||||
},
|
||||
},
|
||||
|
||||
// dimensions(维度)
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `id`,
|
||||
type: `number`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
orderDate: {
|
||||
sql: `order_date`,
|
||||
type: `time`,
|
||||
},
|
||||
|
||||
status: {
|
||||
sql: `status`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
category: {
|
||||
sql: `category`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
region: {
|
||||
sql: `region`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
channel: {
|
||||
sql: `channel`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
customerId: {
|
||||
sql: `customer_id`,
|
||||
type: `number`,
|
||||
},
|
||||
|
||||
amount: {
|
||||
sql: `amount`,
|
||||
type: `number`,
|
||||
format: `currency`,
|
||||
},
|
||||
},
|
||||
|
||||
// 预聚合配置
|
||||
preAggregations: {
|
||||
// 按月聚合(每小时刷新)
|
||||
ordersByMonth: {
|
||||
measures: [Orders.count, Orders.totalAmount, Orders.avgAmount],
|
||||
dimensions: [Orders.category, Orders.region],
|
||||
timeDimension: Orders.orderDate,
|
||||
granularity: `month`,
|
||||
refreshKey: {
|
||||
every: `1 hour`,
|
||||
timezone: 'Asia/Shanghai',
|
||||
},
|
||||
},
|
||||
|
||||
// 按日聚合(高频查询,5分钟刷新)
|
||||
ordersByDay: {
|
||||
measures: [Orders.count, Orders.totalAmount, Orders.cancelRate],
|
||||
dimensions: [Orders.status, Orders.channel],
|
||||
timeDimension: Orders.orderDate,
|
||||
granularity: `day`,
|
||||
refreshKey: {
|
||||
every: `5 minutes`,
|
||||
},
|
||||
},
|
||||
|
||||
// 按地区聚合(每小时刷新)
|
||||
ordersByRegion: {
|
||||
measures: [Orders.count, Orders.totalAmount],
|
||||
dimensions: [Orders.region, Orders.category],
|
||||
timeDimension: Orders.orderDate,
|
||||
granularity: `hour`,
|
||||
refreshKey: {
|
||||
every: `1 hour`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 数据安全:默认禁用
|
||||
enabled: true,
|
||||
});
|
||||
79
backend/cubejs/schema/Products.js
Normal file
79
backend/cubejs/schema/Products.js
Normal file
@ -0,0 +1,79 @@
|
||||
// Cube.js 数据模型 - 产品示例
|
||||
|
||||
cube(`Products`, {
|
||||
sql: `SELECT * FROM products WHERE \${this.enabled}`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
sql: `id`,
|
||||
type: `count`,
|
||||
},
|
||||
|
||||
totalStock: {
|
||||
sql: `stock`,
|
||||
type: `sum`,
|
||||
},
|
||||
|
||||
avgPrice: {
|
||||
sql: `price`,
|
||||
type: `avg`,
|
||||
format: `currency`,
|
||||
},
|
||||
|
||||
totalSales: {
|
||||
sql: `sales_count`,
|
||||
type: `sum`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `id`,
|
||||
type: `number`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
name: {
|
||||
sql: `name`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
category: {
|
||||
sql: `category`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
price: {
|
||||
sql: `price`,
|
||||
type: `number`,
|
||||
format: `currency`,
|
||||
},
|
||||
|
||||
stock: {
|
||||
sql: `stock`,
|
||||
type: `number`,
|
||||
},
|
||||
|
||||
status: {
|
||||
sql: `status`,
|
||||
type: `string`,
|
||||
},
|
||||
|
||||
createdAt: {
|
||||
sql: `created_at`,
|
||||
type: `time`,
|
||||
},
|
||||
},
|
||||
|
||||
preAggregations: {
|
||||
productsByCategory: {
|
||||
measures: [Products.count, Products.totalStock, Products.totalSales],
|
||||
dimensions: [Products.category],
|
||||
timeDimension: Products.createdAt,
|
||||
granularity: `day`,
|
||||
refreshKey: {
|
||||
every: `1 hour`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
20
backend/database/add_pdd_goods_qualities_signal_index.sql
Normal file
20
backend/database/add_pdd_goods_qualities_signal_index.sql
Normal file
@ -0,0 +1,20 @@
|
||||
IF OBJECT_ID(N'dbo.pdd_GoodsQualities', N'U') IS NULL
|
||||
BEGIN
|
||||
THROW 50001, 'dbo.pdd_GoodsQualities does not exist', 1;
|
||||
END;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE object_id = OBJECT_ID(N'dbo.pdd_GoodsQualities')
|
||||
AND name = N'IX_pdd_GoodsQualities_goodsId_createTime_id'
|
||||
)
|
||||
BEGIN
|
||||
CREATE NONCLUSTERED INDEX IX_pdd_GoodsQualities_goodsId_createTime_id
|
||||
ON dbo.pdd_GoodsQualities (goodsId, createTime DESC, id DESC)
|
||||
INCLUDE (
|
||||
qltyRfndOrdrCntRto1m,
|
||||
rankQltyRfndOrdrCntRto1mStage,
|
||||
rankQltyRfndOrdrCntRto1mStplStage
|
||||
);
|
||||
END;
|
||||
@ -0,0 +1,15 @@
|
||||
USE [JianSun];
|
||||
GO
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys.indexes
|
||||
WHERE object_id = OBJECT_ID(N'dbo.pdd_ServiceProblemOrders')
|
||||
AND name = N'IX_pdd_ServiceProblemOrders_goodsId_createTime'
|
||||
)
|
||||
BEGIN
|
||||
CREATE NONCLUSTERED INDEX IX_pdd_ServiceProblemOrders_goodsId_createTime
|
||||
ON dbo.pdd_ServiceProblemOrders (goodsId, createTime)
|
||||
INCLUDE (orderSn, isNeg, title, seekHelpTime);
|
||||
END;
|
||||
GO
|
||||
20
backend/database/create_desktop_client_presence.sql
Normal file
20
backend/database/create_desktop_client_presence.sql
Normal file
@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS desktop_client_presence (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'Presence record ID',
|
||||
user_id BIGINT NOT NULL COMMENT 'Authenticated user ID',
|
||||
client_id CHAR(36) NOT NULL COMMENT 'Persistent desktop installation ID',
|
||||
client_name VARCHAR(100) NOT NULL DEFAULT 'PDD Operations Automation' COMMENT 'Desktop application name',
|
||||
client_version VARCHAR(32) NULL COMMENT 'Desktop application version',
|
||||
platform VARCHAR(32) NULL COMMENT 'Operating system platform',
|
||||
platform_version VARCHAR(128) NULL COMMENT 'Operating system version',
|
||||
device_name VARCHAR(255) NULL COMMENT 'Device hostname',
|
||||
ip_address VARCHAR(64) NULL COMMENT 'Latest client IP address',
|
||||
user_agent VARCHAR(500) NULL COMMENT 'Latest HTTP user agent',
|
||||
online_since_at DATETIME(3) NOT NULL COMMENT 'Current online session start time',
|
||||
last_seen_at DATETIME(3) NOT NULL COMMENT 'Latest heartbeat time',
|
||||
offline_at DATETIME(3) NULL COMMENT 'Explicit offline time',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Created time',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'Updated time',
|
||||
UNIQUE KEY uk_desktop_presence_user_client (user_id, client_id),
|
||||
INDEX idx_desktop_presence_last_seen (last_seen_at),
|
||||
INDEX idx_desktop_presence_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Desktop client online presence';
|
||||
419
backend/database/init.sql
Normal file
419
backend/database/init.sql
Normal file
@ -0,0 +1,419 @@
|
||||
-- =====================================================
|
||||
-- 数据库初始化脚本
|
||||
-- 数据库: shujuku2_system
|
||||
-- =====================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 创建数据库
|
||||
CREATE DATABASE IF NOT EXISTS shujuku2_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE shujuku2_system;
|
||||
|
||||
-- =====================================================
|
||||
-- 1. 用户表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS users;
|
||||
CREATE TABLE users (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
|
||||
username VARCHAR(50) NOT NULL COMMENT '用户名',
|
||||
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希',
|
||||
name VARCHAR(50) NOT NULL COMMENT '姓名',
|
||||
email VARCHAR(100) NOT NULL COMMENT '邮箱',
|
||||
department_id BIGINT COMMENT '部门ID',
|
||||
role ENUM('admin', 'analyst', 'user', 'guest') DEFAULT 'user' COMMENT '角色',
|
||||
status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态',
|
||||
auth_source VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT '最近登录来源',
|
||||
avatar_url VARCHAR(500) COMMENT '头像地址',
|
||||
mobile VARCHAR(30) COMMENT '手机号',
|
||||
job_title VARCHAR(100) COMMENT '职位',
|
||||
dingtalk_user_id VARCHAR(128) COMMENT '钉钉用户ID',
|
||||
dingtalk_union_id VARCHAR(128) COMMENT '钉钉UnionID',
|
||||
google_sub VARCHAR(128) COMMENT 'Google账号唯一标识',
|
||||
department_names TEXT COMMENT '外部部门信息',
|
||||
dingtalk_department_ids TEXT COMMENT '钉钉部门ID列表(JSON)',
|
||||
last_login_at DATETIME COMMENT '最后登录时间',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_username (username),
|
||||
UNIQUE KEY uk_email (email),
|
||||
INDEX idx_department (department_id),
|
||||
INDEX idx_role (role),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
|
||||
-- 插入默认管理员用户 (密码: admin123)
|
||||
INSERT INTO users (username, password_hash, name, email, role, status) VALUES
|
||||
('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', '系统管理员', 'admin@company.com', 'admin', 'active');
|
||||
|
||||
-- =====================================================
|
||||
-- 2. 部门表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS departments;
|
||||
CREATE TABLE departments (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '部门ID',
|
||||
parent_id BIGINT DEFAULT 0 COMMENT '父部门ID',
|
||||
name VARCHAR(100) NOT NULL COMMENT '部门名称',
|
||||
code VARCHAR(50) NOT NULL COMMENT '部门编码',
|
||||
sort INT DEFAULT 0 COMMENT '排序',
|
||||
status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态',
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT '来源',
|
||||
external_id VARCHAR(64) COMMENT '外部部门ID',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX idx_parent (parent_id),
|
||||
INDEX idx_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门表';
|
||||
|
||||
-- 插入示例部门
|
||||
INSERT INTO departments (name, code, sort) VALUES
|
||||
('总公司', 'ROOT', 1),
|
||||
('技术部', 'TECH', 2),
|
||||
('市场部', 'SALES', 3),
|
||||
('财务部', 'FINANCE', 4),
|
||||
('运营部', 'OPS', 5);
|
||||
|
||||
-- =====================================================
|
||||
-- 3. 权限表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS permissions;
|
||||
CREATE TABLE permissions (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '权限ID',
|
||||
page_key VARCHAR(50) NOT NULL COMMENT '页面标识',
|
||||
page_name VARCHAR(100) NOT NULL COMMENT '页面名称',
|
||||
role VARCHAR(20) NOT NULL COMMENT '角色',
|
||||
allowed BOOLEAN DEFAULT TRUE COMMENT '是否允许',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
UNIQUE KEY uk_page_role (page_key, role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表';
|
||||
|
||||
-- 插入默认权限配置
|
||||
INSERT INTO permissions (page_key, page_name, role, allowed) VALUES
|
||||
('dashboard', '仪表盘', 'admin', TRUE),
|
||||
('dashboard', '仪表盘', 'analyst', TRUE),
|
||||
('dashboard', '仪表盘', 'user', TRUE),
|
||||
('dashboard', '仪表盘', 'guest', TRUE),
|
||||
('analysis_summary', '监控', 'admin', TRUE),
|
||||
('analysis_summary', '监控', 'analyst', TRUE),
|
||||
('analysis_summary', '监控', 'user', TRUE),
|
||||
('analysis_summary', '监控', 'guest', TRUE),
|
||||
('analysis_customer_service', '客服', 'admin', TRUE),
|
||||
('analysis_customer_service', '客服', 'analyst', TRUE),
|
||||
('analysis_customer_service', '客服', 'user', TRUE),
|
||||
('analysis_customer_service', '客服', 'guest', TRUE),
|
||||
('analysis_product_design', '开发', 'admin', TRUE),
|
||||
('analysis_product_design', '开发', 'analyst', TRUE),
|
||||
('analysis_product_design', '开发', 'user', TRUE),
|
||||
('analysis_product_design', '开发', 'guest', TRUE),
|
||||
('analysis_logistics', '快递', 'admin', TRUE),
|
||||
('analysis_logistics', '快递', 'analyst', TRUE),
|
||||
('analysis_logistics', '快递', 'user', TRUE),
|
||||
('analysis_logistics', '快递', 'guest', TRUE),
|
||||
('analysis_warehouse', '仓库', 'admin', TRUE),
|
||||
('analysis_warehouse', '仓库', 'analyst', TRUE),
|
||||
('analysis_warehouse', '仓库', 'user', TRUE),
|
||||
('analysis_warehouse', '仓库', 'guest', TRUE),
|
||||
('analysis_supplier', '供应', 'admin', TRUE),
|
||||
('analysis_supplier', '供应', 'analyst', TRUE),
|
||||
('analysis_supplier', '供应', 'user', TRUE),
|
||||
('analysis_supplier', '供应', 'guest', TRUE),
|
||||
('analysis_procurement', '采购', 'admin', TRUE),
|
||||
('analysis_procurement', '采购', 'analyst', TRUE),
|
||||
('analysis_procurement', '采购', 'user', TRUE),
|
||||
('analysis_procurement', '采购', 'guest', TRUE),
|
||||
('analysis_region', '地区', 'admin', TRUE),
|
||||
('analysis_region', '地区', 'analyst', TRUE),
|
||||
('analysis_region', '地区', 'user', TRUE),
|
||||
('analysis_region', '地区', 'guest', TRUE),
|
||||
('reports', '报表管理', 'admin', TRUE),
|
||||
('reports', '报表管理', 'analyst', TRUE),
|
||||
('reports', '报表管理', 'user', FALSE),
|
||||
('reports', '报表管理', 'guest', FALSE),
|
||||
('export', '数据导出', 'admin', TRUE),
|
||||
('export', '数据导出', 'analyst', TRUE),
|
||||
('export', '数据导出', 'user', FALSE),
|
||||
('export', '数据导出', 'guest', FALSE),
|
||||
('alerts', '预警配置', 'admin', TRUE),
|
||||
('alerts', '预警配置', 'analyst', FALSE),
|
||||
('alerts', '预警配置', 'user', FALSE),
|
||||
('alerts', '预警配置', 'guest', FALSE),
|
||||
('users', '用户管理', 'admin', TRUE),
|
||||
('users', '用户管理', 'analyst', FALSE),
|
||||
('users', '用户管理', 'user', FALSE),
|
||||
('users', '用户管理', 'guest', FALSE),
|
||||
('settings', '系统设置', 'admin', TRUE),
|
||||
('settings', '系统设置', 'analyst', FALSE),
|
||||
('settings', '系统设置', 'user', FALSE),
|
||||
('settings', '系统设置', 'guest', FALSE);
|
||||
|
||||
-- =====================================================
|
||||
-- 4. 系统配置表
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS system_config (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
|
||||
config_key VARCHAR(100) NOT NULL COMMENT '配置键',
|
||||
config_value TEXT COMMENT '配置值',
|
||||
config_type VARCHAR(20) DEFAULT 'string' COMMENT '配置类型',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_config_key (config_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
|
||||
|
||||
-- 插入默认配置
|
||||
INSERT IGNORE INTO system_config (config_key, config_value, config_type) VALUES
|
||||
('site_name', '拼多多自动化运营平台', 'string'),
|
||||
('site_description', '数据分析平台', 'string'),
|
||||
('logo', '', 'string'),
|
||||
('favicon', '', 'string'),
|
||||
('copyright', '© 2024 公司名称. All rights reserved.', 'string'),
|
||||
('enable_alert', 'true', 'boolean'),
|
||||
('enable_export', 'true', 'boolean'),
|
||||
('enable_sso', 'true', 'boolean'),
|
||||
('default_theme', 'light', 'string'),
|
||||
('setup_completed', 'false', 'boolean'),
|
||||
('setup_completed_at', '', 'string');
|
||||
|
||||
-- 专用服务密钥与普通配置隔离,升级时不得删除。
|
||||
CREATE TABLE IF NOT EXISTS system_secrets (
|
||||
secret_key VARCHAR(120) PRIMARY KEY COMMENT '密钥用途',
|
||||
secret_value TEXT NOT NULL COMMENT '仅服务端读取的密钥值',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统持久密钥';
|
||||
|
||||
-- =====================================================
|
||||
-- 5. 部门功能页面权限表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS department_page_permissions;
|
||||
CREATE TABLE department_page_permissions (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '部门页面权限ID',
|
||||
department_id BIGINT NOT NULL COMMENT '部门ID',
|
||||
page_key VARCHAR(100) NOT NULL COMMENT '页面标识',
|
||||
page_name VARCHAR(100) NOT NULL COMMENT '页面名称',
|
||||
allowed BOOLEAN DEFAULT TRUE COMMENT '是否允许访问',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_department_page (department_id, page_key),
|
||||
INDEX idx_department_id (department_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门功能页面权限表';
|
||||
|
||||
-- =====================================================
|
||||
-- 6. 认证配置表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS auth_configs;
|
||||
CREATE TABLE auth_configs (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
|
||||
auth_base_url VARCHAR(255) COMMENT '认证回调基础地址',
|
||||
dingtalk_enabled BOOLEAN DEFAULT FALSE COMMENT '是否启用钉钉登录',
|
||||
dingtalk_app_key VARCHAR(255) COMMENT '钉钉AppKey',
|
||||
dingtalk_app_secret VARCHAR(255) COMMENT '钉钉AppSecret',
|
||||
dingtalk_sync_root_dept_id BIGINT DEFAULT 1 COMMENT '钉钉同步根部门ID',
|
||||
google_enabled BOOLEAN DEFAULT FALSE COMMENT '是否启用Google登录',
|
||||
google_client_id VARCHAR(255) COMMENT 'Google Client ID',
|
||||
google_client_secret VARCHAR(255) COMMENT 'Google Client Secret',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='认证配置表';
|
||||
|
||||
-- =====================================================
|
||||
-- 6.1 Redis 配置表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS redis_configs;
|
||||
CREATE TABLE redis_configs (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
|
||||
profile_key VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT 'Redis配置标识:default/changzhou',
|
||||
profile_name VARCHAR(100) COMMENT 'Redis配置名称',
|
||||
host VARCHAR(255) COMMENT 'Redis主机地址',
|
||||
port INT NOT NULL DEFAULT 6379 COMMENT 'Redis端口',
|
||||
password VARCHAR(255) COMMENT 'Redis密码',
|
||||
db INT NOT NULL DEFAULT 0 COMMENT 'Redis数据库编号',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
is_default BOOLEAN DEFAULT TRUE COMMENT '是否默认',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_redis_configs_default (is_default),
|
||||
INDEX idx_redis_configs_profile_key (profile_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Redis配置表';
|
||||
|
||||
-- =====================================================
|
||||
-- 7. 预警规则表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS alert_rules;
|
||||
CREATE TABLE alert_rules (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '规则ID',
|
||||
name VARCHAR(100) NOT NULL COMMENT '规则名称',
|
||||
cube_name VARCHAR(50) NOT NULL COMMENT 'Cube模型名',
|
||||
measure_name VARCHAR(100) NOT NULL COMMENT '监控指标',
|
||||
dimension_filter JSON COMMENT '维度过滤条件',
|
||||
`condition` VARCHAR(20) NOT NULL COMMENT '条件: gt/lt/eq/gte/lte',
|
||||
threshold DECIMAL(18,4) NOT NULL COMMENT '阈值',
|
||||
time_granularity VARCHAR(20) COMMENT '时间粒度',
|
||||
time_range VARCHAR(50) COMMENT '时间范围',
|
||||
notify_channels JSON COMMENT '通知渠道',
|
||||
severity ENUM('low', 'medium', 'high', 'critical') DEFAULT 'medium' COMMENT '严重程度',
|
||||
status ENUM('active', 'paused') DEFAULT 'active' COMMENT '状态',
|
||||
creator_id BIGINT COMMENT '创建者ID',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_cube (cube_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预警规则表';
|
||||
|
||||
-- =====================================================
|
||||
-- 8. 预警记录表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS alert_records;
|
||||
CREATE TABLE alert_records (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID',
|
||||
rule_id BIGINT NOT NULL COMMENT '规则ID',
|
||||
triggered_at DATETIME NOT NULL COMMENT '触发时间',
|
||||
actual_value DECIMAL(18,4) NOT NULL COMMENT '实际值',
|
||||
threshold DECIMAL(18,4) NOT NULL COMMENT '阈值',
|
||||
status ENUM('triggered', 'acknowledged', 'resolved') DEFAULT 'triggered' COMMENT '状态',
|
||||
notified_channels JSON COMMENT '已通知渠道',
|
||||
notification_results JSON COMMENT '通知结果',
|
||||
acknowledged_by BIGINT COMMENT '确认人ID',
|
||||
acknowledged_at DATETIME COMMENT '确认时间',
|
||||
resolved_at DATETIME COMMENT '解决时间',
|
||||
INDEX idx_rule_triggered (rule_id, triggered_at),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_triggered (triggered_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预警记录表';
|
||||
|
||||
-- =====================================================
|
||||
-- 9. 通知渠道表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS notification_channels;
|
||||
CREATE TABLE notification_channels (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '渠道ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
channel_type ENUM('wechat', 'email', 'sms', 'dingtalk') NOT NULL COMMENT '渠道类型',
|
||||
channel_value VARCHAR(255) NOT NULL COMMENT '渠道值(邮箱/手机/企业微信ID)',
|
||||
is_enabled BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_type (channel_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知渠道表';
|
||||
|
||||
-- =====================================================
|
||||
-- 10. S3 配置表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS s3_configs;
|
||||
CREATE TABLE s3_configs (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
|
||||
provider VARCHAR(20) NOT NULL COMMENT '提供商: aws/aliyun/tencent/qiniu/minio/custom',
|
||||
endpoint VARCHAR(255) COMMENT 'S3 Endpoint',
|
||||
region VARCHAR(50) COMMENT 'S3区域',
|
||||
zone VARCHAR(32) COMMENT '厂商区域短码,例如 z0',
|
||||
protocol VARCHAR(10) NOT NULL DEFAULT 'https' COMMENT '访问协议',
|
||||
storage_class VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '默认存储类型',
|
||||
bucket VARCHAR(100) NOT NULL COMMENT '存储桶名称',
|
||||
access_key_id VARCHAR(255) NOT NULL COMMENT 'Access Key ID',
|
||||
secret_access_key VARCHAR(255) NOT NULL COMMENT 'Secret Access Key',
|
||||
custom_domain VARCHAR(255) COMMENT '自定义域名',
|
||||
cdn_url VARCHAR(255) COMMENT 'CDN地址',
|
||||
is_default BOOLEAN DEFAULT FALSE COMMENT '是否默认',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S3配置表';
|
||||
|
||||
-- =====================================================
|
||||
-- 11. 数据库配置表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS database_configs;
|
||||
CREATE TABLE database_configs (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
|
||||
name VARCHAR(100) NOT NULL COMMENT '配置名称',
|
||||
driver VARCHAR(20) NOT NULL COMMENT '驱动: mysql/postgresql',
|
||||
host VARCHAR(255) NOT NULL COMMENT '主机地址',
|
||||
port INT NOT NULL DEFAULT 3306 COMMENT '端口',
|
||||
database_name VARCHAR(100) NOT NULL COMMENT '数据库名',
|
||||
username VARCHAR(100) NOT NULL COMMENT '用户名',
|
||||
password VARCHAR(255) COMMENT '密码',
|
||||
`ssl` BOOLEAN DEFAULT TRUE COMMENT '是否SSL',
|
||||
ssl_ca TEXT COMMENT 'SSL CA证书',
|
||||
max_pool_size INT DEFAULT 10 COMMENT '最大连接池',
|
||||
query_timeout INT DEFAULT 300 COMMENT '查询超时(秒)',
|
||||
use_env_config BOOLEAN DEFAULT FALSE COMMENT '是否优先使用 .env 业务数据库配置',
|
||||
is_default BOOLEAN DEFAULT FALSE COMMENT '是否默认',
|
||||
status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库配置表';
|
||||
|
||||
-- =====================================================
|
||||
-- 12. 操作日志表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS operation_logs;
|
||||
CREATE TABLE operation_logs (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
|
||||
user_id BIGINT COMMENT '用户ID',
|
||||
action VARCHAR(50) NOT NULL COMMENT '操作类型',
|
||||
module VARCHAR(50) NOT NULL COMMENT '模块',
|
||||
description TEXT COMMENT '操作描述',
|
||||
ip_address VARCHAR(45) COMMENT 'IP地址',
|
||||
user_agent TEXT COMMENT 'User Agent',
|
||||
request_params TEXT COMMENT '请求参数',
|
||||
response_status INT COMMENT '响应状态',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_action (action),
|
||||
INDEX idx_module (module),
|
||||
INDEX idx_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
|
||||
|
||||
-- =====================================================
|
||||
-- 11. KPI目标值配置表
|
||||
-- =====================================================
|
||||
DROP TABLE IF EXISTS sys_kpi_targets;
|
||||
CREATE TABLE sys_kpi_targets (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '配置ID',
|
||||
page_code VARCHAR(50) NOT NULL COMMENT '页面编码',
|
||||
kpi_code VARCHAR(50) NOT NULL COMMENT '指标编码',
|
||||
kpi_name VARCHAR(100) NOT NULL COMMENT '指标名称',
|
||||
unit VARCHAR(20) DEFAULT '' COMMENT '单位',
|
||||
direction VARCHAR(10) DEFAULT 'lower' COMMENT '越低越好lower/越高越好higher',
|
||||
target_good DECIMAL(18, 4) COMMENT '优秀线',
|
||||
target_pass DECIMAL(18, 4) COMMENT '达标线',
|
||||
target_warn DECIMAL(18, 4) COMMENT '预警线',
|
||||
is_enabled TINYINT(1) DEFAULT 1 COMMENT '是否启用',
|
||||
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||
remark VARCHAR(200) DEFAULT '' COMMENT '备注',
|
||||
created_by BIGINT COMMENT '创建人',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_by BIGINT COMMENT '更新人',
|
||||
updated_at DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_page_kpi (page_code, kpi_code),
|
||||
INDEX idx_page (page_code),
|
||||
INDEX idx_enabled (is_enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KPI目标值配置表';
|
||||
|
||||
-- 插入默认KPI目标值配置
|
||||
INSERT INTO sys_kpi_targets (page_code, kpi_code, kpi_name, unit, direction, target_good, target_pass, target_warn, sort_order) VALUES
|
||||
-- 客服分析
|
||||
('customerService', 'refundProcessTime', '退款处理时长', 'h', 'lower', 12, 24, 48, 1),
|
||||
('customerService', 'disputeRate', '纠纷判责率', '%', 'lower', 10, 20, 30, 2),
|
||||
('customerService', 'compensationRate', '赔付率', '%', 'lower', 0.3, 0.5, 1, 3),
|
||||
('customerService', 'avgCompensation', '单笔平均赔付', '元', 'lower', 30, 50, 80, 4),
|
||||
('customerService', 'labelRate', '打标完成率', '%', 'higher', 98, 90, 80, 5),
|
||||
-- 仓库分析
|
||||
('warehouse', 'sameDayShipRate', '当日发货率', '%', 'higher', 98, 95, 90, 1),
|
||||
('warehouse', 'wrongShipRate', '错发漏发率', '%', 'lower', 0.2, 0.5, 1, 2),
|
||||
('warehouse', 'nextDayRate', '次日达占比', '%', 'higher', 50, 30, 20, 3),
|
||||
('warehouse', 'inventoryTurnover', '库存周转率', '次/月', 'higher', 10, 6, 4, 4),
|
||||
-- 快递分析
|
||||
('logistics', 'pickupRate24h', '24h揽收及时率', '%', 'higher', 98, 95, 90, 1),
|
||||
('logistics', 'avgDeliveryTime', '平均物流时长', 'h', 'lower', 48, 72, 96, 2),
|
||||
('logistics', 'abnormalRate', '物流异常率', '%', 'lower', 1, 2, 5, 3),
|
||||
('logistics', 'lostDamageRate', '丢件破损率', '%', 'lower', 0.2, 0.5, 1, 4),
|
||||
-- 供应商分析
|
||||
('supplier', 'qualityRefundRate', '品质退款率', '%', 'lower', 1.5, 3, 5, 1),
|
||||
('supplier', 'defectRate', '次品率', '%', 'lower', 1, 2, 3, 2),
|
||||
('supplier', 'shipTimelyRate', '发货及时率', '%', 'higher', 98, 95, 90, 3),
|
||||
-- 采购分析
|
||||
('procurement', 'purchaseRefundRate', '采购SKU退款率', '%', 'lower', 1.5, 3, 5, 1),
|
||||
('procurement', 'dropshipTimelyRate', '代发准时率', '%', 'higher', 98, 95, 90, 2),
|
||||
('procurement', 'stockoutImpactRate', '缺货影响订单率', '%', 'lower', 0.5, 1, 2, 3),
|
||||
-- 开发分析
|
||||
('productDesign', 'designAttributionRate', '开发归因退款率', '%', 'lower', 5, 8, 12, 1),
|
||||
('productDesign', 'firstQualityReturnRate', '首次品退率', '%', 'lower', 1, 2, 3, 2),
|
||||
('productDesign', 'visualIssueRate', '视觉问题率', '%', 'lower', 2, 5, 8, 3);
|
||||
355
backend/database/summary_tables.sql
Normal file
355
backend/database/summary_tables.sql
Normal file
@ -0,0 +1,355 @@
|
||||
-- ========================================
|
||||
-- 本地汇总表(存储在 shujuku2_system 数据库)
|
||||
-- 用于存储每日计算的汇总数据,提升查询性能
|
||||
-- 执行方式: mysql --default-character-set=utf8mb4 -u root -p shujuku2_system < summary_tables.sql
|
||||
-- ========================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
USE shujuku2_system;
|
||||
|
||||
-- 1. 每日核心KPI汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_kpi (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL COMMENT '统计日期',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
order_count BIGINT DEFAULT 0 COMMENT '订单数',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退款件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)',
|
||||
attribution_rate DECIMAL(6,2) DEFAULT 0 COMMENT '归因覆盖率(%)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_stat_date (stat_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日核心KPI汇总';
|
||||
|
||||
-- 2. 部门归因汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_attribution (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
dept VARCHAR(20) NOT NULL COMMENT '部门: 客服/仓库/开发/快递/供应商/采购/无归因',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退款件数',
|
||||
percent DECIMAL(6,2) DEFAULT 0 COMMENT '占比(%)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_dept (stat_date, dept)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门归因汇总';
|
||||
|
||||
-- 3. 仓库维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_warehouse (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
warehouse_name VARCHAR(100) NOT NULL COMMENT '仓库名称',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退款件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)',
|
||||
timely_rate DECIMAL(6,2) DEFAULT 0 COMMENT '发货及时率(%)',
|
||||
accuracy_rate DECIMAL(6,2) DEFAULT 0 COMMENT '发货准确率(%)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_warehouse (stat_date, warehouse_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库维度汇总';
|
||||
|
||||
-- 4. 快递公司维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_courier (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
courier_name VARCHAR(100) NOT NULL COMMENT '快递公司',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退款件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)',
|
||||
avg_delivery_hours DECIMAL(6,1) DEFAULT 0 COMMENT '平均时效(h)',
|
||||
pickup_timely_rate DECIMAL(6,2) DEFAULT 0 COMMENT '24h揽收及时率(%)',
|
||||
damage_count BIGINT DEFAULT 0 COMMENT '破损单量',
|
||||
damage_base_count BIGINT DEFAULT 0 COMMENT '破损率分母单量',
|
||||
damage_rate DECIMAL(6,2) DEFAULT 0 COMMENT '破损率(%)',
|
||||
bad_review_rate DECIMAL(6,2) DEFAULT 0 COMMENT '差评率(%)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_courier (stat_date, courier_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='快递公司维度汇总';
|
||||
|
||||
-- 5. 省份维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_province (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
province VARCHAR(50) NOT NULL COMMENT '省份',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退款件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)',
|
||||
avg_delivery_hours DECIMAL(6,1) DEFAULT 0 COMMENT '平均时效(h)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_province (stat_date, province)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='省份维度汇总';
|
||||
|
||||
-- 6. 供应商维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_supplier (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
supplier_name VARCHAR(100) NOT NULL COMMENT '供应商名称',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退款件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)',
|
||||
quality_rate DECIMAL(6,2) DEFAULT 0 COMMENT '品质问题率(%)',
|
||||
return_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退货率(%)',
|
||||
part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '配件率(%)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_supplier (stat_date, supplier_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商维度汇总';
|
||||
|
||||
-- 7. 客服维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_cs (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
cs_name VARCHAR(100) NOT NULL COMMENT '客服名称',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '关联发货件数',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退款件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)',
|
||||
compensation_count BIGINT DEFAULT 0 COMMENT '赔付次数',
|
||||
chat_issue_count BIGINT DEFAULT 0 COMMENT '聊天问题数',
|
||||
refund_process_hours_total DECIMAL(12,2) DEFAULT 0 COMMENT '退款处理总时长(h)',
|
||||
refund_process_count BIGINT DEFAULT 0 COMMENT '退款处理样本数',
|
||||
avg_refund_process_hours DECIMAL(6,2) DEFAULT 0 COMMENT '退款处理时长均值(h)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_cs (stat_date, cs_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客服维度汇总';
|
||||
|
||||
-- 8. 采购员维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_purchaser (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
purchaser_name VARCHAR(100) NOT NULL COMMENT '采购员名称',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退款件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)',
|
||||
timely_rate DECIMAL(6,2) DEFAULT 0 COMMENT '供货及时率(%)',
|
||||
under10_count BIGINT DEFAULT 0 COMMENT '不足10日产品数',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_purchaser (stat_date, purchaser_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购员维度汇总';
|
||||
|
||||
-- 9. 设计师维度汇总表(一、设计师总体数据)
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_designer (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
designer_name VARCHAR(100) NOT NULL COMMENT '产品设计师',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
ship_amount DECIMAL(14,2) DEFAULT 0 COMMENT '销售额',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退货件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退货率(%)',
|
||||
real_amount DECIMAL(14,2) DEFAULT 0 COMMENT '真实业绩(销售额-售后成本)',
|
||||
aftersale_cost DECIMAL(14,2) DEFAULT 0 COMMENT '售后成本(赔付+补偿+运费)',
|
||||
aftersale_cost_rate DECIMAL(6,2) DEFAULT 0 COMMENT '售后成本率(%)',
|
||||
part_order_count BIGINT DEFAULT 0 COMMENT '配件订单数',
|
||||
part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '配件率(%)',
|
||||
multi_part_order_count BIGINT DEFAULT 0 COMMENT '多次配件订单数',
|
||||
multi_part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '多次配件率(%)',
|
||||
problem_spu_count BIGINT DEFAULT 0 COMMENT '问题SPU数',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_designer (stat_date, designer_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设计师维度汇总';
|
||||
|
||||
-- 9.1 设计师SKU明细表(二、数据明细)
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_designer_sku (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
designer_name VARCHAR(100) NOT NULL COMMENT '产品设计师',
|
||||
sku_id VARCHAR(100) NOT NULL COMMENT 'SKU编码',
|
||||
spu VARCHAR(100) DEFAULT '' COMMENT '商品编码/SPU',
|
||||
supplier_name VARCHAR(100) DEFAULT '' COMMENT '供应商',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
ship_amount DECIMAL(14,2) DEFAULT 0 COMMENT '真实销售额',
|
||||
aftersale_amount DECIMAL(14,2) DEFAULT 0 COMMENT '售后金额',
|
||||
aftersale_cost_rate DECIMAL(6,2) DEFAULT 0 COMMENT '售后成本率(%)',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退货件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退货率(%)',
|
||||
part_count BIGINT DEFAULT 0 COMMENT '配件补发次数',
|
||||
part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '配件率(%)',
|
||||
multi_part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '多次配件率(%)',
|
||||
real_amount DECIMAL(14,2) DEFAULT 0 COMMENT '真实业绩',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_designer_sku (stat_date, designer_name, sku_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设计师SKU明细表';
|
||||
|
||||
-- 9.2 设计师配件明细表(三、配件明细数据)
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_designer_part (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
designer_name VARCHAR(100) NOT NULL COMMENT '产品设计师',
|
||||
supplier_name VARCHAR(100) DEFAULT '' COMMENT '供应商',
|
||||
sku_id VARCHAR(100) NOT NULL COMMENT 'SKU编码',
|
||||
spu VARCHAR(100) DEFAULT '' COMMENT '商品编码/SPU',
|
||||
part_sku_id VARCHAR(100) DEFAULT '' COMMENT '配件编码',
|
||||
part_status VARCHAR(50) DEFAULT '' COMMENT '状态',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
part_count BIGINT DEFAULT 0 COMMENT '补发次数',
|
||||
part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '配件率(%)',
|
||||
multi_part_rate DECIMAL(6,2) DEFAULT 0 COMMENT '多次配件率(%)',
|
||||
category VARCHAR(100) DEFAULT '' COMMENT '类目',
|
||||
material VARCHAR(100) DEFAULT '' COMMENT '材质',
|
||||
weight DECIMAL(10,2) DEFAULT 0 COMMENT '重量(kg)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_designer_part (stat_date, designer_name, sku_id, part_sku_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设计师配件明细表';
|
||||
|
||||
-- 10. SKU问题汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_sku (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
sku_id VARCHAR(100) NOT NULL COMMENT 'SKU编码',
|
||||
sku_name VARCHAR(255) COMMENT '商品名称',
|
||||
spu VARCHAR(100) COMMENT 'SPU编码',
|
||||
ship_count BIGINT DEFAULT 0 COMMENT '发货件数',
|
||||
refund_count BIGINT DEFAULT 0 COMMENT '退款件数',
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0 COMMENT '退款率(%)',
|
||||
problem_type VARCHAR(100) COMMENT '主要问题类型',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_sku (stat_date, sku_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SKU问题汇总';
|
||||
|
||||
-- 11. 聊天问题汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_chat_issue (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
main_reason VARCHAR(100) NOT NULL COMMENT '一级问题',
|
||||
second_reason VARCHAR(100) DEFAULT '' COMMENT '二级问题',
|
||||
issue_count BIGINT DEFAULT 0 COMMENT '问题数量',
|
||||
percent DECIMAL(6,2) DEFAULT 0 COMMENT '占比(%)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_reason (stat_date, main_reason, second_reason)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='聊天问题汇总';
|
||||
|
||||
-- 12. 月度汇总表(趋势图用)
|
||||
CREATE TABLE IF NOT EXISTS summary_monthly_kpi (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_month VARCHAR(7) NOT NULL COMMENT '统计月份(YYYY-MM)',
|
||||
ship_count BIGINT DEFAULT 0,
|
||||
order_count BIGINT DEFAULT 0,
|
||||
refund_count BIGINT DEFAULT 0,
|
||||
refund_rate DECIMAL(6,2) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_stat_month (stat_month)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='月度KPI汇总';
|
||||
|
||||
-- 13. 数据刷新日志表
|
||||
CREATE TABLE IF NOT EXISTS summary_refresh_log (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL COMMENT '统计日期',
|
||||
start_time DATETIME NOT NULL COMMENT '开始时间',
|
||||
end_time DATETIME COMMENT '结束时间',
|
||||
status ENUM('running', 'success', 'failed') DEFAULT 'running' COMMENT '状态',
|
||||
duration_ms INT COMMENT '耗时(毫秒)',
|
||||
error_message TEXT COMMENT '错误信息',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_stat_date (stat_date),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据刷新日志';
|
||||
|
||||
-- 13.5 物流到货时长日度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_delivery (
|
||||
stat_date DATE NOT NULL COMMENT '统计日期',
|
||||
total_order_count BIGINT DEFAULT 0 COMMENT '签收订单数',
|
||||
timely_count BIGINT DEFAULT 0 COMMENT '48h及时签收订单数',
|
||||
timely_rate DECIMAL(6,2) DEFAULT 0 COMMENT '48h及时送达率(%)',
|
||||
total_delivery_hours DECIMAL(12,2) DEFAULT 0 COMMENT '总签收时长(h)',
|
||||
avg_delivery_hours DECIMAL(8,2) DEFAULT 0 COMMENT '平均物流时长(h)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (stat_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物流到货时长日度汇总';
|
||||
|
||||
-- 13.6 省份物流时效日度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_region_delivery (
|
||||
stat_date DATE NOT NULL COMMENT '统计日期',
|
||||
province VARCHAR(32) NOT NULL COMMENT '省份',
|
||||
total_order_count BIGINT DEFAULT 0 COMMENT '签收订单数',
|
||||
total_delivery_hours DECIMAL(12,2) DEFAULT 0 COMMENT '总签收时长(h)',
|
||||
avg_delivery_hours DECIMAL(6,1) DEFAULT 0 COMMENT '平均物流时长(h)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (stat_date, province)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='省份物流时效日度汇总';
|
||||
|
||||
-- 14. 揽收日度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_pickup (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL COMMENT '统计日期',
|
||||
total_order_count BIGINT DEFAULT 0 COMMENT '应揽收订单数',
|
||||
timeout_count BIGINT DEFAULT 0 COMMENT '超时揽收订单数',
|
||||
pickup_rate DECIMAL(6,2) DEFAULT 0 COMMENT '24h揽收及时率(%)',
|
||||
avg_pickup_hours DECIMAL(6,1) DEFAULT 0 COMMENT '平均揽收时长(h)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_stat_date (stat_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收日度汇总';
|
||||
|
||||
-- 15. 揽收-仓库维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_pickup_warehouse (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
warehouse_name VARCHAR(100) NOT NULL COMMENT '仓库名称',
|
||||
total_order_count BIGINT DEFAULT 0,
|
||||
timeout_count BIGINT DEFAULT 0,
|
||||
pickup_rate DECIMAL(6,2) DEFAULT 0,
|
||||
avg_pickup_hours DECIMAL(6,1) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_warehouse (stat_date, warehouse_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-仓库维度汇总';
|
||||
|
||||
-- 16. 揽收-供应商维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_pickup_supplier (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
supplier_name VARCHAR(100) NOT NULL COMMENT '供应商名称',
|
||||
total_order_count BIGINT DEFAULT 0,
|
||||
timeout_count BIGINT DEFAULT 0,
|
||||
pickup_rate DECIMAL(6,2) DEFAULT 0,
|
||||
avg_pickup_hours DECIMAL(6,1) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_supplier (stat_date, supplier_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-供应商维度汇总';
|
||||
|
||||
-- 17. 揽收-区域维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_pickup_region (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
province VARCHAR(50) NOT NULL COMMENT '收货省份',
|
||||
total_order_count BIGINT DEFAULT 0,
|
||||
timeout_count BIGINT DEFAULT 0,
|
||||
pickup_rate DECIMAL(6,2) DEFAULT 0,
|
||||
avg_pickup_hours DECIMAL(6,1) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_province (stat_date, province)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-区域维度汇总';
|
||||
|
||||
-- 18. 揽收-快递公司维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_pickup_courier (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
courier_name VARCHAR(100) NOT NULL COMMENT '快递公司',
|
||||
total_order_count BIGINT DEFAULT 0,
|
||||
timeout_count BIGINT DEFAULT 0,
|
||||
pickup_rate DECIMAL(6,2) DEFAULT 0,
|
||||
avg_pickup_hours DECIMAL(6,1) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_courier (stat_date, courier_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-快递公司维度汇总';
|
||||
|
||||
-- 19. 揽收-区域&快递组合维度汇总表
|
||||
CREATE TABLE IF NOT EXISTS summary_daily_pickup_region_courier (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
stat_date DATE NOT NULL,
|
||||
province VARCHAR(50) NOT NULL COMMENT '收货省份',
|
||||
city VARCHAR(50) DEFAULT '' COMMENT '收货城市',
|
||||
district VARCHAR(50) DEFAULT '' COMMENT '收货区/县/街道',
|
||||
courier_name VARCHAR(100) NOT NULL COMMENT '快递公司',
|
||||
total_order_count BIGINT DEFAULT 0,
|
||||
timeout_count BIGINT DEFAULT 0,
|
||||
pickup_rate DECIMAL(6,2) DEFAULT 0,
|
||||
avg_pickup_hours DECIMAL(6,1) DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_region_courier (stat_date, province, city, district, courier_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='揽收-区域&快递组合维度汇总';
|
||||
|
||||
-- 显示创建结果
|
||||
SELECT TABLE_NAME, TABLE_COMMENT
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = 'shujuku2_system'
|
||||
AND TABLE_NAME LIKE 'summary_%'
|
||||
ORDER BY TABLE_NAME;
|
||||
83
backend/database/yucekj_reconstruction_schema.sql
Normal file
83
backend/database/yucekj_reconstruction_schema.sql
Normal file
@ -0,0 +1,83 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yucekj_reconstruction_site (
|
||||
site_key TEXT PRIMARY KEY,
|
||||
host TEXT NOT NULL,
|
||||
generated_at TEXT,
|
||||
stack_json TEXT,
|
||||
source_file TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yucekj_reconstruction_page (
|
||||
page_key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
route TEXT,
|
||||
chunk TEXT,
|
||||
status TEXT,
|
||||
source_file TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yucekj_reconstruction_route (
|
||||
route_key TEXT PRIMARY KEY,
|
||||
page_key TEXT,
|
||||
path TEXT NOT NULL,
|
||||
route_name TEXT,
|
||||
title TEXT,
|
||||
chunk TEXT,
|
||||
source_file TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yucekj_reconstruction_relation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_key TEXT NOT NULL,
|
||||
to_key TEXT NOT NULL,
|
||||
relation_type TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yucekj_reconstruction_api (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope_key TEXT NOT NULL,
|
||||
api_name TEXT NOT NULL,
|
||||
chunk TEXT,
|
||||
source_file TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS yucekj_reconstruction_artifact (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_key TEXT NOT NULL,
|
||||
artifact_key TEXT,
|
||||
artifact_type TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
source_file TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_yucekj_page_category
|
||||
ON yucekj_reconstruction_page(category);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_yucekj_route_page_key
|
||||
ON yucekj_reconstruction_route(page_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_yucekj_relation_from_key
|
||||
ON yucekj_reconstruction_relation(from_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_yucekj_relation_to_key
|
||||
ON yucekj_reconstruction_relation(to_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_yucekj_api_scope_key
|
||||
ON yucekj_reconstruction_api(scope_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_yucekj_artifact_owner_key
|
||||
ON yucekj_reconstruction_artifact(owner_key);
|
||||
16
backend/ecosystem.config.cjs
Normal file
16
backend/ecosystem.config.cjs
Normal file
@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'yyd-pro-backend',
|
||||
script: 'src/index.js',
|
||||
cwd: __dirname,
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
19
backend/ecosystem.jst-reference-sync.cjs
Normal file
19
backend/ecosystem.jst-reference-sync.cjs
Normal file
@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'jst-reference-sync',
|
||||
script: 'scripts/jst_reference_sync_scheduler.js',
|
||||
cwd: __dirname,
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '300M',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
TZ: 'Asia/Shanghai',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
88
backend/model/ChatLogs.js
Normal file
88
backend/model/ChatLogs.js
Normal file
@ -0,0 +1,88 @@
|
||||
cube(`ChatLogs`, {
|
||||
sql: `SELECT * FROM rpt.pc_kfChatLog`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
title: `总记录数`
|
||||
},
|
||||
|
||||
taggedCount: {
|
||||
type: `count`,
|
||||
filters: [
|
||||
{ sql: `${CUBE}.state != 0` }
|
||||
],
|
||||
title: `已打标数量`
|
||||
}
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `id`,
|
||||
type: `number`,
|
||||
primaryKey: true
|
||||
},
|
||||
|
||||
orderNo: {
|
||||
sql: `orderNo`,
|
||||
type: `string`,
|
||||
title: `订单编号`
|
||||
},
|
||||
|
||||
afsNo: {
|
||||
sql: `afsNo`,
|
||||
type: `string`,
|
||||
title: `售后编号`
|
||||
},
|
||||
|
||||
shopName: {
|
||||
sql: `shopName`,
|
||||
type: `string`,
|
||||
title: `店铺名称`
|
||||
},
|
||||
|
||||
content: {
|
||||
sql: `content`,
|
||||
type: `string`,
|
||||
title: `聊天内容`
|
||||
},
|
||||
|
||||
createdAt: {
|
||||
sql: `createdAt`,
|
||||
type: `time`,
|
||||
title: `创建时间`
|
||||
},
|
||||
|
||||
state: {
|
||||
sql: `state`,
|
||||
type: `number`,
|
||||
title: `打标状态`
|
||||
},
|
||||
|
||||
mainReason: {
|
||||
sql: `mainReason`,
|
||||
type: `string`,
|
||||
title: `主要原因`
|
||||
},
|
||||
|
||||
secondReason: {
|
||||
sql: `secondReason`,
|
||||
type: `string`,
|
||||
title: `次要原因`
|
||||
},
|
||||
|
||||
thirdReason: {
|
||||
sql: `thirdReason`,
|
||||
type: `string`,
|
||||
title: `三级原因`
|
||||
},
|
||||
|
||||
reasonContent: {
|
||||
sql: `reasonContent`,
|
||||
type: `string`,
|
||||
title: `判断依据`
|
||||
}
|
||||
},
|
||||
|
||||
dataSource: `default`
|
||||
});
|
||||
101
backend/model/FreightCosts.js
Normal file
101
backend/model/FreightCosts.js
Normal file
@ -0,0 +1,101 @@
|
||||
// Cube.js 数据模型 - 运费
|
||||
// 基于 fys_jstFreightCost 表
|
||||
|
||||
cube(`FreightCosts`, {
|
||||
sql: `SELECT * FROM rpt.fys_jstFreightCost`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
sql: `Id`,
|
||||
type: `count`,
|
||||
},
|
||||
|
||||
totalFreight: {
|
||||
sql: `freightCost`,
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `总运费`,
|
||||
},
|
||||
|
||||
avgFreight: {
|
||||
sql: `freightCost`,
|
||||
type: `avg`,
|
||||
format: `currency`,
|
||||
title: `平均运费`,
|
||||
},
|
||||
|
||||
totalWeight: {
|
||||
sql: `weight`,
|
||||
type: `sum`,
|
||||
title: `总重量`,
|
||||
},
|
||||
|
||||
avgWeight: {
|
||||
sql: `weight`,
|
||||
type: `avg`,
|
||||
title: `平均重量`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `Id`,
|
||||
type: `number`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
oId: {
|
||||
sql: `o_id`,
|
||||
type: `number`,
|
||||
title: `聚水潭内部单号`,
|
||||
},
|
||||
|
||||
soId: {
|
||||
sql: `so_id`,
|
||||
type: `string`,
|
||||
title: `线上单号`,
|
||||
},
|
||||
|
||||
shopName: {
|
||||
sql: `shop_name`,
|
||||
type: `string`,
|
||||
title: `店铺名称`,
|
||||
},
|
||||
|
||||
receiverState: {
|
||||
sql: `receiver_state`,
|
||||
type: `string`,
|
||||
title: `省份`,
|
||||
},
|
||||
|
||||
expressNo: {
|
||||
sql: `expressNo`,
|
||||
type: `string`,
|
||||
title: `快递单号`,
|
||||
},
|
||||
|
||||
expressName: {
|
||||
sql: `expressName`,
|
||||
type: `string`,
|
||||
title: `快递公司`,
|
||||
},
|
||||
|
||||
freightType: {
|
||||
sql: `CASE WHEN type = 0 THEN '费率' WHEN type = 1 THEN '专线' ELSE '其他' END`,
|
||||
type: `string`,
|
||||
title: `运费类型`,
|
||||
},
|
||||
|
||||
sendDate: {
|
||||
sql: `send_date`,
|
||||
type: `time`,
|
||||
title: `发货日期`,
|
||||
},
|
||||
|
||||
createTime: {
|
||||
sql: `createTime`,
|
||||
type: `time`,
|
||||
title: `创建时间`,
|
||||
},
|
||||
},
|
||||
});
|
||||
204
backend/model/KpiItems.js
Normal file
204
backend/model/KpiItems.js
Normal file
@ -0,0 +1,204 @@
|
||||
// Cube.js 数据模型 - 业绩与退款 KPI
|
||||
// 基于 jst_goods_profit_kpi_items_afs 表
|
||||
|
||||
cube(`KpiItems`, {
|
||||
sql: `SELECT * FROM rpt.jst_goods_profit_kpi_items_afs`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
type: `count`,
|
||||
title: `总记录数`
|
||||
},
|
||||
|
||||
// --- 销售相关 ---
|
||||
|
||||
totalGoodsAmount: {
|
||||
sql: `undefined21`, // 商品金额
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `商品总金额`
|
||||
},
|
||||
|
||||
totalPayment: {
|
||||
sql: `undefined26`, // 付款金额
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `总付款金额`
|
||||
},
|
||||
|
||||
totalCost: {
|
||||
sql: `undefined22`, // 商品成本
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `总成本`
|
||||
},
|
||||
|
||||
totalRealSendAmount: {
|
||||
sql: `undefined29`, // 实发金额
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `实发总金额`
|
||||
},
|
||||
|
||||
totalRealSendCount: {
|
||||
sql: `undefined30`, // 实发数量
|
||||
type: `sum`,
|
||||
title: `实发总数量`
|
||||
},
|
||||
|
||||
totalFreightIncome: {
|
||||
sql: `undefined25`, // 运费收入
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `运费收入`
|
||||
},
|
||||
|
||||
// --- 退款相关 ---
|
||||
|
||||
totalRefundAmountBeforeSend: {
|
||||
sql: `undefined43`, // 发货前退款金额
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `发货前退款金额`
|
||||
},
|
||||
|
||||
totalRefundAmountAfterSend: {
|
||||
sql: `undefined52`, // 发货后退款金额
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `发货后退款金额`
|
||||
},
|
||||
|
||||
totalRefundAmount: {
|
||||
sql: `${totalRefundAmountBeforeSend} + ${totalRefundAmountAfterSend}`,
|
||||
type: `number`,
|
||||
format: `currency`,
|
||||
title: `总退款金额`
|
||||
},
|
||||
|
||||
// --- 利润相关 (估算) ---
|
||||
|
||||
grossProfit: {
|
||||
sql: `${totalPayment} - ${totalCost}`,
|
||||
type: `number`,
|
||||
format: `currency`,
|
||||
title: `毛利`
|
||||
}
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
// --- 标识符 ---
|
||||
id: {
|
||||
sql: `undefined1`, // 内部订单号作为主键假设
|
||||
type: `string`,
|
||||
primaryKey: true,
|
||||
title: `内部订单号`
|
||||
},
|
||||
|
||||
onlineOrderId: {
|
||||
sql: `undefined2`, // 线上订单号
|
||||
type: `string`,
|
||||
title: `线上订单号`
|
||||
},
|
||||
|
||||
subOrderId: {
|
||||
sql: `undefined3`, // 子订单号
|
||||
type: `string`,
|
||||
title: `子订单号`
|
||||
},
|
||||
|
||||
refundId: {
|
||||
sql: `undefined4`, // 售后单号
|
||||
type: `string`,
|
||||
title: `售后单号`
|
||||
},
|
||||
|
||||
// --- 时间维度 ---
|
||||
|
||||
payTime: {
|
||||
sql: `undefined5`, // 支付时间
|
||||
type: `time`,
|
||||
title: `支付时间`
|
||||
},
|
||||
|
||||
sendTime: {
|
||||
sql: `undefined6`, // 发货时间
|
||||
type: `time`,
|
||||
title: `发货时间`
|
||||
},
|
||||
|
||||
orderTime: {
|
||||
sql: `undefined7`, // 订单时间
|
||||
type: `time`,
|
||||
title: `订单时间`
|
||||
},
|
||||
|
||||
// --- 商品维度 ---
|
||||
|
||||
skuId: {
|
||||
sql: `undefined10`, // 商品编码
|
||||
type: `string`,
|
||||
title: `商品编码`
|
||||
},
|
||||
|
||||
iId: {
|
||||
sql: `undefined11`, // 款式编码
|
||||
type: `string`,
|
||||
title: `款式编码`
|
||||
},
|
||||
|
||||
brand: {
|
||||
sql: `undefined15`, // 品牌
|
||||
type: `string`,
|
||||
title: `品牌`
|
||||
},
|
||||
|
||||
category: {
|
||||
sql: `undefined16`, // 分类
|
||||
type: `string`,
|
||||
title: `分类`
|
||||
},
|
||||
|
||||
productName: {
|
||||
sql: `undefined36`, // 线上商品名称
|
||||
type: `string`,
|
||||
title: `商品名称`
|
||||
},
|
||||
|
||||
// --- 店铺/渠道维度 ---
|
||||
|
||||
shopName: {
|
||||
sql: `undefined8`, // 店铺名称
|
||||
type: `string`,
|
||||
title: `店铺名称`
|
||||
},
|
||||
|
||||
// --- 地理维度 ---
|
||||
|
||||
province: {
|
||||
sql: `undefined74`, // 省
|
||||
type: `string`,
|
||||
title: `省份`
|
||||
},
|
||||
|
||||
city: {
|
||||
sql: `undefined75`, // 市
|
||||
type: `string`,
|
||||
title: `城市`
|
||||
},
|
||||
|
||||
// --- 物流维度 ---
|
||||
|
||||
expressCompany: {
|
||||
sql: `undefined73`, // 快递公司
|
||||
type: `string`,
|
||||
title: `快递公司`
|
||||
},
|
||||
|
||||
warehouse: {
|
||||
sql: `undefined66`, // 发货仓库
|
||||
type: `string`,
|
||||
title: `发货仓库`
|
||||
}
|
||||
}
|
||||
});
|
||||
111
backend/model/Orders.js
Normal file
111
backend/model/Orders.js
Normal file
@ -0,0 +1,111 @@
|
||||
// Cube.js 数据模型 - 订单快递详情
|
||||
// 基于 jst_order_express_details 表
|
||||
|
||||
cube(`Orders`, {
|
||||
sql: `SELECT * FROM rpt.jst_order_express_details`,
|
||||
|
||||
// measures(指标)
|
||||
measures: {
|
||||
count: {
|
||||
sql: `id`,
|
||||
type: `count`,
|
||||
},
|
||||
|
||||
totalWeight: {
|
||||
sql: `weight`,
|
||||
type: `sum`,
|
||||
},
|
||||
|
||||
avgWeight: {
|
||||
sql: `weight`,
|
||||
type: `avg`,
|
||||
},
|
||||
},
|
||||
|
||||
// dimensions(维度)
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `id`,
|
||||
type: `number`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
soId: {
|
||||
sql: `so_id`,
|
||||
type: `string`,
|
||||
title: `线上订单号`,
|
||||
},
|
||||
|
||||
logisticId: {
|
||||
sql: `logistic_id`,
|
||||
type: `string`,
|
||||
title: `物流单号`,
|
||||
},
|
||||
|
||||
logisticCompanyName: {
|
||||
sql: `logistic_company_name`,
|
||||
type: `string`,
|
||||
title: `物流公司`,
|
||||
},
|
||||
|
||||
logisticStatusName: {
|
||||
sql: `logistic_status_name`,
|
||||
type: `string`,
|
||||
title: `物流状态`,
|
||||
},
|
||||
|
||||
orderStatusName: {
|
||||
sql: `order_status_name`,
|
||||
type: `string`,
|
||||
title: `订单状态`,
|
||||
},
|
||||
|
||||
shopName: {
|
||||
sql: `shop_name`,
|
||||
type: `string`,
|
||||
title: `店铺名称`,
|
||||
},
|
||||
|
||||
platformName: {
|
||||
sql: `platform_name`,
|
||||
type: `string`,
|
||||
title: `平台`,
|
||||
},
|
||||
|
||||
warehouseName: {
|
||||
sql: `warehouse_name`,
|
||||
type: `string`,
|
||||
title: `发货仓`,
|
||||
},
|
||||
|
||||
receiverAddress: {
|
||||
sql: `receiver_address`,
|
||||
type: `string`,
|
||||
title: `收货省市区`,
|
||||
},
|
||||
|
||||
payTime: {
|
||||
sql: `pay_time`,
|
||||
type: `time`,
|
||||
title: `支付时间`,
|
||||
},
|
||||
|
||||
sendTime: {
|
||||
sql: `send_time`,
|
||||
type: `time`,
|
||||
title: `发货时间`,
|
||||
},
|
||||
|
||||
signTime: {
|
||||
sql: `sign_time`,
|
||||
type: `time`,
|
||||
title: `签收时间`,
|
||||
},
|
||||
|
||||
createdAt: {
|
||||
sql: `createdAt`,
|
||||
type: `time`,
|
||||
title: `创建时间`,
|
||||
},
|
||||
},
|
||||
});
|
||||
167
backend/model/Products.js
Normal file
167
backend/model/Products.js
Normal file
@ -0,0 +1,167 @@
|
||||
// Cube.js 数据模型 - 商品资料
|
||||
// 基于 jst_sku 表
|
||||
|
||||
cube(`Products`, {
|
||||
sql: `SELECT * FROM rpt.jst_sku`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
sql: `autoid`,
|
||||
type: `count`,
|
||||
title: `商品总数`
|
||||
},
|
||||
|
||||
totalCost: {
|
||||
sql: `cost_price`,
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `总成本`
|
||||
},
|
||||
|
||||
avgSalePrice: {
|
||||
sql: `sale_price`,
|
||||
type: `avg`,
|
||||
format: `currency`,
|
||||
title: `平均销售价`
|
||||
},
|
||||
|
||||
avgCostPrice: {
|
||||
sql: `cost_price`,
|
||||
type: `avg`,
|
||||
format: `currency`,
|
||||
title: `平均成本价`
|
||||
},
|
||||
|
||||
totalWeight: {
|
||||
sql: `weight`,
|
||||
type: `sum`,
|
||||
title: `总重量`
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `autoid`,
|
||||
type: `number`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
skuId: {
|
||||
sql: `sku_id`,
|
||||
type: `string`,
|
||||
title: `商品编码`,
|
||||
},
|
||||
|
||||
iId: {
|
||||
sql: `i_id`,
|
||||
type: `string`,
|
||||
title: `款式编码`,
|
||||
},
|
||||
|
||||
name: {
|
||||
sql: `name`,
|
||||
type: `string`,
|
||||
title: `商品名称`,
|
||||
},
|
||||
|
||||
shortName: {
|
||||
sql: `short_name`,
|
||||
type: `string`,
|
||||
title: `商品简称`,
|
||||
},
|
||||
|
||||
salePrice: {
|
||||
sql: `sale_price`,
|
||||
type: `number`,
|
||||
format: `currency`,
|
||||
title: `销售价`,
|
||||
},
|
||||
|
||||
costPrice: {
|
||||
sql: `cost_price`,
|
||||
type: `number`,
|
||||
format: `currency`,
|
||||
title: `成本价`,
|
||||
},
|
||||
|
||||
category: {
|
||||
sql: `category`,
|
||||
type: `string`,
|
||||
title: `分类`,
|
||||
},
|
||||
|
||||
brand: {
|
||||
sql: `brand`,
|
||||
type: `string`,
|
||||
title: `品牌`,
|
||||
},
|
||||
|
||||
supplierName: {
|
||||
sql: `supplier_name`,
|
||||
type: `string`,
|
||||
title: `供应商名称`,
|
||||
},
|
||||
|
||||
enabled: {
|
||||
sql: `CASE WHEN enabled = 1 THEN '启用' WHEN enabled = 0 THEN '备用' ELSE '禁用' END`,
|
||||
type: `string`,
|
||||
title: `系统状态`,
|
||||
},
|
||||
|
||||
itemType: {
|
||||
sql: `item_type`,
|
||||
type: `string`,
|
||||
title: `商品属性`,
|
||||
},
|
||||
|
||||
// 新增字段映射
|
||||
|
||||
priceType: {
|
||||
sql: `CASE WHEN other_price_1 = 1 THEN '预估价' WHEN other_price_1 = 2 THEN '定价' WHEN other_price_1 = 3 THEN '清货价' ELSE '未知' END`,
|
||||
type: `string`,
|
||||
title: `价格类型`,
|
||||
},
|
||||
|
||||
spu: {
|
||||
sql: `other_1`,
|
||||
type: `string`,
|
||||
title: `SPU`,
|
||||
},
|
||||
|
||||
warehouse: {
|
||||
sql: `other_3`,
|
||||
type: `string`,
|
||||
title: `发货仓库`,
|
||||
},
|
||||
|
||||
productState: {
|
||||
sql: `other_5`,
|
||||
type: `string`,
|
||||
title: `商品业务状态`,
|
||||
},
|
||||
|
||||
productNo: {
|
||||
sql: `other_7`,
|
||||
type: `string`,
|
||||
title: `货号`,
|
||||
},
|
||||
|
||||
drawingId: {
|
||||
sql: `other_9`,
|
||||
type: `string`,
|
||||
title: `图纸ID`,
|
||||
},
|
||||
|
||||
created: {
|
||||
sql: `created`,
|
||||
type: `time`,
|
||||
title: `创建时间`,
|
||||
},
|
||||
|
||||
modified: {
|
||||
sql: `modified`,
|
||||
type: `time`,
|
||||
title: `修改时间`,
|
||||
},
|
||||
},
|
||||
});
|
||||
116
backend/model/Shops.js
Normal file
116
backend/model/Shops.js
Normal file
@ -0,0 +1,116 @@
|
||||
// Cube.js 数据模型 - 店铺信息
|
||||
// 基于 fys_shopInfo 表
|
||||
|
||||
cube(`Shops`, {
|
||||
sql: `SELECT * FROM rpt.fys_shopInfo`,
|
||||
|
||||
measures: {
|
||||
count: {
|
||||
sql: `Id`,
|
||||
type: `count`,
|
||||
},
|
||||
|
||||
totalMargin: {
|
||||
sql: `shopMargin`,
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `总保证金`,
|
||||
},
|
||||
|
||||
totalServiceFee: {
|
||||
sql: `shopServiceFee`,
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `总服务费`,
|
||||
},
|
||||
|
||||
totalAmount: {
|
||||
sql: `totalAmount`,
|
||||
type: `sum`,
|
||||
format: `currency`,
|
||||
title: `收款合计`,
|
||||
},
|
||||
},
|
||||
|
||||
dimensions: {
|
||||
id: {
|
||||
sql: `Id`,
|
||||
type: `number`,
|
||||
primaryKey: true,
|
||||
},
|
||||
|
||||
shopName: {
|
||||
sql: `shopName`,
|
||||
type: `string`,
|
||||
title: `店铺名称`,
|
||||
},
|
||||
|
||||
erpShopName: {
|
||||
sql: `erpShopName`,
|
||||
type: `string`,
|
||||
title: `ERP店铺名称`,
|
||||
},
|
||||
|
||||
platform: {
|
||||
sql: `platform`,
|
||||
type: `string`,
|
||||
title: `平台`,
|
||||
},
|
||||
|
||||
responsibleDepartment: {
|
||||
sql: `responsibleDepartment`,
|
||||
type: `string`,
|
||||
title: `所属利润中心`,
|
||||
},
|
||||
|
||||
shopHead: {
|
||||
sql: `shopHead`,
|
||||
type: `string`,
|
||||
title: `店铺负责人`,
|
||||
},
|
||||
|
||||
shopType: {
|
||||
sql: `shopType`,
|
||||
type: `string`,
|
||||
title: `店铺类型`,
|
||||
},
|
||||
|
||||
mainCategory: {
|
||||
sql: `mainCategory`,
|
||||
type: `string`,
|
||||
title: `主营类目`,
|
||||
},
|
||||
|
||||
channelType: {
|
||||
sql: `channelType`,
|
||||
type: `string`,
|
||||
title: `渠道模式`,
|
||||
},
|
||||
|
||||
shopState: {
|
||||
sql: `CASE
|
||||
WHEN shopState = 0 THEN '正常'
|
||||
WHEN shopState = 1 THEN '开店中'
|
||||
WHEN shopState = 2 THEN '变更中'
|
||||
WHEN shopState = 3 THEN '已出售'
|
||||
WHEN shopState = 4 THEN '出售中'
|
||||
WHEN shopState = 5 THEN '关闭中'
|
||||
WHEN shopState = 6 THEN '已关闭'
|
||||
ELSE '未知' END`,
|
||||
type: `string`,
|
||||
title: `店铺状态`,
|
||||
},
|
||||
|
||||
openTime: {
|
||||
sql: `openTime`,
|
||||
type: `time`,
|
||||
title: `开店时间`,
|
||||
},
|
||||
|
||||
createTime: {
|
||||
sql: `createTime`,
|
||||
type: `time`,
|
||||
title: `创建时间`,
|
||||
},
|
||||
},
|
||||
});
|
||||
12756
backend/package-lock.json
generated
Normal file
12756
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
backend/package.json
Normal file
44
backend/package.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "data-dashboard-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "拼多多自动化运营平台后端服务",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"syntax": "node scripts/check-syntax.js",
|
||||
"test": "node --test test/*.test.js",
|
||||
"check": "npm run syntax && npm test",
|
||||
"alert:check": "node src/services/alertChecker.js",
|
||||
"product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.975.0",
|
||||
"@cubejs-backend/mssql-driver": "^0.35.81",
|
||||
"@cubejs-backend/mysql-driver": "^0.35.0",
|
||||
"@cubejs-backend/postgres-driver": "^0.35.0",
|
||||
"@cubejs-backend/server": "^0.35.0",
|
||||
"@cubejs-backend/server-core": "^0.35.0",
|
||||
"axios": "^1.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"casdoor-nodejs-sdk": "^1.5.0",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.19",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"ioredis": "^5.9.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mssql": "^12.2.0",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.6.5",
|
||||
"node-cron": "^3.0.3",
|
||||
"playwright": "^1.60.0",
|
||||
"sharp": "^0.33.5",
|
||||
"uuid": "^9.0.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
249
backend/scripts/analyze-tables.js
Normal file
249
backend/scripts/analyze-tables.js
Normal file
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 数据库表头分析脚本
|
||||
* 从每个表的每个字段提取3个示例值,用于分析字段实际含义
|
||||
*
|
||||
* 使用方式:
|
||||
* node analyze-tables.js --host <host> --port <port> --db <database> --user <user> --password <password>
|
||||
*
|
||||
* 示例:
|
||||
* node analyze-tables.js --host rm-xxx.sqlserver.rds.aliyuncs.com --port 1433 --db mydb --user admin --password 123456
|
||||
*/
|
||||
|
||||
const sql = require('mssql');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 解析命令行参数
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const config = {};
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
const key = args[i].replace('--', '');
|
||||
const value = args[i + 1];
|
||||
config[key] = value;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// 需要分析的表列表
|
||||
const TABLES = [
|
||||
{ name: 'jst_goods_profit_kpi_items_afs', description: '业绩退款相关信息' },
|
||||
{ name: 'jst_sku', description: '普通商品资料表' },
|
||||
{ name: 'fys_shopInfo', description: '店铺信息' },
|
||||
{ name: 'jst_order_express_details', description: '订单快递详情' },
|
||||
{ name: 'fys_SkuDataLibrary_SpuConfig', description: '产品资料SPU配置' },
|
||||
{ name: 'op_operManage', description: 'SPU负责人管理' },
|
||||
{ name: 'op_TargetManagement', description: '商品指标管理' },
|
||||
{ name: 'fys_Ticketing_install', description: '开票安装单' },
|
||||
{ name: 'fys_Ticketing_install_item', description: '安装明细表' },
|
||||
{ name: 'fys_jstFreightCost', description: '聚水潭运费' },
|
||||
{ name: 'fys_jstFreightCostDetail', description: '聚水潭运费明细' },
|
||||
{ name: 'pc_kfChatLog', description: '客服聊天记录' }
|
||||
];
|
||||
|
||||
async function getCloudDbConfig() {
|
||||
const args = parseArgs();
|
||||
|
||||
// 如果提供了命令行参数,使用命令行参数
|
||||
if (args.host && args.db && args.user && args.password) {
|
||||
return {
|
||||
server: args.host,
|
||||
port: parseInt(args.port || '1433'),
|
||||
database: args.db,
|
||||
user: args.user,
|
||||
password: args.password,
|
||||
options: {
|
||||
encrypt: args.ssl === 'true',
|
||||
trustServerCertificate: true,
|
||||
connectTimeout: 30000,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 否则从本地数据库获取配置
|
||||
const mysql = require('mysql2/promise');
|
||||
const localConfig = {
|
||||
host: args.localHost || 'localhost',
|
||||
port: parseInt(args.localPort || '3306'),
|
||||
user: args.localUser || 'root',
|
||||
password: args.localPassword || process.env.LOCAL_DB_PASSWORD || '',
|
||||
database: args.localDb || 'shujuku2_system'
|
||||
};
|
||||
|
||||
try {
|
||||
const connection = await mysql.createConnection(localConfig);
|
||||
const [rows] = await connection.execute(
|
||||
'SELECT driver, host, port, database_name, username, password, `ssl` FROM database_configs WHERE is_default = 1 LIMIT 1'
|
||||
);
|
||||
await connection.end();
|
||||
|
||||
if (rows.length > 0) {
|
||||
const config = rows[0];
|
||||
console.log(` 从本地数据库获取到配置: ${config.host}:${config.port}/${config.database_name}`);
|
||||
return {
|
||||
server: config.host,
|
||||
port: config.port || 1433,
|
||||
database: config.database_name,
|
||||
user: config.username,
|
||||
password: config.password,
|
||||
options: {
|
||||
encrypt: config.ssl === 1,
|
||||
trustServerCertificate: true,
|
||||
connectTimeout: 30000,
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new Error('未找到云数据库配置');
|
||||
} catch (error) {
|
||||
console.error('从本地数据库获取配置失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTableColumns(pool, tableName) {
|
||||
try {
|
||||
const result = await pool.request().query(`
|
||||
SELECT COLUMN_NAME, DATA_TYPE
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_NAME = '${tableName}'
|
||||
ORDER BY ORDINAL_POSITION
|
||||
`);
|
||||
return result.recordset;
|
||||
} catch (error) {
|
||||
console.error(`获取表 ${tableName} 的列信息失败:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function getSampleData(pool, tableName, columnName) {
|
||||
try {
|
||||
// 获取3个非空的示例值
|
||||
const result = await pool.request().query(`
|
||||
SELECT TOP 3 [${columnName}] as value
|
||||
FROM [${tableName}]
|
||||
WHERE [${columnName}] IS NOT NULL AND CAST([${columnName}] AS NVARCHAR(MAX)) != ''
|
||||
ORDER BY NEWID()
|
||||
`);
|
||||
return result.recordset.map(r => {
|
||||
const val = r.value;
|
||||
// 处理不同类型的值
|
||||
if (val instanceof Date) {
|
||||
return val.toISOString().replace('T', ' ').substring(0, 19);
|
||||
}
|
||||
if (typeof val === 'string' && val.length > 100) {
|
||||
return val.substring(0, 100) + '...';
|
||||
}
|
||||
return String(val);
|
||||
});
|
||||
} catch (error) {
|
||||
return [`[错误: ${error.message.substring(0, 50)}]`];
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeTable(pool, tableInfo) {
|
||||
console.log(`\n正在分析表: ${tableInfo.name} (${tableInfo.description})`);
|
||||
|
||||
const columns = await getTableColumns(pool, tableInfo.name);
|
||||
if (columns.length === 0) {
|
||||
return { ...tableInfo, columns: [], error: '无法获取列信息' };
|
||||
}
|
||||
|
||||
const analyzedColumns = [];
|
||||
for (const col of columns) {
|
||||
process.stdout.write(` - ${col.COLUMN_NAME}...`);
|
||||
const samples = await getSampleData(pool, tableInfo.name, col.COLUMN_NAME);
|
||||
analyzedColumns.push({
|
||||
name: col.COLUMN_NAME,
|
||||
type: col.DATA_TYPE,
|
||||
samples
|
||||
});
|
||||
console.log(' 完成');
|
||||
}
|
||||
|
||||
return {
|
||||
...tableInfo,
|
||||
columns: analyzedColumns
|
||||
};
|
||||
}
|
||||
|
||||
function generateMarkdown(results) {
|
||||
let md = `# 云数据库表头分析报告
|
||||
|
||||
> 生成时间: ${new Date().toLocaleString('zh-CN')}
|
||||
>
|
||||
> 本文档通过提取每个字段的3个示例值来帮助理解字段的实际含义
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
for (const table of results) {
|
||||
md += `## ${table.name}\n\n`;
|
||||
md += `**描述**: ${table.description}\n\n`;
|
||||
|
||||
if (table.error) {
|
||||
md += `**错误**: ${table.error}\n\n`;
|
||||
continue;
|
||||
}
|
||||
|
||||
md += `| 序号 | 字段名 | 数据类型 | 示例值1 | 示例值2 | 示例值3 | 推测含义 |\n`;
|
||||
md += `|------|--------|----------|---------|---------|---------|----------|\n`;
|
||||
|
||||
table.columns.forEach((col, idx) => {
|
||||
const s1 = col.samples[0] || '-';
|
||||
const s2 = col.samples[1] || '-';
|
||||
const s3 = col.samples[2] || '-';
|
||||
// 转义管道符号
|
||||
const escape = (str) => String(str).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
||||
md += `| ${idx + 1} | ${col.name} | ${col.type} | ${escape(s1)} | ${escape(s2)} | ${escape(s3)} | |\n`;
|
||||
});
|
||||
|
||||
md += `\n---\n\n`;
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== 云数据库表头分析工具 ===\n');
|
||||
|
||||
let pool;
|
||||
try {
|
||||
// 获取云数据库配置
|
||||
console.log('1. 获取云数据库配置...');
|
||||
const config = await getCloudDbConfig();
|
||||
console.log(` 连接到: ${config.server}:${config.port}/${config.database}`);
|
||||
|
||||
// 连接云数据库
|
||||
console.log('\n2. 连接云数据库...');
|
||||
pool = await sql.connect(config);
|
||||
console.log(' 连接成功!');
|
||||
|
||||
// 分析每个表
|
||||
console.log('\n3. 开始分析表...');
|
||||
const results = [];
|
||||
for (const tableInfo of TABLES) {
|
||||
const result = await analyzeTable(pool, tableInfo);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// 生成Markdown报告
|
||||
console.log('\n4. 生成分析报告...');
|
||||
const markdown = generateMarkdown(results);
|
||||
const outputPath = path.join(__dirname, '../../docs/云数据库表头分析.md');
|
||||
fs.writeFileSync(outputPath, markdown, 'utf8');
|
||||
console.log(` 报告已保存到: ${outputPath}`);
|
||||
|
||||
console.log('\n=== 分析完成 ===');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n错误:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (pool) {
|
||||
await pool.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
64
backend/scripts/backfillLogisticsDamageHistory.js
Normal file
64
backend/scripts/backfillLogisticsDamageHistory.js
Normal file
@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
const database = require('../src/config/database');
|
||||
const cloudDb = require('../src/config/cloudDatabase');
|
||||
const redis = require('../src/config/redis');
|
||||
const { refreshCourier, refreshAllCache } = require('../src/jobs/dailyRefresh');
|
||||
|
||||
function isDateString(value) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(value) && dayjs(value).format('YYYY-MM-DD') === value;
|
||||
}
|
||||
|
||||
async function backfillRange(startDate, endDate) {
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate);
|
||||
const totalDays = end.diff(start, 'day') + 1;
|
||||
|
||||
if (totalDays <= 0) {
|
||||
throw new Error(`Invalid date range: ${startDate} -> ${endDate}`);
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < totalDays; offset += 1) {
|
||||
const statDate = start.add(offset, 'day').format('YYYY-MM-DD');
|
||||
const dayStart = Date.now();
|
||||
console.log(`[logistics-damage-backfill] start ${statDate} (${offset + 1}/${totalDays})`);
|
||||
await refreshCourier(statDate);
|
||||
console.log(`[logistics-damage-backfill] done ${statDate} elapsed=${Date.now() - dayStart}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [startDate, endDate = startDate] = process.argv.slice(2);
|
||||
|
||||
if (!isDateString(startDate) || !isDateString(endDate)) {
|
||||
throw new Error('Usage: node backend/scripts/backfillLogisticsDamageHistory.js <YYYY-MM-DD> [YYYY-MM-DD]');
|
||||
}
|
||||
|
||||
console.log(`[logistics-damage-backfill] range ${startDate} -> ${endDate}`);
|
||||
|
||||
try {
|
||||
await backfillRange(startDate, endDate);
|
||||
try {
|
||||
await refreshAllCache();
|
||||
console.log('[logistics-damage-backfill] cache refresh done');
|
||||
} catch (error) {
|
||||
console.warn(`[logistics-damage-backfill] cache refresh skipped: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
cloudDb.close(),
|
||||
database.close(),
|
||||
redis.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[logistics-damage-backfill] failed');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
671
backend/scripts/backfillPickupHistory.js
Normal file
671
backend/scripts/backfillPickupHistory.js
Normal file
@ -0,0 +1,671 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
const database = require('../src/config/database');
|
||||
const cloudDb = require('../src/config/cloudDatabase');
|
||||
const redis = require('../src/config/redis');
|
||||
const { refreshAllCache } = require('../src/jobs/dailyRefresh');
|
||||
|
||||
const localDb = database.local;
|
||||
|
||||
const PICKUP_EXCLUDED_LABEL_KEYWORDS = [
|
||||
'\u914d\u4ef6',
|
||||
'\u7ecf\u7ba1\u5927\u5355',
|
||||
'\u4e0d\u53d1',
|
||||
'\u7b49\u901a\u77e5',
|
||||
'\u5ef6\u671f',
|
||||
'\u5ef6\u8fdf',
|
||||
'\u6b66\u8fdb\u5b89\u80fd',
|
||||
'\u4e2d\u94c1',
|
||||
'\u7269\u6d41',
|
||||
'\u81ea\u63d0',
|
||||
'\u5916\u8d38',
|
||||
'\u91c7\u8d2d\u5355',
|
||||
'\u5907\u8d27',
|
||||
'\u5b9a\u5236',
|
||||
'\u4e2d\u8f6c\u96c6\u8fd0',
|
||||
'\u4e2d\u8f6c\u8ba2\u5355',
|
||||
'\u65b0\u7586\u897f\u85cf\u5355',
|
||||
'ECLP\u8ba2\u5355',
|
||||
];
|
||||
|
||||
const PICKUP_EXCLUDED_COURIER_KEYWORDS = [
|
||||
'\u6b66\u8fdb\u5b89\u80fd',
|
||||
'\u4e2d\u94c1',
|
||||
'\u7269\u6d41',
|
||||
'\u81ea\u63d0',
|
||||
'\u7ebf\u4e0b\u5feb\u9012',
|
||||
'\u4e13\u7ebf',
|
||||
];
|
||||
|
||||
const PICKUP_EXCLUDED_REMARK_KEYWORDS = [
|
||||
'bbff',
|
||||
'ccdd',
|
||||
'\u8f6cX00006',
|
||||
];
|
||||
|
||||
const PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS = [
|
||||
'\u4eac\u4e1c\u5feb\u8fd0',
|
||||
'\u4eac\u4e1c\u81ea\u8425',
|
||||
'\u987a\u4e30',
|
||||
];
|
||||
|
||||
const PICKUP_TIMELY_LABEL_KEYWORDS = [
|
||||
'CLPS\u8ba2\u5355',
|
||||
'ECLP\u8ba2\u5355',
|
||||
];
|
||||
|
||||
const UNKNOWN_WAREHOUSE = '\u672a\u77e5\u4ed3\u5e93';
|
||||
const UNKNOWN_SUPPLIER = '\u672a\u77e5\u4f9b\u5e94\u5546';
|
||||
const UNKNOWN_PROVINCE = '\u672a\u77e5\u7701\u4efd';
|
||||
const UNKNOWN_COURIER = '\u672a\u77e5\u5feb\u9012';
|
||||
const NO_AFTERSALE = '\u6682\u65e0\u552e\u540e';
|
||||
const NO_REFUND_REQUEST = '\u672a\u7533\u8bf7\u9000\u6b3e';
|
||||
|
||||
const q = (value) => `N'${String(value).replace(/'/g, "''")}'`;
|
||||
|
||||
const buildSqlLikeAny = (column, keywords) => keywords
|
||||
.map((keyword) => `${column} LIKE ${q(`%${keyword}%`)}`)
|
||||
.join(' OR ');
|
||||
|
||||
const excludedLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_EXCLUDED_LABEL_KEYWORDS);
|
||||
const excludedCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_EXCLUDED_COURIER_KEYWORDS);
|
||||
const excludedRemarkSql = buildSqlLikeAny(
|
||||
`ISNULL(src.buyer_message, N'') + N' ' + ISNULL(src.remark, N'') + N' ' + ISNULL(src.remark_content, N'')`,
|
||||
PICKUP_EXCLUDED_REMARK_KEYWORDS
|
||||
);
|
||||
const timelyNoFetchCourierSql = buildSqlLikeAny(`ISNULL(src.courier_name, N'')`, PICKUP_TIMELY_NO_FETCH_COURIER_KEYWORDS);
|
||||
const timelyLabelSql = buildSqlLikeAny(`ISNULL(src.order_labels, N'')`, PICKUP_TIMELY_LABEL_KEYWORDS);
|
||||
|
||||
const BACKFILL_PICKUP_SQL = `
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT
|
||||
NULLIF(LTRIM(RTRIM(undefined1)), '') AS inner_order_id,
|
||||
NULLIF(LTRIM(RTRIM(undefined2)), '') AS order_id,
|
||||
NULLIF(LTRIM(RTRIM(undefined10)), '') AS sku_id,
|
||||
TRY_CONVERT(datetime, REPLACE(undefined5, '/', '-')) AS pay_time,
|
||||
TRY_CONVERT(datetime, REPLACE(undefined6, '/', '-')) AS afs_send_time,
|
||||
NULLIF(LTRIM(RTRIM(undefined8)), '') AS shop_name,
|
||||
NULLIF(LTRIM(RTRIM(undefined62)), '') AS order_labels,
|
||||
NULLIF(LTRIM(RTRIM(undefined72)), '') AS express_no,
|
||||
NULLIF(LTRIM(RTRIM(undefined73)), '') AS afs_courier_name,
|
||||
NULLIF(LTRIM(RTRIM(undefined74)), '') AS province,
|
||||
NULLIF(LTRIM(RTRIM(undefined75)), '') AS city,
|
||||
NULLIF(LTRIM(RTRIM(undefined66)), '') AS afs_warehouse_name,
|
||||
NULLIF(LTRIM(RTRIM(undefined32)), '') AS order_type,
|
||||
TRY_CONVERT(datetime, REPLACE(undefined33, '/', '-')) AS aftersale_apply_time
|
||||
INTO #afs_raw
|
||||
FROM dbo.jst_goods_profit_kpi_items_afs
|
||||
WHERE undefined5 IS NOT NULL
|
||||
AND undefined5 LIKE @payDatePrefix;
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_afs_raw_dedup ON #afs_raw(inner_order_id, sku_id);
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY inner_order_id, sku_id
|
||||
ORDER BY
|
||||
CASE WHEN express_no IS NULL OR express_no = '' THEN 1 ELSE 0 END,
|
||||
CASE WHEN afs_send_time IS NULL THEN 1 ELSE 0 END,
|
||||
afs_send_time DESC,
|
||||
pay_time DESC
|
||||
) AS rn
|
||||
FROM #afs_raw
|
||||
WHERE inner_order_id IS NOT NULL
|
||||
AND inner_order_id <> ''
|
||||
AND sku_id IS NOT NULL
|
||||
AND sku_id <> ''
|
||||
)
|
||||
SELECT
|
||||
inner_order_id,
|
||||
order_id,
|
||||
sku_id,
|
||||
pay_time,
|
||||
afs_send_time,
|
||||
shop_name,
|
||||
order_labels,
|
||||
express_no,
|
||||
afs_courier_name,
|
||||
province,
|
||||
city,
|
||||
afs_warehouse_name,
|
||||
order_type,
|
||||
aftersale_apply_time
|
||||
INTO #afs_dedup
|
||||
FROM ranked
|
||||
WHERE rn = 1;
|
||||
|
||||
CREATE NONCLUSTERED INDEX IX_afs_dedup_order_id ON #afs_dedup(order_id);
|
||||
CREATE NONCLUSTERED INDEX IX_afs_dedup_sku_id ON #afs_dedup(sku_id);
|
||||
|
||||
SELECT DISTINCT express_no
|
||||
INTO #express_keys
|
||||
FROM #afs_dedup
|
||||
WHERE express_no IS NOT NULL
|
||||
AND express_no <> '';
|
||||
|
||||
SELECT DISTINCT order_id
|
||||
INTO #order_keys
|
||||
FROM #afs_dedup
|
||||
WHERE order_id IS NOT NULL
|
||||
AND order_id <> '';
|
||||
|
||||
CREATE UNIQUE CLUSTERED INDEX IX_order_keys ON #order_keys(order_id);
|
||||
|
||||
SELECT DISTINCT sku_id
|
||||
INTO #sku_keys
|
||||
FROM #afs_dedup
|
||||
WHERE sku_id IS NOT NULL
|
||||
AND sku_id <> '';
|
||||
|
||||
CREATE UNIQUE CLUSTERED INDEX IX_sku_keys ON #sku_keys(sku_id);
|
||||
|
||||
SELECT
|
||||
NULLIF(LTRIM(RTRIM(e.logistic_id)), '') AS logistic_id,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.so_id)), '')) AS so_id,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name,
|
||||
MIN(e.send_time) AS send_time,
|
||||
MIN(e.fetch_time) AS fetch_time,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address
|
||||
INTO #express_by_logistic
|
||||
FROM dbo.jst_order_express_details e
|
||||
INNER JOIN #express_keys k
|
||||
ON k.express_no = NULLIF(LTRIM(RTRIM(e.logistic_id)), '')
|
||||
GROUP BY NULLIF(LTRIM(RTRIM(e.logistic_id)), '');
|
||||
|
||||
CREATE UNIQUE CLUSTERED INDEX IX_express_by_logistic ON #express_by_logistic(logistic_id);
|
||||
|
||||
SELECT
|
||||
NULLIF(LTRIM(RTRIM(e.so_id)), '') AS so_id,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.logistic_id)), '')) AS logistic_id,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.logistic_company_name)), '')) AS logistic_company_name,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.order_status_name)), '')) AS order_status_name,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.aftersale_status_name)), '')) AS aftersale_status_name,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.remark)), '')) AS remark,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.buyer_message)), '')) AS buyer_message,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.remark_content)), '')) AS remark_content,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.shop_name)), '')) AS shop_name,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.warehouse_name)), '')) AS warehouse_name,
|
||||
MIN(e.send_time) AS send_time,
|
||||
MIN(e.fetch_time) AS fetch_time,
|
||||
MAX(NULLIF(LTRIM(RTRIM(e.receiver_address)), '')) AS receiver_address
|
||||
INTO #express_by_order
|
||||
FROM dbo.jst_order_express_details e
|
||||
INNER JOIN #order_keys k
|
||||
ON k.order_id = NULLIF(LTRIM(RTRIM(e.so_id)), '')
|
||||
GROUP BY NULLIF(LTRIM(RTRIM(e.so_id)), '');
|
||||
|
||||
CREATE UNIQUE CLUSTERED INDEX IX_express_by_order ON #express_by_order(so_id);
|
||||
|
||||
SELECT
|
||||
NULLIF(LTRIM(RTRIM(s.sku_id)), '') AS sku_id,
|
||||
MAX(NULLIF(LTRIM(RTRIM(s.supplier_name)), '')) AS supplier_name
|
||||
INTO #sku_supplier
|
||||
FROM dbo.jst_sku s
|
||||
INNER JOIN #sku_keys k
|
||||
ON k.sku_id = NULLIF(LTRIM(RTRIM(s.sku_id)), '')
|
||||
GROUP BY NULLIF(LTRIM(RTRIM(s.sku_id)), '');
|
||||
|
||||
CREATE UNIQUE CLUSTERED INDEX IX_sku_supplier ON #sku_supplier(sku_id);
|
||||
|
||||
SELECT
|
||||
d.inner_order_id,
|
||||
d.order_id,
|
||||
d.sku_id,
|
||||
d.pay_time,
|
||||
COALESCE(el.send_time, eo.send_time, d.afs_send_time) AS send_time,
|
||||
COALESCE(el.fetch_time, eo.fetch_time) AS fetch_time,
|
||||
COALESCE(el.shop_name, eo.shop_name, d.shop_name) AS shop_name,
|
||||
COALESCE(el.warehouse_name, eo.warehouse_name, d.afs_warehouse_name) AS warehouse_name,
|
||||
COALESCE(el.logistic_company_name, eo.logistic_company_name, d.afs_courier_name) AS courier_name,
|
||||
COALESCE(d.express_no, el.logistic_id, eo.logistic_id, N'') AS express_no,
|
||||
COALESCE(el.order_status_name, eo.order_status_name, N'') AS order_status_name,
|
||||
COALESCE(el.aftersale_status_name, eo.aftersale_status_name, N'') AS aftersale_status_name,
|
||||
COALESCE(el.remark, eo.remark, N'') AS remark,
|
||||
COALESCE(el.buyer_message, eo.buyer_message, N'') AS buyer_message,
|
||||
COALESCE(el.remark_content, eo.remark_content, N'') AS remark_content,
|
||||
COALESCE(d.order_labels, N'') AS order_labels,
|
||||
COALESCE(d.province, N'') AS province,
|
||||
COALESCE(d.city, N'') AS city,
|
||||
COALESCE(el.receiver_address, eo.receiver_address, N'') AS receiver_address,
|
||||
COALESCE(d.order_type, N'') AS order_type,
|
||||
d.aftersale_apply_time,
|
||||
COALESCE(s.supplier_name, N'') AS supplier_name,
|
||||
COALESCE(shop_meta.responsible_department, N'') AS shop_responsible_department
|
||||
INTO #pickup_enriched
|
||||
FROM #afs_dedup d
|
||||
LEFT JOIN #express_by_logistic el ON d.express_no = el.logistic_id
|
||||
LEFT JOIN #express_by_order eo ON d.order_id = eo.so_id
|
||||
LEFT JOIN #sku_supplier s ON d.sku_id = s.sku_id
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 NULLIF(LTRIM(RTRIM(responsibleDepartment)), N'') AS responsible_department
|
||||
FROM dbo.fys_shopInfo
|
||||
WHERE NULLIF(LTRIM(RTRIM(erpShopName)), N'') = COALESCE(el.shop_name, eo.shop_name, d.shop_name)
|
||||
) shop_meta;
|
||||
|
||||
SELECT
|
||||
src.*,
|
||||
CASE
|
||||
WHEN (${excludedLabelSql}) THEN 1
|
||||
WHEN ISNULL(src.shop_responsible_department, N'') LIKE N'%灞变笢椤圭洰缁?%' THEN 1
|
||||
WHEN src.supplier_name IN (${q('\u865a\u62df')}, ${q('\u666f\u74f4\u5341\u65b9')}) THEN 1
|
||||
WHEN src.order_status_name IN (${q('\u88ab\u62c6\u5206')}, ${q('\u88ab\u5408\u5e76')}) THEN 1
|
||||
WHEN (${excludedCourierSql}) THEN 1
|
||||
WHEN ISNULL(src.express_no, N'') LIKE ${q('%F800%')} THEN 1
|
||||
WHEN (${excludedRemarkSql}) THEN 1
|
||||
WHEN ISNULL(src.shop_name, N'') LIKE ${q('%\u4eac\u559c%')}
|
||||
AND ISNULL(src.aftersale_status_name, ${q(NO_AFTERSALE)}) <> ${q(NO_AFTERSALE)} THEN 1
|
||||
WHEN ISNULL(src.shop_name, N'') LIKE ${q('%\u6837\u54c1\u4e13\u7528\u5e97\u94fa%')} THEN 1
|
||||
WHEN ISNULL(src.order_status_name, N'') LIKE ${q('%\u53d6\u6d88%')}
|
||||
AND src.send_time IS NULL
|
||||
AND ISNULL(src.aftersale_status_name, ${q(NO_AFTERSALE)}) IN (N'', ${q(NO_AFTERSALE)}, ${q(NO_REFUND_REQUEST)}) THEN 1
|
||||
WHEN src.aftersale_apply_time IS NOT NULL
|
||||
AND src.pay_time IS NOT NULL
|
||||
AND src.aftersale_apply_time >= src.pay_time
|
||||
AND DATEDIFF(SECOND, src.pay_time, src.aftersale_apply_time) <= 24 * 3600 THEN 1
|
||||
WHEN ISNULL(src.order_type, N'') LIKE ${q('%\u9884\u552e%')}
|
||||
OR ISNULL(src.order_status_name, N'') LIKE ${q('%\u9884\u552e%')} THEN 1
|
||||
ELSE 0
|
||||
END AS is_excluded,
|
||||
CASE
|
||||
WHEN src.pay_time IS NULL THEN 1
|
||||
WHEN ISNULL(src.shop_name, N'') LIKE ${q('%\u4eac\u559c%')}
|
||||
AND src.send_time IS NOT NULL
|
||||
AND src.send_time >= src.pay_time
|
||||
AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0
|
||||
WHEN (${timelyNoFetchCourierSql})
|
||||
AND src.fetch_time IS NULL
|
||||
AND src.send_time IS NOT NULL
|
||||
AND src.send_time >= src.pay_time
|
||||
AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0
|
||||
WHEN (${timelyLabelSql})
|
||||
AND src.send_time IS NOT NULL
|
||||
AND src.send_time >= src.pay_time
|
||||
AND DATEDIFF(SECOND, src.pay_time, src.send_time) <= 24 * 3600 THEN 0
|
||||
WHEN src.fetch_time IS NOT NULL
|
||||
AND src.fetch_time >= src.pay_time
|
||||
AND DATEDIFF(SECOND, src.pay_time, src.fetch_time) <= 24 * 3600 THEN 0
|
||||
ELSE 1
|
||||
END AS is_timeout,
|
||||
CASE
|
||||
WHEN src.fetch_time IS NOT NULL
|
||||
AND src.pay_time IS NOT NULL
|
||||
AND src.fetch_time >= src.pay_time
|
||||
THEN CAST(DATEDIFF(SECOND, src.pay_time, src.fetch_time) AS DECIMAL(20, 2)) / 3600.0
|
||||
ELSE NULL
|
||||
END AS pickup_hours,
|
||||
CASE
|
||||
WHEN src.receiver_address LIKE ${q('%-%-%')}
|
||||
THEN REVERSE(LEFT(REVERSE(src.receiver_address), CHARINDEX('-', REVERSE(src.receiver_address)) - 1))
|
||||
ELSE N''
|
||||
END AS district
|
||||
INTO #pickup_scored
|
||||
FROM #pickup_enriched src;
|
||||
|
||||
SELECT *
|
||||
INTO #pickup_valid
|
||||
FROM #pickup_scored
|
||||
WHERE is_excluded = 0;
|
||||
|
||||
SELECT
|
||||
COUNT_BIG(*) AS total_count,
|
||||
SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count,
|
||||
CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours
|
||||
FROM #pickup_valid;
|
||||
|
||||
SELECT
|
||||
ISNULL(NULLIF(warehouse_name, N''), ${q(UNKNOWN_WAREHOUSE)}) AS warehouse_name,
|
||||
COUNT_BIG(*) AS total_count,
|
||||
SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count,
|
||||
CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours
|
||||
FROM #pickup_valid
|
||||
GROUP BY ISNULL(NULLIF(warehouse_name, N''), ${q(UNKNOWN_WAREHOUSE)});
|
||||
|
||||
SELECT
|
||||
ISNULL(NULLIF(supplier_name, N''), ${q(UNKNOWN_SUPPLIER)}) AS supplier_name,
|
||||
COUNT_BIG(*) AS total_count,
|
||||
SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count,
|
||||
CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours
|
||||
FROM #pickup_valid
|
||||
GROUP BY ISNULL(NULLIF(supplier_name, N''), ${q(UNKNOWN_SUPPLIER)});
|
||||
|
||||
SELECT
|
||||
ISNULL(NULLIF(province, N''), ${q(UNKNOWN_PROVINCE)}) AS province,
|
||||
COUNT_BIG(*) AS total_count,
|
||||
SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count,
|
||||
CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours
|
||||
FROM #pickup_valid
|
||||
GROUP BY ISNULL(NULLIF(province, N''), ${q(UNKNOWN_PROVINCE)});
|
||||
|
||||
SELECT
|
||||
ISNULL(NULLIF(courier_name, N''), ${q(UNKNOWN_COURIER)}) AS courier_name,
|
||||
COUNT_BIG(*) AS total_count,
|
||||
SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count,
|
||||
CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours
|
||||
FROM #pickup_valid
|
||||
GROUP BY ISNULL(NULLIF(courier_name, N''), ${q(UNKNOWN_COURIER)});
|
||||
|
||||
SELECT
|
||||
ISNULL(NULLIF(province, N''), ${q(UNKNOWN_PROVINCE)}) AS province,
|
||||
ISNULL(NULLIF(city, N''), N'') AS city,
|
||||
ISNULL(NULLIF(district, N''), N'') AS district,
|
||||
ISNULL(NULLIF(courier_name, N''), ${q(UNKNOWN_COURIER)}) AS courier_name,
|
||||
COUNT_BIG(*) AS total_count,
|
||||
SUM(CASE WHEN is_timeout = 1 THEN 1 ELSE 0 END) AS timeout_count,
|
||||
CAST(ROUND(AVG(pickup_hours), 2) AS DECIMAL(10, 2)) AS avg_hours
|
||||
FROM #pickup_valid
|
||||
GROUP BY
|
||||
ISNULL(NULLIF(province, N''), ${q(UNKNOWN_PROVINCE)}),
|
||||
ISNULL(NULLIF(city, N''), N''),
|
||||
ISNULL(NULLIF(district, N''), N''),
|
||||
ISNULL(NULLIF(courier_name, N''), ${q(UNKNOWN_COURIER)});
|
||||
`;
|
||||
|
||||
const TABLE_DELETES = [
|
||||
'summary_daily_pickup_region_courier',
|
||||
'summary_daily_pickup_courier',
|
||||
'summary_daily_pickup_region',
|
||||
'summary_daily_pickup_supplier',
|
||||
'summary_daily_pickup_warehouse',
|
||||
'summary_daily_pickup',
|
||||
];
|
||||
|
||||
const toInt = (value) => Number.parseInt(value, 10) || 0;
|
||||
const toFloat = (value) => Number.parseFloat(value) || 0;
|
||||
|
||||
const calcPickupRate = (totalCount, timeoutCount) => (
|
||||
totalCount > 0 ? Number((((totalCount - timeoutCount) / totalCount) * 100).toFixed(2)) : 0
|
||||
);
|
||||
|
||||
const TRANSIENT_LOCAL_WRITE_ERROR_CODES = new Set([
|
||||
'ETIMEDOUT',
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'EPIPE',
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
||||
'PROTOCOL_ENQUEUE_AFTER_QUIT',
|
||||
'PROTOCOL_PACKETS_OUT_OF_ORDER',
|
||||
]);
|
||||
|
||||
const isDateString = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value) && dayjs(value).format('YYYY-MM-DD') === value;
|
||||
|
||||
const buildPayDatePrefix = (statDate) => `${dayjs(statDate).subtract(1, 'day').format('YYYY/M/D')}%`;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const isTransientLocalWriteError = (error) => {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = String(error.message || '').toLowerCase();
|
||||
const code = String(error.code || '');
|
||||
|
||||
return (
|
||||
TRANSIENT_LOCAL_WRITE_ERROR_CODES.has(code) ||
|
||||
message.includes('timed out') ||
|
||||
message.includes('closed state') ||
|
||||
message.includes('connection lost') ||
|
||||
message.includes('socket hang up')
|
||||
);
|
||||
};
|
||||
|
||||
const chunkRows = (rows, size) => {
|
||||
const chunks = [];
|
||||
for (let index = 0; index < rows.length; index += size) {
|
||||
chunks.push(rows.slice(index, index + size));
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
const buildInsertSql = (tableName, columns, rowCount) => {
|
||||
const valuePlaceholder = `(${columns.map(() => '?').join(', ')})`;
|
||||
const placeholders = new Array(rowCount).fill(valuePlaceholder).join(', ');
|
||||
const quotedColumns = columns.map((column) => `\`${column}\``).join(', ');
|
||||
return `INSERT INTO ${tableName} (${quotedColumns}) VALUES ${placeholders}`;
|
||||
};
|
||||
|
||||
const executeInsertChunks = async (connection, tableName, columns, rows, chunkSize = 500) => {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const chunk of chunkRows(rows, chunkSize)) {
|
||||
const sql = buildInsertSql(tableName, columns, chunk.length);
|
||||
const params = chunk.flat();
|
||||
await connection.query(sql, params);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeOverallRow = (statDate, row = {}) => {
|
||||
const totalCount = toInt(row.total_count);
|
||||
const timeoutCount = toInt(row.timeout_count);
|
||||
|
||||
if (totalCount === 0 && timeoutCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
statDate,
|
||||
totalCount,
|
||||
timeoutCount,
|
||||
calcPickupRate(totalCount, timeoutCount),
|
||||
toFloat(row.avg_hours),
|
||||
];
|
||||
};
|
||||
|
||||
const normalizeNamedRows = (statDate, rows, nameField) => rows
|
||||
.map((row) => {
|
||||
const totalCount = toInt(row.total_count);
|
||||
const timeoutCount = toInt(row.timeout_count);
|
||||
return [
|
||||
statDate,
|
||||
row[nameField] || '',
|
||||
totalCount,
|
||||
timeoutCount,
|
||||
calcPickupRate(totalCount, timeoutCount),
|
||||
toFloat(row.avg_hours),
|
||||
];
|
||||
});
|
||||
|
||||
const normalizeRegionCourierRows = (statDate, rows) => rows
|
||||
.map((row) => {
|
||||
const totalCount = toInt(row.total_count);
|
||||
const timeoutCount = toInt(row.timeout_count);
|
||||
return [
|
||||
statDate,
|
||||
row.province || UNKNOWN_PROVINCE,
|
||||
row.city || '',
|
||||
row.district || '',
|
||||
row.courier_name || UNKNOWN_COURIER,
|
||||
totalCount,
|
||||
timeoutCount,
|
||||
calcPickupRate(totalCount, timeoutCount),
|
||||
toFloat(row.avg_hours),
|
||||
];
|
||||
});
|
||||
|
||||
const fetchPickupRecordsets = async (statDate) => {
|
||||
const pool = await cloudDb.getPool();
|
||||
const request = pool.request();
|
||||
request.input('payDatePrefix', buildPayDatePrefix(statDate));
|
||||
const result = await request.query(BACKFILL_PICKUP_SQL);
|
||||
return result.recordsets;
|
||||
};
|
||||
|
||||
const replacePickupSummaryForDateOnce = async (statDate, recordsets) => {
|
||||
const [
|
||||
overallRows = [],
|
||||
warehouseRows = [],
|
||||
supplierRows = [],
|
||||
regionRows = [],
|
||||
courierRows = [],
|
||||
regionCourierRows = [],
|
||||
] = recordsets;
|
||||
|
||||
const overallRow = normalizeOverallRow(statDate, overallRows[0]);
|
||||
const warehouseInsertRows = normalizeNamedRows(statDate, warehouseRows, 'warehouse_name');
|
||||
const supplierInsertRows = normalizeNamedRows(statDate, supplierRows, 'supplier_name');
|
||||
const regionInsertRows = normalizeNamedRows(statDate, regionRows, 'province');
|
||||
const courierInsertRows = normalizeNamedRows(statDate, courierRows, 'courier_name');
|
||||
const regionCourierInsertRows = normalizeRegionCourierRows(statDate, regionCourierRows);
|
||||
|
||||
const connection = await localDb.pool.getConnection();
|
||||
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
for (const tableName of TABLE_DELETES) {
|
||||
await connection.query(`DELETE FROM ${tableName} WHERE stat_date = ?`, [statDate]);
|
||||
}
|
||||
|
||||
if (overallRow) {
|
||||
await executeInsertChunks(
|
||||
connection,
|
||||
'summary_daily_pickup',
|
||||
['stat_date', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'],
|
||||
[overallRow],
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
await executeInsertChunks(
|
||||
connection,
|
||||
'summary_daily_pickup_warehouse',
|
||||
['stat_date', 'warehouse_name', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'],
|
||||
warehouseInsertRows
|
||||
);
|
||||
|
||||
await executeInsertChunks(
|
||||
connection,
|
||||
'summary_daily_pickup_supplier',
|
||||
['stat_date', 'supplier_name', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'],
|
||||
supplierInsertRows
|
||||
);
|
||||
|
||||
await executeInsertChunks(
|
||||
connection,
|
||||
'summary_daily_pickup_region',
|
||||
['stat_date', 'province', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'],
|
||||
regionInsertRows
|
||||
);
|
||||
|
||||
await executeInsertChunks(
|
||||
connection,
|
||||
'summary_daily_pickup_courier',
|
||||
['stat_date', 'courier_name', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'],
|
||||
courierInsertRows
|
||||
);
|
||||
|
||||
await executeInsertChunks(
|
||||
connection,
|
||||
'summary_daily_pickup_region_courier',
|
||||
['stat_date', 'province', 'city', 'district', 'courier_name', 'total_order_count', 'timeout_count', 'pickup_rate', 'avg_pickup_hours'],
|
||||
regionCourierInsertRows
|
||||
);
|
||||
|
||||
await connection.commit();
|
||||
|
||||
console.log(
|
||||
`[pickup-backfill] ${statDate} total=${overallRow ? overallRow[1] : 0} timeout=${overallRow ? overallRow[2] : 0} `
|
||||
+ `warehouse=${warehouseInsertRows.length} supplier=${supplierInsertRows.length} `
|
||||
+ `region=${regionInsertRows.length} courier=${courierInsertRows.length} regionCourier=${regionCourierInsertRows.length}`
|
||||
);
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (rollbackError) {
|
||||
console.warn(`[pickup-backfill] rollback failed for ${statDate}: ${rollbackError.message}`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
|
||||
const replacePickupSummaryForDate = async (statDate, recordsets) => {
|
||||
const maxAttempts = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
await replacePickupSummaryForDateOnce(statDate, recordsets);
|
||||
return;
|
||||
} catch (error) {
|
||||
const shouldRetry = attempt < maxAttempts && isTransientLocalWriteError(error);
|
||||
|
||||
if (!shouldRetry) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[pickup-backfill] local write retry for ${statDate} (${attempt}/${maxAttempts}) due to: ${error.message}`
|
||||
);
|
||||
await sleep(attempt * 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const backfillRange = async (startDate, endDate) => {
|
||||
const start = dayjs(startDate);
|
||||
const end = dayjs(endDate);
|
||||
const totalDays = end.diff(start, 'day') + 1;
|
||||
|
||||
if (totalDays <= 0) {
|
||||
throw new Error(`Invalid date range: ${startDate} -> ${endDate}`);
|
||||
}
|
||||
|
||||
for (let offset = 0; offset < totalDays; offset += 1) {
|
||||
const statDate = start.add(offset, 'day').format('YYYY-MM-DD');
|
||||
const dayStart = Date.now();
|
||||
console.log(`[pickup-backfill] start ${statDate} (${offset + 1}/${totalDays}) payPrefix=${buildPayDatePrefix(statDate)}`);
|
||||
const recordsets = await fetchPickupRecordsets(statDate);
|
||||
await replacePickupSummaryForDate(statDate, recordsets);
|
||||
console.log(`[pickup-backfill] done ${statDate} elapsed=${Date.now() - dayStart}ms`);
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const [startDate, endDate = startDate] = process.argv.slice(2);
|
||||
|
||||
if (!isDateString(startDate) || !isDateString(endDate)) {
|
||||
throw new Error('Usage: node backend/scripts/backfillPickupHistory.js <YYYY-MM-DD> [YYYY-MM-DD]');
|
||||
}
|
||||
|
||||
console.log(`[pickup-backfill] range ${startDate} -> ${endDate}`);
|
||||
|
||||
try {
|
||||
await backfillRange(startDate, endDate);
|
||||
try {
|
||||
await refreshAllCache();
|
||||
console.log('[pickup-backfill] cache refresh done');
|
||||
} catch (error) {
|
||||
console.warn(`[pickup-backfill] cache refresh skipped: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
cloudDb.close(),
|
||||
database.close(),
|
||||
redis.close(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[pickup-backfill] failed');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
48
backend/scripts/buildLogisticsPickupBoardSnapshot.js
Normal file
48
backend/scripts/buildLogisticsPickupBoardSnapshot.js
Normal file
@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const database = require('../src/config/database');
|
||||
const cloudDb = require('../src/config/cloudDatabase');
|
||||
const redis = require('../src/config/redis');
|
||||
const {
|
||||
queryLogisticsPickupBoard,
|
||||
writeLogisticsPickupBoardSnapshot,
|
||||
} = require('../src/services/logisticsPickupBoard');
|
||||
|
||||
async function main() {
|
||||
const [startDate, endDate = startDate] = process.argv.slice(2);
|
||||
if (!startDate || !endDate) {
|
||||
throw new Error('Usage: node backend/scripts/buildLogisticsPickupBoardSnapshot.js <YYYY-MM-DD> [YYYY-MM-DD]');
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
console.log(`[pickup-board-snapshot] range ${startDate} -> ${endDate}`);
|
||||
|
||||
try {
|
||||
const data = await queryLogisticsPickupBoard({
|
||||
startDate,
|
||||
endDate,
|
||||
shops: '',
|
||||
warehouses: '',
|
||||
purchasers: '',
|
||||
suppliers: '',
|
||||
skuIds: '',
|
||||
});
|
||||
const filePath = writeLogisticsPickupBoardSnapshot(startDate, endDate, data);
|
||||
console.log(`[pickup-board-snapshot] wrote ${filePath}`);
|
||||
console.log(`[pickup-board-snapshot] elapsed=${Date.now() - started}ms`);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
redis.close(),
|
||||
cloudDb.close(),
|
||||
database.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[pickup-board-snapshot] failed');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
61
backend/scripts/check-syntax.js
Normal file
61
backend/scripts/check-syntax.js
Normal file
@ -0,0 +1,61 @@
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const scanDirs = ['src', 'model', 'cubejs'].map((dir) => path.join(rootDir, dir));
|
||||
const ignoredDirs = new Set(['node_modules', '.cubestore', 'dist', 'coverage']);
|
||||
|
||||
function collectJavaScriptFiles(dir, files = []) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
return files;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirs.has(entry.name)) {
|
||||
collectJavaScriptFiles(path.join(dir, entry.name), files);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && /\.(c?m?js)$/.test(entry.name)) {
|
||||
files.push(path.join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
const files = scanDirs.flatMap((dir) => collectJavaScriptFiles(dir)).sort();
|
||||
|
||||
if (!files.length) {
|
||||
console.log('No JavaScript files found for syntax check.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
|
||||
for (const file of files) {
|
||||
const result = spawnSync(process.execPath, ['--check', file], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
failed = true;
|
||||
console.error(`\nSyntax check failed: ${path.relative(rootDir, file)}`);
|
||||
if (result.stderr) {
|
||||
console.error(result.stderr.trim());
|
||||
}
|
||||
if (result.stdout) {
|
||||
console.error(result.stdout.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Syntax check passed for ${files.length} JavaScript files.`);
|
||||
160
backend/scripts/check_product_category_mapping.js
Normal file
160
backend/scripts/check_product_category_mapping.js
Normal file
@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 品类映射校验:比对「商品标签配置」里的品类名 与 jst_sku.category 的真实取值。
|
||||
*
|
||||
* 背景:配置表用的是短名(如「电脑桌/书桌」),而 jst_sku.category 是带前缀的全名
|
||||
* (如「住宅家具 - 电脑桌」),直接等值匹配会 0 命中。本脚本用于找出映射关系。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/check_product_category_mapping.js --file=/path/to/公司SABC标签配置.xlsx
|
||||
* node scripts/check_product_category_mapping.js --file=... --json # 输出 JSON 供后续处理
|
||||
*
|
||||
* 数据源:企划库 planning_database.jst_sku(本机可达,与业务库同表)。
|
||||
* 若使用业务库,改 PLANNING_PUBLIC_DB_* 为 CLOUD_DB_* 即可。
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const XLSX = require('xlsx');
|
||||
|
||||
const IGNORED_CATEGORIES = ['-', ''];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { file: '', json: false };
|
||||
argv.slice(2).forEach((item) => {
|
||||
if (item.startsWith('--file=')) args.file = item.slice('--file='.length);
|
||||
else if (item === '--json') args.json = true;
|
||||
});
|
||||
return args;
|
||||
}
|
||||
|
||||
// 去掉「住宅家具 - 」这类前缀,只留叶子品类名
|
||||
function stripCategoryPrefix(value) {
|
||||
return String(value).replace(/^.*?\s+-\s+/, '').trim();
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
return String(value).trim().toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function readConfigCategories(filePath) {
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null });
|
||||
const list = [];
|
||||
for (let rowNo = 3; rowNo <= 150; rowNo += 1) {
|
||||
const value = String((rows[rowNo - 1] || [])[0] ?? '').trim();
|
||||
if (!value || IGNORED_CATEGORIES.includes(value)) continue;
|
||||
if (!list.includes(value)) list.push(value);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async function loadDatabaseCategories() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.PLANNING_PUBLIC_DB_HOST,
|
||||
port: Number(process.env.PLANNING_PUBLIC_DB_PORT),
|
||||
user: process.env.PLANNING_PUBLIC_DB_USER,
|
||||
password: process.env.PLANNING_PUBLIC_DB_PASSWORD,
|
||||
database: process.env.PLANNING_PUBLIC_DB_NAME,
|
||||
connectTimeout: 20000,
|
||||
});
|
||||
const [rows] = await connection.query(
|
||||
`SELECT TRIM(category) AS category, COUNT(*) AS skuCount
|
||||
FROM jst_sku
|
||||
WHERE category IS NOT NULL AND TRIM(category) <> ''
|
||||
GROUP BY TRIM(category)
|
||||
ORDER BY skuCount DESC`
|
||||
);
|
||||
await connection.end();
|
||||
return rows.map((row) => ({
|
||||
full: row.category,
|
||||
short: stripCategoryPrefix(row.category),
|
||||
skuCount: Number(row.skuCount),
|
||||
}));
|
||||
}
|
||||
|
||||
function matchCategories(configList, dbList) {
|
||||
const matched = [];
|
||||
const ambiguous = [];
|
||||
const missing = [];
|
||||
|
||||
configList.forEach((configName) => {
|
||||
let hits = dbList.filter((item) => item.short === configName);
|
||||
let strategy = '短名精确';
|
||||
|
||||
if (!hits.length) {
|
||||
hits = dbList.filter((item) => (
|
||||
item.short.includes(configName) || configName.includes(item.short)
|
||||
));
|
||||
strategy = '包含匹配';
|
||||
}
|
||||
if (!hits.length) {
|
||||
hits = dbList.filter((item) => normalize(item.short) === normalize(configName));
|
||||
strategy = '忽略大小写/空格';
|
||||
}
|
||||
|
||||
if (!hits.length) missing.push({ configName });
|
||||
else if (hits.length === 1) matched.push({ configName, strategy, target: hits[0] });
|
||||
else ambiguous.push({ configName, strategy, targets: hits });
|
||||
});
|
||||
|
||||
return { matched, ambiguous, missing };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (!args.file) {
|
||||
console.error('缺少 --file=<xlsx 路径> 参数');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(args.file)) {
|
||||
console.error(`文件不存在:${args.file}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const configList = readConfigCategories(args.file);
|
||||
const dbList = await loadDatabaseCategories();
|
||||
const result = matchCategories(configList, dbList);
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({ configList, dbList, ...result }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`配置表品类数:${configList.length}`);
|
||||
console.log(`数据库品类数:${dbList.length}(企划库 jst_sku.category 去重后)`);
|
||||
|
||||
console.log(`\n✅ 唯一命中(${result.matched.length})`);
|
||||
result.matched.forEach((item) => {
|
||||
console.log(` ${item.configName} → ${item.target.full} (${item.target.skuCount} SKU, ${item.strategy})`);
|
||||
});
|
||||
|
||||
console.log(`\n⚠️ 一对多,需拆分或确认(${result.ambiguous.length})`);
|
||||
result.ambiguous.forEach((item) => {
|
||||
console.log(` ${item.configName} [${item.strategy}]`);
|
||||
item.targets.forEach((target) => {
|
||||
console.log(` → ${target.full} (${target.skuCount} SKU)`);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`\n❌ 匹配不到(${result.missing.length})`);
|
||||
result.missing.forEach((item) => console.log(` - ${item.configName}`));
|
||||
|
||||
const total = configList.length;
|
||||
const ok = result.matched.length;
|
||||
console.log(`\n匹配率:${ok}/${total}(${((ok / total) * 100).toFixed(1)}%)`);
|
||||
console.log('\n提示:未命中的配置品类需要与业务方核对命名,一对多的需要确认归属哪一档阈值。');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('校验失败:', error.code || '', error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
75
backend/scripts/import_pdd_1688_property_mappings.js
Normal file
75
backend/scripts/import_pdd_1688_property_mappings.js
Normal file
@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const XLSX = require('xlsx');
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function splitSourceValues(value) {
|
||||
return normalizeText(value)
|
||||
.split(/[\/、,,;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseSheet(workbook, sheetName) {
|
||||
const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], {
|
||||
header: 1,
|
||||
raw: false,
|
||||
defval: '',
|
||||
});
|
||||
const propertyMap = new Map();
|
||||
let categoryName = normalizeText(sheetName);
|
||||
let currentPddProperty = '';
|
||||
let currentSourceProperty = '';
|
||||
|
||||
for (const rawRow of rows.slice(1)) {
|
||||
const row = rawRow.map(normalizeText);
|
||||
categoryName = row[0] || categoryName;
|
||||
currentPddProperty = row[1] || currentPddProperty;
|
||||
currentSourceProperty = row[3] || currentSourceProperty;
|
||||
if (!currentPddProperty) continue;
|
||||
|
||||
const property = propertyMap.get(currentPddProperty) || {
|
||||
pddProperty: currentPddProperty,
|
||||
sourceProperty: currentSourceProperty || '无',
|
||||
valueMappings: [],
|
||||
};
|
||||
if (row[1]) property.sourceProperty = currentSourceProperty || '无';
|
||||
|
||||
const pddValue = row[2];
|
||||
const sourceValues = splitSourceValues(row[4]);
|
||||
if (pddValue && sourceValues.length > 0 && !sourceValues.includes('无')) {
|
||||
property.valueMappings.push({ pddValue, sourceValues });
|
||||
}
|
||||
propertyMap.set(currentPddProperty, property);
|
||||
}
|
||||
|
||||
return {
|
||||
categoryName,
|
||||
propertyMappings: Array.from(propertyMap.values()),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const inputPath = path.resolve(process.argv[2] || '');
|
||||
const outputPath = path.resolve(
|
||||
process.argv[3] || path.join(__dirname, '../src/data/pdd1688PropertyMappings.json')
|
||||
);
|
||||
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||
throw new Error('请传入存在的“拼多多-1688属性对应.xlsx”路径');
|
||||
}
|
||||
const workbook = XLSX.readFile(inputPath, { cellDates: true });
|
||||
const output = {
|
||||
sourceFile: path.basename(inputPath),
|
||||
categories: workbook.SheetNames.map((sheetName) => parseSheet(workbook, sheetName)),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
|
||||
console.log(`Imported ${output.categories.length} category mappings into ${outputPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
84
backend/scripts/jst_reference_sync_scheduler.js
Normal file
84
backend/scripts/jst_reference_sync_scheduler.js
Normal file
@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const cron = require('node-cron');
|
||||
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const cloudDb = require('../src/config/cloudDatabase');
|
||||
const planningDb = require('../src/services/planningDatabase');
|
||||
const { syncJstReferenceTables } = require('../src/services/planningJstReferenceSync');
|
||||
|
||||
const TIMEZONE = process.env.JST_REFERENCE_SYNC_TIMEZONE || 'Asia/Shanghai';
|
||||
const CRON_EXPRESSION = process.env.JST_REFERENCE_SYNC_CRON || '0 22 * * *';
|
||||
const CHUNK_SIZE = Number.parseInt(process.env.JST_REFERENCE_SYNC_CHUNK_SIZE || '500', 10) || 500;
|
||||
const LOOKBACK_MINUTES = Number.parseInt(process.env.JST_REFERENCE_SYNC_LOOKBACK_MINUTES || '10', 10) || 10;
|
||||
|
||||
let isRunning = false;
|
||||
|
||||
async function closePools() {
|
||||
await Promise.allSettled([
|
||||
cloudDb.close(),
|
||||
planningDb.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
async function runSync(reason) {
|
||||
if (isRunning) {
|
||||
console.log(`[jst-reference-sync-scheduler] 跳过 ${reason},上一轮同步仍在运行`);
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
const startedAt = new Date();
|
||||
console.log(`[jst-reference-sync-scheduler] ${reason} 开始`, startedAt.toISOString());
|
||||
|
||||
try {
|
||||
const result = await syncJstReferenceTables({
|
||||
table: 'all',
|
||||
full: false,
|
||||
chunkSize: CHUNK_SIZE,
|
||||
lookbackMinutes: LOOKBACK_MINUTES,
|
||||
onProgress: (payload) => {
|
||||
console.log(
|
||||
`[jst-reference-sync-scheduler] ${payload.table} ${payload.fetchedRows}/${payload.totalRows}`
|
||||
);
|
||||
},
|
||||
});
|
||||
console.log('[jst-reference-sync-scheduler] 同步完成', JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.error('[jst-reference-sync-scheduler] 同步失败:', error);
|
||||
} finally {
|
||||
await closePools();
|
||||
isRunning = false;
|
||||
console.log(
|
||||
`[jst-reference-sync-scheduler] ${reason} 结束,用时 ${Date.now() - startedAt.getTime()}ms`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cron.schedule(CRON_EXPRESSION, () => {
|
||||
runSync('每日22点增量同步');
|
||||
}, {
|
||||
timezone: TIMEZONE,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
runSync('进程启动增量同步');
|
||||
}, 1000);
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('[jst-reference-sync-scheduler] 收到 SIGINT,准备退出');
|
||||
await closePools();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('[jst-reference-sync-scheduler] 收到 SIGTERM,准备退出');
|
||||
await closePools();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[jst-reference-sync-scheduler] 已启动,计划 ${CRON_EXPRESSION},时区 ${TIMEZONE}`
|
||||
);
|
||||
404
backend/scripts/seed_product_tag_config_from_xlsx.js
Normal file
404
backend/scripts/seed_product_tag_config_from_xlsx.js
Normal file
@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 从《公司SABC标签配置.xlsx》导入商品标签配置到系统库。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=/path/to/公司SABC标签配置.xlsx
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --print-sql # 只打印 SQL,不写库
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --dry-run # 只解析并打印摘要
|
||||
*
|
||||
* 解析口径(2026-09-11 评审确认):
|
||||
* - 区块① A3:F150 → 规模标签阈值(148 行,37 品类 × 4 档)
|
||||
* - 区块② H4:L28 → SABC 映射(25 行,5 规模标签 × 5 利润标签)
|
||||
* - 阈值统一四舍五入保留 2 位小数(决策 5,2026-09-11 修订);品类为 '-' 或空的行忽略
|
||||
*
|
||||
* 前置:三张表需已存在(DBA 执行 sql/20260911_create_product_tag_config_system_mysql.sql,
|
||||
* 或由 ensureProductTagConfigSchema() 自动建表)。
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
const database = require('../src/config/database');
|
||||
|
||||
const SCALE_TAG_ORDER = ['爆款', '热卖款', '动销款', '低动销款'];
|
||||
const IGNORED_CATEGORIES = ['-', ''];
|
||||
const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级'];
|
||||
|
||||
// 配置表聚合类目 → DB 类目 的兜底映射(按用户 2026-09-11 推测确认)。
|
||||
// xlsx 里「其他沙发类」「办公家具」本身是聚合类目,DB 中没有同名完整类目;
|
||||
// 其子品类在 DB 中独立存在,且未被别的配置名占用(懒人沙发/文具边柜已单独配置,故排除)。
|
||||
// 用「家族规则」而非逐个枚举叶子名,可自动覆盖 DB 中实际存在的全部子类目,避免名字写错产生幽灵行。
|
||||
// family: 'sofa' → 所有短名含「沙发」的类目,排除 exclude 列表
|
||||
// family: 'office'→ 所有「商业/办公家具 -」前缀的类目,排除 exclude 列表
|
||||
const MANUAL_CATEGORY_MAP = {
|
||||
'其他沙发类': { family: 'sofa', exclude: ['懒人沙发'] },
|
||||
'办公家具': { family: 'office', exclude: ['文具边柜'] },
|
||||
};
|
||||
|
||||
// 区块① / 区块② 在 sheet 中的位置(1-based,与 Excel 界面一致)
|
||||
const BLOCK1 = { startRow: 3, endRow: 150, cols: { category: 0, pMin: 1, pMax: 2, sMin: 3, sMax: 4, scaleTag: 5 } };
|
||||
const BLOCK2 = { startRow: 4, endRow: 28, cols: { scaleTag: 7, profitLevel: 8, profitText: 9, sabc: 11 } };
|
||||
|
||||
// 去掉「住宅家具 - 」这类一级类目前缀,只留叶子品类名
|
||||
function stripCategoryPrefix(value) {
|
||||
return String(value).replace(/^.*?\s+-\s+/, '').trim();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { file: '', printSql: false, dryRun: false };
|
||||
argv.slice(2).forEach((item) => {
|
||||
if (item.startsWith('--file=')) args.file = item.slice('--file='.length);
|
||||
else if (item === '--print-sql') args.printSql = true;
|
||||
else if (item === '--dry-run') args.dryRun = true;
|
||||
});
|
||||
return args;
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
// 决策 5(2026-09-11 11:12 修订):所有阈值统一「四舍五入保留 2 位小数」。
|
||||
// - 0.5 → 0.5(合法业务阈值,不变;不会变成 1)
|
||||
// - 溢出值 7916.34311148132 → 7916.34;2063.13142776357 → 2063.13
|
||||
// - 整数 12000 → 12000
|
||||
function normalizeThreshold(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return Math.round(num * 100) / 100;
|
||||
}
|
||||
|
||||
function sqlText(value) {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function sqlNumber(value) {
|
||||
return value === null || value === undefined ? 'NULL' : String(value);
|
||||
}
|
||||
|
||||
function readWorkbook(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`文件不存在:${filePath}`);
|
||||
}
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null });
|
||||
return { sheetName, rows };
|
||||
}
|
||||
|
||||
function parseScaleThresholds(rows) {
|
||||
const list = [];
|
||||
const skipped = [];
|
||||
for (let rowNo = BLOCK1.startRow; rowNo <= BLOCK1.endRow; rowNo += 1) {
|
||||
const row = rows[rowNo - 1] || [];
|
||||
const category = text(row[BLOCK1.cols.category]);
|
||||
if (IGNORED_CATEGORIES.includes(category)) {
|
||||
if (category) skipped.push({ rowNo, reason: `品类为 "${category}",按决策 6 忽略` });
|
||||
continue;
|
||||
}
|
||||
const scaleTag = text(row[BLOCK1.cols.scaleTag]);
|
||||
if (!SCALE_TAG_ORDER.includes(scaleTag)) {
|
||||
skipped.push({ rowNo, reason: `规模标签 "${scaleTag}" 不在枚举内` });
|
||||
continue;
|
||||
}
|
||||
const raw = {
|
||||
performanceMin: row[BLOCK1.cols.pMin],
|
||||
performanceMax: row[BLOCK1.cols.pMax],
|
||||
salesMin: row[BLOCK1.cols.sMin],
|
||||
salesMax: row[BLOCK1.cols.sMax],
|
||||
};
|
||||
const rounded = {
|
||||
performanceMin: normalizeThreshold(raw.performanceMin),
|
||||
performanceMax: normalizeThreshold(raw.performanceMax),
|
||||
salesMin: normalizeThreshold(raw.salesMin),
|
||||
salesMax: normalizeThreshold(raw.salesMax),
|
||||
};
|
||||
// 只记录「确实被取整」的溢出值,便于核对(决策 5)
|
||||
Object.entries(raw).forEach(([key, value]) => {
|
||||
const before = Number(value);
|
||||
if (!Number.isFinite(before)) return;
|
||||
if (rounded[key] !== before) {
|
||||
skipped.push({ rowNo, reason: `字段 ${key} 原值 ${value} 疑似计算溢出,已取整为 ${rounded[key]}(决策 5)`, rounding: true });
|
||||
}
|
||||
});
|
||||
list.push({
|
||||
// 配置表里是短名(如「电脑桌/书桌」),稍后会展开成 jst_sku.category 的完整值
|
||||
displayName: category,
|
||||
scaleTag,
|
||||
...rounded,
|
||||
priority: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||
sort: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||
});
|
||||
}
|
||||
return { list, skipped };
|
||||
}
|
||||
|
||||
// 读取 jst_sku.category 的全部真实取值(企划库与业务库同表,优先用可达的企划库)
|
||||
async function loadDbCategories() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.PLANNING_PUBLIC_DB_HOST,
|
||||
port: Number(process.env.PLANNING_PUBLIC_DB_PORT),
|
||||
user: process.env.PLANNING_PUBLIC_DB_USER,
|
||||
password: process.env.PLANNING_PUBLIC_DB_PASSWORD,
|
||||
database: process.env.PLANNING_PUBLIC_DB_NAME,
|
||||
connectTimeout: 20000,
|
||||
});
|
||||
const [rows] = await connection.query(
|
||||
`SELECT TRIM(category) AS category, COUNT(*) AS skuCount
|
||||
FROM jst_sku
|
||||
WHERE category IS NOT NULL AND TRIM(category) <> ''
|
||||
GROUP BY TRIM(category)`
|
||||
);
|
||||
await connection.end();
|
||||
return rows.map((row) => ({
|
||||
full: row.category,
|
||||
short: stripCategoryPrefix(row.category),
|
||||
skuCount: Number(row.skuCount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「配置短名」展开为「数据库完整品类名」。
|
||||
* 一个短名可能对应多个 DB 品类(如「电脑桌/书桌」→ 住宅家具-电脑桌 + 住宅家具-书桌),
|
||||
* 展开后每个 DB 品类生成一行,displayName 保留原短名用于展示分组。
|
||||
*/
|
||||
function expandCategories(configRows, dbCategories) {
|
||||
const matched = [];
|
||||
const ambiguous = [];
|
||||
const missing = [];
|
||||
|
||||
configRows.forEach((row) => {
|
||||
const shortName = row.displayName;
|
||||
|
||||
// 1) 手工映射优先:聚合类目 → DB 类目(家族规则,自动展开子类目)
|
||||
if (MANUAL_CATEGORY_MAP[shortName]) {
|
||||
const rule = MANUAL_CATEGORY_MAP[shortName];
|
||||
let resolved = [];
|
||||
if (Array.isArray(rule)) {
|
||||
// 兼容:显式 DB 短名列表
|
||||
rule.forEach((targetShort) => {
|
||||
const hits = dbCategories.filter((item) => item.short === targetShort);
|
||||
if (hits.length) hits.forEach((hit) => resolved.push(hit));
|
||||
else console.warn(` ⚠️ 手工映射目标「${targetShort}」在 jst_sku 中找不到对应类目,已跳过(请核对 MANUAL_CATEGORY_MAP)`);
|
||||
});
|
||||
} else if (rule && rule.family === 'sofa') {
|
||||
resolved = dbCategories.filter((item) => item.short.includes('沙发') && !rule.exclude.includes(item.short));
|
||||
} else if (rule && rule.family === 'office') {
|
||||
resolved = dbCategories.filter((item) => /^商业\/办公家具\s*-\s*/.test(item.full) && !rule.exclude.includes(item.short));
|
||||
}
|
||||
if (!resolved.length) {
|
||||
missing.push(row);
|
||||
} else {
|
||||
resolved.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 剥离前缀后同名
|
||||
let hits = dbCategories.filter((item) => item.short === shortName);
|
||||
// 3) 包含匹配兜底
|
||||
if (!hits.length) {
|
||||
hits = dbCategories.filter((item) => (
|
||||
item.short.includes(shortName) || shortName.includes(item.short)
|
||||
));
|
||||
}
|
||||
|
||||
if (!hits.length) {
|
||||
missing.push(row);
|
||||
} else {
|
||||
hits.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||
if (hits.length > 1) ambiguous.push({ shortName, targets: hits });
|
||||
}
|
||||
});
|
||||
|
||||
return { matched, ambiguous, missing };
|
||||
}
|
||||
|
||||
function parseSabcMappings(rows) {
|
||||
const list = [];
|
||||
// H 列(规模标签)是纵向合并单元格(H4:H8、H9:H13 …),
|
||||
// XLSX.utils.sheet_to_json 只在合并区左上角单元格保留值,其余为 null,
|
||||
// 因此需要向下填充,否则只能解析出 5 条而不是 25 条。
|
||||
let carriedScaleTag = '';
|
||||
for (let rowNo = BLOCK2.startRow; rowNo <= BLOCK2.endRow; rowNo += 1) {
|
||||
const row = rows[rowNo - 1] || [];
|
||||
const ownScaleTag = text(row[BLOCK2.cols.scaleTag]);
|
||||
if (ownScaleTag) carriedScaleTag = ownScaleTag;
|
||||
|
||||
const scaleTag = carriedScaleTag;
|
||||
const profitLevel = text(row[BLOCK2.cols.profitLevel]);
|
||||
const sabcTag = text(row[BLOCK2.cols.sabc]);
|
||||
if (!scaleTag || !profitLevel || !sabcTag) continue;
|
||||
list.push({ scaleTag, profitLevel, sabcTag, sort: list.length + 1 });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function buildScaleInsertSql(list) {
|
||||
if (!list.length) return '-- (无规模阈值数据)';
|
||||
const values = list.map((row) => ` (${sqlText(row.category)}, ${sqlText(row.displayName)}, ${sqlText(row.scaleTag)}, `
|
||||
+ `${sqlNumber(row.performanceMin)}, ${sqlNumber(row.performanceMax)}, `
|
||||
+ `${sqlNumber(row.salesMin)}, ${sqlNumber(row.salesMax)}, ${row.priority}, ${row.sort}, 1)`);
|
||||
return `INSERT INTO product_scale_thresholds\n`
|
||||
+ ' (category, display_name, scale_tag, performance_min, performance_max, sales_min, sales_max, priority, sort, enabled)\nVALUES\n'
|
||||
+ `${values.join(',\n')}\nON DUPLICATE KEY UPDATE\n`
|
||||
+ ' display_name = VALUES(display_name),\n'
|
||||
+ ' performance_min = VALUES(performance_min), performance_max = VALUES(performance_max),\n'
|
||||
+ ' sales_min = VALUES(sales_min), sales_max = VALUES(sales_max),\n'
|
||||
+ ' priority = VALUES(priority), sort = VALUES(sort), enabled = VALUES(enabled);';
|
||||
}
|
||||
|
||||
const PROFIT_LEVEL_RATE = {
|
||||
一级: 0.18,
|
||||
二级: 0.16,
|
||||
三级: 0.12,
|
||||
四级: 0.08,
|
||||
五级: 0.08,
|
||||
};
|
||||
|
||||
async function writeScaleThresholds(list) {
|
||||
await database.local.query('DELETE FROM product_scale_thresholds');
|
||||
if (!list.length) return 0;
|
||||
const values = [];
|
||||
const placeholders = list.map((row) => {
|
||||
values.push(
|
||||
row.category, row.displayName, row.scaleTag,
|
||||
row.performanceMin, row.performanceMax, row.salesMin, row.salesMax,
|
||||
row.priority, row.sort, 1
|
||||
);
|
||||
return '(?,?,?,?,?,?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_scale_thresholds
|
||||
(category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function writeProfitLevels() {
|
||||
await database.local.query('DELETE FROM product_profit_levels');
|
||||
const values = [];
|
||||
const placeholders = PROFIT_LEVELS.map((levelCode, index) => {
|
||||
const rate = PROFIT_LEVEL_RATE[levelCode];
|
||||
values.push(levelCode, rate, `营业利润率≤${rate.toFixed(2)}`, index + 1, 1);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return PROFIT_LEVELS.length;
|
||||
}
|
||||
|
||||
async function writeSabcMappings(list) {
|
||||
await database.local.query('DELETE FROM product_sabc_mappings');
|
||||
if (!list.length) return 0;
|
||||
const values = [];
|
||||
const placeholders = list.map((row) => {
|
||||
values.push(row.scaleTag, row.profitLevel, row.sabcTag, row.sort, 1);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (!args.file) {
|
||||
console.error('缺少 --file=<xlsx 路径> 参数');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { sheetName, rows } = readWorkbook(args.file);
|
||||
console.log(`读取工作簿:${args.file}`);
|
||||
console.log(`Sheet:${sheetName},共 ${rows.length} 行`);
|
||||
|
||||
const { list: configRows, skipped } = parseScaleThresholds(rows);
|
||||
const mappings = parseSabcMappings(rows);
|
||||
|
||||
console.log(`\n解析配置表:`);
|
||||
console.log(` 规模阈值配置行:${configRows.length}(按「配置短名 × 规模标签」计)`);
|
||||
console.log(` SABC 映射 :${mappings.length} 条`);
|
||||
|
||||
if (skipped.length) {
|
||||
console.log(`\n跳过 / 修正明细(${skipped.length} 条):`);
|
||||
skipped.forEach((item) => console.log(` 第 ${item.rowNo} 行:${item.reason}`));
|
||||
}
|
||||
|
||||
// ---- 品类名展开:配置短名 → jst_sku.category 完整名 ----
|
||||
const dbCategories = await loadDbCategories();
|
||||
const { matched: thresholds, ambiguous, missing } = expandCategories(configRows, dbCategories);
|
||||
|
||||
console.log(`\n品类名映射:`);
|
||||
console.log(` 数据库品类数:${dbCategories.length}`);
|
||||
console.log(` 展开后阈值行:${thresholds.length}(配置行 ${configRows.length} → DB 品类行)`);
|
||||
if (ambiguous.length) {
|
||||
// 同一个短名会出现在多个规模标签下,按短名去重后再报告
|
||||
const byShortName = new Map();
|
||||
ambiguous.forEach((item) => {
|
||||
if (!byShortName.has(item.shortName)) byShortName.set(item.shortName, item.targets);
|
||||
});
|
||||
console.log(`\n ⚠️ 一对多(${byShortName.size} 个配置名对应多个 DB 品类,已全部归入同一档阈值):`);
|
||||
byShortName.forEach((targets, shortName) => {
|
||||
console.log(` ${shortName}`);
|
||||
targets.forEach((t) => console.log(` → ${t.full} (${t.skuCount} SKU)`));
|
||||
});
|
||||
}
|
||||
if (missing.length) {
|
||||
console.log(`\n ❌ 匹配不到(${missing.length} 个,这些配置行不会生效,需在 MANUAL_CATEGORY_MAP 中补映射):`);
|
||||
[...new Set(missing.map((m) => m.displayName))].forEach((name) => console.log(` - ${name}`));
|
||||
}
|
||||
|
||||
const byTag = SCALE_TAG_ORDER.map((tag) => ({
|
||||
tag,
|
||||
count: thresholds.filter((row) => row.scaleTag === tag).length,
|
||||
}));
|
||||
console.log(`\n 按规模标签分布:${byTag.map((item) => `${item.tag} ${item.count}`).join(' / ')}`);
|
||||
console.log(` 展开后品类数:${new Set(thresholds.map((row) => row.category)).size}`);
|
||||
|
||||
if (args.printSql) {
|
||||
console.log('\n---------- 规模标签阈值 SQL ----------');
|
||||
console.log(buildScaleInsertSql(thresholds));
|
||||
console.log('\n(利润标签与 SABC 映射的 SQL 见 sql/20260911_create_product_tag_config_system_mysql.sql)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('\n--dry-run:未写入数据库。');
|
||||
return;
|
||||
}
|
||||
|
||||
const writtenThresholds = await writeScaleThresholds(thresholds);
|
||||
const writtenLevels = await writeProfitLevels();
|
||||
const writtenMappings = await writeSabcMappings(mappings);
|
||||
console.log('\n✅ 导入完成:');
|
||||
console.log(` product_scale_thresholds : ${writtenThresholds} 行`);
|
||||
console.log(` product_profit_levels : ${writtenLevels} 行`);
|
||||
console.log(` product_sabc_mappings : ${writtenMappings} 条`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => database.close && database.close())
|
||||
.catch((error) => {
|
||||
console.error('导入失败:', error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
63
backend/scripts/sync_ding_workflow_tables.js
Normal file
63
backend/scripts/sync_ding_workflow_tables.js
Normal file
@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const cloudDb = require('../src/config/cloudDatabase');
|
||||
const planningDb = require('../src/services/planningDatabase');
|
||||
const { runPlanningDingWorkflowSyncJob } = require('../src/jobs/planningDingWorkflowSyncJob');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
full: false,
|
||||
table: 'all',
|
||||
chunkSize: undefined,
|
||||
lookbackMinutes: undefined,
|
||||
lookbackRows: undefined,
|
||||
};
|
||||
|
||||
argv.forEach((arg) => {
|
||||
if (arg === '--full') {
|
||||
options.full = true;
|
||||
return;
|
||||
}
|
||||
if (arg.startsWith('--table=')) {
|
||||
options.table = arg.slice('--table='.length);
|
||||
return;
|
||||
}
|
||||
if (arg.startsWith('--chunkSize=')) {
|
||||
options.chunkSize = Number.parseInt(arg.slice('--chunkSize='.length), 10) || undefined;
|
||||
return;
|
||||
}
|
||||
if (arg.startsWith('--lookbackMinutes=')) {
|
||||
options.lookbackMinutes = Number.parseInt(arg.slice('--lookbackMinutes='.length), 10) || undefined;
|
||||
return;
|
||||
}
|
||||
if (arg.startsWith('--lookbackRows=')) {
|
||||
options.lookbackRows = Number.parseInt(arg.slice('--lookbackRows='.length), 10) || undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
try {
|
||||
console.log('[ding-workflow-sync] 开始同步钉钉流程表', options);
|
||||
const result = await runPlanningDingWorkflowSyncJob('manual_cli', options);
|
||||
console.log('[ding-workflow-sync] 同步完成', JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[ding-workflow-sync] 同步失败:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
cloudDb.close(),
|
||||
planningDb.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
218
backend/scripts/sync_jian_sun_product_analysis_tables.js
Normal file
218
backend/scripts/sync_jian_sun_product_analysis_tables.js
Normal file
@ -0,0 +1,218 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const sql = require('mssql');
|
||||
|
||||
const batchSize = Math.max(100, Number.parseInt(process.env.PRODUCT_ANALYSIS_SYNC_BATCH_SIZE || '5000', 10));
|
||||
const targetSchema = /^[A-Za-z_][A-Za-z0-9_]*$/.test(process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA || '')
|
||||
? process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA
|
||||
: 'app';
|
||||
|
||||
const sourceConfig = {
|
||||
server: process.env.JIAN_SUN_DB_HOST,
|
||||
port: Number(process.env.JIAN_SUN_DB_PORT || 1433),
|
||||
database: process.env.JIAN_SUN_DB_NAME || 'JianSun',
|
||||
user: process.env.JIAN_SUN_DB_USER,
|
||||
password: process.env.JIAN_SUN_DB_PASSWORD,
|
||||
options: { encrypt: false, trustServerCertificate: true },
|
||||
connectionTimeout: 120000,
|
||||
requestTimeout: 120000,
|
||||
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||
};
|
||||
|
||||
const targetConfig = {
|
||||
server: process.env.CLOUD_DB_PUBLIC_HOST,
|
||||
port: Number(process.env.CLOUD_DB_PUBLIC_PORT || 1433),
|
||||
database: process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica',
|
||||
user: process.env.CLOUD_DB_PUBLIC_USER,
|
||||
password: process.env.CLOUD_DB_PUBLIC_PASSWORD,
|
||||
options: {
|
||||
encrypt: String(process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true',
|
||||
trustServerCertificate: true,
|
||||
},
|
||||
connectionTimeout: 120000,
|
||||
requestTimeout: 120000,
|
||||
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||
};
|
||||
|
||||
const tables = [
|
||||
{
|
||||
name: 'pdd_DataActivityRecords',
|
||||
createColumns: `
|
||||
[id] int NOT NULL,
|
||||
[shopName] varchar(40) NULL,
|
||||
[createTime] varchar(20) NULL,
|
||||
[business_type_str] varchar(100) NULL,
|
||||
[activity_name] varchar(100) NULL,
|
||||
[activity_id] int NULL,
|
||||
[goods_name] varchar(100) NULL,
|
||||
[goods_id] bigint NULL,
|
||||
[hd_thumb_url] varchar(100) NULL,
|
||||
[enroll_time ] bigint NULL,
|
||||
[min_activity_sku_price] decimal(38, 2) NULL,
|
||||
[max_activity_sku_price] decimal(38, 2) NULL,
|
||||
[activity_goods_start_time] bigint NULL,
|
||||
[activity_goods_end_time] bigint NULL,
|
||||
[status] int NULL`,
|
||||
columns: [
|
||||
['id', sql.Int, false],
|
||||
['shopName', sql.VarChar(40), true],
|
||||
['createTime', sql.VarChar(20), true],
|
||||
['business_type_str', sql.VarChar(100), true],
|
||||
['activity_name', sql.VarChar(100), true],
|
||||
['activity_id', sql.Int, true],
|
||||
['goods_name', sql.VarChar(100), true],
|
||||
['goods_id', sql.BigInt, true],
|
||||
['hd_thumb_url', sql.VarChar(100), true],
|
||||
['enroll_time ', sql.BigInt, true],
|
||||
['min_activity_sku_price', sql.Decimal(38, 2), true],
|
||||
['max_activity_sku_price', sql.Decimal(38, 2), true],
|
||||
['activity_goods_start_time', sql.BigInt, true],
|
||||
['activity_goods_end_time', sql.BigInt, true],
|
||||
['status', sql.Int, true],
|
||||
],
|
||||
indexColumn: 'goods_id',
|
||||
},
|
||||
{
|
||||
name: 'pdd_GoodsQualities',
|
||||
createColumns: `
|
||||
[id] int NOT NULL,
|
||||
[avgDescRevScr1m] float NULL,
|
||||
[qltyRfndOrdrCntRto1m] float NULL,
|
||||
[ptHelpPzRate30d] float NULL,
|
||||
[midRto30d] float NULL,
|
||||
[goodsId] bigint NULL,
|
||||
[displayMallScoreRk] int NULL,
|
||||
[rankAvgDescRevScr1mStage] int NULL,
|
||||
[rankAvgDescRevScr1mStplStage] int NULL,
|
||||
[rankQltyRfndOrdrCntRto1mStage] int NULL,
|
||||
[rankQltyRfndOrdrCntRto1mStplStage] int NULL,
|
||||
[rankPtHelpPzRate30dStage] int NULL,
|
||||
[rankPtHelpPzRate30dStplStage] int NULL,
|
||||
[rankComscoreStage ] int NULL,
|
||||
[rankComscoreStplStage] int NULL,
|
||||
[goodsScoreRkStage] int NULL,
|
||||
[goodsScoreRkStplStage] int NULL,
|
||||
[createTime] varchar(20) NULL,
|
||||
[shopName] varchar(40) NULL`,
|
||||
columns: [
|
||||
['id', sql.Int, false],
|
||||
['avgDescRevScr1m', sql.Float, true],
|
||||
['qltyRfndOrdrCntRto1m', sql.Float, true],
|
||||
['ptHelpPzRate30d', sql.Float, true],
|
||||
['midRto30d', sql.Float, true],
|
||||
['goodsId', sql.BigInt, true],
|
||||
['displayMallScoreRk', sql.Int, true],
|
||||
['rankAvgDescRevScr1mStage', sql.Int, true],
|
||||
['rankAvgDescRevScr1mStplStage', sql.Int, true],
|
||||
['rankQltyRfndOrdrCntRto1mStage', sql.Int, true],
|
||||
['rankQltyRfndOrdrCntRto1mStplStage', sql.Int, true],
|
||||
['rankPtHelpPzRate30dStage', sql.Int, true],
|
||||
['rankPtHelpPzRate30dStplStage', sql.Int, true],
|
||||
['rankComscoreStage ', sql.Int, true],
|
||||
['rankComscoreStplStage', sql.Int, true],
|
||||
['goodsScoreRkStage', sql.Int, true],
|
||||
['goodsScoreRkStplStage', sql.Int, true],
|
||||
['createTime', sql.VarChar(20), true],
|
||||
['shopName', sql.VarChar(40), true],
|
||||
],
|
||||
indexColumn: 'goodsId',
|
||||
},
|
||||
];
|
||||
|
||||
function qualified(schema, tableName) {
|
||||
return `[${schema}].[${tableName}]`;
|
||||
}
|
||||
|
||||
function assertConfig(config, label) {
|
||||
const missing = ['server', 'database', 'user', 'password'].filter((key) => !config[key]);
|
||||
if (missing.length) throw new Error(`${label} missing configuration: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
async function ensureTargetTable(pool, definition) {
|
||||
const tableName = qualified(targetSchema, definition.name);
|
||||
const pkName = `PK_${definition.name}`;
|
||||
const lookupIndex = `IX_${definition.name}_${definition.indexColumn}_createTime`;
|
||||
await pool.request().batch(`
|
||||
IF OBJECT_ID(N'${tableName}', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE ${tableName} (
|
||||
${definition.createColumns},
|
||||
CONSTRAINT [${pkName}] PRIMARY KEY CLUSTERED ([id])
|
||||
);
|
||||
END;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'${tableName}') AND name = N'${lookupIndex}')
|
||||
BEGIN
|
||||
CREATE INDEX [${lookupIndex}] ON ${tableName} ([${definition.indexColumn}], [createTime]);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
function createBulkTable(definition) {
|
||||
const table = new sql.Table(definition.name);
|
||||
table.schema = targetSchema;
|
||||
table.create = false;
|
||||
definition.columns.forEach(([name, type, nullable]) => table.columns.add(name, type, { nullable }));
|
||||
return table;
|
||||
}
|
||||
|
||||
async function syncTable(sourcePool, targetPool, definition) {
|
||||
await ensureTargetTable(targetPool, definition);
|
||||
const sourceName = qualified('dbo', definition.name);
|
||||
const targetName = qualified(targetSchema, definition.name);
|
||||
const columnSql = definition.columns.map(([name]) => `[${name}]`).join(', ');
|
||||
const targetState = await targetPool.request().query(`SELECT COALESCE(MAX([id]), 0) AS maxId FROM ${targetName}`);
|
||||
let lastId = Number(targetState.recordset[0].maxId || 0);
|
||||
let copied = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await sourcePool.request()
|
||||
.input('lastId', sql.Int, lastId)
|
||||
.query(`SELECT TOP (${batchSize}) ${columnSql} FROM ${sourceName} WHERE [id] > @lastId ORDER BY [id]`);
|
||||
const rows = result.recordset || [];
|
||||
if (!rows.length) break;
|
||||
|
||||
const bulkTable = createBulkTable(definition);
|
||||
rows.forEach((row) => bulkTable.rows.add(...definition.columns.map(([name]) => row[name])));
|
||||
await targetPool.request().bulk(bulkTable);
|
||||
lastId = Number(rows[rows.length - 1].id);
|
||||
copied += rows.length;
|
||||
console.log(`[product-analysis-sync] ${definition.name}: copied ${copied}, target max id ${lastId}`);
|
||||
}
|
||||
|
||||
const [sourceStats, targetStats] = await Promise.all([
|
||||
sourcePool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${sourceName}`),
|
||||
targetPool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${targetName}`),
|
||||
]);
|
||||
return { copied, source: sourceStats.recordset[0], target: targetStats.recordset[0] };
|
||||
}
|
||||
|
||||
async function syncProductAnalysisReplica() {
|
||||
assertConfig(sourceConfig, 'JianSun source');
|
||||
assertConfig(targetConfig, 'JSReportReplica target');
|
||||
const [sourcePool, targetPool] = await Promise.all([
|
||||
new sql.ConnectionPool(sourceConfig).connect(),
|
||||
new sql.ConnectionPool(targetConfig).connect(),
|
||||
]);
|
||||
|
||||
try {
|
||||
const summary = {};
|
||||
for (const definition of tables) {
|
||||
summary[definition.name] = await syncTable(sourcePool, targetPool, definition);
|
||||
}
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return summary;
|
||||
} finally {
|
||||
await Promise.all([sourcePool.close(), targetPool.close()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
syncProductAnalysisReplica().catch((error) => {
|
||||
console.error('[product-analysis-sync] failed:', error.stack || error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncProductAnalysisReplica,
|
||||
};
|
||||
65
backend/scripts/sync_jst_reference_tables.js
Normal file
65
backend/scripts/sync_jst_reference_tables.js
Normal file
@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const cloudDb = require('../src/config/cloudDatabase');
|
||||
const planningDb = require('../src/services/planningDatabase');
|
||||
const { syncJstReferenceTables } = require('../src/services/planningJstReferenceSync');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
full: false,
|
||||
table: 'all',
|
||||
chunkSize: 500,
|
||||
lookbackMinutes: 10,
|
||||
};
|
||||
|
||||
argv.forEach((arg) => {
|
||||
if (arg === '--full') {
|
||||
options.full = true;
|
||||
return;
|
||||
}
|
||||
if (arg.startsWith('--table=')) {
|
||||
options.table = arg.slice('--table='.length);
|
||||
return;
|
||||
}
|
||||
if (arg.startsWith('--chunkSize=')) {
|
||||
options.chunkSize = Number.parseInt(arg.slice('--chunkSize='.length), 10) || options.chunkSize;
|
||||
return;
|
||||
}
|
||||
if (arg.startsWith('--lookbackMinutes=')) {
|
||||
options.lookbackMinutes = Number.parseInt(arg.slice('--lookbackMinutes='.length), 10) || options.lookbackMinutes;
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
try {
|
||||
console.log('[jst-reference-sync] 开始同步聚水潭参考表', options);
|
||||
const result = await syncJstReferenceTables({
|
||||
...options,
|
||||
onProgress: (payload) => {
|
||||
console.log(
|
||||
`[jst-reference-sync] ${payload.table} ${payload.fetchedRows}/${payload.totalRows}`
|
||||
);
|
||||
},
|
||||
});
|
||||
console.log('[jst-reference-sync] 同步完成', JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[jst-reference-sync] 同步失败:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
cloudDb.close(),
|
||||
planningDb.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
34
backend/scripts/sync_stock_selection_tables.js
Normal file
34
backend/scripts/sync_stock_selection_tables.js
Normal file
@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const dataSource = require('../src/config/database');
|
||||
const cloudDb = require('../src/config/cloudDatabase');
|
||||
const { syncStockSelectionTables } = require('../src/services/stockSelectionSync');
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('[sync-stock-selection] 开始同步条码打标表到系统库');
|
||||
const result = await syncStockSelectionTables({
|
||||
syncType: 'script',
|
||||
onProgress: (payload) => {
|
||||
if (payload?.stage) {
|
||||
console.log(`[sync-stock-selection] ${payload.stage}`, payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
console.log('[sync-stock-selection] 同步完成', result);
|
||||
} catch (error) {
|
||||
console.error('[sync-stock-selection] 同步失败:', error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
cloudDb.close(),
|
||||
dataSource.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
20
backend/scripts/verify_image_search_credentials.js
Executable file
20
backend/scripts/verify_image_search_credentials.js
Executable file
@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const database = require('../src/config/database');
|
||||
const { verifyImageSearchCredentials } = require('../src/services/imageSearchCredentialGuard');
|
||||
|
||||
(async () => {
|
||||
const status = await verifyImageSearchCredentials();
|
||||
console.log(JSON.stringify({ success: true, status }));
|
||||
await database.close();
|
||||
})().catch(async (error) => {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
code: error.code || 'IMAGE_SEARCH_CREDENTIAL_GUARD_FAILED',
|
||||
message: error.message,
|
||||
status: error.status || null,
|
||||
}));
|
||||
try { await database.close(); } catch (_error) {}
|
||||
process.exit(1);
|
||||
});
|
||||
24
backend/scripts/verify_similar_products_upstream.js
Executable file
24
backend/scripts/verify_similar_products_upstream.js
Executable file
@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const database = require('../src/config/database');
|
||||
const { testSimilarProductsConfig } = require('../src/services/similarProductsSettings');
|
||||
|
||||
(async () => {
|
||||
const result = await testSimilarProductsConfig();
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
endpointUrl: result.endpointUrl,
|
||||
message: result.message,
|
||||
}));
|
||||
await database.close();
|
||||
})().catch(async (error) => {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
code: error.code || 'SIMILAR_PRODUCTS_UPSTREAM_UNHEALTHY',
|
||||
message: error.message,
|
||||
status: error.status || null,
|
||||
}));
|
||||
try { await database.close(); } catch (_error) {}
|
||||
process.exit(1);
|
||||
});
|
||||
@ -0,0 +1,55 @@
|
||||
-- 商品监控配套:部门商品标签规则 + 部门SKU负责人配置
|
||||
-- 目标库:系统库 shujuku3_system(MySQL / MariaDB)
|
||||
-- 创建日期:2026-09-11
|
||||
--
|
||||
-- 说明:
|
||||
-- 1) product_tag_rules 部门标签 G-S/G-A/G-B/G-C 的阈值规则(tag_scope='department')
|
||||
-- 与公司 SABC(tag_scope='company')共用一张表,靠 tag_scope 区分。
|
||||
-- ⚠️ 公司 SABC 目前由「商品标签配置」页的三张表承载,本表仅启用 department 分支。
|
||||
-- 2) department_sku_owners 部门 × SKU/SPU → 负责人 的手工维护映射
|
||||
--
|
||||
-- 本文件与 services/localSchema.js 的 ensureProductMonitoringSchema() 等价,
|
||||
-- 服务启动时会自动 CREATE TABLE IF NOT EXISTS,本文件供 DBA 手工建表使用。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_tag_rules (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department',
|
||||
tag_level VARCHAR(16) NOT NULL COMMENT '部门标签:G-S/G-A/G-B/G-C(company 分支预留 S/A/B/C/D)',
|
||||
metric_key VARCHAR(64) NOT NULL COMMENT '阈值指标:sales_qty_7d(近7天销售件数)/ sales_amount_7d(近7天销售额)',
|
||||
threshold_min DECIMAL(18,4) NULL COMMENT '区间起点(含)',
|
||||
threshold_max DECIMAL(18,4) NULL COMMENT '区间终点(不含)',
|
||||
priority INT NOT NULL DEFAULT 0 COMMENT '同 scope 内升序命中即停',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key),
|
||||
KEY idx_scope_enabled (tag_scope, enabled),
|
||||
KEY idx_metric (metric_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)';
|
||||
|
||||
-- 初始数据:部门标签四档,默认口径为近 7 天销售件数(sales_qty_7d)
|
||||
-- ⚠️ 阈值为占位默认值,需在「系统设置 → 部门商品标签配置」中按业务口径调整后生效。
|
||||
INSERT INTO product_tag_rules (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES
|
||||
('department', 'G-S', 'sales_qty_7d', 500, NULL, 1, 1),
|
||||
('department', 'G-A', 'sales_qty_7d', 200, 500, 2, 1),
|
||||
('department', 'G-B', 'sales_qty_7d', 50, 200, 3, 1),
|
||||
('department', 'G-C', 'sales_qty_7d', 0, 50, 4, 1)
|
||||
ON DUPLICATE KEY UPDATE tag_scope = tag_scope;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS department_sku_owners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
department_id BIGINT UNSIGNED NOT NULL COMMENT '关联 departments.id',
|
||||
owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser',
|
||||
user_id BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id',
|
||||
spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人;填值则为该 SPU 的负责人',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id),
|
||||
KEY idx_dept (department_id),
|
||||
KEY idx_spu (spu),
|
||||
KEY idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置';
|
||||
2223
backend/src/api/admin.js
Normal file
2223
backend/src/api/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
397
backend/src/api/aiBuyerShow.js
Normal file
397
backend/src/api/aiBuyerShow.js
Normal file
@ -0,0 +1,397 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const axios = require('axios');
|
||||
const {
|
||||
buildDefaultPrompt,
|
||||
createAiBuyerShowJob,
|
||||
generateAiBuyerShowReviews,
|
||||
getAiBuyerShowHistoryDetail,
|
||||
getAiBuyerShowJob,
|
||||
getQueueStats,
|
||||
getWorkerRuntimeStatus,
|
||||
listAiBuyerShowHistory,
|
||||
listAiBuyerShowJobs,
|
||||
testAiBuyerShowDatabase,
|
||||
} = require('../services/aiBuyerShowService');
|
||||
const { buildBuyerShowPrompt } = require('../services/aiBuyerShowReviewService');
|
||||
const {
|
||||
createSeedPack,
|
||||
deleteSeedPack,
|
||||
getSeedPackDownload,
|
||||
listSeedPacks,
|
||||
MAX_SEED_PACK_FILE_BYTES,
|
||||
} = require('../services/aiBuyerShowSeedPackService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024,
|
||||
files: 10,
|
||||
},
|
||||
fileFilter: (_req, file, callback) => {
|
||||
if (String(file.mimetype || '').startsWith('image/')) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(new Error('仅支持上传图片文件'));
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFields = upload.fields([
|
||||
{ name: 'productImages', maxCount: 5 },
|
||||
{ name: 'sceneReferenceImages', maxCount: 4 },
|
||||
]);
|
||||
|
||||
function runUpload(req, res, next) {
|
||||
uploadFields(req, res, (error) => {
|
||||
if (!error) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
|
||||
res.status(413).json({ success: false, error: '图片不能超过 20MB' });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ success: false, error: error.message || '图片上传失败' });
|
||||
});
|
||||
}
|
||||
|
||||
const seedPackUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: MAX_SEED_PACK_FILE_BYTES,
|
||||
files: 1,
|
||||
},
|
||||
fileFilter: (_req, file, callback) => {
|
||||
const isZip = String(file.mimetype || '') === 'application/zip'
|
||||
|| String(file.mimetype || '') === 'application/x-zip-compressed'
|
||||
|| /\.zip$/i.test(String(file.originalname || ''));
|
||||
if (isZip) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(new Error('种菜资源包仅支持上传 ZIP 文件'));
|
||||
},
|
||||
}).single('file');
|
||||
|
||||
function runSeedPackUpload(req, res, next) {
|
||||
seedPackUpload(req, res, (error) => {
|
||||
if (!error) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
|
||||
res.status(413).json({ success: false, error: '种菜资源包 ZIP 不能超过 200MB' });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ success: false, error: error.message || '种菜资源包上传失败' });
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeList(value) {
|
||||
const raw = Array.isArray(value) ? value : String(value || '').split(/[\n,,]+/);
|
||||
return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function isAllowedImageUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(String(value || ''));
|
||||
return ['http:', 'https:'].includes(parsed.protocol) && (
|
||||
parsed.hostname.endsWith('aliyuncs.com')
|
||||
|| parsed.hostname.endsWith('volces.com')
|
||||
|| parsed.hostname.endsWith('jiansuntuzhi.cn')
|
||||
|| parsed.hostname.endsWith('yydd.ai')
|
||||
|| parsed.hostname.endsWith('jiansun.vip')
|
||||
);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/runtime', async (_req, res) => {
|
||||
try {
|
||||
const databaseStatus = await testAiBuyerShowDatabase();
|
||||
const queue = databaseStatus.ok ? await getQueueStats() : null;
|
||||
const worker = databaseStatus.ok ? await getWorkerRuntimeStatus(queue || {}) : {
|
||||
enabled: false,
|
||||
hasApiKey: false,
|
||||
status: 'database_unavailable',
|
||||
source: 'job-table-observation',
|
||||
lastActivityAt: null,
|
||||
lastError: '',
|
||||
};
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
database: databaseStatus,
|
||||
queue,
|
||||
worker,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀运行状态查询失败',
|
||||
data: {
|
||||
database: {
|
||||
ok: false,
|
||||
missingTables: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/prompt-preview', express.json({ limit: '1mb' }), async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
prompt: body.review
|
||||
? buildBuyerShowPrompt({
|
||||
review: body.review,
|
||||
analysis: body.analysis || {},
|
||||
style: body.buyerShowStyle,
|
||||
})
|
||||
: buildDefaultPrompt(body),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message || '提示词预览失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/reviews', runUpload, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = await generateAiBuyerShowReviews({
|
||||
productName: body.productName || body.spu,
|
||||
spu: body.spu || body.productName,
|
||||
scene: body.scene,
|
||||
userType: body.userType,
|
||||
sellingPoints: body.sellingPoints,
|
||||
useEmoji: body.useEmoji,
|
||||
reviewTotal: body.reviewTotal,
|
||||
productImageFiles: req.files?.productImages || [],
|
||||
}, req.user || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] generate reviews failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '攻心评价生成失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/jobs', runUpload, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = await createAiBuyerShowJob({
|
||||
productName: body.productName || body.spu,
|
||||
spu: body.spu || body.productName,
|
||||
scene: body.scene,
|
||||
userType: body.userType,
|
||||
sellingPoints: body.sellingPoints,
|
||||
reviewText: body.reviewText,
|
||||
generationId: body.generationId,
|
||||
reviewRecordId: body.reviewRecordId,
|
||||
prompt: body.prompt,
|
||||
negativePrompt: body.negativePrompt,
|
||||
buyerShowStyle: body.buyerShowStyle,
|
||||
imageCount: 5,
|
||||
aspectRatio: body.aspectRatio,
|
||||
imageSize: body.imageSize,
|
||||
productImageUrls: normalizeList(body.productImageUrls),
|
||||
sceneReferenceImageUrls: normalizeList(body.sceneReferenceImageUrls),
|
||||
productImageFiles: req.files?.productImages || [],
|
||||
sceneReferenceImageFiles: req.files?.sceneReferenceImages || [],
|
||||
}, req.user || {});
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] create job failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀任务创建失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/jobs', async (req, res) => {
|
||||
try {
|
||||
const result = await listAiBuyerShowJobs({
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
spu: req.query.spu,
|
||||
includeTotal: req.query.includeTotal !== '0',
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] list jobs failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀任务列表查询失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const data = await listAiBuyerShowHistory({
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
});
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] list history failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '历史生成记录加载失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/history/:id', async (req, res) => {
|
||||
try {
|
||||
const data = await getAiBuyerShowHistoryDetail(req.params.id);
|
||||
if (!data) {
|
||||
res.status(404).json({ success: false, error: '历史生成记录不存在' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] get history failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '历史生成详情加载失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/image-proxy', async (req, res) => {
|
||||
const imageUrl = String(req.query.url || '').trim();
|
||||
if (!isAllowedImageUrl(imageUrl)) {
|
||||
res.status(400).json({ success: false, error: '不支持的图片地址' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(imageUrl, {
|
||||
responseType: 'stream',
|
||||
timeout: 120000,
|
||||
maxContentLength: 30 * 1024 * 1024,
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
});
|
||||
res.set('Content-Type', response.headers['content-type'] || 'application/octet-stream');
|
||||
res.set('Cache-Control', 'private, max-age=300');
|
||||
response.data.pipe(res);
|
||||
} catch (error) {
|
||||
res.status(error.response?.status || 502).json({
|
||||
success: false,
|
||||
error: error.message || '图片下载失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/jobs/:jobId', async (req, res) => {
|
||||
try {
|
||||
const job = await getAiBuyerShowJob(req.params.jobId);
|
||||
if (!job) {
|
||||
res.status(404).json({ success: false, error: '任务不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data: job });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] get job failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀任务查询失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/queue-stats', async (req, res) => {
|
||||
try {
|
||||
const data = await getQueueStats(req.query.jobId || '');
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'AI买家秀队列统计查询失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/seed-packs', runSeedPackUpload, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const data = await createSeedPack({
|
||||
shopName: body.shopName,
|
||||
productId: body.productId,
|
||||
skuName: body.skuName,
|
||||
fileName: body.fileName || req.file?.originalname,
|
||||
reviewCount: body.reviewCount,
|
||||
file: req.file,
|
||||
}, req.user || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] create seed pack failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '种菜资源包上传失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/seed-packs', async (req, res) => {
|
||||
try {
|
||||
const data = await listSeedPacks({
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
});
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] list seed packs failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '种菜资源包列表查询失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/seed-packs/:id/download', async (req, res) => {
|
||||
try {
|
||||
const data = await getSeedPackDownload(req.params.id);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] get seed pack download failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '种菜资源包下载信息获取失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/seed-packs/:id', async (req, res) => {
|
||||
try {
|
||||
const data = await deleteSeedPack(req.params.id);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[ai-buyer-show] delete seed pack failed:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '种菜资源包删除失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
100
backend/src/api/alibaba1688ProductExternal.js
Normal file
100
backend/src/api/alibaba1688ProductExternal.js
Normal file
@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
requireDesktopPageAccess,
|
||||
requireExternalAutomationApiKey,
|
||||
} = require('../services/externalAutomationAuth');
|
||||
const {
|
||||
listAlibaba1688Products,
|
||||
getAlibaba1688Product,
|
||||
createAlibaba1688Product,
|
||||
replaceAlibaba1688Product,
|
||||
updateAlibaba1688Product,
|
||||
deleteAlibaba1688Product,
|
||||
} = require('../services/alibaba1688ProductService');
|
||||
|
||||
const router = express.Router();
|
||||
const requireAlibabaAccess = requireDesktopPageAccess('planning_upload', 'market_insight');
|
||||
|
||||
function requestContext(req) {
|
||||
return {
|
||||
apiKeyId: req.externalApiKey?.id || '',
|
||||
apiKeyName: req.externalApiKey?.name || '',
|
||||
remoteIp: req.externalApiKey?.remoteIp || '',
|
||||
};
|
||||
}
|
||||
|
||||
function handleError(res, error, fallbackMessage) {
|
||||
const statusCode = error.statusCode || error.status || 500;
|
||||
if (statusCode >= 500) console.error('[Alibaba1688ProductAPI] 请求失败:', error);
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
code: error.code || undefined,
|
||||
error: error.message || fallbackMessage,
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await listAlibaba1688Products(req.query || {});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '查询 1688 商品失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:productId', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await getAlibaba1688Product(req.params.productId);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '查询 1688 商品详情失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await createAlibaba1688Product(req.body || {}, requestContext(req));
|
||||
return res.status(201).json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '新增 1688 商品失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 客户端同步使用 PUT。相同 productId 会完整覆盖快照,不做字段合并。
|
||||
router.put('/:productId', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await replaceAlibaba1688Product(
|
||||
req.params.productId,
|
||||
req.body || {},
|
||||
requestContext(req)
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '覆盖 1688 商品失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:productId', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await updateAlibaba1688Product(req.params.productId, req.body || {});
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '修改 1688 商品状态失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:productId', requireExternalAutomationApiKey, requireAlibabaAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await deleteAlibaba1688Product(req.params.productId);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '删除 1688 商品失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
7500
backend/src/api/analysis.js
Normal file
7500
backend/src/api/analysis.js
Normal file
File diff suppressed because it is too large
Load Diff
347
backend/src/api/auth.js
Normal file
347
backend/src/api/auth.js
Normal file
@ -0,0 +1,347 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const database = require('../config/database');
|
||||
const { getPublicAuthConfig, getAuthConfig } = require('../services/authConfig');
|
||||
const { getResolvedUserWithPageAccess } = require('../services/pageAccess');
|
||||
const {
|
||||
buildCallbackUrl,
|
||||
createOAuthState,
|
||||
exchangeDingTalkCode,
|
||||
exchangeGoogleCode,
|
||||
getStateCookieName,
|
||||
issueLoginResult,
|
||||
upsertUserFromSocial,
|
||||
verifyOAuthState,
|
||||
} = require('../services/socialAuth');
|
||||
const {
|
||||
THIRD_PARTY_FLOW,
|
||||
completeThirdPartyAuthorization,
|
||||
} = require('../services/thirdPartySsoService');
|
||||
const {
|
||||
DIRECT_LOGIN_FLOW,
|
||||
completeDirectLogin,
|
||||
} = require('../services/thirdPartySsoDirectService');
|
||||
|
||||
function parseCookies(req) {
|
||||
const cookieHeader = req.headers.cookie || '';
|
||||
return cookieHeader.split(';').reduce((acc, segment) => {
|
||||
const [rawKey, ...rest] = segment.trim().split('=');
|
||||
if (!rawKey) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[rawKey] = decodeURIComponent(rest.join('=') || '');
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function resolveBaseUrl(req, authBaseUrl, fallbackOrigin) {
|
||||
const candidate = fallbackOrigin || authBaseUrl;
|
||||
if (candidate) {
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
return `${parsed.protocol}//${parsed.host}`;
|
||||
} catch (_error) {
|
||||
// ignore invalid input and fallback below
|
||||
}
|
||||
}
|
||||
|
||||
const forwardedProto = req.headers['x-forwarded-proto'];
|
||||
const forwardedHost = req.headers['x-forwarded-host'];
|
||||
const host = forwardedHost || req.headers.host;
|
||||
const protocol = forwardedProto || req.protocol || 'http';
|
||||
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
function setStateCookie(res, provider, state) {
|
||||
res.cookie(getStateCookieName(provider), state, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 15 * 60 * 1000,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
function clearStateCookie(res, provider) {
|
||||
res.clearCookie(getStateCookieName(provider), {
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
async function startOAuthLogin(req, res, provider) {
|
||||
try {
|
||||
const authConfig = await getAuthConfig();
|
||||
const fallbackOrigin = typeof req.query.origin === 'string' ? req.query.origin : '';
|
||||
const baseUrl = resolveBaseUrl(req, authConfig.authBaseUrl, fallbackOrigin);
|
||||
const callbackUrl = buildCallbackUrl(baseUrl, provider);
|
||||
const state = createOAuthState(provider);
|
||||
|
||||
setStateCookie(res, provider, state);
|
||||
|
||||
if (provider === 'dingtalk') {
|
||||
if (!authConfig.dingtalkEnabled || !authConfig.dingtalkAppKey || !authConfig.dingtalkAppSecret) {
|
||||
return res.status(400).json({ error: '钉钉登录未配置或未启用' });
|
||||
}
|
||||
|
||||
const authorizeUrl =
|
||||
'https://login.dingtalk.com/oauth2/auth?' +
|
||||
`redirect_uri=${encodeURIComponent(callbackUrl)}&` +
|
||||
'response_type=code&' +
|
||||
`client_id=${encodeURIComponent(authConfig.dingtalkAppKey)}&` +
|
||||
'scope=openid&' +
|
||||
`state=${encodeURIComponent(state)}&` +
|
||||
'prompt=consent';
|
||||
|
||||
return res.redirect(authorizeUrl);
|
||||
}
|
||||
|
||||
if (provider === 'google') {
|
||||
if (!authConfig.googleEnabled || !authConfig.googleClientId || !authConfig.googleClientSecret) {
|
||||
return res.status(400).json({ error: 'Google 登录未配置或未启用' });
|
||||
}
|
||||
|
||||
const authorizeUrl =
|
||||
'https://accounts.google.com/o/oauth2/v2/auth?' +
|
||||
`client_id=${encodeURIComponent(authConfig.googleClientId)}&` +
|
||||
`redirect_uri=${encodeURIComponent(callbackUrl)}&` +
|
||||
'response_type=code&' +
|
||||
'scope=openid%20email%20profile&' +
|
||||
'access_type=online&' +
|
||||
'prompt=consent&' +
|
||||
`state=${encodeURIComponent(state)}`;
|
||||
|
||||
return res.redirect(authorizeUrl);
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: '不支持的登录来源' });
|
||||
} catch (error) {
|
||||
console.error(`启动 ${provider} 登录失败:`, error);
|
||||
return res.status(500).json({ error: '启动第三方登录失败' });
|
||||
}
|
||||
}
|
||||
|
||||
exports.getProviders = async (_req, res) => {
|
||||
try {
|
||||
const config = await getPublicAuthConfig();
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
console.error('获取登录方式失败:', error);
|
||||
res.status(500).json({ error: '获取登录方式失败' });
|
||||
}
|
||||
};
|
||||
|
||||
exports.startDingTalkLogin = async (req, res) => startOAuthLogin(req, res, 'dingtalk');
|
||||
exports.startGoogleLogin = async (req, res) => startOAuthLogin(req, res, 'google');
|
||||
|
||||
function isLoopbackRequest(req) {
|
||||
const remoteAddress = String(req.socket?.remoteAddress || '').replace(/^::ffff:/i, '');
|
||||
return ['127.0.0.1', '::1'].includes(remoteAddress);
|
||||
}
|
||||
|
||||
exports.localPreview = async (req, res) => {
|
||||
const explicitlyEnabled = String(process.env.ENABLE_LOCAL_PREVIEW_AUTH || '').toLowerCase() === 'true';
|
||||
if ((process.env.NODE_ENV === 'production' && !explicitlyEnabled) || !isLoopbackRequest(req)) {
|
||||
return res.status(404).json({ error: '本地预览登录不可用' });
|
||||
}
|
||||
|
||||
try {
|
||||
const username = 'preview';
|
||||
const email = 'preview@local.test';
|
||||
let users = await database.local.query(
|
||||
`SELECT id, username, name, email, role, status, avatar_url, auth_source
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
LIMIT 1`,
|
||||
[username]
|
||||
);
|
||||
if (!users[0]) {
|
||||
const passwordHash = await bcrypt.hash(`preview-${Date.now()}-${Math.random()}`, 10);
|
||||
const result = await database.local.insert('users', {
|
||||
username,
|
||||
password_hash: passwordHash,
|
||||
name: '本地预览账号',
|
||||
email,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
auth_source: 'local-preview',
|
||||
});
|
||||
users = await database.local.query(
|
||||
`SELECT id, username, name, email, role, status, avatar_url, auth_source
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[result.insertId]
|
||||
);
|
||||
} else if (
|
||||
users[0].status !== 'active'
|
||||
|| users[0].auth_source !== 'local-preview'
|
||||
|| users[0].role !== 'admin'
|
||||
) {
|
||||
await database.local.update('users', users[0].id, {
|
||||
status: 'active',
|
||||
auth_source: 'local-preview',
|
||||
role: 'admin',
|
||||
});
|
||||
users[0].status = 'active';
|
||||
users[0].auth_source = 'local-preview';
|
||||
users[0].role = 'admin';
|
||||
}
|
||||
|
||||
const result = await issueLoginResult(users[0]);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
console.error('本地预览登录失败:', error);
|
||||
return res.status(500).json({ error: '本地预览登录失败' });
|
||||
}
|
||||
};
|
||||
|
||||
// 登录(用户名密码认证)
|
||||
exports.login = async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '请输入用户名和密码' });
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await database.local.query(
|
||||
`SELECT id, username, password_hash, name, email, role, status, avatar_url, auth_source
|
||||
FROM users
|
||||
WHERE username = ? AND status = ?`,
|
||||
[username, 'active']
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
const passwordMatch = await bcrypt.compare(password, user.password_hash || '');
|
||||
if (!passwordMatch) {
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
const result = await issueLoginResult({
|
||||
...user,
|
||||
auth_source: 'local',
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
res.status(500).json({ error: '登录失败,请稍后重试' });
|
||||
}
|
||||
};
|
||||
|
||||
// OAuth 回调
|
||||
exports.callback = async (req, res) => {
|
||||
const { provider, code, state, origin } = req.body || {};
|
||||
|
||||
if (!provider || !code || !state) {
|
||||
return res.status(400).json({ error: '缺少必要的登录参数' });
|
||||
}
|
||||
|
||||
try {
|
||||
const cookies = parseCookies(req);
|
||||
const stateCookieName = getStateCookieName(provider);
|
||||
const cookieState = cookies[stateCookieName];
|
||||
|
||||
if (!cookieState || cookieState !== state) {
|
||||
return res.status(400).json({ error: '登录状态已失效,请重新发起登录' });
|
||||
}
|
||||
|
||||
const stateClaims = verifyOAuthState(provider, state);
|
||||
|
||||
const authConfig = await getAuthConfig();
|
||||
const baseUrl = resolveBaseUrl(req, authConfig.authBaseUrl, origin);
|
||||
let profile;
|
||||
|
||||
if (provider === 'dingtalk') {
|
||||
profile = await exchangeDingTalkCode(code);
|
||||
} else if (provider === 'google') {
|
||||
profile = await exchangeGoogleCode(code, buildCallbackUrl(baseUrl, 'google'));
|
||||
} else {
|
||||
return res.status(400).json({ error: '不支持的登录来源' });
|
||||
}
|
||||
|
||||
const user = await upsertUserFromSocial(profile);
|
||||
if (stateClaims.flow === DIRECT_LOGIN_FLOW) {
|
||||
if (provider !== 'dingtalk') {
|
||||
return res.status(400).json({ error: '第三方托管登录仅支持钉钉' });
|
||||
}
|
||||
const completed = await completeDirectLogin({
|
||||
stateClaims,
|
||||
userId: user.id,
|
||||
});
|
||||
clearStateCookie(res, provider);
|
||||
return res.json({
|
||||
direct_login_complete: true,
|
||||
request_id: completed.requestId,
|
||||
completion_redirect: completed.completionRedirect || undefined,
|
||||
});
|
||||
}
|
||||
if (stateClaims.flow === THIRD_PARTY_FLOW) {
|
||||
if (provider !== 'dingtalk') {
|
||||
return res.status(400).json({ error: '第三方统一认证仅支持钉钉登录' });
|
||||
}
|
||||
const authorization = await completeThirdPartyAuthorization({
|
||||
stateClaims,
|
||||
userId: user.id,
|
||||
requestContext: {
|
||||
ipAddress: req.ip || req.socket?.remoteAddress || '',
|
||||
userAgent: req.get('user-agent') || '',
|
||||
},
|
||||
});
|
||||
clearStateCookie(res, provider);
|
||||
return res.json({
|
||||
authorization_redirect: authorization.redirectUrl,
|
||||
expires_in: authorization.expiresIn,
|
||||
});
|
||||
}
|
||||
const result = await issueLoginResult(user);
|
||||
clearStateCookie(res, provider);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('OAuth 登录失败:', error);
|
||||
clearStateCookie(res, provider);
|
||||
res.status(500).json({ error: error.message || '第三方登录失败' });
|
||||
}
|
||||
};
|
||||
|
||||
// 验证 token
|
||||
exports.verify = async (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
return res.status(401).json({ valid: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = jwt.verify(token, process.env.CUBEJS_SECRET);
|
||||
const user = await getResolvedUserWithPageAccess(decoded.sub);
|
||||
if (!user || user.status !== 'active') {
|
||||
return res.status(401).json({ valid: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
user: {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
picture: user.picture || '',
|
||||
authSource: user.auth_source || 'local',
|
||||
departmentId: user.departmentId,
|
||||
departmentName: user.departmentName || '',
|
||||
allowedPageKeys: user.allowedPageKeys || [],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(401).json({ valid: false });
|
||||
}
|
||||
};
|
||||
199
backend/src/api/campaignCloud.js
Normal file
199
backend/src/api/campaignCloud.js
Normal file
@ -0,0 +1,199 @@
|
||||
'use strict';
|
||||
|
||||
// 活动报名云端任务接口(自魔术师云后端移植,契约 {code,msg,data} 保持一致)。
|
||||
// 鉴权复用 maixiang 现有 authMiddleware(桌面端钉钉登录 JWT),不再使用魔术师 Key/短信登录。
|
||||
|
||||
const express = require('express');
|
||||
const authMiddleware = require('../middleware/auth');
|
||||
const service = require('../services/campaignCloudService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function operatorOf(req) {
|
||||
return req.user || {};
|
||||
}
|
||||
|
||||
router.get('/me', authMiddleware, async (req, res) => {
|
||||
const user = operatorOf(req);
|
||||
return res.json(service.ok({
|
||||
id: user.sub || user.userId || 1,
|
||||
phone: user.phone || null,
|
||||
name: user.name || user.username || '',
|
||||
nickname: user.name || user.username || '',
|
||||
}));
|
||||
});
|
||||
|
||||
router.get('/activity-ids', authMiddleware, async (_req, res) => {
|
||||
return res.json(service.ok(service.getSupportedActivityIds()));
|
||||
});
|
||||
|
||||
// ---------- 原价卡大促报名任务 ----------
|
||||
|
||||
router.post('/original-price-promotion', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await service.createPromotionGroup({
|
||||
operator: operatorOf(req),
|
||||
mallId: req.body?.mallId,
|
||||
settings: req.body?.settings,
|
||||
goodsList: req.body?.goodsList,
|
||||
remark: req.body?.remark,
|
||||
});
|
||||
return res.json(service.ok(data));
|
||||
} catch (error) {
|
||||
console.error('[campaign-cloud] create promotion failed:', error);
|
||||
return res.status(500).json(service.fail(1, error.message || '创建报名任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/original-price-promotion/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await service.listPromotionGroups({
|
||||
operator: operatorOf(req),
|
||||
mallId: req.body?.mallId,
|
||||
page: req.body?.page,
|
||||
limit: req.body?.limit,
|
||||
});
|
||||
return res.json(service.ok(data));
|
||||
} catch (error) {
|
||||
console.error('[campaign-cloud] list promotion failed:', error);
|
||||
return res.status(500).json(service.fail(1, error.message || '查询报名任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/original-price-promotion/task/start', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await service.startTask({ operator: operatorOf(req), taskId: req.body?.taskId });
|
||||
return res.json(service.ok(null));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '启动任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/original-price-promotion/task/complete', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await service.completeTask({
|
||||
operator: operatorOf(req),
|
||||
taskId: req.body?.taskId,
|
||||
status: req.body?.status,
|
||||
errorMsg: req.body?.errorMsg,
|
||||
});
|
||||
return res.json(service.ok(null));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '完成任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/original-price-promotion/tasks/clear', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await service.clearTerminalTasks({ operator: operatorOf(req), kind: 'promotion' });
|
||||
return res.json(service.ok(data));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '清空历史任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 初始化(复制SKU)任务 ----------
|
||||
|
||||
router.post('/copy-sku', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await service.createCopySkuTask({
|
||||
operator: operatorOf(req),
|
||||
mallId: req.body?.mallId,
|
||||
goodsId: req.body?.goodsId,
|
||||
});
|
||||
return res.json(service.ok(data));
|
||||
} catch (error) {
|
||||
console.error('[campaign-cloud] create copy-sku failed:', error);
|
||||
return res.status(500).json(service.fail(1, error.message || '创建初始化任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/copy-sku/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await service.listCopySkuTasks({
|
||||
operator: operatorOf(req),
|
||||
mallId: req.body?.mallId,
|
||||
page: req.body?.page,
|
||||
limit: req.body?.limit,
|
||||
});
|
||||
return res.json(service.ok(data));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '查询初始化任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/copy-sku/task/start', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await service.startTask({ operator: operatorOf(req), id: req.body?.id });
|
||||
return res.json(service.ok(null));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '启动任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/copy-sku/task/complete', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await service.completeTask({
|
||||
operator: operatorOf(req),
|
||||
id: req.body?.id,
|
||||
status: req.body?.status,
|
||||
errorMsg: req.body?.errorMsg,
|
||||
});
|
||||
return res.json(service.ok(null));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '完成任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/copy-sku/tasks/clear', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await service.clearTerminalTasks({ operator: operatorOf(req), kind: 'copy-sku' });
|
||||
return res.json(service.ok(data));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '清空历史任务失败'));
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 商品初始化记录 ----------
|
||||
|
||||
router.post('/copy-sku/goods-init/report', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await service.reportGoodsInit({ operator: operatorOf(req), payload: req.body || {} });
|
||||
return res.json(service.ok(data));
|
||||
} catch (error) {
|
||||
console.error('[campaign-cloud] report goods init failed:', error);
|
||||
return res.status(500).json(service.fail(1, error.message || '上报初始化数据失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/copy-sku/goods-init/records', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await service.getGoodsInitRecords(req.body?.goodIds);
|
||||
return res.json(service.ok(data));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '查询初始化记录失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/copy-sku/goods-init', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await service.getGoodsInit(req.query?.goodsId);
|
||||
if (!data) {
|
||||
return res.json(service.fail(3009, '商品初始化信息不存在'));
|
||||
}
|
||||
return res.json(service.ok(data));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '查询初始化数据失败'));
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/copy-sku/goods-init', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await service.deleteGoodsInit(req.query?.goodsId);
|
||||
return res.json(service.ok(null));
|
||||
} catch (error) {
|
||||
return res.status(500).json(service.fail(1, error.message || '删除初始化数据失败'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
414
backend/src/api/channel.js
Normal file
414
backend/src/api/channel.js
Normal file
@ -0,0 +1,414 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const authMiddleware = require('../middleware/auth');
|
||||
const {
|
||||
getChannelList,
|
||||
exportChannelListCsv,
|
||||
createChannelExportJob,
|
||||
getChannelExportJob,
|
||||
getChannelExportJobFile,
|
||||
getChannelDashboard,
|
||||
getChannelIdAnalysisList,
|
||||
getChannelIdAnalysisDashboard,
|
||||
getChannelSpuList,
|
||||
getChannelStockMonitorDashboard,
|
||||
getChannelDetail,
|
||||
getChannelLargeCustomerList,
|
||||
getChannelLargeCustomerDashboard,
|
||||
getChannelLargeCustomerDetail,
|
||||
refreshChannelLargeCustomerReadStore,
|
||||
} = require('../services/channelAnalysis');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function parseRangeHeader(rangeHeader, totalSize) {
|
||||
if (!rangeHeader) return null;
|
||||
|
||||
const match = String(rangeHeader).match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match) return { invalid: true };
|
||||
|
||||
let start = match[1] === '' ? null : Number.parseInt(match[1], 10);
|
||||
let end = match[2] === '' ? null : Number.parseInt(match[2], 10);
|
||||
|
||||
if (start === null && end === null) return { invalid: true };
|
||||
if (start === null) {
|
||||
const suffixLength = end;
|
||||
if (!Number.isFinite(suffixLength) || suffixLength <= 0) return { invalid: true };
|
||||
start = Math.max(totalSize - suffixLength, 0);
|
||||
end = totalSize - 1;
|
||||
} else {
|
||||
if (!Number.isFinite(start) || start < 0) return { invalid: true };
|
||||
end = end === null ? totalSize - 1 : end;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(end) || end < start || start >= totalSize) {
|
||||
return { invalid: true };
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
end: Math.min(end, totalSize - 1),
|
||||
};
|
||||
}
|
||||
|
||||
function setCsvDownloadHeaders(res, { filename, totalSize, contentLength, range }) {
|
||||
const encodedFilename = encodeURIComponent(filename || `channel-data-${Date.now()}.csv`);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFilename}`);
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Content-Length', String(contentLength));
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
if (range) {
|
||||
res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${totalSize}`);
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelList({
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
sortField: req.query.sortField,
|
||||
sortOrder: req.query.sortOrder,
|
||||
channels: req.query.channels,
|
||||
statuses: req.query.statuses,
|
||||
purchasers: req.query.purchasers,
|
||||
minAfterSaleAmountRate: req.query.minAfterSaleAmountRate,
|
||||
maxAfterSaleAmountRate: req.query.maxAfterSaleAmountRate,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道数据失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道数据失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/export', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await exportChannelListCsv({
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
keyword: req.query.keyword,
|
||||
sortField: req.query.sortField,
|
||||
sortOrder: req.query.sortOrder,
|
||||
channels: req.query.channels,
|
||||
statuses: req.query.statuses,
|
||||
purchasers: req.query.purchasers,
|
||||
minAfterSaleAmountRate: req.query.minAfterSaleAmountRate,
|
||||
maxAfterSaleAmountRate: req.query.maxAfterSaleAmountRate,
|
||||
});
|
||||
|
||||
const encodedFilename = encodeURIComponent(data.filename || `channel-data-${Date.now()}.csv`);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFilename}`);
|
||||
res.setHeader('X-Total-Count', String(data.total || 0));
|
||||
res.setHeader('X-Export-Mode', data.exportMode || 'exact-enriched');
|
||||
res.send(data.csv);
|
||||
} catch (error) {
|
||||
console.error('导出渠道数据失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '导出渠道数据失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/export-jobs', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = createChannelExportJob({
|
||||
startDate: req.body.startDate,
|
||||
endDate: req.body.endDate,
|
||||
keyword: req.body.keyword,
|
||||
sortField: req.body.sortField,
|
||||
sortOrder: req.body.sortOrder,
|
||||
channels: req.body.channels,
|
||||
statuses: req.body.statuses,
|
||||
purchasers: req.body.purchasers,
|
||||
minAfterSaleAmountRate: req.body.minAfterSaleAmountRate,
|
||||
maxAfterSaleAmountRate: req.body.maxAfterSaleAmountRate,
|
||||
}, req.user);
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('创建渠道数据导出任务失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '创建渠道数据导出任务失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/export-jobs/:jobId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelExportJob(req.params.jobId);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道数据导出任务失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/export-jobs/:jobId/download', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { filePath, filename } = await getChannelExportJobFile(req.params.jobId);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const totalSize = stat.size;
|
||||
const range = parseRangeHeader(req.headers.range, totalSize);
|
||||
|
||||
if (range?.invalid) {
|
||||
res.status(416).setHeader('Content-Range', `bytes */${totalSize}`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (range) {
|
||||
const contentLength = range.end - range.start + 1;
|
||||
setCsvDownloadHeaders(res, { filename, totalSize, contentLength, range });
|
||||
res.status(206);
|
||||
fs.createReadStream(filePath, { start: range.start, end: range.end }).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvDownloadHeaders(res, { filename, totalSize, contentLength: totalSize });
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
} catch (error) {
|
||||
console.error('下载渠道数据导出文件失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '下载渠道数据导出文件失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/dashboard', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelDashboard({
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
keyword: req.query.keyword,
|
||||
limit: req.query.limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道多维分析仪表盘失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道多维分析仪表盘失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/spu-list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelSpuList({
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
sortField: req.query.sortField,
|
||||
sortOrder: req.query.sortOrder,
|
||||
statuses: req.query.statuses,
|
||||
purchasers: req.query.purchasers,
|
||||
minAfterSaleAmountRate: req.query.minAfterSaleAmountRate,
|
||||
maxAfterSaleAmountRate: req.query.maxAfterSaleAmountRate,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道SPU图片数据失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道SPU图片数据失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stock-monitor-dashboard', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelStockMonitorDashboard({
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
keyword: req.query.keyword,
|
||||
limit: req.query.limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道库存影响仪表盘失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道库存影响仪表盘失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/id-analysis-dashboard', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelIdAnalysisDashboard({
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
keyword: req.query.keyword,
|
||||
limit: req.query.limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道ID多维分析仪表盘失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道ID多维分析仪表盘失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/id-analysis', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelIdAnalysisList({
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
sortField: req.query.sortField,
|
||||
sortOrder: req.query.sortOrder,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道ID分析失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道ID分析失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/detail', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelDetail({
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
channelName: req.query.channelName,
|
||||
shopName: req.query.shopName,
|
||||
skuCode: req.query.skuCode,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道明细失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道明细失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/large-customers', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelLargeCustomerList({
|
||||
startMonth: req.query.startMonth,
|
||||
endMonth: req.query.endMonth,
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
sortField: req.query.sortField,
|
||||
sortOrder: req.query.sortOrder,
|
||||
minSingleOrderQty: req.query.minSingleOrderQty,
|
||||
autoSync: req.query.autoSync,
|
||||
channels: req.query.channels,
|
||||
shops: req.query.shops,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道大客户分析失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道大客户分析失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/large-customers/dashboard', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelLargeCustomerDashboard({
|
||||
startMonth: req.query.startMonth,
|
||||
endMonth: req.query.endMonth,
|
||||
keyword: req.query.keyword,
|
||||
minSingleOrderQty: req.query.minSingleOrderQty,
|
||||
channels: req.query.channels,
|
||||
shops: req.query.shops,
|
||||
limit: req.query.limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道大客户多维分析仪表盘失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道大客户多维分析仪表盘失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/large-customers/detail', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getChannelLargeCustomerDetail({
|
||||
startMonth: req.query.startMonth,
|
||||
endMonth: req.query.endMonth,
|
||||
buyerId: req.query.buyerId,
|
||||
channelName: req.query.channelName,
|
||||
shopName: req.query.shopName,
|
||||
channels: req.query.channels,
|
||||
shops: req.query.shops,
|
||||
minSingleOrderQty: req.query.minSingleOrderQty,
|
||||
limit: req.query.limit,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取渠道大客户订单明细失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取渠道大客户订单明细失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/large-customers/refresh', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await refreshChannelLargeCustomerReadStore({
|
||||
startMonth: req.body.startMonth,
|
||||
endMonth: req.body.endMonth,
|
||||
page: req.body.page,
|
||||
pageSize: req.body.pageSize,
|
||||
keyword: req.body.keyword,
|
||||
sortField: req.body.sortField,
|
||||
sortOrder: req.body.sortOrder,
|
||||
minSingleOrderQty: req.body.minSingleOrderQty,
|
||||
channels: req.body.channels,
|
||||
shops: req.body.shops,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('刷新渠道大客户分析失败:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
success: false,
|
||||
error: error.message || '刷新渠道大客户分析失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
93
backend/src/api/csQuality.js
Normal file
93
backend/src/api/csQuality.js
Normal file
@ -0,0 +1,93 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getGoods,
|
||||
getChatDataMeta,
|
||||
getCustomers,
|
||||
getMessages,
|
||||
getProductProfile,
|
||||
startAnalysisJob,
|
||||
getAnalysisJob,
|
||||
runAnalysis,
|
||||
listHistory,
|
||||
getHistoryDetail,
|
||||
} = require('../services/csQualityService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '客服质检请求失败') {
|
||||
const status = Number(error?.statusCode) || (error?.message?.includes('不能超过30天') ? 400 : 500);
|
||||
console.error('[CsQuality]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
stack: error?.stack || null,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
router.get('/goods', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getGoods(req.query) });
|
||||
} catch (error) { sendError(res, error, '加载商品聊天列表失败'); }
|
||||
});
|
||||
|
||||
router.get('/data-meta', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getChatDataMeta() });
|
||||
} catch (error) { sendError(res, error, '加载聊天数据日期范围失败'); }
|
||||
});
|
||||
|
||||
router.get('/goods/:goodsId/profile', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getProductProfile({ ...req.query, goodsId: req.params.goodsId }) });
|
||||
} catch (error) { sendError(res, error, '加载商品表现失败'); }
|
||||
});
|
||||
|
||||
router.get('/customers', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getCustomers(req.query) });
|
||||
} catch (error) { sendError(res, error, '加载客户列表失败'); }
|
||||
});
|
||||
|
||||
router.get('/messages', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMessages(req.query) });
|
||||
} catch (error) { sendError(res, error, '加载聊天内容失败'); }
|
||||
});
|
||||
|
||||
router.post('/analysis', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await runAnalysis(req.body || {}, req.user || {}) });
|
||||
} catch (error) { sendError(res, error, 'AI质检失败'); }
|
||||
});
|
||||
|
||||
router.post('/analysis-jobs', async (req, res) => {
|
||||
try {
|
||||
const data = await startAnalysisJob(req.body || {}, req.user || {});
|
||||
res.status(202).json({ success: true, data });
|
||||
} catch (error) { sendError(res, error, '创建AI质检任务失败'); }
|
||||
});
|
||||
|
||||
router.get('/analysis-jobs/:analysisId', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getAnalysisJob(req.params.analysisId) });
|
||||
} catch (error) { sendError(res, error, '查询AI质检进度失败'); }
|
||||
});
|
||||
|
||||
router.get('/analysis-history', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await listHistory(req.query) });
|
||||
} catch (error) { sendError(res, error, '加载AI质检历史失败'); }
|
||||
});
|
||||
|
||||
router.get('/analysis-history/:id', async (req, res) => {
|
||||
try {
|
||||
const data = await getHistoryDetail(req.params.id);
|
||||
if (!data) return res.status(404).json({ success: false, error: '历史质检记录不存在' });
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) { return sendError(res, error, '加载AI质检历史详情失败'); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
37
backend/src/api/delistExternal.js
Normal file
37
backend/src/api/delistExternal.js
Normal file
@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { listDelistTasks } = require('../services/delistTaskService');
|
||||
const {
|
||||
requireExternalAutomationApiKey,
|
||||
requireDesktopPageAccess,
|
||||
} = require('../services/externalAutomationAuth');
|
||||
|
||||
const router = express.Router();
|
||||
const requireDelistAccess = requireDesktopPageAccess('planning_upload');
|
||||
|
||||
function handleError(res, error, fallbackMessage) {
|
||||
const statusCode = error.statusCode || error.status || 500;
|
||||
if (statusCode >= 500) {
|
||||
console.error('[DelistExternalAPI] 请求失败:', error);
|
||||
}
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error: error.message || fallbackMessage,
|
||||
data: error.data || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// 每日停产下架数据(只读展示;实际下架脚本由统一清货模块执行)
|
||||
router.get('/tasks', requireExternalAutomationApiKey, requireDelistAccess, async (req, res) => {
|
||||
try {
|
||||
const query = { ...(req.query || {}) };
|
||||
const data = await listDelistTasks(query);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '查询停产下架数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
231
backend/src/api/desktopClient.js
Normal file
231
backend/src/api/desktopClient.js
Normal file
@ -0,0 +1,231 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const authMiddleware = require('../middleware/auth');
|
||||
const {
|
||||
buildPublicUrl,
|
||||
getDefaultS3Config,
|
||||
getObjectFromDefaultS3,
|
||||
} = require('../services/objectStorage');
|
||||
const {
|
||||
compareVersions,
|
||||
getDesktopReleaseById,
|
||||
getLatestDesktopRelease,
|
||||
mapRelease,
|
||||
normalizeVersion,
|
||||
} = require('../services/desktopReleaseService');
|
||||
const {
|
||||
DesktopSessionError,
|
||||
createDesktopSession,
|
||||
createSsoTicket,
|
||||
exchangeSsoTicket,
|
||||
getDesktopBootstrap,
|
||||
revokeDesktopSession,
|
||||
} = require('../services/desktopSessionService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function publicRelease(release) {
|
||||
const mapped = mapRelease(release);
|
||||
if (!mapped) return null;
|
||||
return {
|
||||
id: mapped.id,
|
||||
version: mapped.version,
|
||||
releaseNotes: mapped.releaseNotes,
|
||||
platform: mapped.platform,
|
||||
architecture: mapped.architecture,
|
||||
fileName: mapped.fileName,
|
||||
fileSize: mapped.fileSize,
|
||||
sha256: mapped.sha256,
|
||||
publishedAt: mapped.publishedAt,
|
||||
downloadUrl: `/api/desktop-client/releases/${mapped.id}/download`,
|
||||
};
|
||||
}
|
||||
|
||||
function requestUserId(req) {
|
||||
return req.user?.sub ?? req.user?.id;
|
||||
}
|
||||
|
||||
function sendDesktopSessionError(res, error, fallbackMessage) {
|
||||
if (error instanceof DesktopSessionError) {
|
||||
return res.status(error.status).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
console.error(fallbackMessage, error);
|
||||
return res.status(500).json({ success: false, error: fallbackMessage });
|
||||
}
|
||||
|
||||
function safeScriptJson(value) {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.replace(/&/g, '\\u0026')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029');
|
||||
}
|
||||
|
||||
function sendSsoExchangeHtml(res, result) {
|
||||
const nonce = crypto.randomBytes(18).toString('base64');
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
`default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'`
|
||||
);
|
||||
res.type('html').send(`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>正在进入总控</title>
|
||||
<style nonce="${nonce}">html,body{height:100%;margin:0;background:#f7f8fa;color:#8c8c8c;font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{display:grid;place-items:center}</style>
|
||||
</head>
|
||||
<body><span>正在进入总控...</span>
|
||||
<script nonce="${nonce}">
|
||||
try {
|
||||
localStorage.setItem('token', ${safeScriptJson(result.accessToken)});
|
||||
localStorage.setItem('auth_user', JSON.stringify(${safeScriptJson(result.user)}));
|
||||
sessionStorage.setItem('desktop_embed', '1');
|
||||
location.replace(${safeScriptJson(result.redirectPath)});
|
||||
} catch (error) {
|
||||
document.body.textContent = '总控登录状态写入失败,请返回桌面客户端重试';
|
||||
}
|
||||
</script></body></html>`);
|
||||
}
|
||||
|
||||
router.post('/session', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
if (req.user?.tokenType) {
|
||||
throw new DesktopSessionError('请使用用户登录令牌创建桌面会话', 401, 'DESKTOP_USER_TOKEN_REQUIRED');
|
||||
}
|
||||
const data = await createDesktopSession({
|
||||
userId: requestUserId(req),
|
||||
payload: req.body || {},
|
||||
});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendDesktopSessionError(res, error, '创建桌面会话失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/bootstrap', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await getDesktopBootstrap({
|
||||
claims: req.user,
|
||||
clientId: req.query.clientId,
|
||||
});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendDesktopSessionError(res, error, '读取桌面启动配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sso-ticket', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await createSsoTicket({
|
||||
claims: req.user,
|
||||
clientId: req.body?.clientId,
|
||||
redirectPath: req.body?.redirectPath,
|
||||
});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendDesktopSessionError(res, error, '创建总控登录票据失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sso/exchange', async (req, res) => {
|
||||
try {
|
||||
const result = await exchangeSsoTicket(req.query.ticket);
|
||||
return sendSsoExchangeHtml(res, result);
|
||||
} catch (error) {
|
||||
if (error instanceof DesktopSessionError) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(error.status).type('html').send('<!doctype html><meta charset="utf-8"><title>登录失效</title><body>登录票据无效或已过期,请返回桌面客户端重试。</body>');
|
||||
}
|
||||
console.error('交换总控登录票据失败', error);
|
||||
return res.status(500).type('html').send('<!doctype html><meta charset="utf-8"><title>登录失败</title><body>总控登录失败,请稍后重试。</body>');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/session/revoke', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const data = await revokeDesktopSession(req.user);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendDesktopSessionError(res, error, '退出桌面会话失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/latest', async (req, res) => {
|
||||
try {
|
||||
const platform = req.query.platform || 'win32';
|
||||
const architecture = req.query.architecture || '';
|
||||
const release = await getLatestDesktopRelease(platform, architecture);
|
||||
const currentVersion = normalizeVersion(req.query.currentVersion);
|
||||
const data = publicRelease(release);
|
||||
res.setHeader('Cache-Control', 'no-store, max-age=0');
|
||||
res.json({
|
||||
success: true,
|
||||
data: data ? {
|
||||
...data,
|
||||
currentVersion: currentVersion || null,
|
||||
updateAvailable: currentVersion ? compareVersions(data.version, currentVersion) > 0 : null,
|
||||
} : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取桌面客户端最新版本失败:', error);
|
||||
res.status(500).json({ success: false, error: '获取桌面客户端最新版本失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/releases/:id/download', async (req, res) => {
|
||||
try {
|
||||
const release = await getDesktopReleaseById(Number(req.params.id));
|
||||
if (!release) {
|
||||
return res.status(404).json({ success: false, error: '安装包不存在或已下线' });
|
||||
}
|
||||
|
||||
const storageConfig = await getDefaultS3Config();
|
||||
if (storageConfig?.customDomain) {
|
||||
return res.redirect(302, buildPublicUrl(storageConfig, release.objectKey));
|
||||
}
|
||||
|
||||
const object = await getObjectFromDefaultS3(release.objectKey, {
|
||||
range: req.headers.range,
|
||||
});
|
||||
const encodedName = encodeURIComponent(release.fileName).replace(/%20/g, '+');
|
||||
res.status(object.ContentRange ? 206 : 200);
|
||||
const fallbackContentType = release.platform === 'darwin'
|
||||
? 'application/x-apple-diskimage'
|
||||
: 'application/vnd.microsoft.portable-executable';
|
||||
res.setHeader('Content-Type', object.ContentType || fallbackContentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedName}`);
|
||||
res.setHeader('Accept-Ranges', object.AcceptRanges || 'bytes');
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
if (object.ContentLength != null) res.setHeader('Content-Length', String(object.ContentLength));
|
||||
if (object.ContentRange) res.setHeader('Content-Range', object.ContentRange);
|
||||
if (object.ETag) res.setHeader('ETag', object.ETag);
|
||||
|
||||
object.Body.on('error', (error) => {
|
||||
console.error('桌面客户端安装包下载流中断:', error);
|
||||
if (!res.headersSent) res.status(502).end();
|
||||
else res.destroy(error);
|
||||
});
|
||||
object.Body.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('下载桌面客户端安装包失败:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(502).json({ success: false, error: '安装包下载失败,请稍后重试' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
262
backend/src/api/desktopExecutionTasks.js
Normal file
262
backend/src/api/desktopExecutionTasks.js
Normal file
@ -0,0 +1,262 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const authMiddleware = require('../middleware/auth');
|
||||
const { listOnlineDesktopUsers } = require('../services/desktopPresenceService');
|
||||
const { assertDesktopSession } = require('../services/desktopSessionService');
|
||||
const { listSelectionFissionUserShops } = require('../services/planningSelectionFissionService');
|
||||
const {
|
||||
DesktopExecutionTaskError,
|
||||
cancelDesktopExecutionTask,
|
||||
claimDesktopExecutionTask,
|
||||
completeDesktopExecutionTask,
|
||||
createDesktopExecutionTask,
|
||||
failDesktopExecutionTask,
|
||||
getDesktopExecutionSummary,
|
||||
listDesktopExecutionTasks,
|
||||
retryDesktopExecutionTask,
|
||||
updateDesktopExecutionProgress,
|
||||
} = require('../services/desktopExecutionTaskService');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(authMiddleware);
|
||||
|
||||
function actorFromRequest(req) {
|
||||
return {
|
||||
userId: req.user?.sub ?? req.user?.id,
|
||||
username: req.user?.username || '',
|
||||
name: req.user?.name || req.user?.username || '',
|
||||
role: req.user?.role || 'user',
|
||||
};
|
||||
}
|
||||
|
||||
function assertControlCenterRequest(req) {
|
||||
if (req.user?.tokenType === 'desktop-session') {
|
||||
throw new DesktopExecutionTaskError('该接口仅供总控页面使用', 403, 'DESKTOP_TASK_CONTROL_CENTER_REQUIRED');
|
||||
}
|
||||
if (req.user?.role !== 'admin') {
|
||||
throw new DesktopExecutionTaskError('只有管理员可以管理桌面执行任务', 403, 'DESKTOP_TASK_ADMIN_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
async function validatePresaleAssignment(input = {}) {
|
||||
if (String(input.taskType || input.type || '').trim().toLowerCase() !== 'presale') return input;
|
||||
const assignedUserId = Number(input.assignedUserId || input.userId || 0);
|
||||
const presence = await listOnlineDesktopUsers();
|
||||
const operator = (presence.users || []).find((item) => (
|
||||
Number(item.userId) === assignedUserId && item.isOnline
|
||||
));
|
||||
if (!operator) {
|
||||
throw new DesktopExecutionTaskError(
|
||||
'所选运营的桌面客户端当前不在线',
|
||||
409,
|
||||
'DESKTOP_TASK_OPERATOR_OFFLINE'
|
||||
);
|
||||
}
|
||||
|
||||
const operatorName = operator.name || operator.username;
|
||||
const shopData = await listSelectionFissionUserShops(operatorName, { includeInactive: false });
|
||||
const requestedShopIds = [
|
||||
input.targetShopId,
|
||||
...(Array.isArray(input.input?.targetShopIds) ? input.input.targetShopIds : []),
|
||||
].map((value) => String(value || '').trim()).filter(Boolean);
|
||||
const uniqueShopIds = [...new Set(requestedShopIds)];
|
||||
if (!uniqueShopIds.length) {
|
||||
throw new DesktopExecutionTaskError(
|
||||
'请至少选择一个登录状态正常的目标店铺',
|
||||
400,
|
||||
'DESKTOP_TASK_SHOP_REQUIRED'
|
||||
);
|
||||
}
|
||||
const selectableById = new Map(
|
||||
(shopData.rows || []).filter((shop) => shop.selectable).map((shop) => [String(shop.shopId), shop])
|
||||
);
|
||||
const selectedShops = uniqueShopIds.map((shopId) => selectableById.get(shopId)).filter(Boolean);
|
||||
if (selectedShops.length !== uniqueShopIds.length) {
|
||||
throw new DesktopExecutionTaskError(
|
||||
'目标店铺已离线或登录状态失效,请刷新后重新选择',
|
||||
409,
|
||||
'DESKTOP_TASK_SHOP_UNAVAILABLE'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
assignedUserId,
|
||||
assignedUserName: operator.name || operator.username || '',
|
||||
assignedUsername: operator.username || '',
|
||||
targetShopId: selectedShops.length === 1 ? selectedShops[0].shopId : '',
|
||||
targetShopName: selectedShops.length === 1 ? selectedShops[0].shopName : '',
|
||||
input: {
|
||||
...(input.input || {}),
|
||||
targetShopIds: selectedShops.map((shop) => shop.shopId),
|
||||
targetShopNames: selectedShops.map((shop) => shop.shopName),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function assertDesktopRequest(req) {
|
||||
if (req.user?.tokenType !== 'desktop-session') {
|
||||
throw new DesktopExecutionTaskError('该接口仅供桌面客户端使用', 403, 'DESKTOP_TASK_CLIENT_REQUIRED');
|
||||
}
|
||||
const clientId = req.body?.clientId || req.query?.clientId || req.user?.clientId;
|
||||
await assertDesktopSession(req.user, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
function sendError(res, error, fallback) {
|
||||
const status = error instanceof DesktopExecutionTaskError || Number(error.status)
|
||||
? Number(error.status || 500)
|
||||
: 500;
|
||||
if (status >= 500) console.error(`[DesktopExecutionTask] ${fallback}:`, error);
|
||||
return res.status(status).json({
|
||||
success: false,
|
||||
code: error.code || 'DESKTOP_TASK_SERVER_ERROR',
|
||||
error: error.message || fallback,
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/tasks', async (req, res) => {
|
||||
try {
|
||||
assertControlCenterRequest(req);
|
||||
const data = await listDesktopExecutionTasks(req.query || {}, actorFromRequest(req));
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '读取桌面任务失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
assertControlCenterRequest(req);
|
||||
const data = await getDesktopExecutionSummary(req.query || {}, actorFromRequest(req));
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '读取桌面任务概览失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/operators', async (req, res) => {
|
||||
try {
|
||||
assertControlCenterRequest(req);
|
||||
const presence = await listOnlineDesktopUsers();
|
||||
const users = (presence.users || []).map((user) => ({
|
||||
userId: Number(user.userId || 0),
|
||||
username: user.username || '',
|
||||
name: user.name || user.username || '',
|
||||
departmentName: user.departmentName || '',
|
||||
isOnline: Boolean(user.isOnline),
|
||||
onlineDeviceCount: Number(user.activeDeviceCount || 0),
|
||||
lastSeenAt: user.lastSeenAt || null,
|
||||
}));
|
||||
return res.json({ success: true, data: { users, offlineAfterSeconds: presence.offlineAfterSeconds } });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '读取桌面运营列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/operators/:userId/shops', async (req, res) => {
|
||||
try {
|
||||
assertControlCenterRequest(req);
|
||||
const presence = await listOnlineDesktopUsers();
|
||||
const user = (presence.users || []).find((item) => Number(item.userId) === Number(req.params.userId));
|
||||
if (!user) {
|
||||
throw new DesktopExecutionTaskError('未找到该桌面运营记录', 404, 'DESKTOP_TASK_OPERATOR_NOT_FOUND');
|
||||
}
|
||||
const userName = user.name || user.username;
|
||||
const data = await listSelectionFissionUserShops(userName, { includeInactive: false });
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '读取运营店铺状态失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tasks', async (req, res) => {
|
||||
try {
|
||||
assertControlCenterRequest(req);
|
||||
const input = await validatePresaleAssignment(req.body || {});
|
||||
const data = await createDesktopExecutionTask(input, actorFromRequest(req));
|
||||
return res.status(201).json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '下发桌面任务失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tasks/claim', async (req, res) => {
|
||||
try {
|
||||
const clientId = await assertDesktopRequest(req);
|
||||
const data = await claimDesktopExecutionTask(
|
||||
{ ...(req.body || {}), clientId },
|
||||
actorFromRequest(req)
|
||||
);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '领取桌面任务失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tasks/:taskId/progress', async (req, res) => {
|
||||
try {
|
||||
const clientId = await assertDesktopRequest(req);
|
||||
const data = await updateDesktopExecutionProgress(
|
||||
req.params.taskId,
|
||||
{ ...(req.body || {}), clientId },
|
||||
actorFromRequest(req)
|
||||
);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '回传桌面任务进度失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tasks/:taskId/complete', async (req, res) => {
|
||||
try {
|
||||
const clientId = await assertDesktopRequest(req);
|
||||
const data = await completeDesktopExecutionTask(
|
||||
req.params.taskId,
|
||||
{ ...(req.body || {}), clientId },
|
||||
actorFromRequest(req)
|
||||
);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '完成桌面任务失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tasks/:taskId/fail', async (req, res) => {
|
||||
try {
|
||||
const clientId = await assertDesktopRequest(req);
|
||||
const data = await failDesktopExecutionTask(
|
||||
req.params.taskId,
|
||||
{ ...(req.body || {}), clientId },
|
||||
actorFromRequest(req)
|
||||
);
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '回传桌面任务失败结果失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tasks/:taskId/cancel', async (req, res) => {
|
||||
try {
|
||||
assertControlCenterRequest(req);
|
||||
const data = await cancelDesktopExecutionTask(req.params.taskId, actorFromRequest(req));
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '取消桌面任务失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tasks/:taskId/retry', async (req, res) => {
|
||||
try {
|
||||
assertControlCenterRequest(req);
|
||||
const data = await retryDesktopExecutionTask(req.params.taskId, actorFromRequest(req));
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '重试桌面任务失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
54
backend/src/api/desktopPresence.js
Normal file
54
backend/src/api/desktopPresence.js
Normal file
@ -0,0 +1,54 @@
|
||||
const express = require('express');
|
||||
const {
|
||||
DesktopPresenceValidationError,
|
||||
markDesktopOffline,
|
||||
recordDesktopHeartbeat,
|
||||
} = require('../services/desktopPresenceService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function requestUserId(req) {
|
||||
return req.user?.sub ?? req.user?.id;
|
||||
}
|
||||
|
||||
function requestIp(req) {
|
||||
const forwardedFor = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
return (forwardedFor || req.ip || req.socket?.remoteAddress || '').replace(/^::ffff:/i, '');
|
||||
}
|
||||
|
||||
function sendPresenceError(res, error, fallbackMessage) {
|
||||
if (error instanceof DesktopPresenceValidationError) {
|
||||
return res.status(error.status).json({ success: false, error: error.message });
|
||||
}
|
||||
console.error(fallbackMessage, error);
|
||||
return res.status(500).json({ success: false, error: fallbackMessage });
|
||||
}
|
||||
|
||||
router.post('/heartbeat', async (req, res) => {
|
||||
try {
|
||||
const data = await recordDesktopHeartbeat({
|
||||
userId: requestUserId(req),
|
||||
identity: req.user || {},
|
||||
payload: req.body || {},
|
||||
ipAddress: requestIp(req),
|
||||
userAgent: req.headers['user-agent'] || '',
|
||||
});
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendPresenceError(res, error, '桌面客户端心跳上报失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/offline', async (req, res) => {
|
||||
try {
|
||||
const data = await markDesktopOffline({
|
||||
userId: requestUserId(req),
|
||||
clientId: req.body?.clientId,
|
||||
});
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return sendPresenceError(res, error, '桌面客户端离线上报失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
74
backend/src/api/install.js
Normal file
74
backend/src/api/install.js
Normal file
@ -0,0 +1,74 @@
|
||||
const express = require('express');
|
||||
const {
|
||||
bootstrapInstallation,
|
||||
getInstallStatus,
|
||||
shouldAutoRestartAfterInstall,
|
||||
testSystemDatabaseBootstrapConnection,
|
||||
} = require('../services/installService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/status', async (_req, res) => {
|
||||
try {
|
||||
const status = await getInstallStatus();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('获取安装状态失败:', error);
|
||||
res.status(500).json({ error: '获取安装状态失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/system-database/test', async (req, res) => {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const result = await testSystemDatabaseBootstrapConnection(req.body || {});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.databaseExists
|
||||
? (result.tableCount > 0 ? '连接成功,但目标数据库已经包含表结构' : '连接成功,目标数据库已存在且为空')
|
||||
: '连接成功,可在安装时自动创建目标数据库',
|
||||
responseTime: Date.now() - startTime,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('测试系统数据库连接失败:', error);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `连接失败: ${error.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/bootstrap', async (req, res) => {
|
||||
try {
|
||||
const status = await getInstallStatus();
|
||||
if (!status.required) {
|
||||
return res.status(409).json({ error: '系统已经完成安装,无需重复初始化' });
|
||||
}
|
||||
|
||||
const result = await bootstrapInstallation(req.body || {});
|
||||
const restartScheduled = shouldAutoRestartAfterInstall();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: restartScheduled
|
||||
? '系统安装完成,服务即将自动重启'
|
||||
: '系统安装完成,请手动重启后端服务后再登录',
|
||||
needRestart: true,
|
||||
restartScheduled,
|
||||
...result,
|
||||
});
|
||||
|
||||
if (restartScheduled) {
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('执行系统安装失败:', error);
|
||||
res.status(400).json({ error: error.message || '系统安装失败' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
268
backend/src/api/marketingEvents.js
Normal file
268
backend/src/api/marketingEvents.js
Normal file
@ -0,0 +1,268 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
function safeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeSortField(field) {
|
||||
const allowed = new Set([
|
||||
'id', 'shopName', 'createTime', 'activity_name', 'activity_id',
|
||||
'goods_name', 'goods_id', 'min_activity_sku_price', 'max_activity_sku_price',
|
||||
'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||
]);
|
||||
return allowed.has(field) ? field : 'activity_goods_start_time';
|
||||
}
|
||||
|
||||
function normalizeSortOrder(order) {
|
||||
return String(order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
}
|
||||
|
||||
function escapeColumn(field) {
|
||||
const allowed = new Set([
|
||||
'id', 'shopName', 'createTime', 'business_type_str', 'activity_name', 'activity_id',
|
||||
'goods_name', 'goods_id', 'hd_thumb_url', 'enroll_time', 'min_activity_sku_price',
|
||||
'max_activity_sku_price', 'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||
]);
|
||||
if (!allowed.has(field)) return '[id]';
|
||||
return field === 'enroll_time' ? '[enroll_time]' : `[${field}]`;
|
||||
}
|
||||
|
||||
async function getSecondLatestDate() {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
if (rows.length >= 2) return rows[1].createDate;
|
||||
if (rows.length === 1) return rows[0].createDate;
|
||||
return '';
|
||||
}
|
||||
|
||||
router.get('/latest-date', async (req, res) => {
|
||||
try {
|
||||
const date = await getSecondLatestDate();
|
||||
res.json({ success: true, data: { date } });
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询最新数据日期失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'activity_goods_start_time',
|
||||
sortOrder = 'DESC',
|
||||
shopName,
|
||||
activityType,
|
||||
activityName,
|
||||
activityId,
|
||||
goodsName,
|
||||
goodsId,
|
||||
status,
|
||||
date,
|
||||
} = req.query;
|
||||
|
||||
const currentPage = safeInt(page, 1);
|
||||
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
const sortColumn = normalizeSortField(sortBy);
|
||||
const sortDir = normalizeSortOrder(sortOrder);
|
||||
|
||||
const whereParts = [];
|
||||
const params = {};
|
||||
|
||||
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||
if (filterDate) {
|
||||
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||
params.filterDate = filterDate;
|
||||
}
|
||||
|
||||
if (shopName && shopName.trim()) {
|
||||
whereParts.push('[shopName] LIKE @shopName');
|
||||
params.shopName = `%${shopName.trim()}%`;
|
||||
}
|
||||
if (activityType && activityType.trim()) {
|
||||
whereParts.push('[business_type_str] LIKE @activityType');
|
||||
params.activityType = `%${activityType.trim()}%`;
|
||||
}
|
||||
if (activityName && activityName.trim()) {
|
||||
whereParts.push('[activity_name] LIKE @activityName');
|
||||
params.activityName = `%${activityName.trim()}%`;
|
||||
}
|
||||
if (activityId && String(activityId).trim()) {
|
||||
whereParts.push('[activity_id] = @activityId');
|
||||
params.activityId = Number(activityId);
|
||||
}
|
||||
if (goodsName && goodsName.trim()) {
|
||||
whereParts.push('[goods_name] LIKE @goodsName');
|
||||
params.goodsName = `%${goodsName.trim()}%`;
|
||||
}
|
||||
if (goodsId && String(goodsId).trim()) {
|
||||
whereParts.push('[goods_id] = @goodsId');
|
||||
params.goodsId = String(goodsId).trim();
|
||||
}
|
||||
if (status !== undefined && status !== '' && status !== 'all') {
|
||||
whereParts.push('[status] = @status');
|
||||
params.status = Number(status);
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
const countResult = await jianSunDb.query(
|
||||
`SELECT COUNT(*) AS total FROM dbo.pdd_DataActivityRecords ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
const dataResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[id],
|
||||
[shopName],
|
||||
[createTime],
|
||||
[business_type_str] AS activityType,
|
||||
[activity_name] AS activityName,
|
||||
[activity_id] AS activityId,
|
||||
[goods_name] AS goodsName,
|
||||
[goods_id] AS goodsId,
|
||||
[hd_thumb_url] AS thumbUrl,
|
||||
[enroll_time] AS enrollTime,
|
||||
TRY_CONVERT(decimal(18,2), [min_activity_sku_price]) / 100.0 AS minPrice,
|
||||
TRY_CONVERT(decimal(18,2), [max_activity_sku_price]) / 100.0 AS maxPrice,
|
||||
[activity_goods_start_time] AS activityStartTime,
|
||||
[activity_goods_end_time] AS activityEndTime,
|
||||
[status],
|
||||
CASE WHEN [status] = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', [status]) END AS statusLabel
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}
|
||||
ORDER BY ${escapeColumn(sortColumn)} ${sortDir}
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||
{ ...params, offset, limit }
|
||||
);
|
||||
|
||||
const statusCountsResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[status],
|
||||
COUNT(*) AS count
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}
|
||||
GROUP BY [status]
|
||||
ORDER BY [status]`,
|
||||
params
|
||||
);
|
||||
|
||||
const statusCounts = {};
|
||||
let totalAll = 0;
|
||||
statusCountsResult.forEach((row) => {
|
||||
statusCounts[row.status] = row.count;
|
||||
totalAll += row.count;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: dataResult,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: limit,
|
||||
filterDate,
|
||||
statusCounts: { all: totalAll, ...statusCounts },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询活动记录失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const { date } = req.query;
|
||||
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||
|
||||
const whereSql = filterDate ? 'WHERE LEFT([createTime], 10) = @filterDate' : '';
|
||||
const params = filterDate ? { filterDate } : {};
|
||||
|
||||
const [row] = await jianSunDb.query(
|
||||
`SELECT
|
||||
COUNT(*) AS totalRecords,
|
||||
SUM(CASE WHEN [status] = 501 THEN 1 ELSE 0 END) AS successCount,
|
||||
COUNT(DISTINCT TRY_CONVERT(varchar(50), [goods_id])) AS goodsCount,
|
||||
COUNT(DISTINCT [shopName]) AS shopCount,
|
||||
COUNT(DISTINCT [activity_id]) AS activityIdCount,
|
||||
COUNT(DISTINCT [business_type_str]) AS activityTypeCount,
|
||||
AVG(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS avgMinPrice,
|
||||
AVG(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS avgMaxPrice,
|
||||
MIN(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS globalMinPrice,
|
||||
MAX(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS globalMaxPrice
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}`,
|
||||
params
|
||||
);
|
||||
|
||||
const stats = row || {};
|
||||
const totalRecords = Number(stats.totalRecords) || 0;
|
||||
const successCount = Number(stats.successCount) || 0;
|
||||
const successRate = totalRecords > 0 ? ((successCount / totalRecords) * 100).toFixed(1) : '0.0';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filterDate,
|
||||
totalRecords,
|
||||
successCount,
|
||||
successRate,
|
||||
goodsCount: Number(stats.goodsCount) || 0,
|
||||
shopCount: Number(stats.shopCount) || 0,
|
||||
activityIdCount: Number(stats.activityIdCount) || 0,
|
||||
activityTypeCount: Number(stats.activityTypeCount) || 0,
|
||||
avgMinPrice: stats.avgMinPrice != null ? Number(stats.avgMinPrice).toFixed(2) : '-',
|
||||
avgMaxPrice: stats.avgMaxPrice != null ? Number(stats.avgMaxPrice).toFixed(2) : '-',
|
||||
globalMinPrice: stats.globalMinPrice != null ? Number(stats.globalMinPrice).toFixed(2) : '-',
|
||||
globalMaxPrice: stats.globalMaxPrice != null ? Number(stats.globalMaxPrice).toFixed(2) : '-',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询汇总数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status-counts', async (req, res) => {
|
||||
try {
|
||||
const result = await jianSunDb.query(
|
||||
`SELECT
|
||||
[status],
|
||||
COUNT(*) AS count
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
GROUP BY [status]
|
||||
ORDER BY [status]`
|
||||
);
|
||||
|
||||
const statusCounts = {};
|
||||
let total = 0;
|
||||
result.forEach((row) => {
|
||||
statusCounts[row.status] = row.count;
|
||||
total += row.count;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { all: total, ...statusCounts },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询状态统计失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
475
backend/src/api/pddFollowUpBoard.js
Normal file
475
backend/src/api/pddFollowUpBoard.js
Normal file
@ -0,0 +1,475 @@
|
||||
'use strict';
|
||||
|
||||
const dayjs = require('dayjs');
|
||||
const dataSource = require('../config/database');
|
||||
const {
|
||||
FOLLOW_UP_TARGET_TABLE,
|
||||
FOLLOW_UP_DAILY_TABLE,
|
||||
DEFAULT_GMV_THRESHOLD,
|
||||
DEFAULT_FEE_RATIO_THRESHOLD,
|
||||
DEFAULT_AFTER_SALE_RATE_THRESHOLD,
|
||||
} = require('../services/followUpMonitorSync');
|
||||
|
||||
const localDb = dataSource.local;
|
||||
const PLATFORM = 'pdd';
|
||||
|
||||
const PDD_ACCESS_DEPARTMENT_MAP = {
|
||||
'运营中心A': '运营中心A',
|
||||
'运营中心B': '运营中心B',
|
||||
'运营中心C': '运营中心C',
|
||||
'运营中心G': '运营中心G',
|
||||
'品牌中心': '龙晨品牌中心',
|
||||
'龙晨品牌中心': '龙晨品牌中心',
|
||||
};
|
||||
|
||||
const FOLLOW_UP_MONITOR_COLUMNS = [
|
||||
{ key: 'goodsId', title: '商品id' },
|
||||
{ key: 'goodsName', title: '商品名称' },
|
||||
{ key: 'shopName', title: '店铺名称' },
|
||||
{ key: 'idOwner', title: 'id负责人' },
|
||||
{ key: 'ownerDept', title: '所属部门' },
|
||||
{ key: 'status', title: '状态' },
|
||||
{ key: 'gmvThreshold', title: 'GMV阈值' },
|
||||
{ key: 'feeRatioThreshold', title: '费比阈值' },
|
||||
{ key: 'afterSaleRateThreshold', title: '售后阈值' },
|
||||
{ key: 'latestStatDate', title: '最新业务日期' },
|
||||
{ key: 'latestGmv', title: '当日GMV' },
|
||||
{ key: 'latestFeeRatio', title: '当日费比' },
|
||||
{ key: 'latestAfterSaleRate', title: '当日售后率' },
|
||||
{ key: 'gmvAlertDays', title: 'GMV连续异常天数' },
|
||||
{ key: 'feeRatioAlertDays', title: '费比连续异常天数' },
|
||||
{ key: 'afterSaleAlertDays', title: '售后连续异常天数' },
|
||||
{ key: 'warningLevel', title: '预警级别' },
|
||||
{ key: 'issueSummary', title: '异常摘要' },
|
||||
{ key: 'actionAdvice', title: '处理建议' },
|
||||
{ key: 'remark', title: '备注' },
|
||||
{ key: 'updatedAt', title: '更新时间' },
|
||||
];
|
||||
|
||||
function normalizePddDepartments(...departmentValues) {
|
||||
const result = new Set();
|
||||
departmentValues
|
||||
.filter(Boolean)
|
||||
.flatMap((value) => String(value).split(/[\/,,、]/))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((value) => {
|
||||
const mapped = PDD_ACCESS_DEPARTMENT_MAP[value];
|
||||
if (mapped) {
|
||||
result.add(mapped);
|
||||
}
|
||||
});
|
||||
return Array.from(result);
|
||||
}
|
||||
|
||||
async function getPddDataAccessScope(userId) {
|
||||
const rows = await localDb.query(
|
||||
`SELECT
|
||||
u.id,
|
||||
u.role,
|
||||
u.department_names,
|
||||
d.name AS department_name
|
||||
FROM users u
|
||||
LEFT JOIN departments d ON u.department_id = d.id
|
||||
WHERE u.id = ?
|
||||
LIMIT 1`,
|
||||
[ userId ]
|
||||
);
|
||||
|
||||
const user = rows[0];
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user.role === 'admin') {
|
||||
return {
|
||||
isAllData: true,
|
||||
allowedDepartments: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isAllData: false,
|
||||
allowedDepartments: normalizePddDepartments(user.department_names, user.department_name),
|
||||
};
|
||||
}
|
||||
|
||||
function toFiniteNumber(value, fallback = 0) {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : fallback;
|
||||
}
|
||||
|
||||
function normalizeMonitorTargetStatus(value) {
|
||||
return String(value || '').trim().toLowerCase() === 'inactive' ? 'inactive' : 'active';
|
||||
}
|
||||
|
||||
function parseThreshold(value, fallback) {
|
||||
if (value === '' || value === undefined || value === null) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function validateThresholds({ gmvThreshold, feeRatioThreshold, afterSaleRateThreshold }) {
|
||||
if (gmvThreshold < 0) {
|
||||
const error = new Error('GMV阈值不能小于 0');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (afterSaleRateThreshold < 0 || afterSaleRateThreshold > 10) {
|
||||
const error = new Error('售后阈值只能填写 0-10');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (feeRatioThreshold < 20 || feeRatioThreshold > 100) {
|
||||
const error = new Error('费比阈值只能填写 20-100');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function countConsecutiveAlerts(rows, predicate) {
|
||||
let count = 0;
|
||||
for (const row of rows) {
|
||||
if (!predicate(row)) break;
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function buildFollowUpWarningMeta({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }) {
|
||||
const maxAlertDays = Math.max(gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays);
|
||||
if (afterSaleAlertDays >= 20) {
|
||||
return {
|
||||
warningLevel: '立即下架',
|
||||
actionAdvice: '售后指标连续 20 天超阈值,建议立即下架并通知负责人处理。',
|
||||
};
|
||||
}
|
||||
if (maxAlertDays >= 20) {
|
||||
return {
|
||||
warningLevel: '重点处理',
|
||||
actionAdvice: '指标异常已连续超过 20 天,建议升级到管理层并明确整改时限。',
|
||||
};
|
||||
}
|
||||
if (maxAlertDays >= 10) {
|
||||
return {
|
||||
warningLevel: '预警',
|
||||
actionAdvice: '指标异常已连续超过 10 天,建议尽快复盘并执行优化动作。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
warningLevel: '正常',
|
||||
actionAdvice: '当前未达到连续预警阈值,继续观察即可。',
|
||||
};
|
||||
}
|
||||
|
||||
function buildIssueSummary({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }) {
|
||||
const segments = [];
|
||||
if (gmvAlertDays > 0) segments.push(`GMV连续异常${gmvAlertDays}天`);
|
||||
if (feeRatioAlertDays > 0) segments.push(`费比连续异常${feeRatioAlertDays}天`);
|
||||
if (afterSaleAlertDays > 0) segments.push(`售后连续异常${afterSaleAlertDays}天`);
|
||||
return segments.join(',') || '暂无连续异常';
|
||||
}
|
||||
|
||||
async function listFollowUpMonitorTargets() {
|
||||
return localDb.query(
|
||||
`SELECT
|
||||
id,
|
||||
goods_id AS goodsId,
|
||||
fee_ratio_threshold AS feeRatioThreshold,
|
||||
gmv_threshold AS gmvThreshold,
|
||||
after_sale_rate_threshold AS afterSaleRateThreshold,
|
||||
status,
|
||||
remark,
|
||||
created_by AS createdBy,
|
||||
updated_by AS updatedBy,
|
||||
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS createdAt,
|
||||
DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updatedAt
|
||||
FROM \`${FOLLOW_UP_TARGET_TABLE}\`
|
||||
WHERE platform = ?
|
||||
ORDER BY status = 'active' DESC, updated_at DESC, id DESC`,
|
||||
[ PLATFORM ]
|
||||
);
|
||||
}
|
||||
|
||||
async function getLatestDailyWindow() {
|
||||
const rows = await localDb.query(
|
||||
`SELECT DATE_FORMAT(MAX(stat_date), '%Y-%m-%d') AS latestStatDate
|
||||
FROM \`${FOLLOW_UP_DAILY_TABLE}\`
|
||||
WHERE platform = ?`,
|
||||
[ PLATFORM ]
|
||||
);
|
||||
const latestStatDate = rows?.[0]?.latestStatDate || null;
|
||||
if (!latestStatDate) return null;
|
||||
return {
|
||||
latestStatDate,
|
||||
startDate: dayjs(latestStatDate).subtract(29, 'day').format('YYYY-MM-DD'),
|
||||
};
|
||||
}
|
||||
|
||||
async function getFollowUpMonitorSeries(targetIds, startDate, endDate) {
|
||||
if (!Array.isArray(targetIds) || targetIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const rows = await localDb.query(
|
||||
`SELECT
|
||||
target_id AS targetId,
|
||||
goods_id AS goodsId,
|
||||
goods_name AS goodsName,
|
||||
shop_name AS shopName,
|
||||
id_owner AS idOwner,
|
||||
owner_dept AS ownerDept,
|
||||
DATE_FORMAT(stat_date, '%Y-%m-%d') AS statDate,
|
||||
gmv,
|
||||
fee_ratio AS feeRatio,
|
||||
after_sale_rate AS afterSaleRate,
|
||||
gmv_alert_flag AS gmvAlertFlag,
|
||||
fee_ratio_alert_flag AS feeRatioAlertFlag,
|
||||
after_sale_alert_flag AS afterSaleAlertFlag
|
||||
FROM \`${FOLLOW_UP_DAILY_TABLE}\`
|
||||
WHERE platform = ?
|
||||
AND target_id IN (${targetIds.map(() => '?').join(', ')})
|
||||
AND stat_date BETWEEN ? AND ?
|
||||
ORDER BY target_id ASC, stat_date DESC`,
|
||||
[ PLATFORM, ...targetIds, startDate, endDate ]
|
||||
);
|
||||
|
||||
const seriesMap = new Map();
|
||||
rows.forEach((row) => {
|
||||
const targetId = Number(row.targetId || 0);
|
||||
if (!targetId) return;
|
||||
if (!seriesMap.has(targetId)) seriesMap.set(targetId, []);
|
||||
seriesMap.get(targetId).push({
|
||||
...row,
|
||||
gmv: toFiniteNumber(row.gmv, 0),
|
||||
feeRatio: toFiniteNumber(row.feeRatio, 0),
|
||||
afterSaleRate: toFiniteNumber(row.afterSaleRate, 0),
|
||||
gmvAlertFlag: Number(row.gmvAlertFlag || 0),
|
||||
feeRatioAlertFlag: Number(row.feeRatioAlertFlag || 0),
|
||||
afterSaleAlertFlag: Number(row.afterSaleAlertFlag || 0),
|
||||
});
|
||||
});
|
||||
return seriesMap;
|
||||
}
|
||||
|
||||
async function buildFollowUpMonitorList(accessScope) {
|
||||
const targets = await listFollowUpMonitorTargets();
|
||||
const activeTargets = targets.filter((item) => normalizeMonitorTargetStatus(item.status) === 'active');
|
||||
const latestWindow = await getLatestDailyWindow();
|
||||
|
||||
if (!latestWindow) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
columns: FOLLOW_UP_MONITOR_COLUMNS,
|
||||
rows: [],
|
||||
total: 0,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
latestStatDate: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const seriesMap = await getFollowUpMonitorSeries(
|
||||
activeTargets.map((item) => item.id),
|
||||
latestWindow.startDate,
|
||||
latestWindow.latestStatDate
|
||||
);
|
||||
const allowedDepartments = accessScope?.isAllData
|
||||
? null
|
||||
: new Set(Array.isArray(accessScope?.allowedDepartments) ? accessScope.allowedDepartments : []);
|
||||
|
||||
const rows = activeTargets.map((target) => {
|
||||
const targetRows = seriesMap.get(target.id) || [];
|
||||
const latestRow = targetRows[0] || null;
|
||||
|
||||
if (latestRow && allowedDepartments && allowedDepartments.size > 0 && !allowedDepartments.has(String(latestRow.ownerDept || '').trim())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!latestRow) {
|
||||
return {
|
||||
id: target.id,
|
||||
goodsId: target.goodsId,
|
||||
goodsName: '',
|
||||
shopName: '',
|
||||
idOwner: '',
|
||||
ownerDept: '',
|
||||
status: normalizeMonitorTargetStatus(target.status),
|
||||
gmvThreshold: target.gmvThreshold,
|
||||
feeRatioThreshold: target.feeRatioThreshold,
|
||||
afterSaleRateThreshold: target.afterSaleRateThreshold,
|
||||
latestStatDate: latestWindow.latestStatDate,
|
||||
latestGmv: 0,
|
||||
latestFeeRatio: 0,
|
||||
latestAfterSaleRate: 0,
|
||||
gmvAlertDays: 0,
|
||||
feeRatioAlertDays: 0,
|
||||
afterSaleAlertDays: 0,
|
||||
warningLevel: '无数据',
|
||||
issueSummary: '最近30天没有监控结果',
|
||||
actionAdvice: '请确认该商品ID是否存在拼多多源数据,或等待定时任务执行。',
|
||||
remark: target.remark || '',
|
||||
updatedAt: target.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const gmvAlertDays = countConsecutiveAlerts(targetRows, (row) => Number(row.gmvAlertFlag || 0) === 1);
|
||||
const feeRatioAlertDays = countConsecutiveAlerts(targetRows, (row) => Number(row.feeRatioAlertFlag || 0) === 1);
|
||||
const afterSaleAlertDays = countConsecutiveAlerts(targetRows, (row) => Number(row.afterSaleAlertFlag || 0) === 1);
|
||||
const warningMeta = buildFollowUpWarningMeta({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays });
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
goodsId: target.goodsId,
|
||||
goodsName: latestRow.goodsName || '',
|
||||
shopName: latestRow.shopName || '',
|
||||
idOwner: latestRow.idOwner || '',
|
||||
ownerDept: latestRow.ownerDept || '',
|
||||
status: normalizeMonitorTargetStatus(target.status),
|
||||
gmvThreshold: target.gmvThreshold,
|
||||
feeRatioThreshold: target.feeRatioThreshold,
|
||||
afterSaleRateThreshold: target.afterSaleRateThreshold,
|
||||
latestStatDate: latestRow.statDate,
|
||||
latestGmv: latestRow.gmv,
|
||||
latestFeeRatio: latestRow.feeRatio,
|
||||
latestAfterSaleRate: latestRow.afterSaleRate,
|
||||
gmvAlertDays,
|
||||
feeRatioAlertDays,
|
||||
afterSaleAlertDays,
|
||||
warningLevel: warningMeta.warningLevel,
|
||||
issueSummary: buildIssueSummary({ gmvAlertDays, feeRatioAlertDays, afterSaleAlertDays }),
|
||||
actionAdvice: warningMeta.actionAdvice,
|
||||
remark: target.remark || '',
|
||||
updatedAt: target.updatedAt,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
columns: FOLLOW_UP_MONITOR_COLUMNS,
|
||||
rows,
|
||||
total: rows.length,
|
||||
startDate: latestWindow.startDate,
|
||||
endDate: latestWindow.latestStatDate,
|
||||
latestStatDate: latestWindow.latestStatDate,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function registerPddFollowUpBoard({ router, authMiddleware }) {
|
||||
router.get('/pdd/product-dashboard/follow-up-board/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.sub || req.user?.id || req.user?.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ success: false, error: '未获取到登录用户信息' });
|
||||
}
|
||||
|
||||
const accessScope = await getPddDataAccessScope(userId);
|
||||
if (!accessScope) {
|
||||
return res.status(403).json({ success: false, error: '未获取到数据权限' });
|
||||
}
|
||||
|
||||
res.json(await buildFollowUpMonitorList(accessScope));
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/pdd/product-dashboard/follow-up-board/targets', authMiddleware, async (_req, res) => {
|
||||
try {
|
||||
const rows = await listFollowUpMonitorTargets();
|
||||
res.json({ success: true, data: rows });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/pdd/product-dashboard/follow-up-board/targets', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.sub || req.user?.id || req.user?.userId || null;
|
||||
const goodsId = String(req.body?.goodsId || '').trim();
|
||||
if (!goodsId) {
|
||||
return res.status(400).json({ success: false, error: 'goodsId is required' });
|
||||
}
|
||||
|
||||
const gmvThreshold = parseThreshold(req.body?.gmvThreshold, DEFAULT_GMV_THRESHOLD);
|
||||
const feeRatioThreshold = parseThreshold(req.body?.feeRatioThreshold, DEFAULT_FEE_RATIO_THRESHOLD);
|
||||
const afterSaleRateThreshold = parseThreshold(req.body?.afterSaleRateThreshold, DEFAULT_AFTER_SALE_RATE_THRESHOLD);
|
||||
validateThresholds({ gmvThreshold, feeRatioThreshold, afterSaleRateThreshold });
|
||||
|
||||
const status = normalizeMonitorTargetStatus(req.body?.status);
|
||||
const remark = String(req.body?.remark || '').trim() || null;
|
||||
|
||||
await localDb.query(
|
||||
`INSERT INTO \`${FOLLOW_UP_TARGET_TABLE}\`
|
||||
(platform, goods_id, gmv_threshold, fee_ratio_threshold, after_sale_rate_threshold, status, remark, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
gmv_threshold = VALUES(gmv_threshold),
|
||||
fee_ratio_threshold = VALUES(fee_ratio_threshold),
|
||||
after_sale_rate_threshold = VALUES(after_sale_rate_threshold),
|
||||
status = VALUES(status),
|
||||
remark = VALUES(remark),
|
||||
updated_by = VALUES(updated_by)`,
|
||||
[ PLATFORM, goodsId, gmvThreshold, feeRatioThreshold, afterSaleRateThreshold, status, remark, userId, userId ]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/pdd/product-dashboard/follow-up-board/targets/:id/toggle', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.sub || req.user?.id || req.user?.userId || null;
|
||||
const id = Math.max(parseInt(req.params.id || '0', 10), 0);
|
||||
if (!id) {
|
||||
return res.status(400).json({ success: false, error: 'id is required' });
|
||||
}
|
||||
|
||||
await localDb.query(
|
||||
`UPDATE \`${FOLLOW_UP_TARGET_TABLE}\`
|
||||
SET
|
||||
status = CASE WHEN status = 'active' THEN 'inactive' ELSE 'active' END,
|
||||
updated_by = ?
|
||||
WHERE id = ?
|
||||
AND platform = ?`,
|
||||
[ userId, id, PLATFORM ]
|
||||
);
|
||||
|
||||
const rows = await localDb.query(
|
||||
`SELECT goods_id AS goodsId, status
|
||||
FROM \`${FOLLOW_UP_TARGET_TABLE}\`
|
||||
WHERE id = ?
|
||||
AND platform = ?
|
||||
LIMIT 1`,
|
||||
[ id, PLATFORM ]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/pdd/product-dashboard/follow-up-board/targets/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const id = Math.max(parseInt(req.params.id || '0', 10), 0);
|
||||
if (!id) {
|
||||
return res.status(400).json({ success: false, error: 'id is required' });
|
||||
}
|
||||
|
||||
await localDb.query(`DELETE FROM \`${FOLLOW_UP_DAILY_TABLE}\` WHERE target_id = ? AND platform = ?`, [ id, PLATFORM ]);
|
||||
await localDb.query(`DELETE FROM \`${FOLLOW_UP_TARGET_TABLE}\` WHERE id = ? AND platform = ?`, [ id, PLATFORM ]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
};
|
||||
632
backend/src/api/pddSkuDimensionBoard.js
Normal file
632
backend/src/api/pddSkuDimensionBoard.js
Normal file
@ -0,0 +1,632 @@
|
||||
const dataSource = require('../config/database');
|
||||
const planningDatabase = require('../services/planningDatabase');
|
||||
const { ensurePlanningPddSkuDimensionSchema } = require('../services/planningSupportSchema');
|
||||
|
||||
const localDb = planningDatabase;
|
||||
const systemDb = dataSource.local;
|
||||
|
||||
const PDD_ACCESS_DEPARTMENT_MAP = {
|
||||
'运营中心A': '运营中心A',
|
||||
'运营中心B': '运营中心B',
|
||||
'运营中心C': '运营中心C',
|
||||
'运营中心G': '运营中心G',
|
||||
'品牌中心': '龙晨品牌中心',
|
||||
'龙晨品牌中心': '龙晨品牌中心',
|
||||
};
|
||||
|
||||
const SPU_TABLE = 'op_pdd_sku_dimension_spu_daily';
|
||||
const SKU_TABLE = 'op_pdd_sku_dimension_sku_daily';
|
||||
const GOODS_TABLE = 'op_pdd_sku_dimension_goods_daily';
|
||||
const CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
const MAIN_COLUMNS = [
|
||||
{ key: 'spu', title: 'SPU' },
|
||||
{ key: 'goodsIdCount', title: '铺货商品id数' },
|
||||
{ key: 'activeGoodsIdCount', title: '动销商品id数' },
|
||||
{ key: 'skuListingActiveRate', title: 'SPU铺货动销率' },
|
||||
{ key: 'skuIdEfficiency', title: 'SPU链接效能' },
|
||||
{ key: 'isPriceBreak', title: '是否破价' },
|
||||
{ key: 'payOrdrAmt', title: '近7天真实销售额' },
|
||||
{ key: 'feeRatio', title: '近7天真实费比' },
|
||||
{ key: 'afterSaleRate', title: '近7天真实售后率' },
|
||||
{ key: 'goodsUv', title: '近7天访客数' },
|
||||
{ key: 'orderNum', title: '近7天订单量' },
|
||||
{ key: 'payOrdrUsrCnt', title: '近7天买家数' },
|
||||
{ key: 'payOrdrGoodsQty', title: '近7天成交件数' },
|
||||
{ key: 'ctr', title: '近7天点击率' },
|
||||
{ key: 'goodsVcr', title: '近7天成交转化率' },
|
||||
{ key: 'customerUnitPrice', title: '近7天客单价' },
|
||||
{ key: 'pieceUnitPrice', title: '近7天件单价' },
|
||||
{ key: 'spend', title: '近7天总花费' },
|
||||
];
|
||||
|
||||
const SKU_DETAIL_COLUMNS = [
|
||||
{ key: 'skuCode', title: 'SKU货号' },
|
||||
...MAIN_COLUMNS.slice(1),
|
||||
];
|
||||
|
||||
const GOODS_DETAIL_COLUMNS = [
|
||||
{ key: 'goodsId', title: '商品id' },
|
||||
{ key: 'goodsName', title: '商品名称' },
|
||||
{ key: 'hdThumbUrl', title: '商品主图' },
|
||||
{ key: 'shopName', title: '店铺名称' },
|
||||
{ key: 'idOwner', title: 'id负责人' },
|
||||
{ key: 'ownerDept', title: '所属部门' },
|
||||
{ key: 'goodsUv', title: '近7天访客数' },
|
||||
{ key: 'inquiryNum', title: '近7天询单量' },
|
||||
{ key: 'goodsFavCnt', title: '近7天收藏用户' },
|
||||
{ key: 'goodsFavNum', title: '近7天收藏量' },
|
||||
{ key: 'orderUserCount', title: '近7天下单人数' },
|
||||
{ key: 'ctr', title: '近7天点击率' },
|
||||
{ key: 'clickConversionRate', title: '近7天点击转化率' },
|
||||
{ key: 'goodsVcr', title: '近7天成交转化率' },
|
||||
{ key: 'ordrVstrRto', title: '近7天下单率' },
|
||||
{ key: 'payOrdrRto', title: '近7天成交率' },
|
||||
{ key: 'imprUsrCnt', title: '近7天流失指数' },
|
||||
{ key: 'unpaidUserCount', title: '近7天未支付人数' },
|
||||
{ key: 'payOrdrAmt', title: '近7天真实销售额' },
|
||||
{ key: 'orderNum', title: '近7天订单量' },
|
||||
{ key: 'payOrdrUsrCnt', title: '近7天买家数' },
|
||||
{ key: 'payOrdrGoodsQty', title: '近7天成交件数' },
|
||||
{ key: 'customerUnitPrice', title: '近7天客单价' },
|
||||
{ key: 'pieceUnitPrice', title: '近7天件单价' },
|
||||
{ key: 'spend', title: '近7天总花费' },
|
||||
{ key: 'feeRatio', title: '近7天真实费比' },
|
||||
{ key: 'afterSaleRate', title: '近7天真实售后率' },
|
||||
{ key: 'afterSaleCount', title: '近7天售后单量' },
|
||||
{ key: 'badReviewCount', title: '近7天差评量' },
|
||||
{ key: 'isPriceBreak', title: '是否破价' },
|
||||
];
|
||||
|
||||
const MAIN_SORT_FIELDS = {
|
||||
spu: '`spu`',
|
||||
goodsIdCount: '`goodsIdCount`',
|
||||
activeGoodsIdCount: '`activeGoodsIdCount`',
|
||||
skuListingActiveRate: '`skuListingActiveRate`',
|
||||
skuIdEfficiency: '`skuIdEfficiency`',
|
||||
isPriceBreak: '`isPriceBreak`',
|
||||
payOrdrAmt: '`payOrdrAmt`',
|
||||
feeRatio: '`feeRatio`',
|
||||
afterSaleRate: '`afterSaleRate`',
|
||||
goodsUv: '`goodsUv`',
|
||||
orderNum: '`orderNum`',
|
||||
payOrdrUsrCnt: '`payOrdrUsrCnt`',
|
||||
payOrdrGoodsQty: '`payOrdrGoodsQty`',
|
||||
ctr: '`ctr`',
|
||||
goodsVcr: '`goodsVcr`',
|
||||
customerUnitPrice: '`customerUnitPrice`',
|
||||
pieceUnitPrice: '`pieceUnitPrice`',
|
||||
spend: '`spend`',
|
||||
};
|
||||
|
||||
const valueCache = new Map();
|
||||
|
||||
function getMemoryCache(key) {
|
||||
const cached = valueCache.get(key);
|
||||
if (!cached) return null;
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
valueCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
function setMemoryCache(key, value, ttlMs = CACHE_TTL_MS) {
|
||||
valueCache.set(key, {
|
||||
value,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMultiValueQueryParam(value) {
|
||||
const rawValues = Array.isArray(value) ? value : [ value ];
|
||||
return rawValues
|
||||
.flatMap((item) => String(item || '').split(/[,\uff0c]/))
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizePddDepartments(...departmentValues) {
|
||||
const result = new Set();
|
||||
departmentValues
|
||||
.filter(Boolean)
|
||||
.flatMap((value) => String(value).split(/[\/,,、]/))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((value) => {
|
||||
const mapped = PDD_ACCESS_DEPARTMENT_MAP[value];
|
||||
if (mapped) {
|
||||
result.add(mapped);
|
||||
}
|
||||
});
|
||||
return Array.from(result);
|
||||
}
|
||||
|
||||
function getEffectiveDepartmentFilters(accessScope, departmentValues = []) {
|
||||
const requestedDepartments = normalizePddDepartments(...departmentValues);
|
||||
if (accessScope?.isAllData) {
|
||||
return requestedDepartments;
|
||||
}
|
||||
|
||||
const allowedDepartments = Array.isArray(accessScope?.allowedDepartments)
|
||||
? accessScope.allowedDepartments
|
||||
: [];
|
||||
|
||||
if (allowedDepartments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (requestedDepartments.length === 0) {
|
||||
return allowedDepartments;
|
||||
}
|
||||
|
||||
const allowedSet = new Set(allowedDepartments);
|
||||
return requestedDepartments.filter((item) => allowedSet.has(item));
|
||||
}
|
||||
|
||||
async function getPddDataAccessScope(userId) {
|
||||
const rows = await systemDb.query(
|
||||
`SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.role,
|
||||
u.department_names,
|
||||
d.name AS department_name
|
||||
FROM users u
|
||||
LEFT JOIN departments d ON u.department_id = d.id
|
||||
WHERE u.id = ?
|
||||
LIMIT 1`,
|
||||
[ userId ]
|
||||
);
|
||||
|
||||
const user = rows[0];
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user.role === 'admin') {
|
||||
return {
|
||||
isAllData: true,
|
||||
allowedDepartments: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isAllData: false,
|
||||
allowedDepartments: normalizePddDepartments(user.department_names, user.department_name),
|
||||
};
|
||||
}
|
||||
|
||||
async function getLatestSnapshotInfo(tableName) {
|
||||
await ensurePlanningPddSkuDimensionSchema();
|
||||
const cacheKey = `pdd:sku-snapshot:latest:${tableName}`;
|
||||
const cached = getMemoryCache(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const rows = await localDb.query(
|
||||
`SELECT
|
||||
DATE_FORMAT(\`statDate\`, '%Y-%m-%d') AS statDate,
|
||||
DATE_FORMAT(\`startDate\`, '%Y-%m-%d') AS startDate,
|
||||
DATE_FORMAT(\`endDate\`, '%Y-%m-%d') AS endDate
|
||||
FROM \`${tableName}\`
|
||||
ORDER BY \`statDate\` DESC
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
const value = rows[0] || null;
|
||||
setMemoryCache(cacheKey, value, CACHE_TTL_MS);
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildLikeAnyClause(columnName, values, params) {
|
||||
if (!Array.isArray(values) || values.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const placeholders = values.map((value) => {
|
||||
params.push(`%${value}%`);
|
||||
return `${columnName} LIKE ?`;
|
||||
});
|
||||
return `(${placeholders.join(' OR ')})`;
|
||||
}
|
||||
|
||||
function getSortSql(sortField, sortOrder, defaultExpr, defaultDirection = 'DESC', tieBreaker = '') {
|
||||
const fieldExpr = MAIN_SORT_FIELDS[sortField] || defaultExpr;
|
||||
const direction = sortOrder === 'ascend' ? 'ASC' : (sortOrder === 'descend' ? 'DESC' : defaultDirection);
|
||||
return `${fieldExpr} ${direction}${tieBreaker}`;
|
||||
}
|
||||
|
||||
function formatBoolText(value) {
|
||||
return Number(value || 0) === 1 ? '是' : '否';
|
||||
}
|
||||
|
||||
function normalizeMainRows(rows = []) {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
isPriceBreak: formatBoolText(row.isPriceBreak),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeGoodsRows(rows = []) {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
isPriceBreak: formatBoolText(row.isPriceBreak),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchSpuList({
|
||||
statDate,
|
||||
startDate,
|
||||
endDate,
|
||||
page,
|
||||
pageSize,
|
||||
sortField,
|
||||
sortOrder,
|
||||
spu,
|
||||
shopNames,
|
||||
departments,
|
||||
idOwners,
|
||||
accessScope,
|
||||
}) {
|
||||
const whereParts = [ '`statDate` = ?' ];
|
||||
const params = [ statDate ];
|
||||
|
||||
if (spu) {
|
||||
whereParts.push('`spu` LIKE ?');
|
||||
params.push(`%${spu}%`);
|
||||
}
|
||||
|
||||
const shopClause = buildLikeAnyClause('`shopNameList`', shopNames, params);
|
||||
if (shopClause) whereParts.push(shopClause);
|
||||
|
||||
const departmentFilters = getEffectiveDepartmentFilters(accessScope, departments);
|
||||
if (departmentFilters === null) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
columns: MAIN_COLUMNS,
|
||||
rows: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
startDate,
|
||||
endDate,
|
||||
sortField: '',
|
||||
sortOrder: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (Array.isArray(departmentFilters) && departmentFilters.length === 0 && departments.length > 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
columns: MAIN_COLUMNS,
|
||||
rows: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
startDate,
|
||||
endDate,
|
||||
sortField: '',
|
||||
sortOrder: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
const departmentClause = buildLikeAnyClause('`ownerDeptList`', departmentFilters, params);
|
||||
if (departmentClause) whereParts.push(departmentClause);
|
||||
|
||||
const idOwnerClause = buildLikeAnyClause('`idOwnerList`', idOwners, params);
|
||||
if (idOwnerClause) whereParts.push(idOwnerClause);
|
||||
|
||||
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
||||
const totalRows = await localDb.query(
|
||||
`SELECT COUNT(1) AS total FROM \`${SPU_TABLE}\` ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const total = Number(totalRows?.[0]?.total || 0);
|
||||
|
||||
const orderBySql = getSortSql(sortField, sortOrder, '`payOrdrAmt`', 'DESC', ', `spu` ASC');
|
||||
const listRows = await localDb.query(
|
||||
`SELECT
|
||||
\`spu\`,
|
||||
\`goodsIdCount\`,
|
||||
\`activeGoodsIdCount\`,
|
||||
\`skuListingActiveRate\`,
|
||||
\`skuIdEfficiency\`,
|
||||
\`isPriceBreak\`,
|
||||
\`payOrdrAmt\`,
|
||||
\`feeRatio\`,
|
||||
\`afterSaleRate\`,
|
||||
\`goodsUv\`,
|
||||
\`orderNum\`,
|
||||
\`payOrdrUsrCnt\`,
|
||||
\`payOrdrGoodsQty\`,
|
||||
\`ctr\`,
|
||||
\`goodsVcr\`,
|
||||
\`customerUnitPrice\`,
|
||||
\`pieceUnitPrice\`,
|
||||
\`spend\`
|
||||
FROM \`${SPU_TABLE}\`
|
||||
${whereSql}
|
||||
ORDER BY ${orderBySql}
|
||||
LIMIT ? OFFSET ?`,
|
||||
[ ...params, pageSize, (page - 1) * pageSize ]
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
columns: MAIN_COLUMNS,
|
||||
rows: normalizeMainRows(listRows),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
startDate,
|
||||
endDate,
|
||||
sortField: MAIN_SORT_FIELDS[sortField] ? sortField : '',
|
||||
sortOrder: MAIN_SORT_FIELDS[sortField] ? (sortOrder || 'descend') : '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSkuDetail({ spu, accessScope }) {
|
||||
const latest = await getLatestSnapshotInfo(SKU_TABLE);
|
||||
if (!latest?.statDate) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
spu,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
columns: SKU_DETAIL_COLUMNS,
|
||||
rows: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const whereParts = [ '`statDate` = ?', '`spu` = ?' ];
|
||||
const params = [ latest.statDate, spu ];
|
||||
const departmentFilters = getEffectiveDepartmentFilters(accessScope, []);
|
||||
if (departmentFilters === null) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
spu,
|
||||
startDate: latest.startDate,
|
||||
endDate: latest.endDate,
|
||||
columns: SKU_DETAIL_COLUMNS,
|
||||
rows: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (Array.isArray(departmentFilters) && departmentFilters.length > 0) {
|
||||
const scopeClause = buildLikeAnyClause('`ownerDeptList`', departmentFilters, params);
|
||||
if (scopeClause) whereParts.push(scopeClause);
|
||||
}
|
||||
|
||||
const rows = await localDb.query(
|
||||
`SELECT
|
||||
\`skuCode\`,
|
||||
\`goodsIdCount\`,
|
||||
\`activeGoodsIdCount\`,
|
||||
\`skuListingActiveRate\`,
|
||||
\`skuIdEfficiency\`,
|
||||
\`isPriceBreak\`,
|
||||
\`payOrdrAmt\`,
|
||||
\`feeRatio\`,
|
||||
\`afterSaleRate\`,
|
||||
\`goodsUv\`,
|
||||
\`orderNum\`,
|
||||
\`payOrdrUsrCnt\`,
|
||||
\`payOrdrGoodsQty\`,
|
||||
\`ctr\`,
|
||||
\`goodsVcr\`,
|
||||
\`customerUnitPrice\`,
|
||||
\`pieceUnitPrice\`,
|
||||
\`spend\`
|
||||
FROM \`${SKU_TABLE}\`
|
||||
WHERE ${whereParts.join(' AND ')}
|
||||
ORDER BY \`payOrdrAmt\` DESC, \`goodsIdCount\` DESC, \`skuCode\` ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
spu,
|
||||
startDate: latest.startDate,
|
||||
endDate: latest.endDate,
|
||||
columns: SKU_DETAIL_COLUMNS,
|
||||
rows: normalizeMainRows(rows),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchGoodsDetail({ skuCode, accessScope }) {
|
||||
const latest = await getLatestSnapshotInfo(GOODS_TABLE);
|
||||
if (!latest?.statDate) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
skuCode,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
columns: GOODS_DETAIL_COLUMNS,
|
||||
rows: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const whereParts = [ '`statDate` = ?', '`skuCode` = ?' ];
|
||||
const params = [ latest.statDate, skuCode ];
|
||||
const departmentFilters = getEffectiveDepartmentFilters(accessScope, []);
|
||||
if (departmentFilters === null) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
skuCode,
|
||||
startDate: latest.startDate,
|
||||
endDate: latest.endDate,
|
||||
columns: GOODS_DETAIL_COLUMNS,
|
||||
rows: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (Array.isArray(departmentFilters) && departmentFilters.length > 0) {
|
||||
whereParts.push(`\`ownerDept\` IN (${new Array(departmentFilters.length).fill('?').join(', ')})`);
|
||||
params.push(...departmentFilters);
|
||||
}
|
||||
|
||||
const rows = await localDb.query(
|
||||
`SELECT
|
||||
\`goodsId\`,
|
||||
\`goodsName\`,
|
||||
\`hdThumbUrl\`,
|
||||
\`shopName\`,
|
||||
\`idOwner\`,
|
||||
\`ownerDept\`,
|
||||
\`goodsUv\`,
|
||||
\`inquiryNum\`,
|
||||
\`goodsFavCnt\`,
|
||||
\`goodsFavNum\`,
|
||||
\`orderUserCount\`,
|
||||
\`ctr\`,
|
||||
\`clickConversionRate\`,
|
||||
\`goodsVcr\`,
|
||||
\`ordrVstrRto\`,
|
||||
\`payOrdrRto\`,
|
||||
\`imprUsrCnt\`,
|
||||
\`unpaidUserCount\`,
|
||||
\`payOrdrAmt\`,
|
||||
\`orderNum\`,
|
||||
\`payOrdrUsrCnt\`,
|
||||
\`payOrdrGoodsQty\`,
|
||||
\`customerUnitPrice\`,
|
||||
\`pieceUnitPrice\`,
|
||||
\`spend\`,
|
||||
\`feeRatio\`,
|
||||
\`afterSaleRate\`,
|
||||
\`afterSaleCount\`,
|
||||
\`badReviewCount\`,
|
||||
\`isPriceBreak\`
|
||||
FROM \`${GOODS_TABLE}\`
|
||||
WHERE ${whereParts.join(' AND ')}
|
||||
ORDER BY \`payOrdrAmt\` DESC, \`goodsUv\` DESC, \`goodsId\` ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
skuCode,
|
||||
startDate: latest.startDate,
|
||||
endDate: latest.endDate,
|
||||
columns: GOODS_DETAIL_COLUMNS,
|
||||
rows: normalizeGoodsRows(rows),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function registerPddSkuDimensionBoard({
|
||||
router,
|
||||
authMiddleware,
|
||||
}) {
|
||||
router.get('/pdd/product-dashboard/sku-dimension-board/list', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.sub || req.user?.id || req.user?.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ success: false, error: '未获取到登录用户信息' });
|
||||
}
|
||||
|
||||
const accessScope = await getPddDataAccessScope(userId);
|
||||
if (!accessScope) {
|
||||
return res.status(403).json({ success: false, error: '未获取到数据权限' });
|
||||
}
|
||||
|
||||
const latest = await getLatestSnapshotInfo(SPU_TABLE);
|
||||
const page = Math.max(parseInt(req.query.page || '1', 10), 1);
|
||||
const pageSize = Math.min(Math.max(parseInt(req.query.pageSize || '20', 10), 1), 500);
|
||||
const sortField = String(req.query.sortField || '').trim();
|
||||
const sortOrder = String(req.query.sortOrder || '').trim().toLowerCase();
|
||||
const spu = String(req.query.spu || '').trim();
|
||||
const shopNames = normalizeMultiValueQueryParam(req.query.shopName);
|
||||
const departments = normalizeMultiValueQueryParam(req.query.department);
|
||||
const idOwners = normalizeMultiValueQueryParam(req.query.idOwners);
|
||||
|
||||
if (!latest?.statDate) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
columns: MAIN_COLUMNS,
|
||||
rows: [],
|
||||
total: 0,
|
||||
page,
|
||||
pageSize,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
sortField: '',
|
||||
sortOrder: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await fetchSpuList({
|
||||
statDate: latest.statDate,
|
||||
startDate: latest.startDate,
|
||||
endDate: latest.endDate,
|
||||
page,
|
||||
pageSize,
|
||||
sortField,
|
||||
sortOrder,
|
||||
spu,
|
||||
shopNames,
|
||||
departments,
|
||||
idOwners,
|
||||
accessScope,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/pdd/product-dashboard/sku-dimension-board/sku-detail', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const spu = String(req.query.spu || '').trim();
|
||||
if (!spu) {
|
||||
return res.status(400).json({ success: false, error: 'spu is required' });
|
||||
}
|
||||
|
||||
const userId = req.user?.sub || req.user?.id || req.user?.userId;
|
||||
const accessScope = await getPddDataAccessScope(userId);
|
||||
if (!accessScope) {
|
||||
return res.status(403).json({ success: false, error: '未获取到数据权限' });
|
||||
}
|
||||
|
||||
res.json(await fetchSkuDetail({ spu, accessScope }));
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/pdd/product-dashboard/sku-dimension-board/goods-detail', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const skuCode = String(req.query.skuCode || '').trim();
|
||||
if (!skuCode) {
|
||||
return res.status(400).json({ success: false, error: 'skuCode is required' });
|
||||
}
|
||||
|
||||
const userId = req.user?.sub || req.user?.id || req.user?.userId;
|
||||
const accessScope = await getPddDataAccessScope(userId);
|
||||
if (!accessScope) {
|
||||
return res.status(403).json({ success: false, error: '未获取到数据权限' });
|
||||
}
|
||||
|
||||
res.json(await fetchGoodsDetail({ skuCode, accessScope }));
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
};
|
||||
139
backend/src/api/planningMayProductPublic.js
Normal file
139
backend/src/api/planningMayProductPublic.js
Normal file
@ -0,0 +1,139 @@
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const {
|
||||
getPlanningTableData,
|
||||
} = require('../services/planningUploadService');
|
||||
const {
|
||||
getPlanningMayProductSnapshotStatus,
|
||||
} = require('../services/planningMayProductDataService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function getConfiguredApiKeys() {
|
||||
return [
|
||||
process.env.PLANNING_MAY_PRODUCT_API_KEY,
|
||||
process.env.PLANNING_MAY_PRODUCT_API_KEYS,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.flatMap((value) => String(value).split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function safeEquals(left, right) {
|
||||
const leftBuffer = Buffer.from(String(left || ''));
|
||||
const rightBuffer = Buffer.from(String(right || ''));
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function extractApiKey(req) {
|
||||
const headerKey = req.get('x-api-key');
|
||||
if (headerKey) {
|
||||
return headerKey.trim();
|
||||
}
|
||||
|
||||
const authHeader = req.get('authorization') || '';
|
||||
const match = authHeader.match(/^(?:ApiKey|Bearer)\s+(.+)$/i);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
return String(req.query.apiKey || req.query.api_key || '').trim();
|
||||
}
|
||||
|
||||
function requireApiKey(req, res, next) {
|
||||
const configuredKeys = getConfiguredApiKeys();
|
||||
if (configuredKeys.length === 0) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: '停产分析第三方 API key 尚未配置',
|
||||
});
|
||||
}
|
||||
|
||||
const providedKey = extractApiKey(req);
|
||||
if (!providedKey || !configuredKeys.some((key) => safeEquals(providedKey, key))) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'API key 无效或未提供',
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value, fallback, maxValue) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(parsed, maxValue);
|
||||
}
|
||||
|
||||
function buildPublicTablePayload(result = {}, page, pageSize) {
|
||||
return {
|
||||
snapshotDate: result.snapshotDate || result.template?.snapshotDate || null,
|
||||
sourceLatestBizDate: result.sourceLatestBizDate || result.template?.sourceLatestBizDate || null,
|
||||
sourceMode: result.sourceMode || result.template?.sourceMode || null,
|
||||
generatedAt: result.template?.generatedAt || null,
|
||||
calcVersion: result.template?.calcVersion || null,
|
||||
total: Number(result.total || 0),
|
||||
page,
|
||||
pageSize,
|
||||
hasMore: page * pageSize < Number(result.total || 0),
|
||||
reviewDashboard: result.reviewDashboard || null,
|
||||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/status', requireApiKey, async (_req, res) => {
|
||||
try {
|
||||
const data = await getPlanningMayProductSnapshotStatus();
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[PlanningMayProductPublicAPI] 获取状态失败:', error);
|
||||
return res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取停产分析状态失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stop-production', requireApiKey, async (req, res) => {
|
||||
try {
|
||||
const page = normalizePositiveInteger(req.query.page, 1, 100000);
|
||||
const pageSize = normalizePositiveInteger(req.query.pageSize || req.query.page_size, 100, 100);
|
||||
const result = await getPlanningTableData('may_product_data', {
|
||||
page,
|
||||
pageSize,
|
||||
mayMatch: req.query.keyword || req.query.q || req.query.mayMatch,
|
||||
snapshotDate: req.query.snapshotDate || req.query.snapshot_date,
|
||||
reviewAction: req.query.reviewAction || req.query.review_action || 'all',
|
||||
virtualCategory: req.query.virtualCategory || req.query.virtual_category || req.query.mode,
|
||||
inventoryScope: req.query.inventoryScope || req.query.inventory_scope || req.query.stockPath || req.query.stock_path,
|
||||
brandGroup: req.query.brandGroup || req.query.brand_group,
|
||||
brandName: req.query.brandName || req.query.brand || req.query.brand_name,
|
||||
categoryName: req.query.categoryName || req.query.category || req.query.category_name,
|
||||
supplierName: req.query.supplierName || req.query.supplier || req.query.supplier_name,
|
||||
missingMetric: req.query.missingMetric || req.query.missing_metric,
|
||||
});
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({
|
||||
success: true,
|
||||
data: buildPublicTablePayload(result, page, pageSize),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PlanningMayProductPublicAPI] 查询停产分析失败:', error);
|
||||
return res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '查询停产分析失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
203
backend/src/api/planningSelectionFissionExternal.js
Normal file
203
backend/src/api/planningSelectionFissionExternal.js
Normal file
@ -0,0 +1,203 @@
|
||||
const express = require('express');
|
||||
const {
|
||||
claimSelectionFissionDeliveryTask,
|
||||
getSelectionFissionDeliveryPackage,
|
||||
listSelectionFissionDeliveryPlans,
|
||||
listSelectionFissionUserShops,
|
||||
receiveSelectionFissionAutoListingFailure,
|
||||
receiveSelectionFissionAutoListingResult,
|
||||
syncSelectionFissionUserShops,
|
||||
} = require('../services/planningSelectionFissionService');
|
||||
const {
|
||||
requireExternalAutomationApiKey: requireApiKey,
|
||||
requireDesktopPageAccess,
|
||||
} = require('../services/externalAutomationAuth');
|
||||
|
||||
const router = express.Router();
|
||||
const requirePlanningAccess = requireDesktopPageAccess('planning_upload');
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value === true || value === false) return value;
|
||||
const text = String(value ?? '').trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getAuthenticatedDesktopUserName(req) {
|
||||
if (req.externalApiKey?.authType !== 'jwt') return '';
|
||||
return String(req.user?.name || req.user?.username || '').trim();
|
||||
}
|
||||
|
||||
function buildInput(req) {
|
||||
const authenticatedUserName = getAuthenticatedDesktopUserName(req);
|
||||
return {
|
||||
...req.query,
|
||||
schemeCode: req.params.schemeCode || req.query.schemeCode || req.query.scheme_code,
|
||||
schemeId: req.params.schemeCode || req.query.schemeId || req.query.scheme_id,
|
||||
dingtalkUserName: authenticatedUserName ||
|
||||
req.query.dingtalkUserName
|
||||
|| req.query.dingtalk_user_name
|
||||
|| req.query.dingtalkUsername
|
||||
|| req.query.userName
|
||||
|| req.query.user_name,
|
||||
includeIncomplete: normalizeBoolean(req.query.includeIncomplete || req.query.include_incomplete, false),
|
||||
};
|
||||
}
|
||||
|
||||
function buildListingResultInput(req) {
|
||||
const body = req.body || {};
|
||||
const query = req.query || {};
|
||||
const input = {
|
||||
...query,
|
||||
...body,
|
||||
claimToken: req.get('x-task-claim-token')
|
||||
|| req.get('x-claim-token')
|
||||
|| body.claimToken
|
||||
|| body.claim_token
|
||||
|| body.taskClaimToken
|
||||
|| body.task_claim_token
|
||||
|| body.task?.claimToken
|
||||
|| body.task?.claim_token
|
||||
|| body.claim?.claimToken
|
||||
|| body.claim?.claim_token
|
||||
|| query.claimToken
|
||||
|| query.claim_token
|
||||
|| query.taskClaimToken
|
||||
|| query.task_claim_token,
|
||||
};
|
||||
const authenticatedUserName = getAuthenticatedDesktopUserName(req);
|
||||
return authenticatedUserName
|
||||
? { ...input, dingtalkUserName: authenticatedUserName }
|
||||
: input;
|
||||
}
|
||||
|
||||
function buildRequestInput(req) {
|
||||
const input = {
|
||||
...(req.query || {}),
|
||||
...(req.body || {}),
|
||||
};
|
||||
const authenticatedUserName = getAuthenticatedDesktopUserName(req);
|
||||
return authenticatedUserName
|
||||
? { ...input, dingtalkUserName: authenticatedUserName }
|
||||
: input;
|
||||
}
|
||||
|
||||
function buildRequestContext(req) {
|
||||
return {
|
||||
apiKeyId: req.externalApiKey?.id,
|
||||
apiKeyName: req.externalApiKey?.name,
|
||||
remoteIp: req.externalApiKey?.remoteIp,
|
||||
};
|
||||
}
|
||||
|
||||
function handleError(res, error, fallbackMessage) {
|
||||
const statusCode = error.statusCode || error.status || 500;
|
||||
if (statusCode >= 500) {
|
||||
console.error('[SelectionFissionExternalAPI] 请求失败:', error);
|
||||
}
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error: error.message || fallbackMessage,
|
||||
data: error.data || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/plans', requireApiKey, requirePlanningAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await listSelectionFissionDeliveryPlans(buildInput(req));
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '查询可交付选品方案失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/plans/:schemeCode', requireApiKey, requirePlanningAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await getSelectionFissionDeliveryPackage(buildInput(req));
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '查询选品方案交付包失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/shops', requireApiKey, requirePlanningAccess, async (req, res) => {
|
||||
try {
|
||||
const input = buildRequestInput(req);
|
||||
const dingtalkUserName = input.dingtalkUserName
|
||||
|| input.dingtalk_user_name
|
||||
|| input.dingtalkUsername
|
||||
|| input.userName
|
||||
|| input.user_name;
|
||||
const data = await listSelectionFissionUserShops(dingtalkUserName, {
|
||||
includeInactive: normalizeBoolean(input.includeInactive || input.include_inactive, false),
|
||||
});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '查询用户上架店铺失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/shops/sync', requireApiKey, requirePlanningAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await syncSelectionFissionUserShops(
|
||||
buildRequestInput(req),
|
||||
buildRequestContext(req)
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '同步用户上架店铺失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/tasks/claim', requireApiKey, requirePlanningAccess, async (req, res) => {
|
||||
try {
|
||||
const data = await claimSelectionFissionDeliveryTask(
|
||||
buildRequestInput(req),
|
||||
buildRequestContext(req)
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '领取自动上架任务失败');
|
||||
}
|
||||
});
|
||||
|
||||
async function receiveListingResult(req, res) {
|
||||
try {
|
||||
const data = await receiveSelectionFissionAutoListingResult(
|
||||
req.params.taskId,
|
||||
buildListingResultInput(req),
|
||||
buildRequestContext(req)
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '回传自动上架商品ID失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function receiveListingFailure(req, res) {
|
||||
try {
|
||||
const data = await receiveSelectionFissionAutoListingFailure(
|
||||
req.params.taskId,
|
||||
buildListingResultInput(req),
|
||||
buildRequestContext(req)
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '回传自动上架失败结果失败');
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/tasks/:taskId/listing-result', requireApiKey, requirePlanningAccess, receiveListingResult);
|
||||
router.post('/plans/:taskId/listing-result', requireApiKey, requirePlanningAccess, receiveListingResult);
|
||||
router.post('/tasks/:taskId/listing-failure', requireApiKey, requirePlanningAccess, receiveListingFailure);
|
||||
router.post('/plans/:taskId/listing-failure', requireApiKey, requirePlanningAccess, receiveListingFailure);
|
||||
|
||||
module.exports = router;
|
||||
82
backend/src/api/planningUpload.js
Normal file
82
backend/src/api/planningUpload.js
Normal file
@ -0,0 +1,82 @@
|
||||
const express = require('express');
|
||||
const { getResolvedUserWithPageAccess } = require('../services/pageAccess');
|
||||
const { getOpsBIncentiveBoard, getOpsBIncentiveBatches, getOpsBIncentiveDaily } = require('../services/opsBIncentiveBoardService');
|
||||
const { getAlibaba1688Product } = require('../services/alibaba1688ProductService');
|
||||
const { attachEncodingDetail } = require('../services/alibaba1688ProductEncodingRegistry');
|
||||
const router = express.Router();
|
||||
|
||||
async function ensurePlanningUploadAccess(req) {
|
||||
const user = await getResolvedUserWithPageAccess(req.user.sub);
|
||||
if (!user || (user.role !== 'admin' && !user.allowedPageKeys?.includes('ops_b_incentive'))) {
|
||||
const error = new Error('当前账号没有激励看板权限');
|
||||
error.status = 403;
|
||||
throw error;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async function ensureSelectionFissionAccess(req) {
|
||||
// Selection workflows are available to every authenticated user. They share
|
||||
// this router with template uploads, but must not inherit upload permissions.
|
||||
return getResolvedUserWithPageAccess(req.user.sub);
|
||||
}
|
||||
|
||||
|
||||
router.get('/selection-fission/1688-products/:productId', async (req, res) => {
|
||||
try {
|
||||
await ensureSelectionFissionAccess(req);
|
||||
const result = await attachEncodingDetail(
|
||||
await getAlibaba1688Product(req.params.productId)
|
||||
);
|
||||
res.setHeader('Cache-Control', 'private, no-store, no-cache, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('获取1688待上架商品详情失败:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || '获取1688待上架商品详情失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive/batches', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const data = await getOpsBIncentiveBatches();
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B激励看板批次列表失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B激励看板批次列表失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const result = await getOpsBIncentiveBoard(req.query || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B拼多多激励看板失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B拼多多激励看板失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive/daily', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const result = await getOpsBIncentiveDaily(req.query || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B拼多多激励每日明细失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B拼多多激励每日明细失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
39
backend/src/api/presaleExternal.js
Normal file
39
backend/src/api/presaleExternal.js
Normal file
@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { listPresaleTasks } = require('../services/presaleTaskService');
|
||||
const {
|
||||
requireExternalAutomationApiKey,
|
||||
requireDesktopPageAccess,
|
||||
} = require('../services/externalAutomationAuth');
|
||||
|
||||
const router = express.Router();
|
||||
const requirePresaleAccess = requireDesktopPageAccess('planning_upload');
|
||||
|
||||
function handleError(res, error, fallbackMessage) {
|
||||
const statusCode = error.statusCode || error.status || 500;
|
||||
if (statusCode >= 500) {
|
||||
console.error('[PresaleExternalAPI] 请求失败:', error);
|
||||
}
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error: error.message || fallbackMessage,
|
||||
data: error.data || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/tasks', requireExternalAutomationApiKey, requirePresaleAccess, async (req, res) => {
|
||||
try {
|
||||
const query = { ...(req.query || {}) };
|
||||
if (req.externalApiKey?.authType === 'jwt') {
|
||||
query.dingtalkUserName = String(req.user?.name || req.user?.username || '').trim();
|
||||
}
|
||||
const data = await listPresaleTasks(query);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
return handleError(res, error, '查询预售处理数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
833
backend/src/api/pricingCalculator.js
Normal file
833
backend/src/api/pricingCalculator.js
Normal file
@ -0,0 +1,833 @@
|
||||
const express = require('express');
|
||||
const XLSX = require('xlsx');
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getCompanySkusBySpu,
|
||||
fetchFreightProfile,
|
||||
getSelectionFissionProfitPricingContext,
|
||||
getSelectionFissionProductCodeMeta,
|
||||
saveSelectionFissionProfitPricingTable,
|
||||
} = require('../services/planningSelectionFissionService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const HISTORY_TABLE = 'pricing_calculator_history';
|
||||
// 运费/售后画像查询设定超时,避免整页等待。
|
||||
const FREIGHT_PROFILE_TIMEOUT_MS = Math.max(
|
||||
Number.parseInt(process.env.PRICING_CALC_FREIGHT_TIMEOUT_MS || '30000', 10) || 30000,
|
||||
5000
|
||||
);
|
||||
const MAX_HISTORY_ROWS = 500;
|
||||
const MAX_PAYLOAD_BYTES = 1024 * 1024;
|
||||
const CALCULATOR_TYPES = new Set(['company', 'alibaba1688']);
|
||||
|
||||
let historyTableReady = null;
|
||||
|
||||
async function ensureHistoryTable() {
|
||||
if (!historyTableReady) {
|
||||
historyTableReady = (async () => {
|
||||
// 先查表是否存在:生产/隧道账号通常只有 SELECT/INSERT/UPDATE/DELETE,
|
||||
// MariaDB 对 CREATE TABLE IF NOT EXISTS 也会校验 CREATE 权限,存在时直接跳过。
|
||||
const existing = await database.local.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[HISTORY_TABLE]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return;
|
||||
}
|
||||
await database.local.query(
|
||||
`CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
|
||||
user_id BIGINT NOT NULL COMMENT '测算人用户ID',
|
||||
user_name VARCHAR(100) NULL COMMENT '测算人显示名',
|
||||
spu_code VARCHAR(160) NOT NULL COMMENT '公司SPU',
|
||||
brand VARCHAR(100) NULL COMMENT '品牌名称',
|
||||
note VARCHAR(500) NULL COMMENT '备注',
|
||||
row_count INT NOT NULL DEFAULT 0 COMMENT '测算行数',
|
||||
payload_json LONGTEXT NOT NULL COMMENT '完整测算快照 JSON',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_pricing_calc_history_user_created (user_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算历史记录'`
|
||||
);
|
||||
})();
|
||||
}
|
||||
try {
|
||||
await historyTableReady;
|
||||
} catch (error) {
|
||||
// 建表失败后允许下次请求重试
|
||||
historyTableReady = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function sendError(res, error, fallbackMessage) {
|
||||
const status = error.status || error.statusCode || 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error.message || fallbackMessage,
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestUser(req) {
|
||||
const user = req.user || {};
|
||||
return {
|
||||
id: Number(user.sub || user.id || 0) || null,
|
||||
name: user.name || user.username || '',
|
||||
isAdmin: user.role === 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
function requireUserId(req, res) {
|
||||
const { id } = getRequestUser(req);
|
||||
if (!id) {
|
||||
res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' });
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const normalizeText = (value, maxLength) => String(value ?? '').trim().slice(0, maxLength);
|
||||
|
||||
const normalizeCalculatorType = (value) => (
|
||||
CALCULATOR_TYPES.has(value) ? value : 'company'
|
||||
);
|
||||
|
||||
function sanitizeHistoryProduct(input) {
|
||||
const product = input && typeof input === 'object' ? input : {};
|
||||
const dimensions = (Array.isArray(product.skuDimensions) ? product.skuDimensions : [])
|
||||
.slice(0, 4)
|
||||
.map((dimension) => ({
|
||||
name: normalizeText(dimension?.name, 120),
|
||||
values: (Array.isArray(dimension?.values) ? dimension.values : [])
|
||||
.slice(0, 500)
|
||||
.map((value) => normalizeText(value, 500))
|
||||
.filter(Boolean),
|
||||
}))
|
||||
.filter((dimension) => dimension.name);
|
||||
return {
|
||||
productId: normalizeText(product.productId, 80),
|
||||
title: normalizeText(product.title, 500),
|
||||
mainImage: normalizeText(product.mainImage, 2000),
|
||||
sourceUrl: normalizeText(product.sourceUrl, 2000),
|
||||
storeName: normalizeText(product.storeName, 300),
|
||||
category: normalizeText(product.category, 500),
|
||||
skuDimensions: dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSkuRow(row) {
|
||||
const qgyf = Number(row?.raw?.qgyf);
|
||||
return {
|
||||
skuCode: row.skuCode || '',
|
||||
status: row.saleStatus || '',
|
||||
stock: row.stock ?? null,
|
||||
stockWarehouses: Array.isArray(row.stockWarehouses) ? row.stockWarehouses : [],
|
||||
name: row.skuName || row.productName || '',
|
||||
image: row.skuImageUrl || '',
|
||||
size: row.size || '',
|
||||
color: row.colorName || '',
|
||||
cost: row.cost ?? null,
|
||||
minimumPrice: row.minimumPrice ?? null,
|
||||
salePrice: row?.raw?.sale_price ?? row?.raw?.salePrice ?? null,
|
||||
// qgyf = 聚水潭接口返回的全国平均运费(按条码)
|
||||
freight: Number.isFinite(qgyf) ? qgyf : null,
|
||||
supplier: row.supplierName || '',
|
||||
category: row.categoryName || '',
|
||||
lengthCm: row.lengthCm ?? row?.raw?.length_cm ?? row?.raw?.length ?? null,
|
||||
widthCm: row.widthCm ?? row?.raw?.width_cm ?? row?.raw?.width ?? null,
|
||||
heightCm: row.heightCm ?? row?.raw?.height_cm ?? row?.raw?.height ?? null,
|
||||
weight: row.weight ?? row?.raw?.weight ?? row?.raw?.gross_weight ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchFreightProfileBestEffort(spuCode, skuRows, user) {
|
||||
const emptyProfile = {
|
||||
nationalFreight: null,
|
||||
zone1Freight: null,
|
||||
zone2Freight: null,
|
||||
zone3Freight: null,
|
||||
zone4Freight: null,
|
||||
aftersaleRatio: null,
|
||||
minimumPricesBySku: {},
|
||||
raw: null,
|
||||
};
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
fetchFreightProfile({ spuCode, skuRows }, user),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => resolve({ ...emptyProfile, warning: '运费/售后信息查询超时,可手动填写运费' }), FREIGHT_PROFILE_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
return result && typeof result === 'object' ? { ...emptyProfile, ...result } : emptyProfile;
|
||||
} catch (error) {
|
||||
return { ...emptyProfile, warning: error.message || '运费/售后信息查询失败' };
|
||||
}
|
||||
}
|
||||
|
||||
// 拉取 SPU 下全部条码(含销售、退市、停产)+ 运费/售后画像(尽力而为)
|
||||
// 运费由聚水潭接口 qgyf 字段按条码直出,
|
||||
// 默认跳过;如需售后占比/最低限价画像,可设 PRICING_CALC_FREIGHT_PROFILE=on 重新开启。
|
||||
const FREIGHT_PROFILE_ENABLED = String(process.env.PRICING_CALC_FREIGHT_PROFILE || 'off').toLowerCase() === 'on';
|
||||
|
||||
function getSelectionSkuCodeSet(selectionContext) {
|
||||
if (!selectionContext) return null;
|
||||
const selectedSkuCodes = Array.isArray(selectionContext.skuSceneImages?.selectedSkuCodes)
|
||||
? selectionContext.skuSceneImages.selectedSkuCodes
|
||||
: [];
|
||||
const fallbackSkuCodes = Array.isArray(selectionContext.rows)
|
||||
? selectionContext.rows.map((row) => row.skuCode)
|
||||
: [];
|
||||
return new Set(
|
||||
(selectedSkuCodes.length > 0 ? selectedSkuCodes : fallbackSkuCodes)
|
||||
.map((skuCode) => normalizeText(skuCode, 160).toUpperCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
function filterSkuRowsForSelectionContext(skuRows, selectionContext) {
|
||||
const rows = Array.isArray(skuRows) ? skuRows : [];
|
||||
const selectedSkuCodes = getSelectionSkuCodeSet(selectionContext);
|
||||
if (!selectedSkuCodes) return rows;
|
||||
return rows.filter((row) => selectedSkuCodes.has(
|
||||
normalizeText(row?.skuCode || row?.sku_code || row?.barcode, 160).toUpperCase()
|
||||
));
|
||||
}
|
||||
|
||||
router.get('/product-code/meta', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
try {
|
||||
res.json({ success: true, data: await getSelectionFissionProductCodeMeta() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '公司SKU编码配置加载失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/spu/:spuCode', async (req, res) => {
|
||||
const spuCode = normalizeText(req.params.spuCode, 160).toUpperCase();
|
||||
if (!spuCode) {
|
||||
return res.status(400).json({ success: false, error: '请输入公司 SPU' });
|
||||
}
|
||||
|
||||
try {
|
||||
const planId = Number.parseInt(req.query.planId, 10) || 0;
|
||||
const itemId = Number.parseInt(req.query.itemId, 10) || 0;
|
||||
if (Boolean(planId) !== Boolean(itemId)) {
|
||||
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
|
||||
}
|
||||
const skuResult = await getCompanySkusBySpu(spuCode, { includeInactive: true });
|
||||
const allSkuRows = Array.from(skuResult.rows || []);
|
||||
if (allSkuRows.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `未查询到 SPU「${spuCode}」的条码,请确认 SPU 是否正确`,
|
||||
});
|
||||
}
|
||||
|
||||
let selectionContext = null;
|
||||
if (planId && itemId) {
|
||||
selectionContext = await getSelectionFissionProfitPricingContext(planId, itemId);
|
||||
if (selectionContext.spuCode.toUpperCase() !== spuCode) {
|
||||
return res.status(409).json({ success: false, error: `该选品任务绑定的是SPU ${selectionContext.spuCode}` });
|
||||
}
|
||||
}
|
||||
const skuRows = filterSkuRowsForSelectionContext(allSkuRows, selectionContext);
|
||||
if (selectionContext && skuRows.length === 0) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: '选品任务已选 SKU 不在当前 SPU 条码列表中,请返回 SKU 节点重新确认',
|
||||
});
|
||||
}
|
||||
|
||||
const freightProfile = FREIGHT_PROFILE_ENABLED
|
||||
? await fetchFreightProfileBestEffort(spuCode, skuRows, req.user)
|
||||
: { nationalFreight: null, aftersaleRatio: null, minimumPricesBySku: {}, warning: null };
|
||||
const warnings = [...(skuResult.warnings || [])];
|
||||
if (freightProfile.warning) {
|
||||
warnings.push(freightProfile.warning);
|
||||
}
|
||||
|
||||
const savedRowsBySku = new Map((selectionContext?.rows || []).map((row) => [
|
||||
String(row.skuCode || '').toUpperCase(),
|
||||
row,
|
||||
]));
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
spu: spuCode,
|
||||
source: skuResult.source,
|
||||
warnings,
|
||||
freightProfile: {
|
||||
nationalFreight: freightProfile.nationalFreight ?? null,
|
||||
zone1Freight: freightProfile.zone1Freight ?? null,
|
||||
zone2Freight: freightProfile.zone2Freight ?? null,
|
||||
zone3Freight: freightProfile.zone3Freight ?? null,
|
||||
zone4Freight: freightProfile.zone4Freight ?? null,
|
||||
aftersaleRatio: freightProfile.aftersaleRatio ?? null,
|
||||
minimumPricesBySku: freightProfile.minimumPricesBySku || {},
|
||||
},
|
||||
selectionContext: selectionContext ? {
|
||||
planId: selectionContext.planId,
|
||||
itemId: selectionContext.itemId,
|
||||
taskCode: selectionContext.taskCode,
|
||||
schemeCode: selectionContext.schemeCode,
|
||||
listedItemId: selectionContext.listedItemId,
|
||||
pricingTableSavedAt: selectionContext.pricingTableSavedAt,
|
||||
pricingTableSavedBy: selectionContext.pricingTableSavedBy,
|
||||
pricingTableVersion: selectionContext.pricingTableVersion,
|
||||
workflowProfile: selectionContext.workflowProfile,
|
||||
productCodeStatus: selectionContext.productCodeStatus,
|
||||
productCodeStyleCode: selectionContext.productCodeStyleCode,
|
||||
productCodeConfig: selectionContext.productCodeConfig,
|
||||
productCodeErrorMessage: selectionContext.productCodeErrorMessage,
|
||||
productCodeRequestedAt: selectionContext.productCodeRequestedAt,
|
||||
productCodeCompletedAt: selectionContext.productCodeCompletedAt,
|
||||
} : null,
|
||||
rows: skuRows.map((skuRow) => {
|
||||
const mapped = mapSkuRow(skuRow);
|
||||
const saved = savedRowsBySku.get(String(mapped.skuCode || '').toUpperCase());
|
||||
return {
|
||||
...mapped,
|
||||
savedPricing: saved ? {
|
||||
price: saved.finalPrice,
|
||||
groupPrice: saved.groupPrice,
|
||||
installFee: saved.installationFee,
|
||||
coupon: saved.discountAmount,
|
||||
activityDiscount: saved.activityDiscount,
|
||||
shipping: saved.freightNational,
|
||||
commission: saved.platformCommissionRatio,
|
||||
promoRate: saved.promotionRatio,
|
||||
adFee: saved.marketingAdRatio,
|
||||
seeding: saved.farmingRatio,
|
||||
afterSale: saved.aftersaleRatio,
|
||||
insurance: saved.insuranceRatio,
|
||||
brandFee: saved.brandUsageRatio,
|
||||
targetMargin: saved.operatingProfitRatio,
|
||||
originalSkuName: saved.originalSkuName,
|
||||
selectedSkuName: saved.selectedSkuName,
|
||||
internalProductCode: saved.internalProductCode,
|
||||
productCodeProductName: saved.productCodeProductName,
|
||||
productCodeColorSpec: saved.productCodeColorSpec,
|
||||
} : null,
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, 'SPU 条码查询失败,请稍后重试');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存一条测算历史(完整行快照,含输入与公式结果)
|
||||
router.post('/history', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const body = req.body || {};
|
||||
const spuCode = normalizeText(body.spuCode, 160).toUpperCase();
|
||||
const calculatorType = normalizeCalculatorType(body.calculatorType);
|
||||
const rows = Array.isArray(body.rows) ? body.rows : [];
|
||||
if (!spuCode) {
|
||||
return res.status(400).json({ success: false, error: '缺少 SPU 编码' });
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return res.status(400).json({ success: false, error: '测算行为空,无法保存' });
|
||||
}
|
||||
if (rows.length > MAX_HISTORY_ROWS) {
|
||||
return res.status(400).json({ success: false, error: `单条历史最多保存 ${MAX_HISTORY_ROWS} 行` });
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: 4,
|
||||
calculatorType,
|
||||
spuCode,
|
||||
isBlackLabelShop: body.isBlackLabelShop === true,
|
||||
brand: normalizeText(body.brand, 100),
|
||||
note: normalizeText(body.note, 500),
|
||||
savedAt: new Date().toISOString(),
|
||||
summary: body.summary && typeof body.summary === 'object' ? body.summary : null,
|
||||
freightProfile: body.freightProfile && typeof body.freightProfile === 'object' ? body.freightProfile : null,
|
||||
product: calculatorType === 'alibaba1688' ? sanitizeHistoryProduct(body.product) : null,
|
||||
rows,
|
||||
};
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
if (Buffer.byteLength(payloadJson, 'utf8') > MAX_PAYLOAD_BYTES) {
|
||||
return res.status(400).json({ success: false, error: '测算快照过大,无法保存' });
|
||||
}
|
||||
|
||||
try {
|
||||
let selectionPricing = null;
|
||||
const planId = Number.parseInt(body.selectionContext?.planId || body.planId, 10) || 0;
|
||||
const itemId = Number.parseInt(body.selectionContext?.itemId || body.itemId, 10) || 0;
|
||||
if (Boolean(planId) !== Boolean(itemId)) {
|
||||
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
|
||||
}
|
||||
if (planId && itemId) {
|
||||
// 测算页会展示实时接口中的全部条码;自动上架保存用户保留的任务 SKU 子集。
|
||||
const pricingContext = await getSelectionFissionProfitPricingContext(planId, itemId);
|
||||
const listingSkuCodes = new Set((pricingContext.rows || []).map((row) => (
|
||||
normalizeText(row.skuCode, 160).toUpperCase()
|
||||
)));
|
||||
const listingRows = rows.filter((row) => listingSkuCodes.has(
|
||||
normalizeText(row.skuCode || row.sku || row.barcode, 160).toUpperCase()
|
||||
));
|
||||
selectionPricing = await saveSelectionFissionProfitPricingTable(
|
||||
planId,
|
||||
itemId,
|
||||
{
|
||||
spuCode,
|
||||
rows: listingRows,
|
||||
note: payload.note,
|
||||
productCodeConfig: body.productCodeConfig,
|
||||
},
|
||||
req.user,
|
||||
{ pricingContext }
|
||||
);
|
||||
}
|
||||
let insertId = null;
|
||||
if (!selectionPricing) {
|
||||
await ensureHistoryTable();
|
||||
const inserted = await database.local.insert(HISTORY_TABLE, {
|
||||
user_id: userId,
|
||||
user_name: getRequestUser(req).name || null,
|
||||
spu_code: spuCode,
|
||||
brand: payload.brand || null,
|
||||
note: payload.note || null,
|
||||
row_count: rows.length,
|
||||
payload_json: payloadJson,
|
||||
});
|
||||
insertId = inserted.insertId;
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: insertId,
|
||||
spuCode,
|
||||
rowCount: rows.length,
|
||||
selectionPricing: selectionPricing ? {
|
||||
planId: selectionPricing.planId,
|
||||
itemId: selectionPricing.itemId,
|
||||
taskCode: selectionPricing.taskCode,
|
||||
pricingTableSavedAt: selectionPricing.pricingTableSavedAt,
|
||||
pricingTableVersion: selectionPricing.pricingTableVersion,
|
||||
productCodeStatus: selectionPricing.productCodeStatus,
|
||||
productCodeStyleCode: selectionPricing.productCodeStyleCode,
|
||||
productCodeConfig: selectionPricing.productCodeConfig,
|
||||
productCodeCompletedAt: selectionPricing.productCodeCompletedAt,
|
||||
productCodes: (selectionPricing.rows || []).map((row) => ({
|
||||
skuCode: row.skuCode,
|
||||
originalSkuName: row.originalSkuName,
|
||||
selectedSkuName: row.selectedSkuName,
|
||||
internalProductCode: row.internalProductCode,
|
||||
productCodeProductName: row.productCodeProductName,
|
||||
})),
|
||||
} : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存测算记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 历史列表(管理员可见全部,普通用户仅本人,不含快照正文)
|
||||
router.get('/history', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
const { isAdmin } = getRequestUser(req);
|
||||
|
||||
const page = Math.max(Number.parseInt(req.query.page, 10) || 1, 1);
|
||||
const pageSize = Math.min(Math.max(Number.parseInt(req.query.pageSize, 10) || 20, 1), 100);
|
||||
const spuCode = normalizeText(req.query.spuCode, 160).toUpperCase();
|
||||
const calculatorType = normalizeText(req.query.calculatorType, 30);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
try {
|
||||
await ensureHistoryTable();
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (!isAdmin) {
|
||||
where.push('user_id = ?');
|
||||
params.push(userId);
|
||||
}
|
||||
if (spuCode) {
|
||||
where.push('spu_code = ?');
|
||||
params.push(spuCode);
|
||||
}
|
||||
if (calculatorType === 'alibaba1688') {
|
||||
where.push('payload_json LIKE ?');
|
||||
params.push('%"calculatorType":"alibaba1688"%');
|
||||
} else if (calculatorType === 'company') {
|
||||
// 兼容 version 1-3 的公司品历史,它们还没有 calculatorType 字段。
|
||||
where.push('payload_json NOT LIKE ?');
|
||||
params.push('%"calculatorType":"alibaba1688"%');
|
||||
}
|
||||
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
const [countRows, list] = await Promise.all([
|
||||
database.local.query(`SELECT COUNT(*) AS total FROM ${HISTORY_TABLE} ${whereSql}`, params),
|
||||
database.local.query(
|
||||
`SELECT id, user_id, user_name, spu_code, brand, note, row_count, created_at, updated_at
|
||||
FROM ${HISTORY_TABLE}
|
||||
${whereSql}
|
||||
ORDER BY id DESC
|
||||
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: list.map((row) => ({
|
||||
id: row.id,
|
||||
userName: row.user_name || '',
|
||||
spuCode: row.spu_code,
|
||||
brand: row.brand || '',
|
||||
note: row.note || '',
|
||||
rowCount: row.row_count,
|
||||
calculatorType: calculatorType === 'alibaba1688' ? 'alibaba1688' : 'company',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
})),
|
||||
total: Number(countRows[0]?.total || 0),
|
||||
page,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '查询测算历史失败');
|
||||
}
|
||||
});
|
||||
|
||||
async function loadOwnHistoryRow(req, res) {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return null;
|
||||
const { isAdmin } = getRequestUser(req);
|
||||
|
||||
const id = Number.parseInt(req.params.id, 10);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
res.status(400).json({ success: false, error: '无效的历史记录 ID' });
|
||||
return null;
|
||||
}
|
||||
|
||||
await ensureHistoryTable();
|
||||
// 服务端强制数据边界:管理员可见全部,普通用户的他人记录表现为不存在
|
||||
const rows = isAdmin
|
||||
? await database.local.query(`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? LIMIT 1`, [id])
|
||||
: await database.local.query(
|
||||
`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||
[id, userId]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
res.status(404).json({ success: false, error: '测算记录不存在或已被删除' });
|
||||
return null;
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// 历史详情(仅本人)
|
||||
router.get('/history/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await loadOwnHistoryRow(req, res);
|
||||
if (!row) return;
|
||||
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(row.payload_json);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: row.id,
|
||||
userName: row.user_name || '',
|
||||
spuCode: row.spu_code,
|
||||
brand: row.brand || '',
|
||||
note: row.note || '',
|
||||
rowCount: row.row_count,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
payload,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '查询测算记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 删除历史(本人,或管理员可管理全部)
|
||||
router.delete('/history/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await loadOwnHistoryRow(req, res);
|
||||
if (!row) return;
|
||||
|
||||
await database.local.query(
|
||||
`DELETE FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ?`,
|
||||
[row.id, row.user_id]
|
||||
);
|
||||
res.json({ success: true, data: { id: row.id } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '删除测算记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 导出测算表:按前端给定的列顺序(与页面显示一致)与行数据生成 xlsx 下载
|
||||
const MAX_EXPORT_ROWS = 2000;
|
||||
const MAX_EXPORT_COLUMNS = 40;
|
||||
|
||||
router.post('/export', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const body = req.body || {};
|
||||
const spuCode = normalizeText(body.spu, 160).toUpperCase() || 'SPU';
|
||||
const columns = Array.isArray(body.columns) ? body.columns : [];
|
||||
const rows = Array.isArray(body.rows) ? body.rows : [];
|
||||
|
||||
const validColumns = columns
|
||||
.filter((col) => col && typeof col === 'object')
|
||||
.slice(0, MAX_EXPORT_COLUMNS)
|
||||
.map((col) => ({
|
||||
key: normalizeText(col.key, 60),
|
||||
label: normalizeText(col.label, 60) || normalizeText(col.key, 60),
|
||||
percent: col.percent === true,
|
||||
}))
|
||||
.filter((col) => col.key);
|
||||
|
||||
if (validColumns.length === 0) {
|
||||
return res.status(400).json({ success: false, error: '缺少导出列配置' });
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return res.status(400).json({ success: false, error: '导出数据为空' });
|
||||
}
|
||||
if (rows.length > MAX_EXPORT_ROWS) {
|
||||
return res.status(400).json({ success: false, error: `单次最多导出 ${MAX_EXPORT_ROWS} 行` });
|
||||
}
|
||||
|
||||
try {
|
||||
const aoa = [validColumns.map((col) => col.label)];
|
||||
rows.forEach((row) => {
|
||||
aoa.push(validColumns.map((col) => {
|
||||
const value = row && typeof row === 'object' ? row[col.key] : null;
|
||||
return value === null || value === undefined ? '' : value;
|
||||
}));
|
||||
});
|
||||
const sheet = XLSX.utils.aoa_to_sheet(aoa);
|
||||
// 百分比列:保持数值,设置百分比数字格式(0.06 在 Excel 显示为 6.00%)
|
||||
validColumns.forEach((col, colIndex) => {
|
||||
if (!col.percent) return;
|
||||
for (let rowIndex = 1; rowIndex < aoa.length; rowIndex += 1) {
|
||||
const addr = XLSX.utils.encode_cell({ c: colIndex, r: rowIndex });
|
||||
const cell = sheet[addr];
|
||||
if (cell && typeof cell.v === 'number') {
|
||||
cell.z = '0.00%';
|
||||
}
|
||||
}
|
||||
});
|
||||
sheet['!cols'] = validColumns.map((col) => ({ wch: Math.max(12, String(col.label).length * 2 + 6) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, '上架测算');
|
||||
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
|
||||
const stamp = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const filename = `上架测算_${spuCode}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`;
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: `导出失败:${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 用户个性化默认值(每运营维护自己的配置,无配置时前端用系统默认) =====
|
||||
const PREFS_TABLE = 'pricing_calculator_user_prefs';
|
||||
let prefsTableReady = null;
|
||||
|
||||
async function ensurePrefsTable() {
|
||||
if (!prefsTableReady) {
|
||||
prefsTableReady = (async () => {
|
||||
const existing = await database.local.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[PREFS_TABLE]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return;
|
||||
}
|
||||
await database.local.query(
|
||||
`CREATE TABLE IF NOT EXISTS ${PREFS_TABLE} (
|
||||
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
|
||||
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算用户个性化默认值'`
|
||||
);
|
||||
})();
|
||||
}
|
||||
try {
|
||||
await prefsTableReady;
|
||||
} catch (error) {
|
||||
prefsTableReady = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 允许的偏好字段与取值范围(targetMargin 为小数,0.15 = 15%)
|
||||
const PREF_FIELD_RANGES = {
|
||||
coupon: { min: 0, max: 100000 },
|
||||
activityDiscount: { min: 0.01, max: 1 },
|
||||
installFee: { min: 0, max: 100000 },
|
||||
targetMargin: { min: -1, max: 1 },
|
||||
};
|
||||
|
||||
function sanitizePrefs(input) {
|
||||
const src = input && typeof input === 'object' ? input : {};
|
||||
const prefs = {};
|
||||
Object.entries(PREF_FIELD_RANGES).forEach(([key, range]) => {
|
||||
const num = Number(src[key]);
|
||||
if (Number.isFinite(num)) {
|
||||
prefs[key] = Math.min(range.max, Math.max(range.min, num));
|
||||
}
|
||||
});
|
||||
return prefs;
|
||||
}
|
||||
|
||||
const PREFS_PROFILE_KEY = '_profiles';
|
||||
const PREFS_PROFILES = new Set(['company', 'alibaba1688']);
|
||||
|
||||
function normalizePrefsProfile(value) {
|
||||
return PREFS_PROFILES.has(value) ? value : 'company';
|
||||
}
|
||||
|
||||
function parsePrefsStorage(value) {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readPrefsProfile(storageValue, profile = 'company') {
|
||||
const storage = parsePrefsStorage(storageValue);
|
||||
const normalizedProfile = normalizePrefsProfile(profile);
|
||||
const source = normalizedProfile === 'company'
|
||||
? storage
|
||||
: storage[PREFS_PROFILE_KEY]?.[normalizedProfile];
|
||||
const prefs = sanitizePrefs(source);
|
||||
return Object.keys(prefs).length > 0 ? prefs : null;
|
||||
}
|
||||
|
||||
function writePrefsProfile(storageValue, profile, prefs) {
|
||||
const storage = parsePrefsStorage(storageValue);
|
||||
const normalizedProfile = normalizePrefsProfile(profile);
|
||||
if (normalizedProfile === 'company') {
|
||||
return { ...storage, ...prefs };
|
||||
}
|
||||
return {
|
||||
...storage,
|
||||
[PREFS_PROFILE_KEY]: {
|
||||
...(storage[PREFS_PROFILE_KEY] && typeof storage[PREFS_PROFILE_KEY] === 'object'
|
||||
? storage[PREFS_PROFILE_KEY]
|
||||
: {}),
|
||||
[normalizedProfile]: prefs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createPrefsPatch(profile, prefs) {
|
||||
const normalizedProfile = normalizePrefsProfile(profile);
|
||||
return normalizedProfile === 'company'
|
||||
? prefs
|
||||
: { [PREFS_PROFILE_KEY]: { [normalizedProfile]: prefs } };
|
||||
}
|
||||
|
||||
async function persistPrefsProfile(userId, profile, prefs) {
|
||||
const patch = JSON.stringify(createPrefsPatch(profile, prefs));
|
||||
try {
|
||||
// JSON_MERGE_PATCH 在数据库内原子合并,保留另一测算类型的配置,且只需一次网络往返。
|
||||
await database.local.query(
|
||||
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH(
|
||||
IF(JSON_VALID(prefs_json), prefs_json, '{}'),
|
||||
VALUES(prefs_json)
|
||||
)`,
|
||||
[userId, patch]
|
||||
);
|
||||
} catch (error) {
|
||||
const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET']
|
||||
.includes(error.code);
|
||||
if (!canFallback) throw error;
|
||||
|
||||
// 兼容不支持 JSON_MERGE_PATCH 的旧版 MySQL / MariaDB。
|
||||
const existingRows = await database.local.query(
|
||||
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
const storage = writePrefsProfile(existingRows[0]?.prefs_json, profile, prefs);
|
||||
await database.local.query(
|
||||
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`,
|
||||
[userId, JSON.stringify(storage)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/prefs', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
try {
|
||||
await ensurePrefsTable();
|
||||
const rows = await database.local.query(
|
||||
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
const profile = normalizePrefsProfile(req.query.profile);
|
||||
const prefs = rows.length > 0
|
||||
? readPrefsProfile(rows[0].prefs_json, profile)
|
||||
: null;
|
||||
res.json({ success: true, data: { prefs } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '读取个性化配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/prefs', async (req, res) => {
|
||||
const userId = requireUserId(req, res);
|
||||
if (!userId) return;
|
||||
const prefs = sanitizePrefs(req.body?.prefs ?? req.body);
|
||||
const profile = normalizePrefsProfile(req.body?.profile);
|
||||
if (Object.keys(prefs).length === 0) {
|
||||
return res.status(400).json({ success: false, error: '缺少有效的配置项' });
|
||||
}
|
||||
try {
|
||||
await ensurePrefsTable();
|
||||
await persistPrefsProfile(userId, profile, prefs);
|
||||
res.json({ success: true, data: { prefs } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存个性化配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports._test = {
|
||||
filterSkuRowsForSelectionContext,
|
||||
createPrefsPatch,
|
||||
getSelectionSkuCodeSet,
|
||||
normalizeCalculatorType,
|
||||
readPrefsProfile,
|
||||
sanitizeHistoryProduct,
|
||||
sanitizePrefs,
|
||||
writePrefsProfile,
|
||||
};
|
||||
73
backend/src/api/pricingCalculatorExternal.js
Normal file
73
backend/src/api/pricingCalculatorExternal.js
Normal file
@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const pricingCalculatorRoutes = require('./pricingCalculator');
|
||||
const {
|
||||
requireDesktopPageAccess,
|
||||
requireExternalAutomationApiKey,
|
||||
} = require('../services/externalAutomationAuth');
|
||||
|
||||
const router = express.Router();
|
||||
const MAX_OPERATOR_NAME_LENGTH = 100;
|
||||
const MAX_SAFE_ID = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
function normalizeOperatorName(value) {
|
||||
return String(value || '').trim().slice(0, MAX_OPERATOR_NAME_LENGTH);
|
||||
}
|
||||
|
||||
function decodeOperatorNameHeader(value) {
|
||||
const encoded = String(value || '').trim();
|
||||
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function desktopPricingUserId(operatorName) {
|
||||
const normalized = normalizeOperatorName(operatorName).toLocaleLowerCase('zh-CN');
|
||||
if (!normalized) return null;
|
||||
const digest = crypto.createHash('sha256').update(`desktop-pricing:${normalized}`, 'utf8').digest();
|
||||
const value = (digest.readBigUInt64BE(0) % (MAX_SAFE_ID - 1n)) + 1n;
|
||||
return -Number(value);
|
||||
}
|
||||
|
||||
function requireDesktopPricingOperator(req, res, next) {
|
||||
if (req.externalApiKey?.authType === 'jwt' && req.user?.name) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const operatorName = normalizeOperatorName(
|
||||
decodeOperatorNameHeader(req.get('x-operator-name-encoded'))
|
||||
|| req.get('x-operator-name')
|
||||
|| req.query?.operatorName
|
||||
|| req.body?.operatorName
|
||||
);
|
||||
if (!operatorName) {
|
||||
return res.status(400).json({ success: false, error: '缺少运营花名' });
|
||||
}
|
||||
|
||||
const userId = desktopPricingUserId(operatorName);
|
||||
req.user = {
|
||||
id: userId,
|
||||
sub: userId,
|
||||
name: operatorName,
|
||||
username: operatorName,
|
||||
role: 'operator',
|
||||
authSource: 'desktop-api-key',
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
router.use(
|
||||
requireExternalAutomationApiKey,
|
||||
requireDesktopPageAccess('planning_upload'),
|
||||
requireDesktopPricingOperator,
|
||||
pricingCalculatorRoutes
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
module.exports.desktopPricingUserId = desktopPricingUserId;
|
||||
module.exports.requireDesktopPricingOperator = requireDesktopPricingOperator;
|
||||
1739
backend/src/api/productAnalysis.js
Normal file
1739
backend/src/api/productAnalysis.js
Normal file
File diff suppressed because it is too large
Load Diff
159
backend/src/api/productCodeBoard.js
Normal file
159
backend/src/api/productCodeBoard.js
Normal file
@ -0,0 +1,159 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const {
|
||||
getProductCodeMeta,
|
||||
getProductCodeSequence,
|
||||
listProductCodes,
|
||||
buildProductCodeBatchImportTemplateBuffer,
|
||||
buildProductCodeExportBuffer,
|
||||
createProductCodes,
|
||||
createProductCodeBatch,
|
||||
importProductCodeBatchWorkbook,
|
||||
} = require('../services/productCodeBoard');
|
||||
|
||||
const router = express.Router();
|
||||
const productCodeBatchImportUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
},
|
||||
fileFilter: (_req, file, callback) => {
|
||||
const lowerName = String(file.originalname || '').toLowerCase();
|
||||
if (lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls')) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback(new Error('仅支持上传 Excel 文件(.xlsx / .xls)'));
|
||||
},
|
||||
});
|
||||
|
||||
function sendError(res, error, fallbackMessage) {
|
||||
const status = error.status || error.statusCode || 500;
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error.message || fallbackMessage,
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/meta', async (_req, res) => {
|
||||
try {
|
||||
const data = await getProductCodeMeta();
|
||||
res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '获取编码规则失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sequence', async (req, res) => {
|
||||
try {
|
||||
const data = await getProductCodeSequence({
|
||||
operationCenter: req.query.operationCenter,
|
||||
categoryName: req.query.categoryName,
|
||||
});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '获取编码序号失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/items', async (req, res) => {
|
||||
try {
|
||||
const data = await listProductCodes({
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
operationCenter: req.query.operationCenter,
|
||||
categoryName: req.query.categoryName,
|
||||
productStatus: req.query.productStatus,
|
||||
createdByNames: req.query.createdByNames,
|
||||
createdAtStart: req.query.createdAtStart,
|
||||
createdAtEnd: req.query.createdAtEnd,
|
||||
});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '获取商品编码看板失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/items/export', async (req, res) => {
|
||||
try {
|
||||
const { buffer, totalRows } = await buildProductCodeExportBuffer({
|
||||
keyword: req.query.keyword,
|
||||
operationCenter: req.query.operationCenter,
|
||||
categoryName: req.query.categoryName,
|
||||
productStatus: req.query.productStatus,
|
||||
createdByNames: req.query.createdByNames,
|
||||
createdAtStart: req.query.createdAtStart,
|
||||
createdAtEnd: req.query.createdAtEnd,
|
||||
});
|
||||
const fileName = encodeURIComponent('测款数据导出.xlsx');
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`);
|
||||
res.setHeader('X-Export-Count', String(totalRows));
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出测款数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/batch-template', (_req, res) => {
|
||||
try {
|
||||
const buffer = buildProductCodeBatchImportTemplateBuffer();
|
||||
const fileName = encodeURIComponent('测款批量新增模板.xlsx');
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${fileName}`);
|
||||
res.send(buffer);
|
||||
} catch (error) {
|
||||
sendError(res, error, '下载批量导入模板失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/items', async (req, res) => {
|
||||
try {
|
||||
const data = await createProductCodes(req.body, req.user || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '新增商品编码失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/items/batch', async (req, res) => {
|
||||
try {
|
||||
const data = await createProductCodeBatch(req.body, req.user || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '批量新增商品编码失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/items/batch-import', (req, res, next) => {
|
||||
productCodeBatchImportUpload.single('file')(req, res, (error) => {
|
||||
if (error) {
|
||||
return res.status(400).json({ success: false, error: error.message || '上传文件失败' });
|
||||
}
|
||||
return next();
|
||||
});
|
||||
}, async (req, res) => {
|
||||
try {
|
||||
if (!req.file?.buffer) {
|
||||
return res.status(400).json({ success: false, error: '请上传批量新增 Excel 文件' });
|
||||
}
|
||||
|
||||
const data = await importProductCodeBatchWorkbook(req.file.buffer, {
|
||||
operationCenter: req.body.operationCenter,
|
||||
categoryName: req.body.categoryName,
|
||||
}, req.user || {});
|
||||
res.json({
|
||||
success: true,
|
||||
message: `已导入 ${data.productCount || data.importedProductCount || 0} 个商品、${data.skuCount || data.importedSkuCount || 0} 个规格`,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '批量导入商品失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
73
backend/src/api/productMonitoring.js
Normal file
73
backend/src/api/productMonitoring.js
Normal file
@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getMonitoringList,
|
||||
getMonitoringFilters,
|
||||
getDepartmentTagConfig,
|
||||
saveDepartmentTagConfig,
|
||||
getProductOperators,
|
||||
} = require('../services/productMonitoringService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '商品监控请求失败') {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[ProductMonitoring]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
stack: error?.stack || null,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
// 明细列表(含公司标签 / 部门标签 / 负责人 / 汇总)
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMonitoringList(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载商品监控列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 筛选下拉选项
|
||||
router.get('/filters', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMonitoringFilters() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载筛选选项失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道)
|
||||
router.get('/operators', async (req, res) => {
|
||||
try {
|
||||
const sku = req.query?.sku || req.query?.skuoid;
|
||||
if (!sku) {
|
||||
return res.status(400).json({ success: false, error: '缺少 sku 参数' });
|
||||
}
|
||||
return res.json({ success: true, data: { operators: await getProductOperators(sku) } });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '加载运营明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 部门标签 + 负责人配置(系统设置用)
|
||||
router.get('/department-config', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getDepartmentTagConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载部门标签配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/department-config', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await saveDepartmentTagConfig(req.body || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存部门标签配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
417
backend/src/api/promotionModule.js
Normal file
417
backend/src/api/promotionModule.js
Normal file
@ -0,0 +1,417 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const middleDb = require('../config/middlePlatformDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
function safeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toNum(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function fmtMoney(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '-';
|
||||
}
|
||||
|
||||
function fmtRate(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? (n * 100).toFixed(2) : '-';
|
||||
}
|
||||
|
||||
function fmtNum(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '-';
|
||||
}
|
||||
|
||||
function getDateRange(period, customStart, customEnd) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (period) {
|
||||
case 'realtime': {
|
||||
const d = new Date(today);
|
||||
return { start: formatDate(d), end: formatDate(d) };
|
||||
}
|
||||
case 'yesterday': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 1);
|
||||
return { start: formatDate(d), end: formatDate(d) };
|
||||
}
|
||||
case 'week': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1));
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'month': {
|
||||
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'last7d': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 6);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'last30d': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 29);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'custom': {
|
||||
return {
|
||||
start: customStart || formatDate(today),
|
||||
end: customEnd || formatDate(today),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 6);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// 环比:取紧邻上一段等长周期(如近7天 → 再往前7天;自定义区间 → 等长前移)
|
||||
function getPreviousRange(range) {
|
||||
const start = new Date(`${range.start}T00:00:00`);
|
||||
const end = new Date(`${range.end}T00:00:00`);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const lengthDays = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||
const prevEnd = new Date(start);
|
||||
prevEnd.setDate(prevEnd.getDate() - 1);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - (lengthDays - 1));
|
||||
return { start: formatDate(prevStart), end: formatDate(prevEnd) };
|
||||
}
|
||||
|
||||
const PROMOTION_SUMMARY_SELECT = `SELECT
|
||||
COUNT(*) AS totalRecords,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 AS totalOrderSpend,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [gmv])) / 1000.0 AS totalGmv,
|
||||
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [gmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||
ELSE 0 END AS avgRoi,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [spend])) / 1000.0 AS totalSpend,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / 1000.0 AS totalNetGmv,
|
||||
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||
ELSE 0 END AS avgNetRoi,
|
||||
SUM(TRY_CONVERT(int, [netOrderNum])) AS totalNetOrderNum,
|
||||
CASE WHEN SUM(TRY_CONVERT(int, [netOrderNum])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 / SUM(TRY_CONVERT(int, [netOrderNum]))
|
||||
ELSE 0 END AS avgCostPerNetOrder
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate`;
|
||||
|
||||
async function queryPromotionSummary(range) {
|
||||
const [row] = await jianSunDb.query(PROMOTION_SUMMARY_SELECT, {
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
});
|
||||
const stats = row || {};
|
||||
return {
|
||||
totalRecords: toNum(stats.totalRecords),
|
||||
orderSpend: toNum(stats.totalOrderSpend),
|
||||
gmv: toNum(stats.totalGmv),
|
||||
roi: toNum(stats.avgRoi),
|
||||
spend: toNum(stats.totalSpend),
|
||||
netGmv: toNum(stats.totalNetGmv),
|
||||
netRoi: toNum(stats.avgNetRoi),
|
||||
netOrderNum: toNum(stats.totalNetOrderNum),
|
||||
costPerNetOrder: toNum(stats.avgCostPerNetOrder),
|
||||
};
|
||||
}
|
||||
|
||||
// 环比变化率:(当前 - 上一周期) / |上一周期|,上一周期为 0 时返回 null
|
||||
function buildPeriodChanges(current, previous) {
|
||||
const changes = {};
|
||||
Object.keys(current).forEach((key) => {
|
||||
const base = Math.abs(previous[key]);
|
||||
changes[key] = base > 0 ? (current[key] - previous[key]) / base : null;
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function getImageMap(goodsIds) {
|
||||
if (!goodsIds.length) return new Map();
|
||||
try {
|
||||
const placeholders = goodsIds.map((_, i) => `@gid${i}`).join(',');
|
||||
const params = {};
|
||||
goodsIds.forEach((id, i) => { params[`gid${i}`] = String(id); });
|
||||
const rows = await middleDb.query(
|
||||
`SELECT DISTINCT CONVERT(varchar(50), goodsId) AS goodsId, hdThumbUrl
|
||||
FROM dbo.pdd_DataGoods
|
||||
WHERE CONVERT(varchar(50), goodsId) IN (${placeholders})`,
|
||||
params
|
||||
);
|
||||
const map = new Map();
|
||||
rows.forEach((row) => {
|
||||
if (row.hdThumbUrl) map.set(String(row.goodsId), row.hdThumbUrl);
|
||||
});
|
||||
return map;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/latest-date', async (req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
const date = rows.length >= 2 ? rows[1].createDate : (rows[0]?.createDate || '');
|
||||
res.json({ success: true, data: { date: String(date || '').trim() } });
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询最新数据日期失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/shops', async (req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT [shopName]
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [shopName] IS NOT NULL AND LTRIM(RTRIM([shopName])) != ''
|
||||
ORDER BY [shopName]`
|
||||
);
|
||||
res.json({ success: true, data: rows.map((r) => r.shopName) });
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询店铺列表失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const { date, period, start: customStart, end: customEnd } = req.query;
|
||||
let filterDate = (date && String(date).trim()) || '';
|
||||
let range;
|
||||
|
||||
if (period === 'custom') {
|
||||
range = getDateRange('custom', customStart, customEnd);
|
||||
} else if (period) {
|
||||
range = getDateRange(period);
|
||||
filterDate = '';
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
// 单日口径:未带 period 时按最新数据日(默认取倒数第二个有数据的日期)
|
||||
if (!filterDate) {
|
||||
const dateRows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
filterDate = dateRows.length >= 2
|
||||
? String(dateRows[1].createDate || '').trim()
|
||||
: String(dateRows[0]?.createDate || '').trim();
|
||||
}
|
||||
range = { start: filterDate, end: filterDate };
|
||||
}
|
||||
|
||||
const previousRange = getPreviousRange(range);
|
||||
const [current, previous] = await Promise.all([
|
||||
queryPromotionSummary(range),
|
||||
previousRange ? queryPromotionSummary(previousRange) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filterDate: `${range.start} ~ ${range.end}`,
|
||||
previousRange: previousRange ? `${previousRange.start} ~ ${previousRange.end}` : '',
|
||||
totalRecords: current.totalRecords,
|
||||
orderSpend: fmtMoney(current.orderSpend),
|
||||
gmv: fmtMoney(current.gmv),
|
||||
roi: fmtNum(current.roi),
|
||||
spend: fmtMoney(current.spend),
|
||||
netGmv: fmtMoney(current.netGmv),
|
||||
netRoi: fmtNum(current.netRoi),
|
||||
netOrderNum: fmtNum(current.netOrderNum),
|
||||
costPerNetOrder: fmtMoney(current.costPerNetOrder),
|
||||
changes: previous ? buildPeriodChanges(current, previous) : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询汇总数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'orderSpend',
|
||||
sortOrder = 'DESC',
|
||||
keyword,
|
||||
shopName,
|
||||
date,
|
||||
period,
|
||||
start: customStart,
|
||||
end: customEnd,
|
||||
} = req.query;
|
||||
|
||||
const currentPage = safeInt(page, 1);
|
||||
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
|
||||
const whereParts = [];
|
||||
const params = {};
|
||||
|
||||
if (period && period !== 'custom') {
|
||||
const range = getDateRange(period);
|
||||
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||
params.startDate = range.start;
|
||||
params.endDate = range.end;
|
||||
} else if (period === 'custom' && customStart && customEnd) {
|
||||
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||
params.startDate = customStart;
|
||||
params.endDate = customEnd;
|
||||
} else {
|
||||
let filterDate = (date && String(date).trim()) || '';
|
||||
if (!filterDate) {
|
||||
const dateRows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
filterDate = dateRows.length >= 2
|
||||
? String(dateRows[1].createDate || '').trim()
|
||||
: String(dateRows[0]?.createDate || '').trim();
|
||||
}
|
||||
if (filterDate) {
|
||||
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||
params.filterDate = filterDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyword && keyword.trim()) {
|
||||
whereParts.push('([goodsName] LIKE @kw OR [goodsId] LIKE @kw)');
|
||||
params.kw = `%${keyword.trim()}%`;
|
||||
}
|
||||
if (shopName && shopName.trim()) {
|
||||
whereParts.push('[shopName] = @shopName');
|
||||
params.shopName = shopName.trim();
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
const allowedSort = new Set([
|
||||
'goodsName', 'goodsId', 'shopName', 'createTime', 'orderSpend', 'gmv', 'roi',
|
||||
'spend', 'orderNum', 'netGmv', 'netRoi', 'netOrderNum', 'netAvgPayAmount',
|
||||
'settlementGmv', 'settlementRoi', 'settlementOrder', 'impression', 'click', 'ctr', 'cvr',
|
||||
]);
|
||||
const sortCol = allowedSort.has(sortBy) ? `[${sortBy}]` : '[orderSpend]';
|
||||
const sortDir = String(sortOrder || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
const countResult = await jianSunDb.query(
|
||||
`SELECT COUNT(*) AS total FROM dbo.pdd_ReportGoodsSpread ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
const dataResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[id],
|
||||
[goodsName],
|
||||
[goodsId],
|
||||
[shopName],
|
||||
[createTime],
|
||||
[orderSpend],
|
||||
[gmv],
|
||||
[roi],
|
||||
[spend],
|
||||
[orderNum],
|
||||
[transactionCost],
|
||||
[avgPayAmount],
|
||||
[directGmv],
|
||||
[indirectGmv],
|
||||
[directOrderNum],
|
||||
[indirectOrderNum],
|
||||
[netGmv],
|
||||
[netRoi],
|
||||
[netOrderNum],
|
||||
[netAvgPayAmount],
|
||||
[netRate],
|
||||
[settlementGmv],
|
||||
[settlementRoi],
|
||||
[settlementOrder],
|
||||
[refundGmvRate30d],
|
||||
[refundOrderRate30d],
|
||||
[exemptRefundGmvRate30d],
|
||||
[exemptRefundOrderRate30d],
|
||||
[settlementGmvRate],
|
||||
[settlementOrderRate],
|
||||
[costPerSettlementOrderForDeal],
|
||||
[impression],
|
||||
[click],
|
||||
[ctr],
|
||||
[cvr],
|
||||
[targetRoi],
|
||||
[targetBid]
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
${whereSql}
|
||||
ORDER BY ${sortCol} ${sortDir}
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||
{ ...params, offset, limit }
|
||||
);
|
||||
|
||||
const goodsIds = dataResult.map((r) => String(r.goodsId)).filter(Boolean);
|
||||
const imageMap = await getImageMap(goodsIds);
|
||||
|
||||
const list = dataResult.map((row) => ({
|
||||
...row,
|
||||
thumbUrl: imageMap.get(String(row.goodsId)) || '',
|
||||
orderSpend: row.orderSpend != null ? toNum(row.orderSpend) / 1000.0 : null,
|
||||
gmv: row.gmv != null ? toNum(row.gmv) / 1000.0 : null,
|
||||
spend: row.spend != null ? toNum(row.spend) / 1000.0 : null,
|
||||
transactionCost: row.transactionCost != null ? toNum(row.transactionCost) / 1000.0 : null,
|
||||
avgPayAmount: row.avgPayAmount != null ? toNum(row.avgPayAmount) / 1000.0 : null,
|
||||
directGmv: row.directGmv != null ? toNum(row.directGmv) / 1000.0 : null,
|
||||
indirectGmv: row.indirectGmv != null ? toNum(row.indirectGmv) / 1000.0 : null,
|
||||
netGmv: row.netGmv != null ? toNum(row.netGmv) / 1000.0 : null,
|
||||
netAvgPayAmount: row.netAvgPayAmount != null ? toNum(row.netAvgPayAmount) / 1000.0 : null,
|
||||
settlementGmv: row.settlementGmv != null ? toNum(row.settlementGmv) / 1000.0 : null,
|
||||
costPerSettlementOrderForDeal: row.costPerSettlementOrderForDeal != null ? toNum(row.costPerSettlementOrderForDeal) / 1000.0 : null,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: limit,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询推广数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
999
backend/src/api/storeCheckup.js
Normal file
999
backend/src/api/storeCheckup.js
Normal file
@ -0,0 +1,999 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
const META_CACHE_TTL_MS = 60 * 1000;
|
||||
const OVERVIEW_CACHE_TTL_MS = 60 * 1000;
|
||||
const HOME_SUMMARY_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const HOME_SUMMARY_STALE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const HOME_COMPENSATION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const HOME_COMPENSATION_SAMPLE_SIZE = 10000;
|
||||
const metaCache = { value: null, expiresAt: 0, promise: null };
|
||||
const overviewCache = new Map();
|
||||
const overviewInFlight = new Map();
|
||||
const homeSummaryCache = new Map();
|
||||
const homeSummaryInFlight = new Map();
|
||||
const homeReviewCache = new Map();
|
||||
const homeReviewInFlight = new Map();
|
||||
const homeCompensationCache = new Map();
|
||||
const homeCompensationSummaryCache = new Map();
|
||||
const homeCompensationSummaryInFlight = new Map();
|
||||
|
||||
const TAB_KEYS = new Set(['overview', 'business', 'after-sales', 'customer-service', 'service-score', 'stars']);
|
||||
|
||||
function normalizeDate(value) {
|
||||
const text = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
||||
}
|
||||
|
||||
function addDays(value, offset) {
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + offset);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function inclusiveDayCount(startDate, endDate) {
|
||||
const start = new Date(`${startDate}T00:00:00Z`);
|
||||
const end = new Date(`${endDate}T00:00:00Z`);
|
||||
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function normalizeCompensationDate(value) {
|
||||
const text = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
||||
}
|
||||
|
||||
function compensationDate(value) {
|
||||
const text = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}/.test(text) ? text.slice(0, 10) : '';
|
||||
}
|
||||
|
||||
async function loadRecentCompensationRows(shopName = '') {
|
||||
const cacheKey = shopName || '*';
|
||||
const cached = homeCompensationCache.get(cacheKey);
|
||||
if (cached?.rows && cached.expiresAt > Date.now()) return cached.rows;
|
||||
if (cached?.promise) return cached.promise;
|
||||
|
||||
const promise = jianSunDb.query(
|
||||
`SELECT TOP (${HOME_COMPENSATION_SAMPLE_SIZE})
|
||||
id, chargeAmount, chargeReason, chargeTime, couponDetailId,
|
||||
goodsId, goodsName, orderSn, shopName
|
||||
FROM dbo.pdd_abnormal_compensation WITH (NOLOCK)
|
||||
WHERE (@shopName = '' OR shopName = @shopName)
|
||||
ORDER BY id DESC`,
|
||||
{ shopName }
|
||||
).then((rows) => {
|
||||
const normalizedRows = rows
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
date: compensationDate(row.chargeTime),
|
||||
amount: toNumber(row.chargeAmount),
|
||||
reason: String(row.chargeReason || '').trim() || '未分类',
|
||||
goodsId: String(row.goodsId || '').trim(),
|
||||
goodsName: String(row.goodsName || '').trim(),
|
||||
orderSn: String(row.orderSn || '').trim(),
|
||||
detailId: String(row.couponDetailId || '').trim(),
|
||||
shopName: String(row.shopName || '').trim(),
|
||||
}))
|
||||
.filter((row) => row.date);
|
||||
homeCompensationCache.set(cacheKey, {
|
||||
rows: normalizedRows,
|
||||
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
|
||||
promise: null,
|
||||
});
|
||||
if (homeCompensationCache.size > 12) {
|
||||
const firstKey = homeCompensationCache.keys().next().value;
|
||||
homeCompensationCache.delete(firstKey);
|
||||
}
|
||||
return normalizedRows;
|
||||
}).finally(() => {
|
||||
const entry = homeCompensationCache.get(cacheKey);
|
||||
if (entry?.promise) homeCompensationCache.delete(cacheKey);
|
||||
});
|
||||
homeCompensationCache.set(cacheKey, { rows: null, expiresAt: 0, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function resolveCompensationDateRange(req, rows) {
|
||||
const latestAvailableDate = rows.reduce((latest, row) => (row.date > latest ? row.date : latest), '');
|
||||
const endDate = normalizeCompensationDate(req.query.endDate) || latestAvailableDate;
|
||||
const startDate = normalizeCompensationDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
|
||||
if (!startDate || !endDate) {
|
||||
const error = new Error('赔付数据没有可用日期');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const dayCount = inclusiveDayCount(startDate, endDate);
|
||||
if (dayCount < 1) {
|
||||
const error = new Error('赔付开始日期不能晚于结束日期');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (dayCount > 90) {
|
||||
const error = new Error('赔付查询日期范围不能超过90天');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return { startDate, endDate, dayCount, latestAvailableDate };
|
||||
}
|
||||
|
||||
function createCompensationGroup(row) {
|
||||
return {
|
||||
amount: 0,
|
||||
recordCount: 0,
|
||||
orderKeys: new Set(),
|
||||
...row,
|
||||
};
|
||||
}
|
||||
|
||||
function addCompensationRecord(group, row) {
|
||||
group.amount += row.amount;
|
||||
group.recordCount += 1;
|
||||
group.orderKeys.add(row.orderSn || row.detailId || String(row.id));
|
||||
}
|
||||
|
||||
function queryHomeCompensationSummary(rows, range) {
|
||||
const selectedRows = rows.filter((row) => row.date >= range.startDate && row.date <= range.endDate);
|
||||
const orderKeys = new Set();
|
||||
const goodsKeys = new Set();
|
||||
const reasonGroups = new Map();
|
||||
const goodsGroups = new Map();
|
||||
let totalAmount = 0;
|
||||
|
||||
selectedRows.forEach((row) => {
|
||||
const orderKey = row.orderSn || row.detailId || String(row.id);
|
||||
orderKeys.add(orderKey);
|
||||
if (row.goodsId) goodsKeys.add(row.goodsId);
|
||||
totalAmount += row.amount;
|
||||
|
||||
const reasonGroup = reasonGroups.get(row.reason) || createCompensationGroup({ reason: row.reason });
|
||||
addCompensationRecord(reasonGroup, row);
|
||||
reasonGroups.set(row.reason, reasonGroup);
|
||||
|
||||
const goodsKey = row.goodsId || `unknown-${row.id}`;
|
||||
const goodsGroup = goodsGroups.get(goodsKey) || createCompensationGroup({
|
||||
goodsId: row.goodsId || '未识别商品ID',
|
||||
goodsName: row.goodsName,
|
||||
shopName: row.shopName,
|
||||
});
|
||||
addCompensationRecord(goodsGroup, row);
|
||||
if (!goodsGroup.goodsName && row.goodsName) goodsGroup.goodsName = row.goodsName;
|
||||
if (!goodsGroup.shopName && row.shopName) goodsGroup.shopName = row.shopName;
|
||||
goodsGroups.set(goodsKey, goodsGroup);
|
||||
});
|
||||
|
||||
const toGroupResult = (group) => ({
|
||||
...group,
|
||||
orderCount: group.orderKeys.size,
|
||||
amount: Number(group.amount.toFixed(2)),
|
||||
share: totalAmount > 0 ? Number((group.amount * 100 / totalAmount).toFixed(2)) : 0,
|
||||
orderKeys: undefined,
|
||||
});
|
||||
const reasonDistribution = [...reasonGroups.values()]
|
||||
.map(toGroupResult)
|
||||
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
|
||||
.slice(0, 6);
|
||||
const highCompensationGoods = [...goodsGroups.values()]
|
||||
.map(toGroupResult)
|
||||
.sort((left, right) => right.amount - left.amount || right.orderCount - left.orderCount)
|
||||
.slice(0, 6);
|
||||
|
||||
return {
|
||||
...range,
|
||||
sampledRecordCount: rows.length,
|
||||
selectedRecordCount: selectedRows.length,
|
||||
compensationCount: orderKeys.size,
|
||||
recordCount: selectedRows.length,
|
||||
totalAmount: Number(totalAmount.toFixed(2)),
|
||||
averageAmount: orderKeys.size ? Number((totalAmount / orderKeys.size).toFixed(2)) : 0,
|
||||
affectedGoodsCount: goodsKeys.size,
|
||||
reasonDistribution,
|
||||
highCompensationGoods,
|
||||
};
|
||||
}
|
||||
|
||||
function toNullableNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function averageNullable(rows, field) {
|
||||
const values = rows.map((row) => toNullableNumber(row[field])).filter((value) => value !== null);
|
||||
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
|
||||
}
|
||||
|
||||
function cleanShopName(value) {
|
||||
const text = String(value || '').trim();
|
||||
return text || '';
|
||||
}
|
||||
|
||||
function shopFilterSql() {
|
||||
return '(@shopName IS NULL OR @shopName = \'\' OR shopName = @shopName)';
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
if (metaCache.value && metaCache.expiresAt > Date.now()) return metaCache.value;
|
||||
if (metaCache.promise) return metaCache.promise;
|
||||
|
||||
metaCache.promise = Promise.all([
|
||||
jianSunDb.query(
|
||||
`SELECT
|
||||
shopName,
|
||||
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
|
||||
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
|
||||
FROM dbo.pdd_DataNormal
|
||||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||||
GROUP BY shopName
|
||||
ORDER BY shopName`
|
||||
),
|
||||
jianSunDb.query(
|
||||
`SELECT
|
||||
CONVERT(varchar(10), MIN(TRY_CONVERT(date, statDate)), 23) AS minDate,
|
||||
CONVERT(varchar(10), MAX(TRY_CONVERT(date, statDate)), 23) AS latestDate
|
||||
FROM dbo.pdd_DataNormal
|
||||
WHERE statDate IS NOT NULL`
|
||||
),
|
||||
]).then(([shopRows, dateRows]) => {
|
||||
const shops = shopRows
|
||||
.map((row) => ({
|
||||
name: cleanShopName(row.shopName),
|
||||
minDate: normalizeDate(row.minDate),
|
||||
latestDate: normalizeDate(row.latestDate),
|
||||
}))
|
||||
.filter((row) => row.name);
|
||||
const globalDate = dateRows[0] || {};
|
||||
const data = {
|
||||
shops,
|
||||
minDate: normalizeDate(globalDate.minDate),
|
||||
latestDate: normalizeDate(globalDate.latestDate),
|
||||
};
|
||||
metaCache.value = data;
|
||||
metaCache.expiresAt = Date.now() + META_CACHE_TTL_MS;
|
||||
return data;
|
||||
}).finally(() => {
|
||||
metaCache.promise = null;
|
||||
});
|
||||
|
||||
return metaCache.promise;
|
||||
}
|
||||
|
||||
async function resolveContext(req) {
|
||||
const meta = await loadMeta();
|
||||
const shopName = cleanShopName(req.query.shopName);
|
||||
const shop = shopName ? meta.shops.find((item) => item.name === shopName) : null;
|
||||
const requestedDate = normalizeDate(req.query.date);
|
||||
const date = requestedDate || shop?.latestDate || meta.latestDate;
|
||||
return { meta, shopName, date };
|
||||
}
|
||||
|
||||
async function queryLatestRows({ table, dateColumn, fields, targetDate, shopName }) {
|
||||
const rows = await jianSunDb.query(
|
||||
`WITH ranked AS (
|
||||
SELECT ${fields},
|
||||
ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY id DESC) AS rowNumber
|
||||
FROM dbo.${table}
|
||||
WHERE ${shopFilterSql()}
|
||||
AND [${dateColumn}] = @targetDate
|
||||
)
|
||||
SELECT * FROM ranked WHERE rowNumber = 1`,
|
||||
{ targetDate, shopName: shopName || null }
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function queryBusinessSnapshot(targetDate, shopName) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_DataNormal',
|
||||
dateColumn: 'statDate',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, statDate, cfmOrdrAmt, payOrdrAup, payUvRto',
|
||||
});
|
||||
const sales = rows.reduce((sum, row) => sum + toNumber(row.cfmOrdrAmt), 0);
|
||||
const averageConversion = averageNullable(rows, 'payUvRto');
|
||||
const count = rows.length;
|
||||
return {
|
||||
shopCount: count,
|
||||
sales,
|
||||
averageAov: averageNullable(rows, 'payOrdrAup'),
|
||||
averageConversion: averageConversion === null ? null : averageConversion * 100,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryAfterSalesSnapshot(targetDate, shopName) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_ShopNormalOverflow',
|
||||
dateColumn: 'createTime',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
|
||||
});
|
||||
const fields = ['executedCount', 'afterSalesCount', 'waitSellerHandle', 'platformHandling', 'waitBuyerHandle', 'todosCount', 'todosEndedCount', 'cfmOrdrCnt'];
|
||||
const data = fields.reduce((result, field) => ({ ...result, [field]: rows.reduce((sum, row) => sum + toNumber(row[field]), 0) }), {});
|
||||
return { ...data, shopCount: rows.length, rows };
|
||||
}
|
||||
|
||||
async function queryCustomerServiceSnapshot(targetDate, shopName) {
|
||||
const [refundRows, receptionRows] = await Promise.all([
|
||||
queryLatestRows({
|
||||
table: 'pdd_DataCustomerService',
|
||||
dateColumn: 'createTime',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, createTime, dsptRfSucOrdrCnt1m, dsptRfSucRto1m, pltInvlOrdrCnt1m, pltInvlOrdrRto1m, qurfOrdRto1m, avgSucRfProcTime1m, ssslAvgSucRfProcTime1mPct, sucRfOrdrCnt1d, sucRfOrdrAmt1d, rfSucRto1m, avgSlfSucRfProcTime1mMgr, avgSlfSucRfProcTime1mMr',
|
||||
}),
|
||||
jianSunDb.query(
|
||||
`WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
|
||||
FROM dbo.pdd_customer_overflow
|
||||
WHERE ${shopFilterSql()}
|
||||
AND [createTime] = @targetDate
|
||||
)
|
||||
SELECT
|
||||
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
|
||||
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
|
||||
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
|
||||
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
|
||||
SUM(ISNULL(need_manu_reply_consult_user_cnt, 0)) AS needManualReplyCount,
|
||||
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
|
||||
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
|
||||
SUM(ISNULL(unsatisfied_serve_score_cnt, 0)) AS unsatisfiedScoreCount,
|
||||
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), avg_reply_time)) AS averageReplyTime,
|
||||
SUM(ISNULL(serve_refund_cnt, 0)) AS serviceRefundCount,
|
||||
COUNT(DISTINCT cs_name) AS customerServiceCount
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1 AND cs_name = N'店铺总计'`,
|
||||
{ targetDate, shopName: shopName || null }
|
||||
),
|
||||
]);
|
||||
|
||||
const refundFields = [
|
||||
'dsptRfSucOrdrCnt1m', 'pltInvlOrdrCnt1m', 'sucRfOrdrCnt1d', 'sucRfOrdrAmt1d',
|
||||
];
|
||||
const refundTotals = refundFields.reduce((result, field) => ({
|
||||
...result,
|
||||
[field]: refundRows.reduce((sum, row) => sum + toNumber(row[field]), 0),
|
||||
}), {});
|
||||
const averageFields = [
|
||||
'dsptRfSucRto1m', 'pltInvlOrdrRto1m', 'qurfOrdRto1m', 'avgSucRfProcTime1m',
|
||||
'ssslAvgSucRfProcTime1mPct', 'rfSucRto1m', 'avgSlfSucRfProcTime1mMgr', 'avgSlfSucRfProcTime1mMr',
|
||||
];
|
||||
const averages = averageFields.reduce((result, field) => ({
|
||||
...result,
|
||||
[field]: averageNullable(refundRows, field),
|
||||
}), {});
|
||||
return {
|
||||
...refundTotals,
|
||||
...averages,
|
||||
shopCount: refundRows.length,
|
||||
reception: receptionRows[0] || {},
|
||||
rows: refundRows,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryServiceScoreSnapshot(targetDate, shopName) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_MallServiceScore',
|
||||
dateColumn: 'createTime',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, createTime, cstmrServScore, attiLmScore, jcfwLmScore, spLmScore, fhLmScore, wlLmScore',
|
||||
});
|
||||
const fields = ['cstmrServScore', 'attiLmScore', 'jcfwLmScore', 'spLmScore', 'fhLmScore', 'wlLmScore'];
|
||||
const values = fields.reduce((result, field) => ({
|
||||
...result,
|
||||
[field]: averageNullable(rows, field),
|
||||
}), {});
|
||||
return { ...values, shopCount: rows.length, rows };
|
||||
}
|
||||
|
||||
async function queryStarsSnapshot(targetDate, shopName) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_ShopStars',
|
||||
dateColumn: 'createTime',
|
||||
targetDate,
|
||||
shopName,
|
||||
fields: '[id], shopName, createTime, mallStarTomms, mallStarTommsChange, mallStarDesc, ptHelpRate1m, aprsIn3minRplyUsrRto1m, rfSignTime1m, rfProcTime1m, dsr3m, posTypeRto1m, seriousInferiorRto1m, adjNfkAvgCfmSignTime1m, lgstMergeFine1m, gmvRk, ptHelpRate1mNextStarRkDesc, ptHelpRate1mNextPctThreshold, aprsIn3minRplyUsrRto1mNextStarRkDesc, aprsIn3minRplyUsrRto1mNextPctThreshold, rfSignTime1mNextStarRkDesc, rfSignTime1mNextPctThreshold, rfProcTime1mNextStarRkDesc, rfProcTime1mNextPctThreshold, dsr3mNextStarRkDesc, dsr3mNextPctThreshold, posTypeRto1mNextStarRkDesc, posTypeRto1mNextPctThreshold, seriousInferiorRto1mNextStarRkDesc, seriousInferiorRto1mNextPctThreshold, adjNfkAvgCfmSignTime1mNextStarRkDesc, adjNfkAvgCfmSignTime1mNextPctThreshold, lgstMergeFine1mNextStarRkDesc, lgstMergeFine1mNextPctThreshold, gmvRkNextStarRkDesc, gmvRkNextPctThreshold, scoreRk',
|
||||
});
|
||||
const averageFields = ['mallStarTomms', 'mallStarTommsChange', 'ptHelpRate1m', 'aprsIn3minRplyUsrRto1m', 'rfSignTime1m', 'rfProcTime1m', 'dsr3m', 'posTypeRto1m', 'seriousInferiorRto1m', 'adjNfkAvgCfmSignTime1m', 'lgstMergeFine1m', 'gmvRk'];
|
||||
const values = averageFields.reduce((result, field) => ({
|
||||
...result,
|
||||
[field]: averageNullable(rows, field),
|
||||
}), {});
|
||||
const first = rows[0] || {};
|
||||
const targetFields = [
|
||||
['ptHelpRate1m', '平台求助率', 'ptHelpRate1mNextStarRkDesc', 'ptHelpRate1mNextPctThreshold'],
|
||||
['aprsIn3minRplyUsrRto1m', '3分钟人工回复率', 'aprsIn3minRplyUsrRto1mNextStarRkDesc', 'aprsIn3minRplyUsrRto1mNextPctThreshold'],
|
||||
['rfSignTime1m', '在途订单退款时长', 'rfSignTime1mNextStarRkDesc', 'rfSignTime1mNextPctThreshold'],
|
||||
['rfProcTime1m', '签收退货后退款时长', 'rfProcTime1mNextStarRkDesc', 'rfProcTime1mNextPctThreshold'],
|
||||
['dsr3m', '90天用户评价得分', 'dsr3mNextStarRkDesc', 'dsr3mNextPctThreshold'],
|
||||
['posTypeRto1m', '积极评论率', 'posTypeRto1mNextStarRkDesc', 'posTypeRto1mNextPctThreshold'],
|
||||
['seriousInferiorRto1m', '严重劣质率', 'seriousInferiorRto1mNextStarRkDesc', 'seriousInferiorRto1mNextPctThreshold'],
|
||||
['adjNfkAvgCfmSignTime1m', '成团到签收时效', 'adjNfkAvgCfmSignTime1mNextStarRkDesc', 'adjNfkAvgCfmSignTime1mNextPctThreshold'],
|
||||
['lgstMergeFine1m', '物流综合处罚率', 'lgstMergeFine1mNextStarRkDesc', 'lgstMergeFine1mNextPctThreshold'],
|
||||
['gmvRk', '店铺活跃度', 'gmvRkNextStarRkDesc', 'gmvRkNextPctThreshold'],
|
||||
];
|
||||
const targets = shopName ? targetFields.map(([field, label, statusField, thresholdField]) => ({
|
||||
field,
|
||||
label,
|
||||
value: toNullableNumber(first[field]),
|
||||
status: first[statusField] || null,
|
||||
threshold: toNullableNumber(first[thresholdField]),
|
||||
})) : [];
|
||||
const targetFailureCount = rows.reduce((count, row) => count + targetFields.filter(([, , statusField]) => includesFailure(row[statusField])).length, 0);
|
||||
return { ...values, shopCount: rows.length, targets, targetFailureCount, rows };
|
||||
}
|
||||
|
||||
function includesFailure(value) {
|
||||
const text = String(value || '').trim();
|
||||
return text.includes('不达标') || text.includes('未达标') || text.includes('下降');
|
||||
}
|
||||
|
||||
function buildIssues({ afterSales, customerService, stars }) {
|
||||
const issues = [];
|
||||
const addIssue = (key, tone, title, value, description, source) => {
|
||||
if (toNumber(value) > 0 || includesFailure(value)) {
|
||||
issues.push({ key, tone, title, value, description, source });
|
||||
}
|
||||
};
|
||||
addIssue('wait-seller-handle', 'error', '待商家处理售后单', afterSales.waitSellerHandle, '需要优先处理,避免售后时效继续积压', '店铺售后');
|
||||
addIssue('platform-handling', 'warning', '平台处理中售后单', afterSales.platformHandling, '平台介入中的售后订单需要关注处理结果', '店铺售后');
|
||||
addIssue('delay-reply', 'warning', '三分钟未回复咨询', customerService.reception.delayReplyCount, '客服响应存在延迟', '客服接待');
|
||||
addIssue('complaint', 'error', '客服投诉数', customerService.reception.complainUserCount, '存在客户投诉,需要回溯客服接待记录', '客服接待');
|
||||
addIssue('star-target', 'warning', '星级指标存在未达标项', stars.targetFailureCount, '可在“星级提升”中查看具体指标和下一星级标准', '店铺星级');
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function queryOverview(context) {
|
||||
const { shopName, date } = context;
|
||||
const [business, afterSales, customerService, serviceScore, stars] = await Promise.all([
|
||||
queryBusinessSnapshot(date, shopName),
|
||||
queryAfterSalesSnapshot(date, shopName),
|
||||
queryCustomerServiceSnapshot(date, shopName),
|
||||
queryServiceScoreSnapshot(date, shopName),
|
||||
queryStarsSnapshot(date, shopName),
|
||||
]);
|
||||
const reception = customerService.reception || {};
|
||||
return {
|
||||
date,
|
||||
shopName: shopName || '全部店铺',
|
||||
shopCount: business.shopCount,
|
||||
business: {
|
||||
sales: business.sales,
|
||||
averageAov: business.averageAov,
|
||||
averageConversion: business.averageConversion,
|
||||
},
|
||||
afterSales: {
|
||||
afterSalesCount: afterSales.afterSalesCount,
|
||||
executedCount: afterSales.executedCount,
|
||||
waitSellerHandle: afterSales.waitSellerHandle,
|
||||
platformHandling: afterSales.platformHandling,
|
||||
waitBuyerHandle: afterSales.waitBuyerHandle,
|
||||
todosCount: afterSales.todosCount,
|
||||
todosEndedCount: afterSales.todosEndedCount,
|
||||
cfmOrdrCnt: afterSales.cfmOrdrCnt,
|
||||
},
|
||||
customerService: {
|
||||
...customerService,
|
||||
reception: {
|
||||
consultUserCount: toNumber(reception.consultUserCount),
|
||||
inquiryUserCount: toNumber(reception.inquiryUserCount),
|
||||
finalGroupUserCount: toNumber(reception.finalGroupUserCount),
|
||||
csSalesAmount: toNumber(reception.csSalesAmount),
|
||||
needManualReplyCount: toNumber(reception.needManualReplyCount),
|
||||
receiveUserCount: toNumber(reception.receiveUserCount),
|
||||
delayReplyCount: toNumber(reception.delayReplyCount),
|
||||
replyRate3Min: toNullableNumber(reception.replyRate3Min),
|
||||
replyRate30s: toNullableNumber(reception.replyRate30s),
|
||||
unsatisfiedScoreCount: toNumber(reception.unsatisfiedScoreCount),
|
||||
complainUserCount: toNumber(reception.complainUserCount),
|
||||
averageReplyTime: toNullableNumber(reception.averageReplyTime),
|
||||
serviceRefundCount: toNumber(reception.serviceRefundCount),
|
||||
customerServiceCount: toNumber(reception.customerServiceCount),
|
||||
},
|
||||
},
|
||||
serviceScore: {
|
||||
cstmrServScore: serviceScore.cstmrServScore,
|
||||
attiLmScore: serviceScore.attiLmScore,
|
||||
jcfwLmScore: serviceScore.jcfwLmScore,
|
||||
spLmScore: serviceScore.spLmScore,
|
||||
fhLmScore: serviceScore.fhLmScore,
|
||||
wlLmScore: serviceScore.wlLmScore,
|
||||
},
|
||||
stars: {
|
||||
mallStarTomms: stars.mallStarTomms,
|
||||
mallStarTommsChange: stars.mallStarTommsChange,
|
||||
dsr3m: stars.dsr3m,
|
||||
ptHelpRate1m: stars.ptHelpRate1m,
|
||||
aprsIn3minRplyUsrRto1m: stars.aprsIn3minRplyUsrRto1m,
|
||||
targets: stars.targets,
|
||||
},
|
||||
issues: buildIssues({ afterSales, customerService, stars }),
|
||||
coverage: {
|
||||
business: business.shopCount > 0,
|
||||
afterSales: afterSales.shopCount > 0,
|
||||
customerService: customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
|
||||
serviceScore: serviceScore.shopCount > 0,
|
||||
stars: stars.shopCount > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function queryHomeSummary(context) {
|
||||
const { shopName, date } = context;
|
||||
const [afterSales, customerService, serviceScore, stars] = await Promise.all([
|
||||
queryAfterSalesSnapshot(date, shopName),
|
||||
queryCustomerServiceSnapshot(date, shopName),
|
||||
queryServiceScoreSnapshot(date, shopName),
|
||||
queryStarsSnapshot(date, shopName),
|
||||
]);
|
||||
const reception = customerService.reception || {};
|
||||
const riskItems = [
|
||||
{
|
||||
key: 'waitSellerHandle',
|
||||
label: '待商家处理',
|
||||
value: toNumber(afterSales.waitSellerHandle),
|
||||
tone: 'error',
|
||||
},
|
||||
{
|
||||
key: 'delayReplyCount',
|
||||
label: '三分钟未回复',
|
||||
value: toNumber(reception.delayReplyCount),
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
key: 'complainUserCount',
|
||||
label: '客服投诉',
|
||||
value: toNumber(reception.complainUserCount),
|
||||
tone: 'error',
|
||||
},
|
||||
{
|
||||
key: 'starTargetFailure',
|
||||
label: '星级未达标项',
|
||||
value: toNumber(stars.targetFailureCount),
|
||||
tone: 'warning',
|
||||
},
|
||||
];
|
||||
const riskSignalCount = riskItems.reduce((sum, item) => sum + item.value, 0);
|
||||
const riskTypeCount = riskItems.filter((item) => item.value > 0).length;
|
||||
const coverageModules = [
|
||||
afterSales.shopCount > 0,
|
||||
customerService.shopCount > 0 || Boolean(reception.customerServiceCount),
|
||||
serviceScore.shopCount > 0,
|
||||
stars.shopCount > 0,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return {
|
||||
date,
|
||||
shopName: shopName || '全部店铺',
|
||||
shopCount: Math.max(
|
||||
toNumber(afterSales.shopCount),
|
||||
toNumber(customerService.shopCount),
|
||||
toNumber(serviceScore.shopCount),
|
||||
toNumber(stars.shopCount)
|
||||
),
|
||||
totalShopCount: shopName ? 1 : context.meta.shops.length,
|
||||
coverageModules,
|
||||
totalModules: 4,
|
||||
status: riskSignalCount > 0 ? 'attention' : 'healthy',
|
||||
riskSignalCount,
|
||||
riskTypeCount,
|
||||
averageStar: toNullableNumber(stars.mallStarTomms),
|
||||
starChange: toNullableNumber(stars.mallStarTommsChange),
|
||||
evaluationScore: toNullableNumber(stars.dsr3m),
|
||||
riskItems,
|
||||
serviceScores: [
|
||||
{ key: 'cstmrServScore', label: '服务体验', value: toNullableNumber(serviceScore.cstmrServScore) },
|
||||
{ key: 'spLmScore', label: '商品服务', value: toNullableNumber(serviceScore.spLmScore) },
|
||||
{ key: 'wlLmScore', label: '物流服务', value: toNullableNumber(serviceScore.wlLmScore) },
|
||||
{ key: 'fhLmScore', label: '发货服务', value: toNullableNumber(serviceScore.fhLmScore) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReviewDateRange(req) {
|
||||
const shopName = cleanShopName(req.query.shopName);
|
||||
let endDate = normalizeDate(req.query.endDate);
|
||||
if (!endDate) {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT TOP (1) LEFT(createTime, 10) AS latestDate
|
||||
FROM dbo.pdd_Evaluation WITH (NOLOCK)
|
||||
WHERE createTime >= '2000-01-01'
|
||||
AND createTime < '2100-01-01'
|
||||
AND descScore BETWEEN 1 AND 5
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
ORDER BY createTime DESC`,
|
||||
{ shopName }
|
||||
);
|
||||
endDate = normalizeDate(rows[0]?.latestDate);
|
||||
}
|
||||
const startDate = normalizeDate(req.query.startDate) || (endDate ? addDays(endDate, -29) : '');
|
||||
if (!startDate || !endDate) {
|
||||
const error = new Error('评价数据没有可用日期');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const dayCount = inclusiveDayCount(startDate, endDate);
|
||||
if (dayCount < 1) {
|
||||
const error = new Error('评价开始日期不能晚于结束日期');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (dayCount > 90) {
|
||||
const error = new Error('评价查询日期范围不能超过90天');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return { startDate, endDate, dayCount, shopName };
|
||||
}
|
||||
|
||||
async function queryHomeReviewSummary(range) {
|
||||
const params = {
|
||||
startDate: range.startDate,
|
||||
endDateExclusive: addDays(range.endDate, 1),
|
||||
shopName: range.shopName || '',
|
||||
};
|
||||
const [summaryRows, keywordRows] = await Promise.all([
|
||||
jianSunDb.query(
|
||||
`SELECT
|
||||
COUNT_BIG(1) AS totalReviewCount,
|
||||
SUM(CASE WHEN descScore IN (4, 5) THEN 1 ELSE 0 END) AS goodReviewCount,
|
||||
SUM(CASE WHEN descScore IN (1, 2, 3) THEN 1 ELSE 0 END) AS badReviewCount,
|
||||
SUM(CASE WHEN descScore = 1 THEN 1 ELSE 0 END) AS score1Count,
|
||||
SUM(CASE WHEN descScore = 2 THEN 1 ELSE 0 END) AS score2Count,
|
||||
SUM(CASE WHEN descScore = 3 THEN 1 ELSE 0 END) AS score3Count,
|
||||
SUM(CASE WHEN descScore = 4 THEN 1 ELSE 0 END) AS score4Count,
|
||||
SUM(CASE WHEN descScore = 5 THEN 1 ELSE 0 END) AS score5Count,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), descScore)) AS averageScore,
|
||||
COUNT(DISTINCT goodsId) AS reviewedGoodsCount,
|
||||
COUNT(DISTINCT NULLIF(LTRIM(RTRIM(shopName)), '')) AS reviewedShopCount
|
||||
FROM dbo.pdd_Evaluation WITH (NOLOCK)
|
||||
WHERE createTime >= @startDate
|
||||
AND createTime < @endDateExclusive
|
||||
AND descScore BETWEEN 1 AND 5
|
||||
AND (@shopName = '' OR shopName = @shopName)`,
|
||||
params
|
||||
),
|
||||
jianSunDb.query(
|
||||
`SELECT keywords, COUNT_BIG(1) AS reviewCount
|
||||
FROM dbo.pdd_Evaluation WITH (NOLOCK)
|
||||
WHERE createTime >= @startDate
|
||||
AND createTime < @endDateExclusive
|
||||
AND descScore BETWEEN 1 AND 3
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
AND NULLIF(LTRIM(RTRIM(keywords)), '') IS NOT NULL
|
||||
GROUP BY keywords`,
|
||||
params
|
||||
),
|
||||
]);
|
||||
const row = summaryRows[0] || {};
|
||||
const totalReviewCount = toNumber(row.totalReviewCount);
|
||||
const goodReviewCount = toNumber(row.goodReviewCount);
|
||||
const badReviewCount = toNumber(row.badReviewCount);
|
||||
const scoreCounts = [1, 2, 3, 4, 5].map((score) => ({
|
||||
score,
|
||||
count: toNumber(row[`score${score}Count`]),
|
||||
}));
|
||||
const negativeKeywordPattern = /差|不符|异味|瑕疵|破损|低|少发|错发|不好|问题|慢|难/;
|
||||
const badKeywordCounts = new Map();
|
||||
keywordRows.forEach((item) => {
|
||||
const weight = toNumber(item.reviewCount);
|
||||
String(item.keywords || '').split(/[,,]/).forEach((value) => {
|
||||
const keyword = value.trim();
|
||||
if (!keyword || !negativeKeywordPattern.test(keyword)) return;
|
||||
badKeywordCounts.set(keyword, (badKeywordCounts.get(keyword) || 0) + weight);
|
||||
});
|
||||
});
|
||||
const badKeywords = [...badKeywordCounts.entries()]
|
||||
.map(([keyword, count]) => ({ keyword, count }))
|
||||
.sort((left, right) => right.count - left.count || left.keyword.localeCompare(right.keyword, 'zh-CN'))
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
...range,
|
||||
totalReviewCount,
|
||||
goodReviewCount,
|
||||
goodReviewRate: totalReviewCount > 0 ? goodReviewCount * 100 / totalReviewCount : 0,
|
||||
badReviewCount,
|
||||
badReviewRate: totalReviewCount > 0 ? badReviewCount * 100 / totalReviewCount : 0,
|
||||
averageScore: toNullableNumber(row.averageScore),
|
||||
reviewedGoodsCount: toNumber(row.reviewedGoodsCount),
|
||||
reviewedShopCount: toNumber(row.reviewedShopCount),
|
||||
distribution: scoreCounts.map((item) => ({
|
||||
...item,
|
||||
rate: totalReviewCount > 0 ? item.count * 100 / totalReviewCount : 0,
|
||||
})),
|
||||
badKeywords,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryBusinessTab(context) {
|
||||
const startDate = addDays(context.date, -29);
|
||||
const rows = await jianSunDb.query(
|
||||
`WITH ranked AS (
|
||||
SELECT
|
||||
statDate,
|
||||
shopName, cfmOrdrAmt, payOrdrAup, payUvRto,
|
||||
ROW_NUMBER() OVER (PARTITION BY shopName, statDate ORDER BY id DESC) AS rowNumber
|
||||
FROM dbo.pdd_DataNormal
|
||||
WHERE ${shopFilterSql()}
|
||||
AND statDate BETWEEN @startDate AND @targetDate
|
||||
)
|
||||
SELECT statDate,
|
||||
SUM(TRY_CONVERT(decimal(38, 6), cfmOrdrAmt)) AS sales,
|
||||
AVG(TRY_CONVERT(decimal(38, 6), payOrdrAup)) AS averageAov,
|
||||
AVG(TRY_CONVERT(decimal(38, 8), payUvRto)) AS averageConversion,
|
||||
COUNT(*) AS shopCount
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1
|
||||
GROUP BY statDate
|
||||
ORDER BY statDate`,
|
||||
{ startDate, targetDate: context.date, shopName: context.shopName || '' }
|
||||
);
|
||||
return {
|
||||
status: rows.length ? 'ready' : 'empty',
|
||||
metrics: [
|
||||
{ key: 'sales', label: '成交额', value: rows.reduce((sum, row) => sum + toNumber(row.sales), 0), unit: 'currency' },
|
||||
{ key: 'averageAov', label: '平均支付客单价', value: rows.length ? toNumber(rows.at(-1).averageAov) : null, unit: 'currency' },
|
||||
{ key: 'averageConversion', label: '最新支付转化率', value: rows.length ? toNumber(rows.at(-1).averageConversion) * 100 : null, unit: 'percent' },
|
||||
],
|
||||
trend: rows.map((row) => ({ date: row.statDate, sales: toNumber(row.sales), averageAov: toNumber(row.averageAov), averageConversion: toNumber(row.averageConversion) * 100 })),
|
||||
};
|
||||
}
|
||||
|
||||
async function queryAfterSalesTab(context) {
|
||||
const rows = await queryLatestRows({
|
||||
table: 'pdd_ShopNormalOverflow',
|
||||
dateColumn: 'createTime',
|
||||
targetDate: context.date,
|
||||
shopName: context.shopName,
|
||||
fields: '[id], shopName, createTime, executedCount, afterSalesCount, waitSellerHandle, platformHandling, waitBuyerHandle, todosCount, todosEndedCount, cfmOrdrCnt',
|
||||
});
|
||||
return {
|
||||
status: rows.length ? 'ready' : 'empty',
|
||||
rows,
|
||||
columns: [
|
||||
{ key: 'shopName', title: '店铺' }, { key: 'afterSalesCount', title: '售后总量' }, { key: 'executedCount', title: '已处理' },
|
||||
{ key: 'waitSellerHandle', title: '待商家处理' }, { key: 'platformHandling', title: '平台处理中' }, { key: 'waitBuyerHandle', title: '待买家处理' },
|
||||
{ key: 'todosCount', title: '工单总量' }, { key: 'todosEndedCount', title: '已完结工单' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function queryCustomerServiceTab(context) {
|
||||
const rows = await jianSunDb.query(
|
||||
`WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName, cs_name ORDER BY id DESC) AS rowNumber
|
||||
FROM dbo.pdd_customer_overflow
|
||||
WHERE ${shopFilterSql()}
|
||||
AND [createTime] = @targetDate
|
||||
)
|
||||
SELECT
|
||||
cs_name AS csName,
|
||||
SUM(ISNULL(consult_user_cnt, 0)) AS consultUserCount,
|
||||
SUM(ISNULL(inquiry_user, 0)) AS inquiryUserCount,
|
||||
SUM(ISNULL(final_group_user, 0)) AS finalGroupUserCount,
|
||||
SUM(ISNULL(cs_sales_amount, 0)) AS csSalesAmount,
|
||||
SUM(ISNULL(receive_user_cnt, 0)) AS receiveUserCount,
|
||||
SUM(ISNULL(delay_reply, 0)) AS delayReplyCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), reply_rate_3_min)) AS replyRate3Min,
|
||||
AVG(TRY_CONVERT(decimal(18, 8), reply_30s_rate)) AS replyRate30s,
|
||||
SUM(ISNULL(complain_user_cnt, 0)) AS complainUserCount
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1 AND ISNULL(cs_name, '') <> N'店铺总计'
|
||||
GROUP BY cs_name
|
||||
ORDER BY SUM(ISNULL(cs_sales_amount, 0)) DESC`,
|
||||
{ targetDate: context.date, shopName: context.shopName || '' }
|
||||
);
|
||||
return {
|
||||
status: rows.length ? 'ready' : 'empty',
|
||||
rows,
|
||||
columns: [
|
||||
{ key: 'csName', title: '客服账号' }, { key: 'consultUserCount', title: '咨询人数' }, { key: 'inquiryUserCount', title: '询单人数' },
|
||||
{ key: 'finalGroupUserCount', title: '成团人数' }, { key: 'csSalesAmount', title: '客服销售额' }, { key: 'receiveUserCount', title: '人工接待' },
|
||||
{ key: 'replyRate30s', title: '30秒应答率' }, { key: 'replyRate3Min', title: '3分钟回复率' }, { key: 'complainUserCount', title: '投诉数' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function queryServiceScoreTab(context) {
|
||||
const snapshot = await queryServiceScoreSnapshot(context.date, context.shopName);
|
||||
return {
|
||||
status: snapshot.shopCount ? 'ready' : 'empty',
|
||||
metrics: [
|
||||
{ key: 'cstmrServScore', label: '消费者服务体验分', value: snapshot.cstmrServScore, unit: 'score' },
|
||||
{ key: 'attiLmScore', label: '服务态度', value: snapshot.attiLmScore, unit: 'score' },
|
||||
{ key: 'jcfwLmScore', label: '基础服务', value: snapshot.jcfwLmScore, unit: 'score' },
|
||||
{ key: 'spLmScore', label: '商品服务', value: snapshot.spLmScore, unit: 'score' },
|
||||
{ key: 'fhLmScore', label: '发货服务', value: snapshot.fhLmScore, unit: 'score' },
|
||||
{ key: 'wlLmScore', label: '物流服务', value: snapshot.wlLmScore, unit: 'score' },
|
||||
],
|
||||
rows: snapshot.rows,
|
||||
columns: [
|
||||
{ key: 'shopName', title: '店铺' }, { key: 'cstmrServScore', title: '消费者服务体验分' }, { key: 'attiLmScore', title: '服务态度' },
|
||||
{ key: 'jcfwLmScore', title: '基础服务' }, { key: 'spLmScore', title: '商品服务' }, { key: 'fhLmScore', title: '发货服务' }, { key: 'wlLmScore', title: '物流服务' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function queryStarsTab(context) {
|
||||
const snapshot = await queryStarsSnapshot(context.date, context.shopName);
|
||||
return {
|
||||
status: snapshot.shopCount ? 'ready' : 'empty',
|
||||
metrics: [
|
||||
{ key: 'mallStarTomms', label: '综合体验星级', value: snapshot.mallStarTomms, unit: 'star' },
|
||||
{ key: 'mallStarTommsChange', label: '星级变化', value: snapshot.mallStarTommsChange, unit: 'starChange' },
|
||||
{ key: 'dsr3m', label: '90天用户评价得分', value: snapshot.dsr3m, unit: 'score' },
|
||||
{ key: 'ptHelpRate1m', label: '30天平台求助率', value: snapshot.ptHelpRate1m, unit: 'ratio' },
|
||||
{ key: 'aprsIn3minRplyUsrRto1m', label: '30天3分钟人工回复率', value: snapshot.aprsIn3minRplyUsrRto1m, unit: 'ratio' },
|
||||
],
|
||||
targets: snapshot.targets,
|
||||
rows: snapshot.rows,
|
||||
columns: [
|
||||
{ key: 'shopName', title: '店铺' }, { key: 'mallStarTomms', title: '综合体验星级' }, { key: 'mallStarTommsChange', title: '星级变化' },
|
||||
{ key: 'dsr3m', title: '90天评价得分' }, { key: 'ptHelpRate1m', title: '平台求助率' }, { key: 'aprsIn3minRplyUsrRto1m', title: '3分钟回复率' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/meta', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await loadMeta() });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询元数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/overview', async (req, res) => {
|
||||
try {
|
||||
const context = await resolveContext(req);
|
||||
const cacheKey = `${context.shopName || '*'}:${context.date}`;
|
||||
const cached = overviewCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
let pending = overviewInFlight.get(cacheKey);
|
||||
if (!pending) {
|
||||
pending = queryOverview(context)
|
||||
.then((data) => {
|
||||
overviewCache.set(cacheKey, { data, expiresAt: Date.now() + OVERVIEW_CACHE_TTL_MS });
|
||||
return data;
|
||||
})
|
||||
.finally(() => overviewInFlight.delete(cacheKey));
|
||||
overviewInFlight.set(cacheKey, pending);
|
||||
}
|
||||
const data = await pending;
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询体检概览失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/home-summary', async (req, res) => {
|
||||
try {
|
||||
const context = await resolveContext(req);
|
||||
const cacheKey = `${context.shopName || '*'}:${context.date}`;
|
||||
const cached = homeSummaryCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
const refreshSummary = () => {
|
||||
let pending = homeSummaryInFlight.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
pending = queryHomeSummary(context)
|
||||
.then((data) => {
|
||||
const now = Date.now();
|
||||
homeSummaryCache.set(cacheKey, {
|
||||
data,
|
||||
expiresAt: now + HOME_SUMMARY_CACHE_TTL_MS,
|
||||
staleUntil: now + HOME_SUMMARY_STALE_TTL_MS,
|
||||
});
|
||||
return data;
|
||||
})
|
||||
.finally(() => homeSummaryInFlight.delete(cacheKey));
|
||||
homeSummaryInFlight.set(cacheKey, pending);
|
||||
return pending;
|
||||
};
|
||||
if (cached && cached.staleUntil > Date.now()) {
|
||||
refreshSummary().catch((error) => {
|
||||
console.error('[StoreCheckup] 后台刷新首页摘要失败:', error.message);
|
||||
});
|
||||
return res.json({ success: true, data: { ...cached.data, refreshing: true } });
|
||||
}
|
||||
return res.json({ success: true, data: await refreshSummary() });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询首页摘要失败:', error.message);
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/home-reviews', async (req, res) => {
|
||||
try {
|
||||
const range = await resolveReviewDateRange(req);
|
||||
const cacheKey = `${range.shopName || '*'}:${range.startDate}:${range.endDate}`;
|
||||
const cached = homeReviewCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
let pending = homeReviewInFlight.get(cacheKey);
|
||||
if (!pending) {
|
||||
pending = queryHomeReviewSummary(range)
|
||||
.then((data) => {
|
||||
homeReviewCache.set(cacheKey, { data, expiresAt: Date.now() + HOME_SUMMARY_CACHE_TTL_MS });
|
||||
return data;
|
||||
})
|
||||
.finally(() => homeReviewInFlight.delete(cacheKey));
|
||||
homeReviewInFlight.set(cacheKey, pending);
|
||||
}
|
||||
return res.json({ success: true, data: await pending });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询首页评价摘要失败:', error.message);
|
||||
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/home-compensation', async (req, res) => {
|
||||
try {
|
||||
const shopName = cleanShopName(req.query.shopName);
|
||||
const rows = await loadRecentCompensationRows(shopName);
|
||||
const range = { ...(await resolveCompensationDateRange(req, rows)), shopName };
|
||||
const cacheKey = `${shopName || '*'}:${range.startDate}:${range.endDate}`;
|
||||
const cached = homeCompensationSummaryCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
let pending = homeCompensationSummaryInFlight.get(cacheKey);
|
||||
if (!pending) {
|
||||
pending = Promise.resolve(queryHomeCompensationSummary(rows, range))
|
||||
.then((data) => {
|
||||
homeCompensationSummaryCache.set(cacheKey, {
|
||||
data,
|
||||
expiresAt: Date.now() + HOME_COMPENSATION_CACHE_TTL_MS,
|
||||
});
|
||||
return data;
|
||||
})
|
||||
.finally(() => homeCompensationSummaryInFlight.delete(cacheKey));
|
||||
homeCompensationSummaryInFlight.set(cacheKey, pending);
|
||||
}
|
||||
return res.json({ success: true, data: await pending });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询首页赔付摘要失败:', error.message);
|
||||
return res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/tabs/:tabKey', async (req, res) => {
|
||||
try {
|
||||
const tabKey = String(req.params.tabKey || '').trim();
|
||||
if (!TAB_KEYS.has(tabKey) || tabKey === 'overview') {
|
||||
return res.status(400).json({ success: false, error: '不支持的店铺体检模块' });
|
||||
}
|
||||
const context = await resolveContext(req);
|
||||
const handlers = {
|
||||
business: queryBusinessTab,
|
||||
'after-sales': queryAfterSalesTab,
|
||||
'customer-service': queryCustomerServiceTab,
|
||||
'service-score': queryServiceScoreTab,
|
||||
stars: queryStarsTab,
|
||||
};
|
||||
const data = await handlers[tabKey](context);
|
||||
return res.json({ success: true, data: { ...data, date: context.date, shopName: context.shopName || '全部店铺' } });
|
||||
} catch (error) {
|
||||
console.error('[StoreCheckup] 查询体检模块失败:', error.message);
|
||||
return res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
94
backend/src/api/taobaoDetailPlus.js
Normal file
94
backend/src/api/taobaoDetailPlus.js
Normal file
@ -0,0 +1,94 @@
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const {
|
||||
getTaobaoDetailPlusByItemId,
|
||||
} = require('../services/taobaoDetailPlusDataService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function getConfiguredApiKeys() {
|
||||
return [
|
||||
process.env.TAOBAO_DETAIL_PLUS_API_KEY,
|
||||
process.env.TAOBAO_DETAIL_PLUS_API_KEYS,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.flatMap((value) => String(value).split(','))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function safeEquals(left, right) {
|
||||
const leftBuffer = Buffer.from(String(left || ''));
|
||||
const rightBuffer = Buffer.from(String(right || ''));
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function extractApiKey(req) {
|
||||
const headerKey = req.get('x-api-key');
|
||||
if (headerKey) {
|
||||
return headerKey.trim();
|
||||
}
|
||||
|
||||
const authHeader = req.get('authorization') || '';
|
||||
const match = authHeader.match(/^(?:ApiKey|Bearer)\s+(.+)$/i);
|
||||
if (match) {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
return String(req.query.apiKey || req.query.api_key || '').trim();
|
||||
}
|
||||
|
||||
function requireApiKey(req, res, next) {
|
||||
const configuredKeys = getConfiguredApiKeys();
|
||||
if (configuredKeys.length === 0) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: '接口 API key 尚未配置',
|
||||
});
|
||||
}
|
||||
|
||||
const providedKey = extractApiKey(req);
|
||||
if (!providedKey || !configuredKeys.some((key) => safeEquals(providedKey, key))) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'API key 无效或未提供',
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
async function handleDetailQuery(req, res) {
|
||||
try {
|
||||
const itemId = req.params.itemId || req.query.itemId || req.query.item_id || req.query.num_iid;
|
||||
const data = await getTaobaoDetailPlusByItemId(itemId);
|
||||
if (!data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: '未找到该商品数据',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
const statusCode = error.statusCode || 500;
|
||||
if (statusCode >= 500) {
|
||||
console.error('[TaobaoDetailPlusAPI] 查询失败:', error);
|
||||
}
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error: error.message || '查询商品详情失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/', requireApiKey, handleDetailQuery);
|
||||
router.get('/:itemId', requireApiKey, handleDetailQuery);
|
||||
|
||||
module.exports = router;
|
||||
216
backend/src/api/thirdPartySso.js
Normal file
216
backend/src/api/thirdPartySso.js
Normal file
@ -0,0 +1,216 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { getAuthConfig } = require('../services/authConfig');
|
||||
const {
|
||||
SUPPORTED_SCOPES,
|
||||
TOKEN_ISSUER,
|
||||
ThirdPartySsoError,
|
||||
buildUserInfo,
|
||||
exchangeAuthorizationCode,
|
||||
getThirdPartySsoCallbackBaseUrl,
|
||||
revokeAccessToken,
|
||||
validateAuthorizationRequest,
|
||||
verifyThirdPartyAccessToken,
|
||||
} = require('../services/thirdPartySsoService');
|
||||
const {
|
||||
buildCallbackUrl,
|
||||
createOAuthState,
|
||||
getStateCookieName,
|
||||
} = require('../services/socialAuth');
|
||||
const {
|
||||
beginDirectLogin,
|
||||
createDirectLoginSession,
|
||||
pollDirectLoginSession,
|
||||
} = require('../services/thirdPartySsoDirectService');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(express.urlencoded({ extended: false, limit: '32kb' }));
|
||||
|
||||
function requestContext(req) {
|
||||
return {
|
||||
ipAddress: req.ip || req.socket?.remoteAddress || '',
|
||||
userAgent: req.get('user-agent') || '',
|
||||
};
|
||||
}
|
||||
|
||||
function setOauthStateCookie(res, state) {
|
||||
res.cookie(getStateCookieName('dingtalk'), state, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 15 * 60 * 1000,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
function parseClientCredentials(req) {
|
||||
const authorization = String(req.get('authorization') || '');
|
||||
const match = authorization.match(/^Basic\s+(.+)$/iu);
|
||||
if (match) {
|
||||
try {
|
||||
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
|
||||
const separator = decoded.indexOf(':');
|
||||
if (separator >= 0) {
|
||||
return {
|
||||
clientId: decodeURIComponent(decoded.slice(0, separator)),
|
||||
clientSecret: decodeURIComponent(decoded.slice(separator + 1)),
|
||||
};
|
||||
}
|
||||
} catch (_error) {
|
||||
// The service returns a standard invalid_client response below.
|
||||
}
|
||||
}
|
||||
return {
|
||||
clientId: req.body?.client_id || req.body?.clientId,
|
||||
clientSecret: req.body?.client_secret || req.body?.clientSecret || '',
|
||||
};
|
||||
}
|
||||
|
||||
function extractBearerToken(req) {
|
||||
const match = String(req.get('authorization') || '').match(/^Bearer\s+([^\s]+)$/iu);
|
||||
return match?.[1] || '';
|
||||
}
|
||||
|
||||
function extractApiKey(req) {
|
||||
return String(req.get('x-api-key') || '').trim();
|
||||
}
|
||||
|
||||
function sendOauthError(res, error) {
|
||||
const status = error instanceof ThirdPartySsoError ? error.status : 500;
|
||||
const code = error instanceof ThirdPartySsoError ? error.code : 'server_error';
|
||||
return res.status(status).json({
|
||||
error: code,
|
||||
error_description: error.message || '统一认证服务异常',
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/authorize', async (req, res) => {
|
||||
try {
|
||||
const validated = await validateAuthorizationRequest(req.query);
|
||||
const authConfig = await getAuthConfig();
|
||||
if (!authConfig.dingtalkEnabled || !authConfig.dingtalkAppKey || !authConfig.dingtalkAppSecret) {
|
||||
throw new ThirdPartySsoError('钉钉登录未配置或未启用', 503, 'temporarily_unavailable');
|
||||
}
|
||||
|
||||
const callbackUrl = buildCallbackUrl(getThirdPartySsoCallbackBaseUrl(), 'dingtalk');
|
||||
const oauthState = createOAuthState('dingtalk', validated.stateClaims);
|
||||
setOauthStateCookie(res, oauthState);
|
||||
|
||||
const authorizeUrl = new URL('https://login.dingtalk.com/oauth2/auth');
|
||||
authorizeUrl.searchParams.set('redirect_uri', callbackUrl);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', authConfig.dingtalkAppKey);
|
||||
authorizeUrl.searchParams.set('scope', 'openid');
|
||||
authorizeUrl.searchParams.set('state', oauthState);
|
||||
return res.redirect(authorizeUrl.toString());
|
||||
} catch (error) {
|
||||
return sendOauthError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/direct/sessions', async (req, res) => {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.status(201).json(await createDirectLoginSession(
|
||||
req.body || {},
|
||||
extractApiKey(req),
|
||||
{ requestContext: requestContext(req) }
|
||||
));
|
||||
} catch (error) {
|
||||
return sendOauthError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/direct/sessions/:requestId', async (req, res) => {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.json(await pollDirectLoginSession(
|
||||
req.params.requestId,
|
||||
extractApiKey(req),
|
||||
{ requestContext: requestContext(req) }
|
||||
));
|
||||
} catch (error) {
|
||||
return sendOauthError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/direct/login', async (req, res) => {
|
||||
try {
|
||||
const directLogin = await beginDirectLogin(req.query.request_id);
|
||||
const authConfig = await getAuthConfig();
|
||||
if (!authConfig.dingtalkEnabled || !authConfig.dingtalkAppKey || !authConfig.dingtalkAppSecret) {
|
||||
throw new ThirdPartySsoError('钉钉登录未配置或未启用', 503, 'temporarily_unavailable');
|
||||
}
|
||||
|
||||
const callbackUrl = buildCallbackUrl(getThirdPartySsoCallbackBaseUrl(), 'dingtalk');
|
||||
const oauthState = createOAuthState('dingtalk', directLogin.stateClaims);
|
||||
setOauthStateCookie(res, oauthState);
|
||||
|
||||
const authorizeUrl = new URL('https://login.dingtalk.com/oauth2/auth');
|
||||
authorizeUrl.searchParams.set('redirect_uri', callbackUrl);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', authConfig.dingtalkAppKey);
|
||||
authorizeUrl.searchParams.set('scope', 'openid');
|
||||
authorizeUrl.searchParams.set('state', oauthState);
|
||||
return res.redirect(authorizeUrl.toString());
|
||||
} catch (error) {
|
||||
return sendOauthError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/token', async (req, res) => {
|
||||
try {
|
||||
const result = await exchangeAuthorizationCode(req.body || {}, parseClientCredentials(req), {
|
||||
requestContext: requestContext(req),
|
||||
});
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Pragma', 'no-cache');
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (error?.code === 'invalid_client') {
|
||||
res.set('WWW-Authenticate', 'Basic realm="third-party-sso"');
|
||||
}
|
||||
return sendOauthError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/userinfo', async (req, res) => {
|
||||
try {
|
||||
const verified = await verifyThirdPartyAccessToken(extractBearerToken(req));
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.json(buildUserInfo(verified));
|
||||
} catch (error) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="third-party-sso", error="invalid_token"');
|
||||
return sendOauthError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/revoke', async (req, res) => {
|
||||
try {
|
||||
await revokeAccessToken(req.body?.token, parseClientCredentials(req), {
|
||||
requestContext: requestContext(req),
|
||||
});
|
||||
return res.status(200).json({ revoked: true });
|
||||
} catch (error) {
|
||||
return sendOauthError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/.well-known/oauth-authorization-server', (_req, res) => {
|
||||
const issuer = TOKEN_ISSUER.replace(/\/$/u, '');
|
||||
return res.json({
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/token`,
|
||||
revocation_endpoint: `${issuer}/revoke`,
|
||||
userinfo_endpoint: `${issuer}/userinfo`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
||||
scopes_supported: SUPPORTED_SCOPES,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
111
backend/src/api/thirdPartySsoAdmin.js
Normal file
111
backend/src/api/thirdPartySsoAdmin.js
Normal file
@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
ThirdPartySsoError,
|
||||
createSsoClient,
|
||||
listSsoClients,
|
||||
rotateSsoClientSecret,
|
||||
updateSsoClient,
|
||||
} = require('../services/thirdPartySsoService');
|
||||
const {
|
||||
createDirectApiKey,
|
||||
rotateDirectApiKey,
|
||||
updateDirectReturnUrls,
|
||||
} = require('../services/thirdPartySsoDirectService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error) {
|
||||
return res.status(error instanceof ThirdPartySsoError ? error.status : 500).json({
|
||||
error: error.message || '统一认证应用管理失败',
|
||||
code: error instanceof ThirdPartySsoError ? error.code : 'SERVER_ERROR',
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
return res.json({ clients: await listSsoClients() });
|
||||
} catch (error) {
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const client = await createSsoClient(req.body || {}, req.user || {});
|
||||
return res.status(201).json({
|
||||
client,
|
||||
warning: client.clientSecret
|
||||
? 'client_secret 仅在本次创建时返回,请交给第三方后安全保存。'
|
||||
: '公开客户端不使用 client_secret,必须使用 PKCE。',
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/direct-api-key', async (req, res) => {
|
||||
try {
|
||||
return res.status(201).json({
|
||||
credential: await createDirectApiKey(req.body || {}, req.user || {}),
|
||||
warning: 'API Key 仅在本次创建时返回,只能保存在第三方后端。',
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:clientId/rotate-api-key', async (req, res) => {
|
||||
try {
|
||||
return res.json({
|
||||
credential: await rotateDirectApiKey(req.params.clientId, req.user || {}),
|
||||
warning: '新 API Key 仅在本次返回,旧 Key 已立即失效。',
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:clientId/direct-return-urls', async (req, res) => {
|
||||
try {
|
||||
return res.json({
|
||||
client: await updateDirectReturnUrls(req.params.clientId, req.body || {}, req.user || {}),
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:clientId', async (req, res) => {
|
||||
try {
|
||||
return res.json({
|
||||
client: await updateSsoClient(req.params.clientId, req.body || {}, req.user || {}),
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:clientId/rotate-secret', async (req, res) => {
|
||||
try {
|
||||
return res.json({
|
||||
client: await rotateSsoClientSecret(req.params.clientId, req.user || {}),
|
||||
warning: '新 client_secret 仅在本次返回,旧密钥已立即失效。',
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:clientId', async (req, res) => {
|
||||
try {
|
||||
return res.json({
|
||||
client: await updateSsoClient(req.params.clientId, { status: 'disabled' }, req.user || {}),
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
137
backend/src/api/tmallProxy.js
Normal file
137
backend/src/api/tmallProxy.js
Normal file
@ -0,0 +1,137 @@
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const authMiddleware = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const DEFAULT_PROXY_BASE_URL = 'http://localhost:5252';
|
||||
const DEFAULT_PROXY_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const PROXY_USER_HEADER = 'x-commerceinsight-proxy-user';
|
||||
const PROXY_SECRET_HEADER = 'x-commerceinsight-proxy-secret';
|
||||
|
||||
function getProxyBaseUrl() {
|
||||
const baseUrl = String(process.env.TMALL_DOTNET_PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL).trim();
|
||||
return baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getProxyTimeoutMs() {
|
||||
const timeoutMs = Number(process.env.TMALL_DOTNET_PROXY_TIMEOUT_MS);
|
||||
if (Number.isFinite(timeoutMs) && timeoutMs >= 1000) {
|
||||
return timeoutMs;
|
||||
}
|
||||
return DEFAULT_PROXY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function buildTargetUrl(req) {
|
||||
const upstreamBaseUrl = new URL(`${getProxyBaseUrl()}/`);
|
||||
const queryStringIndex = req.originalUrl.indexOf('?');
|
||||
const queryString = queryStringIndex >= 0 ? req.originalUrl.slice(queryStringIndex) : '';
|
||||
const upstreamBasePath = upstreamBaseUrl.pathname === '/' ? '' : upstreamBaseUrl.pathname.replace(/\/$/, '');
|
||||
upstreamBaseUrl.pathname = `${upstreamBasePath}${req.baseUrl}${req.path}`;
|
||||
upstreamBaseUrl.search = queryString.startsWith('?') ? queryString.slice(1) : queryString;
|
||||
return upstreamBaseUrl.toString();
|
||||
}
|
||||
|
||||
function buildProxyHeaders(req) {
|
||||
const proxySharedSecret = String(process.env.TMALL_DOTNET_PROXY_SHARED_SECRET || process.env.CUBEJS_SECRET || '').trim();
|
||||
const headers = {
|
||||
accept: req.headers.accept,
|
||||
'accept-language': req.headers['accept-language'],
|
||||
authorization: req.headers.authorization,
|
||||
cookie: req.headers.cookie,
|
||||
'content-type': req.headers['content-type'],
|
||||
'user-agent': req.headers['user-agent'],
|
||||
'x-forwarded-for': req.ip,
|
||||
'x-forwarded-host': req.get('host'),
|
||||
'x-forwarded-prefix': req.baseUrl || '',
|
||||
'x-forwarded-proto': req.protocol,
|
||||
};
|
||||
|
||||
if (proxySharedSecret && req.user) {
|
||||
headers[PROXY_SECRET_HEADER] = proxySharedSecret;
|
||||
headers[PROXY_USER_HEADER] = Buffer.from(JSON.stringify(req.user), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
);
|
||||
}
|
||||
|
||||
function hasRequestBody(method) {
|
||||
return !['GET', 'HEAD'].includes(String(method || '').toUpperCase());
|
||||
}
|
||||
|
||||
function isJsonContentType(contentType) {
|
||||
const normalized = String(contentType || '').toLowerCase();
|
||||
return normalized.includes('application/json') || normalized.includes('+json');
|
||||
}
|
||||
|
||||
function tryParseJsonPayload(buffer) {
|
||||
if (!buffer || buffer.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(buffer.toString('utf8'));
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeJsonPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (payload.success === false && !payload.error && typeof payload.message === 'string' && payload.message) {
|
||||
return {
|
||||
...payload,
|
||||
error: payload.message,
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.use(async (req, res) => {
|
||||
try {
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: buildTargetUrl(req),
|
||||
headers: buildProxyHeaders(req),
|
||||
data: hasRequestBody(req.method) ? req.body : undefined,
|
||||
timeout: getProxyTimeoutMs(),
|
||||
responseType: 'arraybuffer',
|
||||
transformResponse: [(data) => data],
|
||||
validateStatus: () => true,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
|
||||
const contentType = response.headers['content-type'] || '';
|
||||
if (isJsonContentType(contentType)) {
|
||||
const payload = tryParseJsonPayload(response.data);
|
||||
if (payload !== null) {
|
||||
return res.status(response.status).json(normalizeJsonPayload(payload));
|
||||
}
|
||||
}
|
||||
|
||||
if (contentType) {
|
||||
res.set('Content-Type', contentType);
|
||||
}
|
||||
|
||||
return res.status(response.status).send(Buffer.from(response.data || []));
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error: 'Tmall .NET service unavailable',
|
||||
message: error.message || 'Proxy request failed',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.prewarmHotBoards = async () => null;
|
||||
|
||||
module.exports = router;
|
||||
1111
backend/src/config/cloudDatabase.js
Normal file
1111
backend/src/config/cloudDatabase.js
Normal file
File diff suppressed because it is too large
Load Diff
304
backend/src/config/database.js
Normal file
304
backend/src/config/database.js
Normal file
@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 多数据源配置文件
|
||||
*
|
||||
* 架构说明:
|
||||
* ┌─────────────────────────────────────────────────────────┐
|
||||
* │ 应用服务 │
|
||||
* ├─────────────────────┬───────────────────────────────────┤
|
||||
* │ 本地数据库 │ 云数据库 │
|
||||
* │ (localhost) │ (rm-xxx.rds.aliyuncs.com) │
|
||||
* │ │ │
|
||||
* │ ┌──────────────┐ │ ┌────────────────────────────┐ │
|
||||
* │ │ 用户表 │ │ │ Orders (订单表) │ │
|
||||
* │ │ 部门表 │ │ │ Products (产品表) │ │
|
||||
* │ │ 权限表 │ │ │ ... 其他业务表 │ │
|
||||
* │ │ 配置表 │ │ │ │ │
|
||||
* │ │ 预警记录 │ │ │ (千万级数据) │ │
|
||||
* │ └──────────────┘ │ └────────────────────────────┘ │
|
||||
* │ │ │
|
||||
* │ 用途: │ 用途: │
|
||||
* - 用户认证 │ - Cube.js 数据聚合 │
|
||||
* - 权限管理 │ - 报表查询 │
|
||||
* - 系统配置 │ - 预警检查 │
|
||||
* - 预警规则/记录 │ │
|
||||
* └─────────────────────┴───────────────────────────────────┘
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const { getSystemDbRuntimeConfig } = require('../services/systemDbConfig');
|
||||
|
||||
const TRANSIENT_MYSQL_ERROR_CODES = new Set([
|
||||
'ETIMEDOUT',
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'EPIPE',
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
||||
'PROTOCOL_ENQUEUE_AFTER_QUIT',
|
||||
'PROTOCOL_PACKETS_OUT_OF_ORDER',
|
||||
]);
|
||||
|
||||
function parsePositiveInt(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function isTransientMysqlError(error) {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = String(error.message || '').toLowerCase();
|
||||
const code = String(error.code || '');
|
||||
|
||||
return (
|
||||
TRANSIENT_MYSQL_ERROR_CODES.has(code) ||
|
||||
message.includes('timed out') ||
|
||||
message.includes('read etimedout') ||
|
||||
message.includes('connect etimedout') ||
|
||||
message.includes('connection lost') ||
|
||||
message.includes('pool is closed') ||
|
||||
message.includes('closed state') ||
|
||||
message.includes('socket hang up')
|
||||
);
|
||||
}
|
||||
|
||||
const LOCAL_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000);
|
||||
const LOCAL_DB_CONNECTION_LIMIT = parsePositiveInt(process.env.LOCAL_DB_CONNECTION_LIMIT, 6);
|
||||
const LOCAL_DB_QUEUE_LIMIT = parsePositiveInt(process.env.LOCAL_DB_QUEUE_LIMIT, 50);
|
||||
const LOCAL_DB_MAX_IDLE = parsePositiveInt(process.env.LOCAL_DB_MAX_IDLE, LOCAL_DB_CONNECTION_LIMIT);
|
||||
const LOCAL_DB_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_IDLE_TIMEOUT_MS, 60000);
|
||||
const LOCAL_DB_QUERY_RETRY_COUNT = parsePositiveInt(process.env.LOCAL_DB_QUERY_RETRY_COUNT, 1);
|
||||
const LOCAL_DB_QUERY_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_QUERY_TIMEOUT_MS, 60000);
|
||||
|
||||
function withLocalQueryTimeout(sql) {
|
||||
if (!LOCAL_DB_QUERY_TIMEOUT_MS) {
|
||||
return sql;
|
||||
}
|
||||
|
||||
return {
|
||||
sql,
|
||||
timeout: LOCAL_DB_QUERY_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function createCloudPool() {
|
||||
return mysql.createPool({
|
||||
host: process.env.CLOUD_DB_HOST,
|
||||
port: parseInt(process.env.CLOUD_DB_PORT || '3306'),
|
||||
user: process.env.CLOUD_DB_USER,
|
||||
password: process.env.CLOUD_DB_PASSWORD,
|
||||
database: process.env.CLOUD_DB_NAME,
|
||||
charset: 'utf8mb4',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
ssl: process.env.CLOUD_DB_SSL === 'true' ? { rejectUnauthorized: true } : false,
|
||||
});
|
||||
}
|
||||
|
||||
function createLocalPool() {
|
||||
const config = getSystemDbRuntimeConfig();
|
||||
return mysql.createPool({
|
||||
host: config.host,
|
||||
port: parseInt(config.port || '3306', 10),
|
||||
user: config.username,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
charset: 'utf8mb4',
|
||||
waitForConnections: true,
|
||||
connectionLimit: LOCAL_DB_CONNECTION_LIMIT,
|
||||
maxIdle: LOCAL_DB_MAX_IDLE,
|
||||
idleTimeout: LOCAL_DB_IDLE_TIMEOUT_MS,
|
||||
queueLimit: LOCAL_DB_QUEUE_LIMIT,
|
||||
connectTimeout: LOCAL_DB_CONNECT_TIMEOUT_MS,
|
||||
enableKeepAlive: true,
|
||||
keepAliveInitialDelay: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let cloudPool = createCloudPool();
|
||||
let localPool = createLocalPool();
|
||||
|
||||
async function resetPool(source) {
|
||||
const isLocal = source === 'local';
|
||||
const activePool = isLocal ? localPool : cloudPool;
|
||||
|
||||
if (activePool) {
|
||||
try {
|
||||
await activePool.end();
|
||||
} catch (error) {
|
||||
console.warn(`[database:${source}] 重置连接池时关闭失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocal) {
|
||||
localPool = createLocalPool();
|
||||
dataSource.local.pool = localPool;
|
||||
} else {
|
||||
cloudPool = createCloudPool();
|
||||
dataSource.cloud.pool = cloudPool;
|
||||
}
|
||||
}
|
||||
|
||||
async function runQueryWithRetry(source, runner, options = {}) {
|
||||
const maxAttempts = options.maxAttempts || 1;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const activePool = source === 'local' ? localPool : cloudPool;
|
||||
return await runner(activePool);
|
||||
} catch (error) {
|
||||
const shouldRetry = source === 'local'
|
||||
&& attempt < maxAttempts
|
||||
&& isTransientMysqlError(error);
|
||||
|
||||
if (!shouldRetry) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.warn(`[database:${source}] 查询失败,重置连接池后重试 (${attempt}/${maxAttempts}): ${error.message}`);
|
||||
await resetPool(source);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 数据源管理器
|
||||
// ============================================
|
||||
const dataSource = {
|
||||
// 云数据库 (业务数据 - 只读为主)
|
||||
cloud: {
|
||||
name: 'cloud',
|
||||
pool: cloudPool,
|
||||
isReadOnly: true,
|
||||
|
||||
// 测试连接
|
||||
async test() {
|
||||
try {
|
||||
const connection = await cloudPool.getConnection();
|
||||
await connection.ping();
|
||||
connection.release();
|
||||
return { success: true, message: '业务数据库连接成功' };
|
||||
} catch (error) {
|
||||
return { success: false, message: `连接失败: ${error.message}` };
|
||||
}
|
||||
},
|
||||
|
||||
// 执行查询
|
||||
async query(sql, params = []) {
|
||||
const [rows] = await runQueryWithRetry('cloud', (pool) => pool.execute(sql, params));
|
||||
return rows;
|
||||
},
|
||||
},
|
||||
|
||||
// 系统数据库 (系统配置 + 用户数据 + 汇总结果 - 读写)
|
||||
local: {
|
||||
name: 'local',
|
||||
pool: localPool,
|
||||
isReadOnly: false,
|
||||
|
||||
// 测试连接
|
||||
async test() {
|
||||
try {
|
||||
const connection = await runQueryWithRetry(
|
||||
'local',
|
||||
(pool) => pool.getConnection(),
|
||||
{ maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT }
|
||||
);
|
||||
await connection.ping();
|
||||
connection.release();
|
||||
return { success: true, message: '系统数据库连接成功' };
|
||||
} catch (error) {
|
||||
return { success: false, message: `连接失败: ${error.message}` };
|
||||
}
|
||||
},
|
||||
|
||||
// 执行查询
|
||||
async query(sql, params = []) {
|
||||
// 使用 query 而不是 execute,因为 execute 对 LIMIT 参数有限制
|
||||
const [rows] = await runQueryWithRetry(
|
||||
'local',
|
||||
(pool) => pool.query(withLocalQueryTimeout(sql), params),
|
||||
{ maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT }
|
||||
);
|
||||
return rows;
|
||||
},
|
||||
|
||||
// 执行插入 - 支持两种方式:
|
||||
// 1. insert(sql, params) - 原始 SQL
|
||||
// 2. insert(tableName, data) - 表名 + 数据对象
|
||||
async insert(tableOrSql, dataOrParams = []) {
|
||||
let sql, params;
|
||||
|
||||
if (typeof dataOrParams === 'object' && !Array.isArray(dataOrParams)) {
|
||||
// 表名 + 数据对象方式
|
||||
const tableName = tableOrSql;
|
||||
const data = dataOrParams;
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
// 对保留字使用反引号
|
||||
const quotedColumns = columns.map(c => `\`${c}\``).join(', ');
|
||||
sql = `INSERT INTO ${tableName} (${quotedColumns}) VALUES (${placeholders})`;
|
||||
params = values;
|
||||
} else {
|
||||
// 原始 SQL 方式
|
||||
sql = tableOrSql;
|
||||
params = dataOrParams;
|
||||
}
|
||||
|
||||
const [result] = await runQueryWithRetry(
|
||||
'local',
|
||||
(pool) => pool.query(withLocalQueryTimeout(sql), params),
|
||||
{ maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT }
|
||||
);
|
||||
return { insertId: result.insertId };
|
||||
},
|
||||
|
||||
// 执行更新 - 支持两种方式:
|
||||
// 1. update(sql, params) - 原始 SQL
|
||||
// 2. update(tableName, id, data) - 表名 + ID + 数据对象
|
||||
async update(tableOrSql, idOrParams, data) {
|
||||
let sql, params;
|
||||
|
||||
if (data !== undefined && typeof data === 'object') {
|
||||
// 表名 + ID + 数据对象方式
|
||||
const tableName = tableOrSql;
|
||||
const id = idOrParams;
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
// 对保留字使用反引号
|
||||
const setClause = columns.map(c => `\`${c}\` = ?`).join(', ');
|
||||
sql = `UPDATE ${tableName} SET ${setClause} WHERE id = ?`;
|
||||
params = [...values, id];
|
||||
} else {
|
||||
// 原始 SQL 方式
|
||||
sql = tableOrSql;
|
||||
params = idOrParams || [];
|
||||
}
|
||||
|
||||
const [result] = await runQueryWithRetry(
|
||||
'local',
|
||||
(pool) => pool.query(withLocalQueryTimeout(sql), params),
|
||||
{ maxAttempts: LOCAL_DB_QUERY_RETRY_COUNT }
|
||||
);
|
||||
return result.affectedRows;
|
||||
},
|
||||
},
|
||||
|
||||
// 获取指定数据源
|
||||
get(source) {
|
||||
return source === 'cloud' ? this.cloud : this.local;
|
||||
},
|
||||
|
||||
// 关闭所有连接
|
||||
async close() {
|
||||
await cloudPool.end();
|
||||
await localPool.end();
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = dataSource;
|
||||
132
backend/src/config/jianSunDatabase.js
Normal file
132
backend/src/config/jianSunDatabase.js
Normal file
@ -0,0 +1,132 @@
|
||||
const sql = require('mssql');
|
||||
const database = require('./database');
|
||||
|
||||
const CONFIG_NAME = 'jiansun_database';
|
||||
const CONFIG_CACHE_TTL_MS = 30000;
|
||||
|
||||
let pool = null;
|
||||
let poolPromise = null;
|
||||
let poolSignature = '';
|
||||
let cachedConfig = null;
|
||||
let configPromise = null;
|
||||
let configExpiresAt = 0;
|
||||
|
||||
async function loadConfig() {
|
||||
if (cachedConfig && Date.now() < configExpiresAt) return cachedConfig;
|
||||
if (configPromise) return configPromise;
|
||||
|
||||
configPromise = database.local.query(
|
||||
`SELECT host, port, database_name, username, password, \`ssl\`, status
|
||||
FROM database_configs
|
||||
WHERE name = ?
|
||||
LIMIT 1`,
|
||||
[CONFIG_NAME]
|
||||
).then((rows) => {
|
||||
const stored = rows[0];
|
||||
if (stored) {
|
||||
cachedConfig = stored.status === 'inactive'
|
||||
? { disabled: true }
|
||||
: {
|
||||
server: stored.host,
|
||||
port: Number(stored.port || 1433),
|
||||
database: stored.database_name,
|
||||
user: stored.username,
|
||||
password: stored.password,
|
||||
encrypt: stored.ssl === 1 || stored.ssl === true,
|
||||
};
|
||||
} else {
|
||||
cachedConfig = {
|
||||
server: process.env.JIAN_SUN_DB_HOST,
|
||||
port: Number(process.env.JIAN_SUN_DB_PORT || 1433),
|
||||
database: process.env.JIAN_SUN_DB_NAME || 'JianSun',
|
||||
user: process.env.JIAN_SUN_DB_USER,
|
||||
password: process.env.JIAN_SUN_DB_PASSWORD,
|
||||
encrypt: false,
|
||||
};
|
||||
}
|
||||
|
||||
configExpiresAt = Date.now() + CONFIG_CACHE_TTL_MS;
|
||||
return cachedConfig;
|
||||
}).finally(() => {
|
||||
configPromise = null;
|
||||
});
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
function createPoolConfig(config) {
|
||||
return {
|
||||
server: config.server,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
options: {
|
||||
encrypt: config.encrypt,
|
||||
trustServerCertificate: true,
|
||||
},
|
||||
requestTimeout: 120000,
|
||||
connectionTimeout: 120000,
|
||||
pool: {
|
||||
max: 100,
|
||||
min: 0,
|
||||
idleTimeoutMillis: 120000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getPool() {
|
||||
const config = await loadConfig();
|
||||
if (config.disabled) {
|
||||
throw new Error('JianSun database configuration is inactive');
|
||||
}
|
||||
if (!config.server || !config.database || !config.user || !config.password) {
|
||||
throw new Error('JianSun database configuration is incomplete');
|
||||
}
|
||||
|
||||
const signature = [config.server, config.port, config.database, config.user, config.password, config.encrypt].join('|');
|
||||
if (pool?.connected && signature === poolSignature) return pool;
|
||||
if (poolPromise && signature === poolSignature) return poolPromise;
|
||||
if (pool) await pool.close().catch(() => {});
|
||||
|
||||
poolSignature = signature;
|
||||
poolPromise = new sql.ConnectionPool(createPoolConfig(config)).connect()
|
||||
.then((connectedPool) => {
|
||||
pool = connectedPool;
|
||||
return connectedPool;
|
||||
})
|
||||
.catch((error) => {
|
||||
pool = null;
|
||||
poolSignature = '';
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
poolPromise = null;
|
||||
});
|
||||
return poolPromise;
|
||||
}
|
||||
|
||||
async function query(sqlText, params = {}, options = {}) {
|
||||
const activePool = await getPool();
|
||||
const request = activePool.request();
|
||||
if (options.requestTimeout) request.timeout = Number(options.requestTimeout);
|
||||
Object.entries(params).forEach(([key, value]) => request.input(key, value));
|
||||
const result = await request.query(sqlText);
|
||||
return result.recordset || [];
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (poolPromise) await poolPromise.catch(() => {});
|
||||
if (pool) await pool.close().catch(() => {});
|
||||
pool = null;
|
||||
poolPromise = null;
|
||||
poolSignature = '';
|
||||
cachedConfig = null;
|
||||
configPromise = null;
|
||||
configExpiresAt = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_NAME,
|
||||
query,
|
||||
close,
|
||||
};
|
||||
51
backend/src/config/middlePlatformDatabase.js
Normal file
51
backend/src/config/middlePlatformDatabase.js
Normal file
@ -0,0 +1,51 @@
|
||||
const sql = require('mssql');
|
||||
|
||||
let pool = null;
|
||||
|
||||
function getConfig() {
|
||||
return {
|
||||
server: process.env.CLOUD_DB_HOST,
|
||||
port: Number(process.env.CLOUD_DB_PORT || 1433),
|
||||
database: process.env.CLOUD_DB_NAME,
|
||||
user: process.env.CLOUD_DB_USER,
|
||||
password: process.env.CLOUD_DB_PASSWORD,
|
||||
options: {
|
||||
encrypt: String(process.env.CLOUD_DB_SSL || '').toLowerCase() === 'true',
|
||||
trustServerCertificate: true,
|
||||
},
|
||||
pool: {
|
||||
max: 6,
|
||||
min: 0,
|
||||
idleTimeoutMillis: 60000,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
requestTimeout: 60000,
|
||||
};
|
||||
}
|
||||
|
||||
async function getPool() {
|
||||
if (pool?.connected) return pool;
|
||||
if (pool) {
|
||||
await pool.close().catch(() => {});
|
||||
}
|
||||
pool = await new sql.ConnectionPool(getConfig()).connect();
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function query(sqlText, params = {}) {
|
||||
const activePool = await getPool();
|
||||
const request = activePool.request();
|
||||
Object.entries(params).forEach(([key, value]) => request.input(key, value));
|
||||
const result = await request.query(sqlText);
|
||||
return result.recordset || [];
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (pool) await pool.close().catch(() => {});
|
||||
pool = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
query,
|
||||
close,
|
||||
};
|
||||
354
backend/src/config/redis.js
Normal file
354
backend/src/config/redis.js
Normal file
@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Redis 缓存模块
|
||||
* 用于缓存聚合数据,减少数据库查询
|
||||
*/
|
||||
const Redis = require('ioredis');
|
||||
const { getRuntimeRedisConfig } = require('../services/redisConfig');
|
||||
|
||||
let redis = null;
|
||||
let connected = false;
|
||||
let currentConfigSignature = '';
|
||||
let unavailableUntil = 0;
|
||||
let configCache = {
|
||||
expiresAt: 0,
|
||||
value: null,
|
||||
};
|
||||
const REDIS_FAILURE_COOLDOWN_MS = Number.parseInt(process.env.REDIS_FAILURE_COOLDOWN_MS || '30000', 10);
|
||||
const REDIS_CONNECT_TIMEOUT_MS = Number.parseInt(process.env.REDIS_CONNECT_TIMEOUT_MS || '1000', 10);
|
||||
const REDIS_OPERATION_TIMEOUT_MS = Math.min(
|
||||
Math.max(Number.parseInt(process.env.REDIS_OPERATION_TIMEOUT_MS || '500', 10), 100),
|
||||
2000
|
||||
);
|
||||
const REDIS_CONFIG_RESOLVE_TIMEOUT_MS = Math.min(
|
||||
Math.max(Number.parseInt(process.env.REDIS_CONFIG_RESOLVE_TIMEOUT_MS || '1500', 10), 300),
|
||||
3000
|
||||
);
|
||||
|
||||
function withTimeout(promise, timeoutMs, fallback) {
|
||||
let timer;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(fallback), timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildRedisClientConfig(config) {
|
||||
return {
|
||||
host: config.host,
|
||||
port: parseInt(config.port || '6379', 10),
|
||||
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || undefined,
|
||||
password: config.password || undefined,
|
||||
db: parseInt(config.db || '0', 10),
|
||||
connectTimeout: REDIS_CONNECT_TIMEOUT_MS,
|
||||
retryStrategy: (times) => (times > 1 ? null : 200),
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: true,
|
||||
};
|
||||
}
|
||||
|
||||
function getConfigSignature(config) {
|
||||
if (!config?.host) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
host: config.host,
|
||||
port: parseInt(config.port || '6379', 10),
|
||||
db: parseInt(config.db || '0', 10),
|
||||
username: process.env.REDIS_USERNAME || process.env.REDIS_USER || '',
|
||||
password: config.password || '',
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveRedisConfig(force = false) {
|
||||
const now = Date.now();
|
||||
if (!force && configCache.expiresAt > now) {
|
||||
return configCache.value;
|
||||
}
|
||||
|
||||
const config = await getRuntimeRedisConfig();
|
||||
configCache = {
|
||||
value: config,
|
||||
expiresAt: now + 5000,
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
async function closeCurrentClient() {
|
||||
if (!redis) {
|
||||
connected = false;
|
||||
currentConfigSignature = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const client = redis;
|
||||
redis = null;
|
||||
connected = false;
|
||||
currentConfigSignature = '';
|
||||
|
||||
try {
|
||||
await client.quit();
|
||||
} catch (_error) {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureRedisConnection(client) {
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (unavailableUntil > Date.now()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// `connect` only means that the socket is being opened. Sending a command
|
||||
// in that state can wait for ioredis' retry window, so only ready is usable.
|
||||
if (client.status === 'ready') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (client.status === 'wait' || client.status === 'end') {
|
||||
try {
|
||||
await client.connect();
|
||||
unavailableUntil = 0;
|
||||
return true;
|
||||
} catch (error) {
|
||||
unavailableUntil = Date.now() + REDIS_FAILURE_COOLDOWN_MS;
|
||||
connected = false;
|
||||
console.warn('[Redis] 连接失败,进入冷却期:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Redis 实例
|
||||
*/
|
||||
const getRedis = async () => {
|
||||
const redisConfig = await withTimeout(
|
||||
resolveRedisConfig(),
|
||||
REDIS_CONFIG_RESOLVE_TIMEOUT_MS,
|
||||
null
|
||||
);
|
||||
if (!redisConfig) {
|
||||
await closeCurrentClient();
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextSignature = getConfigSignature(redisConfig);
|
||||
if (redis && currentConfigSignature !== nextSignature) {
|
||||
await closeCurrentClient();
|
||||
}
|
||||
|
||||
if (!redis) {
|
||||
redis = new Redis(buildRedisClientConfig(redisConfig));
|
||||
currentConfigSignature = nextSignature;
|
||||
|
||||
redis.on('connect', () => {
|
||||
connected = true;
|
||||
unavailableUntil = 0;
|
||||
console.log('[Redis] 连接成功');
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
connected = false;
|
||||
unavailableUntil = Date.now() + REDIS_FAILURE_COOLDOWN_MS;
|
||||
console.error('[Redis] 连接错误:', err.message);
|
||||
});
|
||||
|
||||
redis.on('close', () => {
|
||||
connected = false;
|
||||
unavailableUntil = Date.now() + REDIS_FAILURE_COOLDOWN_MS;
|
||||
console.log('[Redis] 连接关闭');
|
||||
});
|
||||
}
|
||||
return redis;
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全获取缓存,Redis 不可用时返回 null
|
||||
*/
|
||||
const get = async (key) => {
|
||||
try {
|
||||
const r = await getRedis();
|
||||
if (!r) {
|
||||
return null;
|
||||
}
|
||||
if (!(await ensureRedisConnection(r))) {
|
||||
return null;
|
||||
}
|
||||
const value = await withTimeout(r.get(key), REDIS_OPERATION_TIMEOUT_MS, null);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch (error) {
|
||||
console.warn('[Redis] GET 失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 原子读取并删除缓存。用于一次性票据,避免并发请求重复消费。
|
||||
*/
|
||||
const take = async (key) => {
|
||||
try {
|
||||
const r = await getRedis();
|
||||
if (!r) {
|
||||
return null;
|
||||
}
|
||||
if (!(await ensureRedisConnection(r))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = await withTimeout(
|
||||
r.eval(
|
||||
"local value = redis.call('GET', KEYS[1]); if value then redis.call('DEL', KEYS[1]); end; return value",
|
||||
1,
|
||||
key
|
||||
),
|
||||
REDIS_OPERATION_TIMEOUT_MS,
|
||||
null
|
||||
);
|
||||
return value ? JSON.parse(value) : null;
|
||||
} catch (error) {
|
||||
console.warn('[Redis] TAKE 失败:', error.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 安全设置缓存,Redis 不可用时静默失败
|
||||
*/
|
||||
const setex = async (key, ttl, value) => {
|
||||
try {
|
||||
const r = await getRedis();
|
||||
if (!r) {
|
||||
return false;
|
||||
}
|
||||
if (!(await ensureRedisConnection(r))) {
|
||||
return false;
|
||||
}
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const result = await withTimeout(r.setex(key, ttl, stringValue), REDIS_OPERATION_TIMEOUT_MS, null);
|
||||
return result === 'OK';
|
||||
} catch (error) {
|
||||
console.warn('[Redis] SETEX 失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
*/
|
||||
const del = async (key) => {
|
||||
try {
|
||||
const r = await getRedis();
|
||||
if (!r) {
|
||||
return false;
|
||||
}
|
||||
if (!(await ensureRedisConnection(r))) {
|
||||
return false;
|
||||
}
|
||||
await withTimeout(r.del(key), REDIS_OPERATION_TIMEOUT_MS, null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[Redis] DEL 失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量删除匹配的 key
|
||||
*/
|
||||
const delPattern = async (pattern) => {
|
||||
try {
|
||||
const r = await getRedis();
|
||||
if (!r) {
|
||||
return 0;
|
||||
}
|
||||
if (!(await ensureRedisConnection(r))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let cursor = '0';
|
||||
let deleted = 0;
|
||||
do {
|
||||
const [nextCursor, keys] = await r.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
||||
cursor = nextCursor;
|
||||
if (Array.isArray(keys) && keys.length > 0) {
|
||||
deleted += await r.del(...keys);
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
console.warn('[Redis] DEL PATTERN 失败:', error.message);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
const test = async () => {
|
||||
try {
|
||||
const r = await getRedis();
|
||||
if (!r) {
|
||||
return { success: false, message: '未配置 Redis' };
|
||||
}
|
||||
if (!(await ensureRedisConnection(r))) {
|
||||
return { success: false, message: '连接失败: Redis 当前不可用' };
|
||||
}
|
||||
const pong = await r.ping();
|
||||
return { success: pong === 'PONG', message: pong === 'PONG' ? 'Redis 连接成功' : `连接失败: ${pong}` };
|
||||
} catch (error) {
|
||||
return { success: false, message: `连接失败: ${error.message}` };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
const close = async () => {
|
||||
await closeCurrentClient();
|
||||
console.log('[Redis] 连接已关闭');
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用已保存的运行时配置,无需重启后端进程。
|
||||
*/
|
||||
const reload = async () => {
|
||||
configCache = {
|
||||
expiresAt: 0,
|
||||
value: null,
|
||||
};
|
||||
unavailableUntil = 0;
|
||||
await closeCurrentClient();
|
||||
const client = await getRedis();
|
||||
if (client) {
|
||||
await ensureRedisConnection(client);
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getRedis,
|
||||
get,
|
||||
take,
|
||||
setex,
|
||||
del,
|
||||
delPattern,
|
||||
test,
|
||||
close,
|
||||
reload,
|
||||
get connected() {
|
||||
return connected;
|
||||
},
|
||||
};
|
||||
134
backend/src/config/reportReplicaDatabase.js
Normal file
134
backend/src/config/reportReplicaDatabase.js
Normal file
@ -0,0 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
const sql = require('mssql');
|
||||
const database = require('./database');
|
||||
|
||||
const CONFIG_NAME = 'shujuku2_report_replica';
|
||||
const CONFIG_CACHE_TTL_MS = 30000;
|
||||
|
||||
let pool = null;
|
||||
let poolPromise = null;
|
||||
let poolSignature = '';
|
||||
let cachedConfig = null;
|
||||
let configPromise = null;
|
||||
let configExpiresAt = 0;
|
||||
|
||||
async function loadConfig() {
|
||||
if (cachedConfig && Date.now() < configExpiresAt) return cachedConfig;
|
||||
if (configPromise) return configPromise;
|
||||
|
||||
configPromise = database.local.query(
|
||||
`SELECT host, port, database_name, username, password, \`ssl\`, status
|
||||
FROM database_configs
|
||||
WHERE name = ?
|
||||
LIMIT 1`,
|
||||
[CONFIG_NAME]
|
||||
).then((rows) => {
|
||||
const stored = rows[0];
|
||||
if (stored) {
|
||||
cachedConfig = stored.status === 'inactive'
|
||||
? { disabled: true }
|
||||
: {
|
||||
server: stored.host,
|
||||
port: Number(stored.port || 1433),
|
||||
database: stored.database_name,
|
||||
user: stored.username,
|
||||
password: stored.password,
|
||||
encrypt: stored.ssl === 1 || stored.ssl === true,
|
||||
};
|
||||
} else {
|
||||
cachedConfig = {
|
||||
server: process.env.CLOUD_DB_REPORT_HOST || process.env.CLOUD_DB_PUBLIC_HOST,
|
||||
port: Number(process.env.CLOUD_DB_REPORT_PORT || process.env.CLOUD_DB_PUBLIC_PORT || 1433),
|
||||
database: process.env.CLOUD_DB_REPORT_DATABASE || process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica',
|
||||
user: process.env.CLOUD_DB_REPORT_USER,
|
||||
password: process.env.CLOUD_DB_REPORT_PASSWORD,
|
||||
encrypt: String(process.env.CLOUD_DB_REPORT_SSL || process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
configExpiresAt = Date.now() + CONFIG_CACHE_TTL_MS;
|
||||
return cachedConfig;
|
||||
}).finally(() => {
|
||||
configPromise = null;
|
||||
});
|
||||
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
function createPoolConfig(config) {
|
||||
return {
|
||||
server: config.server,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
options: {
|
||||
encrypt: config.encrypt,
|
||||
trustServerCertificate: true,
|
||||
},
|
||||
requestTimeout: 120000,
|
||||
connectionTimeout: 120000,
|
||||
pool: {
|
||||
max: 8,
|
||||
min: 0,
|
||||
idleTimeoutMillis: 120000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getPool() {
|
||||
const config = await loadConfig();
|
||||
if (config.disabled) throw new Error('报表副本数据库配置已停用');
|
||||
if (!config.server || !config.database || !config.user || !config.password) {
|
||||
throw new Error('报表副本数据库配置不完整');
|
||||
}
|
||||
|
||||
const signature = [config.server, config.port, config.database, config.user, config.password, config.encrypt].join('|');
|
||||
if (pool?.connected && signature === poolSignature) return pool;
|
||||
if (poolPromise && signature === poolSignature) return poolPromise;
|
||||
if (pool) await pool.close().catch(() => {});
|
||||
|
||||
poolSignature = signature;
|
||||
poolPromise = new sql.ConnectionPool(createPoolConfig(config)).connect()
|
||||
.then((connectedPool) => {
|
||||
pool = connectedPool;
|
||||
return connectedPool;
|
||||
})
|
||||
.catch((error) => {
|
||||
pool = null;
|
||||
poolSignature = '';
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
poolPromise = null;
|
||||
});
|
||||
|
||||
return poolPromise;
|
||||
}
|
||||
|
||||
async function query(sqlText, params = {}, options = {}) {
|
||||
const activePool = await getPool();
|
||||
const request = activePool.request();
|
||||
if (options.requestTimeout) request.timeout = Number(options.requestTimeout);
|
||||
Object.entries(params).forEach(([key, value]) => request.input(key, value));
|
||||
const result = await request.query(sqlText);
|
||||
return result.recordset || [];
|
||||
}
|
||||
|
||||
async function close() {
|
||||
if (poolPromise) await poolPromise.catch(() => {});
|
||||
if (pool) await pool.close().catch(() => {});
|
||||
pool = null;
|
||||
poolPromise = null;
|
||||
poolSignature = '';
|
||||
cachedConfig = null;
|
||||
configPromise = null;
|
||||
configExpiresAt = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_NAME,
|
||||
query,
|
||||
close,
|
||||
};
|
||||
57
backend/src/constants/sidebarPages.js
Normal file
57
backend/src/constants/sidebarPages.js
Normal file
@ -0,0 +1,57 @@
|
||||
const ROLES = ['admin', 'analyst', 'user', 'guest'];
|
||||
|
||||
const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false };
|
||||
const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false };
|
||||
|
||||
const buildPlanningPage = ({ pageKey, pageName, path, allowSubPaths = false }) => ({
|
||||
pageKey,
|
||||
pageName,
|
||||
path,
|
||||
category: 'planning',
|
||||
defaultRoles: DEFAULT_PLANNING_ROLES,
|
||||
departmentDefaultAllowed: false,
|
||||
...(allowSubPaths ? { allowSubPaths: true } : {}),
|
||||
});
|
||||
|
||||
const SIDEBAR_PAGES = [
|
||||
buildPlanningPage({ pageKey: 'ops_b_incentive', pageName: '激励看板', path: '/planning/operation/ops-b-incentive' }),
|
||||
buildPlanningPage({
|
||||
pageKey: 'dashboard_preview',
|
||||
pageName: '仪表盘(开发预览)',
|
||||
path: '/planning/dev-style-preview',
|
||||
}),
|
||||
buildPlanningPage({
|
||||
pageKey: 'marketing_events',
|
||||
pageName: '营销活动',
|
||||
path: '/planning/operation/marketing-events',
|
||||
allowSubPaths: true,
|
||||
}),
|
||||
buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }),
|
||||
buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }),
|
||||
buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }),
|
||||
buildPlanningPage({ pageKey: 'store_checkup', pageName: '店铺体检', path: '/planning/operation/store-checkup' }),
|
||||
buildPlanningPage({ pageKey: 'review_analysis', pageName: '复盘分析', path: '/planning/operation/review-analysis' }),
|
||||
buildPlanningPage({ pageKey: 'customer_service_checkup', pageName: '客服质检', path: '/planning/operation/customer-service-checkup' }),
|
||||
buildPlanningPage({ pageKey: 'ai_buyer_show', pageName: 'AI买家秀', path: '/planning/operation/ai-buyer-show' }),
|
||||
buildPlanningPage({ pageKey: 'ai_seeding_video', pageName: 'AI种草视频', path: '/planning/operation/ai-seeding-video' }),
|
||||
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
|
||||
{
|
||||
pageKey: 'settings',
|
||||
pageName: '系统设置',
|
||||
path: '/settings',
|
||||
category: 'system',
|
||||
defaultRoles: DEFAULT_SYSTEM_ROLES,
|
||||
departmentDefaultAllowed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, page) => {
|
||||
acc[page.pageKey] = page;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
module.exports = {
|
||||
ROLES,
|
||||
SIDEBAR_PAGES,
|
||||
SIDEBAR_PAGE_MAP,
|
||||
};
|
||||
1944
backend/src/data/pdd1688PropertyMappings.json
Normal file
1944
backend/src/data/pdd1688PropertyMappings.json
Normal file
File diff suppressed because it is too large
Load Diff
887
backend/src/index.js
Normal file
887
backend/src/index.js
Normal file
@ -0,0 +1,887 @@
|
||||
// 后端服务入口
|
||||
const path = require('path');
|
||||
|
||||
require('dotenv').config();
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { CubejsServer } = require('@cubejs-backend/server');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const installRoutes = require('./api/install');
|
||||
const authMiddleware = require('./middleware/auth');
|
||||
const { createPageDataCache } = require('./middleware/pageDataCache');
|
||||
const adminRoutes = require('./api/admin');
|
||||
const planningUploadRoutes = require('./api/planningUpload');
|
||||
const planningMayProductPublicRoutes = require('./api/planningMayProductPublic');
|
||||
const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal');
|
||||
const presaleExternalRoutes = require('./api/presaleExternal');
|
||||
const delistExternalRoutes = require('./api/delistExternal');
|
||||
const campaignCloudRoutes = require('./api/campaignCloud');
|
||||
const pricingCalculatorExternalRoutes = require('./api/pricingCalculatorExternal');
|
||||
const desktopClientRoutes = require('./api/desktopClient');
|
||||
const desktopPresenceRoutes = require('./api/desktopPresence');
|
||||
const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks');
|
||||
const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal');
|
||||
const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus');
|
||||
const thirdPartySsoRoutes = require('./api/thirdPartySso');
|
||||
const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin');
|
||||
const channelRoutes = require('./api/channel');
|
||||
const analysisRoutes = require('./api/analysis');
|
||||
const productCodeBoardRoutes = require('./api/productCodeBoard');
|
||||
const productAnalysisRoutes = require('./api/productAnalysis');
|
||||
const marketingEventsRoutes = require('./api/marketingEvents');
|
||||
const promotionModuleRoutes = require('./api/promotionModule');
|
||||
const csQualityRoutes = require('./api/csQuality');
|
||||
const aiBuyerShowRoutes = require('./api/aiBuyerShow');
|
||||
const storeCheckupRoutes = require('./api/storeCheckup');
|
||||
const pricingCalculatorRoutes = require('./api/pricingCalculator');
|
||||
const productMonitoringRoutes = require('./api/productMonitoring');
|
||||
const database = require('./config/database');
|
||||
const cloudDb = require('./config/cloudDatabase');
|
||||
const jianSunDb = require('./config/jianSunDatabase');
|
||||
const redis = require('./config/redis');
|
||||
const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh');
|
||||
const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob');
|
||||
const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob');
|
||||
const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob');
|
||||
const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob');
|
||||
const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
|
||||
const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
|
||||
const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
|
||||
const {
|
||||
startAlibaba1688ImageSearchExpiryScheduler,
|
||||
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
|
||||
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
||||
const { ensureLocalSchema } = require('./services/localSchema');
|
||||
const {
|
||||
prewarmChannelHotData,
|
||||
prewarmChannelIdDefaultSnapshots,
|
||||
prewarmChannelIdDashboardDefaultSnapshot,
|
||||
prewarmChannelStockMonitorDashboardDefaultSnapshot,
|
||||
} = require('./services/channelAnalysis');
|
||||
const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis');
|
||||
const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard');
|
||||
const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService');
|
||||
const {
|
||||
getPublicSystemConfigCache,
|
||||
getStalePublicSystemConfigCache,
|
||||
setPublicSystemConfigCache,
|
||||
} = require('./services/publicSystemConfigCache');
|
||||
const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility');
|
||||
const {
|
||||
shouldRequireImageSearchCredentials,
|
||||
verifyImageSearchCredentials,
|
||||
} = require('./services/imageSearchCredentialGuard');
|
||||
|
||||
const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max(
|
||||
Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60,
|
||||
60
|
||||
);
|
||||
const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max(
|
||||
Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000,
|
||||
5 * 1000
|
||||
);
|
||||
const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max(
|
||||
Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300,
|
||||
30
|
||||
) * 1000;
|
||||
const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max(
|
||||
Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800,
|
||||
500
|
||||
);
|
||||
const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '')
|
||||
.trim()
|
||||
.toLowerCase() === 'true';
|
||||
|
||||
function getBuildIdentity() {
|
||||
return {
|
||||
sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null,
|
||||
sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null,
|
||||
};
|
||||
}
|
||||
const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max(
|
||||
Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000,
|
||||
10000
|
||||
);
|
||||
const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max(
|
||||
Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000,
|
||||
10000
|
||||
);
|
||||
|
||||
function shouldCachePlanningUploadResponse(body = {}) {
|
||||
if (!body || body.error || body.success === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = body.data || body;
|
||||
if (payload?.pending || payload?.cache?.backgroundRefresh) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldHandlePlanningUploadPageCache(req) {
|
||||
// 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。
|
||||
// MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。
|
||||
// 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。
|
||||
// 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。
|
||||
// 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。
|
||||
if (
|
||||
req.path === '/'
|
||||
|| req.path.startsWith('/table/may_product_data')
|
||||
|| req.path.startsWith('/analysis/may-channel-insight')
|
||||
|| req.path.startsWith('/analysis/ops-b-incentive')
|
||||
|| req.path.startsWith('/selection-fission')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const analysisPageCache = createPageDataCache({
|
||||
namespace: 'analysis',
|
||||
redisTtlSeconds: 300,
|
||||
});
|
||||
const channelPageCache = createPageDataCache({
|
||||
namespace: 'channel',
|
||||
redisTtlSeconds: 300,
|
||||
// ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。
|
||||
shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path)
|
||||
&& !req.path.startsWith('/export-jobs'),
|
||||
});
|
||||
const planningUploadPageCache = createPageDataCache({
|
||||
namespace: 'planning-upload',
|
||||
version: 'v27-bypass-may-snapshot-pages-20260423',
|
||||
redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS,
|
||||
shouldHandleRequest: shouldHandlePlanningUploadPageCache,
|
||||
shouldCacheBody: shouldCachePlanningUploadResponse,
|
||||
});
|
||||
let serverMode = 'install';
|
||||
|
||||
function isStartupPrewarmEnabled() {
|
||||
return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function runGuardedPrewarm(label, handler) {
|
||||
return runHeavyDatabaseTask(`prewarm:${label}`, handler);
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs, fallback) {
|
||||
let timer = null;
|
||||
return Promise.race([
|
||||
promise.finally(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(fallback), timeoutMs);
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function hasCompleteCloudEnvConfig() {
|
||||
return cloudDb.hasCompleteEnvConfig();
|
||||
}
|
||||
|
||||
function getCloudEnvConfig() {
|
||||
const config = cloudDb.getEnvBusinessDatabaseConfig();
|
||||
return {
|
||||
driver: config.driver || 'mssql',
|
||||
host: config.host,
|
||||
port: config.port || 1433,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
ssl: Boolean(config.ssl),
|
||||
};
|
||||
}
|
||||
|
||||
function getBootstrapCloudConfig() {
|
||||
return {
|
||||
driver: 'mysql',
|
||||
host: process.env.LOCAL_DB_HOST,
|
||||
port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10),
|
||||
database: process.env.LOCAL_DB_NAME,
|
||||
user: process.env.LOCAL_DB_USER,
|
||||
password: process.env.LOCAL_DB_PASSWORD,
|
||||
ssl: false,
|
||||
bootstrap: true,
|
||||
};
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 从数据库获取云数据库配置
|
||||
// =====================
|
||||
async function getCloudDbConfig() {
|
||||
let localPool;
|
||||
try {
|
||||
// 先连接本地数据库读取配置
|
||||
localPool = mysql.createPool({
|
||||
host: process.env.LOCAL_DB_HOST,
|
||||
port: parseInt(process.env.LOCAL_DB_PORT || '3306'),
|
||||
user: process.env.LOCAL_DB_USER,
|
||||
password: process.env.LOCAL_DB_PASSWORD,
|
||||
database: process.env.LOCAL_DB_NAME,
|
||||
charset: 'utf8mb4',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 2,
|
||||
maxIdle: 2,
|
||||
idleTimeout: 60000,
|
||||
queueLimit: 10,
|
||||
connectTimeout: 5000,
|
||||
enableKeepAlive: true,
|
||||
keepAliveInitialDelay: 0,
|
||||
});
|
||||
|
||||
const [rows] = await localPool.query(
|
||||
'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1',
|
||||
['active']
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const config = rows[0];
|
||||
if (config.use_env_config === 1 || config.use_env_config === true) {
|
||||
if (hasCompleteCloudEnvConfig()) {
|
||||
console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)');
|
||||
return getCloudEnvConfig();
|
||||
}
|
||||
console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置');
|
||||
}
|
||||
|
||||
console.log('📌 使用数据库配置(来自 database_configs 表)');
|
||||
return {
|
||||
driver: config.driver || 'mysql',
|
||||
host: config.host,
|
||||
port: config.port || 3306,
|
||||
database: config.database_name,
|
||||
user: config.username,
|
||||
password: config.password,
|
||||
ssl: config.ssl === 1,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 无法从数据库读取配置:', error.message);
|
||||
} finally {
|
||||
if (localPool) {
|
||||
await localPool.end().catch((error) => {
|
||||
console.warn('⚠️ 关闭系统库临时连接池失败:', error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到环境变量配置
|
||||
if (hasCompleteCloudEnvConfig()) {
|
||||
console.log('📌 使用环境变量配置(CLOUD_DB_*)');
|
||||
return getCloudEnvConfig();
|
||||
}
|
||||
|
||||
console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式');
|
||||
return getBootstrapCloudConfig();
|
||||
}
|
||||
|
||||
function applyCommonMiddleware(app) {
|
||||
app.set('trust proxy', 1);
|
||||
app.use(cors());
|
||||
app.use(express.json({
|
||||
limit: '15mb',
|
||||
verify: (req, _res, buffer) => {
|
||||
req.rawBody = buffer.toString('utf8');
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) {
|
||||
app.get('/api/admin/system-config', async (_req, res) => {
|
||||
const now = Date.now();
|
||||
const cachedConfig = getPublicSystemConfigCache(now);
|
||||
if (cachedConfig) {
|
||||
return res.json(cachedConfig);
|
||||
}
|
||||
|
||||
try {
|
||||
const configs = await Promise.race([
|
||||
database.local.query('SELECT config_key, config_value, config_type FROM system_config'),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
const result = {};
|
||||
filterPublicSystemConfigs(configs).forEach((config) => {
|
||||
if (config.config_type === 'boolean') {
|
||||
result[config.config_key] = config.config_value === 'true' || config.config_value === '1';
|
||||
} else if (config.config_type === 'number') {
|
||||
result[config.config_key] = Number(config.config_value);
|
||||
} else {
|
||||
result[config.config_key] = config.config_value;
|
||||
}
|
||||
});
|
||||
setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
const staleConfig = getStalePublicSystemConfigCache();
|
||||
if (staleConfig) {
|
||||
return res.json(staleConfig);
|
||||
}
|
||||
|
||||
if (serverMode === 'install' || serverMode === 'database_unavailable') {
|
||||
return res.json({
|
||||
site_name: fallbackConfig.siteName,
|
||||
site_description: fallbackConfig.siteDescription,
|
||||
logo: '',
|
||||
});
|
||||
}
|
||||
|
||||
console.warn('获取系统配置超时或失败,返回兜底配置:', error.message);
|
||||
return res.json({
|
||||
site_name: fallbackConfig.siteName,
|
||||
site_description: fallbackConfig.siteDescription,
|
||||
logo: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerInstallModeRoutes(app, installStatus) {
|
||||
const unavailableHandler = (_req, res) => {
|
||||
res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' });
|
||||
};
|
||||
|
||||
app.get('/health', async (_req, res) => {
|
||||
const status = await getInstallStatus();
|
||||
res.json({
|
||||
status: 'install_required',
|
||||
timestamp: new Date().toISOString(),
|
||||
build: getBuildIdentity(),
|
||||
install: status,
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/install', installRoutes);
|
||||
registerPublicSystemConfigRoute(app);
|
||||
|
||||
app.get('/api/auth/providers', (_req, res) => {
|
||||
res.json({
|
||||
authBaseUrl: '',
|
||||
dingtalkEnabled: false,
|
||||
googleEnabled: false,
|
||||
installRequired: true,
|
||||
});
|
||||
});
|
||||
app.get('/api/auth/dingtalk/login', unavailableHandler);
|
||||
app.get('/api/auth/google/login', unavailableHandler);
|
||||
app.post('/api/auth/login', unavailableHandler);
|
||||
app.post('/api/auth/callback', unavailableHandler);
|
||||
app.get('/api/auth/verify', (_req, res) => {
|
||||
res.status(401).json({ valid: false, installRequired: true });
|
||||
});
|
||||
|
||||
app.use('/api/admin', (_req, res) => {
|
||||
res.status(503).json({
|
||||
error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号',
|
||||
});
|
||||
});
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('🛠️ 系统已进入首次安装模式');
|
||||
console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
|
||||
console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`);
|
||||
console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`);
|
||||
console.log('='.repeat(50));
|
||||
}
|
||||
|
||||
async function startInstallServer(installStatus) {
|
||||
serverMode = 'install';
|
||||
const app = express();
|
||||
applyCommonMiddleware(app);
|
||||
registerInstallModeRoutes(app, installStatus);
|
||||
|
||||
const port = parseInt(process.env.PORT || '4000', 10);
|
||||
await new Promise((resolve) => {
|
||||
app.listen(port, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function registerDatabaseUnavailableRoutes(app, installStatus) {
|
||||
const unavailableHandler = (_req, res) => {
|
||||
res.status(503).json({
|
||||
error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务',
|
||||
databaseUnavailable: true,
|
||||
installRequired: false,
|
||||
});
|
||||
};
|
||||
|
||||
app.get('/health', async (_req, res) => {
|
||||
const status = await getInstallStatus();
|
||||
res.status(503).json({
|
||||
status: 'database_unavailable',
|
||||
timestamp: new Date().toISOString(),
|
||||
build: getBuildIdentity(),
|
||||
install: status,
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/install', installRoutes);
|
||||
registerPublicSystemConfigRoute(app);
|
||||
|
||||
app.get('/api/auth/providers', (_req, res) => {
|
||||
res.json({
|
||||
authBaseUrl: '',
|
||||
dingtalkEnabled: false,
|
||||
googleEnabled: false,
|
||||
installRequired: false,
|
||||
databaseUnavailable: true,
|
||||
});
|
||||
});
|
||||
app.get('/api/auth/verify', (_req, res) => {
|
||||
res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false });
|
||||
});
|
||||
app.post('/api/auth/login', unavailableHandler);
|
||||
app.post('/api/auth/callback', unavailableHandler);
|
||||
app.get('/api/auth/dingtalk/login', unavailableHandler);
|
||||
app.get('/api/auth/google/login', unavailableHandler);
|
||||
app.use('/api', unavailableHandler);
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('🚧 系统数据库暂时无法连接,已进入降级模式');
|
||||
console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
|
||||
console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`);
|
||||
console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务');
|
||||
console.log('='.repeat(50));
|
||||
}
|
||||
|
||||
async function startDatabaseUnavailableServer(installStatus) {
|
||||
serverMode = 'database_unavailable';
|
||||
const app = express();
|
||||
applyCommonMiddleware(app);
|
||||
registerDatabaseUnavailableRoutes(app, installStatus);
|
||||
|
||||
const port = parseInt(process.env.PORT || '4000', 10);
|
||||
await new Promise((resolve) => {
|
||||
app.listen(port, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function createFullModeApp(cloudConfig) {
|
||||
const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql';
|
||||
console.log(`📊 数据库类型: ${dbType}`);
|
||||
|
||||
const port = parseInt(process.env.PORT || '4000', 10);
|
||||
|
||||
if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') {
|
||||
console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务');
|
||||
const app = express();
|
||||
await new Promise((resolve) => {
|
||||
app.listen(port, resolve);
|
||||
});
|
||||
|
||||
return {
|
||||
app,
|
||||
cubeMode: 'disabled',
|
||||
cubeError: 'DISABLE_CUBEJS=true',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const cubejsServer = new CubejsServer({
|
||||
schemaPath: 'model',
|
||||
dbType: dbType,
|
||||
logger: (msg, params) => {
|
||||
console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : '');
|
||||
},
|
||||
driverFactory: () => {
|
||||
// 根据 driver 类型选择驱动
|
||||
if (cloudConfig.driver === 'mssql') {
|
||||
const MSSQLDriver = require('@cubejs-backend/mssql-driver');
|
||||
return new MSSQLDriver({
|
||||
server: cloudConfig.host,
|
||||
port: cloudConfig.port,
|
||||
database: cloudConfig.database,
|
||||
user: cloudConfig.user,
|
||||
password: cloudConfig.password,
|
||||
options: {
|
||||
encrypt: cloudConfig.ssl,
|
||||
trustServerCertificate: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 默认使用 MySQL
|
||||
const MySQLDriver = require('@cubejs-backend/mysql-driver');
|
||||
return new MySQLDriver({
|
||||
database: cloudConfig.database,
|
||||
host: cloudConfig.host,
|
||||
port: cloudConfig.port,
|
||||
user: cloudConfig.user,
|
||||
password: cloudConfig.password,
|
||||
ssl: cloudConfig.ssl,
|
||||
});
|
||||
},
|
||||
apiSecret: process.env.CUBEJS_API_SECRET,
|
||||
scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER,
|
||||
jwt: {
|
||||
key: process.env.CUBEJS_SECRET,
|
||||
algorithms: ['HS256'],
|
||||
},
|
||||
});
|
||||
|
||||
const { app } = await cubejsServer.listen();
|
||||
return {
|
||||
app,
|
||||
cubeMode: 'cube',
|
||||
cubeError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message);
|
||||
const app = express();
|
||||
await new Promise((resolve) => {
|
||||
app.listen(port, resolve);
|
||||
});
|
||||
|
||||
return {
|
||||
app,
|
||||
cubeMode: 'express-fallback',
|
||||
cubeError: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// 启动服务
|
||||
// =====================
|
||||
async function startServer(options = {}) {
|
||||
const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true';
|
||||
const { skipLocalSchema = envSkipLocalSchema } = options;
|
||||
serverMode = 'full';
|
||||
if (skipLocalSchema) {
|
||||
console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API');
|
||||
} else {
|
||||
await ensureLocalSchema();
|
||||
}
|
||||
|
||||
if (shouldRequireImageSearchCredentials()) {
|
||||
const credentialStatus = await verifyImageSearchCredentials();
|
||||
console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`);
|
||||
}
|
||||
|
||||
// 获取云数据库配置
|
||||
const cloudConfig = await getCloudDbConfig();
|
||||
if (cloudConfig.bootstrap) {
|
||||
console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`);
|
||||
} else {
|
||||
console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`);
|
||||
}
|
||||
|
||||
// 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。
|
||||
const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig);
|
||||
|
||||
applyCommonMiddleware(cubejsApp);
|
||||
|
||||
// =====================
|
||||
// 健康检查 (多数据源)
|
||||
// =====================
|
||||
cubejsApp.get('/health', async (_req, res) => {
|
||||
const [systemTest, businessTest, redisTest] = await Promise.all([
|
||||
withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }),
|
||||
withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }),
|
||||
withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
build: getBuildIdentity(),
|
||||
databases: {
|
||||
system: systemTest,
|
||||
local: systemTest,
|
||||
cloud: businessTest,
|
||||
},
|
||||
services: {
|
||||
redis: redisTest,
|
||||
cube: {
|
||||
success: cubeMode === 'cube',
|
||||
mode: cubeMode,
|
||||
message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// =====================
|
||||
// 认证 API
|
||||
// =====================
|
||||
cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders);
|
||||
cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin);
|
||||
cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin);
|
||||
cubejsApp.post('/api/auth/login', require('./api/auth').login);
|
||||
cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview);
|
||||
cubejsApp.post('/api/auth/callback', require('./api/auth').callback);
|
||||
cubejsApp.get('/api/auth/verify', require('./api/auth').verify);
|
||||
|
||||
// =====================
|
||||
// 公开访问的 API(无需认证)
|
||||
// =====================
|
||||
cubejsApp.use('/api/install', installRoutes);
|
||||
cubejsApp.use('/api/sso', thirdPartySsoRoutes);
|
||||
cubejsApp.use('/api/desktop-client', desktopClientRoutes);
|
||||
cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes);
|
||||
cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes);
|
||||
cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes);
|
||||
cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes);
|
||||
cubejsApp.use('/api/campaign', campaignCloudRoutes);
|
||||
cubejsApp.use('/api/external/selection-fission/pricing-calculator', pricingCalculatorExternalRoutes);
|
||||
cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes);
|
||||
cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes);
|
||||
cubejsApp.use('/api/external/presale', presaleExternalRoutes);
|
||||
|
||||
// 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo)
|
||||
registerPublicSystemConfigRoute(cubejsApp);
|
||||
|
||||
// =====================
|
||||
// 需要认证的 API
|
||||
// =====================
|
||||
cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes);
|
||||
cubejsApp.use('/api/admin', authMiddleware, adminRoutes);
|
||||
cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes);
|
||||
cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes);
|
||||
cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes);
|
||||
cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
|
||||
cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
|
||||
cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
|
||||
cubejsApp.use('/api/cs-quality', authMiddleware, csQualityRoutes);
|
||||
cubejsApp.use('/api/ai-buyer-show', authMiddleware, aiBuyerShowRoutes);
|
||||
cubejsApp.use('/api/store-checkup', authMiddleware, storeCheckupRoutes);
|
||||
cubejsApp.use('/api/pricing-calculator', authMiddleware, pricingCalculatorRoutes);
|
||||
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
|
||||
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
|
||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||
|
||||
// =====================
|
||||
// 数据分析 API(公开访问,可根据需要添加认证)
|
||||
// =====================
|
||||
cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes);
|
||||
|
||||
// =====================
|
||||
// 数据刷新 API(需要认证)
|
||||
// =====================
|
||||
cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { date } = req.body;
|
||||
console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`);
|
||||
const result = await manualRefresh(date);
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// =====================
|
||||
// 重新加载云数据库配置 API
|
||||
// =====================
|
||||
cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
console.log('🔄 正在重新加载云数据库配置...');
|
||||
res.json({
|
||||
success: true,
|
||||
message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('🚀 后端服务已启动');
|
||||
console.log(`📡 服务地址: http://localhost:4000`);
|
||||
console.log(cubeMode === 'cube'
|
||||
? '📊 Cube.js API: http://localhost:4000/cubejs-api'
|
||||
: '📊 Cube.js API: 已降级不可用,业务 API 继续运行');
|
||||
|
||||
// 测试数据库连接
|
||||
console.log('\n📦 数据库连接测试:');
|
||||
const [systemTest, businessTest, redisTest] = await Promise.all([
|
||||
withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }),
|
||||
withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }),
|
||||
withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }),
|
||||
]);
|
||||
console.log(` [系统] ${systemTest.message}`);
|
||||
console.log(` [业务] ${businessTest.message}`);
|
||||
console.log(` [缓存] ${redisTest.message}`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
startScheduler();
|
||||
startStockSelectionSchedulers();
|
||||
startTaobaoProductEmbeddingSchedulers();
|
||||
startPddSkuDimensionSnapshotSchedulers();
|
||||
startPlanningMayProductSnapshotSchedulers();
|
||||
startPlanningDingWorkflowSyncSchedulers();
|
||||
startOpsBIncentiveSnapshotSchedulers();
|
||||
startGoodsFirstSeenScheduler();
|
||||
startAlibaba1688ImageSearchExpiryScheduler();
|
||||
|
||||
// Keep the first selection-pool page request from paying the remote schema
|
||||
// inspection round-trip over the planning database tunnel.
|
||||
setTimeout(() => {
|
||||
warmSelectionFissionReadSchema()
|
||||
.then(() => console.log('[SelectionFission] 列表读取结构预热完成'))
|
||||
.catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message));
|
||||
}, 1000);
|
||||
|
||||
if (isStartupPrewarmEnabled()) {
|
||||
setTimeout(() => {
|
||||
if (typeof prewarmChannelHotData === 'function') {
|
||||
runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => {
|
||||
console.warn('⚠️ 渠道默认查询预热失败:', error.message);
|
||||
});
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
setTimeout(() => {
|
||||
if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') {
|
||||
runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => {
|
||||
console.warn('⚠️ 分析页默认查询预热失败:', error.message);
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
|
||||
|
||||
if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') {
|
||||
const analysisPrewarmTimer = setInterval(() => {
|
||||
runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => {
|
||||
console.warn('⚠️ 分析页默认查询定时预热失败:', error.message);
|
||||
});
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
if (typeof analysisPrewarmTimer.unref === 'function') {
|
||||
analysisPrewarmTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} else {
|
||||
console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启');
|
||||
}
|
||||
|
||||
if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') {
|
||||
if (typeof prewarmChannelIdDefaultSnapshots === 'function') {
|
||||
setTimeout(() => {
|
||||
runGuardedPrewarm(
|
||||
'channel-id-analysis-resilience',
|
||||
() => prewarmChannelIdDefaultSnapshots({ skipCoverage: true })
|
||||
).catch((error) => {
|
||||
console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message);
|
||||
});
|
||||
}, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS);
|
||||
}
|
||||
|
||||
if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') {
|
||||
setTimeout(() => {
|
||||
runGuardedPrewarm(
|
||||
'channel-id-dashboard-resilience',
|
||||
() => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true })
|
||||
).catch((error) => {
|
||||
console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message);
|
||||
});
|
||||
}, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
runGuardedPrewarm(
|
||||
'channel-stock-monitor-resilience',
|
||||
() => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true })
|
||||
).catch((error) => {
|
||||
console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message);
|
||||
});
|
||||
}, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS);
|
||||
}
|
||||
|
||||
if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false'
|
||||
&& typeof prewarmBrandAnalysisDefault === 'function') {
|
||||
setTimeout(() => {
|
||||
runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault())
|
||||
.then((result) => {
|
||||
console.log(
|
||||
`[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message);
|
||||
});
|
||||
}, BRAND_ANALYSIS_PREWARM_DELAY_MS);
|
||||
}
|
||||
|
||||
const cron = require('node-cron');
|
||||
|
||||
if (typeof prewarmChannelHotData === 'function') {
|
||||
cron.schedule('0 1 * * *', async () => {
|
||||
console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...');
|
||||
await runGuardedPrewarm(
|
||||
'channel-hot-data:daily',
|
||||
() => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true })
|
||||
);
|
||||
}, {
|
||||
timezone: 'Asia/Shanghai',
|
||||
});
|
||||
console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const alertChecker = require('./services/alertChecker');
|
||||
|
||||
cron.schedule('*/5 * * * *', async () => {
|
||||
console.log('🔔 执行预警检查...');
|
||||
await alertChecker.checkAllRules();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const installStatus = await getInstallStatus();
|
||||
if (installStatus.required) {
|
||||
await startInstallServer(installStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
if (installStatus.databaseUnavailable) {
|
||||
console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`);
|
||||
await startServer({ skipLocalSchema: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await startServer();
|
||||
}
|
||||
|
||||
bootstrap().catch((error) => {
|
||||
console.error('❌ 服务启动失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
async function shutdownGracefully() {
|
||||
await Promise.allSettled([
|
||||
cloudDb.close(),
|
||||
jianSunDb.close(),
|
||||
database.close(),
|
||||
redis.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 优雅关闭
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('正在关闭服务...');
|
||||
await shutdownGracefully();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('正在关闭服务...');
|
||||
await shutdownGracefully();
|
||||
process.exit(0);
|
||||
});
|
||||
132
backend/src/jobs/alibaba1688ImageSearchExpiryJob.js
Normal file
132
backend/src/jobs/alibaba1688ImageSearchExpiryJob.js
Normal file
@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const {
|
||||
getAlibaba1688ImageSearchRuntimeConfig,
|
||||
getCredentialValidity,
|
||||
getExpiryNotificationAdminStatus,
|
||||
} = require('../services/alibaba1688ImageSearchSettings');
|
||||
const { sendYydNotification } = require('../services/yydNotificationCenterClient');
|
||||
|
||||
const DEFAULT_CRON = '0 9 * * *';
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function selectExpiryAlertStage(validity = {}) {
|
||||
if (!validity.tracked || !Number.isFinite(Number(validity.remainingMs))) return '';
|
||||
const remainingMs = Number(validity.remainingMs);
|
||||
if (remainingMs <= 0) return 'expired';
|
||||
if (remainingMs <= DAY_MS) return 'one_day';
|
||||
if (remainingMs <= 2 * DAY_MS) return 'two_days';
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatChinaTime(value) {
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return '未记录';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function buildExpiryNotification({ validity, stage, recipientAlias }) {
|
||||
const expired = stage === 'expired';
|
||||
const remainingHours = expired ? 0 : Math.max(1, Math.ceil(Number(validity.remainingMs) / (60 * 60 * 1000)));
|
||||
const remainingText = expired
|
||||
? '已过期'
|
||||
: remainingHours >= 24
|
||||
? `约 ${Math.ceil(remainingHours / 24)} 天`
|
||||
: `约 ${remainingHours} 小时`;
|
||||
const expiryKey = String(validity.expiresAt || '').replace(/\D/g, '').slice(0, 14) || 'unknown';
|
||||
const actionUrl = String(
|
||||
process.env.ALIBABA_1688_IMAGE_SEARCH_SETTINGS_URL
|
||||
|| 'https://pddg.jiansuntuzhi.cn/settings?tab=alibaba1688ImageSearch'
|
||||
).trim();
|
||||
return {
|
||||
idempotencyKey: `alibaba1688_image_search_key_expiry:${expiryKey}:${recipientAlias}:${stage}:v1`,
|
||||
recipientAlias,
|
||||
title: expired ? '1688以图搜款 API Key 已过期' : '1688以图搜款 API Key 即将到期',
|
||||
content: [
|
||||
'**配置项:** 1688 以图搜款 API Key',
|
||||
`**剩余有效时间:** ${remainingText}`,
|
||||
`**到期时间:** ${formatChinaTime(validity.expiresAt)}`,
|
||||
'',
|
||||
'> 请及时在系统设置中替换新 Key,填写新 Key 并保存后会重新计算 7 天有效期。',
|
||||
].join('\n\n'),
|
||||
actionTitle: '立即替换',
|
||||
actionUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkAlibaba1688ImageSearchExpiry(options = {}) {
|
||||
const now = options.now === undefined ? Date.now() : Number(options.now);
|
||||
const config = await getAlibaba1688ImageSearchRuntimeConfig({ forceRefresh: true });
|
||||
const validity = getCredentialValidity(config, now);
|
||||
const notification = await getExpiryNotificationAdminStatus();
|
||||
const stage = selectExpiryAlertStage(validity);
|
||||
if (!stage) {
|
||||
return { sent: false, reason: validity.status, validity };
|
||||
}
|
||||
if (!notification.configured) {
|
||||
return { sent: false, reason: 'notification_not_configured', stage, validity };
|
||||
}
|
||||
|
||||
const payload = buildExpiryNotification({
|
||||
validity,
|
||||
stage,
|
||||
recipientAlias: notification.recipientAlias,
|
||||
});
|
||||
const response = await sendYydNotification(payload);
|
||||
return {
|
||||
sent: true,
|
||||
stage,
|
||||
deliveryId: response?.id || response?.data?.id || null,
|
||||
validity,
|
||||
};
|
||||
}
|
||||
|
||||
async function runAlibaba1688ImageSearchExpiryJob(source = 'scheduled') {
|
||||
try {
|
||||
const result = await checkAlibaba1688ImageSearchExpiry();
|
||||
if (result.sent) {
|
||||
console.log(`[Alibaba1688ImageSearchExpiry] ${source} notification accepted: ${result.stage}`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[Alibaba1688ImageSearchExpiry] ${source} check failed:`, error.message || error);
|
||||
return { sent: false, reason: 'error', error: error.message || String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function startAlibaba1688ImageSearchExpiryScheduler() {
|
||||
const disabled = String(process.env.DISABLE_ALIBABA_1688_IMAGE_SEARCH_EXPIRY_NOTIFICATION || '')
|
||||
.trim()
|
||||
.toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[Scheduler] 1688以图搜款 Key 到期提醒已禁用');
|
||||
return;
|
||||
}
|
||||
const expression = String(process.env.ALIBABA_1688_IMAGE_SEARCH_EXPIRY_CRON || DEFAULT_CRON).trim();
|
||||
cron.schedule(expression, () => {
|
||||
runAlibaba1688ImageSearchExpiryJob('daily_schedule').catch(() => {});
|
||||
}, { timezone: TIMEZONE });
|
||||
const startupTimer = setTimeout(() => {
|
||||
runAlibaba1688ImageSearchExpiryJob('startup').catch(() => {});
|
||||
}, 30 * 1000);
|
||||
if (typeof startupTimer.unref === 'function') startupTimer.unref();
|
||||
console.log(`[Scheduler] 1688以图搜款 Key 到期提醒已注册: ${expression} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildExpiryNotification,
|
||||
checkAlibaba1688ImageSearchExpiry,
|
||||
runAlibaba1688ImageSearchExpiryJob,
|
||||
selectExpiryAlertStage,
|
||||
startAlibaba1688ImageSearchExpiryScheduler,
|
||||
};
|
||||
2245
backend/src/jobs/dailyRefresh.js
Normal file
2245
backend/src/jobs/dailyRefresh.js
Normal file
File diff suppressed because it is too large
Load Diff
80
backend/src/jobs/followUpMonitorJob.js
Normal file
80
backend/src/jobs/followUpMonitorJob.js
Normal file
@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
syncFollowUpMonitorDailySnapshot,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
} = require('../services/followUpMonitorSync');
|
||||
|
||||
let running = false;
|
||||
const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.FOLLOW_UP_MONITOR_LOCK_WAIT_SECONDS || '1', 10), 0);
|
||||
const JOB_ENABLED = String(process.env.FOLLOW_UP_MONITOR_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const JOB_CRON = process.env.FOLLOW_UP_MONITOR_CRON || '20 6 * * *';
|
||||
|
||||
async function withGlobalJobLock(lockKey, trigger, runner) {
|
||||
const connection = await database.local.pool.getConnection();
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]);
|
||||
const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0;
|
||||
lockAcquired = lockStatus === 1;
|
||||
|
||||
if (!lockAcquired) {
|
||||
console.log(`[FollowUpMonitor] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`);
|
||||
return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey };
|
||||
}
|
||||
|
||||
return runner();
|
||||
} finally {
|
||||
try {
|
||||
if (lockAcquired) {
|
||||
await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]);
|
||||
}
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runFollowUpMonitorJob(trigger = 'manual', options = {}) {
|
||||
if (running) {
|
||||
console.log(`[FollowUpMonitor] job is already running, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'running', trigger };
|
||||
}
|
||||
|
||||
running = true;
|
||||
try {
|
||||
return await withGlobalJobLock('follow_up_monitor_daily', trigger, async () => {
|
||||
const result = await syncFollowUpMonitorDailySnapshot({
|
||||
targetDate: options.targetDate || null,
|
||||
goodsIds: Array.isArray(options.goodsIds) ? options.goodsIds : null,
|
||||
requestTimeout: options.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
return { ...result, trigger };
|
||||
});
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startFollowUpMonitorSchedulers() {
|
||||
if (!JOB_ENABLED) {
|
||||
console.log('[Scheduler] 跟进监控日任务未启用');
|
||||
return;
|
||||
}
|
||||
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runFollowUpMonitorJob('daily_schedule').catch((error) => {
|
||||
console.error('[FollowUpMonitor] scheduled job failed', error);
|
||||
});
|
||||
}, { timezone: 'Asia/Shanghai' });
|
||||
|
||||
console.log(`[Scheduler] 跟进监控日任务已注册: ${JOB_CRON} (Asia/Shanghai)`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startFollowUpMonitorSchedulers,
|
||||
runFollowUpMonitorJob,
|
||||
};
|
||||
164
backend/src/jobs/goodsFirstSeenJob.js
Normal file
164
backend/src/jobs/goodsFirstSeenJob.js
Normal file
@ -0,0 +1,164 @@
|
||||
const cron = require('node-cron');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const planningDb = require('../services/planningDatabase');
|
||||
const { pickSkuCaptureTime } = require('../services/opsBIncentiveBoardService');
|
||||
|
||||
const JOB_CRON = String(process.env.GOODS_FIRST_SEEN_CRON || '20 5 * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const SYNC_CHUNK_SIZE = 500;
|
||||
const BACKFILL_CHUNK_SIZE = 800;
|
||||
const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily';
|
||||
|
||||
let schemaEnsured = false;
|
||||
|
||||
async function ensureTable() {
|
||||
if (schemaEnsured) return;
|
||||
await planningDb.query(`
|
||||
CREATE TABLE IF NOT EXISTS pdd_goods_first_seen (
|
||||
goods_id VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||
first_created_at DATETIME NOT NULL COMMENT '首次采集到的SKU创建时间(约等于上架时间)',
|
||||
last_capture_date DATE NOT NULL COMMENT '最近一次出现在采集批次的日期',
|
||||
is_onsale TINYINT NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
schemaEnsured = true;
|
||||
}
|
||||
|
||||
function epochToDatetime(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) return null;
|
||||
const ms = num >= 1e12 ? num : num * 1000;
|
||||
const date = new Date(ms);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// 同步单个采集批次:新商品写入首见时间,已存在商品只刷新最近采集日/在售状态(保留最初 first_created_at)
|
||||
async function syncCaptureDate(captureDate) {
|
||||
await ensureTable();
|
||||
const startedAt = Date.now();
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goodsId,
|
||||
MIN(s.createdAt) AS minCreatedAt,
|
||||
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index))
|
||||
WHERE s.createTime = @captureDate
|
||||
AND s.goodsId IS NOT NULL AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) <> ''
|
||||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId)))`,
|
||||
{ captureDate },
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const records = [];
|
||||
rows.forEach((row) => {
|
||||
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||
if (!createdAt) return;
|
||||
records.push([String(row.goodsId), createdAt, captureDate, Number(row.onsale) === 1 ? 1 : 0]);
|
||||
});
|
||||
|
||||
const countBefore = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||
for (let offset = 0; offset < records.length; offset += SYNC_CHUNK_SIZE) {
|
||||
const chunk = records.slice(offset, offset + SYNC_CHUNK_SIZE);
|
||||
await planningDb.query(
|
||||
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_capture_date = VALUES(last_capture_date),
|
||||
is_onsale = VALUES(is_onsale)`,
|
||||
[chunk]
|
||||
);
|
||||
}
|
||||
const countAfter = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||
|
||||
return {
|
||||
captureDate,
|
||||
goodsInBatch: records.length,
|
||||
upserted: records.length,
|
||||
newGoods: Math.max(0, Number(countAfter[0]?.n || 0) - Number(countBefore[0]?.n || 0)),
|
||||
total: Number(countAfter[0]?.n || 0),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// 每日增量:取最近一个完整采集批次(复用激励看板的 7 天完整性校验)
|
||||
async function refreshLatestCapture() {
|
||||
const captureDate = await pickSkuCaptureTime(new Date().toISOString().slice(0, 10));
|
||||
if (!captureDate) {
|
||||
console.warn('[GoodsFirstSeen] 近7天无可用完整采集批次,跳过本次同步');
|
||||
return { skipped: true };
|
||||
}
|
||||
return syncCaptureDate(captureDate);
|
||||
}
|
||||
|
||||
// 历史回填:对当前快照表里的全部商品,走 goodsId 索引精确点查首次创建时间(不受采集历史跨度限制)
|
||||
async function backfillAllHistory() {
|
||||
await ensureTable();
|
||||
const startedAt = Date.now();
|
||||
const goodsRows = await planningDb.query(
|
||||
`SELECT DISTINCT goodsId FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})`
|
||||
);
|
||||
const goodsIds = [...new Set(goodsRows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))];
|
||||
console.log(`[GoodsFirstSeen] 历史回填开始,快照商品 ${goodsIds.length} 个`);
|
||||
|
||||
const countBefore = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||
let synced = 0;
|
||||
for (let offset = 0; offset < goodsIds.length; offset += BACKFILL_CHUNK_SIZE) {
|
||||
const chunk = goodsIds.slice(offset, offset + BACKFILL_CHUNK_SIZE);
|
||||
const params = {};
|
||||
const placeholders = chunk.map((goodsId, index) => {
|
||||
params[`g${index}`] = Number(goodsId);
|
||||
return `@g${index}`;
|
||||
}).join(', ');
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT s.goodsId AS goodsId,
|
||||
MIN(TRY_CONVERT(BIGINT, s.createdAt)) AS minCreatedAt,
|
||||
MAX(s.createTime) AS lastCaptureRaw,
|
||||
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(INDEXOF_goodsId_pdd_Skus))
|
||||
WHERE s.goodsId IN (${placeholders})
|
||||
GROUP BY s.goodsId`,
|
||||
params,
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const records = [];
|
||||
rows.forEach((row) => {
|
||||
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||
const lastCapture = epochToDatetime(row.lastCaptureRaw);
|
||||
if (!createdAt) return;
|
||||
records.push([String(row.goodsId), createdAt, lastCapture || createdAt, Number(row.onsale) === 1 ? 1 : 0]);
|
||||
});
|
||||
for (let i = 0; i < records.length; i += SYNC_CHUNK_SIZE) {
|
||||
const chunk = records.slice(i, i + SYNC_CHUNK_SIZE);
|
||||
await planningDb.query(
|
||||
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_capture_date = VALUES(last_capture_date),
|
||||
is_onsale = VALUES(is_onsale)`,
|
||||
[chunk]
|
||||
);
|
||||
}
|
||||
synced += records.length;
|
||||
console.log(`[GoodsFirstSeen] 回填进度 ${Math.min(offset + BACKFILL_CHUNK_SIZE, goodsIds.length)}/${goodsIds.length},命中 ${records.length}`);
|
||||
}
|
||||
const countAfter = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||
const summary = { goodsInSnapshot: goodsIds.length, synced, newGoods: Math.max(0, countAfter - countBefore), total: countAfter, elapsedMs: Date.now() - startedAt };
|
||||
console.log(`[GoodsFirstSeen] 历史回填结束: ${JSON.stringify(summary)}`);
|
||||
return summary;
|
||||
}
|
||||
|
||||
function startGoodsFirstSeenScheduler() {
|
||||
const disabled = String(process.env.DISABLE_GOODS_FIRST_SEEN_JOB || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[GoodsFirstSeen] 首见时间同步任务已被禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
refreshLatestCapture().catch((error) => {
|
||||
console.error('[GoodsFirstSeen] 定时同步失败:', error.message);
|
||||
});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 商品首见时间同步任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { refreshLatestCapture, backfillAllHistory, syncCaptureDate, startGoodsFirstSeenScheduler };
|
||||
109
backend/src/jobs/jxStockSelectionSyncJob.js
Normal file
109
backend/src/jobs/jxStockSelectionSyncJob.js
Normal file
@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
syncJxStockSelectionTables,
|
||||
rebuildJxStockSelectionBase,
|
||||
rebuildJxStockSelectionResult,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
} = require('../services/jxStockSelectionSync');
|
||||
|
||||
let baseRunning = false;
|
||||
let resultRunning = false;
|
||||
const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.JX_STOCK_SELECTION_JOB_LOCK_WAIT_SECONDS || '1', 10), 0);
|
||||
|
||||
async function withGlobalJobLock(lockKey, trigger, runner) {
|
||||
const connection = await database.local.pool.getConnection();
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]);
|
||||
const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0;
|
||||
lockAcquired = lockStatus === 1;
|
||||
|
||||
if (!lockAcquired) {
|
||||
console.log(`[JxStockSelectionSync] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`);
|
||||
return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey };
|
||||
}
|
||||
|
||||
return await runner();
|
||||
} finally {
|
||||
try {
|
||||
if (lockAcquired) {
|
||||
await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]);
|
||||
}
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runJxStockSelectionBaseJob(trigger = 'manual_base') {
|
||||
if (baseRunning) {
|
||||
console.log(`[JxStockSelectionSync] base job is already running, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'running' };
|
||||
}
|
||||
|
||||
baseRunning = true;
|
||||
try {
|
||||
return await withGlobalJobLock('jx_stock_selection_base_daily', trigger, async () => {
|
||||
return rebuildJxStockSelectionBase({
|
||||
syncType: trigger,
|
||||
requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
baseRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runJxStockSelectionResultJob(trigger = 'manual_result') {
|
||||
if (resultRunning) {
|
||||
console.log(`[JxStockSelectionSync] result job is already running, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'running' };
|
||||
}
|
||||
|
||||
resultRunning = true;
|
||||
try {
|
||||
return await withGlobalJobLock('jx_stock_selection_result_daily', trigger, async () => {
|
||||
return rebuildJxStockSelectionResult({
|
||||
syncType: trigger,
|
||||
requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
resultRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startJxStockSelectionSchedulers() {
|
||||
cron.schedule('0 5 * * *', () => {
|
||||
runJxStockSelectionBaseJob('daily_schedule_base').catch((error) => {
|
||||
console.error('[JxStockSelectionSync] base scheduled job failed', error);
|
||||
});
|
||||
}, { timezone: 'Asia/Shanghai' });
|
||||
|
||||
cron.schedule('0 7 * * *', () => {
|
||||
runJxStockSelectionResultJob('daily_schedule_result').catch((error) => {
|
||||
console.error('[JxStockSelectionSync] result scheduled job failed', error);
|
||||
});
|
||||
}, { timezone: 'Asia/Shanghai' });
|
||||
|
||||
console.log('[Scheduler] 京喜条码打标底表任务已注册: 每天05:00执行');
|
||||
console.log('[Scheduler] 京喜条码打标结果表任务已注册: 每天07:00执行');
|
||||
}
|
||||
|
||||
async function manualJxStockSelectionSync() {
|
||||
const baseResult = await runJxStockSelectionBaseJob('manual_base');
|
||||
const resultResult = await runJxStockSelectionResultJob('manual_result');
|
||||
return { success: Boolean(baseResult?.success && resultResult?.success), baseResult, resultResult };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startJxStockSelectionSchedulers,
|
||||
manualJxStockSelectionSync,
|
||||
runJxStockSelectionBaseJob,
|
||||
runJxStockSelectionResultJob,
|
||||
runJxStockSelectionSyncJob: syncJxStockSelectionTables,
|
||||
};
|
||||
31
backend/src/jobs/opsBIncentiveSnapshotJob.js
Normal file
31
backend/src/jobs/opsBIncentiveSnapshotJob.js
Normal file
@ -0,0 +1,31 @@
|
||||
const cron = require('node-cron');
|
||||
const { refreshOpsBIncentiveSnapshots } = require('../services/opsBIncentiveBoardService');
|
||||
|
||||
const JOB_CRON = String(process.env.OPS_B_INCENTIVE_SNAPSHOT_CRON || '0 * * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
async function runOpsBIncentiveSnapshotJob(source = 'scheduled') {
|
||||
try {
|
||||
const result = await refreshOpsBIncentiveSnapshots(source);
|
||||
console.log('[OpsBIncentiveSnapshot] refresh completed:', JSON.stringify(result));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[OpsBIncentiveSnapshot] refresh failed:', error.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startOpsBIncentiveSnapshotSchedulers() {
|
||||
// 默认开启;仅当显式设置 DISABLE_OPS_B_INCENTIVE_SNAPSHOT=true 时关闭。
|
||||
const disabled = String(process.env.DISABLE_OPS_B_INCENTIVE_SNAPSHOT || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[Scheduler] 运营中心B激励看板快照任务已被禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runOpsBIncentiveSnapshotJob('daily_schedule').catch(() => {});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 运营中心B激励看板快照任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { startOpsBIncentiveSnapshotSchedulers, runOpsBIncentiveSnapshotJob };
|
||||
80
backend/src/jobs/pddSkuDimensionSnapshotJob.js
Normal file
80
backend/src/jobs/pddSkuDimensionSnapshotJob.js
Normal file
@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const planningDatabase = require('../services/planningDatabase');
|
||||
const {
|
||||
syncPddSkuDimensionSnapshots,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
} = require('../services/pddSkuDimensionSnapshotSync');
|
||||
|
||||
let running = false;
|
||||
const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.PDD_SKU_DIMENSION_SNAPSHOT_LOCK_WAIT_SECONDS || '1', 10), 0);
|
||||
const JOB_ENABLED = String(process.env.PDD_SKU_DIMENSION_SNAPSHOT_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const JOB_CRON = process.env.PDD_SKU_DIMENSION_SNAPSHOT_CRON || '10 6 * * *';
|
||||
|
||||
async function withGlobalJobLock(lockKey, trigger, runner) {
|
||||
const pool = await planningDatabase.getPool();
|
||||
const connection = await pool.getConnection();
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]);
|
||||
const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0;
|
||||
lockAcquired = lockStatus === 1;
|
||||
|
||||
if (!lockAcquired) {
|
||||
console.log(`[PddSkuDimensionSnapshot] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`);
|
||||
return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey };
|
||||
}
|
||||
|
||||
return runner();
|
||||
} finally {
|
||||
try {
|
||||
if (lockAcquired) {
|
||||
await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]);
|
||||
}
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runPddSkuDimensionSnapshotJob(trigger = 'manual', options = {}) {
|
||||
if (running) {
|
||||
console.log(`[PddSkuDimensionSnapshot] job is already running, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'running', trigger };
|
||||
}
|
||||
|
||||
running = true;
|
||||
try {
|
||||
return await withGlobalJobLock('pdd_sku_dimension_snapshot_daily', trigger, async () => {
|
||||
const result = await syncPddSkuDimensionSnapshots({
|
||||
targetDate: options.targetDate || null,
|
||||
requestTimeout: options.requestTimeout || DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
return { ...result, trigger };
|
||||
});
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPddSkuDimensionSnapshotSchedulers() {
|
||||
if (!JOB_ENABLED) {
|
||||
console.log('[Scheduler] 拼多多 SPU/SKU/商品ID 快照任务未启用');
|
||||
return;
|
||||
}
|
||||
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runPddSkuDimensionSnapshotJob('daily_schedule').catch((error) => {
|
||||
console.error('[PddSkuDimensionSnapshot] scheduled job failed', error);
|
||||
});
|
||||
}, { timezone: 'Asia/Shanghai' });
|
||||
|
||||
console.log(`[Scheduler] 拼多多 SPU/SKU/商品ID 快照任务已注册: ${JOB_CRON} (Asia/Shanghai)`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startPddSkuDimensionSnapshotSchedulers,
|
||||
runPddSkuDimensionSnapshotJob,
|
||||
};
|
||||
143
backend/src/jobs/planningDingWorkflowSyncJob.js
Normal file
143
backend/src/jobs/planningDingWorkflowSyncJob.js
Normal file
@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
|
||||
const planningDatabase = require('../services/planningDatabase');
|
||||
const {
|
||||
syncPlanningDingWorkflowTables,
|
||||
} = require('../services/planningDingWorkflowSyncService');
|
||||
|
||||
let running = false;
|
||||
|
||||
const JOB_ENABLED = String(process.env.DING_WORKFLOW_SYNC_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const HOURLY_CRON = process.env.DING_WORKFLOW_SYNC_CRON || '5 * * * *';
|
||||
const FULL_CRON = process.env.DING_WORKFLOW_SYNC_FULL_CRON || '35 3 * * *';
|
||||
const TIMEZONE = process.env.DING_WORKFLOW_SYNC_TIMEZONE || 'Asia/Shanghai';
|
||||
const STARTUP_SYNC_ENABLED = String(process.env.DING_WORKFLOW_SYNC_STARTUP_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const STARTUP_FULL_SYNC = String(process.env.DING_WORKFLOW_SYNC_STARTUP_FULL || 'false').toLowerCase() === 'true';
|
||||
const STARTUP_DELAY_MS = Math.max(
|
||||
Number.parseInt(process.env.DING_WORKFLOW_SYNC_STARTUP_DELAY_MS || '30000', 10) || 30000,
|
||||
1000
|
||||
);
|
||||
const JOB_LOCK_WAIT_SECONDS = Math.max(
|
||||
Number.parseInt(process.env.DING_WORKFLOW_SYNC_LOCK_WAIT_SECONDS || '1', 10) || 1,
|
||||
0
|
||||
);
|
||||
const CHUNK_SIZE = Math.max(Number.parseInt(process.env.DING_WORKFLOW_SYNC_CHUNK_SIZE || '2000', 10) || 2000, 100);
|
||||
const LOOKBACK_MINUTES = Math.max(
|
||||
Number.parseInt(process.env.DING_WORKFLOW_SYNC_LOOKBACK_MINUTES || '180', 10) || 180,
|
||||
0
|
||||
);
|
||||
const LOOKBACK_ROWS = Math.max(
|
||||
Number.parseInt(process.env.DING_WORKFLOW_SYNC_LOOKBACK_ROWS || '10000', 10) || 10000,
|
||||
0
|
||||
);
|
||||
|
||||
async function withGlobalJobLock(lockKey, trigger, runner) {
|
||||
const pool = await planningDatabase.getPool();
|
||||
const connection = await pool.getConnection();
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [lockKey, JOB_LOCK_WAIT_SECONDS]);
|
||||
const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0;
|
||||
lockAcquired = lockStatus === 1;
|
||||
|
||||
if (!lockAcquired) {
|
||||
console.log(`[DingWorkflowSync] global lock not acquired, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger };
|
||||
}
|
||||
|
||||
return runner();
|
||||
} finally {
|
||||
try {
|
||||
if (lockAcquired) {
|
||||
await connection.query('DO RELEASE_LOCK(?)', [lockKey]);
|
||||
}
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runPlanningDingWorkflowSyncJob(trigger = 'manual', options = {}) {
|
||||
if (running) {
|
||||
console.log(`[DingWorkflowSync] job is already running, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'running', trigger };
|
||||
}
|
||||
|
||||
running = true;
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
return await withGlobalJobLock('planning_ding_workflow_sync', trigger, async () => {
|
||||
const full = Boolean(options.full);
|
||||
const table = options.table || 'all';
|
||||
const result = await syncPlanningDingWorkflowTables({
|
||||
table,
|
||||
full,
|
||||
chunkSize: options.chunkSize || CHUNK_SIZE,
|
||||
lookbackMinutes: options.lookbackMinutes || LOOKBACK_MINUTES,
|
||||
lookbackRows: options.lookbackRows || LOOKBACK_ROWS,
|
||||
onProgress: (payload) => {
|
||||
console.log(
|
||||
`[DingWorkflowSync] ${trigger} ${payload.table} ${payload.fetchedRows}/${payload.totalRows}`
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
trigger,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPlanningDingWorkflowSyncSchedulers() {
|
||||
if (!JOB_ENABLED) {
|
||||
console.log('[Scheduler] 钉钉流程表同步任务未启用');
|
||||
return;
|
||||
}
|
||||
|
||||
cron.schedule(HOURLY_CRON, () => {
|
||||
runPlanningDingWorkflowSyncJob('hourly_incremental', { full: false }).catch((error) => {
|
||||
console.error('[DingWorkflowSync] hourly scheduled job failed', error);
|
||||
});
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
console.log(`[Scheduler] 钉钉流程表同步任务已注册: ${HOURLY_CRON} (${TIMEZONE})`);
|
||||
|
||||
if (FULL_CRON && String(FULL_CRON).trim()) {
|
||||
cron.schedule(FULL_CRON, () => {
|
||||
runPlanningDingWorkflowSyncJob('daily_full_reconcile', { full: true }).catch((error) => {
|
||||
console.error('[DingWorkflowSync] daily full reconcile failed', error);
|
||||
});
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
console.log(`[Scheduler] 钉钉流程表全量校准任务已注册: ${FULL_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
if (STARTUP_SYNC_ENABLED) {
|
||||
const timer = setTimeout(() => {
|
||||
runPlanningDingWorkflowSyncJob(
|
||||
STARTUP_FULL_SYNC ? 'startup_full_sync' : 'startup_incremental_sync',
|
||||
{ full: STARTUP_FULL_SYNC }
|
||||
).catch((error) => {
|
||||
console.error('[DingWorkflowSync] startup sync failed', error);
|
||||
});
|
||||
}, STARTUP_DELAY_MS);
|
||||
|
||||
if (typeof timer.unref === 'function') {
|
||||
timer.unref();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startPlanningDingWorkflowSyncSchedulers,
|
||||
runPlanningDingWorkflowSyncJob,
|
||||
};
|
||||
82
backend/src/jobs/planningMayProductSnapshotJob.js
Normal file
82
backend/src/jobs/planningMayProductSnapshotJob.js
Normal file
@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const planningDatabase = require('../services/planningDatabase');
|
||||
const {
|
||||
refreshPlanningMayProductSnapshot,
|
||||
} = require('../services/planningMayProductDataService');
|
||||
|
||||
let running = false;
|
||||
const JOB_LOCK_WAIT_SECONDS = Math.max(
|
||||
Number.parseInt(process.env.PLANNING_MAY_PRODUCT_SNAPSHOT_LOCK_WAIT_SECONDS || '1', 10),
|
||||
0
|
||||
);
|
||||
const JOB_ENABLED = String(process.env.PLANNING_MAY_PRODUCT_SNAPSHOT_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const JOB_CRON = process.env.PLANNING_MAY_PRODUCT_SNAPSHOT_CRON || '30 8 * * *';
|
||||
|
||||
async function withGlobalJobLock(lockKey, trigger, runner) {
|
||||
const pool = await planningDatabase.getPool();
|
||||
const connection = await pool.getConnection();
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [lockKey, JOB_LOCK_WAIT_SECONDS]);
|
||||
const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0;
|
||||
lockAcquired = lockStatus === 1;
|
||||
|
||||
if (!lockAcquired) {
|
||||
console.log(`[PlanningMayProductSnapshot] global lock not acquired, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger };
|
||||
}
|
||||
|
||||
return runner();
|
||||
} finally {
|
||||
try {
|
||||
if (lockAcquired) {
|
||||
await connection.query('DO RELEASE_LOCK(?)', [lockKey]);
|
||||
}
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runPlanningMayProductSnapshotJob(trigger = 'manual', options = {}) {
|
||||
if (running) {
|
||||
console.log(`[PlanningMayProductSnapshot] job is already running, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'running', trigger };
|
||||
}
|
||||
|
||||
running = true;
|
||||
try {
|
||||
return await withGlobalJobLock('planning_may_product_snapshot_daily', trigger, async () => {
|
||||
const result = await refreshPlanningMayProductSnapshot({
|
||||
statDate: options.statDate || null,
|
||||
chunkSize: options.chunkSize || null,
|
||||
});
|
||||
return { ...result, trigger };
|
||||
});
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPlanningMayProductSnapshotSchedulers() {
|
||||
if (!JOB_ENABLED) {
|
||||
console.log('[Scheduler] MAY 商品数据快照任务未启用');
|
||||
return;
|
||||
}
|
||||
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runPlanningMayProductSnapshotJob('daily_schedule').catch((error) => {
|
||||
console.error('[PlanningMayProductSnapshot] scheduled job failed', error);
|
||||
});
|
||||
}, { timezone: 'Asia/Shanghai' });
|
||||
|
||||
console.log(`[Scheduler] MAY 商品数据快照任务已注册: ${JOB_CRON} (Asia/Shanghai)`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startPlanningMayProductSnapshotSchedulers,
|
||||
runPlanningMayProductSnapshotJob,
|
||||
};
|
||||
110
backend/src/jobs/stockSelectionSyncJob.js
Normal file
110
backend/src/jobs/stockSelectionSyncJob.js
Normal file
@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const planningDatabase = require('../services/planningDatabase');
|
||||
const {
|
||||
syncStockSelectionTables,
|
||||
rebuildStockSelectionBase,
|
||||
rebuildStockSelectionResult,
|
||||
DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
} = require('../services/stockSelectionSync');
|
||||
|
||||
let baseRunning = false;
|
||||
let resultRunning = false;
|
||||
const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.STOCK_SELECTION_JOB_LOCK_WAIT_SECONDS || '1', 10), 0);
|
||||
|
||||
async function withGlobalJobLock(lockKey, trigger, runner) {
|
||||
const pool = await planningDatabase.getPool();
|
||||
const connection = await pool.getConnection();
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]);
|
||||
const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0;
|
||||
lockAcquired = lockStatus === 1;
|
||||
|
||||
if (!lockAcquired) {
|
||||
console.log(`[StockSelectionSync] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`);
|
||||
return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey };
|
||||
}
|
||||
|
||||
return runner();
|
||||
} finally {
|
||||
try {
|
||||
if (lockAcquired) {
|
||||
await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]);
|
||||
}
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runStockSelectionBaseJob(trigger = 'manual_base') {
|
||||
if (baseRunning) {
|
||||
console.log(`[StockSelectionSync] base job is already running, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'running' };
|
||||
}
|
||||
|
||||
baseRunning = true;
|
||||
try {
|
||||
return withGlobalJobLock('stock_selection_base_daily', trigger, async () => {
|
||||
return rebuildStockSelectionBase({
|
||||
syncType: trigger,
|
||||
requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
baseRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runStockSelectionResultJob(trigger = 'manual_result') {
|
||||
if (resultRunning) {
|
||||
console.log(`[StockSelectionSync] result job is already running, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'running' };
|
||||
}
|
||||
|
||||
resultRunning = true;
|
||||
try {
|
||||
return withGlobalJobLock('stock_selection_result_daily', trigger, async () => {
|
||||
return rebuildStockSelectionResult({
|
||||
syncType: trigger,
|
||||
requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
resultRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startStockSelectionSchedulers() {
|
||||
cron.schedule('0 5 * * *', () => {
|
||||
runStockSelectionBaseJob('daily_schedule_base').catch((error) => {
|
||||
console.error('[StockSelectionSync] base scheduled job failed', error);
|
||||
});
|
||||
}, { timezone: 'Asia/Shanghai' });
|
||||
|
||||
cron.schedule('0 7 * * *', () => {
|
||||
runStockSelectionResultJob('daily_schedule_result').catch((error) => {
|
||||
console.error('[StockSelectionSync] result scheduled job failed', error);
|
||||
});
|
||||
}, { timezone: 'Asia/Shanghai' });
|
||||
|
||||
console.log('[Scheduler] 条码打标底表任务已注册: 每天05:00执行');
|
||||
console.log('[Scheduler] 条码打标结果表任务已注册: 每天07:00执行');
|
||||
}
|
||||
|
||||
async function manualStockSelectionSync() {
|
||||
const baseResult = await runStockSelectionBaseJob('manual_base');
|
||||
const resultResult = await runStockSelectionResultJob('manual_result');
|
||||
return { success: Boolean(baseResult?.success && resultResult?.success), baseResult, resultResult };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startStockSelectionSchedulers,
|
||||
manualStockSelectionSync,
|
||||
runStockSelectionBaseJob,
|
||||
runStockSelectionResultJob,
|
||||
runStockSelectionSyncJob: syncStockSelectionTables,
|
||||
};
|
||||
74
backend/src/jobs/taobaoProductEmbeddingSyncJob.js
Normal file
74
backend/src/jobs/taobaoProductEmbeddingSyncJob.js
Normal file
@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const planningDatabase = require('../services/planningDatabase');
|
||||
const { syncTaobaoProductEmbeddings } = require('../services/taobaoProductEmbeddingSync');
|
||||
|
||||
let running = false;
|
||||
const JOB_LOCK_WAIT_SECONDS = Math.max(parseInt(process.env.TAOBAO_PRODUCT_EMBEDDING_LOCK_WAIT_SECONDS || '1', 10), 0);
|
||||
const JOB_ENABLED = String(process.env.TAOBAO_PRODUCT_EMBEDDING_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const JOB_CRON = process.env.TAOBAO_PRODUCT_EMBEDDING_CRON || '0 0 * * *';
|
||||
|
||||
async function withGlobalJobLock(lockKey, trigger, runner) {
|
||||
const pool = await planningDatabase.getPool();
|
||||
const connection = await pool.getConnection();
|
||||
let lockAcquired = false;
|
||||
|
||||
try {
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [ lockKey, JOB_LOCK_WAIT_SECONDS ]);
|
||||
const lockStatus = rows && rows[0] ? Number(rows[0].lockStatus) : 0;
|
||||
lockAcquired = lockStatus === 1;
|
||||
|
||||
if (!lockAcquired) {
|
||||
console.log(`[TaobaoEmbeddingSync] global lock not acquired, skip trigger=${trigger}, lockKey=${lockKey}`);
|
||||
return { success: false, skipped: true, reason: 'global_lock_not_acquired', trigger, lockKey };
|
||||
}
|
||||
|
||||
return await runner();
|
||||
} finally {
|
||||
try {
|
||||
if (lockAcquired) {
|
||||
await connection.query('DO RELEASE_LOCK(?)', [ lockKey ]);
|
||||
}
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runTaobaoProductEmbeddingSync(trigger = 'manual') {
|
||||
if (running) {
|
||||
console.log(`[TaobaoEmbeddingSync] job is already running, skip trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'running', trigger };
|
||||
}
|
||||
|
||||
running = true;
|
||||
try {
|
||||
return await withGlobalJobLock('taobao_product_embedding_daily', trigger, async () => {
|
||||
const result = await syncTaobaoProductEmbeddings();
|
||||
return { ...result, trigger };
|
||||
});
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startTaobaoProductEmbeddingSchedulers() {
|
||||
if (!JOB_ENABLED) {
|
||||
console.log('[Scheduler] taobao_product_records 向量任务未启用');
|
||||
return;
|
||||
}
|
||||
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runTaobaoProductEmbeddingSync('daily_schedule').catch((error) => {
|
||||
console.error('[TaobaoEmbeddingSync] scheduled job failed', error);
|
||||
});
|
||||
}, { timezone: 'Asia/Shanghai' });
|
||||
|
||||
console.log(`[Scheduler] taobao_product_records 向量任务已注册: ${JOB_CRON} (Asia/Shanghai)`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startTaobaoProductEmbeddingSchedulers,
|
||||
runTaobaoProductEmbeddingSync,
|
||||
};
|
||||
39
backend/src/middleware/auth.js
Normal file
39
backend/src/middleware/auth.js
Normal file
@ -0,0 +1,39 @@
|
||||
// 认证中间件
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const authMiddleware = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: '未提供认证令牌',
|
||||
code: 'AUTH_TOKEN_MISSING',
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.CUBEJS_SECRET);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({
|
||||
error: '无效的认证令牌',
|
||||
code: 'AUTH_TOKEN_INVALID',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 角色检查中间件
|
||||
const requireRole = (...roles) => {
|
||||
return (req, res, next) => {
|
||||
if (!roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: '权限不足' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = authMiddleware;
|
||||
module.exports.requireRole = requireRole;
|
||||
260
backend/src/middleware/pageDataCache.js
Normal file
260
backend/src/middleware/pageDataCache.js
Normal file
@ -0,0 +1,260 @@
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const redis = require('../config/redis');
|
||||
const dataPageCache = require('../services/dataPageCache');
|
||||
|
||||
const CACHE_BUSTER_KEYS = new Set([
|
||||
'_',
|
||||
'_t',
|
||||
't',
|
||||
'timestamp',
|
||||
'cacheBust',
|
||||
'cacheBuster',
|
||||
'nocache',
|
||||
]);
|
||||
const DEFAULT_REDIS_TTL_SECONDS = Math.max(
|
||||
Number(process.env.PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 300,
|
||||
30
|
||||
);
|
||||
const MAX_RECENT_EVENTS = 200;
|
||||
const pageCacheStats = {
|
||||
total: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
stores: 0,
|
||||
bypasses: 0,
|
||||
invalidations: 0,
|
||||
labels: new Map(),
|
||||
recentEvents: [],
|
||||
lastResetAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function hashValue(value) {
|
||||
if (!value) return '';
|
||||
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeQuery(query = {}) {
|
||||
return Object.keys(query || {})
|
||||
.filter((key) => !CACHE_BUSTER_KEYS.has(key))
|
||||
.sort()
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = query[key];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildUserScope(req) {
|
||||
if (req.user) {
|
||||
return {
|
||||
sub: req.user.sub || req.user.id || '',
|
||||
role: req.user.role || '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authHash: hashValue(req.headers.authorization || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function verifyAuthHeaderIfPresent(req) {
|
||||
if (req.user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authHeader = String(req.headers.authorization || '');
|
||||
if (!authHeader) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
req.user = jwt.verify(authHeader.substring(7), process.env.CUBEJS_SECRET);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getNamespacePrefix(namespace) {
|
||||
return `page-response:${namespace}:`;
|
||||
}
|
||||
|
||||
function buildCacheKey(req, namespace, version) {
|
||||
return dataPageCache.buildHashedCacheKey(
|
||||
getNamespacePrefix(namespace),
|
||||
{
|
||||
method: req.method,
|
||||
path: `${req.baseUrl || ''}${req.path || ''}`,
|
||||
query: normalizeQuery(req.query),
|
||||
user: buildUserScope(req),
|
||||
acceptLanguage: req.headers['accept-language'] || '',
|
||||
},
|
||||
version
|
||||
);
|
||||
}
|
||||
|
||||
function isCacheBypassed(req) {
|
||||
const cacheControl = String(req.headers['cache-control'] || '').toLowerCase();
|
||||
return (
|
||||
cacheControl.includes('no-cache')
|
||||
|| cacheControl.includes('no-store')
|
||||
|| req.query?._refresh === '1'
|
||||
|| req.query?.refresh === '1'
|
||||
);
|
||||
}
|
||||
|
||||
function defaultShouldCacheBody(body) {
|
||||
if (body === undefined) return false;
|
||||
if (body?.success === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordPageCacheEvent(namespace, hit, details = {}) {
|
||||
pageCacheStats.total += 1;
|
||||
if (hit === 'redis') pageCacheStats.hits += 1;
|
||||
if (hit === 'miss') pageCacheStats.misses += 1;
|
||||
if (hit === 'store') pageCacheStats.stores += 1;
|
||||
if (hit === 'bypass') pageCacheStats.bypasses += 1;
|
||||
if (hit === 'invalidate') pageCacheStats.invalidations += 1;
|
||||
|
||||
const labelStats = pageCacheStats.labels.get(namespace) || {
|
||||
label: namespace,
|
||||
total: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
stores: 0,
|
||||
bypasses: 0,
|
||||
invalidations: 0,
|
||||
lastHit: null,
|
||||
lastSeenAt: null,
|
||||
};
|
||||
labelStats.total += 1;
|
||||
labelStats.lastHit = hit;
|
||||
labelStats.lastSeenAt = new Date().toISOString();
|
||||
if (hit === 'redis') labelStats.hits += 1;
|
||||
if (hit === 'miss') labelStats.misses += 1;
|
||||
if (hit === 'store') labelStats.stores += 1;
|
||||
if (hit === 'bypass') labelStats.bypasses += 1;
|
||||
if (hit === 'invalidate') labelStats.invalidations += 1;
|
||||
pageCacheStats.labels.set(namespace, labelStats);
|
||||
|
||||
pageCacheStats.recentEvents.unshift({
|
||||
namespace,
|
||||
hit,
|
||||
path: details.path || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
if (pageCacheStats.recentEvents.length > MAX_RECENT_EVENTS) {
|
||||
pageCacheStats.recentEvents.length = MAX_RECENT_EVENTS;
|
||||
}
|
||||
}
|
||||
|
||||
function getPageDataCacheStats() {
|
||||
return {
|
||||
total: pageCacheStats.total,
|
||||
hits: pageCacheStats.hits,
|
||||
misses: pageCacheStats.misses,
|
||||
stores: pageCacheStats.stores,
|
||||
bypasses: pageCacheStats.bypasses,
|
||||
invalidations: pageCacheStats.invalidations,
|
||||
hitRate: pageCacheStats.total ? Number((pageCacheStats.hits * 100 / pageCacheStats.total).toFixed(2)) : 0,
|
||||
lastResetAt: pageCacheStats.lastResetAt,
|
||||
labels: Array.from(pageCacheStats.labels.values()).sort((a, b) => b.total - a.total),
|
||||
recentEvents: pageCacheStats.recentEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function resetPageDataCacheStats() {
|
||||
pageCacheStats.total = 0;
|
||||
pageCacheStats.hits = 0;
|
||||
pageCacheStats.misses = 0;
|
||||
pageCacheStats.stores = 0;
|
||||
pageCacheStats.bypasses = 0;
|
||||
pageCacheStats.invalidations = 0;
|
||||
pageCacheStats.labels.clear();
|
||||
pageCacheStats.recentEvents = [];
|
||||
pageCacheStats.lastResetAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
function createPageDataCache(options = {}) {
|
||||
const namespace = options.namespace || 'default';
|
||||
const version = options.version || 'v1';
|
||||
const redisTtlSeconds = Math.max(Number(options.redisTtlSeconds) || DEFAULT_REDIS_TTL_SECONDS, 30);
|
||||
const enabled = options.enabled !== false;
|
||||
const invalidateOnMutations = options.invalidateOnMutations !== false;
|
||||
const shouldHandleRequest = typeof options.shouldHandleRequest === 'function'
|
||||
? options.shouldHandleRequest
|
||||
: () => true;
|
||||
const shouldCacheBody = typeof options.shouldCacheBody === 'function'
|
||||
? options.shouldCacheBody
|
||||
: defaultShouldCacheBody;
|
||||
|
||||
return async (req, res, next) => {
|
||||
if (!enabled || !shouldHandleRequest(req)) {
|
||||
recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl });
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
if (invalidateOnMutations) {
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
dataPageCache.deleteByPrefix(getNamespacePrefix(namespace)).catch((error) => {
|
||||
console.warn(`[PageDataCache] ${namespace} invalidation failed:`, error.message);
|
||||
});
|
||||
recordPageCacheEvent(namespace, 'invalidate', { path: req.originalUrl });
|
||||
}
|
||||
});
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!verifyAuthHeaderIfPresent(req) || isCacheBypassed(req)) {
|
||||
recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl });
|
||||
res.set('X-Page-Cache', 'bypass');
|
||||
return next();
|
||||
}
|
||||
|
||||
const cacheKey = buildCacheKey(req, namespace, version);
|
||||
const cachedPayload = await redis.get(cacheKey);
|
||||
if (cachedPayload?.body !== undefined) {
|
||||
recordPageCacheEvent(namespace, 'redis', { path: req.originalUrl });
|
||||
res.set('X-Page-Cache', 'redis');
|
||||
return res.status(cachedPayload.statusCode || 200).json(cachedPayload.body);
|
||||
}
|
||||
|
||||
recordPageCacheEvent(namespace, 'miss', { path: req.originalUrl });
|
||||
res.set('X-Page-Cache', 'miss');
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (body) => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && shouldCacheBody(body, req)) {
|
||||
redis.setex(cacheKey, redisTtlSeconds, {
|
||||
statusCode: res.statusCode,
|
||||
body,
|
||||
cachedAt: new Date().toISOString(),
|
||||
}).then((stored) => {
|
||||
if (stored) {
|
||||
recordPageCacheEvent(namespace, 'store', { path: req.originalUrl });
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.warn(`[PageDataCache] ${namespace} store failed:`, error.message);
|
||||
});
|
||||
}
|
||||
|
||||
return originalJson(body);
|
||||
};
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPageDataCache,
|
||||
getPageDataCacheStats,
|
||||
resetPageDataCacheStats,
|
||||
};
|
||||
280
backend/src/services/aftersalesRefundDetail.js
Normal file
280
backend/src/services/aftersalesRefundDetail.js
Normal file
@ -0,0 +1,280 @@
|
||||
'use strict';
|
||||
|
||||
const dayjs = require('dayjs');
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
const database = require('../config/database');
|
||||
const { applyAftersalesRefundExportOverrides } = require('./aftersalesRefundExportOverride');
|
||||
|
||||
const SYNC_VERSION = 'aftersales-refund-union-v2';
|
||||
const INSERT_BATCH_SIZE = 500;
|
||||
|
||||
const TARGET_COLUMNS = [
|
||||
'stat_date',
|
||||
'source_key',
|
||||
'source_table',
|
||||
'as_id',
|
||||
'outer_as_id',
|
||||
'o_id',
|
||||
'so_id',
|
||||
'type',
|
||||
'node',
|
||||
'order_labels',
|
||||
'shop_name',
|
||||
'freight',
|
||||
'refund',
|
||||
'payment',
|
||||
'free_amount',
|
||||
'total_refund_money',
|
||||
'shop_status',
|
||||
'question_type',
|
||||
'process_result',
|
||||
'status',
|
||||
'created',
|
||||
'modified',
|
||||
'l_id',
|
||||
'confirm_date',
|
||||
'creator_name',
|
||||
'original_order_no',
|
||||
'original_courier_name',
|
||||
'original_waybill_no',
|
||||
];
|
||||
|
||||
function normalizeDate(value, fallback = dayjs().subtract(1, 'day').format('YYYY-MM-DD')) {
|
||||
const parsed = dayjs(value || fallback);
|
||||
if (!parsed.isValid()) {
|
||||
throw new Error(`Invalid date: ${value}`);
|
||||
}
|
||||
return parsed.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function listDatesInRange(startDate, endDate) {
|
||||
const start = dayjs(normalizeDate(startDate));
|
||||
const end = dayjs(normalizeDate(endDate || startDate));
|
||||
if (end.isBefore(start, 'day')) {
|
||||
throw new Error(`Invalid date range: ${startDate} -> ${endDate}`);
|
||||
}
|
||||
|
||||
const dates = [];
|
||||
let cursor = start;
|
||||
while (!cursor.isAfter(end, 'day')) {
|
||||
dates.push(cursor.format('YYYY-MM-DD'));
|
||||
cursor = cursor.add(1, 'day');
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
function decimalOrZero(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function textOrEmpty(value) {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
function toInsertRow(row) {
|
||||
return TARGET_COLUMNS.map((column) => {
|
||||
if (['freight', 'refund', 'payment', 'free_amount', 'total_refund_money'].includes(column)) {
|
||||
return decimalOrZero(row[column]);
|
||||
}
|
||||
if (['created', 'modified', 'confirm_date'].includes(column)) {
|
||||
return row[column] || null;
|
||||
}
|
||||
if (column === 'stat_date') {
|
||||
return normalizeDate(row[column]);
|
||||
}
|
||||
return textOrEmpty(row[column]);
|
||||
});
|
||||
}
|
||||
|
||||
const UNION_SQL = `
|
||||
WITH unified_refunds AS (
|
||||
SELECT
|
||||
CONVERT(varchar(10), CONVERT(date, created), 120) AS stat_date,
|
||||
N'jst_returnAndRefundInfo' AS source_key,
|
||||
N'dbo.jst_returnAndRefundInfo' AS source_table,
|
||||
CAST(as_id AS nvarchar(80)) AS as_id,
|
||||
CAST(outer_as_id AS nvarchar(120)) AS outer_as_id,
|
||||
CAST(o_id AS nvarchar(80)) AS o_id,
|
||||
CAST(so_id AS nvarchar(120)) AS so_id,
|
||||
CAST(type AS nvarchar(50)) AS type,
|
||||
CAST(node AS nvarchar(100)) AS node,
|
||||
CAST(order_labelsText AS nvarchar(max)) AS order_labels,
|
||||
CAST(shop_name AS nvarchar(255)) AS shop_name,
|
||||
TRY_CONVERT(decimal(18,2), freight) AS freight,
|
||||
TRY_CONVERT(decimal(18,2), refund) AS refund,
|
||||
TRY_CONVERT(decimal(18,2), payment) AS payment,
|
||||
TRY_CONVERT(decimal(18,2), free_amount) AS free_amount,
|
||||
(
|
||||
COALESCE(TRY_CONVERT(decimal(18,2), freight), 0)
|
||||
+ COALESCE(TRY_CONVERT(decimal(18,2), refund), 0)
|
||||
- COALESCE(TRY_CONVERT(decimal(18,2), payment), 0)
|
||||
- COALESCE(TRY_CONVERT(decimal(18,2), free_amount), 0)
|
||||
) AS total_refund_money,
|
||||
CAST(shop_status AS nvarchar(100)) AS shop_status,
|
||||
CAST(question_type AS nvarchar(255)) AS question_type,
|
||||
CAST(result AS nvarchar(255)) AS process_result,
|
||||
CAST(status AS nvarchar(100)) AS status,
|
||||
CONVERT(varchar(19), created, 120) AS created,
|
||||
CONVERT(varchar(19), modified, 120) AS modified,
|
||||
CAST(l_id AS nvarchar(120)) AS l_id,
|
||||
CONVERT(varchar(19), confirm_date, 120) AS confirm_date,
|
||||
CAST(creator_name AS nvarchar(100)) AS creator_name,
|
||||
CAST(N'' AS nvarchar(255)) AS original_order_no,
|
||||
CAST(N'' AS nvarchar(120)) AS original_courier_name,
|
||||
CAST(N'' AS nvarchar(120)) AS original_waybill_no
|
||||
FROM dbo.jst_returnAndRefundInfo WITH (NOLOCK)
|
||||
WHERE shop_site <> N'拼多多'
|
||||
AND CONVERT(date, created) = @statDate
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CONVERT(varchar(10), CONVERT(date, created), 120) AS stat_date,
|
||||
N'jst_qmcust_refundlist' AS source_key,
|
||||
N'dbo.jst_qmcust_refundlist' AS source_table,
|
||||
CAST(as_id AS nvarchar(80)) AS as_id,
|
||||
CAST(outer_as_id AS nvarchar(120)) AS outer_as_id,
|
||||
CAST(o_id AS nvarchar(80)) AS o_id,
|
||||
CAST(so_id AS nvarchar(120)) AS so_id,
|
||||
CAST(type AS nvarchar(50)) AS type,
|
||||
CAST(node AS nvarchar(100)) AS node,
|
||||
CAST(order_label AS nvarchar(max)) AS order_labels,
|
||||
CAST(shop_name AS nvarchar(255)) AS shop_name,
|
||||
TRY_CONVERT(decimal(18,2), freight) AS freight,
|
||||
TRY_CONVERT(decimal(18,2), refund) AS refund,
|
||||
TRY_CONVERT(decimal(18,2), payment) AS payment,
|
||||
TRY_CONVERT(decimal(18,2), free_amount) AS free_amount,
|
||||
(
|
||||
COALESCE(TRY_CONVERT(decimal(18,2), freight), 0)
|
||||
+ COALESCE(TRY_CONVERT(decimal(18,2), refund), 0)
|
||||
- COALESCE(TRY_CONVERT(decimal(18,2), payment), 0)
|
||||
- COALESCE(TRY_CONVERT(decimal(18,2), free_amount), 0)
|
||||
) AS total_refund_money,
|
||||
CAST(shop_status AS nvarchar(100)) AS shop_status,
|
||||
CAST(question_type AS nvarchar(255)) AS question_type,
|
||||
CAST(N'' AS nvarchar(255)) AS process_result,
|
||||
CAST(status AS nvarchar(100)) AS status,
|
||||
CONVERT(varchar(19), created, 120) AS created,
|
||||
CONVERT(varchar(19), modified, 120) AS modified,
|
||||
CAST(l_id AS nvarchar(120)) AS l_id,
|
||||
CONVERT(varchar(19), confirm_date, 120) AS confirm_date,
|
||||
CAST(creator_name AS nvarchar(100)) AS creator_name,
|
||||
CAST(N'' AS nvarchar(255)) AS original_order_no,
|
||||
CAST(N'' AS nvarchar(120)) AS original_courier_name,
|
||||
CAST(N'' AS nvarchar(120)) AS original_waybill_no
|
||||
FROM dbo.jst_qmcust_refundlist WITH (NOLOCK)
|
||||
WHERE CONVERT(date, created) = @statDate
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
CONVERT(varchar(10), CONVERT(date, created), 120) AS stat_date,
|
||||
N'jst_refund_pdd_fz_view' AS source_key,
|
||||
N'dbo.jst_refund_pdd_fz_view' AS source_table,
|
||||
CAST(as_id AS nvarchar(80)) AS as_id,
|
||||
CAST(outer_as_id AS nvarchar(120)) AS outer_as_id,
|
||||
CAST(o_id AS nvarchar(80)) AS o_id,
|
||||
CAST(so_id AS nvarchar(120)) AS so_id,
|
||||
CAST(type AS nvarchar(50)) AS type,
|
||||
CAST(node AS nvarchar(100)) AS node,
|
||||
CAST(order_labels_str AS nvarchar(max)) AS order_labels,
|
||||
CAST(shop_name AS nvarchar(255)) AS shop_name,
|
||||
TRY_CONVERT(decimal(18,2), freight) AS freight,
|
||||
TRY_CONVERT(decimal(18,2), refund) AS refund,
|
||||
TRY_CONVERT(decimal(18,2), payment) AS payment,
|
||||
TRY_CONVERT(decimal(18,2), free_amount) AS free_amount,
|
||||
(
|
||||
COALESCE(TRY_CONVERT(decimal(18,2), freight), 0)
|
||||
+ COALESCE(TRY_CONVERT(decimal(18,2), refund), 0)
|
||||
- COALESCE(TRY_CONVERT(decimal(18,2), payment), 0)
|
||||
- COALESCE(TRY_CONVERT(decimal(18,2), free_amount), 0)
|
||||
) AS total_refund_money,
|
||||
CAST(shop_status AS nvarchar(100)) AS shop_status,
|
||||
CAST(question_type AS nvarchar(255)) AS question_type,
|
||||
CAST(result AS nvarchar(255)) AS process_result,
|
||||
CAST(status AS nvarchar(100)) AS status,
|
||||
CONVERT(varchar(19), created, 120) AS created,
|
||||
CONVERT(varchar(19), modified, 120) AS modified,
|
||||
CAST(l_id AS nvarchar(120)) AS l_id,
|
||||
CONVERT(varchar(19), confirm_date, 120) AS confirm_date,
|
||||
CAST(creator_name AS nvarchar(100)) AS creator_name,
|
||||
CAST(N'' AS nvarchar(255)) AS original_order_no,
|
||||
CAST(N'' AS nvarchar(120)) AS original_courier_name,
|
||||
CAST(N'' AS nvarchar(120)) AS original_waybill_no
|
||||
FROM dbo.jst_refund_pdd_fz_view
|
||||
WHERE CONVERT(date, created) = @statDate
|
||||
)
|
||||
SELECT *
|
||||
FROM unified_refunds
|
||||
ORDER BY created ASC, source_key ASC, as_id ASC;
|
||||
`;
|
||||
|
||||
async function writeSyncLog({ statDate, rowCount, status, errorMessage = '' }) {
|
||||
await database.local.query(
|
||||
`INSERT INTO aftersales_refund_detail_sync
|
||||
(stat_date, sync_version, row_count, status, error_message, synced_at)
|
||||
VALUES (?, ?, ?, ?, ?, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
sync_version = VALUES(sync_version),
|
||||
row_count = VALUES(row_count),
|
||||
status = VALUES(status),
|
||||
error_message = VALUES(error_message),
|
||||
synced_at = NOW()`,
|
||||
[statDate, SYNC_VERSION, rowCount, status, errorMessage.slice(0, 1000)]
|
||||
);
|
||||
}
|
||||
|
||||
async function insertRows(rows) {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnSql = TARGET_COLUMNS.map((column) => `\`${column}\``).join(', ');
|
||||
for (let index = 0; index < rows.length; index += INSERT_BATCH_SIZE) {
|
||||
const batch = rows.slice(index, index + INSERT_BATCH_SIZE).map(toInsertRow);
|
||||
await database.local.query(
|
||||
`INSERT INTO aftersales_refund_detail_read (${columnSql}) VALUES ?`,
|
||||
[batch]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAftersalesRefundDetailDate(inputDate) {
|
||||
const statDate = normalizeDate(inputDate);
|
||||
console.log(`[aftersales-refund-detail] sync start ${statDate}`);
|
||||
|
||||
try {
|
||||
const rows = await cloudDb.query(UNION_SQL, { statDate });
|
||||
await database.local.query('DELETE FROM aftersales_refund_detail_read WHERE stat_date = ?', [statDate]);
|
||||
await insertRows(rows);
|
||||
await applyAftersalesRefundExportOverrides(statDate);
|
||||
await writeSyncLog({ statDate, rowCount: rows.length, status: 'success' });
|
||||
console.log(`[aftersales-refund-detail] sync done ${statDate}, rows=${rows.length}`);
|
||||
return { statDate, rowCount: rows.length };
|
||||
} catch (error) {
|
||||
await writeSyncLog({
|
||||
statDate,
|
||||
rowCount: 0,
|
||||
status: 'failed',
|
||||
errorMessage: error.message || String(error),
|
||||
}).catch((logError) => {
|
||||
console.warn('[aftersales-refund-detail] sync log failed:', logError.message || logError);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAftersalesRefundDetailRange(startDate, endDate = startDate) {
|
||||
const results = [];
|
||||
for (const statDate of listDatesInRange(startDate, endDate)) {
|
||||
results.push(await refreshAftersalesRefundDetailDate(statDate));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SYNC_VERSION,
|
||||
refreshAftersalesRefundDetailDate,
|
||||
refreshAftersalesRefundDetailRange,
|
||||
};
|
||||
363
backend/src/services/aftersalesRefundExportOverride.js
Normal file
363
backend/src/services/aftersalesRefundExportOverride.js
Normal file
@ -0,0 +1,363 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const dayjs = require('dayjs');
|
||||
const XLSX = require('xlsx');
|
||||
const database = require('../config/database');
|
||||
|
||||
const INSERT_BATCH_SIZE = 500;
|
||||
|
||||
const TARGET_COLUMNS = [
|
||||
'stat_date',
|
||||
'as_id',
|
||||
'outer_as_id',
|
||||
'o_id',
|
||||
'so_id',
|
||||
'process_result',
|
||||
'question_type',
|
||||
'apply_time',
|
||||
'original_order_no',
|
||||
'original_courier_name',
|
||||
'original_waybill_no',
|
||||
'source_file',
|
||||
];
|
||||
|
||||
function textOrEmpty(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function normalizeWaybill(value) {
|
||||
return textOrEmpty(value).replace(/^[@']+(?=[A-Za-z0-9])/u, '');
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
const parsed = dayjs(value);
|
||||
if (!parsed.isValid()) {
|
||||
throw new Error(`Invalid date: ${value}`);
|
||||
}
|
||||
return parsed.format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function normalizeDateTime(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : null;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
const decoded = XLSX.SSF.parse_date_code(value);
|
||||
if (!decoded) return null;
|
||||
return dayjs(new Date(
|
||||
decoded.y,
|
||||
decoded.m - 1,
|
||||
decoded.d,
|
||||
decoded.H,
|
||||
decoded.M,
|
||||
Math.floor(decoded.S)
|
||||
)).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
const text = textOrEmpty(value).replace(/\//g, '-');
|
||||
if (!text) return null;
|
||||
const parsed = dayjs(text);
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : null;
|
||||
}
|
||||
|
||||
function normalizeSheetRow(row) {
|
||||
const normalized = {};
|
||||
for (const [key, value] of Object.entries(row || {})) {
|
||||
normalized[textOrEmpty(key)] = value;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getRowValue(row, keys) {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(row, key)) {
|
||||
return row[key];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseWorkbook(filePath) {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const workbook = extension === '.csv' || extension === '.tsv'
|
||||
? XLSX.read(fs.readFileSync(filePath, 'utf8'), {
|
||||
type: 'string',
|
||||
raw: false,
|
||||
})
|
||||
: XLSX.readFile(filePath, {
|
||||
cellDates: true,
|
||||
raw: false,
|
||||
});
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
if (!sheetName) {
|
||||
throw new Error(`Workbook has no sheets: ${filePath}`);
|
||||
}
|
||||
|
||||
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], {
|
||||
defval: '',
|
||||
raw: false,
|
||||
}).map(normalizeSheetRow);
|
||||
}
|
||||
|
||||
function toImportRow(row, sourceFile) {
|
||||
const applyTime = normalizeDateTime(getRowValue(row, ['申请日期']));
|
||||
if (!applyTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asId = textOrEmpty(getRowValue(row, ['售后单号']));
|
||||
const outerAsId = textOrEmpty(getRowValue(row, ['退款编码']));
|
||||
if (!asId && !outerAsId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
stat_date: normalizeDate(applyTime),
|
||||
as_id: asId,
|
||||
outer_as_id: outerAsId,
|
||||
o_id: textOrEmpty(getRowValue(row, ['内部订单号'])),
|
||||
so_id: textOrEmpty(getRowValue(row, ['线上订单号', '线上子订单编号'])),
|
||||
process_result: textOrEmpty(getRowValue(row, ['处理结果'])),
|
||||
question_type: textOrEmpty(getRowValue(row, ['问题类型'])),
|
||||
apply_time: applyTime,
|
||||
original_order_no: textOrEmpty(getRowValue(row, ['原始订单号'])),
|
||||
original_courier_name: textOrEmpty(getRowValue(row, ['原订单快递'])),
|
||||
original_waybill_no: normalizeWaybill(getRowValue(row, ['原订单快递单号'])),
|
||||
source_file: sourceFile,
|
||||
};
|
||||
}
|
||||
|
||||
function toInsertRow(row) {
|
||||
return TARGET_COLUMNS.map((column) => {
|
||||
if (column === 'apply_time') {
|
||||
return row[column] || null;
|
||||
}
|
||||
return row[column] || '';
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertRows(rows) {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnSql = TARGET_COLUMNS.map((column) => `\`${column}\``).join(', ');
|
||||
for (let index = 0; index < rows.length; index += INSERT_BATCH_SIZE) {
|
||||
const batch = rows.slice(index, index + INSERT_BATCH_SIZE).map(toInsertRow);
|
||||
await database.local.query(
|
||||
`INSERT INTO aftersales_refund_export_override (${columnSql})
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
o_id = VALUES(o_id),
|
||||
so_id = VALUES(so_id),
|
||||
process_result = VALUES(process_result),
|
||||
question_type = VALUES(question_type),
|
||||
apply_time = VALUES(apply_time),
|
||||
original_order_no = VALUES(original_order_no),
|
||||
original_courier_name = VALUES(original_courier_name),
|
||||
original_waybill_no = VALUES(original_waybill_no),
|
||||
source_file = VALUES(source_file),
|
||||
imported_at = NOW()`,
|
||||
[batch]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildDateFilter(startDate, endDate) {
|
||||
if (!startDate && !endDate) {
|
||||
return () => true;
|
||||
}
|
||||
|
||||
const start = startDate ? normalizeDate(startDate) : null;
|
||||
const end = endDate ? normalizeDate(endDate) : start;
|
||||
return (row) => {
|
||||
if (start && row.stat_date < start) return false;
|
||||
if (end && row.stat_date > end) return false;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
async function importAftersalesRefundExportOverrideFile(filePath, options = {}) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`File not found: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
const sourceFile = path.basename(resolvedPath);
|
||||
const shouldKeep = buildDateFilter(options.startDate, options.endDate);
|
||||
const rows = parseWorkbook(resolvedPath)
|
||||
.map((row) => toImportRow(row, sourceFile))
|
||||
.filter(Boolean)
|
||||
.filter(shouldKeep);
|
||||
|
||||
await upsertRows(rows);
|
||||
|
||||
const dateSummary = rows.reduce((acc, row) => {
|
||||
acc[row.stat_date] = (acc[row.stat_date] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
filePath: resolvedPath,
|
||||
rowCount: rows.length,
|
||||
dates: Object.keys(dateSummary).sort(),
|
||||
dateSummary,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOverrideSetClause() {
|
||||
return `
|
||||
t.process_result = CASE
|
||||
WHEN NULLIF(o.process_result, '') IS NOT NULL THEN o.process_result
|
||||
ELSE t.process_result
|
||||
END,
|
||||
t.original_order_no = CASE
|
||||
WHEN NULLIF(o.original_order_no, '') IS NOT NULL THEN o.original_order_no
|
||||
ELSE t.original_order_no
|
||||
END,
|
||||
t.original_courier_name = CASE
|
||||
WHEN NULLIF(o.original_courier_name, '') IS NOT NULL THEN o.original_courier_name
|
||||
ELSE t.original_courier_name
|
||||
END,
|
||||
t.original_waybill_no = CASE
|
||||
WHEN NULLIF(o.original_waybill_no, '') IS NOT NULL THEN o.original_waybill_no
|
||||
ELSE t.original_waybill_no
|
||||
END
|
||||
`;
|
||||
}
|
||||
|
||||
async function applyAftersalesRefundExportOverrides(statDate) {
|
||||
const setClause = buildOverrideSetClause();
|
||||
const updates = [
|
||||
`UPDATE aftersales_refund_detail_read t
|
||||
JOIN aftersales_refund_export_override o
|
||||
ON t.stat_date = o.stat_date
|
||||
AND t.as_id <> ''
|
||||
AND o.as_id <> ''
|
||||
AND t.as_id = o.as_id
|
||||
SET ${setClause}
|
||||
WHERE t.stat_date = ?`,
|
||||
`UPDATE aftersales_refund_detail_read t
|
||||
JOIN aftersales_refund_export_override o
|
||||
ON t.stat_date = o.stat_date
|
||||
AND t.outer_as_id <> ''
|
||||
AND o.outer_as_id <> ''
|
||||
AND t.outer_as_id = o.outer_as_id
|
||||
SET ${setClause}
|
||||
WHERE t.stat_date = ?
|
||||
AND (t.process_result = '' OR t.original_waybill_no = '' OR t.original_courier_name = '')`,
|
||||
`UPDATE aftersales_refund_detail_read t
|
||||
JOIN aftersales_refund_export_override o
|
||||
ON t.stat_date = o.stat_date
|
||||
AND t.o_id <> ''
|
||||
AND o.o_id <> ''
|
||||
AND t.so_id <> ''
|
||||
AND o.so_id <> ''
|
||||
AND t.o_id = o.o_id
|
||||
AND t.so_id = o.so_id
|
||||
SET ${setClause}
|
||||
WHERE t.stat_date = ?
|
||||
AND (t.process_result = '' OR t.original_waybill_no = '' OR t.original_courier_name = '')`,
|
||||
];
|
||||
|
||||
let affectedRows = 0;
|
||||
for (const sql of updates) {
|
||||
const result = await database.local.query(sql, [statDate]);
|
||||
affectedRows += Number(result.affectedRows || 0);
|
||||
}
|
||||
|
||||
const insertMissingResult = await database.local.query(
|
||||
`INSERT INTO aftersales_refund_detail_read (
|
||||
stat_date,
|
||||
source_key,
|
||||
source_table,
|
||||
as_id,
|
||||
outer_as_id,
|
||||
o_id,
|
||||
so_id,
|
||||
type,
|
||||
node,
|
||||
order_labels,
|
||||
shop_name,
|
||||
freight,
|
||||
refund,
|
||||
payment,
|
||||
free_amount,
|
||||
total_refund_money,
|
||||
shop_status,
|
||||
question_type,
|
||||
process_result,
|
||||
status,
|
||||
created,
|
||||
modified,
|
||||
l_id,
|
||||
confirm_date,
|
||||
creator_name,
|
||||
original_order_no,
|
||||
original_courier_name,
|
||||
original_waybill_no
|
||||
)
|
||||
SELECT
|
||||
o.stat_date,
|
||||
'aftersales_refund_export_override' AS source_key,
|
||||
CONCAT('local.', o.source_file) AS source_table,
|
||||
o.as_id,
|
||||
o.outer_as_id,
|
||||
o.o_id,
|
||||
o.so_id,
|
||||
'' AS type,
|
||||
'' AS node,
|
||||
'' AS order_labels,
|
||||
'' AS shop_name,
|
||||
0 AS freight,
|
||||
0 AS refund,
|
||||
0 AS payment,
|
||||
0 AS free_amount,
|
||||
0 AS total_refund_money,
|
||||
'' AS shop_status,
|
||||
o.question_type,
|
||||
o.process_result,
|
||||
'' AS status,
|
||||
o.apply_time AS created,
|
||||
o.apply_time AS modified,
|
||||
'' AS l_id,
|
||||
NULL AS confirm_date,
|
||||
'' AS creator_name,
|
||||
o.original_order_no,
|
||||
o.original_courier_name,
|
||||
o.original_waybill_no
|
||||
FROM aftersales_refund_export_override o
|
||||
LEFT JOIN aftersales_refund_detail_read t
|
||||
ON t.stat_date = o.stat_date
|
||||
AND (
|
||||
(o.as_id <> '' AND t.as_id = o.as_id)
|
||||
OR (o.outer_as_id <> '' AND t.outer_as_id = o.outer_as_id)
|
||||
OR (o.o_id <> '' AND o.so_id <> '' AND t.o_id = o.o_id AND t.so_id = o.so_id)
|
||||
)
|
||||
WHERE o.stat_date = ?
|
||||
AND t.id IS NULL`,
|
||||
[statDate]
|
||||
);
|
||||
|
||||
return {
|
||||
statDate,
|
||||
affectedRows,
|
||||
insertedRows: Number(insertMissingResult.affectedRows || 0),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyAftersalesRefundExportOverrides,
|
||||
importAftersalesRefundExportOverrideFile,
|
||||
};
|
||||
375
backend/src/services/aiBuyerShowReviewService.js
Normal file
375
backend/src/services/aiBuyerShowReviewService.js
Normal file
@ -0,0 +1,375 @@
|
||||
const axios = require('axios');
|
||||
const database = require('../config/database');
|
||||
|
||||
const DEFAULT_REVIEW_TOTAL = 10;
|
||||
const REVIEW_TOTAL_OPTIONS = [5, 10, 20, 40, 60, 80, 100];
|
||||
const REVIEW_BATCH_SIZE = 5;
|
||||
const REVIEW_MAX_CONCURRENCY = 4;
|
||||
const REVIEW_MAX_REFILL_ATTEMPTS = 3;
|
||||
|
||||
const SUPPORTED_SCENES = [
|
||||
'卧室', '书房', '客厅', '餐厅', '阳台', '入户门', '玄关',
|
||||
'办公室', '户外', '厨房', '电竞房',
|
||||
];
|
||||
const SUPPORTED_USER_TYPES = [
|
||||
'自己', '妈妈', '爸爸', '儿子', '女儿', '老公', '老婆', '男朋友',
|
||||
'女朋友', '闺蜜', '朋友', '同事', '室友', '老人', '孩子', '学生',
|
||||
'上班族', '租房党', '新婚家庭', '宝妈', '宠物家庭', '客户', '领导',
|
||||
];
|
||||
|
||||
function normalizeMultiInput(value) {
|
||||
const raw = Array.isArray(value) ? value : String(value || '').split(/[,,、\n\r]+/);
|
||||
return Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback = true) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function getSystemConfigMap(keys) {
|
||||
try {
|
||||
const placeholders = keys.map(() => '?').join(', ');
|
||||
const rows = await database.local.query(
|
||||
`SELECT config_key AS configKey, config_value AS configValue
|
||||
FROM system_config
|
||||
WHERE config_key IN (${placeholders})`,
|
||||
keys
|
||||
);
|
||||
return new Map(rows.map((row) => [row.configKey, String(row.configValue || '').trim()]));
|
||||
} catch (_) {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function getRuntimeConfig() {
|
||||
const keys = [
|
||||
'ai_buyer_show_ark_api_key',
|
||||
'ai_buyer_show_ark_base_url',
|
||||
'ai_buyer_show_vision_model',
|
||||
'ai_buyer_show_review_model',
|
||||
];
|
||||
const stored = await getSystemConfigMap(keys);
|
||||
const apiKey = String(process.env.ARK_API_KEY || stored.get(keys[0]) || '').trim();
|
||||
if (!apiKey) {
|
||||
const error = new Error('未配置豆包 ARK_API_KEY,无法生成攻心评价');
|
||||
error.statusCode = 503;
|
||||
throw error;
|
||||
}
|
||||
const baseUrl = process.env.ARK_BASE_URL
|
||||
|| stored.get(keys[1])
|
||||
|| 'https://ark.cn-beijing.volces.com/api/v3';
|
||||
const visionModel = process.env.VISION_MODEL
|
||||
|| stored.get(keys[2])
|
||||
|| 'doubao-1-5-vision-pro-32k-250115';
|
||||
const reviewModel = process.env.PDD_HEART_REVIEW_MODEL
|
||||
|| process.env.DOUBAO_TEXT_MODEL
|
||||
|| stored.get(keys[3])
|
||||
|| visionModel;
|
||||
return { apiKey, baseUrl, visionModel, reviewModel };
|
||||
}
|
||||
|
||||
function buildImageDataUrl(buffer, mimeType) {
|
||||
const type = String(mimeType || '').startsWith('image/') ? mimeType : 'image/jpeg';
|
||||
return `data:${type};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
function parseJsonContent(content, fallback) {
|
||||
const trimmed = String(content || '')
|
||||
.trim()
|
||||
.replace(/^```json\s*/i, '')
|
||||
.replace(/^```\s*/i, '')
|
||||
.replace(/```$/i, '')
|
||||
.trim();
|
||||
if (!trimmed) return fallback;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (_) {
|
||||
const candidate = trimmed.match(/\[[\s\S]*\]/)?.[0]
|
||||
|| trimmed.match(/\{[\s\S]*\}/)?.[0];
|
||||
if (!candidate) return fallback;
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function callChatCompletion(messages, modelType, options = {}) {
|
||||
const config = await getRuntimeConfig();
|
||||
const model = modelType === 'vision' ? config.visionModel : config.reviewModel;
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${String(config.baseUrl).replace(/\/$/, '')}/chat/completions`,
|
||||
{
|
||||
model,
|
||||
messages,
|
||||
temperature: options.temperature ?? 0.68,
|
||||
...(options.maxTokens ? { max_tokens: options.maxTokens } : {}),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 180000,
|
||||
}
|
||||
);
|
||||
return {
|
||||
content: response.data?.choices?.[0]?.message?.content || '',
|
||||
model: response.data?.model || model,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.error?.message
|
||||
|| error.response?.data?.message
|
||||
|| error.response?.data?.error
|
||||
|| error.message
|
||||
|| 'AI接口调用失败';
|
||||
const wrapped = new Error(`AI接口调用失败:${detail}`);
|
||||
wrapped.statusCode = error.response?.status || 502;
|
||||
throw wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeImage({ imageBuffer, mimeType, scenes, userTypes, sellingPoints }) {
|
||||
const prompt = [
|
||||
'你是拼多多商品评价运营助手。请识别商品图片,并结合运营填写的信息,严格返回 JSON 对象。',
|
||||
'只返回 JSON,不要 markdown,不要解释。',
|
||||
'JSON 字段:productCategory、productNameGuess、furnitureColors、visualScene、visualSellingPoints、userPainPoints、emotionalAngles。',
|
||||
`运营选择场景:${scenes.length ? scenes.join('、') : '未填写,请根据图片判断最合适场景'}`,
|
||||
`运营选择使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`,
|
||||
`运营填写卖点:${sellingPoints}`,
|
||||
].join('\n');
|
||||
const result = await callChatCompletion([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: buildImageDataUrl(imageBuffer, mimeType) } },
|
||||
],
|
||||
}], 'vision');
|
||||
return { analysis: parseJsonContent(result.content, {}), model: result.model };
|
||||
}
|
||||
|
||||
function normalizeReviewText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').replace(/^["'“”]+|["'“”]+$/g, '').trim();
|
||||
}
|
||||
|
||||
function stripEmoji(value) {
|
||||
return String(value || '')
|
||||
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '')
|
||||
.replace(/\p{Extended_Pictographic}/gu, '')
|
||||
.replace(/[\u{FE0E}\u{FE0F}\u{200D}]/gu, '')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeReviewItem(value, useEmoji) {
|
||||
const source = typeof value === 'string' ? { reviewContent: value } : (value || {});
|
||||
const reviewContent = normalizeReviewText(
|
||||
source.reviewContent || source.content || source.text || source.review || source['评价内容']
|
||||
);
|
||||
if (!reviewContent) return null;
|
||||
return {
|
||||
reviewContent: useEmoji ? reviewContent : stripEmoji(reviewContent),
|
||||
userType: normalizeReviewText(source.userType || source.user || source.person || source['使用人']),
|
||||
sellingPoint: normalizeReviewText(source.sellingPoint || source.selling_points || source.point || source['突出卖点']),
|
||||
scene: normalizeReviewText(source.scene || source.usageScene || source['使用场景']),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviews(content, useEmoji) {
|
||||
const parsed = parseJsonContent(content, null);
|
||||
const source = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.reviews) ? parsed.reviews : []);
|
||||
if (source.length) return source.map((item) => normalizeReviewItem(item, useEmoji)).filter(Boolean);
|
||||
return String(content || '')
|
||||
.split(/\n+/)
|
||||
.map((line) => line.replace(/^\s*(\d+[.、)]|[-*])\s*/, ''))
|
||||
.filter((line) => line.trim().length >= 30)
|
||||
.map((line) => normalizeReviewItem(line, useEmoji))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function dedupeReviews(reviews) {
|
||||
const seen = new Set();
|
||||
return reviews.filter((review) => {
|
||||
const key = review.reviewContent.replace(/[,。!?,.!?\s]/g, '').slice(0, 80);
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function diversifyOpenings(reviews) {
|
||||
const counts = new Map();
|
||||
const replacements = {
|
||||
'说实话': ['其实,', '老实讲,', '用下来,'],
|
||||
'本来': ['一开始', '之前', '原先'],
|
||||
'我是': ['家里', '最近', '买回来后'],
|
||||
};
|
||||
return reviews.map((review) => {
|
||||
const opening = Object.keys(replacements).find((item) => review.reviewContent.startsWith(item));
|
||||
if (!opening) return review;
|
||||
const used = counts.get(opening) || 0;
|
||||
counts.set(opening, used + 1);
|
||||
if (used === 0 && opening !== '我是') return review;
|
||||
return {
|
||||
...review,
|
||||
reviewContent: review.reviewContent.replace(
|
||||
new RegExp(`^${opening}[,,、\s]*`),
|
||||
replacements[opening][used % replacements[opening].length]
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function generateReviewBatch({ count, batchIndex, scenes, userTypes, sellingPoints, analysis, useEmoji }) {
|
||||
const colors = normalizeMultiInput(analysis?.furnitureColors || analysis?.colors);
|
||||
const prompt = [
|
||||
'生成拼多多家具真实买家评价。',
|
||||
`1. 本批生成 ${count} 条。`,
|
||||
'2. 每条 50-200 个中文字符。',
|
||||
'3. 真实买家口吻,不要广告腔,不要出现“攻心”“卖点”“AI”。',
|
||||
'4. 角度要有差异,覆盖使用前顾虑、场景、到手体验、细节、家人反馈。',
|
||||
'5. 不要夸张承诺,不要医疗功效,不要绝对化表达。',
|
||||
'6. 场景和使用人自然交叉组合。',
|
||||
`7. 家具颜色只能使用这些图片识别颜色:${colors.length ? colors.join('、') : '未识别到颜色时不要主动描写颜色'}。`,
|
||||
'8. 文风口语化,像真实用户随手写的评价,不要整齐的产品说明书语气。',
|
||||
useEmoji
|
||||
? '9. 允许少量使用 emoji,每条最多 0-2 个,不要每条都必须有。'
|
||||
: '9. 严禁使用任何 emoji、颜文字或表情符号,一个都不要出现。',
|
||||
'10. 只返回 JSON 数组,每个元素字段固定:reviewContent、userType、sellingPoint、scene。',
|
||||
'11. 同一批开头必须分散,尽量不要使用“我是、本来、说实话、一开始、之前、之前还担心”开头。',
|
||||
`批次:${batchIndex}`,
|
||||
`可用场景:${scenes.length ? scenes.join('、') : analysis?.visualScene || '按图片判断'}`,
|
||||
`可用使用人:${userTypes.length ? userTypes.join('、') : '自己购买给自己使用'}`,
|
||||
`运营填写卖点:${sellingPoints}`,
|
||||
`图片识别信息:${JSON.stringify(analysis || {})}`,
|
||||
].join('\n');
|
||||
const result = await callChatCompletion([{ role: 'user', content: prompt }], 'review', {
|
||||
maxTokens: Math.min(4096, 240 * count),
|
||||
});
|
||||
return { reviews: parseReviews(result.content, useEmoji), prompt, model: result.model };
|
||||
}
|
||||
|
||||
async function runWithLimit(tasks, limit, onResult) {
|
||||
let cursor = 0;
|
||||
async function worker() {
|
||||
while (cursor < tasks.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
const result = await tasks[index]();
|
||||
onResult(result, index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, () => worker()));
|
||||
}
|
||||
|
||||
async function generateHeartReviews({
|
||||
imageBuffer, mimeType, scene, userType, sellingPoints, useEmoji, reviewTotal, onEvent,
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
const scenes = normalizeMultiInput(scene).filter((item) => SUPPORTED_SCENES.includes(item));
|
||||
const userTypes = normalizeMultiInput(userType).filter((item) => SUPPORTED_USER_TYPES.includes(item));
|
||||
const effectiveUserTypes = userTypes.length ? userTypes : ['自己购买给自己使用'];
|
||||
const normalizedSellingPoints = String(sellingPoints || '').trim();
|
||||
const targetTotal = REVIEW_TOTAL_OPTIONS.includes(Number(reviewTotal))
|
||||
? Number(reviewTotal)
|
||||
: DEFAULT_REVIEW_TOTAL;
|
||||
const normalizedUseEmoji = normalizeBoolean(useEmoji, true);
|
||||
if (!normalizedSellingPoints) {
|
||||
const error = new Error('请填写商品卖点');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const vision = await analyzeImage({
|
||||
imageBuffer, mimeType, scenes, userTypes: effectiveUserTypes, sellingPoints: normalizedSellingPoints,
|
||||
});
|
||||
onEvent?.({ type: 'analysis', imageAnalysis: vision.analysis });
|
||||
|
||||
let reviews = [];
|
||||
let reviewPrompt = '';
|
||||
let reviewModel = '';
|
||||
const append = (batch) => {
|
||||
if (!reviewPrompt) reviewPrompt = batch.prompt;
|
||||
if (!reviewModel) reviewModel = batch.model;
|
||||
reviews = dedupeReviews(diversifyOpenings([...reviews, ...batch.reviews])).slice(0, targetTotal);
|
||||
onEvent?.({ type: 'batch', total: reviews.length, targetTotal });
|
||||
};
|
||||
const makeTasks = (count, prefix) => Array.from({ length: Math.ceil(count / REVIEW_BATCH_SIZE) }, (_, index) => {
|
||||
const batchCount = Math.min(REVIEW_BATCH_SIZE, count - index * REVIEW_BATCH_SIZE);
|
||||
return () => generateReviewBatch({
|
||||
count: batchCount,
|
||||
batchIndex: `${prefix}${index + 1}`,
|
||||
scenes: scenes.length ? scenes : [vision.analysis?.visualScene].filter(Boolean),
|
||||
userTypes: effectiveUserTypes,
|
||||
sellingPoints: normalizedSellingPoints,
|
||||
analysis: vision.analysis,
|
||||
useEmoji: normalizedUseEmoji,
|
||||
});
|
||||
});
|
||||
|
||||
await runWithLimit(makeTasks(targetTotal, ''), REVIEW_MAX_CONCURRENCY, append);
|
||||
for (let attempt = 1; reviews.length < targetTotal && attempt <= REVIEW_MAX_REFILL_ATTEMPTS; attempt += 1) {
|
||||
await runWithLimit(makeTasks(targetTotal - reviews.length, `补充${attempt}-`), REVIEW_MAX_CONCURRENCY, append);
|
||||
}
|
||||
if (reviews.length < targetTotal) {
|
||||
const error = new Error(`AI返回评价数量不足,仅生成 ${reviews.length} 条,请重试`);
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
scene: scenes.length ? scenes.join('、') : (vision.analysis?.visualScene || 'AI识别场景'),
|
||||
scenes,
|
||||
userTypes: effectiveUserTypes,
|
||||
sellingPoints: normalizedSellingPoints,
|
||||
useEmoji: normalizedUseEmoji,
|
||||
imageAnalysis: vision.analysis,
|
||||
reviewPrompt,
|
||||
reviews: reviews.slice(0, targetTotal),
|
||||
total: targetTotal,
|
||||
targetTotal,
|
||||
responseMs: Date.now() - startedAt,
|
||||
models: { vision: vision.model, review: reviewModel },
|
||||
};
|
||||
}
|
||||
|
||||
function buildBuyerShowPrompt({ review, analysis, style = '真实' }) {
|
||||
const normalizedStyle = style === '饱和' ? '饱和' : '真实';
|
||||
const productHints = normalizeMultiInput([
|
||||
analysis?.productCategory,
|
||||
analysis?.productNameGuess,
|
||||
...(Array.isArray(analysis?.visualSellingPoints) ? analysis.visualSellingPoints : []),
|
||||
]).join('、');
|
||||
const styleRule = normalizedStyle === '饱和'
|
||||
? '画面可以更精致、饱满、明亮,接近小红书达人分享风格,但商品主体仍必须真实可信,不能变成棚拍硬广、样板间或3D渲染图。'
|
||||
: '普通买家用手机随手拍的真实照片,生活气息强,略微随意,普通室内自然光,不要商业摄影感、样板间、卖场图或渲染图。';
|
||||
return [
|
||||
`根据上传的商品参考图和下面这条买家评价,生成${normalizedStyle}风格的买家秀图片。系统会分 5 次生成 5 张单图,每次只生成其中一张照片。`,
|
||||
`当前风格:${normalizedStyle}`,
|
||||
'硬性要求:必须以参考图里的商品为唯一主体,严格保持商品外观、颜色、材质、比例、结构、边角、五金、纹理、开孔、抽屉、柜门、支脚、轮子等全部产品细节不变。不能改款、变形、新增或删减部件。商品参考图仅用于锁定商品本体,不允许沿用其房间、背景、软装和场景。',
|
||||
'家具结构数量限制:柜体、柜门、抽屉、层板、支脚、把手、开孔、轮子、拉篮和五金件数量、位置、比例必须与参考图一致。',
|
||||
'组图要求:5 张结果图必须像同一位买家在同一个房间、同一个时间围绕同一个商品连续拍摄。整体规划为 2 张完整商品照片 + 3 张局部细节照片。禁止拼图、分屏、九宫格和多画面排版。',
|
||||
`买家评价:${review.reviewContent || ''}`,
|
||||
`使用人:${review.userType || '按评价自然判断'}`,
|
||||
`使用场景:${review.scene || '按评价自然判断'}`,
|
||||
`突出卖点:${review.sellingPoint || '按评价自然提炼'}`,
|
||||
productHints ? `图片识别到的商品信息:${productHints}` : '',
|
||||
`${styleRule} 如果上传了场景参考图,必须以它作为唯一主要场景依据,尽量保留房间类型、空间布局、墙面地面、窗户、非主体家具、软装、生活物品、光线氛围和模特姿态;只有冲突或遮挡商品主体的元素才可轻微调整。没有场景参考图时,才根据评价重新构建生活化场景。场景适合时最好需要有中国模特自然出镜,不能露脸,不能遮挡商品。输出 4:3,1K。`,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_REVIEW_TOTAL,
|
||||
REVIEW_TOTAL_OPTIONS,
|
||||
SUPPORTED_SCENES,
|
||||
SUPPORTED_USER_TYPES,
|
||||
buildBuyerShowPrompt,
|
||||
generateHeartReviews,
|
||||
};
|
||||
237
backend/src/services/aiBuyerShowSeedPackService.js
Normal file
237
backend/src/services/aiBuyerShowSeedPackService.js
Normal file
@ -0,0 +1,237 @@
|
||||
const crypto = require('crypto');
|
||||
const {
|
||||
getPool,
|
||||
query,
|
||||
resolveUser,
|
||||
} = require('./aiBuyerShowService');
|
||||
const { uploadBufferToDefaultS3 } = require('./objectStorage');
|
||||
|
||||
const SEED_PACK_TABLE = 'pdd_seed_resource_packs';
|
||||
const MAX_SEED_PACK_FILE_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
let tableReady = false;
|
||||
|
||||
function normalizeString(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeNullableString(value) {
|
||||
const text = normalizeString(value);
|
||||
return text || null;
|
||||
}
|
||||
|
||||
// multer/busboy may decode multipart filenames as latin1, garbling non-ASCII
|
||||
// names; re-encode to recover the original UTF-8 text.
|
||||
function normalizeUploadFileName(value) {
|
||||
const text = normalizeString(value);
|
||||
if (!text) return '';
|
||||
try {
|
||||
const decoded = Buffer.from(text, 'latin1').toString('utf8');
|
||||
return /[\uFFFD]/.test(decoded) ? text : decoded;
|
||||
} catch (_) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSeedPackTable() {
|
||||
if (tableReady) return;
|
||||
await query(
|
||||
`CREATE TABLE IF NOT EXISTS ${SEED_PACK_TABLE} (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '资源包ID',
|
||||
pack_uid VARCHAR(64) NOT NULL COMMENT '资源包唯一标识',
|
||||
shop_name VARCHAR(128) NOT NULL COMMENT '店铺名称',
|
||||
product_id VARCHAR(64) NOT NULL COMMENT '商品ID',
|
||||
sku_name VARCHAR(255) NULL COMMENT 'SKU名称',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT 'ZIP文件名',
|
||||
file_size BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
|
||||
object_key VARCHAR(512) NOT NULL COMMENT '对象存储Key',
|
||||
file_url VARCHAR(1024) NULL COMMENT '公开下载地址',
|
||||
review_count INT NOT NULL DEFAULT 0 COMMENT '包含评价条数',
|
||||
operator_id VARCHAR(64) NULL COMMENT '上传人ID',
|
||||
operator_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '上传人',
|
||||
download_count INT NOT NULL DEFAULT 0 COMMENT '下载次数',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_pack_uid (pack_uid),
|
||||
KEY idx_seed_pack_shop (shop_name),
|
||||
KEY idx_seed_pack_product (product_id),
|
||||
KEY idx_seed_pack_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='种菜资源包'`
|
||||
);
|
||||
tableReady = true;
|
||||
}
|
||||
|
||||
function normalizePackRow(row = {}) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
packUid: row.pack_uid ?? row.packUid ?? '',
|
||||
shopName: row.shop_name ?? row.shopName ?? '',
|
||||
productId: row.product_id ?? row.productId ?? '',
|
||||
skuName: row.sku_name ?? row.skuName ?? '',
|
||||
fileName: row.file_name ?? row.fileName ?? '',
|
||||
fileSize: Number(row.file_size ?? row.fileSize ?? 0),
|
||||
fileUrl: row.file_url ?? row.fileUrl ?? '',
|
||||
reviewCount: Number(row.review_count ?? row.reviewCount ?? 0),
|
||||
operatorName: row.operator_name ?? row.operatorName ?? '',
|
||||
downloadCount: Number(row.download_count ?? row.downloadCount ?? 0),
|
||||
createdAt: row.created_at || null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSeedPackInput({ shopName, productId, file }) {
|
||||
if (!normalizeString(shopName)) {
|
||||
const error = new Error('店铺名称不能为空');
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
if (!normalizeString(productId)) {
|
||||
const error = new Error('商品ID不能为空');
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
if (!file?.buffer || !file.buffer.length) {
|
||||
const error = new Error('ZIP 文件不能为空');
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
if (file.buffer.length > MAX_SEED_PACK_FILE_BYTES) {
|
||||
const error = new Error('ZIP 文件不能超过 200MB');
|
||||
error.statusCode = 413;
|
||||
return error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function createSeedPack({ shopName, productId, skuName, fileName, reviewCount, file }, user = {}) {
|
||||
await ensureSeedPackTable();
|
||||
const operator = resolveUser(user);
|
||||
const validationError = validateSeedPackInput({ shopName, productId, file });
|
||||
if (validationError) throw validationError;
|
||||
|
||||
const uploaded = await uploadBufferToDefaultS3(
|
||||
file.buffer,
|
||||
'application/zip',
|
||||
'seed-packs',
|
||||
'zip'
|
||||
);
|
||||
|
||||
const resolvedFileName = normalizeString(fileName)
|
||||
|| normalizeUploadFileName(file?.originalname)
|
||||
|| `种菜资源包_${Date.now()}.zip`;
|
||||
|
||||
const packUid = crypto.randomUUID();
|
||||
const insertRows = await query(
|
||||
`INSERT INTO ${SEED_PACK_TABLE} (
|
||||
pack_uid, shop_name, product_id, sku_name, file_name, file_size,
|
||||
object_key, file_url, review_count, operator_id, operator_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
packUid,
|
||||
normalizeString(shopName).slice(0, 128),
|
||||
normalizeString(productId).slice(0, 64),
|
||||
normalizeNullableString(String(skuName || '').slice(0, 255)),
|
||||
resolvedFileName.slice(0, 255),
|
||||
file.buffer.length,
|
||||
uploaded.key,
|
||||
uploaded.url || '',
|
||||
Math.max(Number(reviewCount) || 0, 0),
|
||||
operator.id || null,
|
||||
operator.name,
|
||||
]
|
||||
);
|
||||
|
||||
const rows = await query(
|
||||
`SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
|
||||
[insertRows.insertId]
|
||||
);
|
||||
return normalizePackRow(rows[0]);
|
||||
}
|
||||
|
||||
async function listSeedPacks({ page = 1, pageSize = 15, keyword = '' } = {}) {
|
||||
await ensureSeedPackTable();
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1);
|
||||
const normalizedPageSize = Math.min(Math.max(Number(pageSize) || 15, 1), 100);
|
||||
const offset = (normalizedPage - 1) * normalizedPageSize;
|
||||
const normalizedKeyword = normalizeString(keyword);
|
||||
const likeKeyword = `%${normalizedKeyword}%`;
|
||||
const whereSql = normalizedKeyword
|
||||
? 'WHERE shop_name LIKE ? OR product_id LIKE ? OR sku_name LIKE ? OR operator_name LIKE ? OR file_name LIKE ?'
|
||||
: '';
|
||||
const params = normalizedKeyword ? Array(5).fill(likeKeyword) : [];
|
||||
|
||||
const countRows = await query(
|
||||
`SELECT COUNT(*) AS total FROM ${SEED_PACK_TABLE} ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const rows = await query(
|
||||
`SELECT * FROM ${SEED_PACK_TABLE}
|
||||
${whereSql}
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, normalizedPageSize, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
rows: rows.map(normalizePackRow),
|
||||
total: Number(countRows[0]?.total || 0),
|
||||
page: normalizedPage,
|
||||
pageSize: normalizedPageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSeedPackDownload(id) {
|
||||
await ensureSeedPackTable();
|
||||
const packId = Number(id);
|
||||
if (!Number.isFinite(packId) || packId <= 0) {
|
||||
const error = new Error('资源包不存在');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const rows = await query(
|
||||
`SELECT * FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
|
||||
[packId]
|
||||
);
|
||||
const pack = rows[0];
|
||||
if (!pack) {
|
||||
const error = new Error('资源包不存在');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
await query(
|
||||
`UPDATE ${SEED_PACK_TABLE} SET download_count = download_count + 1 WHERE id = ?`,
|
||||
[packId]
|
||||
);
|
||||
return {
|
||||
...normalizePackRow(pack),
|
||||
downloadCount: Number(pack.downloadCount || 0) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteSeedPack(id) {
|
||||
await ensureSeedPackTable();
|
||||
const packId = Number(id);
|
||||
if (!Number.isFinite(packId) || packId <= 0) {
|
||||
const error = new Error('资源包不存在');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const rows = await query(
|
||||
`SELECT id FROM ${SEED_PACK_TABLE} WHERE id = ? LIMIT 1`,
|
||||
[packId]
|
||||
);
|
||||
if (!rows.length) {
|
||||
const error = new Error('资源包不存在');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
await query(`DELETE FROM ${SEED_PACK_TABLE} WHERE id = ?`, [packId]);
|
||||
return { id: packId };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSeedPack,
|
||||
deleteSeedPack,
|
||||
getSeedPackDownload,
|
||||
listSeedPacks,
|
||||
MAX_SEED_PACK_FILE_BYTES,
|
||||
};
|
||||
1132
backend/src/services/aiBuyerShowService.js
Normal file
1132
backend/src/services/aiBuyerShowService.js
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user