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 @@
+
+
+
+
+
+
+
+
+ 正在加载…
+
+
+
+ 暂无常见问题,请稍后再试,或联系官方客服。
+
+
+
+
+
+ {{ idx + 1 }}
+ {{ item.question }}
+ ›
+
+
+ 【{{ item.category }}】
+ {{ item.answer }}
+
+
+
+
+
+
+
+
+
+
+
+
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');
+ });
+});