Compare commits

309 Commits
Author SHA1 Message Date
yml2213 d47360f484 改用本地推送质量检查 2026-08-31 12:32:13 +08:00
yml2213 efde8897c8 添加提交前检查钩子
代码质量 / backend (push) Waiting to run
代码质量 / frontend (push) Waiting to run
2026-08-31 12:31:01 +08:00
yml2213 298dcdc3eb artifacts文件夹
代码质量 / backend (push) Canceled after 0s
代码质量 / frontend (push) Canceled after 0s
2026-08-31 11:49:51 +08:00
yml2213 b832279c1f feat: split worker login device limits by type 2026-08-30 21:58:13 +08:00
yml2213 ffb71f2276 修复接单端过期会话重复请求 2026-08-30 17:32:34 +08:00
yml2213 535cefd4de 修复:恢复 PostgreSQL 集成测试并规范工单搜索条件 2026-08-30 17:04:35 +08:00
yml2213 25b89bb503 优化:降低访问日志噪声并增加日志文件轮转 2026-08-30 16:59:11 +08:00
yml2213 6ca54e468e 安全加固:增加后台登录 IP 限流并记录验证结果 2026-08-30 16:47:16 +08:00
yml2213 d24c69141a perf: reduce worker order list query load 2026-08-30 16:40:09 +08:00
yml2213 a45d7597fb ops: load MySQL diagnostics credentials from env 2026-08-30 16:22:04 +08:00
yml2213 174f931486 perf: reduce worker overview refetches 2026-08-30 15:49:52 +08:00
yml2213 79d8f84ac9 perf: optimize worker order query hot paths 2026-08-30 15:30:46 +08:00
yml2213 7b9794665c 运维脚本 2026-08-30 15:13:52 +08:00
yml2213 a1b3d9c70c docs: README 补充 ubuntu 部署脚本的 --skip-backup 等参数说明 2026-08-30 14:25:32 +08:00
yml2213 737c957bb3 chore(deploy): 资源默认值适配 4 核 8G 共用服务器
- postgres mem_limit 1.5g/shared_buffers 384MB/effective_cache_size
  4GB/work_mem 16MB/maintenance_work_mem 256MB
- backend mem_limit 768m、cpus 1.5;web 保持不变
- 生产与开发编排、env 模板、运维文档同步更新
2026-08-30 14:07:39 +08:00
yml2213 6dcdec43f1 chore(deploy): 数据库与各服务增加资源限制和优雅关闭保护
- postgres 增加 mem_limit/cpus/shm_size/stop_grace_period 与 PG
  性能参数(shared_buffers 等,env 可覆盖);backend/web 增加
  内存/CPU 限额与更长的优雅关闭宽限(生产与开发编排同步)
- ubuntu-deploy.sh 增加部署前磁盘空间预检,防止备份或迁移
  中途磁盘写满
- .env.server.example 补充资源变量;文档补充容器资源限制说明
2026-08-30 13:59:19 +08:00
yml2213 02c9de8b9c feat(ops): 数据保留清理、CORS 白名单与上传失败回滚
- 新增每日数据保留清理任务:按保留期清空原始报文、删除过期
  事件/审计记录,批量幂等可重跑;067 迁移补齐清理索引并清理
  冗余索引,check-sql-guard 增加防线
- 生产 CORS 改为显式来源白名单(CORS_ALLOWED_ORIGINS),禁用
  通配符,非通配响应补充 Vary: Origin
