Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d47360f484 | ||
|
|
efde8897c8 | ||
|
|
298dcdc3eb | ||
|
|
b832279c1f | ||
|
|
ffb71f2276 | ||
|
|
535cefd4de | ||
|
|
25b89bb503 | ||
|
|
6ca54e468e | ||
|
|
d24c69141a | ||
|
|
a45d7597fb | ||
|
|
174f931486 | ||
|
|
79d8f84ac9 | ||
|
|
7b9794665c | ||
|
|
a1b3d9c70c | ||
|
|
737c957bb3 | ||
|
|
6dcdec43f1 | ||
|
|
02c9de8b9c | ||
|
|
dcb0fa4c76 | ||
|
|
04dc363320 | ||
|
|
f20ff3018f | ||
|
|
5d5d63bb59 | ||
|
|
e680792161 | ||
|
|
d91d7180ad | ||
|
|
e189811617 | ||
|
|
003c3e9208 | ||
|
|
75d94140ed | ||
|
|
1cebdd0487 | ||
|
|
844f071aab | ||
|
|
b49fa12484 | ||
|
|
3df271b30c | ||
|
|
fb0e9c8f9c | ||
|
|
8f3a5c6a67 | ||
|
|
ad59066637 | ||
|
|
f237a6f6ce | ||
|
|
99ce9a8a61 | ||
|
|
d56189fe59 | ||
|
|
0dfc1ef0df | ||
|
|
9080476dd6 | ||
|
|
6d7f9b5f3f | ||
|
|
360c5ea20e | ||
|
|
70b31f4874 | ||
|
|
8e49b7d047 | ||
|
|
260616b872 | ||
|
|
fdf83a6745 | ||
|
|
b4918769f1 | ||
|
|
1b8fdff162 | ||
|
|
90ff15b627 | ||
|
|
7e1c879c41 | ||
|
|
1b8e02dd17 | ||
|
|
0dfe363d35 | ||
|
|
2224dd06d3 | ||
|
|
061bc2636d | ||
|
|
4bc661c1b5 | ||
|
|
15396822b5 | ||
|
|
72b3f7c592 | ||
|
|
f92dc6a4c9 | ||
|
|
91008c98c4 | ||
|
|
2350ba985e | ||
|
|
665fc9ffa7 | ||
|
|
6ac77c0124 | ||
|
|
9beb501fa6 | ||
|
|
7f6a291628 | ||
|
|
7b2a5321b9 | ||
|
|
f4e552c51c | ||
|
|
476ab166d7 | ||
|
|
cee759b90e | ||
|
|
67bb5fec15 | ||
|
|
6aec4a7b40 | ||
|
|
ba35d27976 | ||
|
|
985e61209d | ||
|
|
3618df74c5 | ||
|
|
f1e219d1ef | ||
|
|
ed81122047 | ||
|
|
1f7ff016df | ||
|
|
700bda8250 | ||
|
|
a5c07ebafc | ||
|
|
59e74bf470 | ||
|
|
e7685c2337 | ||
|
|
2134e9dbc4 | ||
|
|
cc7491ecd6 | ||
|
|
eeb3d2f297 | ||
|
|
26f1a00e24 | ||
|
|
a9665b180f | ||
|
|
175b2dbac6 | ||
|
|
3c651ada9c | ||
|
|
20ace8acaa | ||
|
|
45bd917e3c | ||
|
|
90527344ca | ||
|
|
8c058d71b1 | ||
|
|
1e16b42c7d | ||
|
|
46a001c176 | ||
|
|
d5daf7f5dd | ||
|
|
a491a281ea | ||
|
|
5a195e2150 | ||
|
|
ec29469fcb | ||
|
|
656bb5a2ce | ||
|
|
a00e2d8280 | ||
|
|
01bfb53011 | ||
|
|
fa0cc7772c | ||
|
|
a954bf7d3d | ||
|
|
d807012500 | ||
|
|
900c45c88f | ||
|
|
2856d2a313 | ||
|
|
5af2dd08e0 | ||
|
|
2b79034a98 | ||
|
|
49f99e52f2 | ||
|
|
499293c7cf | ||
|
|
eeedb4a4da | ||
|
|
0031df580a | ||
|
|
12e789affc | ||
|
|
15379b20b9 | ||
|
|
91cd4cf280 | ||
|
|
ce40997447 | ||
|
|
18d690ab9b | ||
|
|
3ebfb0991a | ||
|
|
e3089e20a6 | ||
|
|
47c25664e4 | ||
|
|
19bdb0cdad | ||
|
|
b1077ce6ef | ||
|
|
1076addead | ||
|
|
8388d6e875 | ||
|
|
27e9a574e4 | ||
|
|
cc8c95bb58 | ||
|
|
dae32504c7 | ||
|
|
f0d1da4e1a | ||
|
|
3b7322954a | ||
|
|
8dcd47530e | ||
|
|
1c989072a8 | ||
|
|
4e8fb433ef | ||
|
|
a37f07b788 | ||
|
|
b7aa20f0ad | ||
|
|
41f353b1ba | ||
|
|
798ea583f8 | ||
|
|
f4d32a7c07 | ||
|
|
874b093a4b | ||
|
|
cc03382921 | ||
|
|
b2a1ac3db1 | ||
|
|
1090bd691b | ||
|
|
c3bd6daee2 | ||
|
|
e6ab139071 | ||
|
|
993ddcd034 | ||
|
|
db2a401e33 | ||
|
|
2fbfaa0143 | ||
|
|
361b84931f | ||
|
|
81a412323b | ||
|
|
b5d46b2381 | ||
|
|
1c8eda2b5f | ||
|
|
92afc32525 | ||
|
|
942cbcab68 | ||
|
|
e05a6797a3 | ||
|
|
6539e0fb21 | ||
|
|
20b1066ba3 | ||
|
|
e504b3a207 | ||
|
|
d76d1e8d90 | ||
|
|
39aeb0baaa | ||
|
|
3587e44261 | ||
|
|
543bc7f789 | ||
|
|
d2512ccb6b | ||
|
|
218ee1577e | ||
|
|
01a1f0cc93 | ||
|
|
106992e7c5 | ||
|
|
555a7d43a7 | ||
|
|
757eee9742 | ||
|
|
3546036b43 | ||
|
|
50f70d6920 | ||
|
|
12ca943170 | ||
|
|
f1575aec85 | ||
|
|
df90b67d8e | ||
|
|
4ddc4021a2 | ||
|
|
a24f2721e6 | ||
|
|
7b92d6b219 | ||
|
|
ca34187b0b | ||
|
|
1bea34367b | ||
|
|
ab6387759e | ||
|
|
4b1dcd47ea | ||
|
|
323b9ea34c | ||
|
|
4867861b8b | ||
|
|
b2eaf3b6c7 | ||
|
|
ee79195f47 | ||
|
|
052b99b05f | ||
|
|
be6f4f2507 | ||
|
|
cbfc9bff03 | ||
|
|
d57c05ec7e | ||
|
|
cf874b5429 | ||
|
|
7ccf3a6cee | ||
|
|
c2305d419e | ||
|
|
79990066f0 | ||
|
|
9ebc027b96 | ||
|
|
f2c95bc3c8 | ||
|
|
41b39bc5a0 | ||
|
|
1617c5e999 | ||
|
|
c6e9a9b962 | ||
|
|
fe569fe75a | ||
|
|
22f73fefb7 | ||
|
|
a08e4c18d7 | ||
|
|
370acdba60 | ||
|
|
1b4bdec56d | ||
|
|
2aa8769661 | ||
|
|
be7c124d65 | ||
|
|
ecac7b63db | ||
|
|
f938ab0cba | ||
|
|
8d18daa0fe | ||
|
|
18dad9b0fe | ||
|
|
5330b55b75 | ||
|
|
bca2e4a801 | ||
|
|
1d9e102527 | ||
|
|
baf09dc533 | ||
|
|
2881a99040 | ||
|
|
77ed97fe1f | ||
|
|
ea5c2a4105 | ||
|
|
9ed2e0d4fe | ||
|
|
950c4fd578 | ||
|
|
81ae04dae0 | ||
|
|
5c7c3e14e3 | ||
|
|
8705f6a6a1 | ||
|
|
e641064b63 | ||
|
|
bc2909d6f2 | ||
|
|
8d07b06d52 | ||
|
|
737f9ddcbe | ||
|
|
31f134579c | ||
|
|
75146e919d | ||
|
|
c4e7e67115 | ||
|
|
4dd94cde27 | ||
|
|
8a5ba4c2f3 | ||
|
|
27bcb7dd68 | ||
|
|
0270657f2b | ||
|
|
005b159215 | ||
|
|
10ae3d01ee | ||
|
|
ad84388450 | ||
|
|
b9e4d007aa | ||
|
|
57fda860dc | ||
|
|
00491daf5b | ||
|
|
521cbe9d38 | ||
|
|
1756cfb262 | ||
|
|
54551a1aeb | ||
|
|
bdb6bb8200 | ||
|
|
b017efe783 | ||
|
|
cd3b48eef5 | ||
|
|
3b562b4824 | ||
|
|
3a29226077 | ||
|
|
4eb9aa2cd8 | ||
|
|
87d8360e64 | ||
|
|
edccf03eb0 | ||
|
|
d433518038 | ||
|
|
190445319b | ||
|
|
55a658b4fe | ||
|
|
112a3272d2 | ||
|
|
af323af48e | ||
|
|
21211bd760 | ||
|
|
d44c6039ea | ||
|
|
1131780186 | ||
|
|
57ceac7143 | ||
|
|
ecdbacd745 | ||
|
|
f078c75378 | ||
|
|
b87886c688 | ||
|
|
f2edb7f895 | ||
|
|
f2e08a4fbc | ||
|
|
85fa8f11fd | ||
|
|
7bb3142958 | ||
|
|
6a17113374 | ||
|
|
88b205f4fd | ||
|
|
951d0574b1 | ||
|
|
78d6c2f8a1 | ||
|
|
dffe32e84a | ||
|
|
da1f2469bd | ||
|
|
40be8103fc | ||
|
|
e097ddad3d | ||
|
|
e72c936986 | ||
|
|
3d747aa5aa | ||
|
|
c391122c67 | ||
|
|
49705f2774 | ||
|
|
5885720c36 | ||
|
|
a7de443cb9 | ||
|
|
270e3ca89a | ||
|
|
16f9531b40 | ||
|
|
bcde47ef97 | ||
|
|
6e41d7e0db | ||
|
|
7196752d44 | ||
|
|
5a1c5a3759 | ||
|
|
2b3954d0e3 | ||
|
|
23b3711e64 | ||
|
|
6d0534296c | ||
|
|
e1a0a74477 | ||
|
|
995de86322 | ||
|
|
84a4c6c627 | ||
|
|
3a2147e94b | ||
|
|
dc3fd63b04 | ||
|
|
17acd65f28 | ||
|
|
8756a196ae | ||
|
|
8e11d80496 | ||
|
|
848f65319f | ||
|
|
c91dd4d144 | ||
|
|
0deb498a05 | ||
|
|
0933b4514f | ||
|
|
f455f853b1 | ||
|
|
541b458040 | ||
|
|
e8eab6e689 | ||
|
|
4e9c416235 | ||
|
|
316ee6d135 | ||
|
|
5f5a1a8bcf | ||
|
|
0f81380d9c | ||
|
|
769d41655a | ||
|
|
5f5506bb19 | ||
|
|
7fc3d60588 | ||
|
|
00d490a2cd | ||
|
|
5ef1ed6018 | ||
|
|
3284fed7a1 | ||
|
|
867ffd2f3c | ||
|
|
a6ba5f1c7f |
@@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
+18
-1
@@ -1,11 +1,14 @@
|
||||
# Mac Docker 本机联调模板。复制为 .env 后按需修改。
|
||||
# 如果使用 ngrok / cloudflared 等公网地址,只需要同步修改 APP_DOMAIN / CADDY_SITE_ADDR / CLAIM_BASE_URL。
|
||||
# 如果使用 ngrok / cloudflared 等公网地址,只需要同步修改 APP_DOMAIN / CADDY_SITE_ADDR / WORKER_SITE_ADDR / CLAIM_BASE_URL。
|
||||
|
||||
# App / Caddy
|
||||
APP_DOMAIN=localhost
|
||||
CADDY_SITE_ADDR=http://localhost
|
||||
WORKER_SITE_ADDR=http://worker.localhost
|
||||
TZ=Asia/Shanghai
|
||||
BACKEND_PORT=3000
|
||||
# 反向代理层数:Caddy 后保持 1(默认),后端直连暴露时设为 false
|
||||
HTTP_TRUST_PROXY=1
|
||||
|
||||
# Claim Links
|
||||
CLAIM_BASE_URL=http://localhost/#/claim
|
||||
@@ -15,6 +18,12 @@ POSTGRES_DB=order_site
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
DATABASE_URL=postgres://postgres:postgres@postgres:5432/order_site
|
||||
DATABASE_MAX_CONNECTIONS=8
|
||||
DATABASE_IDLE_TIMEOUT_MS=30000
|
||||
DATABASE_CONNECTION_TIMEOUT_MS=5000
|
||||
DATABASE_STATEMENT_TIMEOUT_MS=15000
|
||||
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
|
||||
POSTGRES_LOG_MIN_DURATION_STATEMENT_MS=500
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Logging
|
||||
@@ -22,14 +31,22 @@ LOG_LEVEL=info
|
||||
# integration-*.log 默认 warn,只记对接异常;排查全量时改为 info
|
||||
LOG_INTEGRATION_LEVEL=warn
|
||||
LOG_RETENTION_DAYS=30
|
||||
LOG_MAX_FILE_SIZE_MB=20
|
||||
LOG_MAX_FILES=5
|
||||
|
||||
# File Storage (MinIO / S3 compatible)
|
||||
STORAGE_MODE=minio
|
||||
STORAGE_ENDPOINT=http://minio:9000
|
||||
STORAGE_BUCKET=order-site
|
||||
STORAGE_ACCESS_KEY_ID=minioadmin
|
||||
STORAGE_SECRET_ACCESS_KEY=minioadmin
|
||||
STORAGE_REGION=us-east-1
|
||||
STORAGE_MAX_UPLOAD_SIZE_MB=10
|
||||
STORAGE_OSS_ENDPOINT=
|
||||
STORAGE_OSS_BUCKET=
|
||||
STORAGE_OSS_ACCESS_KEY_ID=
|
||||
STORAGE_OSS_SECRET_ACCESS_KEY=
|
||||
STORAGE_OSS_REGION=oss-cn-hangzhou
|
||||
MINIO_API_PORT=19000
|
||||
MINIO_CONSOLE_PORT=19001
|
||||
|
||||
|
||||
+61
-1
@@ -4,8 +4,11 @@
|
||||
# App / Caddy
|
||||
APP_DOMAIN=order.khhao.com
|
||||
CADDY_SITE_ADDR=https://order.khhao.com
|
||||
WORKER_SITE_ADDR=https://worker.khhao.com
|
||||
TZ=Asia/Shanghai
|
||||
BACKEND_PORT=3000
|
||||
# 反向代理层数:Caddy 后保持 1(默认),后端直连暴露时设为 false
|
||||
HTTP_TRUST_PROXY=1
|
||||
|
||||
# 构建镜像源(可选,默认阿里云公共源)
|
||||
# 阿里云 ECS 同地域内网可改为 mirrors.cloud.aliyuncs.com 加速 apt/apk
|
||||
@@ -15,31 +18,88 @@ BACKEND_PORT=3000
|
||||
|
||||
# Claim Links
|
||||
CLAIM_BASE_URL=https://order.khhao.com/#/claim
|
||||
CLAIM_TOKEN_TTL_HOURS=0
|
||||
|
||||
# Database
|
||||
POSTGRES_DB=order_site
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=change-me-postgres-password
|
||||
DATABASE_URL=postgres://postgres:change-me-postgres-password@postgres:5432/order_site
|
||||
DATABASE_MAX_CONNECTIONS=8
|
||||
DATABASE_IDLE_TIMEOUT_MS=30000
|
||||
DATABASE_CONNECTION_TIMEOUT_MS=5000
|
||||
DATABASE_STATEMENT_TIMEOUT_MS=15000
|
||||
# 慢查询告警阈值;超过该时长的 SQL 只记录指纹和耗时,不记录参数值
|
||||
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
|
||||
# PostgreSQL 原生慢查询日志阈值(毫秒);-1 关闭
|
||||
POSTGRES_LOG_MIN_DURATION_STATEMENT_MS=500
|
||||
# PostgreSQL 容器资源与性能参数。默认值按 4 核 8G 且与其他服务共用的
|
||||
# 服务器设置;数据库独占或内存更足时可上调 shared_buffers 与
|
||||
# effective_cache_size(后者只是规划器提示,不实际占用内存):
|
||||
POSTGRES_MEM_LIMIT=1.5g
|
||||
POSTGRES_CPUS=2
|
||||
POSTGRES_SHARED_BUFFERS=384MB
|
||||
POSTGRES_EFFECTIVE_CACHE_SIZE=4GB
|
||||
POSTGRES_WORK_MEM=16MB
|
||||
POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||
# 后端与 Web 容器限额(内存紧张的小机器可相应收紧)
|
||||
BACKEND_MEM_LIMIT=768m
|
||||
BACKEND_CPUS=1.5
|
||||
WEB_MEM_LIMIT=256m
|
||||
WEB_CPUS=0.5
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
# integration-*.log:对接日志级别(debug|info|warn|error),默认 warn 只记异常
|
||||
LOG_INTEGRATION_LEVEL=warn
|
||||
LOG_RETENTION_DAYS=30
|
||||
# 单个 app/integration 日志文件达到 20MB 后轮转,单日最多保留 5 个分片
|
||||
LOG_MAX_FILE_SIZE_MB=20
|
||||
LOG_MAX_FILES=5
|
||||
|
||||
# File Storage (MinIO / S3 compatible)
|
||||
# 数据库敏感报文/事件保留策略(天)。清理任务每天执行一次。
|
||||
RAW_PAYLOAD_RETENTION_DAYS=180
|
||||
TASK_EVENT_RETENTION_DAYS=180
|
||||
WEBHOOK_EVENT_RETENTION_DAYS=180
|
||||
AUDIT_LOG_RETENTION_DAYS=365
|
||||
|
||||
# 文件存储:生产使用阿里云 OSS(生产编排已移除 MinIO,仅本地开发使用)。
|
||||
# STORAGE_MODE 取值 minio | dual | oss;dual 仅在 MinIO→OSS 迁移/回滚期间使用。
|
||||
STORAGE_MODE=oss
|
||||
# 以下 MinIO 配置仅在 STORAGE_MODE=dual/minio(迁移回滚场景)时使用。
|
||||
STORAGE_ENDPOINT=http://minio:9000
|
||||
STORAGE_BUCKET=order-site
|
||||
STORAGE_ACCESS_KEY_ID=change-me-storage-access-key
|
||||
STORAGE_SECRET_ACCESS_KEY=change-me-storage-secret-key
|
||||
STORAGE_REGION=us-east-1
|
||||
STORAGE_MAX_UPLOAD_SIZE_MB=10
|
||||
# 阿里云 OSS S3 兼容端点:ECS 与桶同地域时用内网地址(免流量费、低延迟)
|
||||
STORAGE_OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com
|
||||
STORAGE_OSS_BUCKET=change-me-oss-bucket
|
||||
STORAGE_OSS_ACCESS_KEY_ID=change-me-oss-access-key
|
||||
STORAGE_OSS_SECRET_ACCESS_KEY=change-me-oss-secret-key
|
||||
STORAGE_OSS_REGION=oss-cn-hangzhou
|
||||
|
||||
# 数据库备份 → OSS(deploy/backup-db.sh 与 dist/db-backup.js 使用)。
|
||||
# 默认复用上面的 STORAGE_OSS_* 上传到同桶 backups/ 前缀,以下仅需按需调整。
|
||||
DB_BACKUP_OSS_PREFIX=backups/postgres
|
||||
# 服务器本地保留的备份份数(OSS 端过期请配置桶生命周期规则)
|
||||
DB_BACKUP_KEEP=7
|
||||
# 建议为备份单独创建 RAM 用户、只授予 backups/ 前缀权限(策略见 docs/数据库备份与恢复.md);
|
||||
# 使用独立桶/密钥时取消注释覆盖:
|
||||
# DB_BACKUP_OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com
|
||||
# DB_BACKUP_OSS_BUCKET=change-me-backup-bucket
|
||||
# DB_BACKUP_OSS_ACCESS_KEY_ID=change-me-backup-access-key
|
||||
# DB_BACKUP_OSS_SECRET_ACCESS_KEY=change-me-backup-secret-key
|
||||
# DB_BACKUP_OSS_REGION=oss-cn-hangzhou
|
||||
|
||||
# Admin
|
||||
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
||||
|
||||
# 生产环境必须使用显式来源白名单,多个来源用逗号分隔。
|
||||
CORS_ALLOWED_ORIGINS=https://order.khhao.com,https://worker.khhao.com
|
||||
|
||||
# 91Kaquan
|
||||
KAQUAN91_USER_ID=
|
||||
KAQUAN91_SECRET=
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
push_lines="$(cat)"
|
||||
while read -r local_ref local_sha remote_ref remote_sha; do
|
||||
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "[pre-push] 分支删除推送,跳过本地检查"
|
||||
exit 0
|
||||
fi
|
||||
done <<< "$push_lines"
|
||||
|
||||
if [ "${SKIP_CHECKS:-0}" = "1" ]; then
|
||||
echo "[pre-push] SKIP_CHECKS=1,跳过本地检查"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[pre-push] 运行 backend check..."
|
||||
npm --prefix apps/backend run check
|
||||
|
||||
echo "[pre-push] 运行 frontend check..."
|
||||
npm --prefix apps/frontend run check
|
||||
|
||||
echo "[pre-push] 本地检查通过"
|
||||
@@ -2,6 +2,7 @@ node_modules
|
||||
.env
|
||||
.env copy
|
||||
.env.local
|
||||
.env.back*
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -30,3 +31,10 @@ apps/backend/data/logs
|
||||
.snow
|
||||
send_code/
|
||||
.reasonix/
|
||||
*.tsbuildinfo
|
||||
.zcode/
|
||||
|
||||
# 服务器本地备份暂存目录(deploy/backup-db.sh)
|
||||
/backups/
|
||||
|
||||
artifacts/
|
||||
@@ -0,0 +1,8 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.vite
|
||||
**/coverage
|
||||
**/data
|
||||
**/package-lock.json
|
||||
**/.env*
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "editorconfig.editorconfig"]
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.configPath": ".prettierrc.json"
|
||||
}
|
||||
@@ -88,12 +88,30 @@ bash deploy/mac-dev.sh
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm test
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run format:check
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run typecheck
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run build
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm test
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run format:check
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run build
|
||||
```
|
||||
|
||||
### 本地推送检查
|
||||
|
||||
本项目不使用 Gitea Actions。需要在本机推送前自动执行前后端检查时,启用本地 Git hook:
|
||||
|
||||
```bash
|
||||
bash scripts/install-git-hooks.sh
|
||||
```
|
||||
|
||||
启用后,每次 `git push` 会依次执行 `apps/backend` 和 `apps/frontend` 的 `npm run check`。
|
||||
仅临时跳过检查时使用:
|
||||
|
||||
```bash
|
||||
SKIP_CHECKS=1 git push
|
||||
```
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
采用 **基线 + 渐进增量** 策略,由 `node-pg-migrate` 管理,记录表为 `schema_migrations`。
|
||||
@@ -159,8 +177,10 @@ cp .env.server.example .env
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `APP_DOMAIN` / `CADDY_SITE_ADDR` | 对外域名与 Caddy 监听地址 |
|
||||
| `APP_DOMAIN` / `CADDY_SITE_ADDR` | 后台与领取页域名、Caddy 主站监听地址 |
|
||||
| `WORKER_SITE_ADDR` | 打手端独立域名,如 `https://worker.khhao.com` |
|
||||
| `CLAIM_BASE_URL` | 领取页完整前缀,如 `https://域名/#/claim` |
|
||||
| `CLAIM_TOKEN_TTL_HOURS` | 领取链接有效期(小时);`0` 表示长期有效,正整数表示限时 |
|
||||
| `DATABASE_URL` | PostgreSQL 连接串(容器内主机名用 `postgres`) |
|
||||
| `ADMIN_SESSION_SECRET` | 后台登录态签名密钥 |
|
||||
| `ADMIN_DEFAULT_USERS_JSON` | 默认后台用户 |
|
||||
@@ -196,6 +216,16 @@ bash deploy/ubuntu-deploy.sh
|
||||
|
||||
首次若无 `.env`,脚本会从示例生成模板并退出;填好后再执行一次即可。脚本会检查 Docker、占位配置、数据目录权限,构建启动并等待健康检查。
|
||||
|
||||
常用参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--skip-backup` | 跳过部署前的自动数据库备份(默认每次部署都会先 `pg_dump` 再上传 OSS,数据量较大时耗时明显)。仅代码改动、无 schema 变更时可加;涉及数据库迁移或 `--reset-db` 前建议保留备份 |
|
||||
| `--reset-db` | 删除 PostgreSQL 数据卷后按 migrations 全量重建(执行前会自动抓取最后一份备份) |
|
||||
| `--yes` | 跳过 `--reset-db` 的交互确认(等价于 `RESET_DB_CONFIRM=1`) |
|
||||
|
||||
`--skip-backup` 也可用环境变量 `SKIP_BACKUP=true bash deploy/ubuntu-deploy.sh` 代替。备份与恢复的完整说明见 `docs/数据库备份与恢复.md`。
|
||||
|
||||
生产入口示例:
|
||||
|
||||
- 前端与领取页:`https://你的域名/`
|
||||
@@ -234,7 +264,7 @@ bash deploy/ubuntu-deploy.sh
|
||||
| 等级权限 | VIP 等级体系:免押额度、最大同时接单量、大厅可见延迟 |
|
||||
| 资金 | 押金冻结 / 退还 / 扣除流水,充值申请与提现审核 |
|
||||
| 物品规则 | 按商品/SKU 匹配自动生成工单,可继承默认时限与拼单配置 |
|
||||
| 管理后台 | 接单工单 CRUD、打手审核、问题单处置、定时任务管理,功能标签页记忆上次选择 |
|
||||
| 管理后台 | 接单工单 CRUD、打手账号管理、问题单处置、定时任务管理,功能标签页记忆上次选择 |
|
||||
|
||||
说明:
|
||||
|
||||
@@ -247,11 +277,13 @@ bash deploy/ubuntu-deploy.sh
|
||||
## 提交前检查
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm test
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run typecheck
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run check
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run check
|
||||
```
|
||||
|
||||
代码格式由仓库根目录的 Prettier 配置统一管理。修改代码后可在对应应用目录执行
|
||||
`npm run format`,提交前使用 `npm run format:check` 只检查、不修改文件。
|
||||
|
||||
确认:
|
||||
|
||||
- 未提交真实账号、Cookie、token、手机号或生产配置
|
||||
|
||||
@@ -40,6 +40,9 @@ npm test
|
||||
| --- | --- |
|
||||
| `PORT` | 服务端口(Compose 中常由 `BACKEND_PORT` 映射) |
|
||||
| `LOG_LEVEL` | `debug` / `info` / `warn` / `error` |
|
||||
| `LOG_MAX_FILE_SIZE_MB` | 单个应用日志文件大小上限,默认 `20` MB,超出后轮转 |
|
||||
| `LOG_MAX_FILES` | 同一日期最多保留的日志文件数量,默认 `5`(含当前文件) |
|
||||
| `LOG_RETENTION_DAYS` | 日志日期保留天数,默认 `30` |
|
||||
| `DATABASE_URL` | PostgreSQL 连接串 |
|
||||
| `ADMIN_SESSION_SECRET` | 后台登录态签名密钥 |
|
||||
| `ADMIN_DEFAULT_USERS_JSON` | 默认后台用户 |
|
||||
@@ -130,6 +133,6 @@ docker compose -f ../../docker-compose.dev.yml exec -T backend npm run db:migrat
|
||||
- `data/logs/app-YYYY-MM-DD.log`
|
||||
- `data/logs/integration-YYYY-MM-DD.log`
|
||||
|
||||
按天切分,默认清理过期日志。成功的 `/health*` 探活默认不写 access log。
|
||||
按天切分并按大小轮转,默认单文件 20MB、单日最多 5 个分片、保留 30 天。成功的 `GET/HEAD` 请求以 `DEBUG` 记录,生产默认 `LOG_LEVEL=info` 不落盘;写请求和所有 4xx/5xx 仍保留 access log。成功的 `/health*` 探活默认不写 access log。
|
||||
|
||||
`data/*.json` 为本地运行配置,可能含敏感信息,默认不提交;请从 `*.example.json` 复制后填写。
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import eslint from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'data/**'],
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts', 'scripts/**/*.ts'],
|
||||
extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
parserOptions: {
|
||||
project: './tsconfig.eslint.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': [
|
||||
'error',
|
||||
{
|
||||
ignoreIIFE: true,
|
||||
ignoreVoid: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'no-useless-assignment': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.test.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-floating-promises': 'off',
|
||||
'no-control-regex': 'off',
|
||||
'no-regex-spaces': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
Generated
+1464
-274
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,15 @@
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:migrate:create": "tsx scripts/create-migration.ts",
|
||||
"db:migrate:status": "tsx scripts/migration-status.ts",
|
||||
"storage:migrate": "tsx scripts/migrate-storage.ts",
|
||||
"storage:migrate:built": "node dist/storage-migrate.js",
|
||||
"check:sql": "tsx scripts/check-sql-guard.ts",
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||
"format": "prettier --write .",
|
||||
"format": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --write \"src/**/*.{ts,js,json}\" \"scripts/**/*.{ts,js}\" eslint.config.js package.json tsconfig.json tsconfig.build.json tsconfig.eslint.json",
|
||||
"format:check": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check \"src/**/*.{ts,js,json}\" \"scripts/**/*.{ts,js}\" eslint.config.js package.json tsconfig.json tsconfig.build.json tsconfig.eslint.json",
|
||||
"lint": "eslint \"src/**/*.ts\" \"scripts/**/*.ts\"",
|
||||
"lint:check": "npm run lint -- --quiet",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"mock:claim": "tsx scripts/mock-kuaishou-cloud-claim.ts",
|
||||
"mock:open91": "tsx scripts/mock-open91-order.ts",
|
||||
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
||||
@@ -19,6 +26,7 @@
|
||||
"test:industry:gen": "tsx scripts/gen-test-cases.ts",
|
||||
"test:industry:curl": "tsx scripts/curl-send-callback.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"check": "npm run format:check && npm run check:sql && npm run lint:check && npm run typecheck && npm test && npm run build",
|
||||
"start": "node dist/index.js",
|
||||
"start:src": "tsx src/index.ts",
|
||||
"mock:feifei": "tsx scripts/mock-feifei-claim.ts"
|
||||
@@ -27,20 +35,24 @@
|
||||
"@alicloud/dysmsapi20170525": "^4.6.0",
|
||||
"@alicloud/openapi-client": "^0.4.15",
|
||||
"@alicloud/tea-util": "^1.4.11",
|
||||
"@aws-sdk/client-s3": "^3.1116.0",
|
||||
"express": "^5.1.0",
|
||||
"minio": "^8.0.7",
|
||||
"multer": "^2.2.0",
|
||||
"node-pg-migrate": "^8.0.4",
|
||||
"pg": "^8.16.3",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prettier": "^3.8.3",
|
||||
"eslint": "10.8.1",
|
||||
"globals": "17.11.0",
|
||||
"prettier": "3.8.3",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^6.0.2"
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "8.67.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 数据库 SQL 约束检查。
|
||||
* 该脚本检查关键防线是否被后续重构移除,不尝试替代 EXPLAIN 和生产监控。
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const BACKEND_ROOT = path.resolve(SCRIPT_DIR, '..')
|
||||
|
||||
const checks: Array<{ name: string; file: string; patterns: string[] }> = [
|
||||
{
|
||||
name: '慢查询阈值配置',
|
||||
file: path.join(BACKEND_ROOT, 'src/config/env-overrides.ts'),
|
||||
patterns: ['DATABASE_SLOW_QUERY_THRESHOLD_MS', 'slowQueryThresholdMs'],
|
||||
},
|
||||
{
|
||||
name: '慢查询日志与参数脱敏',
|
||||
file: path.join(BACKEND_ROOT, 'src/db/client.ts'),
|
||||
patterns: ['logSlowQuery', 'fingerprintSql', '参数值始终不写入日志'],
|
||||
},
|
||||
{
|
||||
name: '高频 pending 查询索引',
|
||||
file: path.join(BACKEND_ROOT, 'src/db/migrations/058_database_query_guardrails.sql'),
|
||||
patterns: [
|
||||
'idx_worker_cancel_requests_pending_worker_order',
|
||||
'idx_worker_feedbacks_pending_worker_order',
|
||||
'idx_work_order_events_order_type_id',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '打手订单拼单查询限定当前页',
|
||||
file: path.join(BACKEND_ROOT, 'src/repositories/worker-platform/work-order-share-repo.ts'),
|
||||
patterns: ['workOrderIds: number[]', 'wos.work_order_id = ANY($2::bigint[])'],
|
||||
},
|
||||
{
|
||||
name: '数据保留清理扫描索引',
|
||||
file: path.join(BACKEND_ROOT, 'src/db/migrations/067_data_retention_indexes.sql'),
|
||||
patterns: [
|
||||
'idx_orders_updated_at',
|
||||
'idx_kuaishou_industry_vouchers_updated_at',
|
||||
'idx_task_events_created_at',
|
||||
'idx_webhook_events_created_at',
|
||||
'DROP INDEX IF EXISTS idx_work_product_match_logs_created_at',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '打手超时事件统计索引',
|
||||
file: path.join(BACKEND_ROOT, 'src/db/migrations/068_timeout_event_worker_index.sql'),
|
||||
patterns: [
|
||||
'idx_work_order_events_timeout_worker_id',
|
||||
"payload_json->>'workerId'",
|
||||
"WHERE event_type LIKE 'timeout_%'",
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const failures: string[] = []
|
||||
|
||||
// 已应用的历史迁移必须保持字节不变,所有结构或数据修复都必须新增迁移文件。
|
||||
try {
|
||||
const diffOutputs = [
|
||||
execFileSync('git', ['diff', '--name-status', 'HEAD', '--', 'src/db/migrations'], {
|
||||
cwd: BACKEND_ROOT,
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
execFileSync('git', ['diff', '--name-status', 'HEAD^', 'HEAD', '--', 'src/db/migrations'], {
|
||||
cwd: BACKEND_ROOT,
|
||||
encoding: 'utf8',
|
||||
}),
|
||||
]
|
||||
const changedHistoricalMigrations = diffOutputs
|
||||
.flatMap((output) => output.split('\n'))
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A')
|
||||
.map(([status, file]) => `${status} ${file}`)
|
||||
if (changedHistoricalMigrations.length > 0) {
|
||||
failures.push(`历史迁移文件不可修改: ${changedHistoricalMigrations.join(', ')}`)
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`历史迁移完整性检查失败: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
for (const check of checks) {
|
||||
if (!fs.existsSync(check.file)) {
|
||||
failures.push(`${check.name}: 文件不存在 ${check.file}`)
|
||||
continue
|
||||
}
|
||||
const source = fs.readFileSync(check.file, 'utf8')
|
||||
for (const pattern of check.patterns) {
|
||||
if (!source.includes(pattern)) {
|
||||
failures.push(`${check.name}: 缺少约束 ${pattern} (${check.file})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('[sql-guard] SQL 约束检查失败')
|
||||
for (const failure of failures) console.error(`[sql-guard] ${failure}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.info('[sql-guard] SQL 约束检查通过')
|
||||
}
|
||||
@@ -36,7 +36,10 @@ try {
|
||||
console.log('\n已清空测试业务数据。')
|
||||
console.log('保留内容:fulfillment profiles/bindings、admin users、配置文件。')
|
||||
} catch (error) {
|
||||
console.error('\n清理失败:', error instanceof Error ? error.message : String(error || '未知错误'))
|
||||
console.error(
|
||||
'\n清理失败:',
|
||||
error instanceof Error ? error.message : String(error || '未知错误'),
|
||||
)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await closeDb()
|
||||
@@ -64,7 +67,8 @@ function parseArgs(argv) {
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
console.log(
|
||||
`
|
||||
用法:
|
||||
npm run cleanup:dev-data -- [--apply]
|
||||
|
||||
@@ -80,7 +84,8 @@ function printHelp() {
|
||||
admin_users / admin_audit_logs
|
||||
本地配置文件
|
||||
数据库连接: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}
|
||||
`.trim())
|
||||
`.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
function printSummary(counts) {
|
||||
|
||||
@@ -20,10 +20,7 @@ const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
|
||||
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
|
||||
|
||||
loadEnvFiles([
|
||||
path.join(WORKSPACE_ROOT, '.env'),
|
||||
path.join(PROJECT_ROOT, '.env'),
|
||||
])
|
||||
loadEnvFiles([path.join(WORKSPACE_ROOT, '.env'), path.join(PROJECT_ROOT, '.env')])
|
||||
|
||||
const accessToken = String(process.env.KUASHOU_INDUSTRY_ACCESS_TOKEN || 'your_access_token').trim()
|
||||
const appKey = String(process.env.KUASHOU_INDUSTRY_APP_KEY || 'your_app_key').trim()
|
||||
@@ -41,7 +38,8 @@ const KUAISHOU_OPEN_API = 'https://openapi.kwaixiaodian.com'
|
||||
const now = Date.now()
|
||||
const validEnd = now + 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
const bizParams: JsonObject = mode === 'consume'
|
||||
const bizParams: JsonObject =
|
||||
mode === 'consume'
|
||||
? {
|
||||
oid,
|
||||
etickets: Array.from({ length: sendNum }, (_, i) => ({
|
||||
@@ -81,13 +79,15 @@ const bizParams: JsonObject = mode === 'consume'
|
||||
token,
|
||||
}
|
||||
|
||||
const method = mode === 'consume'
|
||||
const method =
|
||||
mode === 'consume'
|
||||
? 'integration.callback.virtual.eticket.consume'
|
||||
: mode === 'destroy'
|
||||
? 'integration.callback.virtual.eticket.destroy'
|
||||
: 'integration.callback.virtual.eticket.send'
|
||||
|
||||
const endpoint = mode === 'consume'
|
||||
const endpoint =
|
||||
mode === 'consume'
|
||||
? '/integration/callback/virtual/eticket/consume'
|
||||
: mode === 'destroy'
|
||||
? '/integration/callback/virtual/eticket/destroy'
|
||||
@@ -112,7 +112,9 @@ const modeLabel = mode === 'consume' ? '核销' : mode === 'destroy' ? '销毁'
|
||||
console.log(`# 电子凭证${modeLabel}回调 curl 命令`)
|
||||
console.log(`# oid=${oid} sendNum=${sendNum} access_token=${bodyAccessToken.slice(0, 10)}...`)
|
||||
console.log(`# 先测试网络连通性:`)
|
||||
console.log(`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 https://openapi.kwaixiaodian.com/`)
|
||||
console.log(
|
||||
`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 https://openapi.kwaixiaodian.com/`,
|
||||
)
|
||||
console.log('# 如果返回 000,说明网络不通,需要关闭代理或换服务器')
|
||||
console.log('')
|
||||
console.log('curl -X POST \\')
|
||||
@@ -130,10 +132,15 @@ console.log('')
|
||||
|
||||
// 本地签名校验(不调快手)
|
||||
console.log('# 本地签名校验(不会真正调用快手):')
|
||||
const localEndpoint = mode === 'consume' ? 'consume-code' : mode === 'destroy' ? 'destroy-code' : 'send-code'
|
||||
console.log(`curl -s -X POST http://127.0.0.1:3000/api/v1/open/kuaishou-industry/${localEndpoint} \\`)
|
||||
const localEndpoint =
|
||||
mode === 'consume' ? 'consume-code' : mode === 'destroy' ? 'destroy-code' : 'send-code'
|
||||
console.log(
|
||||
`curl -s -X POST http://127.0.0.1:3000/api/v1/open/kuaishou-industry/${localEndpoint} \\`,
|
||||
)
|
||||
console.log(" -H 'Content-Type: application/x-www-form-urlencoded' \\")
|
||||
console.log(` -d 'appkey=${appKey}&version=1×tamp=${now}&signMethod=MD5&access_token=&method=&sign=${sign}¶m=${encodeURIComponent(paramStr)}'`)
|
||||
console.log(
|
||||
` -d 'appkey=${appKey}&version=1×tamp=${now}&signMethod=MD5&access_token=&method=&sign=${sign}¶m=${encodeURIComponent(paramStr)}'`,
|
||||
)
|
||||
console.log('')
|
||||
|
||||
function signPayload(params: JsonObject, secret: string): string {
|
||||
@@ -141,7 +148,11 @@ function signPayload(params: JsonObject, secret: string): string {
|
||||
.filter(([k]) => k !== 'sign' && k !== 'signSecret')
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
const qs = entries.map(([k, v]) => `${k}=${stringify(v)}`).join('&')
|
||||
return crypto.createHash('md5').update(`${qs}&signSecret=${secret}`, 'utf8').digest('hex').toLowerCase()
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(`${qs}&signSecret=${secret}`, 'utf8')
|
||||
.digest('hex')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function stringify(v: unknown): string {
|
||||
@@ -164,7 +175,10 @@ function parseArgs(raw: string[]): JsonObject {
|
||||
if (!arg) continue
|
||||
const n = arg.startsWith('--') ? arg.slice(2) : arg
|
||||
const eq = n.indexOf('=')
|
||||
if (eq < 0) { p[n] = true; continue }
|
||||
if (eq < 0) {
|
||||
p[n] = true
|
||||
continue
|
||||
}
|
||||
p[n.slice(0, eq).trim()] = n.slice(eq + 1).trim()
|
||||
}
|
||||
return p
|
||||
|
||||
@@ -19,16 +19,15 @@ const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
|
||||
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
|
||||
|
||||
loadEnvFiles([
|
||||
path.join(WORKSPACE_ROOT, '.env'),
|
||||
path.join(PROJECT_ROOT, '.env'),
|
||||
])
|
||||
loadEnvFiles([path.join(WORKSPACE_ROOT, '.env'), path.join(PROJECT_ROOT, '.env')])
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const baseUrl = String(args.baseUrl || 'http://127.0.0.1').replace(/\/+$/, '')
|
||||
const appKey = String(args.appKey || process.env.KUASHOU_INDUSTRY_APP_KEY || '').trim()
|
||||
const signSecret = String(args.signSecret || process.env.KUASHOU_INDUSTRY_SIGN_SECRET || '').trim()
|
||||
const signMethod = String(args.signMethod || 'MD5').trim().toUpperCase()
|
||||
const signMethod = String(args.signMethod || 'MD5')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
|
||||
if (!appKey || !signSecret) {
|
||||
console.error('请配置 appKey 和 signSecret')
|
||||
@@ -85,7 +84,8 @@ function signKuaishou(params: JsonObject, method: string): string {
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
const qs = entries.map(([k, v]) => `${k}=${stringifyVal(v)}`).join('&')
|
||||
const src = `${qs}&signSecret=${signSecret}`
|
||||
if (method === 'HMAC_SHA256') return crypto.createHmac('sha256', signSecret).update(src, 'utf8').digest('base64')
|
||||
if (method === 'HMAC_SHA256')
|
||||
return crypto.createHmac('sha256', signSecret).update(src, 'utf8').digest('base64')
|
||||
return crypto.createHash('md5').update(src, 'utf8').digest('hex').toLowerCase()
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ async function call(endpoint: string, params: JsonObject) {
|
||||
body.append('param', String(params.param || '{}'))
|
||||
|
||||
const started = Date.now()
|
||||
let status = 0; let json: JsonObject = {}
|
||||
let status = 0
|
||||
let json: JsonObject
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -115,8 +116,14 @@ async function call(endpoint: string, params: JsonObject) {
|
||||
})
|
||||
status = res.status
|
||||
const text = await res.text()
|
||||
try { json = JSON.parse(text) } catch { json = { raw: text } }
|
||||
} catch (err) { json = { error: String(err) } }
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch {
|
||||
json = { raw: text }
|
||||
}
|
||||
} catch (err) {
|
||||
json = { error: String(err) }
|
||||
}
|
||||
return { status, json, ms: Date.now() - started }
|
||||
}
|
||||
|
||||
@@ -129,10 +136,6 @@ function paramsToForm(params: JsonObject): string {
|
||||
return p.join('&') + `¶m=${encodeURIComponent(String(params.param || '{}'))}` + signPart
|
||||
}
|
||||
|
||||
function pp(v: unknown): string {
|
||||
return JSON.stringify(v, null, 2)
|
||||
}
|
||||
|
||||
// ───── 先准备订单 ─────
|
||||
console.log('---')
|
||||
console.log('准备测试订单...\n')
|
||||
@@ -409,7 +412,10 @@ function parseArgs(rawArgs: string[]) {
|
||||
if (!arg) continue
|
||||
const n = arg.startsWith('--') ? arg.slice(2) : arg
|
||||
const eq = n.indexOf('=')
|
||||
if (eq < 0) { parsed[n] = true; continue }
|
||||
if (eq < 0) {
|
||||
parsed[n] = true
|
||||
continue
|
||||
}
|
||||
parsed[n.slice(0, eq).trim()] = n.slice(eq + 1).trim()
|
||||
}
|
||||
return parsed
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import '../src/storage-migrate.js'
|
||||
@@ -40,7 +40,11 @@ async function main() {
|
||||
|
||||
let appliedNames = new Set<string>()
|
||||
try {
|
||||
const result = await query<{ name: string | null; filename: string | null; run_on: string | null }>(
|
||||
const result = await query<{
|
||||
name: string | null
|
||||
filename: string | null
|
||||
run_on: string | null
|
||||
}>(
|
||||
`
|
||||
SELECT name, filename, run_on
|
||||
FROM schema_migrations
|
||||
@@ -49,9 +53,7 @@ async function main() {
|
||||
)
|
||||
|
||||
appliedNames = new Set(
|
||||
result.rows
|
||||
.map((row) => normalizeMigrationName(row.filename || row.name))
|
||||
.filter(Boolean),
|
||||
result.rows.map((row) => normalizeMigrationName(row.filename || row.name)).filter(Boolean),
|
||||
)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -20,7 +20,9 @@ if (args.help) {
|
||||
}
|
||||
|
||||
if (!isDevMockEnabled() && args.force !== true) {
|
||||
console.error('当前是生产环境,已拒绝生成 mock 数据。如确实要执行,请追加 --force,或设置 ENABLE_DEV_MOCK=1。')
|
||||
console.error(
|
||||
'当前是生产环境,已拒绝生成 mock 数据。如确实要执行,请追加 --force,或设置 ENABLE_DEV_MOCK=1。',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,7 @@ const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
|
||||
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
|
||||
|
||||
loadEnvFiles([
|
||||
path.join(WORKSPACE_ROOT, '.env'),
|
||||
path.join(PROJECT_ROOT, '.env'),
|
||||
])
|
||||
loadEnvFiles([path.join(WORKSPACE_ROOT, '.env'), path.join(PROJECT_ROOT, '.env')])
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
@@ -24,7 +21,9 @@ if (args.help) {
|
||||
|
||||
const secret = readEnv('KAQUAN91_SECRET')
|
||||
const version = readEnv('KAQUAN91_VERSION') || '1.0'
|
||||
const baseUrl = String(args.baseUrl || process.env.OPEN91_MOCK_BASE_URL || 'http://127.0.0.1').replace(/\/$/, '')
|
||||
const baseUrl = String(
|
||||
args.baseUrl || process.env.OPEN91_MOCK_BASE_URL || 'http://127.0.0.1',
|
||||
).replace(/\/$/, '')
|
||||
const mode = String(args.mode || 'create').trim()
|
||||
|
||||
if (!secret || secret.length !== 32) {
|
||||
@@ -40,7 +39,8 @@ if (!['create', 'query'].includes(mode)) {
|
||||
const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim()
|
||||
const timestamp = Number(args.timestamp || Math.floor(Date.now() / 1000))
|
||||
|
||||
const payload = mode === 'query'
|
||||
const payload =
|
||||
mode === 'query'
|
||||
? {
|
||||
orderNo,
|
||||
timestamp,
|
||||
@@ -75,7 +75,9 @@ const response = await fetch(endpoint, {
|
||||
}).catch((error) => {
|
||||
console.error('请求 91 mock 接口失败,请确认后端入口可访问。')
|
||||
console.error(`当前地址:${endpoint}`)
|
||||
console.error('Docker 开发环境通常使用 http://127.0.0.1;本地直启后端通常使用 http://127.0.0.1:3000。')
|
||||
console.error(
|
||||
'Docker 开发环境通常使用 http://127.0.0.1;本地直启后端通常使用 http://127.0.0.1:3000。',
|
||||
)
|
||||
throw error
|
||||
})
|
||||
|
||||
|
||||
@@ -34,10 +34,7 @@ const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
|
||||
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
|
||||
|
||||
loadEnvFiles([
|
||||
path.join(WORKSPACE_ROOT, '.env'),
|
||||
path.join(PROJECT_ROOT, '.env'),
|
||||
])
|
||||
loadEnvFiles([path.join(WORKSPACE_ROOT, '.env'), path.join(PROJECT_ROOT, '.env')])
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
@@ -65,13 +62,21 @@ if (args.provider && providerPresets[String(args.provider)]) {
|
||||
console.log(` 预设环境: ${preset.description}`)
|
||||
}
|
||||
} else {
|
||||
baseUrl = String(args.baseUrl || process.env.KUASHOU_INDUSTRY_TEST_BASE_URL || 'http://127.0.0.1:3000').replace(/\/+$/, '')
|
||||
baseUrl = String(
|
||||
args.baseUrl || process.env.KUASHOU_INDUSTRY_TEST_BASE_URL || 'http://127.0.0.1:3000',
|
||||
).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// 签名配置:CLI 参数 > 环境变量 > 默认值
|
||||
const appKey = (String(args.appKey || '') || process.env.KUASHOU_INDUSTRY_APP_KEY || '').trim()
|
||||
const signSecret = (String(args.signSecret || '') || process.env.KUASHOU_INDUSTRY_SIGN_SECRET || '').trim()
|
||||
const signMethod = (String(args.signMethod || 'MD5').trim().toUpperCase())
|
||||
const signSecret = (
|
||||
String(args.signSecret || '') ||
|
||||
process.env.KUASHOU_INDUSTRY_SIGN_SECRET ||
|
||||
''
|
||||
).trim()
|
||||
const signMethod = String(args.signMethod || 'MD5')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
|
||||
const isLoadTest = Boolean(args.load)
|
||||
const targetTps = normalizePositiveInteger(args.tps, 100)
|
||||
@@ -119,7 +124,9 @@ if (!isLoadTest) {
|
||||
let failed = 0
|
||||
|
||||
// 1. 通知商家发码
|
||||
const sendParams = buildSignedParams(appKey, {
|
||||
const sendParams = buildSignedParams(
|
||||
appKey,
|
||||
{
|
||||
oid: testOid,
|
||||
sellerId: '2174425348',
|
||||
num: testNum,
|
||||
@@ -133,10 +140,13 @@ if (!isLoadTest) {
|
||||
certExpDays: 30,
|
||||
certActualStartTime: Date.now(),
|
||||
certActualEndTime: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
}, signMethod)
|
||||
},
|
||||
signMethod,
|
||||
)
|
||||
|
||||
const r1 = await post('/send-code', sendParams, { oid: testOid })
|
||||
if (assertResult(1, '通知商家发码', r1)) passed++; else failed++
|
||||
if (assertResult(1, '通知商家发码', r1)) passed++
|
||||
else failed++
|
||||
if (r1.ok) {
|
||||
const len = r1.data?.data?.etickets?.length || 0
|
||||
const firstId = r1.data?.data?.etickets?.[0]?.id
|
||||
@@ -145,12 +155,16 @@ if (!isLoadTest) {
|
||||
|
||||
// 2. 重复发码(幂等性)
|
||||
const r2 = await post('/send-code', sendParams, { oid: testOid })
|
||||
if (assertResult(2, '重复发码(幂等性)', r2)) passed++; else failed++
|
||||
if (assertResult(2, '重复发码(幂等性)', r2)) passed++
|
||||
else failed++
|
||||
|
||||
// 3. 查询全部卡券
|
||||
const qBiz: JsonObject = { oid: testOid, sendType: 'VIRTUAL', eticketType: 'DINING_OPEN_TICKET' }
|
||||
const r3 = await post('/query-code', buildSignedParams(appKey, qBiz, signMethod), { oid: testOid })
|
||||
if (assertResult(3, '查询全部卡券', r3)) passed++; else failed++
|
||||
const r3 = await post('/query-code', buildSignedParams(appKey, qBiz, signMethod), {
|
||||
oid: testOid,
|
||||
})
|
||||
if (assertResult(3, '查询全部卡券', r3)) passed++
|
||||
else failed++
|
||||
if (r3.ok) {
|
||||
console.log(` 卡券数: ${r3.data?.data?.etickets?.length || 0}`)
|
||||
r3.data?.data?.etickets?.forEach((e: JsonObject, i: number) => {
|
||||
@@ -161,13 +175,31 @@ if (!isLoadTest) {
|
||||
// 4. 查询单个卡券
|
||||
const firstEticketId = r3.data?.data?.etickets?.[0]?.id
|
||||
if (firstEticketId) {
|
||||
const r4 = await post('/query-code', buildSignedParams(appKey, { oid: testOid, eticketId: String(firstEticketId), sendType: 'VIRTUAL', eticketType: 'DINING_OPEN_TICKET' }, signMethod), { oid: testOid })
|
||||
if (assertResult(4, `查询单个卡券 (eticketId=${firstEticketId})`, r4)) passed++; else failed++
|
||||
const r4 = await post(
|
||||
'/query-code',
|
||||
buildSignedParams(
|
||||
appKey,
|
||||
{
|
||||
oid: testOid,
|
||||
eticketId: String(firstEticketId),
|
||||
sendType: 'VIRTUAL',
|
||||
eticketType: 'DINING_OPEN_TICKET',
|
||||
},
|
||||
signMethod,
|
||||
),
|
||||
{ oid: testOid },
|
||||
)
|
||||
if (assertResult(4, `查询单个卡券 (eticketId=${firstEticketId})`, r4)) passed++
|
||||
else failed++
|
||||
}
|
||||
|
||||
// 5. 查询不存在的订单
|
||||
const fakeOid = `FAKE_${Date.now()}`
|
||||
const r5 = await post('/query-code', buildSignedParams(appKey, { oid: fakeOid, sendType: 'VIRTUAL' }, signMethod), { oid: fakeOid })
|
||||
const r5 = await post(
|
||||
'/query-code',
|
||||
buildSignedParams(appKey, { oid: fakeOid, sendType: 'VIRTUAL' }, signMethod),
|
||||
{ oid: fakeOid },
|
||||
)
|
||||
const expect4012002 = r5.data?.result === 4012002
|
||||
if (expect4012002) {
|
||||
passed++
|
||||
@@ -179,24 +211,52 @@ if (!isLoadTest) {
|
||||
|
||||
// 6. 销毁指定卡券
|
||||
if (firstEticketId) {
|
||||
const r6 = await post('/destroy-code', buildSignedParams(appKey, { oid: testOid, reason: 'USER_APPLY_REFUND', etickets: [{ id: String(firstEticketId), num: 1, goodsValue: 100 }] }, signMethod), { oid: testOid })
|
||||
if (assertResult(6, `销毁卡券 (id=${firstEticketId})`, r6)) passed++; else failed++
|
||||
const r6 = await post(
|
||||
'/destroy-code',
|
||||
buildSignedParams(
|
||||
appKey,
|
||||
{
|
||||
oid: testOid,
|
||||
reason: 'USER_APPLY_REFUND',
|
||||
etickets: [{ id: String(firstEticketId), num: 1, goodsValue: 100 }],
|
||||
},
|
||||
signMethod,
|
||||
),
|
||||
{ oid: testOid },
|
||||
)
|
||||
if (assertResult(6, `销毁卡券 (id=${firstEticketId})`, r6)) passed++
|
||||
else failed++
|
||||
|
||||
// 7. 验证销毁后状态
|
||||
const r7 = await post('/query-code', buildSignedParams(appKey, { oid: testOid, eticketId: String(firstEticketId), sendType: 'VIRTUAL' }, signMethod), { oid: testOid })
|
||||
const r7 = await post(
|
||||
'/query-code',
|
||||
buildSignedParams(
|
||||
appKey,
|
||||
{ oid: testOid, eticketId: String(firstEticketId), sendType: 'VIRTUAL' },
|
||||
signMethod,
|
||||
),
|
||||
{ oid: testOid },
|
||||
)
|
||||
const destroyed = r7.data?.result === 1 && r7.data?.data?.etickets?.[0]?.status === 'DESTROYED'
|
||||
if (destroyed) {
|
||||
passed++
|
||||
console.log(` ✅ [7] 销毁后验证: status=DESTROYED`)
|
||||
} else {
|
||||
failed++
|
||||
console.log(` ❌ [7] 销毁后验证: 期望 DESTROYED 实际 ${r7.data?.data?.etickets?.[0]?.status}`)
|
||||
console.log(
|
||||
` ❌ [7] 销毁后验证: 期望 DESTROYED 实际 ${r7.data?.data?.etickets?.[0]?.status}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 整单销毁
|
||||
const r8 = await post('/destroy-code', buildSignedParams(appKey, { oid: testOid, reason: 'ETICKET_EXPIRED' }, signMethod), { oid: testOid })
|
||||
if (assertResult(8, '整单销毁', r8)) passed++; else failed++
|
||||
const r8 = await post(
|
||||
'/destroy-code',
|
||||
buildSignedParams(appKey, { oid: testOid, reason: 'ETICKET_EXPIRED' }, signMethod),
|
||||
{ oid: testOid },
|
||||
)
|
||||
if (assertResult(8, '整单销毁', r8)) passed++
|
||||
else failed++
|
||||
|
||||
// 9. 签名错误测试
|
||||
const badParams = buildSignedParams(appKey, { oid: testOid, sendType: 'VIRTUAL' }, signMethod)
|
||||
@@ -213,7 +273,9 @@ if (!isLoadTest) {
|
||||
|
||||
console.log('')
|
||||
console.log('╔══════════════════════════════════════════════════════╗')
|
||||
console.log(`║ HTTP 功能测试: ${String(passed).padStart(2)}/${passed + failed} 通过, ${String(failed).padStart(2)}/${passed + failed} 失败 ║`)
|
||||
console.log(
|
||||
`║ HTTP 功能测试: ${String(passed).padStart(2)}/${passed + failed} 通过, ${String(failed).padStart(2)}/${passed + failed} 失败 ║`,
|
||||
)
|
||||
console.log('╚══════════════════════════════════════════════════════╝')
|
||||
console.log('')
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
@@ -222,16 +284,16 @@ if (!isLoadTest) {
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 压力测试
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const endpointsToTest = testEndpoint
|
||||
? [testEndpoint]
|
||||
: ['send-code', 'query-code', 'destroy-code']
|
||||
const endpointsToTest = testEndpoint ? [testEndpoint] : ['send-code', 'query-code', 'destroy-code']
|
||||
|
||||
const loadTestOid = `LOAD_${Date.now()}`
|
||||
|
||||
// 先发码创建订单以供查询/销毁
|
||||
console.log('')
|
||||
console.log(` 准备测试数据: 通知商家发码 (oid=${loadTestOid})...`)
|
||||
const prepParams = buildSignedParams(appKey, {
|
||||
const prepParams = buildSignedParams(
|
||||
appKey,
|
||||
{
|
||||
oid: loadTestOid,
|
||||
sellerId: '2174425348',
|
||||
num: 10,
|
||||
@@ -245,12 +307,16 @@ const prepParams = buildSignedParams(appKey, {
|
||||
certExpDays: 30,
|
||||
certActualStartTime: Date.now(),
|
||||
certActualEndTime: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
}, signMethod)
|
||||
},
|
||||
signMethod,
|
||||
)
|
||||
const prepResult = await post('/send-code', prepParams, {})
|
||||
if (prepResult.data?.result === 1) {
|
||||
console.log(` 数据准备完成 (${prepResult.ms}ms)`)
|
||||
} else {
|
||||
console.log(` ⚠ 数据准备失败: result=${prepResult.data?.result} error_msg="${prepResult.data?.error_msg}" (${prepResult.ms}ms)`)
|
||||
console.log(
|
||||
` ⚠ 数据准备失败: result=${prepResult.data?.result} error_msg="${prepResult.data?.error_msg}" (${prepResult.ms}ms)`,
|
||||
)
|
||||
console.log(` 请确认服务端已配置正确的 appKey/signSecret 且未触发限流`)
|
||||
}
|
||||
|
||||
@@ -258,14 +324,18 @@ for (const endpoint of endpointsToTest) {
|
||||
console.log('')
|
||||
console.log('╔══════════════════════════════════════════════════════╗')
|
||||
console.log(`║ 压测: ${endpoint.padEnd(46)}║`)
|
||||
console.log(`║ 目标: ${String(targetTps).padEnd(4)} TPS, 持续 ${String(testDuration).padEnd(3)}s ║`)
|
||||
console.log(
|
||||
`║ 目标: ${String(targetTps).padEnd(4)} TPS, 持续 ${String(testDuration).padEnd(3)}s ║`,
|
||||
)
|
||||
console.log('╚══════════════════════════════════════════════════════╝')
|
||||
|
||||
const result = await runLoadTest(endpoint, targetTps, testDuration, loadTestOid)
|
||||
console.log(` 成功 TPS : ${result.successTps.toFixed(1)} (业务 result=1)`)
|
||||
console.log(` 总请求数 : ${result.totalReqs}`)
|
||||
console.log(` 业务成功 : ${result.successes}`)
|
||||
console.log(` 业务失败 : ${result.bizErrors}${result.bizSample ? ` (示例: ${result.bizSample})` : ''}`)
|
||||
console.log(
|
||||
` 业务失败 : ${result.bizErrors}${result.bizSample ? ` (示例: ${result.bizSample})` : ''}`,
|
||||
)
|
||||
console.log(` 被限流 : ${result.rateLimited}`)
|
||||
console.log(` 平均耗时 : ${result.avgMs.toFixed(1)} ms`)
|
||||
console.log(` P50 : ${result.p50Ms.toFixed(1)} ms`)
|
||||
@@ -275,7 +345,9 @@ for (const endpoint of endpointsToTest) {
|
||||
console.log(` 最大耗时 : ${result.maxMs.toFixed(1)} ms`)
|
||||
|
||||
if (result.rateLimited > result.totalReqs * 0.1) {
|
||||
console.log(` ⚠ 注意: ${((result.rateLimited / result.totalReqs) * 100).toFixed(0)}% 请求被限流,建议调高 KUASHOU_INDUSTRY_RATE_LIMIT_MAX`)
|
||||
console.log(
|
||||
` ⚠ 注意: ${((result.rateLimited / result.totalReqs) * 100).toFixed(0)}% 请求被限流,建议调高 KUASHOU_INDUSTRY_RATE_LIMIT_MAX`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +372,7 @@ async function post(
|
||||
|
||||
const startedAt = Date.now()
|
||||
let status = 0
|
||||
let data: JsonObject = {}
|
||||
let data: JsonObject
|
||||
let ok = false
|
||||
|
||||
try {
|
||||
@@ -311,7 +383,11 @@ async function post(
|
||||
})
|
||||
status = res.status
|
||||
const text = await res.text()
|
||||
try { data = JSON.parse(text) } catch { data = { raw: text } }
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
data = { raw: text }
|
||||
}
|
||||
ok = res.ok && data.result === 1
|
||||
} catch (err) {
|
||||
data = { error: String(err) }
|
||||
@@ -455,9 +531,7 @@ function signKuaishouIndustry(params: JsonObject, method: string): string {
|
||||
.filter(([key]) => key !== 'sign' && key !== 'signSecret')
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
|
||||
const queryString = entries
|
||||
.map(([key, value]) => `${key}=${stringifySignVal(value)}`)
|
||||
.join('&')
|
||||
const queryString = entries.map(([key, value]) => `${key}=${stringifySignVal(value)}`).join('&')
|
||||
|
||||
const source = `${queryString}&signSecret=${signSecret}`
|
||||
|
||||
@@ -472,23 +546,30 @@ function stringifySignVal(value: unknown): string {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'object') return JSON.stringify(value, Object.keys(value as JsonObject).sort())
|
||||
if (typeof value === 'object')
|
||||
return JSON.stringify(value, Object.keys(value as JsonObject).sort())
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// ─────────────────── 工具 ───────────────────
|
||||
|
||||
function assertResult(num: number, label: string, r: { ok: boolean; status: number; data: JsonObject; ms: number }) {
|
||||
function assertResult(
|
||||
num: number,
|
||||
label: string,
|
||||
r: { ok: boolean; status: number; data: JsonObject; ms: number },
|
||||
) {
|
||||
if (r.ok) {
|
||||
console.log(` ✅ [${num}] ${label}: result=1 (${r.ms}ms)`)
|
||||
return true
|
||||
}
|
||||
console.log(` ❌ [${num}] ${label}: HTTP ${r.status} result=${r.data?.result} error_msg="${r.data?.error_msg}" (${r.ms}ms)`)
|
||||
console.log(
|
||||
` ❌ [${num}] ${label}: HTTP ${r.status} result=${r.data?.result} error_msg="${r.data?.error_msg}" (${r.ms}ms)`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function avg(arr: number[]): number {
|
||||
@@ -512,10 +593,16 @@ function parseArgs(rawArgs: string[]): JsonObject {
|
||||
for (const rawArg of rawArgs) {
|
||||
const arg = String(rawArg || '').trim()
|
||||
if (!arg) continue
|
||||
if (arg === '--help' || arg === '-h') { parsed.help = true; continue }
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true
|
||||
continue
|
||||
}
|
||||
const normalized = arg.startsWith('--') ? arg.slice(2) : arg
|
||||
const eqIdx = normalized.indexOf('=')
|
||||
if (eqIdx < 0) { parsed[normalized] = true; continue }
|
||||
if (eqIdx < 0) {
|
||||
parsed[normalized] = true
|
||||
continue
|
||||
}
|
||||
const key = normalized.slice(0, eqIdx).trim()
|
||||
const value = normalized.slice(eqIdx + 1).trim()
|
||||
if (key) parsed[key] = value
|
||||
|
||||
@@ -21,13 +21,12 @@ const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
|
||||
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
|
||||
|
||||
loadEnvFiles([
|
||||
path.join(WORKSPACE_ROOT, '.env'),
|
||||
path.join(PROJECT_ROOT, '.env'),
|
||||
])
|
||||
loadEnvFiles([path.join(WORKSPACE_ROOT, '.env'), path.join(PROJECT_ROOT, '.env')])
|
||||
|
||||
process.env.KUASHOU_INDUSTRY_APP_KEY = process.env.KUASHOU_INDUSTRY_APP_KEY || 'test_industry_app_key'
|
||||
process.env.KUASHOU_INDUSTRY_SIGN_SECRET = process.env.KUASHOU_INDUSTRY_SIGN_SECRET || 'test_industry_sign_secret'
|
||||
process.env.KUASHOU_INDUSTRY_APP_KEY =
|
||||
process.env.KUASHOU_INDUSTRY_APP_KEY || 'test_industry_app_key'
|
||||
process.env.KUASHOU_INDUSTRY_SIGN_SECRET =
|
||||
process.env.KUASHOU_INDUSTRY_SIGN_SECRET || 'test_industry_sign_secret'
|
||||
process.env.KUASHOU_INDUSTRY_SHOP_ID = process.env.KUASHOU_INDUSTRY_SHOP_ID || 'test_shop'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
@@ -56,7 +55,9 @@ const [
|
||||
const config = getKuaishouIndustryConfig()
|
||||
const testOid = String(args.oid || `TEST_KS${Date.now()}`).trim()
|
||||
const testNum = normalizePositiveInteger(args.num, 3)
|
||||
const signMethod = String(args.signMethod || 'MD5').trim().toUpperCase()
|
||||
const signMethod = String(args.signMethod || 'MD5')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
|
||||
console.log('')
|
||||
console.log('╔══════════════════════════════════════════════════════╗')
|
||||
@@ -149,7 +150,9 @@ try {
|
||||
console.log(` 已发货数量: ${queryCodeResult.data?.sendNum || 0}`)
|
||||
console.log(` 卡券列表数: ${queryCodeResult.data?.etickets?.length || 0}`)
|
||||
queryCodeResult.data?.etickets?.forEach((eticket: Record<string, unknown>, index: number) => {
|
||||
console.log(` [${index + 1}] id=${eticket.id} status=${eticket.status} num=${eticket.num}`)
|
||||
console.log(
|
||||
` [${index + 1}] id=${eticket.id} status=${eticket.status} num=${eticket.num}`,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
failed++
|
||||
@@ -206,7 +209,9 @@ try {
|
||||
console.log(` 错误信息: ${queryMissingResult.error_msg}`)
|
||||
} else {
|
||||
failed++
|
||||
console.log(` └─ ❌ 查询不存在订单: 期望 result=4012002,实际 result=${queryMissingResult.result}`)
|
||||
console.log(
|
||||
` └─ ❌ 查询不存在订单: 期望 result=4012002,实际 result=${queryMissingResult.result}`,
|
||||
)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -214,7 +219,9 @@ try {
|
||||
// ─────────────────────────────────────────────────────────
|
||||
if (queryCodeResult.data?.etickets?.length > 0) {
|
||||
total++
|
||||
const destroyTargetIds = queryCodeResult.data.etickets.slice(0, 1).map((e: Record<string, unknown>) => ({
|
||||
const destroyTargetIds = queryCodeResult.data.etickets
|
||||
.slice(0, 1)
|
||||
.map((e: Record<string, unknown>) => ({
|
||||
id: String(e.id),
|
||||
num: 1,
|
||||
goodsValue: 100,
|
||||
@@ -248,7 +255,11 @@ try {
|
||||
eticketType: 'DINING_OPEN_TICKET',
|
||||
}
|
||||
|
||||
const verifyDestroyParams = buildRequestParams(config.appKey, verifyDestroyBizParams, signMethod)
|
||||
const verifyDestroyParams = buildRequestParams(
|
||||
config.appKey,
|
||||
verifyDestroyBizParams,
|
||||
signMethod,
|
||||
)
|
||||
const verifyDestroyResult = await handleQueryCode(verifyDestroyParams)
|
||||
|
||||
printResult('销毁后查询', verifyDestroyResult)
|
||||
@@ -258,7 +269,9 @@ try {
|
||||
console.log(` 卡券状态: ${verifyStatus}`)
|
||||
} else {
|
||||
failed++
|
||||
console.log(` 期望 status=DESTROYED,实际 status=${verifyStatus}, result=${verifyDestroyResult.result}`)
|
||||
console.log(
|
||||
` 期望 status=DESTROYED,实际 status=${verifyStatus}, result=${verifyDestroyResult.result}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
console.log(' ⚠ 跳过(无卡券可销毁)')
|
||||
@@ -284,7 +297,6 @@ try {
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
|
||||
} finally {
|
||||
await closeDb()
|
||||
}
|
||||
@@ -292,7 +304,9 @@ try {
|
||||
// ─────────────────────────────────────────────────────────
|
||||
console.log('')
|
||||
console.log('╔══════════════════════════════════════════════════════╗')
|
||||
console.log(`║ 测试完成: ${String(passed).padStart(2)}/${total} 通过, ${String(failed).padStart(2)}/${total} 失败 ║`)
|
||||
console.log(
|
||||
`║ 测试完成: ${String(passed).padStart(2)}/${total} 通过, ${String(failed).padStart(2)}/${total} 失败 ║`,
|
||||
)
|
||||
console.log('╚══════════════════════════════════════════════════════╝')
|
||||
console.log('')
|
||||
|
||||
@@ -338,7 +352,9 @@ function printStep(stepNum: number, title: string) {
|
||||
function printResult(label: string, result: Record<string, unknown>) {
|
||||
const ok = result.result === 1
|
||||
const icon = ok ? '✅' : '❌'
|
||||
const resultText = ok ? `result=${result.result}` : `result=${result.result} error_msg="${result.error_msg}"`
|
||||
const resultText = ok
|
||||
? `result=${result.result}`
|
||||
: `result=${result.result} error_msg="${result.error_msg}"`
|
||||
console.log(` └─ ${icon} ${label}: ${resultText}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,18 @@ async function main() {
|
||||
const config = runtimeConfig.worker.sms
|
||||
console.log('当前短信配置:')
|
||||
console.log(' provider :', config.provider)
|
||||
console.log(' accessKeyId :', config.accessKeyId ? `${config.accessKeyId.slice(0, 4)}...${config.accessKeyId.slice(-4)}` : '(空)')
|
||||
console.log(' accessKeySecret :', config.accessKeySecret ? `${config.accessKeySecret.slice(0, 4)}...${config.accessKeySecret.slice(-4)}(长度 ${config.accessKeySecret.length})` : '(空)')
|
||||
console.log(
|
||||
' accessKeyId :',
|
||||
config.accessKeyId
|
||||
? `${config.accessKeyId.slice(0, 4)}...${config.accessKeyId.slice(-4)}`
|
||||
: '(空)',
|
||||
)
|
||||
console.log(
|
||||
' accessKeySecret :',
|
||||
config.accessKeySecret
|
||||
? `${config.accessKeySecret.slice(0, 4)}...${config.accessKeySecret.slice(-4)}(长度 ${config.accessKeySecret.length})`
|
||||
: '(空)',
|
||||
)
|
||||
console.log(' signName :', config.signName)
|
||||
console.log(' templateCode :', config.templateCode)
|
||||
console.log('')
|
||||
@@ -64,13 +74,18 @@ async function main() {
|
||||
const url = `https://dysmsapi.aliyuncs.com/?${queryString}`
|
||||
|
||||
console.log('stringToSign:')
|
||||
console.log(' ', `GET&%2F&${percentEncode(
|
||||
console.log(
|
||||
' ',
|
||||
`GET&%2F&${percentEncode(
|
||||
Object.entries(params)
|
||||
.filter(([key]) => key !== 'Signature')
|
||||
.map(([key, value]) => `${percentEncode(key)}=${percentEncode(value)}`)
|
||||
.sort((left, right) => (String(left[0]) < String(right[0]) ? -1 : String(left[0]) > String(right[0]) ? 1 : 0))
|
||||
.sort((left, right) =>
|
||||
String(left[0]) < String(right[0]) ? -1 : String(left[0]) > String(right[0]) ? 1 : 0,
|
||||
)
|
||||
.join('&'),
|
||||
)}`)
|
||||
)}`,
|
||||
)
|
||||
console.log('')
|
||||
console.log(`发送测试短信到 ${phone}(验证码 123456)...`)
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { once } from 'node:events'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createApp } from './app.js'
|
||||
import { createDefaultRuntimeConfig } from './config/defaults.js'
|
||||
import { createStartupState } from './startup/state.js'
|
||||
|
||||
test('HTTP health route and explicit CORS preflight', async () => {
|
||||
const startupState = createStartupState()
|
||||
startupState.core.ready = true
|
||||
const config = createDefaultRuntimeConfig('/tmp/order-site')
|
||||
config.cors.allowedOrigins = ['https://order.example.test']
|
||||
const server = createApp({
|
||||
startupState,
|
||||
isShutdownStarted: () => false,
|
||||
config,
|
||||
}).listen(0, '127.0.0.1')
|
||||
await once(server, 'listening')
|
||||
|
||||
try {
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address === 'object')
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`
|
||||
const health = await fetch(`${baseUrl}/health/live`)
|
||||
assert.equal(health.status, 200)
|
||||
assert.equal((await health.json()).data.status, 'alive')
|
||||
|
||||
const preflight = await fetch(`${baseUrl}/api/v1/health`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: 'https://order.example.test' },
|
||||
})
|
||||
assert.equal(preflight.status, 204)
|
||||
assert.equal(preflight.headers.get('access-control-allow-origin'), 'https://order.example.test')
|
||||
assert.equal(preflight.headers.get('vary'), 'Origin')
|
||||
} finally {
|
||||
server.close()
|
||||
await once(server, 'close')
|
||||
}
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import express from 'express'
|
||||
import process from 'node:process'
|
||||
|
||||
import adminRouter from './routes/admin.js'
|
||||
import affiliateDashRouter from './routes/affiliate-dash.js'
|
||||
import collectRouter from './routes/collect.js'
|
||||
import filesRouter from './routes/files.js'
|
||||
import claimsRouter from './routes/claims.js'
|
||||
@@ -26,6 +27,11 @@ type CreateAppOptions = {
|
||||
export function createApp({ startupState, isShutdownStarted, config }: CreateAppOptions) {
|
||||
const app = express()
|
||||
|
||||
// 部署在反代(Caddy)之后时让 req.ip 取 X-Forwarded-For 的真实客户端地址;限流、访问日志与登录设备记录都依赖它。
|
||||
if (config.server?.trustProxy !== undefined && config.server?.trustProxy !== null) {
|
||||
app.set('trust proxy', config.server.trustProxy)
|
||||
}
|
||||
|
||||
app.use(accessLogMiddleware)
|
||||
app.use(createCorsMiddleware(config))
|
||||
app.use(
|
||||
@@ -93,6 +99,7 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp
|
||||
})
|
||||
})
|
||||
|
||||
app.use('/api/v1/open/affiliate-dash', affiliateDashRouter)
|
||||
app.use('/api/v1/open/91', open91Router)
|
||||
app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter)
|
||||
app.use('/api/v1/open/kuaishou-feifei', kuaishouFeifeiRouter)
|
||||
|
||||
@@ -3,9 +3,15 @@ export const APP_CONFIG_KEYS = {
|
||||
scheduledJobs: 'scheduled_jobs',
|
||||
fulfillmentRouting: 'fulfillment_routing',
|
||||
workerFinance: 'worker_finance',
|
||||
workerPlatformNotifications: 'worker_platform_notifications',
|
||||
workerProductMatch: 'worker_product_match',
|
||||
workerAnnouncement: 'worker_announcement',
|
||||
workerHall: 'worker_hall',
|
||||
workerMockShops: 'worker_mock_shops',
|
||||
cloudtentaclesSources: 'cloudtentacles_sources',
|
||||
cloudtentaclesSession: 'cloudtentacles_session',
|
||||
cloudtentaclesOverrideRules: 'cloudtentacles_override_rules',
|
||||
kuaishouFeifei: 'kuaishou_feifei',
|
||||
kuaishouIndustrySource: 'kuaishou_industry_source',
|
||||
affiliateDash: 'affiliate_dash',
|
||||
} as const
|
||||
|
||||
@@ -6,6 +6,8 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
// 默认信任一层反代(Caddy),req.ip 才能取到真实客户端地址;直连暴露端口时设为 false。
|
||||
trustProxy: 1,
|
||||
},
|
||||
|
||||
data: {
|
||||
@@ -17,26 +19,49 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
// 对接日志默认只保留 warn/error,避免 integration-*.log 被 info 刷爆
|
||||
integrationLevel: 'warn',
|
||||
retentionDays: 30,
|
||||
maxFileSizeMb: 20,
|
||||
maxFiles: 5,
|
||||
},
|
||||
|
||||
retention: {
|
||||
// 原始报文只保留追溯所需窗口,清理后保留订单/事件摘要。
|
||||
rawPayloadDays: 180,
|
||||
taskEventDays: 180,
|
||||
webhookEventDays: 180,
|
||||
auditLogDays: 365,
|
||||
},
|
||||
|
||||
database: {
|
||||
url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site',
|
||||
ssl: false,
|
||||
maxConnections: 10,
|
||||
maxConnections: 8,
|
||||
idleTimeoutMs: 30_000,
|
||||
connectionTimeoutMs: 5_000,
|
||||
statementTimeoutMs: 15_000,
|
||||
slowQueryThresholdMs: 500,
|
||||
},
|
||||
|
||||
storage: {
|
||||
mode: 'minio',
|
||||
endpoint: 'http://127.0.0.1:9000',
|
||||
bucket: 'order-site',
|
||||
accessKeyId: 'minioadmin',
|
||||
secretAccessKey: 'minioadmin',
|
||||
region: 'us-east-1',
|
||||
maxUploadSizeMb: 10,
|
||||
oss: {
|
||||
endpoint: '',
|
||||
bucket: '',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
region: 'oss-cn-hangzhou',
|
||||
},
|
||||
},
|
||||
|
||||
orders: {
|
||||
claimBaseUrl: 'http://127.0.0.1:5173/#/claim',
|
||||
tokenTtlHours: 24,
|
||||
// 0 表示长期有效;后台手动关闭任务后领取链接仍会立即失效。
|
||||
tokenTtlHours: 0,
|
||||
},
|
||||
|
||||
admin: {
|
||||
@@ -75,6 +100,18 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
notifyUrl: '',
|
||||
productRules: [],
|
||||
},
|
||||
affiliateDash: {
|
||||
enabled: true,
|
||||
baseUrl: '',
|
||||
appKey: '',
|
||||
appSecret: '',
|
||||
callbackSecret: '',
|
||||
timeoutMs: 10000,
|
||||
notifyUrl: '',
|
||||
timestampToleranceSeconds: 300,
|
||||
preferredMatchEnabled: true,
|
||||
skuMapping: {},
|
||||
},
|
||||
cloudtentacles: {
|
||||
baseUrl: 'https://123.207.217.176',
|
||||
timeoutMs: 5000,
|
||||
@@ -138,6 +175,7 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
|
||||
cooldownSeconds: 60,
|
||||
dailyLimit: 10,
|
||||
},
|
||||
orderTimelineVisible: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ENV_OVERRIDES } from './env-overrides.js'
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const BACKEND_ROOT = path.resolve(CURRENT_DIR, '../..')
|
||||
const WORKSPACE_ROOT = path.resolve(BACKEND_ROOT, '../..')
|
||||
const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT || path.resolve(BACKEND_ROOT, '../..')
|
||||
|
||||
const SPECIAL_RUNTIME_ENV_NAMES = ['APP_BASE_URL', 'CLAIM_BASE_URL']
|
||||
|
||||
@@ -17,6 +17,8 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'BACKEND_PORT',
|
||||
'CADDY_SITE_ADDR',
|
||||
'CHOKIDAR_USEPOLLING',
|
||||
'DB_BACKUP_KEEP',
|
||||
'DB_BACKUP_OSS_PREFIX',
|
||||
'KUASHOU_INDUSTRY_RATE_LIMIT_MAX',
|
||||
'MINIO_API_PORT',
|
||||
'MINIO_CONSOLE_PORT',
|
||||
@@ -24,12 +26,26 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'MINIO_ROOT_USER',
|
||||
'NPM_CONFIG_REGISTRY',
|
||||
'POSTGRES_DB',
|
||||
'POSTGRES_LOG_MIN_DURATION_STATEMENT_MS',
|
||||
'POSTGRES_MEM_LIMIT',
|
||||
'POSTGRES_CPUS',
|
||||
'POSTGRES_SHARED_BUFFERS',
|
||||
'POSTGRES_EFFECTIVE_CACHE_SIZE',
|
||||
'POSTGRES_WORK_MEM',
|
||||
'POSTGRES_MAINTENANCE_WORK_MEM',
|
||||
'BACKEND_MEM_LIMIT',
|
||||
'BACKEND_CPUS',
|
||||
'WEB_MEM_LIMIT',
|
||||
'WEB_CPUS',
|
||||
'POSTGRES_PASSWORD',
|
||||
'POSTGRES_PORT',
|
||||
'POSTGRES_USER',
|
||||
'TZ',
|
||||
'VITE_API_TARGET',
|
||||
'VITE_ALLOWED_HOSTS',
|
||||
'VITE_WORKER_SITE_URL',
|
||||
'WORKSPACE_ROOT',
|
||||
'WORKER_SITE_ADDR',
|
||||
]
|
||||
|
||||
const KNOWN_ENV_NAMES = new Set([
|
||||
|
||||
@@ -13,6 +13,8 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
|
||||
DATA_ROOT: './tmp/backend-data',
|
||||
DATABASE_SSL: 'yes',
|
||||
LOG_RETENTION_DAYS: '45',
|
||||
LOG_MAX_FILE_SIZE_MB: '12',
|
||||
LOG_MAX_FILES: '4',
|
||||
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
|
||||
KAQUAN91_USER_ID: 'kaquan-user',
|
||||
KAQUAN91_SECRET: 'kaquan-secret',
|
||||
@@ -23,6 +25,8 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
|
||||
assert.equal(config.data.root, path.resolve('./tmp/backend-data'))
|
||||
assert.equal(config.database.ssl, true)
|
||||
assert.equal(config.logging.retentionDays, 45)
|
||||
assert.equal(config.logging.maxFileSizeMb, 12)
|
||||
assert.equal(config.logging.maxFiles, 4)
|
||||
assert.deepEqual(config.admin.defaultUsers, [
|
||||
{ username: 'admin', password: 'secret', role: 'admin' },
|
||||
])
|
||||
@@ -42,6 +46,14 @@ test('applyEnvOverrides derives claim base url from app base url when explicit c
|
||||
assert.equal(config.orders.claimBaseUrl, 'https://example.test/#/claim')
|
||||
})
|
||||
|
||||
test('CLAIM_TOKEN_TTL_HOURS=0 表示领取链接长期有效', () => {
|
||||
const config = applyEnvOverrides(createRuntimeConfig(), {
|
||||
CLAIM_TOKEN_TTL_HOURS: '0',
|
||||
})
|
||||
|
||||
assert.equal(config.orders.tokenTtlHours, 0)
|
||||
})
|
||||
|
||||
test('applyEnvOverrides prefers explicit claim base url over app base url', () => {
|
||||
const config = applyEnvOverrides(createRuntimeConfig(), {
|
||||
APP_BASE_URL: 'https://example.test',
|
||||
|
||||
@@ -17,6 +17,7 @@ type RuntimeConfigValue =
|
||||
| AdminDefaultUser[]
|
||||
| KuaishouFeifeiProductRule[]
|
||||
| string[]
|
||||
| Record<string, string>
|
||||
type RuntimeEnv = Record<string, string | undefined>
|
||||
|
||||
type EnvOverride = {
|
||||
@@ -27,19 +28,36 @@ type EnvOverride = {
|
||||
|
||||
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
integerEnv('PORT', ['server', 'port']),
|
||||
trustProxyEnv('HTTP_TRUST_PROXY', ['server', 'trustProxy']),
|
||||
stringEnv('DATA_ROOT', ['data', 'root'], path.resolve),
|
||||
stringEnv('LOG_LEVEL', ['logging', 'level']),
|
||||
stringEnv('LOG_INTEGRATION_LEVEL', ['logging', 'integrationLevel']),
|
||||
integerEnv('LOG_RETENTION_DAYS', ['logging', 'retentionDays']),
|
||||
integerEnv('LOG_MAX_FILE_SIZE_MB', ['logging', 'maxFileSizeMb']),
|
||||
integerEnv('LOG_MAX_FILES', ['logging', 'maxFiles']),
|
||||
integerEnv('RAW_PAYLOAD_RETENTION_DAYS', ['retention', 'rawPayloadDays']),
|
||||
integerEnv('TASK_EVENT_RETENTION_DAYS', ['retention', 'taskEventDays']),
|
||||
integerEnv('WEBHOOK_EVENT_RETENTION_DAYS', ['retention', 'webhookEventDays']),
|
||||
integerEnv('AUDIT_LOG_RETENTION_DAYS', ['retention', 'auditLogDays']),
|
||||
stringEnv('DATABASE_URL', ['database', 'url']),
|
||||
booleanEnv('DATABASE_SSL', ['database', 'ssl']),
|
||||
integerEnv('DATABASE_MAX_CONNECTIONS', ['database', 'maxConnections']),
|
||||
integerEnv('DATABASE_IDLE_TIMEOUT_MS', ['database', 'idleTimeoutMs']),
|
||||
integerEnv('DATABASE_CONNECTION_TIMEOUT_MS', ['database', 'connectionTimeoutMs']),
|
||||
integerEnv('DATABASE_STATEMENT_TIMEOUT_MS', ['database', 'statementTimeoutMs']),
|
||||
integerEnv('DATABASE_SLOW_QUERY_THRESHOLD_MS', ['database', 'slowQueryThresholdMs']),
|
||||
stringEnv('STORAGE_MODE', ['storage', 'mode']),
|
||||
stringEnv('STORAGE_ENDPOINT', ['storage', 'endpoint']),
|
||||
stringEnv('STORAGE_BUCKET', ['storage', 'bucket']),
|
||||
stringEnv('STORAGE_ACCESS_KEY_ID', ['storage', 'accessKeyId']),
|
||||
stringEnv('STORAGE_SECRET_ACCESS_KEY', ['storage', 'secretAccessKey']),
|
||||
stringEnv('STORAGE_REGION', ['storage', 'region']),
|
||||
integerEnv('STORAGE_MAX_UPLOAD_SIZE_MB', ['storage', 'maxUploadSizeMb']),
|
||||
stringEnv('STORAGE_OSS_ENDPOINT', ['storage', 'oss', 'endpoint']),
|
||||
stringEnv('STORAGE_OSS_BUCKET', ['storage', 'oss', 'bucket']),
|
||||
stringEnv('STORAGE_OSS_ACCESS_KEY_ID', ['storage', 'oss', 'accessKeyId']),
|
||||
stringEnv('STORAGE_OSS_SECRET_ACCESS_KEY', ['storage', 'oss', 'secretAccessKey']),
|
||||
stringEnv('STORAGE_OSS_REGION', ['storage', 'oss', 'region']),
|
||||
integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']),
|
||||
stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']),
|
||||
integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionTtlHours']),
|
||||
@@ -164,6 +182,7 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
integerEnv('WORKER_SMS_CODE_TTL_SECONDS', ['worker', 'sms', 'codeTtlSeconds']),
|
||||
integerEnv('WORKER_SMS_COOLDOWN_SECONDS', ['worker', 'sms', 'cooldownSeconds']),
|
||||
integerEnv('WORKER_SMS_DAILY_LIMIT', ['worker', 'sms', 'dailyLimit']),
|
||||
booleanEnv('WORKER_ORDER_TIMELINE_VISIBLE', ['worker', 'orderTimelineVisible']),
|
||||
integerEnv('KUAISHOU_FEIFEI_TIMEOUT_MS', ['platforms', 'kuaishouFeifei', 'timeoutMs']),
|
||||
stringEnv('KUAISHOU_FEIFEI_NOTIFY_URL', ['platforms', 'kuaishouFeifei', 'notifyUrl']),
|
||||
kuaishouFeifeiProductRulesEnv('KUAISHOU_FEIFEI_PRODUCT_RULES_JSON', [
|
||||
@@ -171,6 +190,22 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
'kuaishouFeifei',
|
||||
'productRules',
|
||||
]),
|
||||
stringEnv('AFFILIATE_DASH_BASE_URL', ['platforms', 'affiliateDash', 'baseUrl']),
|
||||
stringEnv('AFFILIATE_DASH_APP_KEY', ['platforms', 'affiliateDash', 'appKey']),
|
||||
stringEnv('AFFILIATE_DASH_APP_SECRET', ['platforms', 'affiliateDash', 'appSecret']),
|
||||
stringEnv('AFFILIATE_DASH_CALLBACK_SECRET', ['platforms', 'affiliateDash', 'callbackSecret']),
|
||||
integerEnv('AFFILIATE_DASH_TIMEOUT_MS', ['platforms', 'affiliateDash', 'timeoutMs']),
|
||||
stringEnv('AFFILIATE_DASH_NOTIFY_URL', ['platforms', 'affiliateDash', 'notifyUrl']),
|
||||
integerEnv('AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS', [
|
||||
'platforms',
|
||||
'affiliateDash',
|
||||
'timestampToleranceSeconds',
|
||||
]),
|
||||
affiliateDashSkuMappingEnv('AFFILIATE_DASH_SKU_MAPPING_JSON', [
|
||||
'platforms',
|
||||
'affiliateDash',
|
||||
'skuMapping',
|
||||
]),
|
||||
corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']),
|
||||
]
|
||||
|
||||
@@ -236,6 +271,31 @@ function booleanEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
}
|
||||
}
|
||||
|
||||
function trustProxyEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
path: configPath,
|
||||
read: parseTrustProxy,
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP_TRUST_PROXY 支持 true/false、反代层数(数字)与 loopback 等预定义子网,其余值回落到默认。 */
|
||||
function parseTrustProxy(rawValue: unknown): RuntimeConfigValue | null {
|
||||
const value = parseString(rawValue)
|
||||
if (value === null) return null
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === 'yes') return true
|
||||
if (normalized === 'false' || normalized === 'no') return false
|
||||
if (normalized === 'loopback' || normalized === 'uniquelocal' || normalized === 'linklocal') {
|
||||
return normalized
|
||||
}
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
const hops = Number(normalized)
|
||||
return hops >= 0 ? hops : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function adminUsersJsonEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
@@ -257,6 +317,28 @@ function kuaishouFeifeiProductRulesEnv(env: string, configPath: RuntimeConfigPat
|
||||
}
|
||||
}
|
||||
|
||||
function affiliateDashSkuMappingEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
path: configPath,
|
||||
read(rawValue) {
|
||||
const text = String(rawValue ?? '').trim()
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>
|
||||
}
|
||||
} catch {
|
||||
// 忽略非法 JSON
|
||||
}
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function corsOriginsEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
import fs from "node:fs";
|
||||
import process from "node:process";
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
|
||||
export function loadEnvFiles(filePaths: string[]): void {
|
||||
for (const filePath of filePaths) {
|
||||
loadEnvFile(filePath);
|
||||
loadEnvFile(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath: string): void {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const rawText = fs.readFileSync(filePath, "utf8");
|
||||
const lines = rawText.split(/\r?\n/);
|
||||
const rawText = fs.readFileSync(filePath, 'utf8')
|
||||
const lines = rawText.split(/\r?\n/)
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
const line = rawLine.trim()
|
||||
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf("=");
|
||||
const separatorIndex = line.indexOf('=')
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const key = line.slice(0, separatorIndex).trim()
|
||||
if (!key || key in process.env) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
process.env[key] = parseEnvValue(line.slice(separatorIndex + 1));
|
||||
process.env[key] = parseEnvValue(line.slice(separatorIndex + 1))
|
||||
}
|
||||
}
|
||||
|
||||
function parseEnvValue(rawValue: string): string {
|
||||
const value = String(rawValue || "").trim();
|
||||
const value = String(rawValue || '').trim()
|
||||
|
||||
if (!value) {
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
|
||||
const quote = value[0];
|
||||
const quote = value[0]
|
||||
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
|
||||
return value.slice(1, -1);
|
||||
return value.slice(1, -1)
|
||||
}
|
||||
|
||||
return value;
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseBoolean(rawValue: unknown): boolean | null {
|
||||
const normalized = String(rawValue || "")
|
||||
const normalized = String(rawValue || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
.toLowerCase()
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (["0", "false", "no", "off"].includes(normalized)) {
|
||||
return false;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseInteger(rawValue: unknown): number | null {
|
||||
const normalized = String(rawValue || "").trim();
|
||||
const normalized = String(rawValue || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
const parsed = Number(normalized)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
export function parseJsonArray<T = unknown>(rawValue: unknown): T[] | null {
|
||||
const normalized = String(rawValue || "").trim();
|
||||
const normalized = String(rawValue || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized);
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : null;
|
||||
const parsed = JSON.parse(normalized)
|
||||
return Array.isArray(parsed) ? (parsed as T[]) : null
|
||||
} catch {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,17 @@ test('validateRuntimeConfig accepts complete production config', () => {
|
||||
assert.deepEqual(issues, [])
|
||||
})
|
||||
|
||||
test('validateRuntimeConfig rejects wildcard CORS in production', () => {
|
||||
const issues = validateRuntimeConfig(createRuntimeConfig({ cors: { allowedOrigins: ['*'] } }), {
|
||||
env: { NODE_ENV: 'production' },
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
issues.map((issue) => issue.path),
|
||||
['cors.allowedOrigins'],
|
||||
)
|
||||
})
|
||||
|
||||
test('validateRuntimeConfig catches invalid numeric and url values', () => {
|
||||
const issues = validateRuntimeConfig(
|
||||
createRuntimeConfig({
|
||||
@@ -88,7 +99,7 @@ test('validateRuntimeConfig catches invalid numeric and url values', () => {
|
||||
},
|
||||
orders: {
|
||||
claimBaseUrl: 'not-a-url',
|
||||
tokenTtlHours: 0,
|
||||
tokenTtlHours: -1,
|
||||
},
|
||||
}),
|
||||
{
|
||||
@@ -102,6 +113,19 @@ test('validateRuntimeConfig catches invalid numeric and url values', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('validateRuntimeConfig accepts zero claim token TTL for permanent links', () => {
|
||||
const issues = validateRuntimeConfig(
|
||||
createRuntimeConfig({
|
||||
orders: {
|
||||
tokenTtlHours: 0,
|
||||
},
|
||||
}),
|
||||
{ env: { NODE_ENV: 'development' } },
|
||||
)
|
||||
|
||||
assert.deepEqual(issues, [])
|
||||
})
|
||||
|
||||
test('assertRuntimeConfigValid throws a readable validation error', () => {
|
||||
assert.throws(
|
||||
() => {
|
||||
|
||||
@@ -44,12 +44,59 @@ export function validateRuntimeConfig(
|
||||
const productionLike = isProductionLike(env)
|
||||
|
||||
requireInteger(issues, 'server.port', config.server?.port, { min: 1, max: 65535 })
|
||||
const trustProxy = config.server?.trustProxy
|
||||
if (trustProxy !== undefined && trustProxy !== null && !isValidTrustProxy(trustProxy)) {
|
||||
issues.push({
|
||||
path: 'server.trustProxy',
|
||||
message: 'server.trustProxy 仅支持 true/false、非负整数或 loopback/uniquelocal/linklocal',
|
||||
})
|
||||
}
|
||||
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
||||
requireOptionalInteger(issues, 'logging.maxFileSizeMb', config.logging?.maxFileSizeMb, { min: 1 })
|
||||
requireOptionalInteger(issues, 'logging.maxFiles', config.logging?.maxFiles, { min: 2 })
|
||||
requireOptionalInteger(issues, 'database.idleTimeoutMs', config.database?.idleTimeoutMs, {
|
||||
min: 1,
|
||||
})
|
||||
requireOptionalInteger(
|
||||
issues,
|
||||
'database.connectionTimeoutMs',
|
||||
config.database?.connectionTimeoutMs,
|
||||
{ min: 1 },
|
||||
)
|
||||
requireOptionalInteger(
|
||||
issues,
|
||||
'database.statementTimeoutMs',
|
||||
config.database?.statementTimeoutMs,
|
||||
{
|
||||
min: 1,
|
||||
},
|
||||
)
|
||||
requireOptionalInteger(
|
||||
issues,
|
||||
'database.slowQueryThresholdMs',
|
||||
config.database?.slowQueryThresholdMs,
|
||||
{ min: 0 },
|
||||
)
|
||||
requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, {
|
||||
min: 1,
|
||||
max: 100,
|
||||
})
|
||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
||||
if (config.retention) {
|
||||
requireInteger(issues, 'retention.rawPayloadDays', config.retention.rawPayloadDays, { min: 1 })
|
||||
requireInteger(issues, 'retention.taskEventDays', config.retention.taskEventDays, { min: 1 })
|
||||
requireInteger(issues, 'retention.webhookEventDays', config.retention.webhookEventDays, {
|
||||
min: 1,
|
||||
})
|
||||
requireInteger(issues, 'retention.auditLogDays', config.retention.auditLogDays, { min: 1 })
|
||||
}
|
||||
const storageMode = normalizeStorageMode(config.storage?.mode)
|
||||
if (!storageMode) {
|
||||
issues.push({
|
||||
path: 'storage.mode',
|
||||
message: '仅支持 minio、dual 或 oss',
|
||||
})
|
||||
}
|
||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 })
|
||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
||||
requireInteger(
|
||||
issues,
|
||||
@@ -86,25 +133,72 @@ export function validateRuntimeConfig(
|
||||
|
||||
validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
||||
validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint)
|
||||
validateOptionalStorageEndpoint(issues, 'storage.oss.endpoint', config.storage?.oss?.endpoint)
|
||||
validateRequiredString(issues, 'data.root', config.data?.root)
|
||||
|
||||
if (productionLike) {
|
||||
validateRequiredString(issues, 'database.url', config.database?.url)
|
||||
validateRequiredString(issues, 'storage.endpoint', config.storage?.endpoint)
|
||||
validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint)
|
||||
validateRequiredString(issues, 'storage.bucket', config.storage?.bucket)
|
||||
validateRequiredString(issues, 'storage.accessKeyId', config.storage?.accessKeyId)
|
||||
validateSecret(issues, 'storage.secretAccessKey', config.storage?.secretAccessKey, {
|
||||
minLength: 8,
|
||||
})
|
||||
if (storageMode === 'minio' || storageMode === 'dual') {
|
||||
validateStorageCredentials(issues, 'storage', config.storage)
|
||||
}
|
||||
if (storageMode === 'oss' || storageMode === 'dual') {
|
||||
validateStorageCredentials(issues, 'storage.oss', config.storage?.oss)
|
||||
}
|
||||
validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
||||
validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 })
|
||||
validateAdminDefaultUsers(issues, config.admin?.defaultUsers)
|
||||
const allowedOrigins = config.cors?.allowedOrigins
|
||||
if (Array.isArray(allowedOrigins)) {
|
||||
if (allowedOrigins.length === 0 || allowedOrigins.includes('*')) {
|
||||
issues.push({
|
||||
path: 'cors.allowedOrigins',
|
||||
message: '生产环境必须配置显式 CORS 域名白名单,不能使用 *',
|
||||
})
|
||||
} else {
|
||||
allowedOrigins.forEach((origin, index) => {
|
||||
if (!isHttpUrl(String(origin || '').trim())) {
|
||||
issues.push({
|
||||
path: `cors.allowedOrigins[${index}]`,
|
||||
message: '必须是 http 或 https 来源 URL',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function normalizeStorageMode(value: unknown): 'minio' | 'dual' | 'oss' | '' {
|
||||
const mode = String(value || 'minio')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return mode === 'minio' || mode === 'dual' || mode === 'oss' ? mode : ''
|
||||
}
|
||||
|
||||
function validateStorageCredentials(
|
||||
issues: RuntimeConfigValidationIssue[],
|
||||
configPath: string,
|
||||
storage:
|
||||
| {
|
||||
endpoint?: unknown
|
||||
bucket?: unknown
|
||||
accessKeyId?: unknown
|
||||
secretAccessKey?: unknown
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
): void {
|
||||
validateRequiredString(issues, `${configPath}.endpoint`, storage?.endpoint)
|
||||
validateOptionalStorageEndpoint(issues, `${configPath}.endpoint`, storage?.endpoint)
|
||||
validateRequiredString(issues, `${configPath}.bucket`, storage?.bucket)
|
||||
validateRequiredString(issues, `${configPath}.accessKeyId`, storage?.accessKeyId)
|
||||
validateSecret(issues, `${configPath}.secretAccessKey`, storage?.secretAccessKey, {
|
||||
minLength: 8,
|
||||
})
|
||||
}
|
||||
|
||||
export function isProductionLike(env: RuntimeEnvironment = process.env): boolean {
|
||||
return env.NODE_ENV === 'production'
|
||||
}
|
||||
@@ -253,6 +347,24 @@ function requireInteger(
|
||||
}
|
||||
}
|
||||
|
||||
function requireOptionalInteger(
|
||||
issues: RuntimeConfigValidationIssue[],
|
||||
configPath: string,
|
||||
value: unknown,
|
||||
options: { min?: number; max?: number } = {},
|
||||
): void {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return
|
||||
}
|
||||
requireInteger(issues, configPath, value, options)
|
||||
}
|
||||
|
||||
function isValidTrustProxy(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return true
|
||||
if (typeof value === 'number') return Number.isInteger(value) && value >= 0
|
||||
return ['loopback', 'uniquelocal', 'linklocal'].includes(String(value))
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { createDefaultRuntimeConfig } from "./defaults.js";
|
||||
import { applyEnvOverrides } from "./env-overrides.js";
|
||||
import { loadEnvFiles } from "./runtime-env.js";
|
||||
import { createDefaultRuntimeConfig } from './defaults.js'
|
||||
import { applyEnvOverrides } from './env-overrides.js'
|
||||
import { loadEnvFiles } from './runtime-env.js'
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const PROJECT_ROOT = path.resolve(CURRENT_DIR, "../..");
|
||||
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, "../..");
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
export const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..')
|
||||
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
|
||||
|
||||
loadEnvFiles([
|
||||
path.join(WORKSPACE_ROOT, ".env"),
|
||||
path.join(PROJECT_ROOT, ".env"),
|
||||
]);
|
||||
loadEnvFiles([path.join(WORKSPACE_ROOT, '.env'), path.join(PROJECT_ROOT, '.env')])
|
||||
|
||||
export const runtimeConfig = applyEnvOverrides(createDefaultRuntimeConfig(PROJECT_ROOT));
|
||||
export const runtimeConfig = applyEnvOverrides(createDefaultRuntimeConfig(PROJECT_ROOT))
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import {
|
||||
manifestKeyFor,
|
||||
parseBackupArgs,
|
||||
pickLatestDumpKey,
|
||||
resolveBackupStorageConfig,
|
||||
runDownload,
|
||||
runLatest,
|
||||
runList,
|
||||
runUpload,
|
||||
} from './db-backup.js'
|
||||
import type { StorageObjectSummary } from './services/file-storage/object-storage.js'
|
||||
|
||||
test('resolveBackupStorageConfig 优先 DB_BACKUP_OSS_* 并按字段回退 STORAGE_OSS_*', () => {
|
||||
const config = resolveBackupStorageConfig({
|
||||
DB_BACKUP_OSS_ENDPOINT: 'https://backup.example.com',
|
||||
STORAGE_OSS_ENDPOINT: 'https://storage.example.com',
|
||||
STORAGE_OSS_BUCKET: 'order-site',
|
||||
STORAGE_OSS_ACCESS_KEY_ID: 'storage-key',
|
||||
DB_BACKUP_OSS_ACCESS_KEY_ID: 'backup-key',
|
||||
STORAGE_OSS_SECRET_ACCESS_KEY: 'storage-secret',
|
||||
})
|
||||
|
||||
assert.equal(config.endpoint, 'https://backup.example.com')
|
||||
assert.equal(config.bucket, 'order-site')
|
||||
assert.equal(config.accessKeyId, 'backup-key')
|
||||
assert.equal(config.secretAccessKey, 'storage-secret')
|
||||
assert.equal(config.region, 'oss-cn-hangzhou')
|
||||
})
|
||||
|
||||
test('resolveBackupStorageConfig 全部未配置时返回空值由调用方校验', () => {
|
||||
const config = resolveBackupStorageConfig({})
|
||||
assert.equal(config.endpoint, '')
|
||||
assert.equal(config.bucket, '')
|
||||
})
|
||||
|
||||
test('parseBackupArgs 解析命令与参数并去掉前缀尾部斜杠', () => {
|
||||
const args = parseBackupArgs([
|
||||
'upload',
|
||||
'--key',
|
||||
'backups/postgres/order_site-20260824-030000.dump',
|
||||
'--prefix',
|
||||
'backups/postgres/',
|
||||
'--kind',
|
||||
'app-configs',
|
||||
'--size',
|
||||
'1234',
|
||||
])
|
||||
|
||||
assert.equal(args.command, 'upload')
|
||||
assert.equal(args.key, 'backups/postgres/order_site-20260824-030000.dump')
|
||||
assert.equal(args.prefix, 'backups/postgres')
|
||||
assert.equal(args.kind, 'app-configs')
|
||||
assert.equal(args.size, 1234)
|
||||
})
|
||||
|
||||
test('parseBackupArgs 无参数时使用默认前缀与类型', () => {
|
||||
const args = parseBackupArgs(['list'])
|
||||
assert.equal(args.command, 'list')
|
||||
assert.equal(args.prefix, 'backups/postgres')
|
||||
assert.equal(args.kind, 'postgres-dump')
|
||||
assert.equal(args.key, '')
|
||||
assert.equal(args.allowUnverified, false)
|
||||
})
|
||||
|
||||
test('parseBackupArgs 支持显式兼容未校验备份', () => {
|
||||
assert.equal(parseBackupArgs(['latest', '--allow-unverified']).allowUnverified, true)
|
||||
})
|
||||
|
||||
test('manifestKeyFor 在备份 key 后追加清单后缀', () => {
|
||||
assert.equal(manifestKeyFor('backups/postgres/a.dump'), 'backups/postgres/a.dump.manifest.json')
|
||||
})
|
||||
|
||||
test('pickLatestDumpKey 只认 dump 并取最近修改的对象', () => {
|
||||
const key = pickLatestDumpKey([
|
||||
{
|
||||
key: 'backups/postgres/a.dump',
|
||||
size: 1,
|
||||
etag: '',
|
||||
lastModified: new Date('2026-08-01T00:00:00Z'),
|
||||
},
|
||||
{
|
||||
key: 'backups/postgres/a.dump.manifest.json',
|
||||
size: 1,
|
||||
etag: '',
|
||||
lastModified: new Date('2026-08-23T00:00:00Z'),
|
||||
},
|
||||
{
|
||||
key: 'backups/configs/a.tar.gz',
|
||||
size: 1,
|
||||
etag: '',
|
||||
lastModified: new Date('2026-08-23T00:00:00Z'),
|
||||
},
|
||||
{
|
||||
key: 'backups/postgres/b.dump',
|
||||
size: 1,
|
||||
etag: '',
|
||||
lastModified: new Date('2026-08-22T00:00:00Z'),
|
||||
},
|
||||
])
|
||||
|
||||
assert.equal(key, 'backups/postgres/b.dump')
|
||||
})
|
||||
|
||||
test('pickLatestDumpKey 无备份时返回 null,无时间时按 key 兜底', () => {
|
||||
assert.equal(pickLatestDumpKey([]), null)
|
||||
assert.equal(
|
||||
pickLatestDumpKey([
|
||||
{ key: 'backups/postgres/order_site-1.dump', size: 1, etag: '' },
|
||||
{ key: 'backups/postgres/order_site-2.dump', size: 1, etag: '' },
|
||||
]),
|
||||
'backups/postgres/order_site-2.dump',
|
||||
)
|
||||
})
|
||||
|
||||
test('runUpload 支持流式输入并生成带长度和 sha256 的 manifest', async () => {
|
||||
const uploads: Array<{ key: string; content: Buffer; contentLength?: number }> = []
|
||||
const storage = {
|
||||
putObject: async (input: {
|
||||
key: string
|
||||
content: Buffer | Readable
|
||||
contentLength?: number
|
||||
}) => {
|
||||
const chunks: Buffer[] = []
|
||||
if (Buffer.isBuffer(input.content)) {
|
||||
chunks.push(input.content)
|
||||
} else {
|
||||
for await (const chunk of input.content) chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
uploads.push({
|
||||
key: input.key,
|
||||
content: Buffer.concat(chunks),
|
||||
contentLength: input.contentLength,
|
||||
})
|
||||
},
|
||||
} as never
|
||||
|
||||
await runUpload(
|
||||
storage,
|
||||
parseBackupArgs([
|
||||
'upload',
|
||||
'--key',
|
||||
'backups/configs/a.tar.gz',
|
||||
'--kind',
|
||||
'app-configs',
|
||||
'--size',
|
||||
'5',
|
||||
]),
|
||||
Readable.from([Buffer.from('hello')]),
|
||||
)
|
||||
|
||||
assert.equal(uploads.length, 2)
|
||||
assert.equal(uploads[0]?.content.toString(), 'hello')
|
||||
assert.equal(uploads[0]?.contentLength, 5)
|
||||
assert.match(uploads[1]?.content.toString() || '', /"size": 5/)
|
||||
assert.match(uploads[1]?.content.toString() || '', /sha256/)
|
||||
})
|
||||
|
||||
test('runLatest 跳过没有 manifest 的新对象并选择最近的有效备份', async () => {
|
||||
const objects: StorageObjectSummary[] = [
|
||||
{
|
||||
key: 'backups/postgres/new.dump',
|
||||
size: 5,
|
||||
etag: '',
|
||||
lastModified: new Date('2026-08-23T03:00:00Z'),
|
||||
},
|
||||
{
|
||||
key: 'backups/postgres/valid.dump',
|
||||
size: 5,
|
||||
etag: '',
|
||||
lastModified: new Date('2026-08-22T03:00:00Z'),
|
||||
},
|
||||
]
|
||||
const storage = {
|
||||
listObjects: async () => objects,
|
||||
getObject: async (key: string) => {
|
||||
if (key.endsWith('new.dump.manifest.json')) {
|
||||
const error = Object.assign(new Error('missing'), { name: 'NoSuchKey' })
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
reader: Readable.from([
|
||||
JSON.stringify({
|
||||
key: 'backups/postgres/valid.dump',
|
||||
kind: 'postgres-dump',
|
||||
size: 5,
|
||||
sha256: 'a'.repeat(64),
|
||||
createdAt: '2026-08-22T03:00:00Z',
|
||||
}),
|
||||
]),
|
||||
}
|
||||
},
|
||||
} as never
|
||||
|
||||
assert.equal(
|
||||
await runLatest(storage, parseBackupArgs(['latest', '--prefix', 'backups/postgres'])),
|
||||
'backups/postgres/valid.dump',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,526 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import fsPromises from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { Transform, type Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import {
|
||||
createConfiguredObjectStorage,
|
||||
type ObjectStorage,
|
||||
type StorageObjectSummary,
|
||||
} from './services/file-storage/object-storage.js'
|
||||
|
||||
// 数据库备份传输工具:在 backend 容器内运行,负责备份文件与阿里云 OSS 之间的传输与校验。
|
||||
// 由 deploy/backup-db.sh 与 deploy/restore-db.sh 通过 docker compose 调用。
|
||||
// 日志一律走 stderr,stdout 只输出机器可读内容(下载的二进制、latest 的 key、list 的行),
|
||||
// 便于宿主机脚本重定向捕获。
|
||||
//
|
||||
// 用法:
|
||||
// node dist/db-backup.js upload --key backups/postgres/xxx.dump [--kind postgres-dump|app-configs] [--size bytes]
|
||||
// 从 stdin 读取备份内容上传,并生成 <key>.manifest.json(size/sha256/迁移记录)。
|
||||
// node dist/db-backup.js download --key backups/postgres/xxx.dump [--allow-unverified]
|
||||
// 下载到 stdout,并按清单校验 sha256 与大小。
|
||||
// node dist/db-backup.js latest [--prefix backups/postgres] [--allow-unverified]
|
||||
// 在 stdout 打印最新 .dump 备份的 key(一行)。
|
||||
// node dist/db-backup.js list [--prefix backups/postgres]
|
||||
// 按时间倒序列出 key、大小、时间。
|
||||
//
|
||||
// OSS 配置:DB_BACKUP_OSS_* 优先,未设置的字段回退 STORAGE_OSS_*。
|
||||
|
||||
const USAGE = `用法:
|
||||
node dist/db-backup.js upload --key <oss-key> [--kind postgres-dump|app-configs] [--size bytes]
|
||||
node dist/db-backup.js download --key <oss-key> [--allow-unverified]
|
||||
node dist/db-backup.js latest [--prefix backups/postgres] [--allow-unverified]
|
||||
node dist/db-backup.js list [--prefix backups/postgres]
|
||||
|
||||
环境变量:DB_BACKUP_OSS_{ENDPOINT,BUCKET,ACCESS_KEY_ID,SECRET_ACCESS_KEY,REGION} 优先,
|
||||
缺省字段复用 STORAGE_OSS_*;默认前缀 backups/postgres。`
|
||||
|
||||
const DEFAULT_BACKUP_PREFIX = 'backups/postgres'
|
||||
|
||||
export type BackupStorageConfig = {
|
||||
endpoint: string
|
||||
bucket: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
region: string
|
||||
}
|
||||
|
||||
export type BackupCliArgs = {
|
||||
command: string
|
||||
key: string
|
||||
prefix: string
|
||||
kind: string
|
||||
allowUnverified: boolean
|
||||
size?: number
|
||||
}
|
||||
|
||||
type BackupManifest = {
|
||||
key: string
|
||||
kind: string
|
||||
size: number
|
||||
sha256: string
|
||||
createdAt: string
|
||||
database: string | null
|
||||
serverVersion: string | null
|
||||
migrations: string[] | null
|
||||
}
|
||||
|
||||
function logInfo(message: string): void {
|
||||
process.stderr.write(`[db-backup] ${message}\n`)
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`[db-backup] ${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
export function resolveBackupStorageConfig(
|
||||
env: Record<string, string | undefined>,
|
||||
): BackupStorageConfig {
|
||||
return {
|
||||
endpoint: readWithFallback(env, 'DB_BACKUP_OSS_ENDPOINT', 'STORAGE_OSS_ENDPOINT'),
|
||||
bucket: readWithFallback(env, 'DB_BACKUP_OSS_BUCKET', 'STORAGE_OSS_BUCKET'),
|
||||
accessKeyId: readWithFallback(env, 'DB_BACKUP_OSS_ACCESS_KEY_ID', 'STORAGE_OSS_ACCESS_KEY_ID'),
|
||||
secretAccessKey: readWithFallback(
|
||||
env,
|
||||
'DB_BACKUP_OSS_SECRET_ACCESS_KEY',
|
||||
'STORAGE_OSS_SECRET_ACCESS_KEY',
|
||||
),
|
||||
region:
|
||||
readWithFallback(env, 'DB_BACKUP_OSS_REGION', 'STORAGE_OSS_REGION') || 'oss-cn-hangzhou',
|
||||
}
|
||||
}
|
||||
|
||||
function readWithFallback(
|
||||
env: Record<string, string | undefined>,
|
||||
primary: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
return String(env[primary] || '').trim() || String(env[fallback] || '').trim()
|
||||
}
|
||||
|
||||
export function parseBackupArgs(argv: string[]): BackupCliArgs {
|
||||
const args: BackupCliArgs = {
|
||||
command: String(argv[0] || ''),
|
||||
key: '',
|
||||
prefix: DEFAULT_BACKUP_PREFIX,
|
||||
kind: 'postgres-dump',
|
||||
allowUnverified: false,
|
||||
}
|
||||
|
||||
for (let index = 1; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (arg === '--allow-unverified') {
|
||||
args.allowUnverified = true
|
||||
continue
|
||||
}
|
||||
if (arg === '--size' && value !== undefined) {
|
||||
const size = Number(value)
|
||||
if (Number.isSafeInteger(size) && size >= 0) args.size = size
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if ((arg === '--key' || arg === '--prefix' || arg === '--kind') && value !== undefined) {
|
||||
if (arg === '--key') args.key = value
|
||||
if (arg === '--prefix') args.prefix = value.replace(/\/+$/, '')
|
||||
if (arg === '--kind') args.kind = value
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
export function manifestKeyFor(key: string): string {
|
||||
return `${key}.manifest.json`
|
||||
}
|
||||
|
||||
export function pickLatestDumpKey(objects: StorageObjectSummary[]): string | null {
|
||||
let latest: { key: string; time: number } | null = null
|
||||
for (const item of objects) {
|
||||
if (!item.key.endsWith('.dump')) {
|
||||
continue
|
||||
}
|
||||
const time = item.lastModified ? item.lastModified.getTime() : 0
|
||||
if (!latest || time > latest.time || (time === latest.time && item.key > latest.key)) {
|
||||
latest = { key: item.key, time }
|
||||
}
|
||||
}
|
||||
return latest ? latest.key : null
|
||||
}
|
||||
|
||||
function createBackupStorage(): ObjectStorage {
|
||||
const config = resolveBackupStorageConfig(process.env)
|
||||
const missing = (['endpoint', 'bucket', 'accessKeyId', 'secretAccessKey'] as const)
|
||||
.filter((field) => !config[field])
|
||||
.map((field) => `DB_BACKUP_OSS_${field.replace(/([A-Z])/g, '_$1').toUpperCase()}`)
|
||||
if (missing.length) {
|
||||
logInfo(`缺少 OSS 配置:${missing.join(', ')}(或对应的 STORAGE_OSS_*)`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
return createConfiguredObjectStorage(config)
|
||||
}
|
||||
|
||||
export async function runUpload(
|
||||
storage: ObjectStorage,
|
||||
args: BackupCliArgs,
|
||||
content: Buffer | Readable,
|
||||
): Promise<void> {
|
||||
if (!args.key) {
|
||||
fail(`upload 需要 --key。${USAGE}`)
|
||||
}
|
||||
if (args.size === 0) {
|
||||
fail('输入为空,没有可上传的备份内容。')
|
||||
}
|
||||
const hash = crypto.createHash('sha256')
|
||||
let size = 0
|
||||
let uploadContent: Buffer | Readable = content
|
||||
if (Buffer.isBuffer(content)) {
|
||||
size = content.length
|
||||
hash.update(content)
|
||||
} else {
|
||||
const hashingStream = new HashingStream(hash, (chunkSize) => {
|
||||
size += chunkSize
|
||||
})
|
||||
content.pipe(hashingStream)
|
||||
uploadContent = hashingStream
|
||||
}
|
||||
if (size === 0 && Buffer.isBuffer(content)) {
|
||||
fail('输入为空,没有可上传的备份内容。')
|
||||
}
|
||||
|
||||
const snapshot = args.kind === 'postgres-dump' ? await readDatabaseSnapshot() : null
|
||||
|
||||
await storage.putObject({
|
||||
key: args.key,
|
||||
content: uploadContent,
|
||||
contentType: 'application/octet-stream',
|
||||
...(Buffer.isBuffer(content)
|
||||
? { contentLength: content.length }
|
||||
: args.size === undefined
|
||||
? {}
|
||||
: { contentLength: args.size }),
|
||||
})
|
||||
if (size === 0) {
|
||||
fail('输入为空,没有可上传的备份内容。')
|
||||
}
|
||||
if (args.size !== undefined && args.size !== size) {
|
||||
fail(`输入大小与 --size 不一致:期望 ${args.size} 字节,实际 ${size} 字节。`)
|
||||
}
|
||||
const sha256 = hash.digest('hex')
|
||||
|
||||
const manifest: BackupManifest = {
|
||||
key: args.key,
|
||||
kind: args.kind,
|
||||
size,
|
||||
sha256,
|
||||
createdAt: new Date().toISOString(),
|
||||
database: snapshot ? snapshot.database : null,
|
||||
serverVersion: snapshot ? snapshot.serverVersion : null,
|
||||
migrations: snapshot ? snapshot.migrations : null,
|
||||
}
|
||||
await storage.putObject({
|
||||
key: manifestKeyFor(args.key),
|
||||
content: Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`),
|
||||
contentType: 'application/json',
|
||||
})
|
||||
|
||||
logInfo(
|
||||
`上传完成:${args.key}(${formatSize(size)},sha256=${sha256.slice(0, 12)}…),清单 ${manifestKeyFor(args.key)}`,
|
||||
)
|
||||
if (args.kind === 'postgres-dump' && !snapshot) {
|
||||
logInfo('未能读取数据库迁移记录(数据库暂不可达不影响备份本身,清单相应字段为空)。')
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDownload(storage: ObjectStorage, args: BackupCliArgs): Promise<Buffer> {
|
||||
if (!args.key) {
|
||||
fail(`download 需要 --key。${USAGE}`)
|
||||
}
|
||||
|
||||
const stored = await storage.getObject(args.key)
|
||||
const hash = crypto.createHash('sha256')
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stored.reader) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
hash.update(buffer)
|
||||
chunks.push(buffer)
|
||||
}
|
||||
const content = Buffer.concat(chunks)
|
||||
const sha256 = hash.digest('hex')
|
||||
|
||||
const manifest = await readManifest(storage, args.key)
|
||||
if (!manifest) {
|
||||
if (!args.allowUnverified) {
|
||||
fail(
|
||||
`缺少 ${manifestKeyFor(args.key)},拒绝使用未校验备份。需要兼容旧备份时显式传 --allow-unverified。`,
|
||||
)
|
||||
}
|
||||
logInfo(`未找到 ${manifestKeyFor(args.key)},已显式允许跳过 sha256 校验。`)
|
||||
} else {
|
||||
validateManifest(manifest, args.key, content.length, sha256, args.kind)
|
||||
logInfo(`sha256 校验通过:${args.key}(${formatSize(content.length)})`)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
export async function runLatest(storage: ObjectStorage, args: BackupCliArgs): Promise<string> {
|
||||
const objects = (await storage.listObjects(`${args.prefix}/`))
|
||||
.filter((item) => item.key.endsWith('.dump'))
|
||||
.sort((left, right) => {
|
||||
const leftTime = left.lastModified ? left.lastModified.getTime() : 0
|
||||
const rightTime = right.lastModified ? right.lastModified.getTime() : 0
|
||||
return rightTime - leftTime || right.key.localeCompare(left.key)
|
||||
})
|
||||
|
||||
for (const item of objects) {
|
||||
try {
|
||||
const manifest = await readManifest(storage, item.key)
|
||||
if (manifest) {
|
||||
validateManifestMetadata(manifest, item.key, 'postgres-dump')
|
||||
const migrations = manifest.migrations ? `,迁移 ${manifest.migrations.length} 个` : ''
|
||||
logInfo(
|
||||
`最新备份:${item.key}(${formatSize(manifest.size)},${manifest.createdAt}${migrations})`,
|
||||
)
|
||||
return item.key
|
||||
}
|
||||
} catch (error) {
|
||||
logInfo(`跳过无效备份 ${item.key}:${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
if (args.allowUnverified) {
|
||||
logInfo(`最新备份:${item.key}(已显式允许无清单备份)。`)
|
||||
return item.key
|
||||
}
|
||||
}
|
||||
|
||||
if (!objects.length) {
|
||||
fail(`前缀 ${args.prefix}/ 下没有 .dump 备份。`)
|
||||
}
|
||||
fail(`前缀 ${args.prefix}/ 下没有带有效 manifest 的 .dump 备份。`)
|
||||
}
|
||||
|
||||
export async function runList(storage: ObjectStorage, args: BackupCliArgs): Promise<string[]> {
|
||||
const objects = (await storage.listObjects(`${args.prefix}/`))
|
||||
.filter((item) => !item.key.endsWith('.manifest.json'))
|
||||
.sort((left, right) => {
|
||||
const leftTime = left.lastModified ? left.lastModified.getTime() : 0
|
||||
const rightTime = right.lastModified ? right.lastModified.getTime() : 0
|
||||
return rightTime - leftTime
|
||||
})
|
||||
|
||||
const lines = objects.map((item) => {
|
||||
const time = item.lastModified ? item.lastModified.toISOString() : '-'
|
||||
return `${item.key}\t${item.size}\t${time}`
|
||||
})
|
||||
logInfo(`前缀 ${args.prefix}/ 下共 ${objects.length} 个备份。`)
|
||||
return lines
|
||||
}
|
||||
|
||||
async function readManifest(storage: ObjectStorage, key: string): Promise<BackupManifest | null> {
|
||||
try {
|
||||
const stored = await storage.getObject(manifestKeyFor(key))
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of stored.reader) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as BackupManifest
|
||||
} catch (error) {
|
||||
if (isMissingObjectError(error)) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function validateManifestMetadata(
|
||||
manifest: BackupManifest,
|
||||
key: string,
|
||||
expectedKind: string,
|
||||
): void {
|
||||
if (manifest.key !== key || manifest.kind !== expectedKind) {
|
||||
throw new Error(`manifest 与备份 key/type 不匹配`)
|
||||
}
|
||||
if (!Number.isSafeInteger(manifest.size) || manifest.size <= 0) {
|
||||
throw new Error(`manifest size 无效`)
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/i.test(manifest.sha256)) {
|
||||
throw new Error(`manifest sha256 无效`)
|
||||
}
|
||||
if (!manifest.createdAt || Number.isNaN(Date.parse(manifest.createdAt))) {
|
||||
throw new Error(`manifest createdAt 无效`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateManifest(
|
||||
manifest: BackupManifest,
|
||||
key: string,
|
||||
size: number,
|
||||
sha256: string,
|
||||
expectedKind: string,
|
||||
): void {
|
||||
validateManifestMetadata(manifest, key, expectedKind)
|
||||
if (manifest.sha256.toLowerCase() !== sha256.toLowerCase()) {
|
||||
fail(`sha256 校验失败:${key} 内容与清单不一致,请勿使用该备份。`)
|
||||
}
|
||||
if (manifest.size !== size) {
|
||||
fail(`大小校验失败:${key} 期望 ${manifest.size} 字节,实际 ${size} 字节。`)
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingObjectError(error: unknown): boolean {
|
||||
const candidate = error as {
|
||||
name?: string
|
||||
Code?: string
|
||||
$metadata?: { httpStatusCode?: number }
|
||||
}
|
||||
return (
|
||||
candidate?.$metadata?.httpStatusCode === 404 ||
|
||||
['NotFound', 'NoSuchKey', 'NoSuchBucket'].includes(
|
||||
String(candidate?.name || candidate?.Code || ''),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class HashingStream extends Transform {
|
||||
constructor(
|
||||
private readonly hash: crypto.Hash,
|
||||
private readonly onChunk: (size: number) => void,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
_transform(
|
||||
chunk: Buffer,
|
||||
_encoding: BufferEncoding,
|
||||
callback: (error?: Error | null, data?: Buffer) => void,
|
||||
): void {
|
||||
this.hash.update(chunk)
|
||||
this.onChunk(chunk.length)
|
||||
callback(null, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadToStdout(storage: ObjectStorage, args: BackupCliArgs): Promise<void> {
|
||||
if (!args.key) {
|
||||
fail(`download 需要 --key。${USAGE}`)
|
||||
}
|
||||
|
||||
const stored = await storage.getObject(args.key)
|
||||
const hash = crypto.createHash('sha256')
|
||||
const tempPath = path.join('/tmp', `order-site-backup-${process.pid}-${Date.now()}.dump`)
|
||||
let size = 0
|
||||
try {
|
||||
const hashingStream = new HashingStream(hash, (chunkSize) => {
|
||||
size += chunkSize
|
||||
})
|
||||
await pipeline(stored.reader, hashingStream, fs.createWriteStream(tempPath, { mode: 0o600 }))
|
||||
|
||||
const sha256 = hash.digest('hex')
|
||||
const manifest = await readManifest(storage, args.key)
|
||||
if (!manifest) {
|
||||
if (!args.allowUnverified) {
|
||||
fail(
|
||||
`缺少 ${manifestKeyFor(args.key)},拒绝使用未校验备份。需要兼容旧备份时显式传 --allow-unverified。`,
|
||||
)
|
||||
}
|
||||
logInfo(`未找到 ${manifestKeyFor(args.key)},已显式允许跳过 sha256 校验。`)
|
||||
} else {
|
||||
validateManifest(manifest, args.key, size, sha256, args.kind)
|
||||
logInfo(`sha256 校验通过:${args.key}(${formatSize(size)})`)
|
||||
}
|
||||
await pipeline(fs.createReadStream(tempPath), process.stdout)
|
||||
} finally {
|
||||
await fsPromises.rm(tempPath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// 尽力而为地补充数据库快照信息;数据库不可达时返回 null,不影响备份传输。
|
||||
async function readDatabaseSnapshot(): Promise<{
|
||||
database: string | null
|
||||
serverVersion: string | null
|
||||
migrations: string[] | null
|
||||
} | null> {
|
||||
try {
|
||||
const { runtimeConfig } = await import('./config/runtime.js')
|
||||
const { query, closeDb } = await import('./db/client.js')
|
||||
try {
|
||||
let database: string | null = null
|
||||
try {
|
||||
const url = new URL(String(runtimeConfig.database?.url || ''))
|
||||
database = decodeURIComponent(url.pathname.replace(/^\//, '')) || null
|
||||
} catch {
|
||||
database = null
|
||||
}
|
||||
const [versionResult, migrationsResult] = await Promise.all([
|
||||
query<{ server_version: string }>('SHOW server_version'),
|
||||
query<{ filename: string }>('SELECT filename FROM schema_migrations ORDER BY filename'),
|
||||
])
|
||||
return {
|
||||
database,
|
||||
serverVersion: versionResult.rows[0]?.server_version || null,
|
||||
migrations: migrationsResult.rows.map((row) => row.filename),
|
||||
}
|
||||
} finally {
|
||||
await closeDb()
|
||||
}
|
||||
} catch (error) {
|
||||
logInfo(`读取数据库快照失败:${error instanceof Error ? error.message : String(error)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(size: number): string {
|
||||
if (size >= 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(2)} MB`
|
||||
if (size >= 1024) return `${(size / 1024).toFixed(2)} KB`
|
||||
return `${size} B`
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseBackupArgs(process.argv.slice(2))
|
||||
if (!['upload', 'download', 'latest', 'list'].includes(args.command)) {
|
||||
logInfo(USAGE)
|
||||
process.exitCode = 2
|
||||
return
|
||||
}
|
||||
|
||||
const storage = createBackupStorage()
|
||||
|
||||
switch (args.command) {
|
||||
case 'upload':
|
||||
await runUpload(storage, args, process.stdin)
|
||||
break
|
||||
case 'download':
|
||||
await downloadToStdout(storage, args)
|
||||
break
|
||||
case 'latest':
|
||||
process.stdout.write(`${await runLatest(storage, args)}\n`)
|
||||
break
|
||||
case 'list':
|
||||
for (const line of await runList(storage, args)) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function isInvokedAsScript(entryPath: string | undefined): boolean {
|
||||
if (!entryPath) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return import.meta.url === pathToFileURL(path.resolve(entryPath)).href
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (isInvokedAsScript(process.argv[1])) {
|
||||
await main()
|
||||
}
|
||||
@@ -2,16 +2,26 @@ import { Pool } from 'pg'
|
||||
import type { PoolClient, QueryResult, QueryResultRow } from 'pg'
|
||||
|
||||
import { runtimeConfig } from '../config/runtime.js'
|
||||
import { logWarn } from '../utils/logger.js'
|
||||
|
||||
let poolInstance: Pool | null = null
|
||||
|
||||
export function getDb(): Pool {
|
||||
if (!poolInstance) {
|
||||
poolInstance = new Pool({
|
||||
const pool = new Pool({
|
||||
connectionString: String(runtimeConfig.database?.url || '').trim(),
|
||||
ssl: runtimeConfig.database?.ssl ? { rejectUnauthorized: false } : false,
|
||||
max: Number(runtimeConfig.database?.maxConnections || 10),
|
||||
idleTimeoutMillis: Number(runtimeConfig.database?.idleTimeoutMs || 30_000),
|
||||
connectionTimeoutMillis: Number(runtimeConfig.database?.connectionTimeoutMs || 5_000),
|
||||
statement_timeout: Number(runtimeConfig.database?.statementTimeoutMs || 15_000),
|
||||
})
|
||||
pool.on('error', (error) => {
|
||||
void logWarn('[db/pool]', '数据库连接池出现空闲连接错误', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
poolInstance = pool
|
||||
}
|
||||
|
||||
return poolInstance
|
||||
@@ -27,7 +37,40 @@ export async function query<T extends QueryResultRow>(
|
||||
params: unknown[] = [],
|
||||
): Promise<QueryResult<T>> {
|
||||
const pool = getDb()
|
||||
return pool.query<T>(text, params)
|
||||
const startedAt = process.hrtime.bigint()
|
||||
try {
|
||||
const result = await pool.query<T>(text, params)
|
||||
logSlowQuery(text, startedAt, result.rowCount ?? 0)
|
||||
return result
|
||||
} catch (error) {
|
||||
logSlowQuery(text, startedAt, 0, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function logSlowQuery(text: string, startedAt: bigint, rowCount: number, error?: unknown) {
|
||||
const thresholdMs = Number(runtimeConfig.database?.slowQueryThresholdMs ?? 500)
|
||||
if (thresholdMs <= 0) return
|
||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000
|
||||
if (durationMs < thresholdMs && !error) return
|
||||
void logWarn('[db/query]', error ? '数据库 SQL 执行失败' : '数据库慢查询', {
|
||||
durationMs: Math.round(durationMs * 100) / 100,
|
||||
thresholdMs,
|
||||
rowCount,
|
||||
sql: fingerprintSql(text),
|
||||
error: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/** 只保留可聚合的 SQL 形状,参数值始终不写入日志。 */
|
||||
function fingerprintSql(text: string) {
|
||||
return String(text || '')
|
||||
.replace(/--[^\r\n]*/g, ' ')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
.replace(/\$\d+/g, '?')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 500)
|
||||
}
|
||||
|
||||
export async function withTransaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
|
||||
@@ -76,9 +76,7 @@ function assertMigrationFilesOrdered() {
|
||||
|
||||
const invalid = files.filter((name) => !MIGRATION_FILE_PATTERN.test(name))
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`迁移文件命名必须为 NNN_name.sql(三位序号),非法文件: ${invalid.join(', ')}`,
|
||||
)
|
||||
throw new Error(`迁移文件命名必须为 NNN_name.sql(三位序号),非法文件: ${invalid.join(', ')}`)
|
||||
}
|
||||
|
||||
const versions = files.map((name) => Number(name.slice(0, 3)))
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS webhook_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL DEFAULT '',
|
||||
task_id BIGINT,
|
||||
payload JSONB NOT NULL,
|
||||
processed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_events_provider_event_id
|
||||
ON webhook_events(provider, event_id);
|
||||
|
||||
COMMENT ON TABLE webhook_events IS '第三方回调事件去重表(provider + event_id 唯一,幂等处理)';
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 修复历史库中迁移记录已存在、但 008/009 的接单字段未实际落库的情况。
|
||||
-- 所有变更均幂等,正常完成过旧迁移的数据库不会受影响。
|
||||
|
||||
ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS sharing_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS sharing_total_quantity INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS sharing_unit_reward INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS deadline_at TIMESTAMPTZ;
|
||||
ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS timeout_minutes INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS timeout_policy TEXT NOT NULL DEFAULT 'reopen';
|
||||
|
||||
ALTER TABLE work_product_rules ADD COLUMN IF NOT EXISTS sharing_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE work_product_rules ADD COLUMN IF NOT EXISTS sharing_total_quantity INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE work_product_rules ADD COLUMN IF NOT EXISTS sharing_unit_reward INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE work_product_rules ADD COLUMN IF NOT EXISTS timeout_minutes INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE work_product_rules ADD COLUMN IF NOT EXISTS timeout_policy TEXT NOT NULL DEFAULT 'reopen';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_deadline
|
||||
ON work_orders(status, deadline_at)
|
||||
WHERE deadline_at IS NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 领取链接支持长期有效:expired_at 为 NULL 表示仅能由后台手动关闭。
|
||||
-- 不回填已有令牌,历史限时链接仍按其原过期时间执行。
|
||||
ALTER TABLE claim_tokens
|
||||
ALTER COLUMN expired_at DROP NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 打手昵称唯一:昵称作为登录凭证之一,需全局唯一。
|
||||
-- 存量空昵称账号用账号名兜底(冲突时保持空昵称,不参与唯一约束)。
|
||||
|
||||
UPDATE worker_users
|
||||
SET display_name = username
|
||||
WHERE display_name = ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM worker_users other
|
||||
WHERE other.display_name = worker_users.username
|
||||
AND other.id <> worker_users.id
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_users_display_name_unique
|
||||
ON worker_users (display_name)
|
||||
WHERE display_name <> '';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 打手类型:internal 内部打手(提交验收后免审核,直接进入待打款)/ external 外部打手(默认,需后台验收)。
|
||||
ALTER TABLE worker_users
|
||||
ADD COLUMN worker_type TEXT NOT NULL DEFAULT 'external';
|
||||
|
||||
COMMENT ON COLUMN worker_users.worker_type IS '打手类型:internal 内部免审核 / external 外部需审核';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 物品规则支持按数量计价:unit_price_fen > 0 时,工单接单总价 = 单价 × SKU 名称文字中的规格数量(如 指挥官秘钥15个 → 15);0 表示按固定接单金额。
|
||||
ALTER TABLE work_product_rules
|
||||
ADD COLUMN unit_price_fen INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN work_product_rules.unit_price_fen IS '按数量计价单价(分),0 表示未启用,按固定接单金额;数量取自商品名称文字(如 15个/三个)';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 019_acceptance_draft.sql —— 验收图片暂存(草稿)。
|
||||
--
|
||||
-- 说明:
|
||||
-- 1. 上传图片与提交验收分离:打手可先把验收图片/说明保存为暂存草稿,
|
||||
-- 不改变工单状态,刷新页面不丢失;提交验收时草稿作为初始内容;
|
||||
-- 2. 已提交验收(pending_acceptance)的工单仍可补充/修改图片,
|
||||
-- 直接更新 acceptance_json(保留原 submitted_at);
|
||||
-- 3. 整单草稿存 work_orders.draft_acceptance_json,
|
||||
-- 拼单草稿存各自 work_order_shares.draft_acceptance_json。
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS draft_acceptance_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
ALTER TABLE work_order_shares
|
||||
ADD COLUMN IF NOT EXISTS draft_acceptance_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN work_orders.draft_acceptance_json IS '打手暂存的验收草稿({note, files, imageUrls, updatedAt}),未提交时保存于此,提交后清空';
|
||||
COMMENT ON COLUMN work_order_shares.draft_acceptance_json IS '拼单打手各自暂存的验收草稿({note, files, imageUrls, updatedAt}),提交后清空';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 020_worker_order_notes.sql —— 打手工单备注。
|
||||
--
|
||||
-- 整单与拼单份额分别保存,确保拼单打手只能查看和修改自己的备注。
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS worker_note TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE work_order_shares
|
||||
ADD COLUMN IF NOT EXISTS worker_note TEXT NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN work_orders.worker_note IS '整单打手维护的个人备注';
|
||||
COMMENT ON COLUMN work_order_shares.worker_note IS '拼单打手维护的个人备注';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 021_worker_order_notes_by_worker.sql —— 按打手保存工单备注。
|
||||
--
|
||||
-- 工单可重新分配,拼单也有多个打手;备注必须按工单和打手独立存储。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_work_order_notes (
|
||||
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (work_order_id, worker_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE worker_work_order_notes IS '打手维护的个人工单备注';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 022_work_order_pinned_at.sql —— 工单置顶。
|
||||
--
|
||||
-- 已发布到抢单大厅(待抢单)的工单可由管理员置顶,置顶后按置顶时间在抢单大厅优先展示。
|
||||
|
||||
ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ NULL;
|
||||
|
||||
COMMENT ON COLUMN work_orders.pinned_at IS '置顶时间;非空表示该工单在抢单大厅置顶展示';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 打手提现账户:每位打手仅允许首次登记,后续变更由客服处理。
|
||||
CREATE TABLE IF NOT EXISTS worker_withdrawal_accounts (
|
||||
worker_id BIGINT PRIMARY KEY REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||
account_channel TEXT NOT NULL CHECK (account_channel IN ('alipay', 'wechat')),
|
||||
account_name TEXT NOT NULL,
|
||||
account_no TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE worker_withdrawal_accounts IS '打手不可自行修改的提现账户';
|
||||
COMMENT ON COLUMN worker_withdrawal_accounts.account_channel IS '提现渠道:alipay / wechat';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 提现账户改为每个渠道各登记一次,保留既有账户数据。
|
||||
ALTER TABLE worker_withdrawal_accounts
|
||||
DROP CONSTRAINT IF EXISTS worker_withdrawal_accounts_pkey;
|
||||
|
||||
ALTER TABLE worker_withdrawal_accounts
|
||||
ADD PRIMARY KEY (worker_id, account_channel);
|
||||
|
||||
COMMENT ON TABLE worker_withdrawal_accounts IS '打手每个渠道仅可自行登记一次的提现账户';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 微信提现不使用账号,改为保存不可修改的收款二维码。
|
||||
ALTER TABLE worker_withdrawal_accounts
|
||||
ADD COLUMN IF NOT EXISTS wechat_qr_code_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN worker_withdrawal_accounts.wechat_qr_code_json IS '微信收款二维码文件信息';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- VIP5 免审核验收:允许无验收图自动结算,并在验收后 24 小时内补图。
|
||||
-- 权限保存在等级 JSON 中,避免为单一等级能力新增固定表字段。
|
||||
|
||||
UPDATE worker_levels
|
||||
SET
|
||||
permission_json = jsonb_set(
|
||||
COALESCE(permission_json, '{}'::jsonb),
|
||||
'{autoAcceptWithoutEvidence}',
|
||||
CASE WHEN level_key = 'vip5' THEN 'true'::jsonb ELSE 'false'::jsonb END,
|
||||
true
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE NOT (COALESCE(permission_json, '{}'::jsonb) ? 'autoAcceptWithoutEvidence');
|
||||
|
||||
COMMENT ON COLUMN worker_levels.permission_json IS
|
||||
'等级权限:免押额度、最大同时接单量、VIP 免审核及其他等级能力配置';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 后台待办通知:业务状态驱动,多个后台账号共享处理状态。
|
||||
CREATE TABLE IF NOT EXISTS admin_notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
notification_type TEXT NOT NULL,
|
||||
priority TEXT NOT NULL DEFAULT 'normal',
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
dedupe_key TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
action_path TEXT NOT NULL DEFAULT '',
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
reminder_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_reminded_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution_reason TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_notifications_pending
|
||||
ON admin_notifications (status, priority, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_notifications_entity
|
||||
ON admin_notifications (entity_type, entity_id);
|
||||
|
||||
COMMENT ON TABLE admin_notifications IS '后台共享业务待办通知,不使用个人已读状态';
|
||||
COMMENT ON COLUMN admin_notifications.dedupe_key IS '同一业务待办的唯一标识';
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 隐藏待办用于保留业务催办状态,不展示在后台铃铛中。
|
||||
ALTER TABLE admin_notifications
|
||||
ADD COLUMN IF NOT EXISTS visible BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
-- 每条待办保存创建或更新当时的声音开关,配置调整不影响历史待办。
|
||||
ALTER TABLE admin_notifications
|
||||
ADD COLUMN IF NOT EXISTS sound_enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_notifications_visible_pending
|
||||
ON admin_notifications (visible, status, priority, updated_at DESC);
|
||||
|
||||
COMMENT ON COLUMN admin_notifications.visible IS '是否展示在后台待办铃铛中';
|
||||
COMMENT ON COLUMN admin_notifications.sound_enabled IS '该次待办更新是否允许浏览器声音播报';
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 029_worker_sessions.sql —— 打手端登录设备会话。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
device_type TEXT NOT NULL DEFAULT 'pc',
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_sessions_worker_status
|
||||
ON worker_sessions(worker_id, status, expires_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_sessions_worker_device
|
||||
ON worker_sessions(worker_id, device_type, device_id, status);
|
||||
|
||||
COMMENT ON TABLE worker_sessions IS '打手端登录会话与设备记录';
|
||||
COMMENT ON COLUMN worker_sessions.device_type IS '设备类型:pc、mobile 等;所有设备合计每个账号最多 3 台';
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 030_realtime_events.sql —— SSE 事件持久化与断线补发。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS realtime_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_type TEXT NOT NULL,
|
||||
entity_id BIGINT NOT NULL DEFAULT 0,
|
||||
scopes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
operation TEXT NOT NULL DEFAULT 'refresh',
|
||||
data JSONB,
|
||||
admin_audience BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
worker_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
broadcast_workers BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
occurred_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_realtime_events_occurred_at
|
||||
ON realtime_events(occurred_at DESC);
|
||||
|
||||
COMMENT ON TABLE realtime_events IS 'SSE 实时事件日志,保留最近事件以支持断线后的增量补发';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 031_work_order_search.sql —— 扩展接单工单搜索字段并建立模糊检索索引。
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_search_text_trgm
|
||||
ON work_orders USING gin (
|
||||
LOWER(
|
||||
COALESCE(work_order_no, '') || ' ' ||
|
||||
COALESCE(platform_order_id, '') || ' ' ||
|
||||
COALESCE(product_name, '') || ' ' ||
|
||||
COALESCE(material_json->'collect'->'fields'->>'gameId', '') || ' ' ||
|
||||
COALESCE(material_json->'collect'->'fields'->>'gameNickname', '') || ' ' ||
|
||||
COALESCE(material_json->'collect'->'fields'->>'gameAccount', '') || ' ' ||
|
||||
COALESCE(material_json->'collect'->'fields'->>'roleName', '') || ' ' ||
|
||||
COALESCE(material_json->'fields'->>'gameId', '') || ' ' ||
|
||||
COALESCE(material_json->'fields'->>'gameNickname', '') || ' ' ||
|
||||
COALESCE(material_json->>'gameId', '') || ' ' ||
|
||||
COALESCE(material_json->>'gameNickname', '')
|
||||
) gin_trgm_ops
|
||||
);
|
||||
|
||||
COMMENT ON INDEX idx_work_orders_search_text_trgm IS '接单工单模糊搜索:订单号、商品名、游戏 ID/昵称及历史字段';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 032_worker_work_order_views.sql —— 打手个人工单首次查看状态。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_work_order_views (
|
||||
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||
viewed_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (work_order_id, worker_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_work_order_views_worker_order
|
||||
ON worker_work_order_views(worker_id, work_order_id);
|
||||
|
||||
COMMENT ON TABLE worker_work_order_views IS '打手个人工单首次查看记录,用于展示未读“新”标识';
|
||||
COMMENT ON COLUMN worker_work_order_views.viewed_at IS '首次点击订单详情、复制资料、编辑备注或验收时写入';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 033_worker_product_match_context.sql —— 接单规则支持快手大标题与 SKU 组合匹配。
|
||||
|
||||
ALTER TABLE work_product_rules
|
||||
ADD COLUMN IF NOT EXISTS match_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN work_product_rules.match_json IS '接单规则匹配条件:快手 sellerId/itemId/skuId/itemTitle/skuNick 等字段';
|
||||
@@ -0,0 +1,58 @@
|
||||
-- 034_worker_product_match_catalog.sql —— 接单模板与快手商品/SKU 映射、匹配日志。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work_product_rule_mappings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rule_id BIGINT NOT NULL REFERENCES work_product_rules(id) ON DELETE CASCADE,
|
||||
seller_ids_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
rel_item_id TEXT NOT NULL DEFAULT '',
|
||||
item_title TEXT NOT NULL DEFAULT '',
|
||||
rel_sku_id TEXT NOT NULL DEFAULT '',
|
||||
sku_nick TEXT NOT NULL DEFAULT '',
|
||||
mapping_type TEXT NOT NULL DEFAULT 'product_default'
|
||||
CHECK (mapping_type IN ('product_default', 'sku_exact', 'sku_series')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
CHECK (jsonb_typeof(seller_ids_json) = 'array' AND jsonb_array_length(seller_ids_json) > 0),
|
||||
CHECK (mapping_type IN ('sku_exact', 'sku_series') OR rel_item_id <> '' OR item_title <> ''),
|
||||
CHECK (
|
||||
(mapping_type = 'product_default' AND rel_sku_id = '' AND sku_nick = '')
|
||||
OR
|
||||
(mapping_type = 'sku_exact' AND (rel_sku_id <> '' OR sku_nick <> ''))
|
||||
OR
|
||||
(mapping_type = 'sku_series' AND rel_sku_id = '' AND sku_nick <> '')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS work_product_rule_mappings_lookup_idx
|
||||
ON work_product_rule_mappings (rel_item_id, mapping_type, enabled);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS work_product_rule_mappings_seller_ids_idx
|
||||
ON work_product_rule_mappings USING GIN (seller_ids_json);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work_product_match_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
order_item_id BIGINT REFERENCES order_items(id) ON DELETE SET NULL,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
seller_id TEXT NOT NULL DEFAULT '',
|
||||
rel_item_id TEXT NOT NULL DEFAULT '',
|
||||
item_title TEXT NOT NULL DEFAULT '',
|
||||
rel_sku_id TEXT NOT NULL DEFAULT '',
|
||||
sku_nick TEXT NOT NULL DEFAULT '',
|
||||
match_status TEXT NOT NULL CHECK (match_status IN ('matched', 'unmatched', 'ambiguous')),
|
||||
rule_id BIGINT REFERENCES work_product_rules(id) ON DELETE SET NULL,
|
||||
mapping_id BIGINT REFERENCES work_product_rule_mappings(id) ON DELETE SET NULL,
|
||||
candidates_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS work_product_match_logs_created_idx
|
||||
ON work_product_match_logs (created_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS work_product_match_logs_status_idx
|
||||
ON work_product_match_logs (match_status, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE work_product_rule_mappings IS '快手多店商品、SKU 精确与数量系列到接单模板的确定性映射';
|
||||
COMMENT ON TABLE work_product_match_logs IS '快手接单商品匹配日志与调试载荷快照';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 035_worker_product_match_sku_mode.sql —— SKU 系列匹配方式。
|
||||
|
||||
ALTER TABLE work_product_rule_mappings
|
||||
ADD COLUMN IF NOT EXISTS sku_match_mode TEXT NOT NULL DEFAULT 'fuzzy'
|
||||
CHECK (sku_match_mode IN ('fuzzy', 'exact'));
|
||||
|
||||
COMMENT ON COLUMN work_product_rule_mappings.sku_match_mode
|
||||
IS 'SKU 数量系列匹配方式:fuzzy 兼容密钥/秘钥与附加文案,exact 仅完整系列名匹配';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 036_worker_product_rule_sharing_auto.sql —— 按件计价拼单可随匹配订单自动计算。
|
||||
|
||||
ALTER TABLE work_product_rules
|
||||
ADD COLUMN IF NOT EXISTS sharing_auto_from_order BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN work_product_rules.sharing_auto_from_order
|
||||
IS '按件计价且启用拼单时,是否用匹配 SKU 数量与模板单价自动生成拼单配置;false 表示使用手工配置';
|
||||
@@ -0,0 +1,79 @@
|
||||
-- 037_repair_worker_product_mapping_schema.sql —— 修复旧版商品映射表到多店映射结构。
|
||||
|
||||
ALTER TABLE work_product_rule_mappings
|
||||
ADD COLUMN IF NOT EXISTS seller_ids_json JSONB;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'work_product_rule_mappings'
|
||||
AND column_name = 'seller_id'
|
||||
) THEN
|
||||
UPDATE work_product_rule_mappings
|
||||
SET seller_ids_json = jsonb_build_array(seller_id)
|
||||
WHERE seller_ids_json IS NULL
|
||||
AND seller_id IS NOT NULL
|
||||
AND seller_id <> '';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE work_product_rule_mappings
|
||||
ALTER COLUMN seller_ids_json SET DEFAULT '[]'::jsonb,
|
||||
ALTER COLUMN seller_ids_json SET NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'work_product_rule_mappings'
|
||||
AND column_name = 'seller_id'
|
||||
) THEN
|
||||
ALTER TABLE work_product_rule_mappings
|
||||
ALTER COLUMN seller_id SET DEFAULT '';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE work_product_rule_mappings
|
||||
SET mapping_type = 'sku_exact'
|
||||
WHERE mapping_type = 'sku_override';
|
||||
|
||||
DROP INDEX IF EXISTS work_product_rule_mappings_product_identity_unique;
|
||||
DROP INDEX IF EXISTS work_product_rule_mappings_sku_identity_unique;
|
||||
|
||||
ALTER TABLE work_product_rule_mappings
|
||||
DROP CONSTRAINT IF EXISTS work_product_rule_mappings_check,
|
||||
DROP CONSTRAINT IF EXISTS work_product_rule_mappings_check1,
|
||||
DROP CONSTRAINT IF EXISTS work_product_rule_mappings_mapping_type_check,
|
||||
DROP CONSTRAINT IF EXISTS work_product_rule_mappings_seller_id_check,
|
||||
DROP CONSTRAINT IF EXISTS work_product_rule_mappings_seller_ids_json_check,
|
||||
DROP CONSTRAINT IF EXISTS work_product_rule_mappings_product_scope_check,
|
||||
DROP CONSTRAINT IF EXISTS work_product_rule_mappings_sku_scope_check,
|
||||
ADD CONSTRAINT work_product_rule_mappings_seller_ids_json_check
|
||||
CHECK (jsonb_typeof(seller_ids_json) = 'array' AND jsonb_array_length(seller_ids_json) > 0),
|
||||
ADD CONSTRAINT work_product_rule_mappings_mapping_type_check
|
||||
CHECK (mapping_type IN ('product_default', 'sku_exact', 'sku_series')),
|
||||
ADD CONSTRAINT work_product_rule_mappings_product_scope_check
|
||||
CHECK (mapping_type IN ('sku_exact', 'sku_series') OR rel_item_id <> '' OR item_title <> ''),
|
||||
ADD CONSTRAINT work_product_rule_mappings_sku_scope_check
|
||||
CHECK (
|
||||
(mapping_type = 'product_default' AND rel_sku_id = '' AND sku_nick = '')
|
||||
OR
|
||||
(mapping_type = 'sku_exact' AND (rel_sku_id <> '' OR sku_nick <> ''))
|
||||
OR
|
||||
(mapping_type = 'sku_series' AND rel_sku_id = '' AND sku_nick <> '')
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS work_product_rule_mappings_seller_ids_idx
|
||||
ON work_product_rule_mappings USING GIN (seller_ids_json);
|
||||
|
||||
DROP INDEX IF EXISTS work_product_rule_mappings_lookup_idx;
|
||||
|
||||
CREATE INDEX work_product_rule_mappings_lookup_idx
|
||||
ON work_product_rule_mappings (rel_item_id, mapping_type, enabled);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 修复历史拼单:份数已拼满且所有参与者均已提交时,母工单应进入待验收。
|
||||
UPDATE work_orders wo
|
||||
SET
|
||||
status = 'pending_acceptance',
|
||||
submitted_at = COALESCE(
|
||||
wo.submitted_at,
|
||||
(
|
||||
SELECT MAX(wos.submitted_at)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = wo.id
|
||||
AND wos.status != 'cancelled'
|
||||
),
|
||||
NOW()
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE wo.status = 'open'
|
||||
AND wo.sharing_enabled = true
|
||||
AND COALESCE((
|
||||
SELECT SUM(wos.quantity)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = wo.id
|
||||
AND wos.status != 'cancelled'
|
||||
), 0) >= wo.sharing_total_quantity
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = wo.id
|
||||
AND wos.status = 'joined'
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE worker_sms_codes
|
||||
ADD COLUMN IF NOT EXISTS failed_attempt_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN worker_sms_codes.failed_attempt_count IS '当前验证码已失败的校验次数,达到上限后验证码锁定';
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
issued_at TIMESTAMPTZ NOT NULL,
|
||||
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_sessions_user_status
|
||||
ON admin_sessions(user_id, status, expires_at DESC);
|
||||
|
||||
COMMENT ON TABLE admin_sessions IS '后台登录会话,支持单设备退出和服务端撤销';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 每日排行榜按验收时间筛选,避免全表扫描已完成工单与拼单记录。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_accepted_at_worker
|
||||
ON work_orders(accepted_at DESC, assigned_worker_id)
|
||||
WHERE status = 'accepted' AND assigned_worker_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_shares_accepted_at_worker
|
||||
ON work_order_shares(accepted_at DESC, worker_id)
|
||||
WHERE status = 'accepted';
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 短信验证码注册已完成手机号校验,历史遗留的待审核账号统一自动启用。
|
||||
UPDATE worker_users
|
||||
SET status = 'active',
|
||||
review_note = CASE
|
||||
WHEN BTRIM(review_note) = '' THEN '短信验证码注册自动启用'
|
||||
ELSE review_note
|
||||
END,
|
||||
reviewed_at = COALESCE(reviewed_at, updated_at, NOW()),
|
||||
updated_at = NOW()
|
||||
WHERE status = 'pending_review';
|
||||
|
||||
COMMENT ON TABLE worker_users IS '打手账号,手机号验证码注册后自动启用;后台可冻结或调整状态';
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 打手撤单申请:提交后由客服审核,审核通过才执行撤单与处罚。
|
||||
|
||||
ALTER TABLE worker_users
|
||||
ADD COLUMN IF NOT EXISTS level_penalty_accepted_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_order_cancel_requests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||
work_order_share_id BIGINT REFERENCES work_order_shares(id) ON DELETE SET NULL,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
review_note TEXT NOT NULL DEFAULT '',
|
||||
reviewed_by TEXT NOT NULL DEFAULT '',
|
||||
level_before_id BIGINT REFERENCES worker_levels(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_cancel_requests_pending_target
|
||||
ON worker_order_cancel_requests (work_order_id, worker_id, COALESCE(work_order_share_id, 0))
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_cancel_requests_worker_created
|
||||
ON worker_order_cancel_requests (worker_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_cancel_requests_status_created
|
||||
ON worker_order_cancel_requests (status, created_at DESC);
|
||||
|
||||
COMMENT ON COLUMN worker_users.level_penalty_accepted_count IS '撤单处罚扣减的有效验收次数,不删除真实历史订单';
|
||||
COMMENT ON TABLE worker_order_cancel_requests IS '打手发起、客服审核的撤单申请';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 打手问题反馈:关联工单,由客服回复并关闭,不改变工单状态或资金。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_order_feedbacks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||
work_order_share_id BIGINT REFERENCES work_order_shares(id) ON DELETE SET NULL,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
reply TEXT NOT NULL DEFAULT '',
|
||||
handled_by TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
handled_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_order_feedbacks_status_created
|
||||
ON worker_order_feedbacks (status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_order_feedbacks_worker_created
|
||||
ON worker_order_feedbacks (worker_id, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE worker_order_feedbacks IS '打手针对工单提交的客户资料和处理问题反馈';
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 045_work_order_source_product_rule.sql —— 记录工单的来源接单模板,支持后续按模板同步价格。
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS product_rule_id BIGINT
|
||||
REFERENCES work_product_rules(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_product_rule_open
|
||||
ON work_orders(product_rule_id, id DESC)
|
||||
WHERE status = 'open';
|
||||
|
||||
-- 历史自动建单已在 source_synced 事件中记录 ruleKey,迁移时尽可能补齐关联。
|
||||
UPDATE work_orders wo
|
||||
SET product_rule_id = wpr.id
|
||||
FROM work_product_rules wpr
|
||||
WHERE wo.product_rule_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM work_order_events event
|
||||
WHERE event.work_order_id = wo.id
|
||||
AND event.event_type = 'source_synced'
|
||||
AND event.payload_json ->> 'ruleKey' = wpr.rule_key
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN work_orders.product_rule_id
|
||||
IS '自动建单时命中的接单模板;用于模板价格同步,删除模板后保留工单但置空';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 046_worker_freeze_reason.sql —— 记录账号冻结原因,避免被普通审核备注覆盖。
|
||||
|
||||
ALTER TABLE worker_users
|
||||
ADD COLUMN IF NOT EXISTS frozen_reason TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE worker_users
|
||||
ADD COLUMN IF NOT EXISTS frozen_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN worker_users.frozen_reason IS '最近一次冻结账号的原因或备注';
|
||||
COMMENT ON COLUMN worker_users.frozen_at IS '最近一次冻结账号的时间';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 打手订单列表按打手关联拼单记录,避免全表扫描后逐行查找拼单份额。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_shares_worker_order
|
||||
ON work_order_shares(worker_id, work_order_id);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- 高频打手订单、拼单统计、快手回调扫描和仪表盘查询的索引保护。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_shares_order_status_include_quantity
|
||||
ON work_order_shares(work_order_id, status) INCLUDE (quantity);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_events_order_type
|
||||
ON work_order_events(work_order_id, event_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_status_updated_at
|
||||
ON fulfillment_tasks(task_status, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_callback_retry_order
|
||||
ON kuaishou_industry_vouchers(COALESCE(send_callback_sent_at, created_at), id)
|
||||
WHERE send_callback_status <> 'success';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 支付宝提现账户保存收款二维码;历史账户保留空值,需补充后才能继续提现。
|
||||
ALTER TABLE worker_withdrawal_accounts
|
||||
ADD COLUMN IF NOT EXISTS alipay_qr_code_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN worker_withdrawal_accounts.alipay_qr_code_json IS '支付宝收款二维码文件信息';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 支持资金申请列表按打手快速统计提现次数和已提现金额。
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_finance_requests_worker_withdraw_stats
|
||||
ON worker_finance_requests(worker_id, request_type)
|
||||
INCLUDE (status, amount);
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 051_worker_hall_queue.sql —— 抢单大厅展示队列与容量查询索引。
|
||||
|
||||
ALTER TABLE work_orders ADD COLUMN IF NOT EXISTS hall_queued_at TIMESTAMPTZ NULL;
|
||||
|
||||
UPDATE work_orders
|
||||
SET hall_queued_at = COALESCE(published_at, updated_at, created_at)
|
||||
WHERE status = 'open'
|
||||
AND hall_queued_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_hall_queue
|
||||
ON work_orders(hall_queued_at ASC, id ASC)
|
||||
WHERE status = 'open';
|
||||
|
||||
COMMENT ON COLUMN work_orders.hall_queued_at IS '进入抢单大厅候选队列的时间;用于按先进先出限制大厅展示数量';
|
||||
@@ -0,0 +1,71 @@
|
||||
-- 验收后的售后追责单:不改变已验收工单的生命周期和历史结算。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_after_sales_cases (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
case_no TEXT NOT NULL UNIQUE,
|
||||
work_order_id BIGINT NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE,
|
||||
work_order_share_id BIGINT REFERENCES work_order_shares(id) ON DELETE SET NULL,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE RESTRICT,
|
||||
problem_type TEXT NOT NULL DEFAULT 'fake_evidence',
|
||||
complaint_note TEXT NOT NULL DEFAULT '',
|
||||
complaint_files_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
acceptance_snapshot_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status TEXT NOT NULL DEFAULT 'awaiting_worker_response',
|
||||
worker_response TEXT NOT NULL DEFAULT '',
|
||||
worker_response_files_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
responded_at TIMESTAMPTZ,
|
||||
resolution_action TEXT NOT NULL DEFAULT '',
|
||||
resolution_note TEXT NOT NULL DEFAULT '',
|
||||
recovery_amount INTEGER NOT NULL DEFAULT 0,
|
||||
pending_deposit_deduction_amount INTEGER NOT NULL DEFAULT 0,
|
||||
available_deduction_amount INTEGER NOT NULL DEFAULT 0,
|
||||
debt_amount INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
resolved_by TEXT NOT NULL DEFAULT '',
|
||||
resolved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_after_sales_cases_active_target
|
||||
ON worker_after_sales_cases(work_order_id, worker_id, COALESCE(work_order_share_id, 0))
|
||||
WHERE status IN ('awaiting_worker_response', 'under_review', 'recovery_pending');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_after_sales_cases_status_created
|
||||
ON worker_after_sales_cases(status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_after_sales_cases_worker_created
|
||||
ON worker_after_sales_cases(worker_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_after_sales_debts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
case_id BIGINT NOT NULL UNIQUE REFERENCES worker_after_sales_cases(id) ON DELETE CASCADE,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker_users(id) ON DELETE RESTRICT,
|
||||
original_amount INTEGER NOT NULL,
|
||||
outstanding_amount INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
settled_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_after_sales_debts_worker_open
|
||||
ON worker_after_sales_debts(worker_id, status, created_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS worker_after_sales_case_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
case_id BIGINT NOT NULL REFERENCES worker_after_sales_cases(id) ON DELETE CASCADE,
|
||||
actor_type TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL DEFAULT '',
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_after_sales_case_events_case_created
|
||||
ON worker_after_sales_case_events(case_id, created_at ASC, id ASC);
|
||||
|
||||
COMMENT ON TABLE worker_after_sales_cases IS '已验收工单的独立售后追责单,不回退原工单状态';
|
||||
COMMENT ON COLUMN worker_after_sales_cases.acceptance_snapshot_json IS '创建售后单时固化的验收证据快照';
|
||||
COMMENT ON TABLE worker_after_sales_debts IS '售后扣款不足时形成的打手追缴欠款';
|
||||
COMMENT ON TABLE worker_after_sales_case_events IS '售后问题单不可篡改的操作记录';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 打手改昵称卡:默认每人 1 张,修改昵称后一年内锁定。
|
||||
|
||||
ALTER TABLE worker_users
|
||||
ADD COLUMN IF NOT EXISTS rename_cards INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS last_renamed_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN worker_users.rename_cards IS '剩余改昵称卡数量,注册默认 1 张';
|
||||
COMMENT ON COLUMN worker_users.last_renamed_at IS '最近一次修改昵称时间,修改后一年内禁止再次修改';
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 好友赠送验收模式:打手接单后提交本人资料 → 后台确认加好友 → 冷却 N 小时后才能赠送并提交验收。
|
||||
-- 发布、核销、大厅、抢单、押金、结算链路保持不变,仅验收前置流程不同。
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS acceptance_mode TEXT NOT NULL DEFAULT 'standard',
|
||||
ADD COLUMN IF NOT EXISTS gift_phase TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS booster_material_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS friend_added_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS gift_available_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS gift_cooldown_hours INTEGER NOT NULL DEFAULT 72;
|
||||
|
||||
COMMENT ON COLUMN work_orders.acceptance_mode IS '验收模式:standard 标准验收 / friend_gift 好友赠送(建单时从接单模板拷贝)';
|
||||
COMMENT ON COLUMN work_orders.gift_phase IS '好友赠送阶段:material_required 待打手提交资料 / material_submitted 待后台确认好友 / friend_countdown 冷却中;gift_ready 为 gift_available_at 到点后的实时计算值,不落库';
|
||||
COMMENT ON COLUMN work_orders.booster_material_json IS '打手本人资料:昵称/ID/区服/系统 + 主页截图';
|
||||
COMMENT ON COLUMN work_orders.friend_added_at IS '后台确认加好友时间(冷却起点)';
|
||||
COMMENT ON COLUMN work_orders.gift_available_at IS '可赠送时间 = 确认好友时间 + 冷却小时数';
|
||||
COMMENT ON COLUMN work_orders.gift_cooldown_hours IS '好友赠送冷却小时数,建单时从模板拷贝';
|
||||
|
||||
-- 后台按“可赠送”筛选待办时走该部分索引,避免全表扫描。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_gift_available
|
||||
ON work_orders (gift_available_at)
|
||||
WHERE acceptance_mode = 'friend_gift' AND status = 'in_progress';
|
||||
|
||||
ALTER TABLE work_product_rules
|
||||
ADD COLUMN IF NOT EXISTS acceptance_mode TEXT NOT NULL DEFAULT 'standard',
|
||||
ADD COLUMN IF NOT EXISTS gift_cooldown_hours INTEGER NOT NULL DEFAULT 72;
|
||||
|
||||
COMMENT ON COLUMN work_product_rules.acceptance_mode IS '验收模式:standard 标准验收 / friend_gift 好友赠送;启用 friend_gift 时不允许拼单';
|
||||
COMMENT ON COLUMN work_product_rules.gift_cooldown_hours IS '好友赠送冷却小时数(默认 72 即 3 天),建单时拷贝到工单';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 验收报酬默认进入待解冻,售后问题单成立前不能直接提现。
|
||||
COMMENT ON COLUMN worker_wallets.pending_unfreeze_amount IS '待解冻金额:验收报酬与押金按配置天数解冻到账';
|
||||
COMMENT ON TABLE worker_deposit_unfreezes IS '待解冻计划:验收报酬与押金到期自动转入可用余额';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 售后处置为补救完成/问题不成立时,退还此前对同一责任单元已追缴的金额。
|
||||
ALTER TABLE worker_after_sales_cases
|
||||
ADD COLUMN IF NOT EXISTS refunded_amount INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS refunded_by TEXT NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN worker_after_sales_cases.refunded_amount IS '已退还的追缴金额;补救完成/问题不成立时按原追缴额全额退还';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 打手查看工单资料更新的时间:客服更新资料后订单展示"资料已更新"角标,打手打开详情后清除。
|
||||
ALTER TABLE worker_work_order_views
|
||||
ADD COLUMN IF NOT EXISTS material_seen_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN worker_work_order_views.material_seen_at IS '打手最近一次打开工单详情的时间,用于计算资料已更新角标';
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 数据库可观测性与高频查询索引保护。
|
||||
-- PostgreSQL 容器通过 shared_preload_libraries 预加载;托管数据库若无权限,跳过扩展创建并由平台侧开启。
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
|
||||
EXCEPTION
|
||||
WHEN insufficient_privilege OR undefined_file THEN
|
||||
RAISE NOTICE '无法创建 pg_stat_statements,请在数据库平台侧启用该扩展';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- 打手订单状态计算中的 pending 撤单/反馈 EXISTS。
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_cancel_requests_pending_worker_order
|
||||
ON worker_order_cancel_requests(worker_id, work_order_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_feedbacks_pending_worker_order
|
||||
ON worker_order_feedbacks(worker_id, work_order_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- 工单公共详情和资料更新角标按工单、事件类型取最新事件。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_events_order_type_id
|
||||
ON work_order_events(work_order_id, event_type, id DESC);
|
||||
|
||||
-- 排行榜按打手和验收时间筛选;全部周期仍需汇总表或缓存,索引不能替代聚合。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_orders_accepted_worker_at
|
||||
ON work_orders(assigned_worker_id, accepted_at DESC)
|
||||
WHERE status = 'accepted' AND assigned_worker_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_shares_accepted_worker_at
|
||||
ON work_order_shares(worker_id, accepted_at DESC)
|
||||
WHERE status = 'accepted';
|
||||
@@ -0,0 +1,87 @@
|
||||
-- 后台 RBAC 权限模型。保留 admin_users.role 兼容旧账号,权限通过角色键动态解析。
|
||||
CREATE TABLE IF NOT EXISTS admin_roles (
|
||||
role_key TEXT PRIMARY KEY,
|
||||
role_name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_system BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_permissions (
|
||||
permission_key TEXT PRIMARY KEY,
|
||||
permission_name TEXT NOT NULL,
|
||||
permission_group TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
is_system BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_role_permissions (
|
||||
role_key TEXT NOT NULL REFERENCES admin_roles(role_key) ON DELETE CASCADE,
|
||||
permission_key TEXT NOT NULL REFERENCES admin_permissions(permission_key) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (role_key, permission_key)
|
||||
);
|
||||
|
||||
INSERT INTO admin_roles (role_key, role_name, description)
|
||||
VALUES
|
||||
('admin', '管理员', '拥有全部后台权限'),
|
||||
('operator', '普通运营', '负责接单平台和订单运营'),
|
||||
('support', '客服', '负责客服处理和售后协助')
|
||||
ON CONFLICT (role_key) DO UPDATE SET
|
||||
role_name = EXCLUDED.role_name,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description)
|
||||
VALUES
|
||||
('worker_order.view', '查看接单订单', '接单管理', '查看接单平台订单及详情'),
|
||||
('worker_order.edit', '编辑接单订单', '接单管理', '修改接单订单基础资料'),
|
||||
('worker_order.configure_share', '配置拼单', '接单管理', '配置接单订单拼单规则'),
|
||||
('worker_order.assign', '指派打手', '接单管理', '指派或取消指派打手'),
|
||||
('worker_order.return_to_hall', '撤单并退回大厅', '接单管理', '将进行中的订单撤回抢单大厅'),
|
||||
('worker_order.cancel', '撤单并取消订单', '接单管理', '取消订单并终止后续履约'),
|
||||
('worker_order.cancel_share', '撤销拼单参与者', '接单管理', '撤销拼单中的单个参与者'),
|
||||
('worker_order.delete', '删除接单订单', '接单管理', '删除尚未履约的接单订单'),
|
||||
('worker_order.publish', '发布接单订单', '接单管理', '发布或下架接单订单'),
|
||||
('worker_order.accept', '验收接单订单', '接单管理', '验收接单订单和拼单份额'),
|
||||
('worker_order.reopen', '重新启用订单', '接单管理', '重新启用已取消的接单订单'),
|
||||
('worker_order.update_material', '更新订单资料', '接单管理', '补充或更新订单资料'),
|
||||
('worker_order.manage_problem', '处理问题单', '接单管理', '标记和处置问题单'),
|
||||
('worker_order.deduct_deposit', '扣除押金', '接单管理', '处理待解冻押金'),
|
||||
('worker_cancel_request.review', '审核撤单申请', '接单管理', '审核打手提交的撤单申请')
|
||||
ON CONFLICT (permission_key) DO UPDATE SET
|
||||
permission_name = EXCLUDED.permission_name,
|
||||
permission_group = EXCLUDED.permission_group,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW();
|
||||
|
||||
-- 管理员默认拥有全部已登记权限。
|
||||
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||
SELECT 'admin', permission_key FROM admin_permissions
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 普通运营保持现有完整接单操作;客服默认不拥有高风险直接撤单权限。
|
||||
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||
SELECT 'operator', permission_key
|
||||
FROM admin_permissions
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO admin_role_permissions (role_key, permission_key)
|
||||
SELECT 'support', permission_key
|
||||
FROM admin_permissions
|
||||
WHERE permission_key IN (
|
||||
'worker_order.view',
|
||||
'worker_order.edit',
|
||||
'worker_order.assign',
|
||||
'worker_order.publish',
|
||||
'worker_order.update_material',
|
||||
'worker_order.manage_problem',
|
||||
'worker_cancel_request.review'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_role_permissions_permission
|
||||
ON admin_role_permissions(permission_key, role_key);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- 提现申请改为"申请即冻结"资金模型:
|
||||
-- 1) worker_wallets 新增 frozen_withdraw_amount,申请提现立即从 available_amount 划转到该字段;
|
||||
-- 2) worker_finance_requests 新增 frozen_amount 标记该笔申请是否已冻结资金(0=旧数据未冻结),
|
||||
-- 审核通过/拒绝按此标记选择解冻或旧口径扣款,保证存量数据平滑过渡;
|
||||
-- 3) 仅对迁移后创建的申请生效,存量申请仍按旧口径在审核通过时扣款。
|
||||
|
||||
ALTER TABLE worker_wallets
|
||||
ADD COLUMN IF NOT EXISTS frozen_withdraw_amount INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE worker_finance_requests
|
||||
ADD COLUMN IF NOT EXISTS frozen_amount INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 061_work_order_admin_note.sql —— 后台客服工单备注。
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS admin_note TEXT NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN work_orders.admin_note IS '后台客服维护的工单备注,不展示给打手';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 062_worker_cancel_request_wording.sql —— 统一后台权限中的取消申请文案。
|
||||
UPDATE admin_permissions
|
||||
SET
|
||||
permission_name = '审核取消订单申请',
|
||||
description = '审核打手提交的取消订单申请'
|
||||
WHERE permission_key = 'worker_cancel_request.review';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 063_worker_cancel_request_wording_v2.sql —— 将退单审核文案明确为打手退单。
|
||||
UPDATE admin_permissions
|
||||
SET
|
||||
permission_name = '审核打手退单申请',
|
||||
description = '审核打手提交的退单申请'
|
||||
WHERE permission_key = 'worker_cancel_request.review';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- 064_work_order_gift_cooldown_minutes.sql —— 好友赠送冷却支持按分钟配置。
|
||||
-- 小时与分钟两种单位互斥;保留原小时字段,历史模板与工单自动沿用小时配置。
|
||||
|
||||
ALTER TABLE work_product_rules
|
||||
ADD COLUMN IF NOT EXISTS gift_cooldown_minutes INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE work_orders
|
||||
ADD COLUMN IF NOT EXISTS gift_cooldown_minutes INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN work_product_rules.gift_cooldown_minutes
|
||||
IS '好友赠送按分钟配置时的总分钟数(1-43200);非零时小时字段为 0,建单时拷贝到工单';
|
||||
|
||||
COMMENT ON COLUMN work_orders.gift_cooldown_minutes
|
||||
IS '好友赠送按分钟配置时的总分钟数(1-43200);非零时小时字段为 0,从模板拷贝';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 065_worker_personal_deposit_free_amount.sql —— 单个打手的免押额度覆盖等级配置。
|
||||
|
||||
ALTER TABLE worker_users
|
||||
ADD COLUMN IF NOT EXISTS personal_deposit_free_amount INTEGER;
|
||||
|
||||
COMMENT ON COLUMN worker_users.personal_deposit_free_amount IS '个人追加免押额度,单位分;与等级免押额度叠加,NULL/0 表示未追加';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 066_worker_unfreeze_fund_type.sql —— 区分待解冻押金与待解冻报酬,防止后台扣押金误扣报酬。
|
||||
|
||||
ALTER TABLE worker_deposit_unfreezes
|
||||
ADD COLUMN IF NOT EXISTS fund_type TEXT NOT NULL DEFAULT 'legacy_combined';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_deposit_unfreezes_order_fund_status
|
||||
ON worker_deposit_unfreezes(work_order_id, fund_type, status);
|
||||
|
||||
COMMENT ON COLUMN worker_deposit_unfreezes.fund_type IS '待解冻资金类型:deposit 押金 / reward 报酬 / legacy_combined 历史合并记录';
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 067_data_retention_indexes.sql —— 数据保留清理所需索引。
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at
|
||||
ON webhook_events(created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_updated_at
|
||||
ON orders(updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_updated_at
|
||||
ON kuaishou_industry_vouchers(updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_events_created_at
|
||||
ON task_events(created_at DESC);
|
||||
|
||||
DROP INDEX IF EXISTS idx_work_product_match_logs_created_at;
|
||||
|
||||
COMMENT ON INDEX idx_webhook_events_created_at IS '支持 webhook 回调去重数据按创建时间清理';
|
||||
COMMENT ON INDEX idx_orders_updated_at IS '支持订单原始报文按更新时间清理';
|
||||
COMMENT ON INDEX idx_kuaishou_industry_vouchers_updated_at IS '支持电子凭证原始报文按更新时间清理';
|
||||
COMMENT ON INDEX idx_task_events_created_at IS '支持任务事件按创建时间清理';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 068_timeout_event_worker_index.sql —— 后台打手超时次数统计。
|
||||
-- countTimeoutEventsByWorkerIds 按 payload_json->>'workerId' 聚合;
|
||||
-- 部分表达式索引避免每次后台列表请求扫描全部 work_order_events。
|
||||
CREATE INDEX IF NOT EXISTS idx_work_order_events_timeout_worker_id
|
||||
ON work_order_events ((payload_json->>'workerId'))
|
||||
WHERE event_type LIKE 'timeout_%';
|
||||
|
||||
COMMENT ON INDEX idx_work_order_events_timeout_worker_id IS
|
||||
'支持按打手统计 timeout_* 工单事件,避免 JSON 全表扫描';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 将历史平板会话归入手机设备桶,确保新旧数据使用同一套 PC/手机上限。
|
||||
UPDATE worker_sessions
|
||||
SET device_type = 'mobile', updated_at = NOW()
|
||||
WHERE device_type = 'tablet';
|
||||
|
||||
UPDATE worker_sessions
|
||||
SET device_type = 'pc', updated_at = NOW()
|
||||
WHERE device_type IS NULL OR device_type NOT IN ('pc', 'mobile');
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { Pool } from 'pg'
|
||||
|
||||
const connectionString = process.env.TEST_POSTGRES_URL
|
||||
|
||||
test('real PostgreSQL transaction rolls back atomically', { skip: !connectionString }, async () => {
|
||||
const pool = new Pool({ connectionString })
|
||||
const client = await pool.connect()
|
||||
const tableName = `integration_tx_${Date.now()}`
|
||||
try {
|
||||
await client.query(`CREATE TEMP TABLE ${tableName} (id integer primary key, value text)`)
|
||||
await client.query('BEGIN')
|
||||
await client.query(`INSERT INTO ${tableName} (id, value) VALUES (1, 'before-error')`)
|
||||
try {
|
||||
await client.query(`INSERT INTO ${tableName} (id, value) VALUES (1, 'duplicate')`)
|
||||
} catch {
|
||||
await client.query('ROLLBACK')
|
||||
}
|
||||
const result = await client.query(`SELECT COUNT(*)::int AS count FROM ${tableName}`)
|
||||
assert.equal(result.rows[0].count, 0)
|
||||
} finally {
|
||||
client.release()
|
||||
await pool.end()
|
||||
}
|
||||
})
|
||||
|
||||
test(
|
||||
'real PostgreSQL enforces callback replay idempotency under concurrency',
|
||||
{
|
||||
skip: !connectionString,
|
||||
},
|
||||
async () => {
|
||||
const pool = new Pool({ connectionString })
|
||||
const tableName = `integration_webhook_${Date.now()}`
|
||||
try {
|
||||
await pool.query(
|
||||
`CREATE TABLE ${tableName} (
|
||||
id serial primary key,
|
||||
provider text not null,
|
||||
event_id text not null,
|
||||
unique(provider, event_id)
|
||||
)`,
|
||||
)
|
||||
await Promise.all(
|
||||
Array.from({ length: 8 }, () =>
|
||||
pool.query(
|
||||
`INSERT INTO ${tableName} (provider, event_id)
|
||||
VALUES ('integration', 'replay-1')
|
||||
ON CONFLICT (provider, event_id) DO NOTHING`,
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = await pool.query(`SELECT COUNT(*)::int AS count FROM ${tableName}`)
|
||||
assert.equal(result.rows[0].count, 1)
|
||||
} finally {
|
||||
await pool.query(`DROP TABLE IF EXISTS ${tableName}`)
|
||||
await pool.end()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -11,11 +11,20 @@ import {
|
||||
} from './task-status.js'
|
||||
|
||||
test('任务状态机声明 kuaishou-lewan 主流程跳转', () => {
|
||||
assert.equal(canTaskTransition(TASK_STATUS.PENDING_BINDING_PREPARE, TASK_STATUS.WAITING_BINDING), true)
|
||||
assert.equal(
|
||||
canTaskTransition(TASK_STATUS.PENDING_BINDING_PREPARE, TASK_STATUS.WAITING_BINDING),
|
||||
true,
|
||||
)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.WAITING_BINDING, TASK_STATUS.ROLE_CONFIRMED), true)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.ROLE_CONFIRMED, TASK_STATUS.REDEEMING), true)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.REDEEMING, TASK_STATUS.DISPATCHED_PENDING_RETURN), true)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.DISPATCHED_PENDING_RETURN, TASK_STATUS.COMPLETED), true)
|
||||
assert.equal(
|
||||
canTaskTransition(TASK_STATUS.REDEEMING, TASK_STATUS.DISPATCHED_PENDING_RETURN),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
canTaskTransition(TASK_STATUS.DISPATCHED_PENDING_RETURN, TASK_STATUS.COMPLETED),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('任务状态机收敛领取和 91 查询的业务判断', () => {
|
||||
|
||||
@@ -259,10 +259,7 @@ export function isKuaishouCloudRedeemSettledStatus(status: unknown): boolean {
|
||||
export function canRedeemKuaishouCloudClaimStatus(status: unknown): boolean {
|
||||
const normalized = normalizeTaskStatus(status)
|
||||
// 允许 WAITING_BINDING:UID 匹配后可一键确认+兑换
|
||||
return (
|
||||
normalized === TASK_STATUS.ROLE_CONFIRMED ||
|
||||
normalized === TASK_STATUS.WAITING_BINDING
|
||||
)
|
||||
return normalized === TASK_STATUS.ROLE_CONFIRMED || normalized === TASK_STATUS.WAITING_BINDING
|
||||
}
|
||||
|
||||
export function canRegenerateClaimLinkStatus(status: unknown): boolean {
|
||||
@@ -315,7 +312,7 @@ export function resolveInitialPaidTaskStatus(
|
||||
| null
|
||||
| undefined,
|
||||
): TaskStatus {
|
||||
if (Boolean(profile?.requires_claim)) {
|
||||
if (profile?.requires_claim) {
|
||||
return TASK_STATUS.PAID
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user