Initial public PDD edition without market, Claw, skills or platform switching

This commit is contained in:
lunanting 2026-09-12 11:51:28 +08:00
commit ec8949cfbf
365 changed files with 175127 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.sh text eol=lf

58
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
# 后端
安装、启动、配置与贡献说明见[项目文档](../README.md)。

View 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,
};

View 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,
});

View 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`,
},
},
},
});

View 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;

View File

@ -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

View 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
View 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);

View 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;

View 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);

View 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',
},
},
],
};

View 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
View 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`
});

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

44
backend/package.json Normal file
View 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"
}

View 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();

View 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);
});

View 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);
});

View 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);
});

View 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.`);

View 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;
});

View 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();

View 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}`
);

View 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;
});

View 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();

View 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,
};

View 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();

View 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();

View 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);
});

View 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);
});

View File

@ -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

File diff suppressed because it is too large Load Diff

View 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;

View 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

File diff suppressed because it is too large Load Diff

347
backend/src/api/auth.js Normal file
View 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 });
}
};

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 });
}
});
};

View 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 });
}
});
};

View 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;

View 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;

View 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;

View 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;

View 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,
};

View 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;

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View 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;

View 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,
};

View 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
View 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;
},
};

View 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,
};

View 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,
};

File diff suppressed because it is too large Load Diff

887
backend/src/index.js Normal file
View 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);
});

View 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,
};

File diff suppressed because it is too large Load Diff

View 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,
};

View 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 };

View 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,
};

View 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 };

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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;

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

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