- 文件上传任一步失败时回滚已上传对象,清理失败记录 warn 日志
- 新增 HTTP/CORS 与 PostgreSQL 集成测试;若干文件仅 prettier 重排
2026-08-30 13:59:01 +08:00
yml2213 dcb0fa4c76 优化订单流程与数据库关闭 2026-08-30 13:15:47 +08:00
yml2213 04dc363320 修复押金与待解冻资金处理 2026-08-28 22:22:26 +08:00
yml2213 f20ff3018f feat(打手): 支持个人追加免押额度 2026-08-28 21:36:54 +08:00
yml2213 5d5d63bb59 feat(接单): 支持客服手动结束赠送冷却 2026-08-28 20:39:41 +08:00
yml2213 e680792161 feat(接单): 好友赠送冷却支持分钟单位 2026-08-28 20:17:47 +08:00
yml2213 d91d7180ad feat(worker): support batch sharing cancellation 2026-08-28 19:40:49 +08:00
yml2213 e189811617 将退单审核文案明确为打手退单 2026-08-23 18:19:47 +08:00
yml2213 003c3e9208 统一取消订单申请审核文案 2026-08-23 18:13:37 +08:00
yml2213 75d94140ed 增加接单工单客服备注 2026-08-23 17:51:40 +08:00
yml2213 1cebdd0487 增加接单分类和大厅分类配额 2026-08-23 17:37:32 +08:00
yml2213 844f071aab 修复提现申请冻结余额 2026-08-23 16:51:32 +08:00
yml2213 b49fa12484 打手列表新增VIP等级筛选 2026-08-23 16:21:39 +08:00
yml2213 3df271b30c 后台新增RBAC权限模型支持按角色配置接单管理权限
- 新增 admin_roles/admin_permissions/admin_role_permissions 三表与种子数据(059迁移)
- 接单平台全部管理路由从固定角色切换为细粒度权限校验
- 撤单按动作二次校验:退回大厅与取消订单分别要求对应权限
- 管理员账号页新增角色权限配置卡片(admin专属,带审计日志)
- 权限更新走事务保证原子性;session实时解析权限确保修改立即生效
2026-08-23 16:04:10 +08:00
yml2213 fb0e9c8f9c 增加备份文档 2026-08-23 15:24:37 +08:00
yml2213 8f3a5c6a67 修正备份演练表行数统计 2026-08-23 15:08:47 +08:00
yml2213 ad59066637 修复备份恢复演练校验 2026-08-23 15:01:11 +08:00
yml2213 f237a6f6ce 修正备份镜像检测逻辑 2026-08-23 14:48:57 +08:00
yml2213 99ce9a8a61 增加备份镜像前置检查 2026-08-23 14:45:31 +08:00
yml2213 d56189fe59 完善数据库备份恢复链路 2026-08-23 14:39:30 +08:00
yml2213 0dfc1ef0df mall.khhao.com增加 2026-08-23 13:40:06 +08:00
yml2213 9080476dd6 生产编排移除内置MinIO图片存储全面改走阿里云OSS 2026-08-23 13:38:32 +08:00
yml2213 6d7f9b5f3f 准备迁移1 2026-08-23 12:54:38 +08:00
yml2213 360c5ea20e 好友赠送单确认加好友后取消扣押金覆盖撤单撤回取消指派撤单申请与超时问题单处置 2026-08-23 11:23:17 +08:00
yml2213 70b31f4874 SSE鉴权失效停止重连清理会话跳登录并降噪4xx错误日志 2026-08-23 11:01:41 +08:00
yml2213 8e49b7d047 移除Mock订单号输入框的提示文字恢复表单布局 2026-08-23 10:40:16 +08:00
yml2213 260616b872 工单订单号全局唯一校验覆盖Mock创建与自动同步跨订单查重 2026-08-23 10:38:02 +08:00
yml2213 fdf83a6745 匹配日志支持结果状态关键词与日期筛选及服务端分页 2026-08-23 10:30:47 +08:00
yml2213 b4918769f1 优化待解冻, 平台杂项配置优化 2026-08-22 20:11:01 +08:00
yml2213 1b8fdff162 管理后台新增打手详情抽屉聚合钱包流水待解冻与订单数据 2026-08-22 19:56:23 +08:00
yml2213 90ff15b627 短信发送失败增加冷却防连点限流并配置trust proxy恢复真实客户端IP 2026-08-22 19:10:06 +08:00
yml2213 7e1c879c41 修复新订单广播绕过等级可见延迟导致抢单提示不可拼单 2026-08-22 18:56:00 +08:00
yml2213 1b8e02dd17 增加数据库慢查询观测与索引防线并收窄打手拼单分页查询 2026-08-22 18:40:11 +08:00
yml2213 0dfe363d35 重构打手订单概览查询为索引可用的UNION分支避免work_orders全表扫描 2026-08-22 15:41:01 +08:00
yml2213 2224dd06d3 打手端待审撤单与未处理反馈订单归入问题单分组并标注来源角标 2026-08-22 15:04:30 +08:00
yml2213 061bc2636d 大厅配置并入杂项配置标签页 2026-08-22 14:42:16 +08:00
yml2213 4bc661c1b5 杂项配置面板管理本地Mock自定义店铺支持增删改名 2026-08-22 14:39:46 +08:00
yml2213 15396822b5 本地Mock自定义店铺自动记忆沉淀为下拉可选项 2026-08-22 14:31:34 +08:00
yml2213 72b3f7c592 本地Mock店铺支持自定义输入便于录入其他平台订单 2026-08-22 14:26:04 +08:00
yml2213 f92dc6a4c9 已取消工单在列表与详情展示取消原因来源操作人与时间 2026-08-22 14:17:20 +08:00
yml2213 91008c98c4 接单模板左侧编辑卡片吸顶固定不再随列表滚动 2026-08-22 14:07:59 +08:00
yml2213 2350ba985e 本地Mock生成工单成功后重置表单为默认值 2026-08-22 14:05:16 +08:00
yml2213 665fc9ffa7 工单流转记录始终展示操作人并区分涉及打手补充编辑与撤回明细 2026-08-22 14:02:52 +08:00
yml2213 6ac77c0124 售后处置为补救完成或问题不成立时自动退还原追缴金额 2026-08-22 13:54:58 +08:00
yml2213 9beb501fa6 打手端资金流水类型补充中文标签并放行拼单报酬筛选 2026-08-22 13:42:53 +08:00
yml2213 7f6a291628 新增验收报酬待解冻配置并修复待解冻结算漏入队与重复释放流水 2026-08-22 13:38:17 +08:00
yml2213 7b2a5321b9 修复问题单退回后拼单份额无法验收 2026-08-22 13:07:54 +08:00
yml2213 f4e552c51c 资金申请时间筛选支持精确到时分并修复截止日SQL括号缺失 2026-08-22 12:26:05 +08:00
yml2213 476ab166d7 修复资金申请渠道筛选失效并为时间筛选增加快捷选项 2026-08-22 12:08:11 +08:00
yml2213 cee759b90e 修复后台售后问题单固化验收图同一图片重复展示 2026-08-22 11:53:52 +08:00
yml2213 67bb5fec15 优化打手端售后问题单页面展示问题类型处理结果与提交说明 2026-08-22 11:25:18 +08:00
yml2213 6aec4a7b40 新增好友赠送验收模式支持打手资料提交加好友冷却与模板配置 2026-08-22 11:19:49 +08:00
yml2213 ba35d27976 新增打手改昵称卡功能默认一张且一年冷却 2026-08-22 10:20:10 +08:00
yml2213 985e61209d 资金申请增加渠道筛选合计与审核留痕 2026-08-22 09:57:28 +08:00
yml2213 3618df74c5 接单工单与资金申请列表支持创建时间区间筛选 2026-08-22 09:48:09 +08:00
yml2213 f1e219d1ef 新增后台接单排行榜支持周期与自定义区间筛选 2026-08-22 09:48:09 +08:00
yml2213 ed81122047 大厅与后台工单列表改为精确补丁并支持抢单确认 2026-08-22 09:30:38 +08:00
yml2213 1f7ff016df 新增打手端新订单语音播报开关 2026-08-22 09:12:26 +08:00
yml2213 700bda8250 优化售后问题单后台位置 2026-08-21 20:03:37 +08:00
yml2213 a5c07ebafc 统一新增仓储与路由文件代码格式 2026-08-21 19:09:23 +08:00
yml2213 59e74bf470 拆分打手个人中心读取与资金服务 2026-08-21 19:08:22 +08:00
yml2213 e7685c2337 拆分打手端路由 2026-08-21 19:04:57 +08:00
yml2213 2134e9dbc4 拆分后台打手平台路由 2026-08-21 18:57:12 +08:00
yml2213 cc7491ecd6 拆分打手平台映射器 2026-08-21 18:53:59 +08:00
yml2213 eeb3d2f297 拆分工单验收结算仓储 2026-08-21 18:50:03 +08:00
yml2213 26f1a00e24 拆分工单异常处置仓储 2026-08-21 18:48:08 +08:00
yml2213 a9665b180f 拆分工单流转事务仓储 2026-08-21 18:47:05 +08:00
yml2213 175b2dbac6 拆分工单押金事务仓储 2026-08-21 18:44:35 +08:00
yml2213 3c651ada9c 收敛押金到期查询 2026-08-21 18:28:05 +08:00
yml2213 20ace8acaa 收敛工单活跃统计 2026-08-21 18:27:36 +08:00
yml2213 45bd917e3c 收敛工单撤单统计 2026-08-21 18:23:42 +08:00
yml2213 90527344ca 拆分工单资料映射 2026-08-21 18:22:41 +08:00
yml2213 8c058d71b1 拆分工单事件映射 2026-08-21 18:21:04 +08:00
yml2213 1e16b42c7d 拆分工单管理仓储 2026-08-21 18:19:32 +08:00
yml2213 46a001c176 收敛工单超时查询 2026-08-21 18:15:07 +08:00
yml2213 d5daf7f5dd 拆分工单押金查询 2026-08-21 18:14:20 +08:00
yml2213 a491a281ea 拆分工单查询仓储 2026-08-21 18:13:28 +08:00
yml2213 5a195e2150 拆分打手排名仓储 2026-08-21 18:11:21 +08:00
yml2213 ec29469fcb 拆分打手资金仓储 2026-08-21 18:09:42 +08:00
yml2213 656bb5a2ce 拆分打手钱包仓储 2026-08-21 18:04:11 +08:00
yml2213 a00e2d8280 拆分打手等级仓储 2026-08-21 17:54:13 +08:00
yml2213 01bfb53011 拆分打手会话仓储 2026-08-21 17:53:17 +08:00
yml2213 fa0cc7772c 拆分打手平台参数校验 2026-08-21 17:51:30 +08:00
yml2213 a954bf7d3d 拆分打手输入规范化 2026-08-21 17:48:28 +08:00
yml2213 d807012500 收敛后台工单服务入口 2026-08-21 17:45:50 +08:00
yml2213 900c45c88f 拆分后台撤单服务 2026-08-21 17:36:17 +08:00
yml2213 2856d2a313 拆分后台工单押金服务 2026-08-21 17:32:58 +08:00
yml2213 5af2dd08e0 拆分后台工单指派服务 2026-08-21 17:31:39 +08:00
yml2213 2b79034a98 拆分后台大厅汇总服务 2026-08-21 17:25:12 +08:00
yml2213 49f99e52f2 拆分后台工单验收服务 2026-08-21 17:23:51 +08:00
yml2213 499293c7cf 拆分后台工单管理服务 2026-08-21 17:22:13 +08:00
yml2213 eeedb4a4da 拆分后台模拟工单服务 2026-08-21 17:19:04 +08:00
yml2213 0031df580a 拆分后台工单资料服务 2026-08-21 17:17:06 +08:00
yml2213 12e789affc 拆分后台拼单配置服务 2026-08-21 17:12:46 +08:00
yml2213 15379b20b9 拆分后台撤单审核服务 2026-08-21 17:10:26 +08:00
yml2213 91cd4cf280 拆分后台问题反馈服务 2026-08-21 17:06:25 +08:00
yml2213 ce40997447 拆分后台工单查询服务 2026-08-21 17:05:12 +08:00
yml2213 18d690ab9b 拆分后台打手账号服务 2026-08-21 17:03:53 +08:00
yml2213 3ebfb0991a 拆分打手大厅操作服务 2026-08-21 17:02:31 +08:00
yml2213 e3089e20a6 拆分打手个人订单服务 2026-08-21 17:01:03 +08:00
yml2213 47c25664e4 拆分打手验收服务 2026-08-21 16:58:49 +08:00
yml2213 19bdb0cdad 拆分资料收集服务 2026-08-21 16:54:07 +08:00
yml2213 b1077ce6ef 拆分打手订单基础服务 2026-08-21 16:52:32 +08:00
yml2213 1076addead 拆分打手大厅服务 2026-08-21 16:49:58 +08:00
yml2213 8388d6e875 拆分打手资料资金服务 2026-08-21 16:48:52 +08:00
yml2213 27e9a574e4 拆分打手登录服务 2026-08-21 16:44:56 +08:00
yml2213 cc8c95bb58 拆分打手注册服务 2026-08-21 16:42:04 +08:00
yml2213 dae32504c7 拆分打手短信服务 2026-08-21 16:39:57 +08:00
yml2213 f0d1da4e1a 拆分打手会话服务 2026-08-21 16:37:16 +08:00
yml2213 3b7322954a 拆分打手认证规则 2026-08-21 16:32:15 +08:00
yml2213 8dcd47530e 拆分接单平台共享服务 2026-08-21 16:30:55 +08:00
yml2213 1c989072a8 拆分接单平台规则 2026-08-21 16:28:16 +08:00
yml2213 4e8fb433ef 拆分打手订单列表 2026-08-21 16:18:48 +08:00
yml2213 a37f07b788 拆分打手订单展示 2026-08-21 16:08:24 +08:00
yml2213 b7aa20f0ad 拆分打手个人中心 2026-08-21 16:00:00 +08:00
yml2213 41f353b1ba 拆分打手平台通用模块 2026-08-21 15:28:20 +08:00
yml2213 798ea583f8 拆分打手平台售后与订单模块 2026-08-21 15:17:28 +08:00
yml2213 f4d32a7c07 增加售后相关的 2026-08-21 14:30:00 +08:00
yml2213 874b093a4b 新增大厅订单容量控制 2026-08-21 14:02:30 +08:00
yml2213 cc03382921 优化手机端 2026-08-21 13:01:54 +08:00
yml2213 b2a1ac3db1 优化资金审核与流水展示 2026-08-21 12:54:44 +08:00
yml2213 1090bd691b 隐藏大厅加载完成提示 2026-08-21 12:43:41 +08:00
yml2213 c3bd6daee2 优化本地Mock接单 2026-08-21 12:39:24 +08:00
yml2213 e6ab139071 修复容器测试并补订单回归 2026-08-21 12:29:19 +08:00
yml2213 993ddcd034 优化订单打手信息展示 2026-08-21 11:49:42 +08:00
yml2213 db2a401e33 修复打手订单筛选查询 2026-08-21 11:36:51 +08:00
yml2213 2fbfaa0143 展示打手提现统计 2026-08-21 00:05:04 +08:00
yml2213 361b84931f 完善支付宝提现收款码 2026-08-21 00:00:05 +08:00
yml2213 81a412323b 优化工单资料与审核展示 2026-08-20 23:42:13 +08:00
yml2213 b5d46b2381 全项目数据库性能优化 2026-08-20 23:04:52 +08:00
yml2213 1c8eda2b5f 测试修复-2 2026-08-20 22:48:03 +08:00
yml2213 92afc32525 测试修复-1 2026-08-20 22:45:19 +08:00
yml2213 942cbcab68 完善账号冻结与VIP免审核 2026-08-20 20:42:03 +08:00
yml2213 e05a6797a3 新增接单模板价格同步 2026-08-20 20:24:00 +08:00
yml2213 6539e0fb21 新增打手端滚动公告 2026-08-20 20:04:43 +08:00
yml2213 20b1066ba3 新增打手问题反馈 2026-08-20 19:56:10 +08:00
yml2213 e504b3a207 新增打手撤单审核 2026-08-20 19:43:03 +08:00
yml2213 d76d1e8d90 允许客服处理问题单 2026-08-20 18:05:38 +08:00
yml2213 39aeb0baaa 支持待验收工单按VIP等级筛选 2026-08-20 18:02:13 +08:00
yml2213 3587e44261 增加取消订单重新可以发布 2026-08-20 17:46:53 +08:00
yml2213 543bc7f789 修复工单打手筛选搜索 2026-08-20 17:34:39 +08:00
yml2213 d2512ccb6b 调整接单密码强度规则 2026-08-20 16:19:44 +08:00
yml2213 218ee1577e 修复短信登录手机号校验 2026-08-20 16:13:07 +08:00
yml2213 01a1f0cc93 支持已验收订单补图 2026-08-20 15:17:36 +08:00
yml2213 106992e7c5 修复撤单 造成打手已超时订单问题 2026-08-20 15:03:21 +08:00
yml2213 555a7d43a7 增加“验证码登录”模式 2026-08-20 14:46:53 +08:00
yml2213 757eee9742 登录界面增加忘记密码. 后台增加密码重置 2026-08-20 14:40:01 +08:00
yml2213 3546036b43 客服增加电子凭证冲正 2026-08-20 11:32:21 +08:00
yml2213 50f70d6920 调整拼单完成单数统计 2026-08-20 10:12:39 +08:00
yml2213 12ca943170 优化打手注册和列表筛选 2026-08-20 09:52:51 +08:00
yml2213 f1575aec85 修复拼单工单打手展示 2026-08-19 19:31:55 +08:00
yml2213 df90b67d8e v5拼单免审核 2026-08-19 19:27:23 +08:00
yml2213 4ddc4021a2 修复刷新测试-1 2026-08-19 19:08:13 +08:00
yml2213 a24f2721e6 修复接单模板无法新增 2026-08-19 18:58:54 +08:00
yml2213 7b92d6b219 增加每日接单排行榜 2026-08-19 18:55:04 +08:00
yml2213 ca34187b0b 修复订单详情抽屉内容溢出 2026-08-19 18:49:15 +08:00
yml2213 1bea34367b 修复更新资料未修改接单价格 2026-08-19 18:42:34 +08:00
yml2213 ab6387759e 修复拼单明细并支持参与者撤单 2026-08-19 14:35:23 +08:00
yml2213 4b1dcd47ea 修复后台登录失效自动跳转 2026-08-18 18:45:57 +08:00
yml2213 323b9ea34c 增加后台会话撤销并统一拼单业务状态 2026-08-18 18:36:48 +08:00
yml2213 4867861b8b 保证接单注册事务一致性 2026-08-18 18:17:42 +08:00
yml2213 b2eaf3b6c7 修复接单注册审核与拼单状态校验 2026-08-18 18:16:48 +08:00
yml2213 ee79195f47 完善拼单状态与份额验收流程 2026-08-18 18:02:43 +08:00
yml2213 052b99b05f feat(admin): 重构接单模板与商品绑定内联编辑 UI 布局
- 优化接单模板与模板列表 6:4 比例自适应布局
- 适用商品绑定支持左侧直接内联展开编辑,免弹窗模式
- 支持绑定规则多标签快速切换、实时新增与删除
- 基础表单与商品绑定统一采用 3 列网格布局,严格几何对齐
- 「启用拼单」开关及参数行内紧凑展开,移除多余背景框与纵向滚动条
2026-08-18 16:55:58 +08:00
yml2213 be6f4f2507 修复拼单默认自动匹配 2026-08-18 15:24:30 +08:00
yml2213 cbfc9bff03 修复映射迁移重复执行 2026-08-18 15:10:03 +08:00
yml2213 d57c05ec7e 商品映射列表增加分页 2026-08-18 15:06:40 +08:00
yml2213 cf874b5429 优化模板匹配展示并修复映射迁移 2026-08-18 15:04:42 +08:00
yml2213 7ccf3a6cee 优化按件拼单自动计算 2026-08-18 14:37:13 +08:00
yml2213 c2305d419e 关闭旧商品匹配默认回退 2026-08-18 13:50:56 +08:00
yml2213 79990066f0 优化接单模板匹配 2026-08-18 13:37:32 +08:00
yml2213 9ebc027b96 优化商品匹配配置 2026-08-18 13:01:12 +08:00
yml2213 f2c95bc3c8 优化接单商品匹配 2026-08-18 12:05:12 +08:00
yml2213 41b39bc5a0 源订单改成时间 2026-08-18 10:10:01 +08:00
yml2213 1617c5e999 脱敏排行榜昵称 2026-08-18 10:07:42 +08:00
yml2213 c6e9a9b962 优化快手接单匹配 2026-08-18 10:06:01 +08:00
yml2213 fe569fe75a 限制客服接单平台可见内容 2026-08-18 09:09:50 +08:00
yml2213 22f73fefb7 开放客服生成接单工单权限 2026-08-18 09:04:16 +08:00
yml2213 a08e4c18d7 优化新字的颜色 2026-08-17 20:31:10 +08:00
yml2213 370acdba60 增加工单首次查看标识 2026-08-17 20:11:35 +08:00
yml2213 1b4bdec56d 增强工单游戏资料搜索 2026-08-17 19:26:31 +08:00
yml2213 2aa8769661 改造实时事件缓存同步 2026-08-17 17:32:02 +08:00
yml2213 be7c124d65 优化物品规则分页加载 2026-08-17 16:20:54 +08:00
yml2213 ecac7b63db 增加接单工单筛选统计 2026-08-17 15:49:47 +08:00
yml2213 f938ab0cba 增加打手登录设备管理与三台设备限制 2026-08-17 15:26:40 +08:00
yml2213 8d18daa0fe 增加打手密码强度校验 2026-08-17 15:02:33 +08:00
yml2213 18dad9b0fe 增加订单实时状态同步 2026-08-17 14:51:51 +08:00
yml2213 5330b55b75 完善接单平台通知配置 2026-08-17 13:54:01 +08:00
yml2213 bca2e4a801 优化打手订单结算和大厅加载 2026-08-17 13:06:40 +08:00
yml2213 1d9e102527 增加待补图订单筛选 2026-08-17 12:43:22 +08:00
yml2213 baf09dc533 支持VIP5免审核后补图 2026-08-17 12:29:34 +08:00
yml2213 2881a99040 规则编辑左侧固定 2026-08-17 11:43:00 +08:00
yml2213 77ed97fe1f 支持后台修改打手提现信息 2026-08-17 11:28:23 +08:00
yml2213 ea5c2a4105 优化打手提现审核流程 2026-08-17 11:18:14 +08:00
yml2213 9ed2e0d4fe 打手端流转记录改为后台配置开关控制,默认关闭
- 新增 WORKER_ORDER_TIMELINE_VISIBLE 配置(默认 false):打手端订单详情不展示流转记录
- 打手端 events 接口按开关拦截(关闭时 403)
- profile 接口下发 orderTimelineVisible 供前端判断
- 管理端流转记录不受影响,始终可见
2026-08-16 19:42:16 +08:00
yml2213 950c4fd578 接单工单新增流转记录(订单详情时间线)
- 后端:新增工单事件查询接口(管理端 + 打手端),事件带操作人显示名(管理员用户名 / 打手昵称 / 系统)
- 补齐关键节点事件:抢单接手(grabbed)、验收通过(accepted 带操作人)
- 管理端/打手端事件写入操作人(admin session username 贯通至事件)
- 前端:订单详情新增「流转记录」时间线,按订单生命周期节点展示(创建/发单/接手/资料/验收/问题/取消),时间+操作人+状态流转
2026-08-16 19:25:20 +08:00
yml2213 81ae04dae0 接入 ESLint 并清理未使用代码
- 前后端接入 ESLint 10 扁平配置(typescript-eslint 类型感知检查 + react-hooks/react-refresh),新增 lint / lint:check / lint:fix 脚本并纳入 check 链路
- 清理全部 no-unused-vars 与 no-useless-assignment 警告,修复 floating promises、正则转义等问题
- 删除确认无引用的死代码(markKuaishouCloudBindUrlRefreshFailed、未使用 helper 等)
2026-08-16 18:04:55 +08:00
yml2213 5c7c3e14e3 统一前后端代码格式化配置 2026-08-16 17:27:12 +08:00
yml2213 8705f6a6a1 指派工单时自动核销电子凭证 2026-08-16 16:59:02 +08:00
yml2213 e641064b63 发布工单时自动核销电子凭证 2026-08-16 16:43:32 +08:00
yml2213 bc2909d6f2 打手端迁移至独立子域名 2026-08-16 16:08:38 +08:00
yml2213 8d07b06d52 接单工单支持置顶,抢单大厅优先展示
- 新增 work_orders.pinned_at 字段与迁移
- 管理端对已发布(待抢单)订单提供置顶/取消置顶操作,置顶订单带标记
- 抢单大厅按置顶时间优先排序,打手端卡片展示置顶标签
2026-08-16 15:57:25 +08:00
yml2213 737f9ddcbe 接单工单状态筛选支持多选
- 前端:状态筛选改为多选(Select mode=multiple),可一次筛选多个状态
- 后端:listWorkOrders 支持 statuses 数组查询(ANY 匹配),兼容旧单状态参数
- 批量验收入口仅在单独筛选待验收时显示,避免多选误操作
2026-08-16 15:48:15 +08:00
yml2213 31f134579c 已移除排行榜中的 VIP 等级 2026-08-16 15:29:32 +08:00
yml2213 75146e919d 修复VIP自动成长降级 2026-08-16 14:54:43 +08:00
yml2213 c4e7e67115 优化拖拽 2026-08-16 14:38:10 +08:00
yml2213 4dd94cde27 优化打手订单与界面 2026-08-16 13:52:45 +08:00
yml2213 8a5ba4c2f3 优化接单大厅数量和排行榜 2026-08-16 13:36:16 +08:00
yml2213 27bcb7dd68 fix: 修复移动端我的订单卡片与搜索栏Flex样式异常 2026-08-16 11:14:33 +08:00
yml2213 0270657f2b feat: 优化移动端打手接单界面布局并完成CSS架构模块化拆分 2026-08-16 11:03:19 +08:00
yml2213 005b159215 优化我的界面 2026-08-16 10:43:22 +08:00
yml2213 10ae3d01ee 初步优化手机端ui 2026-08-16 10:33:45 +08:00
yml2213 ad84388450 优化打手接单上限统计 2026-08-16 10:06:12 +08:00
yml2213 b9e4d007aa 优化撤单处理方式 2026-08-15 17:41:01 +08:00
yml2213 57fda860dc 支持更新代练中订单价格 2026-08-15 17:31:42 +08:00
yml2213 00491daf5b 增加管理员联系二维码 2026-08-15 16:24:36 +08:00
yml2213 521cbe9d38 优化订单状态颜色展示 2026-08-15 16:04:36 +08:00
yml2213 1756cfb262 支持按接单人员筛选订单 2026-08-15 16:02:05 +08:00
yml2213 54551a1aeb 物品规则ui优化 2026-08-15 15:48:25 +08:00
yml2213 bdb6bb8200 支持物品规则分类筛选 2026-08-15 15:33:25 +08:00
yml2213 b017efe783 支持客服下架和撤单 2026-08-15 15:27:40 +08:00
yml2213 cd3b48eef5 优化下架和撤单功能 2026-08-15 15:25:56 +08:00
yml2213 3b562b4824 复制订单信息包含物品 2026-08-15 15:15:06 +08:00
yml2213 3a29226077 限制验收图片列表展示 2026-08-15 15:10:39 +08:00
yml2213 4eb9aa2cd8 支持批量验收接单订单 2026-08-15 15:07:31 +08:00
yml2213 87d8360e64 支持代练中更新订单资料 2026-08-15 15:03:08 +08:00
yml2213 edccf03eb0 统一展示平台订单号 2026-08-15 14:54:56 +08:00
yml2213 d433518038 修复验收图片多选暂存 2026-08-15 14:49:48 +08:00
yml2213 190445319b fix: 优化验收操作和图片去重 2026-08-15 14:27:12 +08:00
yml2213 55a658b4fe fix: 限制客服编辑资料字段 2026-08-15 14:22:47 +08:00
yml2213 112a3272d2 feat: 增加打手工单备注 2026-08-15 14:22:13 +08:00
yml2213 af323af48e feat: 支持验收图片暂存 2026-08-15 14:20:58 +08:00
yml2213 21211bd760 feat: 支持取消指派打手,可重新指派他人
- 后台可取消进行中订单的指派:订单回未分配,冻结押金全额退还,记录事件与审计
- 工单列表新增「取消指派」按钮(带确认弹窗),客服/管理员/运营可用
- 取消后可重新指派给其他打手
2026-08-15 13:48:32 +08:00
yml2213 d44c6039ea feat: 物品规则页左右布局,接单平台统计条精简
- 物品规则改为左右布局:左侧编辑/新建表单(加宽、一屏展示无滚动条),右侧可滚动规则列表
- 列表顶部新增搜索(规则标识/商品/SKU/店铺),点击行快速载入编辑,当前行高亮
- 接单平台顶部统计改为单行紧凑统计条,减少页面高度
2026-08-15 11:28:52 +08:00
yml2213 1131780186 feat: 物品规则接单金额可填0并限制零元发布,客服增加发单指派权限
- 物品规则接单金额允许填 0(按数量计价不受影响)
- 接单金额为 0 的工单禁止发布:前端发布按钮置灰 + 后端 409 拦截
- 客服(support)获得发布工单与指派打手权限
2026-08-15 11:16:26 +08:00
yml2213 57ceac7143 feat: 打手验收图片上限提升到 20 张 2026-08-15 10:11:26 +08:00
yml2213 ecdbacd745 feat: 客服可修改工单,商品名双击编辑,订单列显示来源店铺
- 工单编辑接口放开 support 角色,客服可双击改发单金额/押金及商品名
- 订单列商品名支持双击原地修改(单击看详情不变)
- 订单列新增来源/店铺展示;Mock 工单补充店铺信息并回填存量数据
2026-08-15 10:07:32 +08:00
yml2213 f078c75378 feat: 人工补充资料的系统字段改为选填
系统(安卓/苹果)与区服均改为选填,仅游戏编号、游戏昵称必填;
同步更新默认字段模板单元测试
2026-08-15 09:55:47 +08:00
yml2213 b87886c688 fix: 按数量计价模式无需填写接单金额
选择按数量×单价时,接单金额可留空(建单时按 单价×SKU文字数量 计算总价);
仅当接单金额和单价都为空时才拦截
2026-08-15 09:53:26 +08:00
yml2213 f2edb7f895 feat: 物品规则支持按数量计价(二次匹配SKU文字数量)
- 规则新增单价(unit_price_fen,0 关闭),开启后接单总价 = 单价 × 商品名称文字中的规格数量
- 新增 resolveSkuNameQuantity:支持阿拉伯数字与中文数字(如 指挥官秘钥15个 → 15、三个 → 3)
- 建单同步时按文字数量计算奖励,skuQuantity 记入工单 material.source 便于核对
- 规则表单新增计价方式(固定金额/按数量×单价),列表展示单价
- 新增 3 个数量解析单元测试(269 全通过)
2026-08-14 18:25:06 +08:00
yml2213 f2e08a4fbc feat: 订单资料图片完整展示与打手快捷复制
- 修复资料截图 URL 过期问题:读取工单时统一重签截图访问链接(后台/打手端/收集页)
- 修复后台手动补资料截图从未保存的问题(提交真实上传列表)
- 收集页提交后回显已上传截图,补资料弹窗重开回显已有截图
- 打手端我的订单新增快捷复制:复制信息(多行标签:值)+ 一键复制图片(JPEG 自动转 PNG)
- 快捷复制列与后台资料列同款展示:全字段逐行 + 截图缩略图
- 后台接单工单资料列优化:显示全部字段与截图缩略图
- 详情抽屉移除冗余的资料要求卡片
2026-08-14 18:13:21 +08:00
yml2213 85fa8f11fd feat: 接单工单金额双击编辑与资料粘贴识别
- 表格双击发单金额/押金原地编辑,自动聚焦全选,Esc 取消
- 发单金额高于用户付款金额时保存前弹确认提示(编辑弹窗同步)
- 亏损单(发单金额>付款金额)在列表中红色加粗显示,便于定位
- Mock 工单支持填写用户付款金额(留空默认同接单金额),便于本地测试
- 人工补充资料支持客服消息剪贴板粘贴自动识别填充(系统/区服/角色名称/游戏编号,区服选填有则识别)
2026-08-14 17:34:13 +08:00
yml2213 7bb3142958 feat: 打手分内部/外部,内部免审核;支持后台指派打手
- worker_users 新增 worker_type(internal/external,迁移 017)
- 内部打手提交验收后自动通过进入待打款(accepted+结算),外部打手保持后台审核
- 打手管理页支持设为内部/外部并显示类型标签
- 新增后台指派打手:未分配/待抢单工单可指定已注册打手,进入其我的订单并冻结押金
- 指派带审计与事件记录,校验打手状态/接单上限/余额押金
2026-08-14 16:35:15 +08:00
yml2213 6a17113374 feat: 打手支持昵称登录,物品规则标识唯一校验
- 昵称全局唯一(迁移 016 唯一索引 + 注册校验),存量空昵称自动补账号名
- 登录解析链扩展为 账号/手机号/昵称,均可加密码登录
- 登录页提示更新为「手机号 / 昵称 / 账号」,注册页提示昵称可作登录账号
- 新建物品规则时校验规则标识唯一:已存在则拒绝(409),编辑不受影响
- 前端规则表单增加规则标识重复的即时校验
2026-08-14 16:16:49 +08:00
yml2213 88b205f4fd feat: 优化接单工单资料补充体验
- 区服改为非必填,保持 QQ区/微信区 单选;游戏编号/昵称保持输入框,系统保持安卓/苹果单选
- 后台工单 API 归一化输出资料字段,后台人工补充资料弹窗与收集页字段一致
- 人工补充资料弹窗单选字段由下拉框改为直接点选(Radio)
- 买家主页截图支持全局剪贴板粘贴(Ctrl+V/⌘V),并提示点击/粘贴/拖拽三种上传方式
2026-08-14 16:05:55 +08:00
yml2213 951d0574b1 优化接单工单筛选布局 2026-08-14 15:40:17 +08:00
yml2213 78d6c2f8a1 优化电子凭证操作与测试数据 2026-08-14 15:14:34 +08:00
yml2213 dffe32e84a feat: 冻结/拒绝的打手账号禁止登录
- loginWorker 仅允许 active 状态登录,rejected(冻结)/pending_review/disabled 均拦截并返回对应错误码
- verifyWorkerSessionToken 对非 active 状态直接失效会话,冻结后已登录会话立即退出
- 补充 assertWorkerLoginAllowed 四种状态的单元测试
2026-08-14 15:14:26 +08:00
yml2213 da1f2469bd 调整接单工单金额展示 2026-08-11 16:56:10 +08:00
yml2213 40be8103fc 优化快手发码接单数据 2026-08-11 14:30:19 +08:00
yml2213 e097ddad3d 支持领取链接长期有效 2026-08-11 14:06:29 +08:00
yml2213 e72c936986 优化履约路由回退策略 2026-08-11 11:38:47 +08:00
yml2213 3d747aa5aa 补全历史履约通道优先级 2026-08-11 11:00:11 +08:00
yml2213 c391122c67 修复履约任务创建筛选 2026-08-11 10:15:29 +08:00
yml2213 49705f2774 优化 affiliate-dash 商户单号 2026-08-11 09:48:05 +08:00
yml2213 5885720c36 解耦接单与履约订单处理 2026-08-10 18:23:41 +08:00
yml2213 a7de443cb9 优化 不在频繁报错 试 91 订单失败 2026-08-10 16:51:45 +08:00
yml2213 270e3ca89a 关闭 send-code 临时抓包日志 2026-08-10 15:59:27 +08:00
yml2213 16f9531b40 修复接单物品规则管理 2026-08-10 15:53:11 +08:00
yml2213 bcde47ef97 彻底修复 lewan 自动发货问题-8-8-2 2026-08-08 23:43:29 +08:00
yml2213 6e41d7e0db 彻底修复 lewan 自动发货问题-8-8 2026-08-08 23:08:05 +08:00
yml2213 7196752d44 失效号码清理下沉到刷新流程:所有触发路径(claim轮询/91交付/回收/后台)遇无权限号码统一清空绑定,不再反复打上游 2026-08-08 15:14:59 +08:00
yml2213 5a1c5a3759 回收遇无权限号码直接清空任务绑定不再重试:已手动退号的旧绑定一次性收敛,发货收尾视号码已释放 2026-08-08 14:59:23 +08:00
yml2213 2b3954d0e3 修复履约路由总开关关闭后仍回退默认配置自动发货:总开关关闭即停止自动履约 2026-08-08 14:56:28 +08:00
yml2213 23b3711e64 平台配置 kuaishou-lewan 调试区新增虚拟号列表查看与手动退号 2026-08-08 14:30:43 +08:00
yml2213 6d0534296c 修复取号账号与退号账号混用:操作已有号码严格锁定取号账号,禁止静默 fallback 到其他账号 2026-08-08 14:23:11 +08:00
yml2213 e1a0a74477 优化孤儿号码回收:权限不足重试3次后停止打上游,1小时后自动重置,health 摘要展示孤儿数量 2026-08-08 02:38:01 +08:00
yml2213 995de86322 修复 lewan 虚拟号配额被占满:过期绑定自动回收、退号失败不再追加取号、取号占满增加冷却 2026-08-08 02:15:13 +08:00
yml2213 84a4c6c627 修复 kuaishou-lewan 登录验证后未保存的新账号被服务端数据覆盖丢失 2026-08-08 02:02:31 +08:00
yml2213 3a2147e94b 优化 affiliate-dash 成功后绑定快照 2026-08-07 12:14:53 +08:00
yml2213 dc3fd63b04 修复 affiliate-dash 绑定状态一致性 2026-08-07 11:40:28 +08:00
yml2213 17acd65f28 修复 affiliate-dash 重复绑定与提交前校验:UID 未变复用绑定防重复生成二维码、提交前强制刷新绑定状态、UID 仅允许纯数字且限 15 位 2026-08-06 23:46:02 +08:00
yml2213 8756a196ae 移除 affiliate-dash 不更换二维码测试 2026-08-05 22:40:22 +08:00
yml2213 8e11d80496 修复 affiliate-dash 提交前绑定校验 2026-08-05 22:25:57 +08:00
yml2213 848f65319f 增加一个不更换二维码测试 2026-08-05 22:06:07 +08:00
yml2213 c91dd4d144 优化领取页绑定 UID 校验 2026-08-05 21:19:09 +08:00
yml2213 0deb498a05 修复 affiliate-dash 绑定不一致时仍显示第 3 步 + 更新 UID 后旧绑定信息残留
- 前端:标题/副文案改为 bound && !bindMismatch 才显示第 3 步,不一致时保持第 2 步并引导重新绑定
- 后端:bindAffiliateDashClaimForTask 重新绑定时清空 bound/boundAccount/bindMismatch,避免旧账号残留导致误报不一致
2026-08-05 20:50:03 +08:00
yml2213 0933b4514f affiliate-dash 绑定 UID 强制校验:绑定账号与第一步填写 UID 不一致时禁止提交
- bind 时记录 expectedGameAccount(第一步 UID 权威值)
- 轮询 bind-result 时本地对比 boundAccount 与 expectedGameAccount(去空白),不依赖平台 mismatch 字段
- submit 前实时拉 bind-result 二次校验,不一致 409 拒绝(前端无法绕过)
- 前端:bindMismatch 时不显示提交发货按钮,提示核对后重新绑定
- 拉取失败不阻塞轮询与提交(平台侧提交仍会校验)
2026-08-05 20:39:13 +08:00
yml2213 f455f853b1 affiliate-dash 结果页:移除渠道字段,结果时间改用 task.redeemedAt(原 flow.dispatch.dispatchAt 非本流程字段显示为空) 2026-08-05 20:24:16 +08:00
yml2213 541b458040 修复 affiliate-dash 提交成功(delivered)后结果页数秒后才显示成功
- syncAffiliateDashTaskStatus 新增 snapshotOnly:submit 拿到 delivered 后直接用本地快照收敛任务为 REDEEMED,不等平台回调(此前要等 delivered notify 落地,约 9 秒)
- 新增终态保护:已 REDEEMED/MANUAL_REVIEW/CLOSED/FAILED 的任务,非 delivered 状态不再降级(轮询时平台详情短暂返回 paid/delivering 会把成功任务打回 REDEEMING)
- submitAffiliateDashClaim:result.status=delivered 时重新读任务并 snapshotOnly 收敛
2026-08-05 20:20:10 +08:00
yml2213 e8eab6e689 fix(kuaishou-industry): 修复电子凭证发货回调并发导致 807000 获取并发锁失败
同一 oid 的发货回调可能被多个来源并发调度(内存重试链 timer、worker_scan
兜底扫描、快手重复推送 send-code 后的再次调度),快手侧对同一 oid 有并发锁,
并发请求会返回 807000「获取并发锁失败」。

