feat(security): 三端闭环加固 + 微信提现打款/订阅消息落地
后端: 身份与归属鉴权收口; 资金安全(结算闸门/支付防篡改/报名费真实扣款/赠金/结算唯一入账/提现打款); 数据泄露与隐私; 登录与密钥安全(默认密钥闸门/登录限流); 可观测性; 启动自检. 微信: 商家转账到零钱提现打款闭环(含回调/查单/失败退回佣金); 订阅消息真实下发闭环与后台模板字段对齐. 后台: 财务/微信支付配置读取失败锁定; 看板/待办/走势去假数据; 打款与测试推送文案修正. 小程序: 请求层错误透传与登录过期提示; 多页失败反馈与乐观回滚; 帮助中心FAQ页面化. 测试: 新增 40+ 回归护栏与真实 MySQL 冒烟(75 套件/599 用例全绿).
这个提交包含在:
@@ -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 || '打款失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -287,6 +287,48 @@
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="left">提现打款(微信商家转账到零钱)</a-divider>
|
||||
<a-alert type="info" style="margin-bottom: 12px">
|
||||
提现审核"通过"将对大神发起真实的微信商家转账到零钱。需商户号已开通该产品并配置「转账场景ID」;未配置时生产环境会拒绝打款(不会只改状态、不发钱)。
|
||||
</a-alert>
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="8">
|
||||
<a-form-item
|
||||
label="转账场景ID (transfer_scene_id)"
|
||||
help="商户平台「商家转账到零钱」配置的转账场景编号"
|
||||
>
|
||||
<a-input v-model="wxPayForm.transferSceneId" placeholder="如 佣金报酬场景ID" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="转账备注 (transfer_remark)" help="展示给收款用户的转账备注,≤32字符">
|
||||
<a-input v-model="wxPayForm.transferRemark" placeholder="佣金提现" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="收款人姓名加密" help="启用后按平台公钥加密提交收款人姓名(部分场景必填)">
|
||||
<a-switch v-model="wxPayForm.transferUserNameEnabled" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="转账结果回调地址 (transfer_notify_url,选填)">
|
||||
<a-input
|
||||
v-model="wxPayForm.transferNotifyUrl"
|
||||
placeholder="https://peiww.gynhc.com/api/pay/wechat/transfer-notify"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="转账场景信息 (transfer_scene_report_infos,部分场景必填)"
|
||||
help='JSON 数组,元素形如 {"info_type":"岗位类型","info_content":"外卖骑手"};不同转账场景要求的 info_type 不同,请以微信商户平台「商家转账到零钱」该场景说明为准。留空则不提交。'
|
||||
>
|
||||
<a-textarea
|
||||
v-model="wxPayForm.transferSceneReportInfosText"
|
||||
:auto-size="{ minRows: 2, maxRows: 6 }"
|
||||
placeholder='[{"info_type":"岗位类型","info_content":"外卖骑手"}]'
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<input
|
||||
ref="pemFileInputRef"
|
||||
type="file"
|
||||
@@ -375,6 +417,12 @@
|
||||
platformPublicKeyId: '',
|
||||
platformPublicKey: '',
|
||||
notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify',
|
||||
transferSceneId: '',
|
||||
transferRemark: '佣金提现',
|
||||
transferNotifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/transfer-notify',
|
||||
transferUserNameEnabled: false,
|
||||
transferSceneReportInfos: [] as Array<{ info_type: string; info_content: string }>,
|
||||
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) {
|
||||
|
||||
76
docs/安全与稳定性加固清单.md
普通文件
76
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 与既有字段使用)。
|
||||
118
docs/部署验收与1分钱支付闭环.md
普通文件
118
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,不影响业务)。
|
||||
|
||||
## 八、回滚
|
||||
|
||||
本轮改动遵循"边界内最小改动",无破坏性数据库结构变更。如需回滚,按部署前的文件/库备份还原即可。
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -192,6 +192,15 @@
|
||||
"backgroundColor": "#0A0F1D"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/help-faq",
|
||||
"style": {
|
||||
"navigationBarTitleText": "帮助中心与常见问题",
|
||||
"navigationBarBackgroundColor": "#0A0F1D",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#0A0F1D"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/activity/create",
|
||||
"style": {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 || '同城陪伴师',
|
||||
|
||||
@@ -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: '接单成功!请准时履约',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
285
pwuniapp/pages/user/help-faq.vue
普通文件
285
pwuniapp/pages/user/help-faq.vue
普通文件
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<view class="faq-page" :class="{ 'theme-warm': currentThemeKey === 'warm' }">
|
||||
<!-- 顶部标题区 -->
|
||||
<view class="faq-header">
|
||||
<view class="faq-badge">帮助中心</view>
|
||||
<text class="faq-title">常见问题解答</text>
|
||||
<text class="faq-meta">点击问题查看解答;如仍有疑问,可联系官方在线客服</text>
|
||||
</view>
|
||||
|
||||
<!-- 问答列表 -->
|
||||
<scroll-view class="faq-scroll" scroll-y enable-flex>
|
||||
<view v-if="isLoading" class="faq-state">
|
||||
<text class="state-text">正在加载…</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="faqList.length === 0" class="faq-state">
|
||||
<text class="state-text">暂无常见问题,请稍后再试,或联系官方客服。</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="faq-list">
|
||||
<view
|
||||
v-for="(item, idx) in faqList"
|
||||
:key="item.id || idx"
|
||||
class="faq-item"
|
||||
@click="toggle(idx)"
|
||||
>
|
||||
<view class="faq-q-row">
|
||||
<text class="faq-index">{{ idx + 1 }}</text>
|
||||
<text class="faq-question">{{ item.question }}</text>
|
||||
<text class="faq-arrow" :class="{ open: activeIndex === idx }">›</text>
|
||||
</view>
|
||||
<view v-if="activeIndex === idx" class="faq-answer-box">
|
||||
<text v-if="item.category" class="faq-category">【{{ item.category }}】</text>
|
||||
<text class="faq-answer">{{ item.answer }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="faq-footer">
|
||||
<text class="footer-tip">仍未解决?请在「个人中心 → 官方在线客服」联系我们</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||
import { getPlatformConfigs } from '@/common/api.js';
|
||||
|
||||
const currentThemeKey = ref(getCurrentThemeKey());
|
||||
const isLoading = ref(true);
|
||||
const faqList = ref([]);
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
const toggle = (idx) => {
|
||||
activeIndex.value = activeIndex.value === idx ? -1 : idx;
|
||||
};
|
||||
|
||||
const loadFaq = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const res = await getPlatformConfigs();
|
||||
const list = (res && res.faq_list) || [];
|
||||
faqList.value = Array.isArray(list) ? list : [];
|
||||
} catch (e) {
|
||||
console.warn('[FAQ] 加载帮助中心配置失败:', e && e.message);
|
||||
faqList.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
applyThemeToNativeUI(currentThemeKey.value);
|
||||
loadFaq();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
applyThemeToNativeUI(currentThemeKey.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.faq-page {
|
||||
min-height: 100vh;
|
||||
background-color: #0A0F1D;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.faq-header {
|
||||
padding: 40rpx 36rpx 24rpx;
|
||||
background: #10172A;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
.faq-badge {
|
||||
display: inline-block;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #10B981;
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.faq-title {
|
||||
display: block;
|
||||
font-size: 38rpx;
|
||||
font-weight: 800;
|
||||
color: #F8FAFC;
|
||||
letter-spacing: 1rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.faq-meta {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
color: #64748B;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-scroll {
|
||||
flex: 1;
|
||||
padding: 28rpx 32rpx calc(60rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.faq-state {
|
||||
padding: 80rpx 20rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.state-text {
|
||||
font-size: 26rpx;
|
||||
color: #64748B;
|
||||
text-align: center;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
background: #151D30;
|
||||
border-radius: 20rpx;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding: 28rpx 28rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.faq-q-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.faq-index {
|
||||
flex-shrink: 0;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818CF8;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.faq-question {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #F8FAFC;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.faq-arrow {
|
||||
flex-shrink: 0;
|
||||
margin-left: 12rpx;
|
||||
font-size: 40rpx;
|
||||
color: #64748B;
|
||||
transition: transform 0.2s ease;
|
||||
transform: rotate(90deg);
|
||||
|
||||
&.open {
|
||||
transform: rotate(-90deg);
|
||||
color: #10B981;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.faq-answer-box {
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
|
||||
.faq-category {
|
||||
display: block;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #818CF8;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #94A3B8;
|
||||
line-height: 1.75;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-footer {
|
||||
margin-top: 36rpx;
|
||||
padding: 0 8rpx;
|
||||
|
||||
.footer-tip {
|
||||
font-size: 22rpx;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* 浅色暖阳模式适配 */
|
||||
.theme-warm {
|
||||
background-color: #FAF8F5;
|
||||
|
||||
.faq-header {
|
||||
background: #FFFFFF;
|
||||
border-bottom-color: #E7E5E4;
|
||||
|
||||
.faq-title {
|
||||
color: #1C1917;
|
||||
}
|
||||
.faq-meta {
|
||||
color: #78716C;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-state .state-text {
|
||||
color: #78716C;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
background: #FFFFFF;
|
||||
border-color: #E7E5E4;
|
||||
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.faq-q-row {
|
||||
.faq-question {
|
||||
color: #1C1917;
|
||||
}
|
||||
.faq-arrow {
|
||||
color: #A8A29E;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-answer-box {
|
||||
border-top-color: #F5F5F4;
|
||||
|
||||
.faq-category {
|
||||
color: #EA580C;
|
||||
}
|
||||
.faq-answer {
|
||||
color: #57534E;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-footer .footer-tip {
|
||||
color: #A8A29E;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// 已读取AGENTS.md
|
||||
/**
|
||||
* 微信小程序「订阅消息」(服务通知) 下发客户端。
|
||||
* 说明:订阅消息为一次性订阅,用户每次授权仅可下发一条;调用需有效 access_token。
|
||||
* 该模块为纯函数/无状态,便于离线单元校验与复用。
|
||||
*/
|
||||
import https from 'https';
|
||||
|
||||
export interface SubscribeMessagePayload {
|
||||
touser: string;
|
||||
template_id: string;
|
||||
page?: string;
|
||||
data: Record<string, { value: string }>;
|
||||
miniprogram_state?: 'developer' | 'trial' | 'formal';
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装微信订阅消息 data 字段:入参为 { 字段名: 值 },输出 { 字段名: { value } }。
|
||||
* 微信各字段有长度限制(thing 类通常 ≤20 字符),此处统一截断到 20,避免 47003 参数错误。
|
||||
*/
|
||||
export function buildTemplateData(input: Record<string, string | number | undefined | null>): Record<string, { value: string }> {
|
||||
const out: Record<string, { value: string }> = {};
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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<any> {
|
||||
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<any> {
|
||||
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;
|
||||
|
||||
@@ -208,7 +208,11 @@ export async function initDatabase(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
|
||||
@@ -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<any[]> {
|
||||
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<any> {
|
||||
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<any | null> {
|
||||
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<any> {
|
||||
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<number> {
|
||||
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<any> {
|
||||
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<number> {
|
||||
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<any[]> {
|
||||
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<number> {
|
||||
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<any[]> {
|
||||
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<any | null> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<any[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
文件差异内容过多而无法显示
加载差异
@@ -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;');
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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("'房主追加赏金差额退补'");
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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('该大神昵称已被占用,请更换昵称后重新提交');
|
||||
});
|
||||
});
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 [];');
|
||||
});
|
||||
});
|
||||
@@ -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 = ?');
|
||||
});
|
||||
});
|
||||
@@ -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('暂无常见问题');
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('确认验收失败,请稍后重试');
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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('不可申请完成');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
@@ -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 <> ?');
|
||||
});
|
||||
});
|
||||
@@ -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()}');
|
||||
});
|
||||
});
|
||||
});
|
||||
57
server/tests/read-scope.spec.ts
普通文件
57
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()}');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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))');
|
||||
});
|
||||
});
|
||||
@@ -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, '履约中', '服务中', '待确认'");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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))");
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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 ||');
|
||||
});
|
||||
});
|
||||
@@ -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();');
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
20
server/tests/upload-auth.spec.ts
普通文件
20
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('请先登录后再上传');
|
||||
});
|
||||
});
|
||||
@@ -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 ?');
|
||||
});
|
||||
});
|
||||
@@ -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("'查看财务流水'");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()}');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户