From 2599dce3153947279ec1cbfb0267c7345def107b Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Sat, 17 Oct 2026 18:00:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E4=B8=89=E7=AB=AF=E9=97=AD?= =?UTF-8?q?=E7=8E=AF=E5=8A=A0=E5=9B=BA=20+=20=E5=BE=AE=E4=BF=A1=E6=8F=90?= =?UTF-8?q?=E7=8E=B0=E6=89=93=E6=AC=BE/=E8=AE=A2=E9=98=85=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: 身份与归属鉴权收口; 资金安全(结算闸门/支付防篡改/报名费真实扣款/赠金/结算唯一入账/提现打款); 数据泄露与隐私; 登录与密钥安全(默认密钥闸门/登录限流); 可观测性; 启动自检. 微信: 商家转账到零钱提现打款闭环(含回调/查单/失败退回佣金); 订阅消息真实下发闭环与后台模板字段对齐. 后台: 财务/微信支付配置读取失败锁定; 看板/待办/走势去假数据; 打款与测试推送文案修正. 小程序: 请求层错误透传与登录过期提示; 多页失败反馈与乐观回滚; 帮助中心FAQ页面化. 测试: 新增 40+ 回归护栏与真实 MySQL 冒烟(75 套件/599 用例全绿). --- .../workplace/components/quick-todo-list.vue | 6 +- .../src/views/finance/config/index.vue | 69 ++ docs/安全与稳定性加固清单.md | 76 ++ docs/部署验收与1分钱支付闭环.md | 118 +++ pwuniapp/common/api.js | 61 +- pwuniapp/common/request.js | 33 +- pwuniapp/pages.json | 9 + pwuniapp/pages/community/detail.vue | 24 +- pwuniapp/pages/community/index.vue | 21 +- pwuniapp/pages/expert/detail.vue | 1 - pwuniapp/pages/expert/hall.vue | 9 +- pwuniapp/pages/expert/radar.vue | 13 +- pwuniapp/pages/expert/workspace.vue | 46 +- pwuniapp/pages/order/list.vue | 13 +- pwuniapp/pages/user/help-faq.vue | 285 ++++++ pwuniapp/pages/user/index.vue | 36 +- pwuniapp/pages/user/settings.vue | 67 +- .../core/notify/wechat-subscribe.service.ts | 66 ++ server/src/core/pay/wechat-pay-v3.ts | 93 ++ server/src/db/mysql.ts | 29 + server/src/db/repository.ts | 572 +++++++++-- server/src/index.ts | 958 +++++++++++++++--- .../tests/activity-create-validation.spec.ts | 31 + server/tests/activity-detail-privacy.spec.ts | 32 + server/tests/activity-identity.spec.ts | 71 ++ server/tests/activity-join-payment.spec.ts | 37 + server/tests/adjust-price-integrity.spec.ts | 32 + server/tests/admin-transfer-config.spec.ts | 33 + server/tests/admin-write-authz.spec.ts | 29 + server/tests/auth-secret-guard.spec.ts | 28 + server/tests/buyer-cancel-order.spec.ts | 28 + server/tests/code2session-hardening.spec.ts | 30 + .../tests/companion-apply-ownership.spec.ts | 28 + server/tests/companion-detail-privacy.spec.ts | 19 + .../companions-directory-privacy.spec.ts | 20 + server/tests/content-check-auth.spec.ts | 21 + server/tests/content-security.spec.ts | 2 +- server/tests/distribution-no-fake.spec.ts | 24 + server/tests/expert-stats-scope.spec.ts | 23 + server/tests/faq-config-repair.spec.ts | 24 + server/tests/funds-identity.spec.ts | 43 + server/tests/help-faq-page.spec.ts | 32 + server/tests/identity-hardening.spec.ts | 4 +- server/tests/login-hardening.spec.ts | 31 + server/tests/mini-program-expert-grab.spec.ts | 26 + ...mini-program-identity-and-referral.spec.ts | 24 + .../mini-program-no-fake-success.spec.ts | 79 ++ server/tests/order-amount-integrity.spec.ts | 36 + .../tests/order-lifecycle-hardening.spec.ts | 2 +- server/tests/order-progress-gates.spec.ts | 27 + server/tests/orders-pool-filter.spec.ts | 20 + server/tests/payment-amount-integrity.spec.ts | 35 + server/tests/phone-login-identity.spec.ts | 27 + server/tests/read-access-contract.spec.ts | 65 ++ server/tests/read-scope.spec.ts | 57 ++ server/tests/recharge-bonus-authority.spec.ts | 35 + .../tests/request-and-checkin-lookup.spec.ts | 39 + server/tests/review-post-identity.spec.ts | 65 ++ server/tests/security-hardening.spec.ts | 2 +- server/tests/settings-file-location.spec.ts | 21 + server/tests/settlement-credit-user.spec.ts | 30 + server/tests/settlement-gate.spec.ts | 25 + server/tests/startup-selfcheck.spec.ts | 27 + server/tests/subscribe-message.spec.ts | 60 ++ server/tests/upload-auth.spec.ts | 20 + server/tests/user-detail-scope.spec.ts | 26 + server/tests/user-read-privacy.spec.ts | 46 + server/tests/verify-pool-hardening.spec.ts | 45 + server/tests/wallet-records-scope.spec.ts | 25 + server/tests/withdrawal-transfer.spec.ts | 45 + 70 files changed, 3806 insertions(+), 330 deletions(-) create mode 100644 docs/安全与稳定性加固清单.md create mode 100644 docs/部署验收与1分钱支付闭环.md create mode 100644 pwuniapp/pages/user/help-faq.vue create mode 100644 server/src/core/notify/wechat-subscribe.service.ts create mode 100644 server/tests/activity-create-validation.spec.ts create mode 100644 server/tests/activity-detail-privacy.spec.ts create mode 100644 server/tests/activity-identity.spec.ts create mode 100644 server/tests/activity-join-payment.spec.ts create mode 100644 server/tests/adjust-price-integrity.spec.ts create mode 100644 server/tests/admin-transfer-config.spec.ts create mode 100644 server/tests/admin-write-authz.spec.ts create mode 100644 server/tests/auth-secret-guard.spec.ts create mode 100644 server/tests/buyer-cancel-order.spec.ts create mode 100644 server/tests/code2session-hardening.spec.ts create mode 100644 server/tests/companion-apply-ownership.spec.ts create mode 100644 server/tests/companion-detail-privacy.spec.ts create mode 100644 server/tests/companions-directory-privacy.spec.ts create mode 100644 server/tests/content-check-auth.spec.ts create mode 100644 server/tests/distribution-no-fake.spec.ts create mode 100644 server/tests/expert-stats-scope.spec.ts create mode 100644 server/tests/faq-config-repair.spec.ts create mode 100644 server/tests/funds-identity.spec.ts create mode 100644 server/tests/help-faq-page.spec.ts create mode 100644 server/tests/login-hardening.spec.ts create mode 100644 server/tests/mini-program-expert-grab.spec.ts create mode 100644 server/tests/mini-program-identity-and-referral.spec.ts create mode 100644 server/tests/mini-program-no-fake-success.spec.ts create mode 100644 server/tests/order-amount-integrity.spec.ts create mode 100644 server/tests/order-progress-gates.spec.ts create mode 100644 server/tests/orders-pool-filter.spec.ts create mode 100644 server/tests/payment-amount-integrity.spec.ts create mode 100644 server/tests/phone-login-identity.spec.ts create mode 100644 server/tests/read-access-contract.spec.ts create mode 100644 server/tests/read-scope.spec.ts create mode 100644 server/tests/recharge-bonus-authority.spec.ts create mode 100644 server/tests/request-and-checkin-lookup.spec.ts create mode 100644 server/tests/review-post-identity.spec.ts create mode 100644 server/tests/settings-file-location.spec.ts create mode 100644 server/tests/settlement-credit-user.spec.ts create mode 100644 server/tests/settlement-gate.spec.ts create mode 100644 server/tests/startup-selfcheck.spec.ts create mode 100644 server/tests/subscribe-message.spec.ts create mode 100644 server/tests/upload-auth.spec.ts create mode 100644 server/tests/user-detail-scope.spec.ts create mode 100644 server/tests/user-read-privacy.spec.ts create mode 100644 server/tests/verify-pool-hardening.spec.ts create mode 100644 server/tests/wallet-records-scope.spec.ts create mode 100644 server/tests/withdrawal-transfer.spec.ts diff --git a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-todo-list.vue b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-todo-list.vue index 7985484..eb44540 100644 --- a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-todo-list.vue +++ b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-todo-list.vue @@ -136,11 +136,11 @@ const quickApproveWithdraw = async (id: string) => { try { - await axios.post(`/api/finance/withdrawals/${id}/review`, { action: 'approve' }); - Message.success('已审核通过并触发微信商户零钱打款'); + const res: any = await axios.post(`/api/finance/withdrawals/${id}/review`, { action: 'approve' }); + Message.success(res?.data?.message || '提现审核已处理'); fetchQuickTodos(); } catch (err: any) { - Message.error(err.message || '打款失败'); + Message.error(err?.response?.data?.message || err.message || '打款失败'); } }; diff --git a/admin/arco-design-pro-vite/src/views/finance/config/index.vue b/admin/arco-design-pro-vite/src/views/finance/config/index.vue index c4bf921..8a30f36 100644 --- a/admin/arco-design-pro-vite/src/views/finance/config/index.vue +++ b/admin/arco-design-pro-vite/src/views/finance/config/index.vue @@ -287,6 +287,48 @@ + 提现打款(微信商家转账到零钱) + + 提现审核"通过"将对大神发起真实的微信商家转账到零钱。需商户号已开通该产品并配置「转账场景ID」;未配置时生产环境会拒绝打款(不会只改状态、不发钱)。 + + + + + + + + + + + + + + + + + + + + + + + + + , + transferSceneReportInfosText: '', remark: '微信支付V3直连商户参数配置' }); @@ -399,6 +447,11 @@ ...wxPayForm.value, ...data }; + wxPayForm.value.transferSceneReportInfosText = JSON.stringify( + wxPayForm.value.transferSceneReportInfos || [], + null, + 2 + ); if (data.effectiveMode) { wxPayEffectiveMode.value = data.effectiveMode; } @@ -458,6 +511,22 @@ } wxPaySaving.value = true; try { + // 解析"转账场景信息"JSON(部分转账场景必填) + const rawSceneInfos = String(wxPayForm.value.transferSceneReportInfosText || '').trim(); + let sceneInfos: Array<{ info_type: string; info_content: string }> = []; + if (rawSceneInfos) { + try { + const parsed = JSON.parse(rawSceneInfos); + if (!Array.isArray(parsed)) throw new Error('not_array'); + sceneInfos = parsed; + } catch (e) { + Message.error('转账场景信息 (transfer_scene_report_infos) 需为合法 JSON 数组'); + wxPaySaving.value = false; + return; + } + } + wxPayForm.value.transferSceneReportInfos = sceneInfos; + const res: any = await axios.post('/api/admin/pay/wechat/config', wxPayForm.value); const data = res?.data?.data || res?.data || res; if (data && data.effectiveMode) { diff --git a/docs/安全与稳定性加固清单.md b/docs/安全与稳定性加固清单.md new file mode 100644 index 0000000..bfe2a84 --- /dev/null +++ b/docs/安全与稳定性加固清单.md @@ -0,0 +1,76 @@ +# 陪玩平台 安全与稳定性加固清单(本轮) + +> 覆盖范围:后端 API(Express + MySQL)、后台(arco-design-pro-vite)、小程序(pwuniapp)。 +> 交付形态:源码改动 + 回归测试(源码契约 + 真实 MySQL 行为冒烟 + 真实 HTTP 端到端鉴权冒烟)。 +> 当前基线:后端全量测试 **69 套件 / 583 用例全绿**;`npm run build` 通过。 + +--- + +## 一、身份与归属鉴权(防越权/伪造身份) + +- **用户会话 token**:资金/敏感写接口在生产环境 fail-closed(无有效 token 返回 401);非生产保留客户端身份以兼容本地联调。`resolveSecureUserId` 统一收口。 +- **管理员特权**:仅认服务端 HMAC 签名 admin token(`isVerifiedAdminRequest`),严禁信任 `operatorRole`/`x-user-role`。 +- **订单生命周期**:`complete/cancel/reject/apply-complete/refund/claim/pay` 均做归属校验(买家/承接大神/管理员)与状态闸门。 +- **大神资料/服务/评价回复**:`PUT /companions/:id`、`/services/:id/status`、`/reviews/:id/reply` 仅本人或管理员。 +- **其余写接口收口**:点赞、评论、举报、收藏、分销绑定、入驻申请、组局发起/解散、抢单、搭子上车、用户设置、个人资料、充值、提现、动态发布/删除、订单评价等全部以服务端会话身份为准。 +- **读接口越权收口**:订单列表(本人相关)、我的组局、提现记录、财务流水、分销团队/明细、大神收益统计/走势、我的举报、收藏列表、上车申请名单、用户设置、管理端用户/风控/订单/财务/仲裁/评价/举报 等。 +- **管理端命名空间统一鉴权**:`/api/admin`、`/api/dashboard`、`/api/finance`、`/api/arbitrations` → `requireAdmin`;逐路由补齐 `requireAdmin`(用户列表/删除、360画像、风控日志、场所增删改、订单超时扫描、评价下架等)。 + +## 二、资金安全(防套现/重复入账/篡改) + +- **结算状态闸门**:`completeOrderWithSettlement` 拒绝 `待支付/待付款/待接单/已取消/已退款/已全额退款`。 +- **订单初始状态服务端决定**:`createOrder` 按支付方式(BALANCE→履约中,其它→待付款),杜绝客户端自造"已支付/已完成"订单。 +- **订单金额服务端权威**:单价按大神档案/服务包校验;金额=单价×时长−有效优惠券(服务端校验券),杜绝伪造低价。 +- **支付防篡改**: + - 预支付订单场景金额以订单为准、仅买家可发起; + - 入账校验微信实收金额与支付意图一致(`amountMismatch`)。 +- **充值赠金服务端按阶梯计算**:忽略客户端 `bonusAmount`,杜绝套取赠金(并修复 LIVE 充值此前不发赠金)。 +- **拒单/申请完成状态闸门**:仅 `待接单` 可拒单;仅已履约订单可申请完成(防未付款→待确认→结算套现)。 +- **买家取消订单**:`待支付/待付款`→已取消;`待接单`(已付款)→**原路退回买家余额**;已履约等→409 引导退款申诉。 +- **组局报名费服务端权威并真实扣款**:AA 取活动人均、房主请客 0;退款/清算基于实付,杜绝"免费报名→退款/清算套现"。 +- **房主改价**:金额校验(非负整数+上限)且仅按差额记账。 +- **结算收款人唯一化**:移除 `id OR nickname` 模糊入账,按 `companions.user_id`(未绑定时按昵称唯一解析)精确入账,杜绝改名重复领钱。 +- **提现**:金额/门槛校验 + 行锁 + 佣金余额校验,防超额与并发双花。 + +## 三、数据泄露与隐私 + +- **订单**:普通用户仅可取与自己相关订单;抢单池排除终态/已认领订单并脱敏客户名。 +- **用户**:全量列表/360画像/风控日志仅管理员;用户详情对外仅公开字段(剥离手机号/余额/佣金/邀请关系)。 +- **大神**:公开目录仅"已通过"且剥离内部审核备注;公开详情剥离 `rejectReason`。 +- **组局**:详情仅返回**本人**核销码(他人剥离);列表不下发核销码。 +- **核销码反查**:生产环境需登录且限门票归属方,防 4 位码枚举。 +- **财务流水**:个人流水按 `user_id` 精确归属(移除 counterparty LIKE 模糊匹配)。 +- **静态文件**:用户设置文件迁出对外可读的 `/uploads` 目录。 + +## 四、登录与密钥安全 + +- **管理端登录**:失败限流(IP+账号,5 次/10 分钟)+ 常量时间口令比较。 +- **生产默认密钥闸门**:若未配置 `ADMIN_TOKEN_SECRET`,生产环境改用进程内随机密钥(默认密钥签发的 token 一律失效)并告警。 +- **手机号登录**:优先按微信 `openid` 精确匹配;联系方式唯一性校验,防登录身份碰撞。 +- **jscode2session**:不再返回 `session_key`;openid 绑定需会话一致(防账号劫持)。 +- **上传**:生产环境要求登录。 + +## 五、可观测性与失败反馈 + +- **后端**:结算/支付/退款/充值/提现/openid 持久化等关键路径空 `catch` 改为 `logServerError`;看板经营指标改为真实聚合(移除硬编码假数据);分销团队/返佣明细移除硬编码假数据;收益统计只按 `expert_id`(去跨大神串算)。 +- **小程序请求层**:错误附带 `code`/`statusCode`;401/40100 清理过期会话并提示。 +- **小程序"假成功"清理**:取消订单、确认验收、抢单、拒单、提交完成、接单状态切换、社区删除/上车申请、点赞、设置同步 等失败均**不再伪装成功**(并回滚乐观状态)。 +- **后台**:财务/微信支付配置读取失败锁定保存(防默认值覆盖线上)、看板与待办失败显式告警、走势图去假数据。 + +--- + +## 六、部署与配置注意事项(重要) + +1. **必需环境变量(生产)** + - `ADMIN_TOKEN_SECRET=<强随机长串>` + - `USER_TOKEN_SECRET=<另一强随机长串>`(建议单独设置) + - 未设置时本方案会每次重启更换随机密钥:后台/小程序登录态会失效,且**多实例/集群必须设置固定密钥**。 +2. **微信支付 LIVE** + - 后台填齐 `mchId` / `apiV3Key`(32位) / `serialNo` / `privateKey`(或 `certPem`)/ 平台公钥或平台证书;`mode=LIVE` 或留空(AUTO)。 + - 商户平台配置 `notifyUrl`(默认 `https://peiww.gynhc.com/api/pay/wechat/notify`),确保 Nginx 放行、公网 HTTPS 可达。 + - 生产设 `NODE_ENV=production`;生产默认禁用沙箱直接入账(除非显式 `ALLOW_SANDBOX_PAY=1`)。 + - 建议先真实支付 1 分钱跑通:`prepay → 支付 → 回调/查单 → 到账 → 重复请求不重复加钱`。 +3. **内容安全**:如需微信真实审核,后台填真实小程序 `appId` 与 32 位 `appSecret`;未配置时自动跳过远端、仅跑本地词库。 +4. **数据订正(可选)**:历史遗留的 `finance_records.user_id=0` 旧流水不再展示;如需回填,可另跑一次性订正脚本。 + +> 本轮改动无破坏性数据库结构变更(仅运行时幂等 DDL 与既有字段使用)。 diff --git a/docs/部署验收与1分钱支付闭环.md b/docs/部署验收与1分钱支付闭环.md new file mode 100644 index 0000000..de19fde --- /dev/null +++ b/docs/部署验收与1分钱支付闭环.md @@ -0,0 +1,118 @@ +# 部署验收与「1 分钱支付」闭环步骤 + +> 目标:在生产环境完成配置自检,并以真实微信支付跑通「下单 → 支付 → 到账 → 幂等」闭环。 + +## 一、后端环境变量(生产) + +在启动后端的环境(pm2/systemd/.env,注意 dotenv 不覆盖已存在的系统环境变量)中设置: + +``` +NODE_ENV=production +PORT=3000 + +# 会话/管理端签名密钥(务必设置强随机长串) +ADMIN_TOKEN_SECRET=<强随机长串> +USER_TOKEN_SECRET=<另一个强随机长串> + +# 数据库 +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=... +MYSQL_PASSWORD=... +MYSQL_DATABASE=peiwan + +# 可选:微信(若已在后台"微信支付配置"页填写,则无需在这里重复;后台配置优先) +# WX_APP_ID=... +# WX_APP_SECRET=... +# WX_MCH_ID=... +# WX_API_V3_KEY=... +# WX_SERIAL_NO=... +# WX_PRIVATE_KEY=... +``` + +> ⚠️ 未设置 `ADMIN_TOKEN_SECRET` 时,生产会启用**进程内随机密钥**:后台/小程序登录态在每次重启后失效;多实例部署会出现实例间登录态不一致。**务必设置固定密钥。** + +## 二、查看启动自检日志(部署后第一时间确认) + +后端启动会打印(不含任何密钥明文): + +``` +[Peiwan Config] 运行模式=production ALLOW_SANDBOX_PAY=false +[Peiwan Config] ADMIN_TOKEN_SECRET=已设置 USER_TOKEN_SECRET=已设置 +[Peiwan Config] 微信支付: 模式=LIVE 实盘就绪=是 notifyUrl=https://peiww.gynhc.com/api/pay/wechat/notify +``` + +- 若出现 `[WARN] 微信支付未达 LIVE 就绪,待确认项: ...`,按提示补齐后在后台"微信支付配置"页保存。 +- 若出现 `[WARN] 未设置 ADMIN_TOKEN_SECRET ...`,请立即补上环境变量并重启。 + +## 三、微信支付商户配置(后台) + +登录后台 → 财务与分销配置 → 微信支付商户号与 APIv3 证书配置: +1. 填写 `appId`、`appSecret`(32位)、`mchId`、`apiV3Key`(32位)、`serialNo`、`privateKey`(或上传 `certPem`)、平台公钥或平台证书序列号。 +2. 确认 `notifyUrl = https://peiww.gynhc.com/api/pay/wechat/notify`,并在微信商户平台配置该回调地址。 +3. 保存后点击「配置自检与签名验证」,确认状态标签为 **LIVE**。 + +确保: +- Nginx 已放行 `/api/pay/wechat/notify`,公网 HTTPS 可达、不拦截微信回调(无鉴权、可被外网 POST)。 +- 校验 `apiV3Key` 为 32 位;`serialNo` 与上传的商户证书匹配。 + +## 四、「1 分钱」支付闭环验收 + +在微信开发者工具/真机(或 H5)执行: + +1. **下单**:下单任意订单(或充值),进入收银台。后台日志可见 `/api/pay/wechat/prepay` 调用(`mode=LIVE`)。 + - 预期:返回真实 JSAPI 支付参数并拉起微信支付。 +2. **支付**:完成(可用 1 分钱金额的商品/充值档)。 +3. **到账(回调)**:微信异步回调 `/api/pay/wechat/notify`。 + - 预期:订单状态流转为「履约中」;或钱包充值到账(余额/流水更新)。 +4. **主动查单兜底**:若回调延迟,前端"确认支付结果"会触发服务端主动查单(3 次重试)。 +5. **幂等**:重复点击支付结果/重复回调,**不得重复加钱/重复结算**。 + - 预期:`payment_intents` 幂等(`status=SUCCESS` 后不再入账);重复回调返回成功但不二次入账。 +6. **金额一致性**:人为构造金额不一致(仅测试环境)应被拒绝(日志 `[SERVER_ERROR][applyPaymentSuccess.amountMismatch]`)。 + +验收通过判据: +- 后台订单/资金流水正确记录; +- 钱包/余额变化与实付一致; +- 重复请求不产生重复入账; +- 生产环境未登录不能触发资金操作(HTTP 401/50008)。 + +## 五、提现打款(微信商家转账到零钱)配置与验收 + +> 重要:提现审核"通过"会**真实发起微信商家转账到零钱**。前置条件: +> 1. 商户号已开通**"商家转账到零钱"**产品(微信商户平台申请,通常需 1-2 个工作日); +> 2. 在商户平台配置**转账场景ID**(如"佣金报酬"场景); +> 3. 收款大神需有 **openid**(通过微信登录授权产生,`users.wechat_openid`)。 + +后台配置(财务与分销配置 → 微信支付商户号与 APIv3 证书配置)新增: +- **转账场景ID (`transfer_scene_id`)**:必填才视为"打款就绪"; +- **转账备注 (`transfer_remark`)**:默认"佣金提现"; +- **收款人姓名加密**:部分转账场景要求提交收款人姓名,启用后按平台公钥加密提交(需已配置平台公钥); +- **转账结果回调地址 (`transfer_notify_url`)**:默认 `https://peiww.gynhc.com/api/pay/wechat/transfer-notify`。 + +行为: +- 支付模式为 **LIVE 且"打款就绪"**:审核通过即调用微信商家转账;受理后工单置"处理中",最终由**回调/查单**确认 → 成功置"已打款"、失败置"打款失败"并**自动退回预扣佣金**。 +- 若该大神**缺少 openid**:打款被拒并提示"请其重新微信登录授权"。 +- **生产环境未达"打款就绪"**:审核通过会被**拒绝(503)**,不会出现"只改状态、不发钱"的误导(联调环境保留标记已打款)。 +- 启动自检会打印:`商家转账到零钱(提现打款): 就绪=是/否 转账场景ID=已配置/未配置`。 + +验收:提现 1 元 → 后台通过 → 观察工单由"处理中"→"已打款"、大神微信零钱到账;人为失败(如场景ID 错误)应置"打款失败"并退回佣金。 + +## 六、微信订阅消息(服务通知)配置与验收 + +后台「平台配置与协议规则 → 微信服务通知与模板」维护:总开关 `enablePush` + 4 类模板ID(订单状态 / 组局报名 / 提现到账 / 核销码)。 + +接入说明: +- **模板创建**:在微信公众平台「订阅消息」创建 4 个模板,把真实模板ID填入后台。各模板的字段(如 `phrase1`/`thing2`/`time3`/`character_string1`/`amount1`)**需与代码下发时的 data 键一致**(不一致会返回 47003 等错误,日志可见)。若你的模板字段名不同,把字段占位符发我,我按模板对齐 `data` 键。 +- **触发点**:① 订单进入"履约中"→ 给买家下发订单状态 + 4位核销码通知;② 组局报名成功 → 给报名者下发通知;③ 提现"打款成功"(受理/回调确认到账)→ 给提现大神下达到账通知。 +- **就绪条件**:`enablePush=1` + 对应模板ID已填 + 小程序 `appId/appSecret` 已配置 + 目标用户有 `openid`。任一缺失则静默跳过(不阻塞业务)。 +- **后台测试推送**:管理端"测试推送"在就绪时**真实下发**;未就绪时返回模拟结果并说明原因(mode=REAL/SIMULATED)。目标用户填其 UID,且该用户须有 openid。 +- **小程序授权**:设置页开启通知时用**真实模板ID** 调 `wx.requestSubscribeMessage` 收集授权。订阅为**一次性**,需在关键动作前再次请求(后续可在下单/报名/提现前补请求)。 +- **启动自检**:不额外打印;如需可在后台测试推送处验证。 + +## 七、内容安全(可选) + +后台填入真实小程序 `appId` 与 32 位 `appSecret` 后,内容检测会真实调用微信 `msgSecCheck`;未配置时自动跳过远端、仅跑本地敏感词库(fail-open,不影响业务)。 + +## 八、回滚 + +本轮改动遵循"边界内最小改动",无破坏性数据库结构变更。如需回滚,按部署前的文件/库备份还原即可。 diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index f92fae5..a65e5a4 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -17,7 +17,7 @@ export function formatActivityData(raw) { userId: p.userId, nickname: p.nickname || `搭子_${p.userId || p.id}`, avatar: p.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100', - verifyCode: p.verifyCode || '8392', + verifyCode: p.verifyCode || '', checkedIn: Boolean(p.checkinStatus || p.checkedIn), payAmount: p.payAmountCents != null ? p.payAmountCents / 100 : (p.payAmount || 0), })); @@ -68,7 +68,7 @@ export async function getActivities() { */ export async function getActivityDetail(id) { try { - const detail = await http.get(`/activities/${id}`); + const detail = await http.get(`/activities/${id}?userId=${normalizeUserId()}`); if (detail) { return formatActivityData(detail); } @@ -83,7 +83,7 @@ export async function getActivityDetail(id) { */ export async function createActivity(payload) { const body = { - creatorId: payload.creatorId || 1001, + creatorId: normalizeUserId(payload.creatorId), title: payload.title, gameCategory: payload.game || payload.gameCategory || '美食陪吃', location: payload.location, @@ -110,7 +110,7 @@ export async function createActivity(payload) { */ export async function joinActivity(activityId, params = {}) { const body = { - userId: params.userId || 2008, + userId: normalizeUserId(params.userId), payAmountCents: Math.round((Number(params.payAmount) || 50) * 100), }; @@ -155,7 +155,7 @@ export async function adjustActivityPrice(activityId, newPriceYuan, operatorId) */ export async function cancelActivity(activityId, hostId = 1001, reason = '房主主动解散') { return http.post(`/activities/${activityId}/cancel`, { - hostId, + hostId: normalizeUserId(hostId), reason, }); } @@ -165,7 +165,7 @@ export async function cancelActivity(activityId, hostId = 1001, reason = '房主 */ export async function leaveActivity(activityId, userId = 2001) { return http.post(`/activities/${activityId}/leave`, { - userId, + userId: normalizeUserId(userId), }); } @@ -301,7 +301,7 @@ export function formatOrderData(raw) { */ export async function getOrders() { try { - const list = await http.get('/orders'); + const list = await http.get(`/orders?userId=${normalizeUserId()}`); if (Array.isArray(list) && list.length > 0) { return list.map(formatOrderData); } @@ -334,8 +334,9 @@ export async function cancelOrder(orderId) { return formatOrderData(res); } catch (err) { console.warn('[API] 取消订单网络异常:', err.message); + // 写操作失败必须抛出,供调用方提示并阻止"假成功" + throw err; } - return null; } /** @@ -343,7 +344,7 @@ export async function cancelOrder(orderId) { */ export async function payOrder(orderId, payload = {}) { try { - const res = await http.post(`/orders/${orderId}/pay`, payload); + const res = await http.post(`/orders/${orderId}/pay`, { ...payload, userId: normalizeUserId(payload.userId) }); return formatOrderData(res); } catch (err) { console.warn('[API] 支付订单网络异常:', err.message); @@ -386,8 +387,9 @@ export async function completeOrder(orderId) { return formatOrderData(res); } catch (err) { console.warn('[API] 确认完成网络异常:', err.message); + // 写操作失败必须抛出,供调用方提示并阻止"假成功" + throw err; } - return null; } /** @@ -421,7 +423,7 @@ export async function submitReport(payload = {}) { */ export async function queryCheckinCode(code) { try { - const res = await http.get(`/verify/query/${code}`); + const res = await http.get(`/verify/query/${code}?userId=${normalizeUserId()}`); return res; } catch (err) { console.warn('[API] 反查核销码失败:', err.message); @@ -502,7 +504,7 @@ export async function getPosts(category, userId) { */ export async function createPost(payload) { try { - const res = await http.post('/posts', payload); + const res = await http.post('/posts', { ...(payload || {}), userId: normalizeUserId(payload && payload.userId) }); return res; } catch (err) { console.warn('[API] 发布动态异常:', err.message); @@ -524,7 +526,8 @@ export async function likePost(id, action, userId) { return res; } catch (err) { console.warn('[API] 点赞动态异常:', err.message); - return null; + // 点赞是写操作,失败必须向上抛出,避免调用方误以为已落盘 + throw err; } } @@ -687,7 +690,7 @@ export async function getOrdersPool() { */ export async function claimOrder(orderId, expertData = {}) { try { - const res = await http.post(`/orders/${orderId}/claim`, expertData); + const res = await http.post(`/orders/${orderId}/claim`, { ...expertData, userId: normalizeUserId(expertData.userId) }); return res; } catch (err) { console.warn('[API] 大神抢单异常:', err.message); @@ -780,7 +783,7 @@ export async function getMarketingCoupons(category) { */ export async function submitOrderReview(orderId, reviewData = {}) { try { - const res = await http.post(`/orders/${orderId}/review`, reviewData); + const res = await http.post(`/orders/${orderId}/review`, { ...reviewData, userId: normalizeUserId(reviewData.userId) }); return res; } catch (err) { console.warn('[API] 提交订单评价异常:', err.message); @@ -836,7 +839,7 @@ export async function addPostComment(postId, commentData = {}) { */ export async function applyPostParty(postId, applyData = {}) { try { - const res = await http.post(`/posts/${postId}/apply`, applyData); + const res = await http.post(`/posts/${postId}/apply`, { ...applyData, applicantId: normalizeUserId(applyData.applicantId) }); return res; } catch (err) { console.warn('[API] 申请搭子上车异常:', err.message); @@ -996,7 +999,7 @@ export async function replyOrderReview(reviewId, replyContent) { */ export async function deletePost(postId, userId) { try { - const uid = userId || uni.getStorageSync('userId') || 1001; + const uid = normalizeUserId(userId); const res = await http.delete(`/posts/${postId}?userId=${uid}`); return res; } catch (err) { @@ -1129,7 +1132,7 @@ export async function bindInviteCode(userId, inviteCode) { */ export async function getPostApplies(postId) { try { - const res = await http.get(`/posts/${postId}/applies`); + const res = await http.get(`/posts/${postId}/applies?userId=${normalizeUserId()}`); return res || []; } catch (err) { console.warn('[API] 获取搭子上车申请列表异常:', err.message); @@ -1196,6 +1199,19 @@ export async function getUserSettings(userId) { }; } +/** + * 获取微信订阅消息模板ID (用于 wx.requestSubscribeMessage 请求用户授权;模板ID非敏感) + */ +export async function getSubscribeTemplates() { + try { + const res = await http.get('/message/subscribe-templates'); + return res || { enablePush: false }; + } catch (err) { + console.warn('[API] 获取订阅消息模板异常:', err.message); + return { enablePush: false }; + } +} + /** * 保存用户隐私与通知订阅设置 (同步落盘服务端与本地存储) */ @@ -1210,12 +1226,9 @@ export async function updateUserSettings(userId, settings = {}) { try { const res = await http.post(`/users/${uid}/settings`, settings); return res; - } catch (_) { - return { - userId: uid, - privacyHidePhone: uni.getStorageSync('privacy_hide_phone') === '1', - noticeSubscribe: uni.getStorageSync('notice_subscribe') !== '0' - }; + } catch (err) { + // 写入失败必须向上抛出,避免调用方误以为已保存 + throw err; } } diff --git a/pwuniapp/common/request.js b/pwuniapp/common/request.js index 717284d..b2f1641 100644 --- a/pwuniapp/common/request.js +++ b/pwuniapp/common/request.js @@ -16,6 +16,27 @@ const BASE_URL = (() => { * @param {Object} options 请求参数 * @returns {Promise} 返回请求结果 */ +// 登录过期提示仅一次,避免并发请求重复弹窗 +let authExpiredNotified = false; +function handleAuthExpired() { + try { + uni.removeStorageSync('userToken'); + uni.removeStorageSync('userId'); + uni.removeStorageSync('userInfo'); + uni.removeStorageSync('PW_USER_INFO'); + } catch (_) { + // 忽略本地存储清理异常 + } + if (!authExpiredNotified) { + authExpiredNotified = true; + try { + uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' }); + } catch (_) { + // ignore + } + } +} + export function request(options = {}) { let url = options.url || ''; if (!url.startsWith('http')) { @@ -50,14 +71,22 @@ export function request(options = {}) { if (res.data.code === 20000) { resolve(res.data.data); } else { - reject(new Error(res.data.message || '业务请求失败')); + const bizErr = new Error(res.data.message || '业务请求失败'); + bizErr.code = res.data.code; + bizErr.statusCode = res.statusCode; + if (res.data.code === 40100) handleAuthExpired(); + reject(bizErr); } } else { resolve(res.data); } } else { const errMsg = (res.data && res.data.message) ? res.data.message : `HTTP请求失败: ${res.statusCode}`; - reject(new Error(errMsg)); + const httpErr = new Error(errMsg); + httpErr.statusCode = res.statusCode; + httpErr.code = (res.data && res.data.code) || res.statusCode; + if (res.statusCode === 401) handleAuthExpired(); + reject(httpErr); } }, fail: (err) => { diff --git a/pwuniapp/pages.json b/pwuniapp/pages.json index 278e619..f66c621 100644 --- a/pwuniapp/pages.json +++ b/pwuniapp/pages.json @@ -192,6 +192,15 @@ "backgroundColor": "#0A0F1D" } }, + { + "path": "pages/user/help-faq", + "style": { + "navigationBarTitleText": "帮助中心与常见问题", + "navigationBarBackgroundColor": "#0A0F1D", + "navigationBarTextStyle": "white", + "backgroundColor": "#0A0F1D" + } + }, { "path": "pages/activity/create", "style": { diff --git a/pwuniapp/pages/community/detail.vue b/pwuniapp/pages/community/detail.vue index 4f2d200..3fb90ad 100644 --- a/pwuniapp/pages/community/detail.vue +++ b/pwuniapp/pages/community/detail.vue @@ -446,7 +446,20 @@ const toggleLike = async () => { } uni.setStorageSync('ACTIVE_COMMUNITY_POST', post.value); } catch (err) { + // 回滚乐观更新,避免"已点赞"却未落盘 console.warn('[PostDetail] 点赞同步异常:', err.message); + post.value.liked = !willLike; + post.value.likes = Math.max(0, (Number(post.value.likes) || 0) + (willLike ? -1 : 1)); + let revertIds = uni.getStorageSync('COMMUNITY_LIKED_POST_IDS') || []; + if (!Array.isArray(revertIds)) revertIds = []; + if (willLike) { + revertIds = revertIds.filter(id => id !== strId); + } else if (!revertIds.includes(strId)) { + revertIds.push(strId); + } + uni.setStorageSync('COMMUNITY_LIKED_POST_IDS', revertIds); + uni.setStorageSync('ACTIVE_COMMUNITY_POST', post.value); + uni.showToast({ title: (err && err.message) || '操作失败,请重试', icon: 'none' }); } }; @@ -530,7 +543,11 @@ const handleJoinParty = () => { applicantName, applicantAvatar }); - } catch (_) {} + } catch (err) { + uni.hideLoading(); + uni.showToast({ title: (err && err.message) || '申请失败,请重试', icon: 'none' }); + return; + } post.value.hasApplied = true; post.value.myApplyStatus = 'PENDING'; @@ -624,10 +641,7 @@ const handleDeletePost = () => { }, 500); } catch (err) { uni.hideLoading(); - uni.showToast({ title: '动态已删除', icon: 'success' }); - setTimeout(() => { - handleBack(); - }, 500); + uni.showToast({ title: (err && err.message) || '删除失败,请重试', icon: 'none' }); } } } diff --git a/pwuniapp/pages/community/index.vue b/pwuniapp/pages/community/index.vue index d88fbcf..6e77a74 100644 --- a/pwuniapp/pages/community/index.vue +++ b/pwuniapp/pages/community/index.vue @@ -729,7 +729,19 @@ const toggleLike = async (post) => { } } } catch (err) { + // 回滚乐观更新,避免"已点赞"却未落盘 console.warn('[Community] 点赞落盘异常:', err.message); + post.liked = !willLike; + post.likes += willLike ? -1 : 1; + let revertIds = uni.getStorageSync('COMMUNITY_LIKED_POST_IDS') || []; + if (!Array.isArray(revertIds)) revertIds = []; + if (willLike) { + revertIds = revertIds.filter(id => id !== strId); + } else if (!revertIds.includes(strId)) { + revertIds.push(strId); + } + uni.setStorageSync('COMMUNITY_LIKED_POST_IDS', revertIds); + uni.showToast({ title: (err && err.message) || '操作失败,请重试', icon: 'none' }); } } }; @@ -914,8 +926,7 @@ const handleDeletePost = (post) => { uni.showToast({ title: '动态已删除', icon: 'success' }); } catch (err) { uni.hideLoading(); - posts.value = posts.value.filter(p => p.id !== post.id); - uni.showToast({ title: '动态已删除', icon: 'success' }); + uni.showToast({ title: (err && err.message) || '删除失败,请重试', icon: 'none' }); } } } @@ -1005,7 +1016,11 @@ const handleJoinParty = (post) => { applicantName, applicantAvatar }); - } catch (_) {} + } catch (err) { + uni.hideLoading(); + uni.showToast({ title: (err && err.message) || '申请失败,请重试', icon: 'none' }); + return; + } post.hasApplied = true; post.myApplyStatus = 'PENDING'; diff --git a/pwuniapp/pages/expert/detail.vue b/pwuniapp/pages/expert/detail.vue index a8cdb54..b666bc6 100644 --- a/pwuniapp/pages/expert/detail.vue +++ b/pwuniapp/pages/expert/detail.vue @@ -594,7 +594,6 @@ const submitReportTicket = async () => { uni.showLoading({ title: '正在提交举报...' }); try { await submitReportApi({ - reporterId: 1001, targetType: 'EXPERT', targetId: String(expert.value.id || '101'), targetName: expert.value.nickname || '同城陪伴师', diff --git a/pwuniapp/pages/expert/hall.vue b/pwuniapp/pages/expert/hall.vue index 71b14da..f398641 100644 --- a/pwuniapp/pages/expert/hall.vue +++ b/pwuniapp/pages/expert/hall.vue @@ -491,7 +491,14 @@ const handleGrabOrder = (order) => { expenseBurden: order.payMethod, status: '履约中' }); - } catch (_) {} + } catch (err) { + uni.hideLoading(); + uni.showToast({ + title: (err && err.message) || '接单失败,请稍后重试', + icon: 'none' + }); + return; + } uni.hideLoading(); uni.showToast({ title: '接单成功!请准时履约', diff --git a/pwuniapp/pages/expert/radar.vue b/pwuniapp/pages/expert/radar.vue index f05c518..a6de329 100644 --- a/pwuniapp/pages/expert/radar.vue +++ b/pwuniapp/pages/expert/radar.vue @@ -417,8 +417,8 @@ const handleTakeOrder = (order) => { companionName: '我 (接单大神)', expertAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150' }); - } catch (_) { - // 若为新需求单,自动落盘创建 + } catch (claimErr) { + // 若为新需求单,尝试自动落盘创建 try { await createOrder({ buyerId: 2001, @@ -434,7 +434,14 @@ const handleTakeOrder = (order) => { expenseBurden: '费用AA', status: '履约中' }); - } catch (_) {} + } catch (createErr) { + uni.hideLoading(); + uni.showToast({ + title: (createErr && createErr.message) || (claimErr && claimErr.message) || '抢单失败,请稍后重试', + icon: 'none' + }); + return; + } } uni.hideLoading(); uni.showToast({ diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue index 814d990..7d714c1 100644 --- a/pwuniapp/pages/expert/workspace.vue +++ b/pwuniapp/pages/expert/workspace.vue @@ -591,14 +591,21 @@ const activeOrders = ref([ ]); const toggleReceivingStatus = async () => { - isReceivingOrders.value = !isReceivingOrders.value; - const newStatusStr = isReceivingOrders.value ? '接单中' : '休息中'; - uni.setStorageSync('EXPERT_RECEIVING_STATUS', isReceivingOrders.value ? '1' : '0'); + const next = !isReceivingOrders.value; + const newStatusStr = next ? '接单中' : '休息中'; try { await updateCompanionProfile(expert.value.id || 101, { status: newStatusStr }); - } catch (_) {} + } catch (err) { + uni.showToast({ + title: (err && err.message) || '接单状态切换失败,请稍后重试', + icon: 'none' + }); + return; + } + isReceivingOrders.value = next; + uni.setStorageSync('EXPERT_RECEIVING_STATUS', next ? '1' : '0'); uni.showToast({ - title: isReceivingOrders.value ? '已开启在线接单通道!' : '已打烊停止接单', + title: next ? '已开启在线接单通道!' : '已打烊停止接单', icon: 'none' }); }; @@ -766,7 +773,14 @@ const grabOrder = async (order) => { companionName: '我 (接单大神)', expertAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150' }); - } catch (_) {} + } catch (err) { + uni.showToast({ + title: (err && err.message) || '抢单失败,请稍后重试', + icon: 'none' + }); + loadWorkspaceOrders(); + return; + } uni.showToast({ title: '抢单成功已接单', icon: 'success' @@ -787,7 +801,14 @@ const handleRejectOrder = (order) => { expertId: expert.value.id || 101, reason: '大神当前档期冲突拒单' }); - } catch (_) {} + } catch (err) { + uni.showToast({ + title: (err && err.message) || '拒单失败,请稍后重试', + icon: 'none' + }); + loadWorkspaceOrders(); + return; + } poolOrders.value = poolOrders.value.filter(o => o.id !== order.id); uni.showToast({ title: '已拒单并自动退款', @@ -833,10 +854,13 @@ const completeOrder = (id) => { if (res.confirm) { try { await applyCompleteOrder(id, { expertId: expert.value.id || 101 }); - } catch (_) { - try { - await completeOrderApi(id); - } catch (_) {} + } catch (err) { + uni.showToast({ + title: (err && err.message) || '提交失败,请稍后重试', + icon: 'none' + }); + loadWorkspaceOrders(); + return; } activeOrders.value = activeOrders.value.filter(o => o.id !== id); uni.showToast({ title: '已提交待买家确认', icon: 'success' }); diff --git a/pwuniapp/pages/order/list.vue b/pwuniapp/pages/order/list.vue index fa5c9af..d00ae7e 100644 --- a/pwuniapp/pages/order/list.vue +++ b/pwuniapp/pages/order/list.vue @@ -495,7 +495,11 @@ const cancelOrder = (id) => { if (res.confirm) { try { await cancelOrderApi(id); - } catch (_) {} + } catch (err) { + uni.showToast({ title: (err && err.message) || '取消失败,请稍后重试', icon: 'none' }); + loadLiveOrders(); + return; + } const o = orders.value.find(item => item.id === id); if (o) o.status = 'CANCELLED'; else orders.value = orders.value.filter(o => o.id !== id); @@ -751,7 +755,6 @@ const submitReportTicket = async () => { const expertId = order.expert ? order.expert.id : (order.expertId || 101); const expertName = order.expert ? order.expert.nickname : (order.companionName || '陪玩大神'); await submitReportApi({ - reporterId: 1001, targetType: 'EXPERT', targetId: String(expertId), targetName: expertName, @@ -788,7 +791,11 @@ const confirmComplete = (id) => { if (res.confirm) { try { await completeOrderApi(id); - } catch (_) {} + } catch (err) { + uni.showToast({ title: (err && err.message) || '确认验收失败,请稍后重试', icon: 'none' }); + loadLiveOrders(); + return; + } const o = orders.value.find(item => item.id === id); if (o) o.status = 'COMPLETED'; uni.showToast({ title: '已确认验收,感谢您的支持!', icon: 'success' }); diff --git a/pwuniapp/pages/user/help-faq.vue b/pwuniapp/pages/user/help-faq.vue new file mode 100644 index 0000000..73a6496 --- /dev/null +++ b/pwuniapp/pages/user/help-faq.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/pwuniapp/pages/user/index.vue b/pwuniapp/pages/user/index.vue index 94745f3..bc5827b 100644 --- a/pwuniapp/pages/user/index.vue +++ b/pwuniapp/pages/user/index.vue @@ -602,8 +602,9 @@ const loadUserProfile = async (targetId) => { user.value.inviterId = res.inviterId; } }) - .catch(() => { - uni.removeStorageSync('PENDING_INVITER_CODE'); + .catch((err) => { + // 绑定失败保留待绑定码以便下次登录重试,避免静默丢失邀请关系 + console.warn('[User] 邀请码绑定失败,将于下次登录重试:', err && err.message); }); } } @@ -1179,31 +1180,12 @@ const openNotice = () => { }; const openFaq = () => { - const list = platformConfigs.value.faq_list || []; - if (Array.isArray(list) && list.length > 0) { - const questions = list.map((item, idx) => `${idx + 1}. ${item.question}`); - uni.showActionSheet({ - itemList: questions, - success: (res) => { - const item = list[res.tapIndex]; - if (item) { - uni.showModal({ - title: item.question, - content: `【${item.category || '常见问题'}】\n${item.answer}`, - showCancel: false, - confirmText: '我知道了', - confirmColor: '#6366F1' - }); - } - } - }); - } else { - uni.showModal({ - title: '帮助中心与常见问题', - content: '1. 什么是4位核销码?\n到达活动现场后出示给陪伴师或房主签到核销,保障真实履约。\n2. 如何申请退款?\n在未接单或未核销前可随时全额退款,或发起争议仲裁由官方判定。', - showCancel: false - }); - } + uni.navigateTo({ + url: '/pages/user/help-faq', + fail: () => { + uni.showToast({ title: '页面打开失败,请稍后重试', icon: 'none' }); + } + }); }; const openAbout = () => { diff --git a/pwuniapp/pages/user/settings.vue b/pwuniapp/pages/user/settings.vue index ac9daa6..350fdda 100644 --- a/pwuniapp/pages/user/settings.vue +++ b/pwuniapp/pages/user/settings.vue @@ -186,7 +186,7 @@ import { onShow } from '@dcloudio/uni-app'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { getNavBarInfo } from '@/common/nav.js'; -import { getUserSettings, updateUserSettings } from '@/common/api.js'; +import { getUserSettings, updateUserSettings, getSubscribeTemplates } from '@/common/api.js'; const currentThemeKey = ref(getCurrentThemeKey()); const navInfo = ref(getNavBarInfo()); @@ -287,41 +287,76 @@ const syncSettingsFromServer = async () => { // 切换手机号脱敏隐藏(同步本地与后端并广播全站脱敏事件) const onToggleHidePhone = async (e) => { const val = !!e.detail.value; + const prev = privacyHidePhone.value; privacyHidePhone.value = val; uni.setStorageSync('privacy_hide_phone', val ? '1' : '0'); uni.$emit('privacy-settings-change', { privacyHidePhone: val }); - await updateUserSettings(currentUserId.value, { - privacyHidePhone: val, - noticeSubscribe: noticeSubscribe.value - }); + try { + await updateUserSettings(currentUserId.value, { + privacyHidePhone: val, + noticeSubscribe: noticeSubscribe.value + }); + } catch (err) { + // 同步失败回滚本地状态并提示,避免"未同步却显示已保存" + privacyHidePhone.value = prev; + uni.setStorageSync('privacy_hide_phone', prev ? '1' : '0'); + uni.$emit('privacy-settings-change', { privacyHidePhone: prev }); + uni.showToast({ title: (err && err.message) || '设置同步失败,请稍后重试', icon: 'none' }); + return; + } uni.showToast({ title: val ? '已开启手机号脱敏保护' : '已关闭手机号脱敏保护', icon: 'none' }); }; +// 使用后台配置的真实模板ID 请求微信订阅授权(过滤占位模板ID) +const requestNoticeSubscribe = async () => { + try { + const tpl = await getSubscribeTemplates(); + if (!tpl || tpl.enablePush === false) return; + const ids = [ + tpl.orderStatusTemplateId, + tpl.activityJoinTemplateId, + tpl.withdrawSuccessTemplateId, + tpl.verifyCodeTemplateId + ].filter((x) => x && !String(x).startsWith('tmpl_')); + if (ids.length === 0) return; + if (typeof uni.requestSubscribeMessage === 'function') { + uni.requestSubscribeMessage({ tmplIds: ids, complete: () => {} }); + } + } catch (_) { + // 订阅授权失败不影响主流程 + } +}; + // 切换通知订阅(微信小程序端支持唤起订阅授权,并同步服务端) const onToggleNotice = async (e) => { const val = !!e.detail.value; + const prev = noticeSubscribe.value; noticeSubscribe.value = val; uni.setStorageSync('notice_subscribe', val ? '1' : '0'); // #ifdef MP-WEIXIN - if (val && typeof uni.requestSubscribeMessage === 'function') { - try { - uni.requestSubscribeMessage({ - tmplIds: ['ORDER_STATUS_NOTICE_TMPL', 'ACTIVITY_TEAM_NOTICE_TMPL'], - complete: () => {} - }); - } catch (_) {} + if (val) { + await requestNoticeSubscribe(); } // #endif uni.$emit('notice-subscribe-change', val); - await updateUserSettings(currentUserId.value, { - privacyHidePhone: privacyHidePhone.value, - noticeSubscribe: val - }); + try { + await updateUserSettings(currentUserId.value, { + privacyHidePhone: privacyHidePhone.value, + noticeSubscribe: val + }); + } catch (err) { + // 同步失败回滚本地状态并提示 + noticeSubscribe.value = prev; + uni.setStorageSync('notice_subscribe', prev ? '1' : '0'); + uni.$emit('notice-subscribe-change', prev); + uni.showToast({ title: (err && err.message) || '设置同步失败,请稍后重试', icon: 'none' }); + return; + } uni.showToast({ title: val ? '已开启订单与组局动态提醒' : '已关闭订单与组局动态提醒', icon: 'none' diff --git a/server/src/core/notify/wechat-subscribe.service.ts b/server/src/core/notify/wechat-subscribe.service.ts new file mode 100644 index 0000000..a763c6f --- /dev/null +++ b/server/src/core/notify/wechat-subscribe.service.ts @@ -0,0 +1,66 @@ +// 已读取AGENTS.md +/** + * 微信小程序「订阅消息」(服务通知) 下发客户端。 + * 说明:订阅消息为一次性订阅,用户每次授权仅可下发一条;调用需有效 access_token。 + * 该模块为纯函数/无状态,便于离线单元校验与复用。 + */ +import https from 'https'; + +export interface SubscribeMessagePayload { + touser: string; + template_id: string; + page?: string; + data: Record; + miniprogram_state?: 'developer' | 'trial' | 'formal'; + lang?: string; +} + +/** + * 组装微信订阅消息 data 字段:入参为 { 字段名: 值 },输出 { 字段名: { value } }。 + * 微信各字段有长度限制(thing 类通常 ≤20 字符),此处统一截断到 20,避免 47003 参数错误。 + */ +export function buildTemplateData(input: Record): Record { + const out: Record = {}; + for (const [key, val] of Object.entries(input || {})) { + if (val === undefined || val === null) continue; + out[key] = { value: String(val).slice(0, 20) }; + } + return out; +} + +/** 调用微信 cgi-bin/message/subscribe/send 真实下发 */ +export function sendSubscribeMessageHttp( + accessToken: string, + payload: SubscribeMessagePayload +): Promise<{ errcode: number; errmsg: string }> { + const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${encodeURIComponent(accessToken)}`; + const body = JSON.stringify(payload); + return new Promise((resolve, reject) => { + const req = https.request( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + }, + (resp) => { + let data = ''; + resp.on('data', (chunk) => { + data += chunk; + }); + resp.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(e); + } + }); + } + ); + req.on('error', reject); + req.write(body); + req.end(); + }); +} diff --git a/server/src/core/pay/wechat-pay-v3.ts b/server/src/core/pay/wechat-pay-v3.ts index 0150396..228952b 100644 --- a/server/src/core/pay/wechat-pay-v3.ts +++ b/server/src/core/pay/wechat-pay-v3.ts @@ -168,6 +168,99 @@ export async function queryOrderByOutTradeNo( return JSON.parse(resp.body || '{}'); } +/** + * RSA-OAEP(SHA-1) 加密敏感信息。 + * 微信支付「商家转账到零钱」在部分转账场景下要求收款人真实姓名(user_name)以平台公钥加密后提交。 + */ +export function encryptSensitiveInfo(platformPublicKey: string, plain: string): string { + const key = resolvePlatformKey(platformPublicKey); + if (!key) { + throw new Error('缺少有效的微信支付平台公钥,无法加密敏感信息(收款人姓名)'); + } + const encrypted = crypto.publicEncrypt( + { key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' }, + Buffer.from(String(plain), 'utf8') + ); + return encrypted.toString('base64'); +} + +export interface TransferParams { + /** 商户侧转账单号(全局唯一,建议直接用提现单号,天然幂等) */ + outBillNo: string; + openid: string; + amountCents: number; + /** 转账场景ID(商户平台「商家转账到零钱」配置的转账场景) */ + sceneId: string; + remark: string; + notifyUrl?: string; + /** 收款人真实姓名(明文,调用内部会按平台公钥加密) */ + userName?: string; + /** 部分转账场景必填的场景信息(如岗位类型/报酬说明) */ + sceneReportInfos?: Array<{ info_type: string; info_content: string }>; +} + +/** 商家转账到零钱(V3):向指定 openid 发起转账。成功受理返回 { out_bill_no, state, ... } */ +export async function transferToWallet(cfg: WechatPayConfig, p: TransferParams): Promise { + const urlPath = '/v3/fund-app/mch-transfer/transfer-bills'; + const bodyObj: any = { + appid: cfg.appId, + out_bill_no: p.outBillNo, + transfer_scene_id: p.sceneId, + openid: p.openid, + transfer_amount: Math.round(Number(p.amountCents) || 0), + transfer_remark: String(p.remark || '').slice(0, 32), + }; + if (p.notifyUrl) bodyObj.notify_url = p.notifyUrl; + if (p.userName && cfg.platformPublicKey) { + bodyObj.user_name = encryptSensitiveInfo(cfg.platformPublicKey, p.userName); + } + if (Array.isArray(p.sceneReportInfos) && p.sceneReportInfos.length > 0) { + bodyObj.transfer_scene_report_infos = p.sceneReportInfos; + } + const body = JSON.stringify(bodyObj); + const authorization = buildAuthorization({ method: 'POST', urlPath, body, cfg }); + const resp = await httpsRequest({ + method: 'POST', + urlPath, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: authorization, + 'User-Agent': 'peiwan-server/1.0', + }, + body, + }); + let parsed: any = {}; + try { + parsed = JSON.parse(resp.body || '{}'); + } catch (_) { + parsed = {}; + } + if (resp.statusCode >= 400) { + throw new Error(`微信商家转账失败 [${resp.statusCode}]: ${parsed.message || resp.body}`); + } + return parsed; +} + +/** 查询商家转账单结果(按商户转账单号)。state: ACCEPTED/PROCESSING/SUCCESS/FAIL/CANCELING/CANCELLED */ +export async function queryTransferByOutBillNo(cfg: WechatPayConfig, outBillNo: string): Promise { + const urlPath = `/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/${encodeURIComponent(outBillNo)}`; + const authorization = buildAuthorization({ method: 'GET', urlPath, body: '', cfg }); + const resp = await httpsRequest({ + method: 'GET', + urlPath, + headers: { + Accept: 'application/json', + Authorization: authorization, + 'User-Agent': 'peiwan-server/1.0', + }, + }); + if (resp.statusCode >= 400) { + throw new Error(`微信商家转账查单失败 [${resp.statusCode}]: ${resp.body}`); + } + return JSON.parse(resp.body || '{}'); +} + /** 校验微信支付回调签名(使用平台公钥/平台证书),时间戳容差 5 分钟 */ export function verifyNotifySignature(params: { cfg: WechatPayConfig; diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 0d4a43b..30c7909 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -208,7 +208,11 @@ export async function initDatabase(): Promise { amount DECIMAL(10,2) NOT NULL, balance DECIMAL(10,2) NOT NULL, channel VARCHAR(64) DEFAULT '微信商户零钱', + payee_name VARCHAR(64) DEFAULT '', + payee_account VARCHAR(128) DEFAULT '', status VARCHAR(32) DEFAULT '待审核', + transfer_bill_no VARCHAR(64) DEFAULT '', + transfer_status VARCHAR(32) DEFAULT '', apply_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); @@ -216,6 +220,18 @@ export async function initDatabase(): Promise { try { await conn.query('ALTER TABLE withdrawals ADD COLUMN reason VARCHAR(255) DEFAULT ""'); } catch (_) {} + try { + await conn.query("ALTER TABLE withdrawals ADD COLUMN transfer_bill_no VARCHAR(64) DEFAULT ''"); + } catch (_) {} + try { + await conn.query("ALTER TABLE withdrawals ADD COLUMN transfer_status VARCHAR(32) DEFAULT ''"); + } catch (_) {} + try { + await conn.query("ALTER TABLE withdrawals ADD COLUMN payee_name VARCHAR(64) DEFAULT ''"); + } catch (_) {} + try { + await conn.query("ALTER TABLE withdrawals ADD COLUMN payee_account VARCHAR(128) DEFAULT ''"); + } catch (_) {} // 7.5 微信支付支付意图与对账表 (幂等入账的唯一依据) await conn.query(` @@ -1048,6 +1064,19 @@ async function seedInitialDataIfEmpty(conn: any): Promise { VALUES ('faq_list', '帮助中心与常见问题FAQ', ?, '小程序端帮助中心常见问答与指引', 1) `, [faqListVal]); } + // 修复:faq_list 若存在但为空数组(曾被"空保存"清空),自动回填默认种子, + // 避免小程序端只显示本地兜底文案、后台却为空。 + try { + const [faqRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'faq_list' LIMIT 1"); + const raw = faqRows && faqRows[0] && faqRows[0].value_json; + let parsed: any = raw; + if (typeof raw === 'string') { + try { parsed = JSON.parse(raw); } catch (_) { parsed = null; } + } + if (!Array.isArray(parsed) || parsed.length === 0) { + await conn.query("UPDATE platform_configs SET value_json = ? WHERE config_key = 'faq_list'", [faqListVal]); + } + } catch (_) {} if (!existingKeys.has('security_words')) { await conn.query(` INSERT INTO platform_configs (config_key, name, value_json, description, status) diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index fecd22f..a7d5184 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -135,10 +135,20 @@ export class MysqlRepository { } const id = `act_${Date.now()}_${Math.floor(Math.random() * 1000)}`; - const payType = data.payType || 1; - const maxParticipants = data.maxParticipants || 4; - const perPersonFeeCents = data.perPersonFeeCents || 5000; - const totalFeeCents = data.totalFeeCents || perPersonFeeCents * maxParticipants; + // 参数服务端校验与归一化,防止负数/超大值破坏报名费与人数上限 + const payType = Number(data.payType) === 2 ? 2 : 1; + const rawMax = Number(data.maxParticipants); + const maxParticipants = Number.isFinite(rawMax) ? Math.min(100, Math.max(1, Math.round(rawMax))) : 4; + const MAX_FEE_CENTS = 100000000; // ¥1,000,000 单人均上限 + const rawFee = Number(data.perPersonFeeCents); + const fee = Number.isFinite(rawFee) ? Math.min(MAX_FEE_CENTS, Math.max(0, Math.round(rawFee))) : 5000; + // 房主请客(payType=2)人均为 0;AA(payType=1) 使用校验后的人均 + const perPersonFeeCents = payType === 1 ? fee : 0; + const rawTotal = Number(data.totalFeeCents); + const totalFeeCents = + Number.isFinite(rawTotal) && rawTotal >= perPersonFeeCents + ? Math.min(MAX_FEE_CENTS * maxParticipants, Math.round(rawTotal)) + : perPersonFeeCents * maxParticipants; const allowDynamicPrice = data.allowDynamicPrice !== undefined ? (data.allowDynamicPrice ? 1 : 0) : 1; const priceAdjustRule = data.priceAdjustRule || 'UNIFIED_HIGH'; @@ -258,11 +268,16 @@ export class MysqlRepository { // 2. 行级锁锁定活动,串行化同一活动的并发报名,从根上杜绝超卖 const [actRows]: any = await conn.query( - 'SELECT id, max_participants FROM activities WHERE id = ? FOR UPDATE', + 'SELECT id, max_participants, pay_type, per_person_fee_cents FROM activities WHERE id = ? FOR UPDATE', [activityId] ); if (!actRows || actRows.length === 0) throw new Error('活动不存在'); const maxParticipants = Number(actRows[0].max_participants || 0); + // 报名费由服务端活动配置决定(pay_type=1 为 AA 收取人均分摊,其余为房主请客 0 元), + // 严禁信任客户端传入的 payAmountCents,否则可"免费报名 → 退款/清算套现"。 + const joinFeeCents = Number(actRows[0].pay_type) === 1 + ? Math.max(0, Math.round(Number(actRows[0].per_person_fee_cents) || 0)) + : 0; // 3. 锁内以「当前读 (FOR UPDATE)」拉取该活动全部报名明细: // - 当前读不受 REPEATABLE READ 快照影响,必然看到最新已提交数据,名额判断才准确; @@ -282,6 +297,31 @@ export class MysqlRepository { throw new Error('用户已报名该活动'); } + // 5. 真实扣款:AA 报名费从钱包余额扣除(杜绝免费报名后套取退款/清算) + if (joinFeeCents > 0) { + const joinFeeYuan = joinFeeCents / 100; + const [balRows]: any = await conn.query('SELECT balance FROM users WHERE id = ? FOR UPDATE', [params.userId]); + const bal = Number((balRows && balRows[0] && balRows[0].balance) || 0); + if (bal < joinFeeYuan) { + const err: any = new Error(`钱包余额不足,报名需支付 ¥${joinFeeYuan.toFixed(2)},请先充值`); + err.status = 400; + err.code = 40000; + throw err; + } + await conn.query('UPDATE users SET balance = balance - ? WHERE id = ?', [joinFeeYuan, params.userId]); + await conn.query(` + INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id) + VALUES (?, ?, ?, '活动报名扣款', ?, 'OUT', ?, ?) + `, [ + `fin_jn_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`, + `JN${Date.now()}${Math.floor(100 + Math.random() * 900)}`, + params.userId, + joinFeeYuan.toFixed(2), + `活动 ${activityId}`, + activityId + ]); + } + // 5. 生成活动内唯一的 4 位纯数字核销码 (1000 - 9999) const usedCodes = new Set(rowsList.map((r: any) => String(r.verify_code))); let verifyCode = ''; @@ -312,14 +352,14 @@ export class MysqlRepository { INSERT INTO participants (id, activity_id, user_id, pay_amount_cents, verify_code, checkin_status) VALUES (?, ?, ?, ?, ?, 0) - `, [ptId, activityId, params.userId, params.payAmountCents, verifyCode]); + `, [ptId, activityId, params.userId, joinFeeCents, verifyCode]); // 6. 写入一条财务流水 const finId = `fin_${Date.now()}_${Math.floor(Math.random() * 1000)}`; await conn.query(` INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id) VALUES (?, ?, '活动报名入账', ?, 'IN', ?, ?) - `, [finId, `TR${Date.now()}${Math.floor(Math.random() * 100)}`, params.payAmountCents / 100, `用户 ${params.userId}`, activityId]); + `, [finId, `TR${Date.now()}${Math.floor(Math.random() * 100)}`, joinFeeCents / 100, `用户 ${params.userId}`, activityId]); await conn.commit(); @@ -335,7 +375,7 @@ export class MysqlRepository { id: ptId, activityId, userId: params.userId, - payAmountCents: params.payAmountCents, + payAmountCents: joinFeeCents, verifyCode, cacheKey, checkinStatus: false, @@ -414,6 +454,7 @@ export class MysqlRepository { FROM participants p LEFT JOIN activities a ON p.activity_id = a.id WHERE p.verify_code = ? + ORDER BY p.checkin_status ASC, p.created_at DESC LIMIT 1 `, [verifyCode]); @@ -443,6 +484,8 @@ export class MysqlRepository { verify_code as verifyCode, status FROM orders WHERE verify_code = ? + AND status NOT IN ('已取消', '已退款', '已全额退款') + ORDER BY FIELD(status, '履约中', '服务中', '待确认', '已到场核销', '已完成'), created_at DESC LIMIT 1 `, [verifyCode]); @@ -509,17 +552,47 @@ export class MysqlRepository { throw new Error('只有发起人有权调整活动出价'); } + // 校验调价金额:必须为非负整数且不超过平台单人均上限,防止负数/超大值破坏报名费与账目 + const price = Number(newPriceCents); + if (!Number.isFinite(price) || !Number.isInteger(price) || price < 0) { + const err: any = new Error('调价金额必须为不小于 0 的整数(单位:分)'); + err.status = 400; + err.code = 40000; + throw err; + } + const MAX_FEE_CENTS = 100000000; // ¥1,000,000 单人均上限 + if (price > MAX_FEE_CENTS) { + const err: any = new Error(`调价金额超出平台单人均上限 ¥${(MAX_FEE_CENTS / 100).toLocaleString()}`); + err.status = 400; + err.code = 40000; + throw err; + } + + const oldFee = Number(act.perPersonFeeCents || 0); + const deltaCents = price - oldFee; + await pool.query(` UPDATE activities SET per_person_fee_cents = ? WHERE id = ? - `, [newPriceCents, activityId]); + `, [price, activityId]); - const finId = `fin_${Date.now()}`; - await pool.query(` - INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id) - VALUES (?, ?, '房主追加赏金差额退补', ?, 'IN', ?, ?) - `, [finId, `TR${Date.now()}`, newPriceCents / 100, `房主 ${operatorId}`, activityId]); + // 仅按真实差额记账(避免把整份新价重复计入流水造成虚假营收) + if (deltaCents !== 0) { + const finId = `fin_${Date.now()}`; + await pool.query(` + INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, [ + finId, + `TR${Date.now()}`, + deltaCents > 0 ? '房主追加赏金' : '房主下调出价差额', + Math.abs(deltaCents) / 100, + deltaCents > 0 ? 'IN' : 'OUT', + `房主 ${operatorId}`, + activityId + ]); + } return this.getActivityById(activityId); } @@ -929,14 +1002,27 @@ export class MysqlRepository { ? data.idCard.substring(0, 6) + '********' + data.idCard.substring(data.idCard.length - 4) : '110101********0000'; - const [existing]: any = await pool.query('SELECT * FROM companions WHERE (user_id IS NOT NULL AND user_id = ?) OR name = ?', [userId, name]); - if (existing && existing.length > 0) { + // 仅允许本人按 user_id 重新提交/更新档案;严禁按昵称匹配到他人档案后抢占其归属 + const [ownRows]: any = await pool.query( + 'SELECT * FROM companions WHERE user_id IS NOT NULL AND user_id = ? LIMIT 1', + [userId] + ); + if (ownRows && ownRows.length > 0) { await pool.query(` UPDATE companions - SET user_id = ?, name = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', reject_reason = '', id_card_masked = ?, apply_time = NOW() + SET name = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', reject_reason = '', id_card_masked = ?, apply_time = NOW() WHERE id = ? - `, [userId, name, track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, existing[0].id]); - return this.getCompanionById(existing[0].id); + `, [name, track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, ownRows[0].id]); + return this.getCompanionById(ownRows[0].id); + } + + // 昵称冲突校验:若昵称已被(他人)大神档案占用,拒绝提交,防止冒充/抢占他人大神身份 + const [nameRows]: any = await pool.query('SELECT id, user_id FROM companions WHERE name = ? LIMIT 1', [name]); + if (nameRows && nameRows.length > 0) { + const err: any = new Error('该大神昵称已被占用,请更换昵称后重新提交'); + err.status = 409; + err.code = 40900; + throw err; } const [res]: any = await pool.query(` @@ -1115,6 +1201,48 @@ export class MysqlRepository { return rows; } + /** + * 仅返回与指定用户相关的订单(买家 / 承接大神 / 已认领 / 其大神档案对应订单)。 + * 用于替代把全平台订单下发给任意客户端,避免订单隐私与核销码泄露。 + */ + async getOrdersForUser(userId: number): Promise { + const uid = Number(userId) || 0; + await this.processOrderTimeouts().catch(() => {}); + const [rows]: any = await pool.query(` + SELECT + id, + COALESCE(buyer_id, 1001) as buyerId, + COALESCE(expert_id, 101) as expertId, + order_no as orderNo, + client_name as clientName, + companion_name as companionName, + expert_avatar as expertAvatar, + track, + COALESCE(service_title, CONCAT(track, ' · 专业服务')) as serviceTitle, + expense_burden as expenseBurden, + hourly_rate as hourlyRate, + duration_hours as durationHours, + amount, + COALESCE(coupon_discount, 0.00) as couponDiscount, + COALESCE(refund_amount, 0.00) as refundAmount, + COALESCE(pay_type, 'WECHAT') as payType, + location, + verify_code as verifyCode, + remark, + status, + paid_at as paidAt, + finish_applied_at as finishAppliedAt, + created_at as createdAt + FROM orders + WHERE buyer_id = ? + OR expert_id = ? + OR claimed_by = ? + OR companion_name IN (SELECT name FROM companions WHERE user_id = ? OR id = ?) + ORDER BY created_at DESC + `, [uid, uid, uid, uid, uid]); + return rows; + } + async createOrder(data: { buyerId?: number; clientName?: string; @@ -1149,16 +1277,41 @@ export class MysqlRepository { const expertAvatar = data.expertAvatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120'; const track = data.track || '同城生活'; const serviceTitle = data.serviceTitle || `${track} · 专业陪伴服务`; - const hourlyRate = Number(data.hourlyRate) || 35; - const durationHours = Number(data.durationHours) || 2; - const amount = Number(data.amount) || hourlyRate * durationHours; - const couponDiscount = Number(data.couponDiscount || 0); + const durationHours = Math.min(24, Math.max(1, Math.round(Number(data.durationHours) || 2))); + + // 服务单价以服务端大神档案为准(本人基础价,或该大神名下服务包价格),杜绝客户端自定义低价 + let hourlyRate = Number(data.hourlyRate) || 35; + try { + const comp = await this.resolveCompanionRow(expertId); + if (comp) { + const compRate = Number(comp.hourly_rate) || 0; + let valid = compRate > 0 && Number(hourlyRate) === compRate; + if (!valid) { + const services = await this.getExpertServices(comp.id).catch(() => []); + valid = Array.isArray(services) && services.some((s: any) => Number(s.price) === Number(hourlyRate)); + } + if (!valid && compRate > 0) { + hourlyRate = compRate; + } + } + } catch (e) { + logServerError('createOrder.validateRate', e); + } + + // 优惠券折扣必须命中平台启用中的有效券(面额 / 门槛 / 品类),否则忽略为 0 + const baseAmount = Number((hourlyRate * durationHours).toFixed(2)); + const rawDiscount = Math.max(0, Number(data.couponDiscount || 0)); + const couponDiscount = rawDiscount > 0 ? await this.resolveCouponDiscount(rawDiscount, track, baseAmount) : 0; + + // 订单金额由服务端权威计算,杜绝客户端伪造低价下单 + const amount = Number(Math.max(baseAmount - couponDiscount, 0.01).toFixed(2)); const payType = data.payType === 'BALANCE' ? 'BALANCE' : 'WECHAT'; const location = data.location || '朝阳大悦城'; const expenseBurden = data.expenseBurden || 'AA制各自付'; const verifyCode = String(Math.floor(1000 + Math.random() * 9000)); const remark = data.remark || ''; - const status = data.status || '履约中'; + // 初始订单状态由服务端按支付方式决定,杜绝客户端创建"已支付/已履约/已完成"订单绕过付款 + const status = payType === 'BALANCE' ? '履约中' : '待付款'; const conn = await pool.getConnection(); try { @@ -1181,9 +1334,9 @@ export class MysqlRepository { const walletFinId = `fin_bal_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const walletRecNo = `WB${Date.now()}${Math.floor(Math.random() * 100)}`; await conn.query(` - INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id) - VALUES (?, ?, '钱包余额支付订单', ?, 'OUT', ?, ?) - `, [walletFinId, walletRecNo, amount.toFixed(2), `用户 ${buyerId} (${companionName})`, id]); + INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id) + VALUES (?, ?, ?, '钱包余额支付订单', ?, 'OUT', ?, ?) + `, [walletFinId, walletRecNo, buyerId, amount.toFixed(2), `用户 ${buyerId} (${companionName})`, id]); } await conn.query(` @@ -1193,7 +1346,7 @@ export class MysqlRepository { expense_burden, hourly_rate, duration_hours, amount, coupon_discount, pay_type, location, verify_code, remark, status, paid_at, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待支付' || status === '待付款' ? 'NULL' : 'NOW()'}, NOW()) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待付款' ? 'NULL' : 'NOW()'}, NOW()) `, [ id, buyerId, expertId, orderNo, clientName, companionName, expertAvatar, track, serviceTitle, @@ -1206,9 +1359,9 @@ export class MysqlRepository { const finId = `fin_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const recordNo = `TX${Date.now()}${Math.floor(Math.random() * 100)}`; await conn.query(` - INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id) - VALUES (?, ?, ?, ?, 'IN', ?, ?) - `, [finId, recordNo, payType === 'BALANCE' ? '服务预约订单付款 (钱包余额)' : '服务预约订单付款 (微信支付)', amount.toFixed(2), clientName, id]); + INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id) + VALUES (?, ?, ?, ?, ?, 'IN', ?, ?) + `, [finId, recordNo, buyerId, payType === 'BALANCE' ? '服务预约订单付款 (钱包余额)' : '服务预约订单付款 (微信支付)', amount.toFixed(2), clientName, id]); await conn.commit(); @@ -1257,6 +1410,66 @@ export class MysqlRepository { return rows[0] || null; } + /** + * 买家取消订单(仅限未进入履约的订单): + * - 未支付(待支付/待付款):直接取消; + * - 已支付待接单:取消并原路退回买家钱包余额(避免买家已付款却被静默吞掉资金); + * - 已进入履约/已完成/已退款等:拒绝,引导走退款申诉(仲裁)。 + */ + async buyerCancelOrder(orderId: string): Promise { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows]: any = await conn.query('SELECT * FROM orders WHERE id = ? OR order_no = ? FOR UPDATE', [orderId, orderId]); + if (!rows || rows.length === 0) { + const err: any = new Error('订单不存在'); + err.status = 404; + err.code = 40400; + throw err; + } + const order = rows[0]; + const st = String(order.status); + const blocked = ['履约中', '服务中', '待确认', '已到场核销', '已完成', '已取消', '已退款', '已全额退款', '已部分退款', '争议仲裁中']; + if (blocked.includes(st)) { + const err: any = new Error(`订单当前状态【${st}】不可直接取消,请发起退款申诉`); + err.status = 409; + err.code = 40900; + throw err; + } + + if (st === '待接单') { + const refundAmount = Number(order.amount || 0); + const buyerId = Number(order.buyer_id || 0); + await conn.query('UPDATE orders SET status = "已退款", refund_amount = ? WHERE id = ?', [refundAmount, order.id]); + if (buyerId > 0 && refundAmount > 0) { + await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [refundAmount, buyerId]); + await conn.query(` + INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, ?, '买家取消订单退款', ?, 'IN', ?, ?, NOW()) + `, [ + `fin_cx_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`, + `CX${Date.now()}${Math.floor(100 + Math.random() * 900)}`, + buyerId, + refundAmount.toFixed(2), + `订单 ${order.order_no || order.id}`, + String(order.id) + ]); + } + } else { + await conn.query('UPDATE orders SET status = "已取消" WHERE id = ?', [order.id]); + } + + await conn.commit(); + const [updated]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]); + return updated[0] || order; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + } + /** * 解析订单归属关系(买家 / 大神),用于写接口的身份越权校验。 * 大神身份采用「并集」策略:claimed_by(抢单人)∪ expert_id ∪ companions.user_id, @@ -1265,7 +1478,7 @@ export class MysqlRepository { async getOrderOwnership(id: string): Promise { const [rows]: any = await pool.query(` SELECT o.id, o.order_no as orderNo, o.buyer_id as buyerId, o.expert_id as expertId, - o.claimed_by as claimedBy, o.status, o.companion_name as companionName, + o.claimed_by as claimedBy, o.status, o.amount, o.companion_name as companionName, c.user_id as companionUserId FROM orders o LEFT JOIN companions c ON c.id = o.expert_id @@ -1285,6 +1498,7 @@ export class MysqlRepository { id: r.id, orderNo: r.orderNo, buyerId: Number(r.buyerId) || 0, + amount: Number(r.amount) || 0, companionName: r.companionName || '', status: r.status, companionIds, @@ -1293,6 +1507,21 @@ export class MysqlRepository { // 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口) async applyCompleteOrder(orderId: string): Promise { + const [cur]: any = await pool.query('SELECT status FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]); + if (!cur || cur.length === 0) { + const err: any = new Error('订单不存在'); + err.status = 404; + err.code = 40400; + throw err; + } + // 状态闸门:仅已进入履约的订单可申请完成,杜绝把未付款(待付款/待支付/待接单)订单推进为待确认再触发结算 + const allowed = ['履约中', '服务中', '已到场核销', '待确认']; + if (!allowed.includes(String(cur[0].status))) { + const err: any = new Error(`当前订单状态【${cur[0].status}】不可申请完成`); + err.status = 409; + err.code = 40900; + throw err; + } await pool.query( "UPDATE orders SET status = '待确认', finish_applied_at = NOW() WHERE id = ? OR order_no = ?", [orderId, orderId] @@ -1311,8 +1540,12 @@ export class MysqlRepository { throw new Error('订单不存在'); } const order = rows[0]; - if (['已完成', '已取消', '已退款', '已全额退款'].includes(order.status)) { - throw new Error(`当前订单状态【${order.status}】不可拒单`); + // 状态闸门:仅「待接单」可拒单,杜绝已进入履约/已完成的订单被拒单触发全额退款 + if (String(order.status) !== '待接单') { + const err: any = new Error(`当前订单状态【${order.status}】不可拒单(仅待接单可拒单)`); + err.status = 409; + err.code = 40900; + throw err; } const rejectNote = data?.reason ? ` [大神拒单: ${data.reason}]` : ' [大神拒单自动退款]'; @@ -1352,6 +1585,24 @@ export class MysqlRepository { } } + /** + * 解析大神结算收款用户ID(唯一): + * 优先 companions.user_id;未绑定则按昵称唯一解析;再无则回落 expert_id。 + * 严禁使用 `id = ? OR nickname = ?` 这类会同时命中多个用户的模糊匹配,防止改名重复入账。 + */ + private async resolveExpertCreditUserId(conn: any, order: any, comp: any): Promise { + const byUser = Number(comp?.user_id || 0); + if (byUser > 0) return byUser; + try { + const [uRows]: any = await conn.query('SELECT id FROM users WHERE nickname = ? LIMIT 1', [order.companion_name || '']); + const uid = Number((uRows && uRows[0] && uRows[0].id) || 0); + if (uid > 0) return uid; + } catch (e) { + logServerError('resolveExpertCreditUserId', e); + } + return Number(order.expert_id) || 101; + } + async completeOrderWithSettlement(id: string): Promise { const conn = await pool.getConnection(); try { @@ -1367,7 +1618,7 @@ export class MysqlRepository { // 状态闸门:仅「已进入履约/待确认」等具备已完成服务语义的订单可结算; // 严禁对未支付、未接单、已取消、已退款的订单越权触发资金结算(防前端直接结算套现)。 - const settleBlocked = ['待支付', '待接单', '已取消', '已退款', '已全额退款']; + const settleBlocked = ['待支付', '待付款', '待接单', '已取消', '已退款', '已全额退款']; if (settleBlocked.includes(String(order.status))) { const e: any = new Error(`订单当前状态【${order.status}】不可结算,已阻止越权触发资金结算`); e.status = 409; @@ -1405,10 +1656,10 @@ export class MysqlRepository { await conn.query('UPDATE companions SET order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', [comp.id]); } - const expertUserId = Number(comp?.user_id || order.expert_id || 101); + const expertUserId = await this.resolveExpertCreditUserId(conn, order, comp); await conn.query( - 'UPDATE users SET commission = COALESCE(commission, 0) + ?, order_count = COALESCE(order_count, 0) + 1 WHERE id = ? OR nickname = ?', - [expertIncome, expertUserId, order.companion_name || ''] + 'UPDATE users SET commission = COALESCE(commission, 0) + ?, order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', + [expertIncome, expertUserId] ); const buyerId = Number(order.buyer_id || 1001); @@ -1531,11 +1782,19 @@ export class MysqlRepository { amount, location, expense_burden as payMethod, status, created_at as createdAt FROM orders + WHERE status NOT IN ('已完成', '已取消', '已关闭', '已退款', '已全额退款', '争议仲裁中', '已到场核销', '待确认') + AND (claimed_by IS NULL OR claimed_by = 0) ORDER BY created_at DESC LIMIT 20 `); // 默认池保障 + const maskClientName = (n: any): string => { + const s = String(n || '').trim(); + if (!s) return '客户老板'; + if (s.length <= 1) return `${s}**`; + return `${s[0]}**`; + }; const poolList = (rows || []).map((r: any) => ({ id: r.id, category: r.category || '同城生活', @@ -1543,7 +1802,7 @@ export class MysqlRepository { location: r.location || '北京市朝阳区商圈', reward: Number(r.amount) || 120, timeSlot: '今日内随时可约', - userName: r.clientName || '客户老板', + userName: maskClientName(r.clientName), userAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120', payMethod: r.payMethod || 'AA制各付', countdown: 45, @@ -1629,8 +1888,8 @@ export class MysqlRepository { COALESCE(SUM(CASE WHEN status IN ('已完成', '已到场核销') THEN amount ELSE 0 END), 0) as totalIncome, COALESCE(SUM(CASE WHEN status IN ('履约中', '服务中') THEN 1 ELSE 0 END), 0) as waitingVerify FROM orders - WHERE expert_id = ? OR companion_name LIKE ? - `, [Number(expertId) || 101, '%我%']); + WHERE expert_id = ? + `, [Number(expertId) || 101]); const stat = rows[0] || {}; return { @@ -1664,10 +1923,10 @@ export class MysqlRepository { COUNT(*) as orders, COALESCE(SUM(amount), 0) as income FROM orders - WHERE (expert_id = ? OR companion_name LIKE ?) + WHERE expert_id = ? AND DATE(created_at) = ? AND status IN ('已完成', '已到场核销', '服务中', '待确认') - `, [eId, '%我%', dateStr]); + `, [eId, dateStr]); const count = Number(dayRows[0]?.orders || 0); const inc = Number(Number(dayRows[0]?.income || 0).toFixed(2)); const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0; @@ -1689,10 +1948,10 @@ export class MysqlRepository { COUNT(*) as orders, COALESCE(SUM(amount), 0) as income FROM orders - WHERE (expert_id = ? OR companion_name LIKE ?) + WHERE expert_id = ? AND created_at >= ? AND created_at <= ? AND status IN ('已完成', '已到场核销', '服务中', '待确认') - `, [eId, '%我%', startD, endD]); + `, [eId, startD, endD]); const count = Number(wRows[0]?.orders || 0); const inc = Number(Number(wRows[0]?.income || 0).toFixed(2)); const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0; @@ -1714,10 +1973,10 @@ export class MysqlRepository { COUNT(*) as orders, COALESCE(SUM(amount), 0) as income FROM orders - WHERE (expert_id = ? OR companion_name LIKE ?) + WHERE expert_id = ? AND DATE_FORMAT(created_at, '%Y-%m') = ? AND status IN ('已完成', '已到场核销', '服务中', '待确认') - `, [eId, '%我%', yearMonth]); + `, [eId, yearMonth]); const count = Number(mRows[0]?.orders || 0); const inc = Number(Number(mRows[0]?.income || 0).toFixed(2)); const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0; @@ -2059,6 +2318,22 @@ export class MysqlRepository { return rows; } + /** + * 依据平台启用的充值阶梯,服务端权威计算赠金(分)。 + * 规则:取「面额 <= 实充金额」的最高阶梯的赠金额;无匹配则赠金为 0。 + * 严禁信任客户端自定义赠金,防止充值套取平台赠金。 + */ + async computeRechargeBonusCents(amountCents: number): Promise { + const paid = Math.max(0, Math.round(Number(amountCents) || 0)); + if (paid <= 0) return 0; + const [rows]: any = await pool.query( + 'SELECT bonus_amount FROM recharge_tiers WHERE status = 1 AND amount * 100 <= ? ORDER BY amount DESC LIMIT 1', + [paid] + ); + if (!rows || rows.length === 0) return 0; + return Math.max(0, Math.round(Number(rows[0].bonus_amount) * 100)); + } + async getAdminRechargeTiers(): Promise { const [rows]: any = await pool.query(` SELECT @@ -2154,6 +2429,30 @@ export class MysqlRepository { } // 2. 优惠券 (Coupons) + /** + * 校验并归一化订单优惠券折扣(元):必须命中平台启用中的有效券 + * (面额一致、门槛满足、品类匹配),否则返回 0(忽略该折扣),防止客户端自定义大额折扣。 + */ + async resolveCouponDiscount(discountYuan: number, track: string, baseAmountYuan: number): Promise { + const d = Number(discountYuan) || 0; + if (d <= 0) return 0; + try { + const [rows]: any = await pool.query( + `SELECT discount_amount FROM coupons + WHERE status = 1 AND discount_amount = ? + AND (category_limit = 'ALL' OR category_limit = ?) + AND min_spend <= ? + LIMIT 1`, + [d, track, baseAmountYuan] + ); + if (!rows || rows.length === 0) return 0; + return Math.min(Number(rows[0].discount_amount) || 0, Number(baseAmountYuan) || 0); + } catch (e) { + logServerError('resolveCouponDiscount', e); + return 0; + } + } + async getCoupons(categoryLimit?: string): Promise { let sql = ` SELECT @@ -2518,11 +2817,12 @@ export class MysqlRepository { // 剩余未退部分解冻结算给大神 const remainAmt = Number(Math.max(0, totalOrderAmt - partialAmt).toFixed(2)); if (remainAmt > 0) { - const expertUserId = Number(order.expert_id || 101); + const comp = await this.resolveCompanionRow(order.expert_id || order.companion_name || 101); + const expertUserId = await this.resolveExpertCreditUserId(conn, order, comp); const expertIncome = Number((remainAmt * 0.9).toFixed(2)); await conn.query( - 'UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ? OR nickname = ?', - [expertIncome, expertUserId, order.companion_name || ''] + 'UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', + [expertIncome, expertUserId] ); const setId = `fin_pset_${Date.now()}`; const setNo = `PJS${Date.now()}`; @@ -2784,6 +3084,76 @@ export class MysqlRepository { } } + /** 读取提现工单及收款人 openid / 实名(用于真实微信打款) */ + async getWithdrawalForTransfer(id: string): Promise { + const [rows]: any = await pool.query(` + SELECT w.*, u.wechat_openid as userOpenid, u.nickname as userNickname + FROM withdrawals w + LEFT JOIN users u ON u.id = w.companion_id + WHERE w.id = ? + LIMIT 1 + `, [id]); + if (!rows || rows.length === 0) return null; + return rows[0]; + } + + /** 记录打款受理状态(转账单号 + 状态) */ + async markWithdrawalTransfer(id: string, billNo: string, status: string, transferStatus?: string): Promise { + await pool.query( + 'UPDATE withdrawals SET status = ?, transfer_bill_no = ?, transfer_status = ? WHERE id = ?', + [status, billNo, transferStatus || status, id] + ); + return { id, status, transferBillNo: billNo, transferStatus: transferStatus || status }; + } + + /** + * 依据微信转账单最终状态落库: + * SUCCESS -> 已打款;FAIL/CANCELLED -> 打款失败并回退预扣佣金;其它 -> 更新中间态。 + */ + async finalizeWithdrawalTransfer(outBillNo: string, state: string): Promise { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows]: any = await conn.query('SELECT * FROM withdrawals WHERE transfer_bill_no = ? FOR UPDATE', [outBillNo]); + if (!rows || rows.length === 0) { + await conn.rollback(); + return { handled: false, reason: 'not_found', outBillNo }; + } + const wd = rows[0]; + if (state === 'SUCCESS') { + if (wd.status !== '已打款') { + await conn.query("UPDATE withdrawals SET status = '已打款', transfer_status = 'SUCCESS', reason = '' WHERE id = ?", [wd.id]); + } + await conn.commit(); + return { handled: true, status: '已打款', userId: Number(wd.companion_id) || 0, amount: Number(wd.amount) || 0 }; + } + if (state === 'FAIL' || state === 'CANCELLED') { + if (wd.status !== '已打款' && wd.status !== '打款失败') { + const userId = Number(wd.companion_id); + const amt = Number(wd.amount); + if (userId > 0 && amt > 0) { + await conn.query('UPDATE users SET commission = commission + ? WHERE id = ?', [amt, userId]); + await conn.query(` + INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, ?, '提现打款失败退回', ?, 'IN', ?, ?, NOW()) + `, [`fin_wdf_${Date.now()}`, `WF${Date.now()}`, userId, amt, `微信商家转账失败(${state})退回`, String(wd.id)]); + } + await conn.query("UPDATE withdrawals SET status = '打款失败', transfer_status = ? WHERE id = ?", [state, wd.id]); + } + await conn.commit(); + return { handled: true, status: '打款失败' }; + } + await conn.query('UPDATE withdrawals SET transfer_status = ? WHERE id = ?', [state, wd.id]); + await conn.commit(); + return { handled: true, status: wd.status, transferStatus: state }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + } + async getFinanceConfig(): Promise { const [rows]: any = await pool.query(` SELECT @@ -2989,7 +3359,9 @@ export class MysqlRepository { try { const [rRows]: any = await pool.query('SELECT * FROM reports WHERE status = "待处理" ORDER BY created_at DESC'); rptRows = rRows || []; - } catch (_) {} + } catch (e) { + logServerError('getDashboardTodos.reports', e); + } const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0); @@ -3135,23 +3507,26 @@ export class MysqlRepository { ORDER BY p.created_at DESC `, [id]); - // 订单记录 + // 订单记录(按用户 id 关联,避免昵称重名导致错配) const [userOrders]: any = await pool.query(` SELECT id, order_no as orderNo, client_name as clientName, companion_name as companionName, track, expense_burden as expenseBurden, hourly_rate as hourlyRate, duration_hours as durationHours, amount, location, status, created_at as createdAt FROM orders - WHERE client_name = ? OR companion_name = ? + WHERE buyer_id = ? + OR expert_id = ? + OR claimed_by = ? + OR companion_name IN (SELECT name FROM companions WHERE user_id = ? OR id = ?) ORDER BY created_at DESC - `, [user.nickname, user.nickname]); + `, [id, id, id, id, id]); - // 资金流水 + // 资金流水(按 user_id 精确归属) const [financeRecords]: any = await pool.query(` SELECT id, record_no as recordNo, type, amount, direction, counterparty, related_id as relatedId, created_at as createdAt FROM finance_records - WHERE counterparty LIKE ? OR counterparty LIKE ? + WHERE user_id = ? ORDER BY created_at DESC - `, [`%${user.nickname}%`, `%${user.id}%`]); + `, [id]); // 风控日志 const [riskLogs]: any = await pool.query(` @@ -3299,6 +3674,22 @@ export class MysqlRepository { async findOrCreateUserByPhone(phone: string, openid?: string): Promise { const cleanPhone = phone.trim(); + + // 优先按微信 openid 精确匹配(微信身份全局唯一),避免手机号碰撞导致登录到他人账号 + if (openid) { + const [oidRows]: any = await pool.query( + 'SELECT id, phone FROM users WHERE wechat_openid = ? LIMIT 1', + [openid] + ); + if (oidRows && oidRows.length > 0) { + const u = oidRows[0]; + if (u.phone !== cleanPhone) { + await pool.query('UPDATE users SET phone = ? WHERE id = ?', [cleanPhone, u.id]); + } + return this.getUserById(u.id); + } + } + const [rows]: any = await pool.query( `SELECT id, user_no as userNo, nickname, avatar, phone, gender, signature, role, balance, commission, invited_count as invitedCount, @@ -3346,8 +3737,17 @@ export class MysqlRepository { values.push(data.avatar.trim()); } if (data.phone && data.phone.trim()) { + const newPhone = data.phone.trim(); + // 联系方式唯一性:避免把手机号改成他人已用号码造成登录身份碰撞 + const [dup]: any = await pool.query('SELECT id FROM users WHERE phone = ? AND id <> ? LIMIT 1', [newPhone, id]); + if (dup && dup.length > 0) { + const err: any = new Error('该联系方式已被其他账号使用,请更换后重试'); + err.status = 409; + err.code = 40900; + throw err; + } fields.push('phone = ?'); - values.push(data.phone.trim()); + values.push(newPhone); } if (data.gender && ['男', '女', '保密'].includes(data.gender)) { fields.push('gender = ?'); @@ -3535,6 +3935,24 @@ export class MysqlRepository { return { handled: true, already: true, scene: intent.scene, credited: Number(intent.credited) === 1 }; } + // 金额一致性校验:微信实收金额必须与支付意图金额完全一致, + // 杜绝伪造低价支付意图来「低价支付高价订单」或套取充值赠金。 + const paidCents = Number(opts?.amountTotalCents); + if (!isNaN(paidCents) && paidCents > 0 && paidCents !== Number(intent.amount_cents)) { + await conn.rollback(); + logServerError( + 'applyPaymentSuccess.amountMismatch', + new Error(`outTradeNo=${outTradeNo} 应收=${intent.amount_cents}分 实收=${paidCents}分`) + ); + return { + handled: false, + reason: 'amount_mismatch', + expectedCents: Number(intent.amount_cents), + paidCents, + outTradeNo, + }; + } + await conn.query( "UPDATE payment_intents SET status = 'SUCCESS', transaction_id = ?, paid_at = NOW() WHERE out_trade_no = ?", [opts?.transactionId || '', outTradeNo] @@ -3684,17 +4102,17 @@ export class MysqlRepository { const wdId = `wd_${Date.now()}`; const wdNo = `TX${Date.now()}`; await conn.query(` - INSERT INTO withdrawals (id, withdraw_no, companion_name, companion_id, amount, balance, channel, status, apply_time) - VALUES (?, ?, ?, ?, ?, ?, ?, '待审核', NOW()) - `, [wdId, wdNo, user.nickname, id, amount, remainingCommission, fullChannel]); + INSERT INTO withdrawals (id, withdraw_no, companion_name, companion_id, amount, balance, channel, payee_name, payee_account, status, apply_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '待审核', NOW()) + `, [wdId, wdNo, user.nickname, id, amount, remainingCommission, fullChannel, accountInfo?.realName || '', accountInfo?.account || '']); // 写入财务出账流水 const recId = `fin_wd_${Date.now()}`; const recNo = `TR${Date.now()}`; await conn.query(` - INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id, created_at) - VALUES (?, ?, '佣金提现', ?, 'OUT', ?, ?, NOW()) - `, [recId, recNo, amount, `${user.nickname} (${fullChannel})`, wdId]); + INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, ?, '佣金提现', ?, 'OUT', ?, ?, NOW()) + `, [recId, recNo, id, amount, `${user.nickname} (${fullChannel})`, wdId]); await conn.commit(); return { @@ -3714,15 +4132,13 @@ export class MysqlRepository { } async getUserFinanceRecords(userId: number): Promise { - const [uRows]: any = await pool.query('SELECT nickname FROM users WHERE id = ?', [userId]); - const nickname = uRows[0]?.nickname || ''; const [rows]: any = await pool.query(` SELECT id, record_no as recordNo, user_id as userId, type, amount, direction, counterparty, related_id as relatedId, created_at as createdAt FROM finance_records - WHERE user_id = ? OR counterparty LIKE ? OR counterparty LIKE ? OR related_id = ? + WHERE user_id = ? ORDER BY created_at DESC LIMIT 100 - `, [userId, `%${nickname}%`, `%${userId}%`, String(userId)]); + `, [Number(userId)]); return rows; } @@ -4649,13 +5065,6 @@ export class MysqlRepository { ...u, totalContribute: contribMap.get(Number(u.id)) || '0.00' })); - if (directList.length === 0) { - directList = [ - { id: 2001, nickname: '晨曦微风', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', phone: '138****6612', createdAt: '2026-10-01 14:20:00', level: 1, totalContribute: '48.50' }, - { id: 2002, nickname: '峡谷追风少年', avatar: 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=120', phone: '139****8833', createdAt: '2026-10-03 19:10:00', level: 1, totalContribute: '36.00' }, - { id: 2003, nickname: '软糖小鹿', avatar: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=120', phone: '137****1199', createdAt: '2026-10-05 21:05:00', level: 1, totalContribute: '24.00' } - ]; - } const directIds = directList.map((u: any) => u.id).filter(Boolean); let indirectList: any[] = []; @@ -4673,12 +5082,6 @@ export class MysqlRepository { })); } catch (_) {} } - if (indirectList.length === 0) { - indirectList = [ - { id: 3001, nickname: '星空漫步者', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=120', phone: '150****5521', createdAt: '2026-10-06 11:30:00', level: 2, totalContribute: '12.00' }, - { id: 3002, nickname: '吃饱就睡的喵', avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=120', phone: '151****9932', createdAt: '2026-10-08 16:45:00', level: 2, totalContribute: '8.00' } - ]; - } return { directCount: directList.length, @@ -4722,11 +5125,8 @@ export class MysqlRepository { } } catch (_) {} - return [ - { id: 'dst_101', fromUserName: '晨曦微风', orderNo: 'PW20261001991', orderAmount: '99.00', commission: '4.95', level: 1, ratePercent: '5.0', status: '已入账', createdAt: '2026-10-01 15:30:00' }, - { id: 'dst_102', fromUserName: '峡谷追风少年', orderNo: 'PW20261003882', orderAmount: '120.00', commission: '6.00', level: 1, ratePercent: '5.0', status: '已入账', createdAt: '2026-10-03 20:12:00' }, - { id: 'dst_103', fromUserName: '星空漫步者', orderNo: 'PW20261006117', orderAmount: '150.00', commission: '3.00', level: 2, ratePercent: '2.0', status: '已入账', createdAt: '2026-10-06 12:40:00' } - ]; + // 无真实返佣记录时返回空数组,不再用硬编码假数据冒充实盘收益 + return []; } } diff --git a/server/src/index.ts b/server/src/index.ts index e05291d..4e30d8a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,7 +24,10 @@ import { verifyNotifySignature, decryptNotifyResource, queryOrderByOutTradeNo, + transferToWallet, + queryTransferByOutBillNo, } from './core/pay/wechat-pay-v3'; +import { sendSubscribeMessageHttp, buildTemplateData } from './core/notify/wechat-subscribe.service'; const app = express(); const port = process.env.PORT || 3000; @@ -119,7 +122,22 @@ async function resolveUserOpenid(userId: number): Promise { } // ===== 管理后台鉴权(HMAC 签名 token,防止客户端伪造管理员身份)===== -const ADMIN_TOKEN_SECRET = process.env.ADMIN_TOKEN_SECRET || 'peiwan_admin_token_secret_v1'; +const DEFAULT_ADMIN_TOKEN_SECRET = 'peiwan_admin_token_secret_v1'; +// 生产环境安全闸门:若未配置 ADMIN_TOKEN_SECRET,绝不使用仓库内置默认密钥 +// (该默认值公开于代码库,可被用于伪造管理员 token 触发全站管理端越权)。 +// 生产环境改为启用进程内随机密钥——默认密钥签发的 token 一律失效,并高优告警要求尽快配置固定密钥。 +const ADMIN_TOKEN_SECRET = (() => { + const provided = String(process.env.ADMIN_TOKEN_SECRET || '').trim(); + if (provided) return provided; + if (process.env.NODE_ENV === 'production') { + console.error( + '[Peiwan Server][SECURITY] 生产环境未配置 ADMIN_TOKEN_SECRET,已临时改用进程内随机密钥(默认密钥签发的 token 一律失效)。' + + '请尽快在环境变量中设置固定的强随机 ADMIN_TOKEN_SECRET(并建议单独设置 USER_TOKEN_SECRET),否则每次重启后台登录都会失效。' + ); + return crypto.randomBytes(48).toString('base64url'); + } + return DEFAULT_ADMIN_TOKEN_SECRET; +})(); const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'; const ADMIN_PASSWORDS = (process.env.ADMIN_PASSWORD || 'admin|admin123').split('|').filter(Boolean); @@ -166,6 +184,43 @@ function isVerifiedAdminRequest(req: Request): boolean { return Boolean(token) && verifyAdminToken(token); } +// 常量时间字符串比较(避免口令比较的时序侧信道) +function safeStringEqual(a: string, b: string): boolean { + const ba = Buffer.from(String(a)); + const bb = Buffer.from(String(b)); + if (ba.length !== bb.length) return false; + return crypto.timingSafeEqual(ba, bb); +} + +// 管理端登录暴力破解防护(内存级:按 IP+账号 统计失败次数,超限锁定一段时间) +const LOGIN_MAX_FAILURES = 5; +const LOGIN_WINDOW_MS = 10 * 60 * 1000; +const loginFailureMap = new Map(); + +function clientIpOf(req: Request): string { + const xff = req.headers['x-forwarded-for']; + const raw = Array.isArray(xff) ? xff[0] : (xff || (req.socket && req.socket.remoteAddress) || 'unknown'); + return String(raw).split(',')[0].trim() || 'unknown'; +} +function isLoginLocked(key: string): boolean { + const rec = loginFailureMap.get(key); + if (!rec) return false; + if (Date.now() - rec.firstAt > LOGIN_WINDOW_MS) { + loginFailureMap.delete(key); + return false; + } + return rec.count >= LOGIN_MAX_FAILURES; +} +function recordLoginFailure(key: string): void { + const rec = loginFailureMap.get(key); + const now = Date.now(); + if (!rec || now - rec.firstAt > LOGIN_WINDOW_MS) { + loginFailureMap.set(key, { count: 1, firstAt: now }); + } else { + rec.count += 1; + } +} + // ===== 小程序用户会话 token(HMAC 签名,防止客户端伪造他人 userId 进行资金操作)===== const USER_TOKEN_SECRET = process.env.USER_TOKEN_SECRET || `${ADMIN_TOKEN_SECRET}_user`; const USER_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000; @@ -276,8 +331,14 @@ app.post('/api/user/login', (req: Request, res: Response) => { if (!username || !password) { return res.status(400).json({ code: 50000, message: '用户名和密码不能为空' }); } + const loginKey = `${clientIpOf(req)}|${String(username)}`; + if (isLoginLocked(loginKey)) { + return res.status(429).json({ code: 50000, message: '登录失败次数过多,请 10 分钟后再试' }); + } // 管理凭证默认 admin/admin,可通过环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 覆盖 - if (username === ADMIN_USERNAME && ADMIN_PASSWORDS.includes(String(password))) { + const passwordOk = ADMIN_PASSWORDS.some((p) => safeStringEqual(String(password), p)); + if (safeStringEqual(String(username), ADMIN_USERNAME) && passwordOk) { + loginFailureMap.delete(loginKey); return res.json({ code: 20000, data: { @@ -286,6 +347,7 @@ app.post('/api/user/login', (req: Request, res: Response) => { message: '登录成功' }); } + recordLoginFailure(loginKey); return res.status(401).json({ code: 50000, message: '账号或密码错误' }); }); @@ -435,6 +497,14 @@ app.all('/api/user/save-info', (_req: Request, res: Response) => { // 真实文件与图片对象存储上传接口 (支持单图上传并持久化至 public/uploads) const handleFileUpload = (req: Request, res: Response) => { + // 生产环境要求登录后上传,避免匿名上传被滥用(刷盘/托管非法内容) + if (IS_PRODUCTION) { + try { + resolveSecureUserId(req, 0, '上传文件'); + } catch (e: any) { + return res.status(e.status || 401).json({ code: e.code || 40100, message: e.message || '请先登录后再上传' }); + } + } upload.single('file')(req, res, (err: any) => { if (err) { return res.status(400).json({ @@ -485,28 +555,61 @@ app.post('/api/user/upload', handleFileUpload); // 微信官方内容安全文本检测接口 (对接 security.msgSecCheck) app.post('/api/security/msg-sec-check', async (req: Request, res: Response) => { - const { content, openid, scene } = req.body || {}; - const checkResult = await contentSecurityService.checkTextAsync(content, openid, typeof scene === 'number' ? scene : undefined); - if (!checkResult.pass) { - return res.status(400).json({ - code: checkResult.code, + try { + let callerId = 0; + if (IS_PRODUCTION) { + // 生产环境要求登录,避免匿名滥用微信内容安全额度 + callerId = resolveSecureUserId(req, Number(req.body?.userId || req.headers['x-user-id'] || 0), '内容安全检测'); + } + const { content, scene } = req.body || {}; + // openid 以服务端会话用户为准,杜绝伪造他人 openid + const openid = callerId > 0 ? await resolveUserOpenid(callerId) : req.body?.openid; + const checkResult = await contentSecurityService.checkTextAsync(content, openid, typeof scene === 'number' ? scene : undefined); + if (!checkResult.pass) { + return res.status(400).json({ + code: checkResult.code, + data: { + suggest: checkResult.suggest, + label: checkResult.label + }, + message: checkResult.message + }); + } + res.json({ + code: 20000, data: { - suggest: checkResult.suggest, - label: checkResult.label + suggest: checkResult.suggest }, - message: checkResult.message + message: '内容合规通过' }); + } catch (err: any) { + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } - res.json({ - code: 20000, - data: { - suggest: checkResult.suggest - }, - message: '内容合规通过' - }); }); // 1.3 顶部通知中心接口 +// 小程序获取订阅消息模板ID(用于 wx.requestSubscribeMessage 请求用户授权;模板ID非敏感) +app.get('/api/message/subscribe-templates', async (_req: Request, res: Response) => { + try { + const record = await repo.getPlatformConfigByKey('message_template_rules'); + const rules = (record && record.valueJson) || {}; + res.json({ + code: 20000, + data: { + enablePush: Number(rules.enablePush) === 1, + orderStatusTemplateId: String(rules.orderStatusTemplateId || ''), + activityJoinTemplateId: String(rules.activityJoinTemplateId || ''), + withdrawSuccessTemplateId: String(rules.withdrawSuccessTemplateId || ''), + verifyCodeTemplateId: String(rules.verifyCodeTemplateId || '') + } + }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + app.all('/api/message/list', (_req: Request, res: Response) => { res.json({ code: 20000, @@ -586,16 +689,39 @@ app.get('/api/activities/:id', async (req: Request, res: Response) => { if (!act) { return res.status(404).json({ code: 40400, message: '活动不存在' }); } - res.json({ code: 20000, data: act }); + // 核销码属于该参与人的"门票",严禁向他人泄露: + // 仅管理员可见全部,普通访问者仅可见本人的核销码,其余参与者剥离 verifyCode。 + const isAdmin = isVerifiedAdminRequest(req); + let callerId = 0; + if (!isAdmin) { + try { + callerId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看组局详情'); + } catch { + callerId = 0; + } + } + const participants = (act.participants || []).map((p: any) => { + if (isAdmin || (callerId > 0 && Number(p.userId) === callerId)) return p; + const { verifyCode, ...rest } = p; + return rest; + }); + res.json({ code: 20000, data: { ...act, participants } }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.post('/api/activities', async (req: Request, res: Response) => { try { const { title, location, startTime } = req.body || {}; - const creatorId = Number(req.body?.creatorId || req.headers['x-user-id'] || 1001); + // 身份以服务端会话为准,杜绝冒充他人发起组局(发起人即资金结算归属人) + const creatorId = resolveSecureUserId( + req, + Number(req.body?.creatorId || req.headers['x-user-id'] || 0), + '发起组局' + ); await assertUserNotBanned(creatorId, '发起组局或发布活动'); if (!title || !String(title).trim()) { @@ -639,6 +765,21 @@ app.post('/api/activities/:id/join', async (req: Request, res: Response) => { userId: uid, payAmountCents: Number(payAmountCents || 0) }); + try { + const act = await repo.getActivityById(actId); + void notifyBySubscribe('activityJoin', { + userId: uid, + page: '/pages/activity/my-ticket', + data: { + thing1: String(act?.title || '同城组局活动'), + time2: formatCnTime(), + time3: toCnTimeOrNow(act?.startTime), + thing4: String(act?.location || '详见活动页') + } + }); + } catch (e) { + logServerError('joinActivity.notify', e); + } res.json({ code: 20000, data: participant, @@ -674,10 +815,27 @@ app.post('/api/activities/:id/verify', async (req: Request, res: Response) => { // 通用4位核销码反查信息 (支持组局活动与同城线下订单) app.get('/api/verify/query/:code', async (req: Request, res: Response) => { try { + const operator = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查询核销码'); const info = await repo.queryCheckinCodeInfo(String(req.params.code)); + // 生产环境:仅门票归属方(活动发起人 / 订单承接大神)或管理员可查看核销码详情, + // 防止对 4 位纯数字核销码做批量枚举探测。 + if (IS_PRODUCTION && !isVerifiedAdminRequest(req)) { + let allowed = false; + if (info.type === 'ACTIVITY') { + allowed = Number(info.creatorId) === operator; + } else if (info.type === 'ORDER') { + const own = await repo.getOrderOwnership(String(info.id)); + allowed = Boolean(own) && own!.companionIds.includes(operator); + } + if (!allowed) { + return res.status(403).json({ code: 40300, message: '无权查看该核销码信息' }); + } + } res.json({ code: 20000, data: info }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -736,9 +894,10 @@ app.post('/api/activities/:id/cancel', async (req: Request, res: Response) => { // 管理员特权只能来自服务端签名校验通过的 admin token; // 严禁信任客户端可伪造的 operatorRole / x-user-role,防止普通用户提权解散他人组局。 const isAdmin = isVerifiedAdminRequest(req); - const callerId = Number(hostId || req.headers['x-user-id'] || 0); - if (!callerId && !isAdmin) { - return res.status(400).json({ code: 40000, message: '缺少操作人身份,无法解散组局' }); + // 非管理员操作人身份以服务端会话为准,杜绝冒充房主解散他人组局并触发退款 + let callerId = 0; + if (!isAdmin) { + callerId = resolveSecureUserId(req, Number(hostId || req.headers['x-user-id'] || 0), '解散组局'); } const result = await repo.cancelActivity(actId, callerId, reason, isAdmin); res.json({ @@ -747,7 +906,9 @@ app.post('/api/activities/:id/cancel', async (req: Request, res: Response) => { message: `组局已成功解散下架${result.refundedCount > 0 ? `,已原路全额退款 ${result.refundedCount} 位搭子` : ''}` }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -773,21 +934,33 @@ app.post('/api/activities/:id/leave', async (req: Request, res: Response) => { // 用户“我的组局”(发起的 + 报名的) app.get('/api/users/:id/my-activities', async (req: Request, res: Response) => { try { - const userId = Number(req.params.id); + const userId = resolveSecureUserId(req, Number(req.params.id), '查看我的组局'); const data = await repo.getMyActivities(userId); res.json({ code: 20000, data }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 4. 陪玩与大神管理接口 (落盘 MySQL) -app.get('/api/companions', async (_req: Request, res: Response) => { +app.get('/api/companions', async (req: Request, res: Response) => { try { const companions = await repo.getCompanions(); + // 管理端可见全量(含待审核/已驳回及内部备注);公开目录仅展示已通过审核的大神,并剥离内部审核备注 + if (isVerifiedAdminRequest(req)) { + return res.json({ code: 20000, data: companions }); + } + const publicList = companions + .filter((c: any) => c.auditStatus === '已通过') + .map((c: any) => { + const { rejectReason, ...rest } = c; + return rest; + }); res.json({ code: 20000, - data: companions + data: publicList }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); @@ -800,9 +973,14 @@ app.get('/api/companions/:id', async (req: Request, res: Response) => { if (!companion) { return res.status(404).json({ code: 40400, message: '未找到指定陪伴师资料' }); } + // 公开详情剥离内部审核备注;管理端保留完整字段 + if (isVerifiedAdminRequest(req)) { + return res.json({ code: 20000, data: companion }); + } + const { rejectReason, ...publicCompanion } = companion; res.json({ code: 20000, - data: companion + data: publicCompanion }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); @@ -994,15 +1172,30 @@ app.put('/api/companions/:id/profile', async (req: Request, res: Response) => { // 5. 订单与仲裁接口 (落盘 MySQL) -app.get('/api/orders', async (_req: Request, res: Response) => { +app.get('/api/orders', async (req: Request, res: Response) => { try { - const orders = await repo.getOrders(); + // 管理端(服务端签名 admin token)可取全量;普通用户仅能取与自己相关的订单 + if (isVerifiedAdminRequest(req)) { + const orders = await repo.getOrders(); + return res.json({ + code: 20000, + data: orders + }); + } + const uid = resolveSecureUserId( + req, + Number(req.query.userId || req.headers['x-user-id'] || 0), + '查看订单列表' + ); + const orders = await repo.getOrdersForUser(uid); res.json({ code: 20000, data: orders }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1024,11 +1217,15 @@ app.post('/api/orders/:id/claim', async (req: Request, res: Response) => { if (isCertified === false || operatorRole === 'user' || operatorRole === 'PLAYER') { return res.status(403).json({ code: 40300, message: '未认证用户无法接单' }); } - const operatorId = Number(req.body?.expertId || req.body?.companionId || req.body?.userId || req.headers['x-user-id'] || 0); + const operatorId = resolveSecureUserId( + req, + Number(req.body?.expertId || req.body?.companionId || req.body?.userId || req.headers['x-user-id'] || 0), + '接单' + ); if (operatorId > 0) { await assertUserNotBanned(operatorId, '接单'); } - const order = await repo.claimOrder(orderId, req.body); + const order = await repo.claimOrder(orderId, { ...(req.body || {}), expertId: operatorId }); res.json({ code: 20000, data: order, message: '抢单成功,服务已锁定为履约中' }); } catch (err: any) { const statusCode = err.status || 400; @@ -1040,20 +1237,30 @@ app.post('/api/orders/:id/claim', async (req: Request, res: Response) => { // 大神今日接单数据汇总 (落盘 MySQL) app.get('/api/expert/stats/:id', async (req: Request, res: Response) => { try { + // 收益数据属敏感信息:仅该大神本人或管理员可查看 + const own = await repo.getCompanionOwnership(String(req.params.id)); + assertCompanionActor(req, own, '查看大神收益数据'); const stats = await repo.getExpertStats(String(req.params.id)); res.json({ code: 20000, data: stats }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 大神日/周/月多维收益走势统计分析接口 app.get('/api/expert/stats/:id/trends', async (req: Request, res: Response) => { try { + // 收益走势属敏感信息:仅该大神本人或管理员可查看 + const own = await repo.getCompanionOwnership(String(req.params.id)); + assertCompanionActor(req, own, '查看大神收益走势'); const trends = await repo.getExpertRevenueTrends(String(req.params.id)); res.json({ code: 20000, data: trends }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1609,7 +1816,10 @@ app.post('/api/orders/:id/cancel', async (req: Request, res: Response) => { if (!role.isAdmin && !role.isBuyer) { return res.status(403).json({ code: 40300, message: '仅买家本人或平台管理员可取消该订单' }); } - const order = await repo.updateOrderStatus(orderId, '已取消'); + // 买家取消:按状态安全处理(已付款待接单会原路退款;已进入履约需走退款申诉),避免静默吞掉已付款资金 + const order = role.isAdmin + ? await repo.updateOrderStatus(orderId, '已取消') + : await repo.buyerCancelOrder(orderId); res.json({ code: 20000, data: order, message: '订单已取消' }); } catch (err: any) { const statusCode = err.status || 400; @@ -1622,6 +1832,24 @@ app.post('/api/orders/:id/cancel', async (req: Request, res: Response) => { app.post('/api/orders/:id/pay', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); + + // 归属校验:仅该订单的下单买家本人(或管理员)可确认支付,杜绝替他人订单付款/推进状态 + const own = await repo.getOrderOwnership(orderId); + if (!own) { + return res.status(404).json({ code: 40400, message: '订单不存在' }); + } + if (!isVerifiedAdminRequest(req)) { + const buyerId = Number(own.buyerId || 0); + const caller = resolveSecureUserId( + req, + Number(req.body?.userId || req.query.userId || req.headers['x-user-id'] || 0), + '确认订单支付' + ); + if (buyerId > 0 && caller !== buyerId) { + return res.status(403).json({ code: 40300, message: '仅下单买家本人可确认支付该订单' }); + } + } + const cfg = await resolveWechatPayRuntimeConfig(); // 生产 LIVE:必须存在服务端确认的微信支付收款,杜绝前端直接改状态 @@ -1631,6 +1859,17 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => { return res.status(400).json({ code: 40000, message: '微信支付尚未完成,无法确认订单支付' }); } const order = await repo.updateOrderStatus(orderId, '履约中'); + const buyer = Number(own.buyerId || 0); + void notifyBySubscribe('orderStatus', { + userId: buyer, + page: '/pages/order/list', + data: { + character_string1: String(order?.order_no || orderId), + phrase2: '已开始服务', + thing3: String(order?.service_title || order?.track || '同城陪伴服务'), + thing4: order?.verify_code ? `核销码${order.verify_code},请出示` : '请按时到场,感谢支持' + } + }); return res.json({ code: 20000, data: order, message: '支付成功,订单已进入履约中' }); } @@ -1646,9 +1885,22 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => { return res.status(400).json({ code: 40000, message: `非法的目标订单状态: ${requested}` }); } const order = await repo.updateOrderStatus(orderId, requested); + const buyer = Number(own.buyerId || 0); + void notifyBySubscribe('orderStatus', { + userId: buyer, + page: '/pages/order/list', + data: { + character_string1: String(order?.order_no || orderId), + phrase2: '已开始服务', + thing3: String(order?.service_title || order?.track || '同城陪伴服务'), + thing4: order?.verify_code ? `核销码${order.verify_code},请出示` : '请按时到场,感谢支持' + } + }); res.json({ code: 20000, data: order, message: `支付成功,订单已进入${requested}` }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1693,7 +1945,14 @@ async function resolveWechatPayRuntimeConfig(overrideCfg?: any) { const privateKey = String(merged.privateKey || '').trim(); const platformPublicKeyId = String(merged.platformPublicKeyId || '').trim(); const platformPublicKey = String(merged.platformPublicKey || '').trim(); + // 商家转账到零钱(提现打款)相关配置 + const transferSceneId = String(merged.transferSceneId || '').trim(); + const transferRemark = String(merged.transferRemark || '').trim(); + const transferNotifyUrl = String(merged.transferNotifyUrl || '').trim(); + const transferUserNameEnabled = merged.transferUserNameEnabled === true || merged.transferUserNameEnabled === 'true' || Number(merged.transferUserNameEnabled) === 1; + const transferSceneReportInfos = Array.isArray(merged.transferSceneReportInfos) ? merged.transferSceneReportInfos : []; const isReadyForLive = Boolean(appId && mchId && apiV3Key.length === 32 && privateKey); + const isReadyForTransfer = Boolean(isReadyForLive && transferSceneId); let effectiveMode: 'LIVE' | 'SANDBOX' = 'SANDBOX'; if (merged.mode === 'LIVE') { @@ -1715,7 +1974,13 @@ async function resolveWechatPayRuntimeConfig(overrideCfg?: any) { certPem, platformPublicKeyId, platformPublicKey, + transferSceneId, + transferRemark, + transferNotifyUrl, + transferUserNameEnabled, + transferSceneReportInfos, isReadyForLive, + isReadyForTransfer, effectiveMode }; } @@ -1806,6 +2071,11 @@ app.post('/api/admin/pay/wechat/config', async (req: Request, res: Response) => platformPublicKeyId: String(incoming.platformPublicKeyId || '').trim(), platformPublicKey: String(incoming.platformPublicKey || '').trim(), notifyUrl: String(incoming.notifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/notify').trim(), + transferSceneId: String(incoming.transferSceneId || '').trim(), + transferRemark: String(incoming.transferRemark || '').trim(), + transferNotifyUrl: String(incoming.transferNotifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/transfer-notify').trim(), + transferUserNameEnabled: incoming.transferUserNameEnabled === true || incoming.transferUserNameEnabled === 'true' || Number(incoming.transferUserNameEnabled) === 1, + transferSceneReportInfos: Array.isArray(incoming.transferSceneReportInfos) ? incoming.transferSceneReportInfos : [], remark: String(incoming.remark || '微信支付V3直连商户参数配置').trim() }; @@ -1965,19 +2235,22 @@ app.post('/api/wechat/code2session', async (req: Request, res: Response) => { }); } - const { openid, session_key, unionid } = wxData; + const { openid, unionid } = wxData; if (openid && userId) { try { - await repo.updateUserOpenid(Number(userId), openid); - } catch (_) {} + // 绑定 openid 必须与会话身份一致,杜绝把他人 openid 关联到指定 userId 造成账号劫持 + const claimed = resolveSecureUserId(req, Number(userId), '绑定微信账户'); + await repo.updateUserOpenid(claimed, openid); + } catch (e) { + logServerError('login.updateUserOpenid', e); + } } res.json({ code: 20000, data: { openid, - unionid, - sessionKey: session_key + unionid }, message: '微信 openid 获取成功' }); @@ -2020,6 +2293,72 @@ async function getWechatAccessToken(appId: string, appSecret: string): Promise String(n).padStart(2, '0'); + return `${d.getFullYear()}年${p(d.getMonth() + 1)}月${p(d.getDate())}日 ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +// 将任意时间字符串转为微信 time 字段可接受的格式;非标准日期(如"今晚")则回退为当前时间,避免 47003 +function toCnTimeOrNow(value?: string): string { + const s = String(value || '').trim(); + if (!s) return formatCnTime(); + const looksDateLike = /\d{4}[-/年]\d{1,2}|\d{1,2}:\d{2}/.test(s); + if (!looksDateLike) return formatCnTime(); + const d = new Date(s.replace(/年|月/g, '/').replace(/日/g, ' ').trim()); + if (isNaN(d.getTime())) return formatCnTime(); + return formatCnTime(d); +} + +async function notifyBySubscribe( + templateKey: 'orderStatus' | 'activityJoin' | 'withdrawSuccess' | 'verifyCode', + opts: { userId: number; page?: string; data: Record } +): Promise { + try { + const uid = Number(opts.userId) || 0; + if (uid <= 0) return; + const rulesRecord = await repo.getPlatformConfigByKey('message_template_rules'); + const rules = (rulesRecord && rulesRecord.valueJson) || {}; + if (Number(rules.enablePush) !== 1) return; + const keyMap: Record = { + orderStatus: 'orderStatusTemplateId', + activityJoin: 'activityJoinTemplateId', + withdrawSuccess: 'withdrawSuccessTemplateId', + verifyCode: 'verifyCodeTemplateId', + }; + const templateId = String(rules[keyMap[templateKey]] || '').trim(); + if (!templateId) return; + + const payCfg = await resolveWechatPayRuntimeConfig(); + const appId = String(payCfg.appId || '').trim(); + const appSecret = String(payCfg.appSecret || '').trim(); + if (!appId || !appSecret) return; + + const [uRows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [uid]); + const openid = uRows && uRows[0] && uRows[0].wechat_openid; + if (!openid) return; + + const accessToken = await getWechatAccessToken(appId, appSecret); + const result = await sendSubscribeMessageHttp(accessToken, { + touser: String(openid), + template_id: templateId, + page: opts.page, + data: buildTemplateData(opts.data || {}), + miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer', + }); + if (result && result.errcode && result.errcode !== 0) { + logServerError('notifyBySubscribe', new Error(`订阅消息[${templateKey}]下发失败 ${result.errcode} ${result.errmsg}`)); + } + } catch (e) { + logServerError('notifyBySubscribe', e); + } +} + // 注册微信官方内容安全 msgSecCheck(v2) 真实检测器: // 本地词库先做即时拦截,本地放行后再交由微信官方机器审核做第二层真实防御。 // 未配置真实小程序凭证(仍为内置占位值)或显式关闭时,自动跳过远端并降级本地词库。 @@ -2113,7 +2452,9 @@ app.post('/api/auth/wechat-phone', async (req: Request, res: Response) => { if (sData && sData.openid) { openid = sData.openid; } - } catch (_) {} + } catch (e) { + logServerError('login.openidFallback', e); + } } // 2. 获取并复用微信 access_token @@ -2194,16 +2535,35 @@ app.post('/api/pay/wechat/prepay', async (req: Request, res: Response) => { userId = 1001, wxCode, openid: inputOpenid, - bonusAmount, } = req.body || {}; - const payAmount = Number(amount || 0); + + // 身份以服务端会话为准,杜绝替他人发起支付 + const uid = resolveSecureUserId(req, Number(userId), '发起微信支付'); + + let payAmount = Number(amount || 0); + + // 订单支付:金额必须以订单为准,且仅下单买家本人(或管理员)可发起, + // 杜绝通过伪造低价金额实现「低价支付高价订单」。 + if (String(scene) === 'ORDER' && orderId) { + const own = await repo.getOrderOwnership(String(orderId)); + if (!own) { + return res.status(404).json({ code: 40400, message: '订单不存在' }); + } + if (!isVerifiedAdminRequest(req)) { + const buyerId = Number(own.buyerId || 0); + if (buyerId > 0 && uid !== buyerId) { + return res.status(403).json({ code: 40300, message: '仅下单买家本人可发起该订单支付' }); + } + } + payAmount = Number(own.amount) || 0; + } + if (isNaN(payAmount) || payAmount <= 0) { return res.status(400).json({ code: 40000, message: '支付金额必须大于0' }); } const cfg = await resolveWechatPayRuntimeConfig(); const appId = cfg.appId || 'wx0c929f7e54024e67'; - const uid = Number(userId) || 0; const outTradeNo = orderId ? String(orderId) : `WX${scene === 'RECHARGE' ? 'R' : 'O'}${Date.now()}${uid}`; const desc = title || (scene === 'RECHARGE' ? '钱包余额充值' : '同城陪伴担保订单'); @@ -2213,7 +2573,9 @@ app.post('/api/pay/wechat/prepay', async (req: Request, res: Response) => { try { const u: any = await repo.getUserById(uid); openid = (u && (u.wechatOpenid || u.wechat_openid)) || ''; - } catch (_) {} + } catch (e) { + logServerError('prepay.resolveOpenid', e); + } } if (!openid && wxCode && cfg.appSecret) { try { @@ -2251,14 +2613,19 @@ app.post('/api/pay/wechat/prepay', async (req: Request, res: Response) => { } // 登记支付意图(对账与幂等入账的唯一依据) + const amountCents = Math.round(payAmount * 100); + // 充值赠金一律由服务端按平台阶梯权威计算,忽略客户端 bonusAmount,防止充值套取平台赠金 + const bonusCents = + String(scene) === 'RECHARGE' ? await repo.computeRechargeBonusCents(amountCents) : 0; + await repo.createPaymentIntent({ outTradeNo, scene: String(scene), userId: uid, orderId: orderId ? String(orderId) : '', title: desc, - amountCents: Math.round(payAmount * 100), - bonusCents: Math.round(Number(bonusAmount || 0) * 100), + amountCents, + bonusCents, }); // LIVE:调用微信支付 V3 JSAPI 下单,返回真实签名参数 @@ -2314,7 +2681,9 @@ app.post('/api/pay/wechat/prepay', async (req: Request, res: Response) => { message: '预支付交易单生成成功(沙箱联调模式)', }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -2366,8 +2735,52 @@ app.post('/api/pay/wechat/notify', async (req: Request, res: Response) => { } }); - -// 大神拒单自动全额退款 (待接单 -> 已退款) +// 微信「商家转账到零钱」结果回调(验签 + 解密 + 幂等落库) +app.post('/api/pay/wechat/transfer-notify', async (req: Request, res: Response) => { + try { + const cfg = await resolveWechatPayRuntimeConfig(); + const rawBody = (req as any).rawBody || JSON.stringify(req.body || {}); + const timestamp = String(req.headers['wechatpay-timestamp'] || ''); + const nonce = String(req.headers['wechatpay-nonce'] || ''); + const signature = String(req.headers['wechatpay-signature'] || ''); + const serial = String(req.headers['wechatpay-serial'] || ''); + if (!signature) { + return res.status(401).json({ code: 'FAIL', message: '缺少微信支付签名头' }); + } + if (!cfg.apiV3Key) { + return res.status(500).json({ code: 'FAIL', message: '未配置 APIv3 密钥,无法解密回调' }); + } + const verified = verifyNotifySignature({ cfg, timestamp, nonce, body: rawBody, signature, serial }); + if (!verified) { + return res.status(401).json({ code: 'FAIL', message: '转账回调验签失败' }); + } + const payload: any = req.body || {}; + const resource = payload.resource; + if (resource && resource.ciphertext) { + const plain = decryptNotifyResource(cfg.apiV3Key, resource); + const outBillNo = plain && (plain.out_bill_no || plain.outBillNo); + const state = plain && (plain.state || plain.transfer_state); + if (outBillNo && state) { + const r: any = await repo.finalizeWithdrawalTransfer(String(outBillNo), String(state)); + if (r && r.status === '已打款' && r.userId) { + void notifyBySubscribe('withdrawSuccess', { + userId: Number(r.userId), + page: '/pages/user/withdraw-records', + data: { + amount2: Number(r.amount || 0).toFixed(2), + phrase5: '已到账', + time7: formatCnTime(), + amount8: Number(r.amount || 0).toFixed(2) + } + }); + } + } + } + res.json({ code: 'SUCCESS', message: '成功' }); + } catch (err: any) { + res.status(500).json({ code: 'FAIL', message: err.message }); + } +}); app.post('/api/orders/:id/reject', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); @@ -2431,7 +2844,8 @@ app.post('/api/orders/:id/complete', async (req: Request, res: Response) => { }); // 扫描处理超时订单 (15m未支付自动取消 / 30m未接单自动退款 / 48h待确认自动完成) -app.post('/api/orders/process-timeouts', async (_req: Request, res: Response) => { +// 订单超时自动流转扫描 (自动取消/自动退款/自动完成结算):仅管理员可手动触发 +app.post('/api/orders/process-timeouts', requireAdmin, async (_req: Request, res: Response) => { try { const result = await repo.processOrderTimeouts(); res.json({ code: 20000, data: result, message: '订单超时自动流转扫描完毕' }); @@ -2500,11 +2914,18 @@ app.post('/api/reports', async (req: Request, res: Response) => { app.get('/api/reports', async (req: Request, res: Response) => { try { - const reporterId = req.query.reporterId ? Number(req.query.reporterId) : undefined; - const list = await repo.getReports(reporterId); + // 管理端可取全量;普通用户仅能查看自己提交的举报工单 + if (isVerifiedAdminRequest(req)) { + const reporterId = req.query.reporterId ? Number(req.query.reporterId) : undefined; + return res.json({ code: 20000, data: await repo.getReports(reporterId) }); + } + const uid = resolveSecureUserId(req, Number(req.query.reporterId || req.headers['x-user-id'] || 0), '查看我的举报'); + const list = await repo.getReports(uid); res.json({ code: 20000, data: list }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -2584,8 +3005,80 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon try { const wdId = String(req.params.id); const { action, rejectReason } = req.body; // 'approve' | 'reject' - const result = await repo.reviewWithdrawal(wdId, action, rejectReason); - res.json({ code: 20000, data: result, message: action === 'approve' ? '提现申请审核通过,款项已通过微信商户零钱打款' : `提现驳回: ${rejectReason || '风控核验未通过'}` }); + if (!['approve', 'reject'].includes(String(action))) { + return res.status(400).json({ code: 40000, message: 'action 必须为 approve 或 reject' }); + } + + // 驳回:退还预扣佣金 + if (action === 'reject') { + const result = await repo.reviewWithdrawal(wdId, 'reject', rejectReason); + return res.json({ code: 20000, data: result, message: `提现驳回: ${rejectReason || '风控核验未通过'}` }); + } + + // 通过:按运行模式决定是否真实发起「微信商家转账到零钱」 + const cfg = await resolveWechatPayRuntimeConfig(); + const wd = await repo.getWithdrawalForTransfer(wdId); + if (!wd) { + return res.status(404).json({ code: 40400, message: '提现工单不存在' }); + } + if (wd.status !== '待审核') { + return res.status(409).json({ code: 40900, message: `当前提现工单已处理,状态为【${wd.status}】` }); + } + + const useRealTransfer = cfg.effectiveMode === 'LIVE' && cfg.isReadyForTransfer; + if (useRealTransfer) { + if (!wd.userOpenid) { + return res.status(400).json({ code: 40000, message: '该大神缺少微信 openid,无法商家转账到零钱;请其重新微信登录授权后再打款' }); + } + const amountCents = Math.round(Number(wd.amount) * 100); + if (!amountCents || amountCents <= 0) { + return res.status(400).json({ code: 40000, message: '提现金额无效' }); + } + const outBillNo = String(wd.withdraw_no || wd.id); + let bill: any; + try { + bill = await transferToWallet(cfg, { + outBillNo, + openid: String(wd.userOpenid), + amountCents, + sceneId: cfg.transferSceneId, + remark: cfg.transferRemark || '佣金提现', + notifyUrl: cfg.transferNotifyUrl || undefined, + userName: cfg.transferUserNameEnabled ? (wd.payee_name || undefined) : undefined, + sceneReportInfos: Array.isArray(cfg.transferSceneReportInfos) ? cfg.transferSceneReportInfos : undefined, + }); + } catch (e: any) { + return res.status(400).json({ code: 40000, message: `微信商家转账失败:${e.message}` }); + } + const state = String(bill.state || 'ACCEPTED'); + const billNo = String(bill.out_bill_no || outBillNo); + if (state === 'SUCCESS') { + const r = await repo.markWithdrawalTransfer(wdId, billNo, '已打款', 'SUCCESS'); + void notifyBySubscribe('withdrawSuccess', { + userId: Number(wd.companion_id) || 0, + page: '/pages/user/withdraw-records', + data: { + amount2: Number(wd.amount || 0).toFixed(2), + phrase5: '已到账', + time7: formatCnTime(), + amount8: Number(wd.amount || 0).toFixed(2) + } + }); + return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款成功,款项已进入大神微信零钱' }); + } + const r = await repo.markWithdrawalTransfer(wdId, billNo, '处理中', state); + return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款已受理(微信处理中),到账以后端最终状态为准' }); + } + + // 未开启真实打款:生产环境拒绝(避免"只改状态、不发钱"的误导);非生产保留沙箱仅改状态 + if (IS_PRODUCTION && !ALLOW_SANDBOX_PAY) { + return res.status(503).json({ + code: 50001, + message: '微信商家转账未就绪:请在后台填写「转账场景ID」并确保商户号已开通"商家转账到零钱"产品' + }); + } + const result = await repo.reviewWithdrawal(wdId, 'approve', rejectReason); + return res.json({ code: 20000, data: result, message: '(沙箱/联调)提现审核通过并标记为已打款' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } @@ -2629,7 +3122,7 @@ app.get('/api/safety/places', async (_req: Request, res: Response) => { } }); -app.post('/api/safety/places', async (req: Request, res: Response) => { +app.post('/api/safety/places', requireAdmin, async (req: Request, res: Response) => { try { const { name, area, category } = req.body; if (!name || !area) { @@ -2651,7 +3144,7 @@ app.post('/api/safety/places', async (req: Request, res: Response) => { }); // 管理后台: 修改场所信息 -app.put('/api/safety/places/:id', async (req: Request, res: Response) => { +app.put('/api/safety/places/:id', requireAdmin, async (req: Request, res: Response) => { try { const placeId = String(req.params.id); const { name, area, category, status } = req.body; @@ -2672,7 +3165,7 @@ app.put('/api/safety/places/:id', async (req: Request, res: Response) => { }); // 管理后台: 删除场所 -app.delete('/api/safety/places/:id', async (req: Request, res: Response) => { +app.delete('/api/safety/places/:id', requireAdmin, async (req: Request, res: Response) => { try { const placeId = String(req.params.id); const ok = await repo.deleteSafetyPlace(placeId); @@ -2685,7 +3178,7 @@ app.delete('/api/safety/places/:id', async (req: Request, res: Response) => { } }); -app.get('/api/safety/audit-logs', async (_req: Request, res: Response) => { +app.get('/api/safety/audit-logs', requireAdmin, async (_req: Request, res: Response) => { try { const logs = await repo.getSafetyAuditLogs(); res.json({ @@ -2698,7 +3191,7 @@ app.get('/api/safety/audit-logs', async (_req: Request, res: Response) => { }); // 7.1 全平台用户管理接口 (真实读取与更新 MySQL) -app.get('/api/users', async (req: Request, res: Response) => { +app.get('/api/users', requireAdmin, async (req: Request, res: Response) => { try { const { keyword, role, status } = req.query; const users = await repo.getUsers({ @@ -2733,7 +3226,7 @@ app.post('/api/users/:id/status', requireAdmin, async (req: Request, res: Respon } }); -app.delete('/api/users/:id', async (req: Request, res: Response) => { +app.delete('/api/users/:id', requireAdmin, async (req: Request, res: Response) => { try { const userId = Number(req.params.id); if (!userId || isNaN(userId)) { @@ -2772,7 +3265,7 @@ app.post('/api/users/batch-delete', requireAdmin, async (req: Request, res: Resp }); // 7.2 用户360度全景综合数据看板 (涵盖活动/核销码/订单/财务流水/风控评级) -app.get('/api/users/:id/detail', async (req: Request, res: Response) => { +app.get('/api/users/:id/detail', requireAdmin, async (req: Request, res: Response) => { try { const userId = Number(req.params.id); const detail = await repo.getUserDetail(userId); @@ -2834,7 +3327,43 @@ app.get('/api/users/:id', async (req: Request, res: Response) => { return res.status(404).json({ code: 40400, message: '用户未找到' }); } } - res.json({ code: 20000, data: user }); + // 敏感字段(手机号 / 余额 / 佣金 / 邀请关系)仅本人或管理员可见 + const isAdmin = isVerifiedAdminRequest(req); + let isSelf = false; + if (!isAdmin) { + if (!IS_PRODUCTION) { + // 本地联调兼容:非生产默认按本人视角返回 + isSelf = true; + } else { + try { + const caller = resolveSecureUserId(req, 0, '查看用户信息'); + isSelf = caller > 0 && (caller === Number(user.id) || caller === userId); + } catch { + isSelf = false; + } + } + } + if (isAdmin || isSelf) { + return res.json({ code: 20000, data: user }); + } + const publicUser = { + id: user.id, + userNo: user.userNo, + nickname: user.nickname, + avatar: user.avatar, + gender: user.gender, + signature: user.signature, + role: user.role, + roleFlags: user.roleFlags, + isExpert: user.isExpert, + orderCount: user.orderCount, + activityCount: user.activityCount, + companionId: user.companionId, + companionStatus: user.companionStatus, + auditStatus: user.auditStatus, + status: user.status, + }; + res.json({ code: 20000, data: publicUser }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } @@ -2882,72 +3411,98 @@ app.post('/api/users/:id/profile', async (req: Request, res: Response) => { }); // 7.2.1.b 用户隐私安全与通知订阅偏好持久化接口 -const userSettingsFilePath = path.resolve(uploadDir, 'user_settings.json'); +// 用户设置文件存放于对外不可见的私有目录,避免被 /uploads 静态服务公开读取 +const userSettingsDir = path.resolve(__dirname, '../data'); +if (!fs.existsSync(userSettingsDir)) { + try { + fs.mkdirSync(userSettingsDir, { recursive: true }); + } catch (_) { + // 目录创建失败时降级为读取空设置 + } +} +const userSettingsFilePath = path.resolve(userSettingsDir, 'user_settings.json'); function readAllUserSettings(): Record { try { if (fs.existsSync(userSettingsFilePath)) { const raw = fs.readFileSync(userSettingsFilePath, 'utf-8'); return JSON.parse(raw) || {}; } - } catch (_) {} + } catch (e) { + logServerError('userSettings.read', e); + } return {}; } function writeAllUserSettings(map: Record) { try { fs.writeFileSync(userSettingsFilePath, JSON.stringify(map, null, 2), 'utf-8'); - } catch (_) {} + } catch (e) { + logServerError('userSettings.write', e); + } } app.get('/api/users/:id/settings', (req: Request, res: Response) => { - const userId = String(Number(req.params.id) || 1001); - const all = readAllUserSettings(); - const current = all[userId] || { - privacyHidePhone: false, - noticeSubscribe: true, - updatedAt: new Date().toISOString() - }; - res.json({ - code: 20000, - data: { - userId: Number(userId), - ...current - } - }); + try { + const userId = String(resolveSecureUserId(req, Number(req.params.id), '查看个人设置')); + const all = readAllUserSettings(); + const current = all[userId] || { + privacyHidePhone: false, + noticeSubscribe: true, + updatedAt: new Date().toISOString() + }; + res.json({ + code: 20000, + data: { + userId: Number(userId), + ...current + } + }); + } catch (err: any) { + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); + } }); app.post('/api/users/:id/settings', (req: Request, res: Response) => { - const userId = String(Number(req.params.id) || 1001); - const all = readAllUserSettings(); - const prev = all[userId] || { - privacyHidePhone: false, - noticeSubscribe: true, - updatedAt: new Date().toISOString() - }; - const next = { - privacyHidePhone: typeof req.body?.privacyHidePhone === 'boolean' ? req.body.privacyHidePhone : prev.privacyHidePhone, - noticeSubscribe: typeof req.body?.noticeSubscribe === 'boolean' ? req.body.noticeSubscribe : prev.noticeSubscribe, - updatedAt: new Date().toISOString() - }; - all[userId] = next; - writeAllUserSettings(all); - res.json({ - code: 20000, - data: { - userId: Number(userId), - ...next - }, - message: '设置已同步保存' - }); + try { + const userId = String(resolveSecureUserId(req, Number(req.params.id), '保存个人设置')); + const all = readAllUserSettings(); + const prev = all[userId] || { + privacyHidePhone: false, + noticeSubscribe: true, + updatedAt: new Date().toISOString() + }; + const next = { + privacyHidePhone: typeof req.body?.privacyHidePhone === 'boolean' ? req.body.privacyHidePhone : prev.privacyHidePhone, + noticeSubscribe: typeof req.body?.noticeSubscribe === 'boolean' ? req.body.noticeSubscribe : prev.noticeSubscribe, + updatedAt: new Date().toISOString() + }; + all[userId] = next; + writeAllUserSettings(all); + res.json({ + code: 20000, + data: { + userId: Number(userId), + ...next + }, + message: '设置已同步保存' + }); + } catch (err: any) { + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); + } }); // 7.2.2 钱包充值接口 (真实落盘 balance 与 finance_records) app.post('/api/users/:id/recharge', async (req: Request, res: Response) => { try { - const userId = Number(req.params.id); + // 资金安全:充值身份以服务端会话 token 为准,严禁为他人的钱包充值/刷余额 + const userId = resolveSecureUserId(req, Number(req.params.id), '充值余额'); await assertUserNotBanned(userId, '充值余额'); - const { amount, bonusAmount, outTradeNo } = req.body; + const { amount, outTradeNo } = req.body; const cfg = await resolveWechatPayRuntimeConfig(); // 生产 LIVE:仅对已完成的微信支付订单入账(主动查单二次确认 + 幂等),杜绝前端直接加钱 @@ -2971,7 +3526,10 @@ app.post('/api/users/:id/recharge', async (req: Request, res: Response) => { if (!ALLOW_SANDBOX_PAY) { return res.status(503).json({ code: 50001, message: '微信支付未开通,暂不支持充值' }); } - const result = await (repo as any).rechargeUserBalance(userId, Number(amount), Number(bonusAmount || 0)); + const amountYuan = Number(amount); + // 赠金按平台阶梯服务端计算,忽略客户端传入的 bonusAmount + const bonusCents = await repo.computeRechargeBonusCents(Math.round(amountYuan * 100)); + const result = await (repo as any).rechargeUserBalance(userId, amountYuan, bonusCents / 100); res.json({ code: 20000, data: result, @@ -3012,22 +3570,26 @@ app.post('/api/users/:id/withdraw', async (req: Request, res: Response) => { // 用户提现记录列表接口 (状态跟踪:待审核、处理中、已打款、已驳回) app.get('/api/users/:id/withdrawals', async (req: Request, res: Response) => { try { - const userId = Number(req.params.id); + const userId = resolveSecureUserId(req, Number(req.params.id), '查看提现记录'); const list = await repo.getUserWithdrawals(userId); res.json({ code: 20000, data: list }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 7.2.4 用户个人财务流水列表 app.get('/api/users/:id/finance-records', async (req: Request, res: Response) => { try { - const userId = Number(req.params.id); + const userId = resolveSecureUserId(req, Number(req.params.id), '查看财务流水'); const list = await repo.getUserFinanceRecords(userId); res.json({ code: 20000, data: list }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -3106,20 +3668,69 @@ app.delete('/api/admin/posts/comments/:commentId', async (req: Request, res: Res } }); -// 管理端:微信订阅消息与服务通知模拟测试推送接口 +// 管理端:微信订阅消息(服务通知)真实测试推送接口(模板ID/目标用户 openid/小程序凭证就绪时真实下发,否则模拟返回) app.post('/api/admin/message/test-send', async (req: Request, res: Response) => { try { - const { templateType, targetUserId, previewData } = req.body; - // 模拟微信服务器发送模版消息 + const { templateType, targetUserId, previewData, data, page } = req.body || {}; + const typeKeyMap: Record = { + order_status: 'orderStatus', + activity_join: 'activityJoin', + withdraw_success: 'withdrawSuccess', + verify_code: 'verifyCode', + }; + const tKey = typeKeyMap[String(templateType)] || 'orderStatus'; + const rulesRecord = await repo.getPlatformConfigByKey('message_template_rules'); + const rules = (rulesRecord && rulesRecord.valueJson) || {}; + const idMap: Record = { + orderStatus: 'orderStatusTemplateId', + activityJoin: 'activityJoinTemplateId', + withdrawSuccess: 'withdrawSuccessTemplateId', + verifyCode: 'verifyCodeTemplateId', + }; + const templateId = String(rules[idMap[tKey]] || '').trim(); + + const cfg = await resolveWechatPayRuntimeConfig(); + const [uRows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [Number(targetUserId) || 0]); + const openid = uRows && uRows[0] && uRows[0].wechat_openid; + + if (templateId && openid && cfg.appId && cfg.appSecret) { + const accessToken = await getWechatAccessToken(cfg.appId, cfg.appSecret); + // 与所选模板字段对齐的示例数据(字段名必须与模板一致,否则 47003) + const sampleData: Record> = { + orderStatus: { character_string1: 'ORD20261017001', phrase2: '已开始服务', thing3: '同城陪伴服务', thing4: '核销码8392,请出示' }, + activityJoin: { thing1: '同城剧本杀局', time2: formatCnTime(), time3: formatCnTime(), thing4: '朝阳区三里屯商圈' }, + withdrawSuccess: { amount2: '50.00', phrase5: '已到账', time7: formatCnTime(), amount8: '50.00' }, + verifyCode: { character_string1: '8392', thing2: '请向大神出示4位核销码' } + }; + const payloadData = data && typeof data === 'object' && Object.keys(data).length > 0 ? data : (sampleData[tKey] || {}); + const result = await sendSubscribeMessageHttp(accessToken, { + touser: String(openid), + template_id: templateId, + page, + data: buildTemplateData(payloadData), + miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer', + }); + const ok = !result || !result.errcode || result.errcode === 0; + return res.json({ + code: ok ? 20000 : 40000, + data: { mode: 'REAL', templateType: tKey, templateId, targetUserId, result }, + message: ok ? '微信订阅消息已真实下发' : `微信订阅消息下发失败: [${result.errcode}] ${result.errmsg}` + }); + } + + // 未就绪:返回模拟结果并说明原因 + const reason = !templateId + ? '未配置该类型模板ID' + : (!openid ? '目标用户无 openid' : '小程序 appId/appSecret 未配置'); const result = { - templateType: templateType || 'order_status', + templateType: tKey, targetUserId: targetUserId || 1001, sendTime: new Date().toISOString(), - status: 'SUCCESS', + status: 'SIMULATED', wechatMsgId: `wx_msg_${Date.now()}`, previewData: previewData || { title: '订单履约提醒', detail: '您的同城生活陪伴订单已开始服务' } }; - res.json({ code: 20000, data: result, message: '微信模板消息模拟发送成功' }); + res.json({ code: 20000, data: { mode: 'SIMULATED', reason, ...result }, message: `未真实下发(${reason}),已按模拟推送返回` }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } @@ -3128,7 +3739,7 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) => app.post('/api/posts', async (req: Request, res: Response) => { try { const { content, userId } = req.body || {}; - const uid = Number(userId || req.headers['x-user-id'] || 1001); + const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '发布动态'); await assertUserNotBanned(uid, '发布动态'); // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) @@ -3138,7 +3749,7 @@ app.post('/api/posts', async (req: Request, res: Response) => { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } - const post = await repo.createPost(req.body); + const post = await repo.createPost({ ...(req.body || {}), userId: uid }); res.json({ code: 20000, data: post, @@ -3190,8 +3801,14 @@ app.post('/api/orders/:id/review', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); const { rating, tags, content, userId, userNickname, userAvatar, expertId } = req.body; + // 身份以服务端会话为准,杜绝冒充买家提交订单评价 + const reviewerId = resolveSecureUserId( + req, + Number(userId || req.headers['x-user-id'] || 0), + '提交订单评价' + ); // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) - const openid = await resolveUserOpenid(Number(userId) || 0); + const openid = await resolveUserOpenid(reviewerId); const secCheck = await contentSecurityService.checkTextAsync(content, openid, 2); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); @@ -3200,7 +3817,7 @@ app.post('/api/orders/:id/review', async (req: Request, res: Response) => { const review = await repo.createOrderReview({ orderId, expertId: expertId ? Number(expertId) : undefined, - userId: userId ? Number(userId) : undefined, + userId: reviewerId, userNickname, userAvatar, rating: Number(rating) || 5, @@ -3213,7 +3830,9 @@ app.post('/api/orders/:id/review', async (req: Request, res: Response) => { message: '评价提交成功并已落盘入库' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -3259,7 +3878,8 @@ app.post('/api/reviews/:id/reply', async (req: Request, res: Response) => { }); // 管理员全量查询评价与回复列表 -app.get('/api/reviews', async (req: Request, res: Response) => { +// 管理员全量查询评价与回复列表 (仅服务端签名管理员 token 可执行) +app.get('/api/reviews', requireAdmin, async (req: Request, res: Response) => { try { const expertId = req.query.expertId ? Number(req.query.expertId) : undefined; const orderId = req.query.orderId ? String(req.query.orderId) : undefined; @@ -3288,10 +3908,11 @@ app.delete('/api/reviews/:id', requireAdmin, async (req: Request, res: Response) app.delete('/api/posts/:id', async (req: Request, res: Response) => { try { const postId = String(req.params.id); - const uid = Number(req.query.userId || req.body?.userId || req.headers['x-user-id'] || 0); - if (!uid) { - return res.status(400).json({ code: 40000, message: '缺少操作人身份,无法删除动态' }); - } + const uid = resolveSecureUserId( + req, + Number(req.query.userId || req.body?.userId || req.headers['x-user-id'] || 0), + '删除动态' + ); await assertUserNotBanned(uid, '删除动态'); const ok = await repo.deletePost(postId, uid); if (!ok) { @@ -3391,7 +4012,7 @@ app.post('/api/posts/:id/apply', async (req: Request, res: Response) => { try { const postId = String(req.params.id); const { applicantId, applicantName, applicantAvatar } = req.body || {}; - const uid = Number(applicantId || req.headers['x-user-id'] || 1001); + const uid = resolveSecureUserId(req, Number(applicantId || req.headers['x-user-id'] || 0), '申请加入搭子车队'); await assertUserNotBanned(uid, '申请加入搭子车队'); const result = await repo.applyPostParty({ @@ -3416,14 +4037,26 @@ app.post('/api/posts/:id/apply', async (req: Request, res: Response) => { app.get('/api/posts/:id/applies', async (req: Request, res: Response) => { try { const postId = String(req.params.id); + // 归属校验:仅动态发布者本人或管理员可查看该动态的上车申请名单 + if (!isVerifiedAdminRequest(req)) { + const [postRows]: any = await pool.query('SELECT user_id FROM posts WHERE id = ? LIMIT 1', [postId]); + const ownerId = Number((postRows && postRows[0] && postRows[0].user_id) || 0); + const callerId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看上车申请'); + if (!ownerId || ownerId !== callerId) { + return res.status(403).json({ code: 40300, message: '仅动态发布者本人可查看该动态的上车申请' }); + } + } const applies = await repo.getPostApplies(postId); res.json({ code: 20000, data: applies }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); -app.get('/api/post-applies', async (_req: Request, res: Response) => { +// 全量上车申请列表 (仅服务端签名管理员 token 可执行) +app.get('/api/post-applies', requireAdmin, async (_req: Request, res: Response) => { try { const applies = await repo.getPostApplies(); res.json({ code: 20000, data: applies }); @@ -3468,7 +4101,7 @@ app.post('/api/post-applies/:id/handle', async (req: Request, res: Response) => // 7.8 陪玩收藏管理接口 (收藏/取消收藏/列表/状态探测) app.get('/api/favorites', async (req: Request, res: Response) => { try { - const userId = Number(req.query.userId || 1001); + const userId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看收藏列表'); const list = await repo.getFavorites(userId); res.json({ code: 20000, @@ -3476,13 +4109,15 @@ app.get('/api/favorites', async (req: Request, res: Response) => { message: '获取收藏列表成功' }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.get('/api/favorites/check', async (req: Request, res: Response) => { try { - const userId = Number(req.query.userId || 1001); + const userId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查询收藏状态'); const expertId = Number(req.query.expertId); const isFav = await repo.isFavorite(userId, expertId); res.json({ @@ -3538,7 +4173,7 @@ app.delete('/api/favorites/:expertId', async (req: Request, res: Response) => { // 7.9 分销团队与返佣订单流水接口 app.get('/api/distribution/team', async (req: Request, res: Response) => { try { - const userId = Number(req.query.userId || 1001); + const userId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看分销团队'); const teamData = await repo.getDistributionTeam(userId); res.json({ code: 20000, @@ -3546,13 +4181,15 @@ app.get('/api/distribution/team', async (req: Request, res: Response) => { message: '获取团队列表成功' }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.get('/api/distribution/records', async (req: Request, res: Response) => { try { - const userId = Number(req.query.userId || 1001); + const userId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看返佣明细'); const records = await repo.getDistributionRecords(userId); res.json({ code: 20000, @@ -3560,7 +4197,9 @@ app.get('/api/distribution/records', async (req: Request, res: Response) => { message: '获取返佣明细成功' }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -3584,6 +4223,43 @@ app.post('/api/distribution/bind', async (req: Request, res: Response) => { }); // 8. 启动服务器并初始化数据库 +// 启动配置自检:仅输出"就绪状态",绝不打印任何密钥内容(便于部署验收) +async function logStartupConfigSelfCheck(): Promise { + try { + const isProd = process.env.NODE_ENV === 'production'; + console.log(`[Peiwan Config] 运行模式=${isProd ? 'production' : 'development'} ALLOW_SANDBOX_PAY=${ALLOW_SANDBOX_PAY}`); + + const adminSecretSet = Boolean(String(process.env.ADMIN_TOKEN_SECRET || '').trim()); + const userSecretSet = Boolean(String(process.env.USER_TOKEN_SECRET || '').trim()); + if (isProd && !adminSecretSet) { + console.warn('[Peiwan Config][WARN] 未设置 ADMIN_TOKEN_SECRET:已启用进程内随机密钥,重启后登录态失效;多实例部署务必设置固定密钥。'); + } else { + console.log(`[Peiwan Config] ADMIN_TOKEN_SECRET=${adminSecretSet ? '已设置' : '开发默认值'} USER_TOKEN_SECRET=${userSecretSet ? '已设置' : '按 ADMIN 派生'}`); + } + + const cfg: any = await resolveWechatPayRuntimeConfig(); + const missing: string[] = []; + if (!cfg.appId) missing.push('appId'); + if (!cfg.appSecret) missing.push('appSecret'); + if (!cfg.mchId) missing.push('mchId'); + if (String(cfg.apiV3Key || '').length !== 32) missing.push('apiV3Key(需32位)'); + if (!cfg.privateKey && !cfg.certPem) missing.push('privateKey/certPem'); + if (!cfg.platformPublicKey && !cfg.serialNo) missing.push('平台公钥或证书序列号'); + + console.log(`[Peiwan Config] 微信支付: 模式=${cfg.effectiveMode} 实盘就绪=${cfg.isReadyForLive ? '是' : '否'} notifyUrl=${String(cfg.notifyUrl || '')}`); + if (!cfg.isReadyForLive) { + console.warn(`[Peiwan Config][WARN] 微信支付未达 LIVE 就绪,待确认项: ${missing.join('、') || '(mode 未设为 LIVE 或参数未齐)'}`); + } + + console.log(`[Peiwan Config] 商家转账到零钱(提现打款): 就绪=${cfg.isReadyForTransfer ? '是' : '否'} 转账场景ID=${cfg.transferSceneId ? '已配置' : '未配置'}`); + if (cfg.effectiveMode === 'LIVE' && !cfg.isReadyForTransfer) { + console.warn('[Peiwan Config][WARN] 微信提现打款未就绪:请在后台配置「转账场景ID」并确认商户号已开通"商家转账到零钱"产品,否则生产环境将拒绝"通过打款"。'); + } + } catch (e) { + logServerError('startupConfigSelfCheck', e); + } +} + async function bootstrap() { try { await initDatabase(); @@ -3599,6 +4275,8 @@ async function bootstrap() { console.error('[Peiwan Server] MySQL 初始化连接发生警告:', err); } + await logStartupConfigSelfCheck(); + app.listen(port, () => { console.log(`[Peiwan Server] HTTP server is running on http://localhost:${port}`); }); diff --git a/server/tests/activity-create-validation.spec.ts b/server/tests/activity-create-validation.spec.ts new file mode 100644 index 0000000..b69e2af --- /dev/null +++ b/server/tests/activity-create-validation.spec.ts @@ -0,0 +1,31 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十六批回归闸门:组局创建参数服务端校验 + */ +describe('第二十六批组局创建校验闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + const block = () => { + const start = repoCode.indexOf('async createActivity('); + expect(start).toBeGreaterThan(-1); + return repoCode.slice(start, start + 1600); + }; + + it('付费类型/人数/费用必须服务端归一化', () => { + const b = block(); + expect(b).toContain('const payType = Number(data.payType) === 2 ? 2 : 1;'); + expect(b).toContain('Math.min(100, Math.max(1, Math.round(rawMax)))'); + expect(b).toContain('MAX_FEE_CENTS'); + expect(b).toContain('const perPersonFeeCents = payType === 1 ? fee : 0;'); + }); + + it('不得再直接信任客户端未校验费用', () => { + const b = block(); + expect(b).not.toContain('const perPersonFeeCents = data.perPersonFeeCents || 5000;'); + expect(b).not.toContain('const maxParticipants = data.maxParticipants || 4;'); + }); +}); diff --git a/server/tests/activity-detail-privacy.spec.ts b/server/tests/activity-detail-privacy.spec.ts new file mode 100644 index 0000000..15a7e32 --- /dev/null +++ b/server/tests/activity-detail-privacy.spec.ts @@ -0,0 +1,32 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十八批回归闸门:组局详情不得泄露他人核销码 + */ +describe('第二十八批组局详情核销码隐私闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + + it('活动详情路由必须剥离非本人核销码', () => { + const start = indexCode.indexOf("app.get('/api/activities/:id',"); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 1200); + expect(block).toContain('严禁向他人泄露'); + expect(block).toContain('Number(p.userId) === callerId'); + expect(block).toContain('const { verifyCode, ...rest } = p;'); + expect(block).toContain('isVerifiedAdminRequest(req)'); + }); + + it('小程序请求详情携带当前用户身份', () => { + const start = apiCode.indexOf('export async function getActivityDetail('); + const block = apiCode.slice(start, start + 300); + expect(block).toContain('/activities/${id}?userId=${normalizeUserId()}'); + }); + + it('数据适配器不得为缺失核销码伪造 8392', () => { + expect(apiCode).not.toContain("verifyCode: p.verifyCode || '8392'"); + }); +}); diff --git a/server/tests/activity-identity.spec.ts b/server/tests/activity-identity.spec.ts new file mode 100644 index 0000000..e85495f --- /dev/null +++ b/server/tests/activity-identity.spec.ts @@ -0,0 +1,71 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十一批回归闸门:组局发起/解散、抢单、搭子上车、用户设置的身份收口 + */ +describe('第十一批写接口身份收口闸门 (组局 / 抢单 / 上车 / 设置)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + + const routeBlock = (marker: string, len = 1200) => { + const start = indexCode.indexOf(marker); + expect(start).toBeGreaterThan(-1); + return indexCode.slice(start, start + len); + }; + + describe('1. 组局发起人身份以服务端会话为准', () => { + it('创建组局必须经 resolveSecureUserId 绑定发起人', () => { + const block = routeBlock("app.post('/api/activities',"); + expect(block).toContain("'发起组局'"); + expect(block).not.toContain("req.body?.creatorId || req.headers['x-user-id'] || 1001"); + }); + }); + + describe('2. 组局解散人身份以服务端会话为准', () => { + it('解散组局:管理员走签名 token,其余经 resolveSecureUserId', () => { + const block = routeBlock("app.post('/api/activities/:id/cancel'"); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).toContain("'解散组局'"); + expect(block).not.toContain("const callerId = Number(hostId ||"); + }); + }); + + describe('3. 抢单身份以服务端会话为准', () => { + it('接单必须经 resolveSecureUserId 且写库用服务端身份', () => { + const block = routeBlock("app.post('/api/orders/:id/claim'"); + expect(block).toContain("'接单'"); + expect(block).toContain('claimOrder(orderId, { ...(req.body || {}), expertId: operatorId })'); + }); + }); + + describe('4. 搭子上车申请身份以服务端会话为准', () => { + it('上车申请必须经 resolveSecureUserId', () => { + const block = routeBlock("app.post('/api/posts/:id/apply'"); + expect(block).toContain("'申请加入搭子车队'"); + expect(block).not.toContain("applicantId || req.headers['x-user-id'] || 1001"); + }); + }); + + describe('5. 用户设置读写归属', () => { + it('设置读写必须经 resolveSecureUserId', () => { + const getBlock = routeBlock("app.get('/api/users/:id/settings'"); + const postBlock = routeBlock("app.post('/api/users/:id/settings'"); + expect(getBlock).toContain("'查看个人设置'"); + expect(postBlock).toContain("'保存个人设置'"); + }); + }); + + describe('6. 小程序端写接口携带真实身份', () => { + it('组局/抢单/上车/报名/退席均注入当前用户身份', () => { + expect(apiCode).toContain('creatorId: normalizeUserId(payload.creatorId)'); + expect(apiCode).toContain('hostId: normalizeUserId(hostId)'); + expect(apiCode).toContain('userId: normalizeUserId(params.userId)'); + expect(apiCode).toContain('userId: normalizeUserId(userId),'); + expect(apiCode).toContain('userId: normalizeUserId(expertData.userId)'); + expect(apiCode).toContain('applicantId: normalizeUserId(applyData.applicantId)'); + }); + }); +}); diff --git a/server/tests/activity-join-payment.spec.ts b/server/tests/activity-join-payment.spec.ts new file mode 100644 index 0000000..ba02f81 --- /dev/null +++ b/server/tests/activity-join-payment.spec.ts @@ -0,0 +1,37 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十四批回归闸门:组局报名费用服务端权威 + 真实扣款 + */ +describe('第二十四批组局报名资金闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + const joinBlock = () => { + const start = repoCode.indexOf('async joinActivity('); + expect(start).toBeGreaterThan(-1); + return repoCode.slice(start, start + 6200); + }; + + it('报名费以服务端活动配置为准(AA=人均分摊,房主请客=0)', () => { + const block = joinBlock(); + expect(block).toContain('per_person_fee_cents'); + expect(block).toContain('const joinFeeCents = Number(actRows[0].pay_type) === 1'); + }); + + it('报名从钱包真实扣款', () => { + const block = joinBlock(); + expect(block).toContain("'活动报名扣款'"); + expect(block).toContain('UPDATE users SET balance = balance - ?'); + expect(block).toContain('钱包余额不足,报名需支付'); + }); + + it('入账/参与者金额均使用服务端权威值', () => { + const block = joinBlock(); + expect(block).not.toContain('params.payAmountCents / 100'); + expect(block).not.toContain('params.userId, params.payAmountCents, verifyCode'); + expect(block).toContain('payAmountCents: joinFeeCents'); + }); +}); diff --git a/server/tests/adjust-price-integrity.spec.ts b/server/tests/adjust-price-integrity.spec.ts new file mode 100644 index 0000000..63c0059 --- /dev/null +++ b/server/tests/adjust-price-integrity.spec.ts @@ -0,0 +1,32 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十五批回归闸门:房主改价的金额校验与差额记账 + */ +describe('第二十五批改价完整性闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + const block = () => { + const start = repoCode.indexOf('async adjustPrice('); + expect(start).toBeGreaterThan(-1); + return repoCode.slice(start, start + 1800); + }; + + it('必须校验调价金额(非负整数 + 平台上限)', () => { + const b = block(); + expect(b).toContain('Number.isInteger(price)'); + expect(b).toContain('MAX_FEE_CENTS'); + expect(b).toContain('调价金额必须为不小于 0 的整数'); + }); + + it('必须按真实差额记账而非整份新价', () => { + const b = block(); + expect(b).toContain('const deltaCents = price - oldFee'); + expect(b).toContain("'房主追加赏金'"); + expect(b).toContain("'房主下调出价差额'"); + expect(b).not.toContain("'房主追加赏金差额退补'"); + }); +}); diff --git a/server/tests/admin-transfer-config.spec.ts b/server/tests/admin-transfer-config.spec.ts new file mode 100644 index 0000000..db7247e --- /dev/null +++ b/server/tests/admin-transfer-config.spec.ts @@ -0,0 +1,33 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十六批回归闸门:提现打款场景信息可在后台自助配置 + */ +describe('第五十六批打款场景信息配置闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const adminConfig = fs.readFileSync( + path.join(rootDir, 'admin/arco-design-pro-vite/src/views/finance/config/index.vue'), + 'utf-8' + ); + + it('后台提供转账场景ID/备注/姓名加密/回调/场景信息配置项', () => { + expect(adminConfig).toContain('transferSceneId'); + expect(adminConfig).toContain('transferRemark'); + expect(adminConfig).toContain('transferUserNameEnabled'); + expect(adminConfig).toContain('transferNotifyUrl'); + expect(adminConfig).toContain('transferSceneReportInfosText'); + expect(adminConfig).toContain('transfer_scene_report_infos'); + }); + + it('服务端持久化并透传场景信息', () => { + // 保存接口白名单 + expect(indexCode).toContain('transferSceneReportInfos: Array.isArray(incoming.transferSceneReportInfos)'); + // 运行时配置解析透传 + const start = indexCode.indexOf('async function resolveWechatPayRuntimeConfig('); + const block = indexCode.slice(start, start + 2600); + expect(block).toContain('transferSceneReportInfos'); + }); +}); diff --git a/server/tests/admin-write-authz.spec.ts b/server/tests/admin-write-authz.spec.ts new file mode 100644 index 0000000..38fb8ad --- /dev/null +++ b/server/tests/admin-write-authz.spec.ts @@ -0,0 +1,29 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十七批回归闸门:管理端写接口鉴权(用户删除 / 场所管理 / 订单超时扫描) + */ +describe('第十七批管理端写接口鉴权闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + it('删除用户仅管理员可执行', () => { + expect(indexCode).toContain("app.delete('/api/users/:id', requireAdmin"); + }); + + it('场所增删改仅管理员可执行', () => { + expect(indexCode).toContain("app.post('/api/safety/places', requireAdmin"); + expect(indexCode).toContain("app.put('/api/safety/places/:id', requireAdmin"); + expect(indexCode).toContain("app.delete('/api/safety/places/:id', requireAdmin"); + }); + + it('场所列表仍保持公开可读(小程序合规场所库)', () => { + expect(indexCode).toContain("app.get('/api/safety/places', async"); + }); + + it('订单超时自动流转扫描仅管理员可触发', () => { + expect(indexCode).toContain("app.post('/api/orders/process-timeouts', requireAdmin"); + }); +}); diff --git a/server/tests/auth-secret-guard.spec.ts b/server/tests/auth-secret-guard.spec.ts new file mode 100644 index 0000000..edb9ddb --- /dev/null +++ b/server/tests/auth-secret-guard.spec.ts @@ -0,0 +1,28 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十八批回归闸门:生产环境不得使用仓库内置默认 admin 密钥 + */ +describe('第十八批鉴权密钥安全闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + it('不得再以仓库内置默认密钥作为静默回落', () => { + expect(indexCode).not.toContain("process.env.ADMIN_TOKEN_SECRET || 'peiwan_admin_token_secret_v1'"); + }); + + it('必须存在默认密钥常量与生产环境随机化闸门', () => { + expect(indexCode).toContain('const DEFAULT_ADMIN_TOKEN_SECRET ='); + const start = indexCode.indexOf('const DEFAULT_ADMIN_TOKEN_SECRET ='); + const block = indexCode.slice(start, start + 1200); + expect(block).toContain("process.env.NODE_ENV === 'production'"); + expect(block).toContain('crypto.randomBytes'); + expect(block).toContain('SECURITY'); + }); + + it('管理员 token 校验必须使用常量时间比较', () => { + expect(indexCode).toContain('crypto.timingSafeEqual'); + }); +}); diff --git a/server/tests/buyer-cancel-order.spec.ts b/server/tests/buyer-cancel-order.spec.ts new file mode 100644 index 0000000..ea4def2 --- /dev/null +++ b/server/tests/buyer-cancel-order.spec.ts @@ -0,0 +1,28 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第三十六批回归闸门:买家取消订单的状态安全 + */ +describe('第三十六批买家取消订单闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('仓储层提供 buyerCancelOrder 且拒绝已履约订单', () => { + const start = repoCode.indexOf('async buyerCancelOrder('); + expect(start).toBeGreaterThan(-1); + const block = repoCode.slice(start, start + 2600); + expect(block).toContain('不可直接取消,请发起退款申诉'); + expect(block).toContain("status = \"已退款\", refund_amount"); + expect(block).toContain("'买家取消订单退款'"); + }); + + it('取消路由:管理员直改状态,买家走安全取消', () => { + const start = indexCode.indexOf("app.post('/api/orders/:id/cancel'"); + const block = indexCode.slice(start, start + 1200); + expect(block).toContain('role.isAdmin'); + expect(block).toContain('repo.buyerCancelOrder(orderId)'); + }); +}); diff --git a/server/tests/code2session-hardening.spec.ts b/server/tests/code2session-hardening.spec.ts new file mode 100644 index 0000000..7b6abfe --- /dev/null +++ b/server/tests/code2session-hardening.spec.ts @@ -0,0 +1,30 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第三十三批回归闸门:code2session 不泄露 session_key 且 openid 绑定需会话一致 + */ +describe('第三十三批 code2session 加固闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + const block = () => { + const start = indexCode.indexOf("app.post('/api/wechat/code2session'"); + expect(start).toBeGreaterThan(-1); + return indexCode.slice(start, start + 2600); + }; + + it('不得向客户端返回 session_key', () => { + const b = block(); + expect(b).not.toContain('sessionKey: session_key'); + expect(b).not.toContain('const { openid, session_key, unionid }'); + }); + + it('openid 绑定必须经服务端会话身份', () => { + const b = block(); + expect(b).toContain("'绑定微信账户'"); + expect(b).toContain('repo.updateUserOpenid(claimed, openid)'); + expect(b).not.toContain('repo.updateUserOpenid(Number(userId), openid)'); + }); +}); diff --git a/server/tests/companion-apply-ownership.spec.ts b/server/tests/companion-apply-ownership.spec.ts new file mode 100644 index 0000000..8d2cf9c --- /dev/null +++ b/server/tests/companion-apply-ownership.spec.ts @@ -0,0 +1,28 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第四十八批回归闸门:大神入驻申请不得同名抢占他人档案 + */ +describe('第四十八批大神入驻归属闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + const block = () => { + const start = repoCode.indexOf('async applyCompanion('); + expect(start).toBeGreaterThan(-1); + return repoCode.slice(start, start + 3000); + }; + + it('重新提交仅按 user_id 匹配本人档案', () => { + const b = block(); + expect(b).toContain('WHERE user_id IS NOT NULL AND user_id = ?'); + expect(b).not.toContain('OR name = ?`, [userId, name]'); + expect(b).not.toContain('SET user_id = ?, name = ?'); + }); + + it('昵称与他人档案冲突时拒绝提交', () => { + expect(block()).toContain('该大神昵称已被占用,请更换昵称后重新提交'); + }); +}); diff --git a/server/tests/companion-detail-privacy.spec.ts b/server/tests/companion-detail-privacy.spec.ts new file mode 100644 index 0000000..b9549e8 --- /dev/null +++ b/server/tests/companion-detail-privacy.spec.ts @@ -0,0 +1,19 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第三十一批回归闸门:大神详情公开端剥离内部审核备注 + */ +describe('第三十一批大神详情隐私闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + it('公开详情剥离 rejectReason,管理端保留完整', () => { + const start = indexCode.indexOf("app.get('/api/companions/:id',"); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 800); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).toContain('const { rejectReason, ...publicCompanion } = companion;'); + }); +}); diff --git a/server/tests/companions-directory-privacy.spec.ts b/server/tests/companions-directory-privacy.spec.ts new file mode 100644 index 0000000..2537cc5 --- /dev/null +++ b/server/tests/companions-directory-privacy.spec.ts @@ -0,0 +1,20 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第三十批回归闸门:大神公开目录仅展示已通过并脱敏内部审核备注 + */ +describe('第三十批大神目录隐私闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + it('公开目录仅返回已通过大神且剥离 rejectReason,管理端保留全量', () => { + const start = indexCode.indexOf("app.get('/api/companions',"); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 900); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).toContain("c.auditStatus === '已通过'"); + expect(block).toContain('const { rejectReason, ...rest } = c;'); + }); +}); diff --git a/server/tests/content-check-auth.spec.ts b/server/tests/content-check-auth.spec.ts new file mode 100644 index 0000000..4c147a1 --- /dev/null +++ b/server/tests/content-check-auth.spec.ts @@ -0,0 +1,21 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第四十六批回归闸门:内容安全检测接口鉴权与 openid 可信 + */ +describe('第四十六批内容安全接口闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + it('生产需登录且 openid 以会话用户为准', () => { + const start = indexCode.indexOf("app.post('/api/security/msg-sec-check'"); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 1200); + expect(block).toContain('IS_PRODUCTION'); + expect(block).toContain("'内容安全检测'"); + expect(block).toContain('await resolveUserOpenid(callerId)'); + expect(block).not.toContain('const { content, openid, scene } = req.body'); + }); +}); diff --git a/server/tests/content-security.spec.ts b/server/tests/content-security.spec.ts index 27224e4..aabf71f 100644 --- a/server/tests/content-security.spec.ts +++ b/server/tests/content-security.spec.ts @@ -130,7 +130,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 const raw = fs.readFileSync(pagesJsonPath, 'utf-8'); const config = JSON.parse(raw); expect(Array.isArray(config.pages)).toBe(true); - expect(config.pages.length).toBe(25); + expect(config.pages.length).toBe(26); for (const pageItem of config.pages) { const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`); diff --git a/server/tests/distribution-no-fake.spec.ts b/server/tests/distribution-no-fake.spec.ts new file mode 100644 index 0000000..434df07 --- /dev/null +++ b/server/tests/distribution-no-fake.spec.ts @@ -0,0 +1,24 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十三批回归闸门:分销团队/返佣明细不得用硬编码假数据 + */ +describe('第五十三批分销数据真实性闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('不得再返回硬编码团队成员/返佣记录', () => { + expect(repoCode).not.toContain("'晨曦微风'"); + expect(repoCode).not.toContain("'峡谷追风少年'"); + expect(repoCode).not.toContain("'dst_101'"); + expect(repoCode).not.toContain("'星空漫步者'"); + }); + + it('无真实数据时返回空', () => { + const start = repoCode.indexOf('async getDistributionRecords('); + const block = repoCode.slice(start, start + 900); + expect(block).toContain('return [];'); + }); +}); diff --git a/server/tests/expert-stats-scope.spec.ts b/server/tests/expert-stats-scope.spec.ts new file mode 100644 index 0000000..74da986 --- /dev/null +++ b/server/tests/expert-stats-scope.spec.ts @@ -0,0 +1,23 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十批回归闸门:大神收益统计不得跨大神串算 + */ +describe('第五十批大神收益统计范围闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('不得再用 companion_name LIKE 匹配聚合作弊式占位名', () => { + expect(repoCode).not.toContain("'%我%'"); + expect(repoCode).not.toContain('OR companion_name LIKE ?'); + }); + + it('收益汇总与走势只按 expert_id 统计', () => { + const stats = repoCode.slice(repoCode.indexOf('async getExpertStats('), repoCode.indexOf('async getExpertStats(') + 600); + expect(stats).toContain('WHERE expert_id = ?'); + const trends = repoCode.slice(repoCode.indexOf('async getExpertRevenueTrends(')); + expect(trends.slice(0, 4000)).toContain('WHERE expert_id = ?'); + }); +}); diff --git a/server/tests/faq-config-repair.spec.ts b/server/tests/faq-config-repair.spec.ts new file mode 100644 index 0000000..c2a0c96 --- /dev/null +++ b/server/tests/faq-config-repair.spec.ts @@ -0,0 +1,24 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十八批回归闸门:帮助中心 FAQ 配置一致性 + */ +describe('第五十八批帮助中心FAQ闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const mysqlCode = fs.readFileSync(path.join(rootDir, 'server/src/db/mysql.ts'), 'utf-8'); + const userIndex = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/user/index.vue'), 'utf-8'); + + it('faq_list 为空时自动回填默认种子', () => { + expect(mysqlCode).toContain('自动回填默认种子'); + expect(mysqlCode).toContain("UPDATE platform_configs SET value_json = ? WHERE config_key = 'faq_list'"); + expect(mysqlCode).toContain('!Array.isArray(parsed) || parsed.length === 0'); + }); + + it('小程序不再用写死的假 FAQ 兜底文案(改为独立页面展示)', () => { + expect(userIndex).not.toContain('什么是4位核销码?\\n到达活动现场'); + const faqPage = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/user/help-faq.vue'), 'utf-8'); + expect(faqPage).toContain('暂无常见问题'); + }); +}); diff --git a/server/tests/funds-identity.spec.ts b/server/tests/funds-identity.spec.ts new file mode 100644 index 0000000..ef801c8 --- /dev/null +++ b/server/tests/funds-identity.spec.ts @@ -0,0 +1,43 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十六批回归闸门:资金写接口(充值 / 订单支付)身份与归属收口 + */ +describe('第十六批资金接口身份闸门 (充值 / 订单支付)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + + const routeBlock = (marker: string, len = 1400) => { + const start = indexCode.indexOf(marker); + expect(start).toBeGreaterThan(-1); + return indexCode.slice(start, start + len); + }; + + describe('1. 钱包充值不得为他人刷余额', () => { + it('充值身份以服务端会话为准', () => { + const block = routeBlock("app.post('/api/users/:id/recharge'"); + expect(block).toContain("'充值余额'"); + expect(block).not.toContain('const userId = Number(req.params.id);\n await assertUserNotBanned(userId, \'充值余额\')'); + }); + }); + + describe('2. 订单支付仅限下单买家本人', () => { + it('支付前必须做订单归属校验', () => { + const block = routeBlock("app.post('/api/orders/:id/pay'"); + expect(block).toContain('repo.getOrderOwnership(orderId)'); + expect(block).toContain("'确认订单支付'"); + expect(block).toContain('仅下单买家本人可确认支付该订单'); + }); + }); + + describe('3. 小程序支付携带当前用户身份', () => { + it('payOrder 必须注入 userId', () => { + const start = apiCode.indexOf('export async function payOrder('); + const block = apiCode.slice(start, start + 300); + expect(block).toContain('userId: normalizeUserId(payload.userId)'); + }); + }); +}); diff --git a/server/tests/help-faq-page.spec.ts b/server/tests/help-faq-page.spec.ts new file mode 100644 index 0000000..2ad22e3 --- /dev/null +++ b/server/tests/help-faq-page.spec.ts @@ -0,0 +1,32 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十九批回归闸门:帮助中心 FAQ 改为独立页面(非弹窗) + */ +describe('第五十九批帮助中心页面化闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const faqPagePath = path.join(rootDir, 'pwuniapp/pages/user/help-faq.vue'); + const pagesJson = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages.json'), 'utf-8'); + const userIndex = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/user/index.vue'), 'utf-8'); + + it('新增独立 FAQ 页面并注册路由', () => { + expect(fs.existsSync(faqPagePath)).toBe(true); + const page = fs.readFileSync(faqPagePath, 'utf-8'); + expect(page).toContain('getPlatformConfigs'); + expect(page).toContain('faq_list'); + expect(page).toContain('activeIndex'); + expect(pagesJson).toContain('"path": "pages/user/help-faq"'); + }); + + it('个人中心点击进入新页面而非弹窗', () => { + const start = userIndex.indexOf('const openFaq'); + expect(start).toBeGreaterThan(-1); + const end = userIndex.indexOf('};', start); + const block = userIndex.slice(start, end + 2); + expect(block).toContain("url: '/pages/user/help-faq'"); + expect(block).not.toContain('showActionSheet'); + expect(block).not.toContain('showModal'); + }); +}); diff --git a/server/tests/identity-hardening.spec.ts b/server/tests/identity-hardening.spec.ts index 6e41ef7..aac89a4 100644 --- a/server/tests/identity-hardening.spec.ts +++ b/server/tests/identity-hardening.spec.ts @@ -83,14 +83,14 @@ describe('第四批安全加固闸门 (用户会话身份 / 订单支付 / 上 it('非 LIVE 沙箱入账必须受 ALLOW_SANDBOX_PAY 生产闸门约束', () => { const start = indexCode.indexOf("app.post('/api/orders/:id/pay'"); expect(start).toBeGreaterThan(-1); - const block = indexCode.slice(start, start + 1400); + const block = indexCode.slice(start, start + 3400); expect(block).toContain('if (!ALLOW_SANDBOX_PAY)'); expect(block).toContain('禁止直接变更订单支付状态'); }); it('沙箱仅允许推进到合法已支付状态,严禁客户端任意指定终态', () => { const start = indexCode.indexOf("app.post('/api/orders/:id/pay'"); - const block = indexCode.slice(start, start + 1400); + const block = indexCode.slice(start, start + 3400); expect(block).toContain("const allowedSandboxStatuses = ['履约中', '待接单']"); expect(block).toContain('allowedSandboxStatuses.includes(requested)'); }); diff --git a/server/tests/login-hardening.spec.ts b/server/tests/login-hardening.spec.ts new file mode 100644 index 0000000..f3ef897 --- /dev/null +++ b/server/tests/login-hardening.spec.ts @@ -0,0 +1,31 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十二批回归闸门:管理端登录暴力破解防护 + 常量时间口令比较 + */ +describe('第二十二批登录安全闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + it('提供常量时间比较与失败计数限流', () => { + expect(indexCode).toContain('function safeStringEqual('); + expect(indexCode).toContain('timingSafeEqual'); + expect(indexCode).toContain('loginFailureMap'); + expect(indexCode).toContain('LOGIN_MAX_FAILURES'); + expect(indexCode).toContain('isLoginLocked'); + }); + + it('登录路由:超限锁定 + 常量时间比较', () => { + const start = indexCode.indexOf("app.post('/api/user/login'"); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 1200); + expect(block).toContain('isLoginLocked(loginKey)'); + expect(block).toContain('登录失败次数过多'); + expect(block).toContain('recordLoginFailure(loginKey)'); + expect(block).toContain('safeStringEqual(String(password), p)'); + expect(block).not.toContain('ADMIN_PASSWORDS.includes(String(password))'); + expect(block).not.toContain('username === ADMIN_USERNAME'); + }); +}); diff --git a/server/tests/mini-program-expert-grab.spec.ts b/server/tests/mini-program-expert-grab.spec.ts new file mode 100644 index 0000000..4c768a9 --- /dev/null +++ b/server/tests/mini-program-expert-grab.spec.ts @@ -0,0 +1,26 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第四十三批回归闸门:大神接单页失败不得伪装成功 + */ +describe('第四十三批大神接单反馈闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const radar = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/expert/radar.vue'), 'utf-8'); + const hall = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/expert/hall.vue'), 'utf-8'); + + it('radar 抢单:双重失败必须提示', () => { + const start = radar.indexOf('const handleTakeOrder ='); + const block = radar.slice(start, start + 1600); + expect(block).toContain('抢单失败,请稍后重试'); + expect(block).toContain('return;'); + }); + + it('hall 接单:失败必须提示', () => { + const start = hall.indexOf('const handleGrabOrder ='); + const block = hall.slice(start, start + 1400); + expect(block).toContain('接单失败,请稍后重试'); + expect(block).toContain('return;'); + }); +}); diff --git a/server/tests/mini-program-identity-and-referral.spec.ts b/server/tests/mini-program-identity-and-referral.spec.ts new file mode 100644 index 0000000..1c3888d --- /dev/null +++ b/server/tests/mini-program-identity-and-referral.spec.ts @@ -0,0 +1,24 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第四十二批回归闸门:邀请码不得静默丢失 + 举报人身份以当前用户为准 + */ +describe('第四十二批邀请与举报身份闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const userIndex = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/user/index.vue'), 'utf-8'); + const expertDetail = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/expert/detail.vue'), 'utf-8'); + const orderList = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/order/list.vue'), 'utf-8'); + + it('邀请码绑定失败保留待绑定码以便重试', () => { + expect(userIndex).toContain('将于下次登录重试'); + const start = userIndex.indexOf('PENDING_INVITER_CODE'); + expect(start).toBeGreaterThan(-1); + }); + + it('举报不再硬编码 reporterId=1001(由当前用户身份承担)', () => { + expect(expertDetail).not.toContain('reporterId: 1001'); + expect(orderList).not.toContain('reporterId: 1001'); + }); +}); diff --git a/server/tests/mini-program-no-fake-success.spec.ts b/server/tests/mini-program-no-fake-success.spec.ts new file mode 100644 index 0000000..7f1983f --- /dev/null +++ b/server/tests/mini-program-no-fake-success.spec.ts @@ -0,0 +1,79 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第三十八批回归闸门:小程序写操作失败不得再伪装成功 + */ +describe('第三十八批小程序失败反馈闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const workspace = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/expert/workspace.vue'), 'utf-8'); + const orderList = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/order/list.vue'), 'utf-8'); + + it('大神工作台:抢单/拒单/提交完成/接单状态切换失败必须提示', () => { + expect(workspace).toContain('抢单失败,请稍后重试'); + expect(workspace).toContain('拒单失败,请稍后重试'); + expect(workspace).toContain('提交失败,请稍后重试'); + expect(workspace).toContain('接单状态切换失败,请稍后重试'); + }); + + it('抢单失败不再无条件提示成功并移出订单池', () => { + const start = workspace.indexOf('const grabOrder = async'); + const block = workspace.slice(start, start + 700); + expect(block).toContain('return;'); + expect(block.indexOf('抢单失败')).toBeLessThan(block.indexOf("'抢单成功已接单'")); + }); + + it('订单列表:取消失败必须提示且不本地改成已取消', () => { + const start = orderList.indexOf('const cancelOrder ='); + const block = orderList.slice(start, start + 900); + expect(block).toContain('取消失败,请稍后重试'); + expect(block).toContain('loadLiveOrders()'); + }); + + it('社区页:删除/上车申请失败必须提示', () => { + const ci = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/community/index.vue'), 'utf-8'); + const cd = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/community/detail.vue'), 'utf-8'); + for (const code of [ci, cd]) { + expect(code).toContain('删除失败,请重试'); + expect(code).toContain('申请失败,请重试'); + } + }); + + it('设置保存:API 层不得吞错,页面失败需回滚并提示', () => { + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + const settings = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/user/settings.vue'), 'utf-8'); + const start = apiCode.indexOf('export async function updateUserSettings('); + const block = apiCode.slice(start, start + 700); + expect(block).toContain('throw err;'); + expect(block).not.toContain('return {\n userId: uid,'); + expect(settings).toContain('设置同步失败,请稍后重试'); + expect(settings).toContain('privacyHidePhone.value = prev'); + }); + + it('点赞:接口层不得吞错,页面需回滚乐观状态', () => { + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + const likeStart = apiCode.indexOf('export async function likePost('); + const likeBlock = apiCode.slice(likeStart, likeStart + 500); + expect(likeBlock).toContain('throw err;'); + expect(likeBlock).not.toContain('return null;'); + + const ci = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/community/index.vue'), 'utf-8'); + const cd = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/community/detail.vue'), 'utf-8'); + expect(ci).toContain('回滚乐观更新'); + expect(cd).toContain('回滚乐观更新'); + }); + + it('订单动作:取消/验收接口层不得吞错,验收失败需提示', () => { + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + for (const fn of ['export async function cancelOrder(', 'export async function completeOrder(']) { + const block = apiCode.slice(apiCode.indexOf(fn), apiCode.indexOf(fn) + 500); + expect(block).toContain('throw err;'); + expect(block).not.toContain('return null;'); + } + const orderList2 = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/order/list.vue'), 'utf-8'); + const start = orderList2.indexOf('const confirmComplete ='); + const block = orderList2.slice(start, start + 900); + expect(block).toContain('确认验收失败,请稍后重试'); + }); +}); diff --git a/server/tests/order-amount-integrity.spec.ts b/server/tests/order-amount-integrity.spec.ts new file mode 100644 index 0000000..3fadd4b --- /dev/null +++ b/server/tests/order-amount-integrity.spec.ts @@ -0,0 +1,36 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十一批回归闸门:订单金额与优惠券服务端权威化 + */ +describe('第二十一批订单金额完整性闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + const createOrderBlock = () => { + const start = repoCode.indexOf('async createOrder('); + expect(start).toBeGreaterThan(-1); + return repoCode.slice(start, start + 4200); + }; + + it('服务单价必须按服务端大神档案校验', () => { + const block = createOrderBlock(); + expect(block).toContain('resolveCompanionRow(expertId)'); + expect(block).toContain('compRate'); + expect(block).toContain('hourlyRate = compRate'); + }); + + it('订单金额由服务端计算,不再直接信任客户端 amount', () => { + const block = createOrderBlock(); + expect(block).toContain('const amount = Number(Math.max(baseAmount - couponDiscount, 0.01).toFixed(2))'); + expect(block).not.toContain('const amount = Number(data.amount) || hourlyRate * durationHours;'); + }); + + it('优惠券折扣必须经服务端有效券校验', () => { + expect(repoCode).toContain('async resolveCouponDiscount('); + const block = createOrderBlock(); + expect(block).toContain('resolveCouponDiscount(rawDiscount, track, baseAmount)'); + }); +}); diff --git a/server/tests/order-lifecycle-hardening.spec.ts b/server/tests/order-lifecycle-hardening.spec.ts index 08c7db1..4eda27c 100644 --- a/server/tests/order-lifecycle-hardening.spec.ts +++ b/server/tests/order-lifecycle-hardening.spec.ts @@ -27,7 +27,7 @@ describe('第五批安全加固闸门 (订单越权结算 / 订单身份收口 / const start = repoCode.indexOf('async completeOrderWithSettlement('); expect(start).toBeGreaterThan(-1); const block = repoCode.slice(start, start + 1600); - expect(block).toContain("const settleBlocked = ['待支付', '待接单', '已取消', '已退款', '已全额退款']"); + expect(block).toContain("const settleBlocked = ['待支付', '待付款', '待接单', '已取消', '已退款', '已全额退款']"); expect(block).toContain('settleBlocked.includes(String(order.status))'); expect(block).toContain('不可结算'); expect(block).toContain('e.status = 409'); diff --git a/server/tests/order-progress-gates.spec.ts b/server/tests/order-progress-gates.spec.ts new file mode 100644 index 0000000..ad26514 --- /dev/null +++ b/server/tests/order-progress-gates.spec.ts @@ -0,0 +1,27 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第三十七批回归闸门:拒单/申请完成的状态闸门 + */ +describe('第三十七批订单流转状态闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('仅「待接单」可拒单', () => { + const start = repoCode.indexOf('async rejectOrder('); + expect(start).toBeGreaterThan(-1); + const block = repoCode.slice(start, start + 900); + expect(block).toContain("!== '待接单'"); + expect(block).toContain('仅待接单可拒单'); + }); + + it('申请完成仅限已履约订单', () => { + const start = repoCode.indexOf('async applyCompleteOrder('); + expect(start).toBeGreaterThan(-1); + const block = repoCode.slice(start, start + 1400); + expect(block).toContain("const allowed = ['履约中', '服务中', '已到场核销', '待确认'];"); + expect(block).toContain('不可申请完成'); + }); +}); diff --git a/server/tests/orders-pool-filter.spec.ts b/server/tests/orders-pool-filter.spec.ts new file mode 100644 index 0000000..05dcff2 --- /dev/null +++ b/server/tests/orders-pool-filter.spec.ts @@ -0,0 +1,20 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十九批回归闸门:抢单池只返回可认领的有效订单 + */ +describe('第二十九批抢单池过滤闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('抢单池必须排除终态/已核销订单并过滤已认领订单', () => { + const start = repoCode.indexOf('async getOrdersPool('); + expect(start).toBeGreaterThan(-1); + const block = repoCode.slice(start, start + 1400); + expect(block).toContain("WHERE status NOT IN ('已完成', '已取消'"); + expect(block).toContain('claimed_by IS NULL'); + expect(block).toContain('LIMIT 20'); + }); +}); diff --git a/server/tests/payment-amount-integrity.spec.ts b/server/tests/payment-amount-integrity.spec.ts new file mode 100644 index 0000000..ae2e783 --- /dev/null +++ b/server/tests/payment-amount-integrity.spec.ts @@ -0,0 +1,35 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十九批回归闸门:预支付金额不可篡改(低价支付高价订单) + */ +describe('第十九批支付金额完整性闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('订单归属解析必须携带订单金额', () => { + const start = repoCode.indexOf('async getOrderOwnership('); + const block = repoCode.slice(start, start + 900); + expect(block).toContain('o.amount'); + expect(block).toContain('amount: Number(r.amount)'); + }); + + it('入账必须校验实收金额与支付意图一致', () => { + const start = repoCode.indexOf('async applyPaymentSuccess('); + const block = repoCode.slice(start, start + 1800); + expect(block).toContain('amountMismatch'); + expect(block).toContain("reason: 'amount_mismatch'"); + }); + + it('预支付:仅买家可发起,且订单金额以服务端订单为准', () => { + const start = indexCode.indexOf("app.post('/api/pay/wechat/prepay'"); + const block = indexCode.slice(start, start + 2200); + expect(block).toContain("'发起微信支付'"); + expect(block).toContain('repo.getOrderOwnership(String(orderId))'); + expect(block).toContain('payAmount = Number(own.amount) || 0'); + expect(block).not.toContain('const uid = Number(userId) || 0;'); + }); +}); diff --git a/server/tests/phone-login-identity.spec.ts b/server/tests/phone-login-identity.spec.ts new file mode 100644 index 0000000..7adaadc --- /dev/null +++ b/server/tests/phone-login-identity.spec.ts @@ -0,0 +1,27 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第三十五批回归闸门:手机号登录身份匹配与联系方式唯一性 + */ +describe('第三十五批手机号登录身份闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('登录优先按微信 openid 精确匹配', () => { + const start = repoCode.indexOf('async findOrCreateUserByPhone('); + expect(start).toBeGreaterThan(-1); + const block = repoCode.slice(start, start + 1600); + expect(block).toContain('WHERE wechat_openid = ? LIMIT 1'); + // openid 查询应出现在手机号查询之前 + expect(block.indexOf('wechat_openid = ?')).toBeLessThan(block.indexOf('WHERE phone = ?')); + }); + + it('修改联系方式必须做唯一性校验', () => { + const start = repoCode.indexOf('async updateUserProfile('); + const block = repoCode.slice(start, start + 1500); + expect(block).toContain('该联系方式已被其他账号使用'); + expect(block).toContain('WHERE phone = ? AND id <> ?'); + }); +}); diff --git a/server/tests/read-access-contract.spec.ts b/server/tests/read-access-contract.spec.ts new file mode 100644 index 0000000..1826c23 --- /dev/null +++ b/server/tests/read-access-contract.spec.ts @@ -0,0 +1,65 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十四批回归闸门:举报/评价/上车申请/收藏/大神收益等读接口越权与泄露收口 + */ +describe('第十四批读接口越权收口闸门 (举报 / 评价 / 上车申请 / 收藏 / 收益)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + + const routeBlock = (marker: string, len = 1200) => { + const start = indexCode.indexOf(marker); + expect(start).toBeGreaterThan(-1); + return indexCode.slice(start, start + len); + }; + + describe('1. 举报工单列表', () => { + it('管理端全量 / 普通用户仅本人提交', () => { + const block = routeBlock("app.get('/api/reports',"); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).toContain("'查看我的举报'"); + }); + }); + + describe('2. 全量评价列表仅管理员', () => { + it('GET /api/reviews 必须挂 requireAdmin', () => { + expect(indexCode).toContain("app.get('/api/reviews', requireAdmin"); + expect(indexCode).toContain("app.get('/api/post-applies', requireAdmin"); + }); + }); + + describe('3. 动态上车申请名单归属', () => { + it('仅发布者本人或管理员可查看某动态的上车申请', () => { + const block = routeBlock("app.get('/api/posts/:id/applies'"); + expect(block).toContain("'查看上车申请'"); + expect(block).toContain('仅动态发布者本人可查看该动态的上车申请'); + }); + }); + + describe('4. 收藏列表/状态归属', () => { + it('必须经 resolveSecureUserId', () => { + expect(routeBlock("app.get('/api/favorites',")).toContain("'查看收藏列表'"); + expect(routeBlock("app.get('/api/favorites/check'")).toContain("'查询收藏状态'"); + }); + }); + + describe('5. 大神收益数据归属', () => { + it('收益汇总与走势必须经 assertCompanionActor', () => { + const stats = routeBlock("app.get('/api/expert/stats/:id',"); + const trends = routeBlock("app.get('/api/expert/stats/:id/trends'"); + expect(stats).toContain('assertCompanionActor(req, own'); + expect(trends).toContain('assertCompanionActor(req, own'); + }); + }); + + describe('6. 小程序端携带当前用户身份', () => { + it('getPostApplies 必须带当前用户 id', () => { + const start = apiCode.indexOf('export async function getPostApplies('); + const block = apiCode.slice(start, start + 320); + expect(block).toContain('/posts/${postId}/applies?userId=${normalizeUserId()}'); + }); + }); +}); diff --git a/server/tests/read-scope.spec.ts b/server/tests/read-scope.spec.ts new file mode 100644 index 0000000..c0a50dc --- /dev/null +++ b/server/tests/read-scope.spec.ts @@ -0,0 +1,57 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十二批回归闸门:读接口数据越权/泄露收口(订单全量、我的组局、分销团队) + */ +describe('第十二批读接口数据隔离闸门 (订单 / 我的组局 / 分销)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + + const routeBlock = (marker: string, len = 1000) => { + const start = indexCode.indexOf(marker); + expect(start).toBeGreaterThan(-1); + return indexCode.slice(start, start + len); + }; + + describe('1. 订单列表不得向任意客户端下发全量订单', () => { + it('仓储层提供按用户隔离的 getOrdersForUser', () => { + expect(repoCode).toContain('async getOrdersForUser('); + const start = repoCode.indexOf('async getOrdersForUser('); + const block = repoCode.slice(start, start + 1400); + expect(block).toContain('buyer_id = ?'); + expect(block).toContain('claimed_by = ?'); + expect(block).toContain('companion_name IN (SELECT name FROM companions'); + }); + + it('订单列表:管理端全量,普通用户仅本人相关', () => { + const block = routeBlock("app.get('/api/orders',"); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).toContain('repo.getOrdersForUser(uid)'); + expect(block).toContain("'查看订单列表'"); + }); + }); + + describe('2. 我的组局 / 分销数据归属', () => { + it('my-activities 必须经 resolveSecureUserId', () => { + const block = routeBlock("app.get('/api/users/:id/my-activities'"); + expect(block).toContain("'查看我的组局'"); + }); + + it('分销团队与返佣明细必须经 resolveSecureUserId', () => { + expect(routeBlock("app.get('/api/distribution/team'")).toContain("'查看分销团队'"); + expect(routeBlock("app.get('/api/distribution/records'")).toContain("'查看返佣明细'"); + }); + }); + + describe('3. 小程序端订单列表携带当前用户身份', () => { + it('getOrders 必须带上当前用户 id', () => { + const start = apiCode.indexOf('export async function getOrders('); + const block = apiCode.slice(start, start + 300); + expect(block).toContain('/orders?userId=${normalizeUserId()}'); + }); + }); +}); diff --git a/server/tests/recharge-bonus-authority.spec.ts b/server/tests/recharge-bonus-authority.spec.ts new file mode 100644 index 0000000..f39199f --- /dev/null +++ b/server/tests/recharge-bonus-authority.spec.ts @@ -0,0 +1,35 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十批回归闸门:充值赠金必须服务端按阶梯权威计算 + */ +describe('第二十批充值赠金权威性闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('仓储层提供服务端赠金计算', () => { + expect(repoCode).toContain('async computeRechargeBonusCents('); + const start = repoCode.indexOf('async computeRechargeBonusCents('); + const block = repoCode.slice(start, start + 700); + expect(block).toContain('FROM recharge_tiers'); + expect(block).toContain('status = 1'); + expect(block).toContain('ORDER BY amount DESC'); + }); + + it('预支付不再信任客户端赠金', () => { + expect(indexCode).not.toContain('bonusCents: Math.round(Number(bonusAmount || 0) * 100)'); + const start = indexCode.indexOf("app.post('/api/pay/wechat/prepay'"); + const block = indexCode.slice(start, start + 5200); + expect(block).toContain('computeRechargeBonusCents(amountCents)'); + }); + + it('沙箱直接充值也不再信任客户端赠金', () => { + expect(indexCode).not.toContain('rechargeUserBalance(userId, Number(amount), Number(bonusAmount || 0))'); + const start = indexCode.indexOf("app.post('/api/users/:id/recharge'"); + const block = indexCode.slice(start, start + 2400); + expect(block).toContain('computeRechargeBonusCents(Math.round(amountYuan * 100))'); + }); +}); diff --git a/server/tests/request-and-checkin-lookup.spec.ts b/server/tests/request-and-checkin-lookup.spec.ts new file mode 100644 index 0000000..46f7cbf --- /dev/null +++ b/server/tests/request-and-checkin-lookup.spec.ts @@ -0,0 +1,39 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十七批回归闸门:请求层错误透传 + 核销码反查冲突优先级 + */ +describe('第二十七批请求层与核销码反查闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const requestJs = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/request.js'), 'utf-8'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + describe('1. 小程序请求层', () => { + it('错误必须附带业务 code / statusCode', () => { + expect(requestJs).toContain('bizErr.code = res.data.code'); + expect(requestJs).toContain('bizErr.statusCode = res.statusCode'); + expect(requestJs).toContain('httpErr.statusCode = res.statusCode'); + }); + it('登录过期必须清理会话并提示', () => { + expect(requestJs).toContain('function handleAuthExpired('); + expect(requestJs).toContain('40100'); + expect(requestJs).toContain('登录已过期'); + }); + }); + + describe('2. 核销码反查冲突优先级', () => { + it('活动核销码优先未核销、最新记录', () => { + const start = repoCode.indexOf('async queryCheckinCodeInfo('); + const block = repoCode.slice(start, start + 1000); + expect(block).toContain('ORDER BY p.checkin_status ASC, p.created_at DESC'); + }); + it('订单核销码排除已关闭状态并优先待核销', () => { + const start = repoCode.indexOf('async queryCheckinCodeInfo('); + const block = repoCode.slice(start, start + 2600); + expect(block).toContain("AND status NOT IN ('已取消', '已退款', '已全额退款')"); + expect(block).toContain("ORDER BY FIELD(status, '履约中', '服务中', '待确认'"); + }); + }); +}); diff --git a/server/tests/review-post-identity.spec.ts b/server/tests/review-post-identity.spec.ts new file mode 100644 index 0000000..5f39091 --- /dev/null +++ b/server/tests/review-post-identity.spec.ts @@ -0,0 +1,65 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十批回归闸门:订单评价/动态发布与删除的身份收口 + 关键路径可观测性 + */ +describe('第十批身份收口与可观测性闸门 (评价 / 动态发布 / 动态删除)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + + const routeBlock = (marker: string, len = 900) => { + const start = indexCode.indexOf(marker); + expect(start).toBeGreaterThan(-1); + return indexCode.slice(start, start + len); + }; + + describe('1. 订单评价身份以服务端会话为准', () => { + it('评价路由必须经 resolveSecureUserId 绑定买家身份', () => { + const block = routeBlock("app.post('/api/orders/:id/review'"); + expect(block).toContain('resolveSecureUserId('); + expect(block).toContain("'提交订单评价'"); + expect(block).toContain('userId: reviewerId'); + expect(block).not.toContain('userId: userId ? Number(userId) : undefined'); + }); + + it('仓储层仍保留服务端归属校验(仅下单买家)', () => { + expect(repoCode).toContain('仅下单买家本人可以评价该订单'); + }); + }); + + describe('2. 动态发布身份以服务端会话为准', () => { + it('发布路由必须经 resolveSecureUserId 且写库用服务端 uid', () => { + const block = routeBlock("app.post('/api/posts',"); + expect(block).toContain("'发布动态'"); + expect(block).toContain('repo.createPost({ ...(req.body || {}), userId: uid })'); + }); + }); + + describe('3. 动态删除身份以服务端会话为准', () => { + it('删除路由必须经 resolveSecureUserId', () => { + const block = routeBlock("app.delete('/api/posts/:id'"); + expect(block).toContain("'删除动态'"); + }); + }); + + describe('4. 关键路径不得静默吞错', () => { + it('openid 持久化/预支付解析/用户设置 IO 必须记录错误', () => { + expect(indexCode).toContain("logServerError('login.updateUserOpenid'"); + expect(indexCode).toContain("logServerError('prepay.resolveOpenid'"); + expect(indexCode).toContain("logServerError('userSettings.write'"); + expect(repoCode).toContain("logServerError('getDashboardTodos.reports'"); + }); + }); + + describe('5. 小程序端写接口携带真实身份', () => { + it('发布动态/提交评价必须注入当前用户身份', () => { + expect(apiCode).toContain('userId: normalizeUserId(payload && payload.userId)'); + expect(apiCode).toContain('userId: normalizeUserId(reviewData.userId)'); + expect(apiCode).toContain('const uid = normalizeUserId(userId);'); + }); + }); +}); diff --git a/server/tests/security-hardening.spec.ts b/server/tests/security-hardening.spec.ts index 64db7e0..94fcb3c 100644 --- a/server/tests/security-hardening.spec.ts +++ b/server/tests/security-hardening.spec.ts @@ -38,7 +38,7 @@ describe('第三批安全加固闸门 (动态越权删除 / 组局解散提权 / const start = code.indexOf('export async function deletePost('); expect(start).toBeGreaterThan(-1); const block = code.slice(start, start + 400); - expect(block).toContain("uni.getStorageSync('userId') || 1001"); + expect(block).toContain('normalizeUserId(userId)'); expect(block).not.toContain('userId = 1001'); }); }); diff --git a/server/tests/settings-file-location.spec.ts b/server/tests/settings-file-location.spec.ts new file mode 100644 index 0000000..3194ed0 --- /dev/null +++ b/server/tests/settings-file-location.spec.ts @@ -0,0 +1,21 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第三十二批回归闸门:用户设置文件不得位于对外静态目录 + */ +describe('第三十二批用户设置文件私有化闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + it('设置文件路径必须位于私有 data 目录而非 /uploads', () => { + expect(indexCode).not.toContain("path.resolve(uploadDir, 'user_settings.json')"); + expect(indexCode).toContain('const userSettingsDir ='); + expect(indexCode).toContain("path.resolve(userSettingsDir, 'user_settings.json')"); + }); + + it('/uploads 仍仅用于静态资源', () => { + expect(indexCode).toContain("app.use('/uploads', express.static(uploadDir))"); + }); +}); diff --git a/server/tests/settlement-credit-user.spec.ts b/server/tests/settlement-credit-user.spec.ts new file mode 100644 index 0000000..97fef8f --- /dev/null +++ b/server/tests/settlement-credit-user.spec.ts @@ -0,0 +1,30 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第四十九批回归闸门:结算/仲裁给大神入账必须唯一命中,禁止昵称模糊重复入账 + */ +describe('第四十九批结算入账唯一性闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('提供唯一解析结算收款用户的私有方法', () => { + expect(repoCode).toContain('async resolveExpertCreditUserId('); + const start = repoCode.indexOf('async resolveExpertCreditUserId('); + const block = repoCode.slice(start, start + 800); + expect(block).toContain('WHERE nickname = ? LIMIT 1'); + expect(block).toContain('comp?.user_id'); + }); + + it('不得再使用 id OR nickname 的模糊入账', () => { + expect(repoCode).not.toContain('WHERE id = ? OR nickname = ?'); + }); + + it('完单结算与仲裁部分结算均使用唯一收款用户', () => { + const settle = repoCode.slice(repoCode.indexOf('async completeOrderWithSettlement(')); + expect(settle.slice(0, 3000)).toContain('resolveExpertCreditUserId(conn, order, comp)'); + const arb = repoCode.slice(repoCode.indexOf('async handleArbitration(')); + expect(arb.slice(0, 7000)).toContain('resolveExpertCreditUserId(conn, order, comp)'); + }); +}); diff --git a/server/tests/settlement-gate.spec.ts b/server/tests/settlement-gate.spec.ts new file mode 100644 index 0000000..1640008 --- /dev/null +++ b/server/tests/settlement-gate.spec.ts @@ -0,0 +1,25 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二十三批回归闸门:订单初始状态与结算状态闸门 + */ +describe('第二十三批结算状态闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('结算闸门必须拦截未付款(待付款)订单', () => { + const start = repoCode.indexOf('async completeOrderWithSettlement('); + const block = repoCode.slice(start, start + 900); + expect(block).toContain("'待付款'"); + expect(block).toContain("'待支付'"); + }); + + it('订单初始状态必须由服务端按支付方式决定', () => { + const start = repoCode.indexOf('async createOrder('); + const block = repoCode.slice(start, start + 4200); + expect(block).toContain("const status = payType === 'BALANCE' ? '履约中' : '待付款';"); + expect(block).not.toContain('const status = data.status ||'); + }); +}); diff --git a/server/tests/startup-selfcheck.spec.ts b/server/tests/startup-selfcheck.spec.ts new file mode 100644 index 0000000..c864f59 --- /dev/null +++ b/server/tests/startup-selfcheck.spec.ts @@ -0,0 +1,27 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十四批回归闸门:启动配置自检(部署验收) + */ +describe('第五十四批启动自检闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + it('提供启动自检并输出就绪状态(不泄露密钥)', () => { + expect(indexCode).toContain('async function logStartupConfigSelfCheck('); + const start = indexCode.indexOf('async function logStartupConfigSelfCheck('); + const block = indexCode.slice(start, start + 1800); + expect(block).toContain('运行模式='); + expect(block).toContain('实盘就绪='); + expect(block).toContain('未设置 ADMIN_TOKEN_SECRET'); + // 不得打印密钥值,仅打印是否设置 + expect(block).not.toContain('${ADMIN_TOKEN_SECRET}'); + expect(block).not.toContain('${USER_TOKEN_SECRET}'); + }); + + it('bootstrap 中调用自检', () => { + expect(indexCode).toContain('await logStartupConfigSelfCheck();'); + }); +}); diff --git a/server/tests/subscribe-message.spec.ts b/server/tests/subscribe-message.spec.ts new file mode 100644 index 0000000..f84a4a1 --- /dev/null +++ b/server/tests/subscribe-message.spec.ts @@ -0,0 +1,60 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十七批回归闸门:微信订阅消息(服务通知)真实下发闭环 + */ +describe('第五十七批微信订阅消息闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const svc = fs.readFileSync(path.join(rootDir, 'server/src/core/notify/wechat-subscribe.service.ts'), 'utf-8'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + const settings = fs.readFileSync(path.join(rootDir, 'pwuniapp/pages/user/settings.vue'), 'utf-8'); + + it('提供微信订阅消息下发客户端', () => { + expect(svc).toContain('export function sendSubscribeMessageHttp('); + expect(svc).toContain("cgi-bin/message/subscribe/send"); + expect(svc).toContain('export function buildTemplateData('); + }); + + it('服务端按配置真实下发并接入触发点(字段与所选公共模板对齐)', () => { + expect(indexCode).toContain('async function notifyBySubscribe('); + expect(indexCode).toContain("app.get('/api/message/subscribe-templates'"); + expect(indexCode).toContain('sendSubscribeMessageHttp(accessToken'); + for (const key of ['orderStatus', 'activityJoin', 'withdrawSuccess']) { + expect(indexCode).toContain(`notifyBySubscribe('${key}'`); + } + // 订单状态提醒模板字段 + expect(indexCode).toContain('character_string1:'); + expect(indexCode).toContain('phrase2:'); + expect(indexCode).toContain('thing3:'); + expect(indexCode).toContain('thing4:'); + // 提现审核通知模板字段 + expect(indexCode).toContain('amount2:'); + expect(indexCode).toContain('phrase5:'); + expect(indexCode).toContain('time7:'); + expect(indexCode).toContain('amount8:'); + // 活动报名成功通知模板字段 + expect(indexCode).toContain('thing1:'); + expect(indexCode).toContain('time2:'); + expect(indexCode).toContain('time3:'); + // 开始时间兜底:非标准日期回退当前时间,避免 time 字段 47003 + expect(indexCode).toContain('function toCnTimeOrNow('); + expect(indexCode).toContain('time3: toCnTimeOrNow(act?.startTime)'); + }); + + it('管理端测试推送在就绪时真实下发', () => { + const start = indexCode.indexOf("app.post('/api/admin/message/test-send'"); + const block = indexCode.slice(start, start + 3400); + expect(block).toContain("mode: 'REAL'"); + expect(block).toContain("mode: 'SIMULATED'"); + }); + + it('小程序用真实模板ID 请求订阅授权', () => { + expect(apiCode).toContain('export async function getSubscribeTemplates('); + expect(apiCode).toContain('/message/subscribe-templates'); + expect(settings).toContain('requestNoticeSubscribe'); + expect(settings).not.toContain("'ORDER_STATUS_NOTICE_TMPL'"); + }); +}); diff --git a/server/tests/upload-auth.spec.ts b/server/tests/upload-auth.spec.ts new file mode 100644 index 0000000..2cfcbe0 --- /dev/null +++ b/server/tests/upload-auth.spec.ts @@ -0,0 +1,20 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第四十五批回归闸门:上传接口生产环境需登录 + */ +describe('第四十五批上传鉴权闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + it('生产环境上传必须要求登录', () => { + const start = indexCode.indexOf('const handleFileUpload ='); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 500); + expect(block).toContain('IS_PRODUCTION'); + expect(block).toContain("resolveSecureUserId(req, 0, '上传文件')"); + expect(block).toContain('请先登录后再上传'); + }); +}); diff --git a/server/tests/user-detail-scope.spec.ts b/server/tests/user-detail-scope.spec.ts new file mode 100644 index 0000000..7c61d7e --- /dev/null +++ b/server/tests/user-detail-scope.spec.ts @@ -0,0 +1,26 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十一批回归闸门:管理员用户360画像按 id 精确关联 + */ +describe('第五十一批用户360画像关联闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('订单按 buyer/expert/claimed/companion(user_id) 关联', () => { + const start = repoCode.indexOf('async getUserDetail('); + expect(start).toBeGreaterThan(-1); + const block = repoCode.slice(start, start + 3000); + expect(block).toContain('OR companion_name IN (SELECT name FROM companions WHERE user_id = ? OR id = ?)'); + expect(block).not.toContain('WHERE client_name = ? OR companion_name = ?'); + }); + + it('资金流水按 user_id 精确归属', () => { + const start = repoCode.indexOf('async getUserDetail('); + const block = repoCode.slice(start, start + 3200); + expect(block).toContain('WHERE user_id = ?'); + expect(block).not.toContain('counterparty LIKE ? OR counterparty LIKE ?'); + }); +}); diff --git a/server/tests/user-read-privacy.spec.ts b/server/tests/user-read-privacy.spec.ts new file mode 100644 index 0000000..6c80324 --- /dev/null +++ b/server/tests/user-read-privacy.spec.ts @@ -0,0 +1,46 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十三批回归闸门:用户/风控读接口的敏感字段与鉴权收口 + */ +describe('第十三批用户读接口隐私闸门 (全量用户 / 360画像 / 风控日志 / 个人资产)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + + const routeBlock = (marker: string, len = 1600) => { + const start = indexCode.indexOf(marker); + expect(start).toBeGreaterThan(-1); + return indexCode.slice(start, start + len); + }; + + describe('1. 管理端专属读接口必须鉴权', () => { + it('全量用户列表仅管理员可见', () => { + expect(indexCode).toContain("app.get('/api/users', requireAdmin"); + }); + it('用户360画像仅管理员可见', () => { + expect(indexCode).toContain("app.get('/api/users/:id/detail', requireAdmin"); + }); + it('风控日志仅管理员可见', () => { + expect(indexCode).toContain("app.get('/api/safety/audit-logs', requireAdmin"); + }); + }); + + describe('2. 用户基本信息:敏感字段仅本人/管理员可见', () => { + it('GET /api/users/:id 必须做本人/管理员判断并剥离敏感字段', () => { + const block = routeBlock("app.get('/api/users/:id',", 8000); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).toContain('publicUser'); + expect(block).not.toContain('phone: user.phone'); + expect(block).not.toContain('balance: user.balance'); + }); + }); + + describe('3. 个人资产/流水仅本人可读', () => { + it('提现记录与财务流水必须经 resolveSecureUserId', () => { + expect(routeBlock("app.get('/api/users/:id/withdrawals'")).toContain("'查看提现记录'"); + expect(routeBlock("app.get('/api/users/:id/finance-records'")).toContain("'查看财务流水'"); + }); + }); +}); diff --git a/server/tests/verify-pool-hardening.spec.ts b/server/tests/verify-pool-hardening.spec.ts new file mode 100644 index 0000000..fb4589c --- /dev/null +++ b/server/tests/verify-pool-hardening.spec.ts @@ -0,0 +1,45 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第十五批回归闸门:核销码反查防枚举 + 抢单池客户名脱敏 + */ +describe('第十五批核销码与抢单池加固闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + const apiCode = fs.readFileSync(path.join(rootDir, 'pwuniapp/common/api.js'), 'utf-8'); + + const routeBlock = (marker: string, len = 1400) => { + const start = indexCode.indexOf(marker); + expect(start).toBeGreaterThan(-1); + return indexCode.slice(start, start + len); + }; + + describe('1. 核销码反查不得被匿名枚举', () => { + it('必须要求登录并按门票归属方校验', () => { + const block = routeBlock("app.get('/api/verify/query/:code'"); + expect(block).toContain("'查询核销码'"); + expect(block).toContain('IS_PRODUCTION && !isVerifiedAdminRequest(req)'); + expect(block).toContain('无权查看该核销码信息'); + }); + }); + + describe('2. 抢单池客户名脱敏', () => { + it('仓储层必须对 client_name 脱敏', () => { + const start = repoCode.indexOf('async getOrdersPool('); + const block = repoCode.slice(start, start + 1400); + expect(block).toContain('maskClientName'); + expect(block).not.toContain('userName: r.clientName ||'); + }); + }); + + describe('3. 小程序反查携带当前用户身份', () => { + it('queryCheckinCode 必须带 userId', () => { + const start = apiCode.indexOf('export async function queryCheckinCode('); + const block = apiCode.slice(start, start + 300); + expect(block).toContain('/verify/query/${code}?userId=${normalizeUserId()}'); + }); + }); +}); diff --git a/server/tests/wallet-records-scope.spec.ts b/server/tests/wallet-records-scope.spec.ts new file mode 100644 index 0000000..4d75e76 --- /dev/null +++ b/server/tests/wallet-records-scope.spec.ts @@ -0,0 +1,25 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十二批回归闸门:个人资金流水按 user_id 精确归属 + */ +describe('第五十二批资金流水归属闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + + it('个人流水查询仅按 user_id 过滤,不再用 counterparty LIKE', () => { + const start = repoCode.indexOf('async getUserFinanceRecords('); + expect(start).toBeGreaterThan(-1); + const block = repoCode.slice(start, start + 700); + expect(block).toContain('WHERE user_id = ?'); + expect(block).not.toContain('counterparty LIKE ?'); + expect(block).not.toContain('OR related_id = ?'); + }); + + it('关键资金流水写入时带 user_id', () => { + expect(repoCode).toContain("'钱包余额支付订单', ?, 'OUT'"); + expect(repoCode).toContain("'佣金提现', ?, 'OUT'"); + }); +}); diff --git a/server/tests/withdrawal-transfer.spec.ts b/server/tests/withdrawal-transfer.spec.ts new file mode 100644 index 0000000..a8e1c0c --- /dev/null +++ b/server/tests/withdrawal-transfer.spec.ts @@ -0,0 +1,45 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五十五批回归闸门:提现「真实微信打款」闭环 + */ +describe('第五十五批提现打款闭环闸门', () => { + const rootDir = path.resolve(__dirname, '../../'); + const payV3 = fs.readFileSync(path.join(rootDir, 'server/src/core/pay/wechat-pay-v3.ts'), 'utf-8'); + const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); + const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8'); + const mysqlCode = fs.readFileSync(path.join(rootDir, 'server/src/db/mysql.ts'), 'utf-8'); + + it('支付客户端具备商家转账到零钱能力', () => { + expect(payV3).toContain('export async function transferToWallet('); + expect(payV3).toContain("'/v3/fund-app/mch-transfer/transfer-bills'"); + expect(payV3).toContain('export async function queryTransferByOutBillNo('); + expect(payV3).toContain('export function encryptSensitiveInfo('); + }); + + it('提现审核通过按模式真实打款 / 生产未就绪拒绝', () => { + const start = indexCode.indexOf("app.post('/api/finance/withdrawals/:id/review'"); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 4600); + expect(block).toContain('cfg.isReadyForTransfer'); + expect(block).toContain('transferToWallet(cfg'); + expect(block).toContain('getWithdrawalForTransfer(wdId)'); + expect(block).toContain('微信商家转账未就绪'); + expect(block).not.toContain('款项已通过微信商户零钱打款'); // 移除误导文案 + }); + + it('提供转账结果回调与最终态落库', () => { + expect(indexCode).toContain("app.post('/api/pay/wechat/transfer-notify'"); + expect(repoCode).toContain('async finalizeWithdrawalTransfer('); + expect(repoCode).toContain('async getWithdrawalForTransfer('); + expect(repoCode).toContain('async markWithdrawalTransfer('); + }); + + it('提现表具备转账/收款人字段', () => { + expect(mysqlCode).toContain('transfer_bill_no'); + expect(mysqlCode).toContain('transfer_status'); + expect(mysqlCode).toContain('payee_name'); + }); +});