- runKuaishouIndustrySendCallbackForOid 增加进程内 in-flight 互斥,
  正在执行时其它调度直接跳过,由当前执行链路决定成功或按 backoff 重试
  (try/finally 保证所有 return 路径释放)
- listKuaishouIndustryVouchersForSendCallbackRetry 增加 maxAttempts 参数
  及 SQL 条件 COALESCE(send_callback_attempt_count,0) < ,缩小扫描集
2026-08-05 20:05:43 +08:00
yml2213 4e9c416235 affiliate-dash 领取界面优化(合并多次迭代为一次提交)
- 第 2 步绑定页:移除金额/单号/商品信息区,移除标题上方 affiliate-dash 标签
- 二维码区参考旧设计:左右竖排红字 Q区用Q扫/V区用V扫,去浅蓝背景,放大至 220px
- 第 4 步结果页:废弃 canvas 生成发货成功图,改用旧版结果卡片(绿勾+兑换成功+信息网格+商品明细)
- 开发 Mock:新增 affiliate-dash 领取五步 mock(uid/bind/submitted/completed/failed,不调真实平台)
2026-08-05 19:39:15 +08:00
yml2213 316ee6d135 履约配置页 affiliate-dash 覆盖收尾:preferredMatchEnabled 开关补入平台配置 UI
- 分析结论:履约路由面板已含 affiliate_dash(开关/优先级/规则),职责划分成立
- 缺口修复:指定匹配优先开关此前无 UI 入口,现平台配置 affiliate-dash tab 增加 Switch
- 后端 effective 透出 preferredMatchEnabled;前端类型/panel 补齐
- 前后端 typecheck 通过,224 测试全绿
2026-08-05 17:52:14 +08:00
yml2213 5f5a1a8bcf 重构:affiliate-dash 平台配置从履约配置页移至平台配置页
- AdminPlatformShopsPage 新增 affiliateDash tab + AffiliateDashPlatformPanel(密钥/钱包/商品/映射/测试)
- AdminPlatformFulfillmentPage 移除 affiliate-dash tab 与组件
- 后端与 API 路径不变(本就挂 /platform-config),纯前端 UI 归位
- 前端 tsc -b 通过
2026-08-05 17:45:44 +08:00
yml2213 0f81380d9c 新增透传:91 编码与 affiliate_dash sku 一致时自动命中
- resolveAffiliateDashSkuByProductNo 改 async,匹配两级:①指定映射(skuMapping 归一命中) ②透传(productNo == affiliate_dash 商品 sku)
- 商品列表内存缓存 5 分钟,拉取失败返回上次缓存/空集合,匹配 miss 走其他通道不阻塞下单
- 两层命中均触发指定匹配最高优先(preferredExecutorKey)
- 测试 +3(224 全绿);线上验证 suit_alan_walker/suit_cloud_bear 透传命中,乱码回退
2026-08-05 17:40:13 +08:00
yml2213 769d41655a 优化履约匹配:指定匹配(91中文名→sku映射)最高优先 + 映射键归一化
- 91 商品编码全部为中文名(幸运币90个等),运营按中文名→affiliate_dash sku 配置显式映射
- resolveFulfillmentRoute 新增 preferredExecutorKey:映射命中强制优先(规则之前),停用/不可用回退原逻辑
- matchAffiliateDashSku 归一化:NFKC 全角→半角、去空白、小写,先精确再遍历
- preferredMatchEnabled 配置(默认 true,可一键回退,不耽误现有 cloud/feifei 生产)
- 测试 +9(221 全绿);行为验证:映射命中/全角/空格命中,未映射回退
2026-08-05 16:44:37 +08:00
yml2213 5f5506bb19 docs: 履约匹配逻辑深度分析(§21 痛点 P1-P8 + 优化建议分档)
- 匹配链路全貌:91 item → 三路匹配 → 路由(规则/优先级/manual_review) → planner 动态 profile
- 8 个痛点:P1 精确映射排在名字模糊匹配之后(可信度倒挂)、P2 productNo 未拆 ---- 后缀、P3 映射键无归一化、P4 缺名字兜底、P5 余额不参与路由、P6 双轨配置冲突、P7 重复计算、P8 命名语义误导
- 优化建议分档:快赢档 P1-P3 / 中档 P4 / 后档 P5-P8;按用户指示先分析不改代码
2026-08-05 16:14:35 +08:00
yml2213 7fc3d60588 新增 affiliate_dash 回调路由与幂等去重(阶段 5)
- POST /api/v1/open/affiliate-dash:验签 → 查任务(404 重试) → webhook_events 幂等闸门 → 状态同步
- migration 013:webhook_events(provider+event_id 唯一)
- task-repo findAffiliateDashTaskByOrder(仿 feifei 同构 SQL)
- notify-service:去重闸门在找到任务之后,任务未匹配不被 eventId 永久拦截
- typecheck + 212 测试通过;前置错误分支脚本验证通过
2026-08-05 16:07:33 +08:00
yml2213 00d490a2cd 优化 affiliate_dash 核销时机:建单成功即核销(参考 feifei 给用户链接即核销)
- prepareAffiliateDashTask 建单成功(91 卡信息到手、链接可发放)立即核销行业电子凭证
- 核销失败不阻塞下单与链接发放(consumeStatus=failed, delivered 回调兜底补核销)
- sync delivered 分支 alreadyConsumed 短路,避免重复核销
- typecheck + 212 测试通过
2026-08-05 16:02:44 +08:00
yml2213 5ef1ed6018 新增 affiliate_dash 统一领取页流程(阶段 4)
- 后端:claim 详情分流 affiliate_dash 分支 + 详情轮询刷新 bind-result
- 输 UID 触发 bind(拿 bindUuid/二维码, task→waiting_binding)
- 新增 POST /claim/:token/affiliate-dash/submit(submit→redeeming + 事件)
- 前端:ClaimAffiliateDashSteps(二维码/绑定状态/mismatch警告/提交发货)
- claim-snapshot/claim-poll 按 flowType 分流(step2 绑定中/step3 已绑定待提交/step4 结果)
- 前后端 typecheck + 后端 212 测试通过
2026-08-05 15:56:51 +08:00
yml2213 3284fed7a1 新增 affiliate_dash 履约配置与商品映射(阶段 3)
- bootstrap CORE_PROFILES + routing ROUTABLE/DEFAULT_PRIORITY 接入 affiliate_dash
- planner 上下文 affiliateDash 块 + resolveDynamicAffiliateDashProfile
- product-resolution: 91 productNo→sku 映射匹配(skuMapping) + snapshot.affiliateDash
- prepare 前余额预检(getAffiliateDashWallet)
- AFFILIATE_DASH_SKU_MAPPING_JSON env 注入
- admin 后端接口(GET/POST 配置、match、products、wallet)+ 前端 Tabs 面板
- 联调:模拟 91 进单命中 affiliate_dash 路由, 未命中回退;前后端 typecheck + 212 测试通过
2026-08-05 15:08:41 +08:00
yml2213 867ffd2f3c 新增 affiliate_dash 履约 executor(阶段 2)
- executor 常量/守卫 + affiliate-dash-executor(preparePaidTask/resolveDeliveryLink)
- 履约业务实现:prepareAffiliateDashTask(幂等建单, client_order_no=task_no, data 透传 91单号/game_account)
  + syncAffiliateDashTaskStatus(状态合并: delivering→redeeming / delivered→核销→redeemed /
  ship_failed→retry_pending / cancelled→closed)
- registry 注册 + 测试(212 全绿)
- 真实建单联调通过(线上 skin.khhao.com,幂等不重复扣款)
- 修复 POST /orders 响应 data.order 映射层级
2026-08-05 14:56:36 +08:00
yml2213 a6ba5f1c7f 新增 affiliate_dash 发货平台客户端与签名(阶段 1)
- 平台配置接入:runtime-config/types/defaults/env-overrides/app-config-keys 增加 affiliateDash
- affiliate-dash 平台客户端:请求签名(§3.1 app_key+body_sha256)、回调验签(§3.2 timingSafeEqual)、
  http-client 封装(超时/错误归一/全链路日志)、订单/交付/钱包/商品接口封装
- 单测 12/12 + 全量 211 通过,真实联调拉取线上商品 33 个验证签名链路
- 新增 affiliate_dash 对接文档 v2(含契约验证结论与深度分析)
2026-08-05 14:51:40 +08:00
625 changed files with 74776 additions and 18110 deletions
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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 | ossdual 仅在 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=
+25
View File
@@ -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] 本地检查通过"
+8
View File
@@ -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/
+8
View File
@@ -0,0 +1,8 @@
**/node_modules
**/dist
**/.vite
**/coverage
**/data
**/package-lock.json
**/.env*
@@ -4,3 +4,4 @@
"printWidth": 100,
"trailingComma": "all"
}
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["esbenp.prettier-vscode", "editorconfig.editorconfig"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"prettier.configPath": ".prettierrc.json"
}
+37 -5
View File
@@ -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、手机号或生产配置
+4 -1
View File
@@ -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` 复制后填写。
+49
View File
@@ -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',
},
},
)
+1464 -274
View File
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -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"
}
}
+105
View File
@@ -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 约束检查通过')
}
+8 -3
View File
@@ -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) {
+27 -13
View File
@@ -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&timestamp=${now}&signMethod=MD5&access_token=&method=&sign=${sign}&param=${encodeURIComponent(paramStr)}'`)
console.log(
` -d 'appkey=${appKey}&version=1&timestamp=${now}&signMethod=MD5&access_token=&method=&sign=${sign}&param=${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
+20 -14
View File
@@ -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('&') + `&param=${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
+1
View File
@@ -0,0 +1 @@
import '../src/storage-migrate.js'
+6 -4
View File
@@ -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)
}
+9 -7
View File
@@ -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
+31 -15
View File
@@ -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}`)
}
+20 -5
View File
@@ -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...`)
+40
View File
@@ -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')
}
})
+7
View File
@@ -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
+40 -2
View File
@@ -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',
+82
View File
@@ -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,
+36 -36
View File
@@ -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(
() => {
+120 -8
View File
@@ -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)
+10 -13
View File
@@ -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))
+202
View File
@@ -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',
)
})
+526
View File
@@ -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()
}
+45 -2
View File
@@ -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> {
+1 -3
View File
@@ -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()
}
},
)
+12 -3
View File
@@ -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 查询的业务判断', () => {
+2 -5
View File
@@ -259,10 +259,7 @@ export function isKuaishouCloudRedeemSettledStatus(status: unknown): boolean {
export function canRedeemKuaishouCloudClaimStatus(status: unknown): boolean {
const normalized = normalizeTaskStatus(status)
// 允许 WAITING_BINDINGUID 匹配后可一键确认+兑换
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