From 78117bb4e1990449c06965e7a2d5508656bff391 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Fri, 16 Oct 2026 17:38:17 +0800 Subject: [PATCH] feat: security hardening, wechat pay v3 platform cert integration, companion certification revocation, and audit closed-loop --- .../src/views/activity/list/index.vue | 17 + .../src/views/banner/manage/index.vue | 8 +- .../src/views/banner/nav-category/index.vue | 8 +- .../src/views/companion/audit/index.vue | 63 +- .../src/views/companion/list/index.vue | 112 +- .../workplace/components/action-hub.vue | 27 +- .../workplace/components/content-chart.vue | 50 +- .../workplace/components/data-panel.vue | 25 +- .../workplace/components/quick-todo-list.vue | 12 +- .../workplace/components/quick-verifier.vue | 9 +- .../src/views/finance/config/index.vue | 361 +++- .../src/views/finance/coupons/index.vue | 8 +- .../views/finance/recharge-tiers/index.vue | 8 +- .../views/safety/platform-config/index.vue | 426 +++++ .../src/views/user/list/index.vue | 88 +- pwuniapp/App.vue | 14 +- pwuniapp/common/api.js | 294 ++- pwuniapp/common/icons.js | 15 + pwuniapp/common/location.js | 83 + pwuniapp/common/request.js | 7 + pwuniapp/common/role.js | 119 +- pwuniapp/components/CustomTabBar.vue | 5 +- pwuniapp/manifest.json | 13 +- pwuniapp/pages.json | 30 + pwuniapp/pages/activity/create.vue | 393 +++- pwuniapp/pages/activity/detail.vue | 836 +++++++-- pwuniapp/pages/auth/login.vue | 104 +- pwuniapp/pages/community/detail.vue | 1244 +++++++++++++ pwuniapp/pages/community/index.vue | 802 ++++++++- pwuniapp/pages/expert/detail.vue | 135 +- pwuniapp/pages/expert/hall.vue | 789 ++++++++- pwuniapp/pages/index/index.vue | 15 + pwuniapp/pages/order/create.vue | 148 +- pwuniapp/pages/order/list.vue | 39 +- pwuniapp/pages/user/favorites.vue | 30 +- pwuniapp/pages/user/index.vue | 270 +-- pwuniapp/pages/user/recharge.vue | 1407 +++++++++++++++ pwuniapp/pages/user/reports.vue | 1190 +++++++++++++ pwuniapp/pages/user/settings.vue | 178 +- server/src/core/pay/wechat-pay-v3.ts | 215 +++ .../core/security/content-security.service.ts | 45 + server/src/core/security/user-token.ts | 54 + server/src/db/mysql.ts | 125 +- server/src/db/repository.ts | 1295 ++++++++++++-- server/src/index.ts | 1578 +++++++++++++++-- server/tests/admin-features.spec.ts | 82 +- server/tests/admin-observability.spec.ts | 69 + server/tests/client-features.spec.ts | 36 + server/tests/content-security.spec.ts | 116 +- server/tests/dashboard-reality.spec.ts | 49 + .../tests/identity-binding-hardening.spec.ts | 84 + server/tests/identity-hardening.spec.ts | 152 ++ server/tests/order-activity-integrity.spec.ts | 93 + .../tests/order-lifecycle-hardening.spec.ts | 133 ++ server/tests/ownership-hardening.spec.ts | 74 + server/tests/rbac-permission.spec.ts | 13 + server/tests/security-hardening.spec.ts | 116 ++ server/tests/wechat-pay-v3.spec.ts | 212 +++ 58 files changed, 12971 insertions(+), 952 deletions(-) create mode 100644 pwuniapp/common/location.js create mode 100644 pwuniapp/pages/community/detail.vue create mode 100644 pwuniapp/pages/user/recharge.vue create mode 100644 pwuniapp/pages/user/reports.vue create mode 100644 server/src/core/pay/wechat-pay-v3.ts create mode 100644 server/src/core/security/user-token.ts create mode 100644 server/tests/admin-observability.spec.ts create mode 100644 server/tests/dashboard-reality.spec.ts create mode 100644 server/tests/identity-binding-hardening.spec.ts create mode 100644 server/tests/identity-hardening.spec.ts create mode 100644 server/tests/order-activity-integrity.spec.ts create mode 100644 server/tests/order-lifecycle-hardening.spec.ts create mode 100644 server/tests/ownership-hardening.spec.ts create mode 100644 server/tests/security-hardening.spec.ts create mode 100644 server/tests/wechat-pay-v3.spec.ts diff --git a/admin/arco-design-pro-vite/src/views/activity/list/index.vue b/admin/arco-design-pro-vite/src/views/activity/list/index.vue index 0a8b678..db42a4b 100644 --- a/admin/arco-design-pro-vite/src/views/activity/list/index.vue +++ b/admin/arco-design-pro-vite/src/views/activity/list/index.vue @@ -176,6 +176,16 @@ + + + @@ -240,6 +250,7 @@ title: '', gameCategory: '同城陪吃', location: '', + startTime: '', payType: 1, maxParticipants: 4, perPersonFee: 100, @@ -307,6 +318,7 @@ title: '', gameCategory: '同城陪吃', location: '', + startTime: '', payType: 1, maxParticipants: 4, perPersonFee: 100, @@ -319,12 +331,17 @@ Message.warning('请填写完整的组局标题与公开地点'); return; } + if (!createForm.value.startTime) { + Message.warning('请选择活动时段'); + return; + } try { await axios.post('/api/activities', { creatorId: 1001, title: createForm.value.title, gameCategory: createForm.value.gameCategory, location: createForm.value.location, + startTime: createForm.value.startTime, payType: createForm.value.payType, maxParticipants: createForm.value.maxParticipants, perPersonFeeCents: createForm.value.perPersonFee * 100, diff --git a/admin/arco-design-pro-vite/src/views/banner/manage/index.vue b/admin/arco-design-pro-vite/src/views/banner/manage/index.vue index beadf7a..8291d16 100644 --- a/admin/arco-design-pro-vite/src/views/banner/manage/index.vue +++ b/admin/arco-design-pro-vite/src/views/banner/manage/index.vue @@ -355,14 +355,14 @@ try { if (isEdit.value && editingId.value) { const res: any = await axios.put(`/api/admin/banners/${editingId.value}`, formData.value); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('轮播图修改成功'); modalVisible.value = false; fetchData(); } } else { const res: any = await axios.post('/api/admin/banners', formData.value); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('轮播图创建成功,已同步至小程序端'); modalVisible.value = false; fetchData(); @@ -380,7 +380,7 @@ const res: any = await axios.post(`/api/admin/banners/${record.id}/status`, { status: newStatus, }); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { record.status = newStatus; Message.success(newStatus === 1 ? '已上架至小程序端' : '已下线隐藏'); } @@ -394,7 +394,7 @@ const handleDelete = async (id: number) => { try { const res: any = await axios.delete(`/api/admin/banners/${id}`); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('轮播图已删除'); fetchData(); } diff --git a/admin/arco-design-pro-vite/src/views/banner/nav-category/index.vue b/admin/arco-design-pro-vite/src/views/banner/nav-category/index.vue index 7e1aef7..3d1f9b2 100644 --- a/admin/arco-design-pro-vite/src/views/banner/nav-category/index.vue +++ b/admin/arco-design-pro-vite/src/views/banner/nav-category/index.vue @@ -311,14 +311,14 @@ try { if (isEdit.value && editingId.value) { const res: any = await axios.put(`/api/admin/nav-categories/${editingId.value}`, formData.value); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('品类导航修改成功'); modalVisible.value = false; fetchData(); } } else { const res: any = await axios.post('/api/admin/nav-categories', formData.value); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('品类导航新增成功,已同步至小程序首页'); modalVisible.value = false; fetchData(); @@ -336,7 +336,7 @@ const res: any = await axios.post(`/api/admin/nav-categories/${record.id}/status`, { status: newStatus, }); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { record.status = newStatus; Message.success(newStatus === 1 ? '品类已上架至小程序' : '品类已下线隐藏'); } @@ -350,7 +350,7 @@ const handleDelete = async (id: number) => { try { const res: any = await axios.delete(`/api/admin/nav-categories/${id}`); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('品类导航已彻底删除'); fetchData(); } diff --git a/admin/arco-design-pro-vite/src/views/companion/audit/index.vue b/admin/arco-design-pro-vite/src/views/companion/audit/index.vue index 25aa7e1..e632bd6 100644 --- a/admin/arco-design-pro-vite/src/views/companion/audit/index.vue +++ b/admin/arco-design-pro-vite/src/views/companion/audit/index.vue @@ -78,15 +78,21 @@ - + + + 原因:{{ record.rejectReason }} + - + 驳回 - 已审核结案 + + 已审核结案 + + 解除认证 + + + + 已审核结案 + + 恢复认证 + + - + - + @@ -143,9 +170,10 @@ const statusTab = ref('ALL'); const playingId = ref(null); - // 驳回弹窗 + // 驳回/解除认证弹窗 const rejectModalVisible = ref(false); const currentRejectItem = ref(null); + const rejectActionType = ref<'reject' | 'revoke'>('reject'); const rejectForm = ref({ reason: '', }); @@ -195,32 +223,39 @@ }, 3000); }; - const handleAudit = async (id: number, action: 'approve' | 'reject', reason?: string) => { + const handleAudit = async (id: number, action: 'approve' | 'reject' | 'revoke', reason?: string) => { try { await axios.post(`/api/companions/${id}/audit`, { action, reason, }); - Message.success(action === 'approve' ? '审核通过,已开通接单资质' : '已成功驳回'); + Message.success( + action === 'approve' + ? '审核通过,已开通接单资质' + : action === 'revoke' + ? '已解除大神认证资质' + : '已成功驳回' + ); fetchData(); } catch (err: any) { Message.error(err.message || '操作失败'); } }; - const openRejectModal = (record: any) => { + const openRejectModal = (record: any, actionType: 'reject' | 'revoke' = 'reject') => { currentRejectItem.value = record; + rejectActionType.value = actionType; rejectForm.value.reason = ''; rejectModalVisible.value = true; }; const handleRejectSubmit = () => { if (!rejectForm.value.reason) { - Message.warning('请填写具体的驳回原因'); + Message.warning(rejectActionType.value === 'revoke' ? '请填写解除认证的原因' : '请填写具体的驳回原因'); return; } if (currentRejectItem.value) { - handleAudit(currentRejectItem.value.id, 'reject', rejectForm.value.reason); + handleAudit(currentRejectItem.value.id, rejectActionType.value, rejectForm.value.reason); rejectModalVisible.value = false; } }; diff --git a/admin/arco-design-pro-vite/src/views/companion/list/index.vue b/admin/arco-design-pro-vite/src/views/companion/list/index.vue index 93fffbb..d8d586f 100644 --- a/admin/arco-design-pro-vite/src/views/companion/list/index.vue +++ b/admin/arco-design-pro-vite/src/views/companion/list/index.vue @@ -131,20 +131,64 @@ /> - + - - {{ record.status === '接单中' ? '设为休息' : '恢复接单' }} - + + + {{ record.status === '接单中' ? '设为休息' : '恢复接单' }} + + + 解除认证 + + + + + + + 解除【{{ currentRevokeItem?.name }}】的大神认证资质后,该大神将立即从小程序大神列表下架,并退回为普通玩家身份(如需恢复接单须重新提交资质审核或由运营在审核列表恢复)。 + + + + + + {{ preset }} + + + + + + @@ -160,6 +204,18 @@ const statusFilter = ref('ALL'); const playingId = ref(null); + const revokeModalVisible = ref(false); + const currentRevokeItem = ref(null); + const revokeForm = ref({ + reason: '', + }); + const revokePresets = [ + '服务评分低于平台考核标准,暂停接单资质', + '多次收到用户违约或服务态度投诉,清退整改', + '实名身份或语音资料复核不符,需重新提交认证', + '大神本人主动申请退网解约', + ]; + const fetchData = async () => { loading.value = true; try { @@ -216,9 +272,43 @@ } }; - const toggleStatus = (record: any) => { - record.status = record.status === '接单中' ? '休息中' : '接单中'; - Message.success(`大神【${record.name}】状态已切换为【${record.status}】`); + const toggleStatus = async (record: any) => { + const nextStatus = record.status === '接单中' ? '休息中' : '接单中'; + try { + await axios.put(`/api/companions/${record.id}`, { + status: nextStatus, + }); + record.status = nextStatus; + Message.success(`大神【${record.name}】状态已切换为【${nextStatus}】`); + } catch (err: any) { + Message.error(err.message || '切换接单状态失败'); + } + }; + + const openRevokeModal = (record: any) => { + currentRevokeItem.value = record; + revokeForm.value.reason = ''; + revokeModalVisible.value = true; + }; + + const handleRevokeSubmit = async () => { + const reason = revokeForm.value.reason.trim(); + if (!reason) { + Message.warning('请填写或选择解除认证的原因'); + return; + } + if (!currentRevokeItem.value) return; + try { + await axios.post(`/api/companions/${currentRevokeItem.value.id}/audit`, { + action: 'revoke', + reason, + }); + Message.success(`已解除【${currentRevokeItem.value.name}】的大神认证资质`); + revokeModalVisible.value = false; + fetchData(); + } catch (err: any) { + Message.error(err.message || '解除认证失败'); + } }; onMounted(() => { diff --git a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/action-hub.vue b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/action-hub.vue index b5cb2c6..8ac19f1 100644 --- a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/action-hub.vue +++ b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/action-hub.vue @@ -1,10 +1,13 @@ + + 待办数据加载失败,"需要人工介入"计数可能显示为 0,请勿据此判断当前无待办;请刷新页面或检查服务端接口。 + 实时待办与决策中心 - 需要人工介入 ({{ totalUrgentCount }}) + 需要人工介入 ({{ loadFailed ? '加载失败' : totalUrgentCount }}) 跨角色协同决策流:运营现场核验、风控资质审核、争议仲裁裁决、财务提现打款 @@ -127,16 +130,18 @@ import { ref, computed, onMounted } from 'vue'; import { useRouter } from 'vue-router'; import axios from 'axios'; + import { Message } from '@arco-design/web-vue'; const router = useRouter(); + const loadFailed = ref(false); const todos = ref({ - pendingAuditsCount: 2, - pendingArbitrationsCount: 1, - pendingWithdrawalsCount: 1, - pendingWithdrawalAmount: 1250, - pendingReportsCount: 1, - riskInterceptionsCount: 3, + pendingAuditsCount: 0, + pendingArbitrationsCount: 0, + pendingWithdrawalsCount: 0, + pendingWithdrawalAmount: 0, + pendingReportsCount: 0, + riskInterceptionsCount: 0, }); const totalUrgentCount = computed(() => { @@ -153,9 +158,15 @@ const res: any = await axios.get('/api/dashboard/todos'); if (res && res.data) { todos.value = res.data; + loadFailed.value = false; + } else { + loadFailed.value = true; + Message.error('待办数据加载失败,请刷新重试'); } } catch { - // 保持兜底状态 + // 不再静默兜底为 0:显式标记失败,避免管理员误判"当前无待办" + loadFailed.value = true; + Message.error('待办数据加载失败,请刷新重试'); } }; diff --git a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/content-chart.vue b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/content-chart.vue index 56d28d1..ce92c1e 100644 --- a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/content-chart.vue +++ b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/content-chart.vue @@ -11,7 +11,10 @@ {{ $t('workplace.viewMore') }} - + + 近 7 日成交走势加载失败,图表暂不可用;请刷新页面或检查服务端接口。 + + @@ -19,6 +22,7 @@ diff --git a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-todo-list.vue b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-todo-list.vue index 748102e..7985484 100644 --- a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-todo-list.vue +++ b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-todo-list.vue @@ -1,5 +1,8 @@ + + 重点待办加载失败,列表为空并不代表无待办;请刷新页面或检查服务端接口。 + @@ -99,6 +102,7 @@ const pendingWithdrawals = ref([]); const pendingArbitrations = ref([]); const pendingReports = ref([]); + const loadFailed = ref(false); const fetchQuickTodos = async () => { try { @@ -108,9 +112,15 @@ pendingWithdrawals.value = (res.data.pendingWithdrawals || []).slice(0, 1); pendingArbitrations.value = (res.data.pendingArbitrations || []).slice(0, 1); pendingReports.value = (res.data.pendingReports || []).slice(0, 1); + loadFailed.value = false; + } else { + loadFailed.value = true; + Message.error('重点待办加载失败,请刷新重试'); } } catch { - // 保持兜底 + // 不再静默兜底为空列表:显式标记失败,避免管理员漏处理待办 + loadFailed.value = true; + Message.error('重点待办加载失败,请刷新重试'); } }; diff --git a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-verifier.vue b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-verifier.vue index 61741da..eaa95d2 100644 --- a/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-verifier.vue +++ b/admin/arco-design-pro-vite/src/views/dashboard/workplace/components/quick-verifier.vue @@ -6,6 +6,9 @@ + + 组局活动列表加载失败,暂无法选择要核销的活动;请刷新页面或检查服务端接口后再试。 + 现场用户出示4位纯数字即可即时到场校验 ([]); const lastVerifiedResult = ref(''); + const activitiesFailed = ref(false); const fetchActivities = async () => { try { @@ -63,8 +67,11 @@ activityList.value = res.data; selectedActivityId.value = res.data[0].id; } + activitiesFailed.value = false; } catch { - // 保持兜底 + // 不再静默兜底为空活动列表:显式标记失败,避免现场核销误判"无活动可选" + activitiesFailed.value = true; + Message.error('组局活动列表加载失败,暂无法核销,请刷新重试'); } }; diff --git a/admin/arco-design-pro-vite/src/views/finance/config/index.vue b/admin/arco-design-pro-vite/src/views/finance/config/index.vue index 70d66c8..c4bf921 100644 --- a/admin/arco-design-pro-vite/src/views/finance/config/index.vue +++ b/admin/arco-design-pro-vite/src/views/finance/config/index.vue @@ -5,6 +5,15 @@ 分销与抽成配置 + + 检测到配置读取失败。为避免用页面默认值覆盖线上正确配置,保存与自检按钮已暂时禁用;请刷新页面或检查服务端接口后重试。 + + 参数全局生效说明:修改抽成与分销比例后,新生成的订单将即刻采用最新费率进行自动清结算分账;老订单按照下单时的历史固定抽成费率履约。 @@ -91,13 +100,240 @@ - + 保存并实时生效 + + + + + + + {{ + wxPayEffectiveMode === 'LIVE' + ? '当前运行状态:微信支付实盘模式 (LIVE)' + : '当前运行状态:沙箱收银台模式 (SANDBOX)' + }} + + + 配置自检与签名验证 + + + + + + 当您申请下来微信支付商户号后,在此填入商户号 (mchid)、APIv3 密钥及商户私钥证书 (apiclient_key.pem) 并保存,小程序端下单与钱包充值即可无缝切换为真实微信支付收银台。未配置完整前默认运行于沙箱模拟收银台模式,不影响功能验收。 + + + + + + + + 自动识别切换 (填齐证书即开启实盘,未填齐走沙箱) + 强制微信支付实盘模式 (LIVE - 生产环境) + 强制沙箱收银台模式 (SANDBOX - 演示测试) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 商户 API 证书与私钥内容 (PEM 格式) + + + + + + 包含 -----BEGIN PRIVATE KEY----- 至 -----END PRIVATE KEY----- + + + 读取本地 apiclient_key.pem 文件 + + + + + + + + + + + 包含 -----BEGIN CERTIFICATE----- 至 -----END CERTIFICATE----- + + + 读取本地 apiclient_cert.pem 文件 + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ chk.pass ? '通过' : '待完善' }} + + {{ chk.item }}: + {{ chk.detail }} + + + + + + + + + + 保存微信支付商户配置 + + + 执行参数与证书签名自检 + + + + + @@ -107,6 +343,9 @@ import { Message } from '@arco-design/web-vue'; const saving = ref(false); + // 配置读取是否成功:失败时锁定保存,避免用页面默认值覆盖线上配置 + const configLoaded = ref(false); + const wxPayLoaded = ref(false); const form = ref({ platformRatePercent: 10, firstReferralPercent: 5, @@ -116,18 +355,67 @@ withdrawFeePercent: 0, }); + // 微信支付 V3 商户配置表单 + const wxPaySaving = ref(false); + const wxPayTesting = ref(false); + const wxPayEffectiveMode = ref<'LIVE' | 'SANDBOX'>('SANDBOX'); + const wxPayTestResult = ref(null); + const pemFileInputRef = ref(null); + const currentPemTargetField = ref<'privateKey' | 'certPem'>('privateKey'); + + const wxPayForm = ref({ + mode: 'AUTO', + appId: '', + appSecret: '', + mchId: '', + apiV3Key: '', + serialNo: '', + privateKey: '', + certPem: '', + platformPublicKeyId: '', + platformPublicKey: '', + notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify', + remark: '微信支付V3直连商户参数配置' + }); + const fetchData = async () => { try { const res: any = await axios.get('/api/finance/config'); if (res && res.data) { form.value = res.data; } + configLoaded.value = true; } catch { - // 保持兜底 + // 加载失败绝不能用页面默认值兜底,否则保存会覆盖线上正确费率 + configLoaded.value = false; + Message.error('财务抽成配置加载失败,已锁定保存按钮,避免以默认值覆盖线上配置'); + } + + try { + const wxRes: any = await axios.get('/api/admin/pay/wechat/config'); + const data = wxRes?.data?.data || wxRes?.data || wxRes; + if (data && typeof data === 'object') { + wxPayForm.value = { + ...wxPayForm.value, + ...data + }; + if (data.effectiveMode) { + wxPayEffectiveMode.value = data.effectiveMode; + } + } + wxPayLoaded.value = true; + } catch { + // 加载失败绝不能展示空表单,否则保存会清空线上商户号/密钥/证书 + wxPayLoaded.value = false; + Message.error('微信支付商户配置加载失败,已锁定保存按钮,避免清空线上商户参数'); } }; const handleSave = async () => { + if (!configLoaded.value) { + Message.warning('配置尚未成功加载,请刷新页面后再保存,避免覆盖线上费率'); + return; + } saving.value = true; try { await axios.post('/api/finance/config', form.value); @@ -139,6 +427,75 @@ } }; + const triggerPemFileSelect = (field: 'privateKey' | 'certPem') => { + currentPemTargetField.value = field; + if (pemFileInputRef.value) { + pemFileInputRef.value.value = ''; + pemFileInputRef.value.click(); + } + }; + + const handlePemFileChange = (e: Event) => { + const input = e.target as HTMLInputElement; + const file = input?.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const content = String(reader.result || '').trim(); + wxPayForm.value[currentPemTargetField.value] = content; + Message.success(`已成功读取证书文件: ${file.name}`); + }; + reader.onerror = () => { + Message.error('读取证书文件失败,请手动复制粘贴文本内容'); + }; + reader.readAsText(file, 'utf-8'); + }; + + const handleSaveWxPay = async () => { + if (!wxPayLoaded.value) { + Message.warning('商户配置尚未成功加载,请刷新页面后再保存,避免清空线上商户参数'); + return; + } + wxPaySaving.value = true; + try { + 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) { + wxPayEffectiveMode.value = data.effectiveMode; + } + Message.success( + wxPayEffectiveMode.value === 'LIVE' + ? '微信支付商户配置已保存,实盘支付模式 (LIVE) 已激活生效' + : '微信支付配置已保存,当前运行于沙箱收银台模式 (SANDBOX)' + ); + } catch (err: any) { + Message.error(err.response?.data?.message || err.message || '保存微信支付配置失败'); + } finally { + wxPaySaving.value = false; + } + }; + + const handleTestWxPay = async () => { + if (!wxPayLoaded.value) { + Message.warning('商户配置尚未成功加载,请刷新页面后再自检'); + return; + } + wxPayTesting.value = true; + try { + const res: any = await axios.post('/api/admin/pay/wechat/test', wxPayForm.value); + const data = res?.data?.data || res?.data || res; + wxPayTestResult.value = data; + if (data && data.effectiveMode) { + wxPayEffectiveMode.value = data.effectiveMode; + } + Message.success('微信支付配置与签名自检完成'); + } catch (err: any) { + Message.error(err.response?.data?.message || err.message || '配置自检失败'); + } finally { + wxPayTesting.value = false; + } + }; + onMounted(() => { fetchData(); }); diff --git a/admin/arco-design-pro-vite/src/views/finance/coupons/index.vue b/admin/arco-design-pro-vite/src/views/finance/coupons/index.vue index c42487b..ace970a 100644 --- a/admin/arco-design-pro-vite/src/views/finance/coupons/index.vue +++ b/admin/arco-design-pro-vite/src/views/finance/coupons/index.vue @@ -285,14 +285,14 @@ try { if (isEdit.value && editingId.value) { const res: any = await axios.put(`/api/admin/coupons/${editingId.value}`, formData.value); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('优惠券修改成功'); modalVisible.value = false; fetchData(); } } else { const res: any = await axios.post('/api/admin/coupons', formData.value); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('优惠券创建成功,已同步至小程序'); modalVisible.value = false; fetchData(); @@ -310,7 +310,7 @@ const res: any = await axios.put(`/api/admin/coupons/${record.id}`, { status: newStatus, }); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { record.status = newStatus; Message.success(newStatus === 1 ? '优惠券已上架发放' : '优惠券已停用下架'); } @@ -324,7 +324,7 @@ const handleDelete = async (id: number) => { try { const res: any = await axios.delete(`/api/admin/coupons/${id}`); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('优惠券已彻底删除'); fetchData(); } diff --git a/admin/arco-design-pro-vite/src/views/finance/recharge-tiers/index.vue b/admin/arco-design-pro-vite/src/views/finance/recharge-tiers/index.vue index 0c69c34..6679603 100644 --- a/admin/arco-design-pro-vite/src/views/finance/recharge-tiers/index.vue +++ b/admin/arco-design-pro-vite/src/views/finance/recharge-tiers/index.vue @@ -215,14 +215,14 @@ try { if (isEdit.value && editingId.value) { const res: any = await axios.put(`/api/admin/recharge-tiers/${editingId.value}`, formData.value); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('充值阶梯修改成功'); modalVisible.value = false; fetchData(); } } else { const res: any = await axios.post('/api/admin/recharge-tiers', formData.value); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('充值阶梯创建成功,已同步至小程序'); modalVisible.value = false; fetchData(); @@ -240,7 +240,7 @@ const res: any = await axios.put(`/api/admin/recharge-tiers/${record.id}`, { status: newStatus, }); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { record.status = newStatus; Message.success(newStatus === 1 ? '充值档位已上架' : '充值档位已下线'); } @@ -254,7 +254,7 @@ const handleDelete = async (id: number) => { try { const res: any = await axios.delete(`/api/admin/recharge-tiers/${id}`); - if (res && (res.data.code === 20000 || res.status === 200)) { + if (res && res.code === 20000) { Message.success('充值阶梯已彻底删除'); fetchData(); } diff --git a/admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue b/admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue index e13acc9..5c6d9bf 100644 --- a/admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue +++ b/admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue @@ -705,6 +705,311 @@ + + + + + + 在此配置微信小程序支付 (JSAPI) 所需的直连商户号 (mchid)、APIv3 密钥、商户证书序列号及私钥 (apiclient_key.pem)。保存后无需重启服务器即可实时生效;未配置完整前自动运行于沙箱模拟收银台模式。 + + + + + + + {{ + wxPayForm.valueJson.mchId && wxPayForm.valueJson.apiV3Key && wxPayForm.valueJson.privateKey && wxPayForm.valueJson.mode !== 'SANDBOX' + ? '已满足实盘支付条件 (LIVE)' + : '当前运行:沙箱收银台模式 (SANDBOX)' + }} + + + 配置与签名自检 + + + + + + + + + + 自动识别切换 (填齐商户号与证书即开启实盘) + 强制微信支付实盘模式 (LIVE - 生产环境) + 强制沙箱收银台模式 (SANDBOX - 演示测试) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 支持直接粘贴文本,或点击右侧按钮读取本地 apiclient_key.pem 文件 + + + 读取本地 apiclient_key.pem 文件 + + + + + + + + + + + 支持直接粘贴文本,或点击右侧按钮读取本地 apiclient_cert.pem 文件并自动解析序列号 + + + + 从证书提取序列号 + + + 读取本地 apiclient_cert.pem 文件 + + + + + + + + + 微信支付平台公钥配置(用于商家转账到零钱实名加密与异步回调验签,选填) + + + + + + + + + + + + + + + 支持直接粘贴文本,或点击右侧按钮读取从微信商户后台下载的本地 pub_key.pem 公钥文件 + + + 读取本地 pub_key.pem 文件 + + + + + + + + + + + + + {{ chk.pass ? '通过' : '待完善' }} + + {{ chk.item }}: + {{ chk.detail }} + + + + + + + + 保存微信支付商户配置 + + + 执行参数与证书签名自检 + + + + + + + + + + + + + 在此维护微信小程序的地图定位服务参数(腾讯位置服务 Tencent LBS Key)。用于同城组局发布时的地图选点(chooseLocation)、经纬度逆地址解析及附近搭子/活动测距。 + + 配置指引:登录腾讯位置服务控制台 (lbs.qq.com) -> 创建应用并添加 Key -> 勾选 WebServiceAPI 与微信小程序授权(填入小程序 AppID: wx0c929f7e54024e67)。 + + + + + + + + + + + + + + + + + + + + + + + + 需在腾讯位置服务后台开通 WebServiceAPI,并授权绑定小程序 AppID: wx0c929f7e54024e67。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 保存地图定位配置 + + + + + + + @@ -1030,6 +1335,117 @@ const saveMessageTemplateConfig = () => { saveConfig('message_template_rules', messageTemplateForm); }; +// Tab 12 微信支付商户号与证书配置 +const wxPayForm = reactive({ + name: '微信支付商户号与证书配置', + description: '微信支付V3商户号、APIv3密钥与证书参数配置', + status: 1, + valueJson: { + mode: 'AUTO', + appId: '', + appSecret: '', + mchId: '', + apiV3Key: '', + serialNo: '', + privateKey: '', + certPem: '', + platformPublicKeyId: '', + platformPublicKey: '', + notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify', + remark: '微信支付V3直连商户参数配置' + } +}); + +const wxPayTesting = ref(false); +const wxPayTestResult = ref(null); +const pemInputRef = ref(null); +const activePemField = ref<'privateKey' | 'certPem' | 'platformPublicKey'>('privateKey'); + +const triggerPemSelect = (field: 'privateKey' | 'certPem' | 'platformPublicKey') => { + activePemField.value = field; + if (pemInputRef.value) { + pemInputRef.value.value = ''; + pemInputRef.value.click(); + } +}; + +const extractCertSerialNo = async () => { + if (!wxPayForm.valueJson.certPem) { + Message.warning('请先读取或粘贴 apiclient_cert.pem 证书内容'); + return; + } + try { + const res: any = await axios.post('/api/admin/pay/wechat/parse-cert', { + certPem: wxPayForm.valueJson.certPem + }); + const data = res?.data?.data || res?.data || res; + if (data && data.serialNo) { + wxPayForm.valueJson.serialNo = data.serialNo; + Message.success(`已自动提取 40 位证书序列号: ${data.serialNo}`); + } + } catch (err: any) { + Message.warning(err.response?.data?.message || err.message || '未能自动提取序列号,请手动填写'); + } +}; + +const onPemFileSelected = (e: Event) => { + const input = e.target as HTMLInputElement; + const file = input?.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = async () => { + const content = String(reader.result || '').trim(); + wxPayForm.valueJson[activePemField.value] = content; + Message.success(`已读取证书文件: ${file.name}`); + if (activePemField.value === 'certPem' && content.includes('BEGIN CERTIFICATE')) { + await extractCertSerialNo(); + } + }; + reader.readAsText(file, 'utf-8'); +}; + +const saveWechatPayConfig = () => { + saveConfig('wechat_pay_config', wxPayForm); +}; + +// Tab 13 地图定位与LBS服务配置 +const mapLocationForm = reactive({ + name: '地图定位与LBS服务配置', + description: '腾讯位置服务Key、选点与周边测距参数配置', + status: 1, + valueJson: { + provider: 'tencent', + mapKey: '', + secretKey: '', + serviceName: '腾讯位置服务 (Tencent LBS)', + enableChooseLocation: 1, + enableDistanceCalculation: 1, + defaultCity: '北京市', + remark: '腾讯位置服务 Key(需在 lbs.qq.com 申请并勾选 WebServiceAPI 与微信小程序 AppID: wx0c929f7e54024e67)' + } +}); + +const saveMapLocationConfig = () => { + saveConfig('map_location_config', mapLocationForm); +}; + +const testWechatPayConfig = async () => { + wxPayTesting.value = true; + try { + const res: any = await axios.post('/api/admin/pay/wechat/test', wxPayForm.valueJson); + const data = res?.data?.data || res?.data || res; + wxPayTestResult.value = data; + if (data?.serialNo && !wxPayForm.valueJson.serialNo) { + wxPayForm.valueJson.serialNo = data.serialNo; + } + Message.success('微信支付配置与签名自检完成'); + } catch (err: any) { + Message.error(err.response?.data?.message || err.message || '自检请求失败'); + } finally { + wxPayTesting.value = false; + } +}; + const sendTestMessage = async () => { testSending.value = true; try { @@ -1125,6 +1541,16 @@ const fetchConfigs = async () => { messageTemplateForm.description = item.description; messageTemplateForm.status = item.status; messageTemplateForm.valueJson = { ...messageTemplateForm.valueJson, ...(item.valueJson || {}) }; + } else if (item.configKey === 'wechat_pay_config') { + wxPayForm.name = item.name; + wxPayForm.description = item.description; + wxPayForm.status = item.status; + wxPayForm.valueJson = { ...wxPayForm.valueJson, ...(item.valueJson || {}) }; + } else if (item.configKey === 'map_location_config') { + mapLocationForm.name = item.name; + mapLocationForm.description = item.description; + mapLocationForm.status = item.status; + mapLocationForm.valueJson = { ...mapLocationForm.valueJson, ...(item.valueJson || {}) }; } }); } diff --git a/admin/arco-design-pro-vite/src/views/user/list/index.vue b/admin/arco-design-pro-vite/src/views/user/list/index.vue index 42c4681..5a01c6c 100644 --- a/admin/arco-design-pro-vite/src/views/user/list/index.vue +++ b/admin/arco-design-pro-vite/src/views/user/list/index.vue @@ -77,6 +77,20 @@ + + + + 批量删除 ({{ selectedRowKeys.length }}) + + + 刷新列表 @@ -128,6 +142,8 @@ - + @@ -225,6 +241,24 @@ {{ record.status === '正常' ? '封禁' : '解封' }} + + + + + 删除 + + @@ -292,6 +326,20 @@ {{ userDetail.status === '正常' ? '封禁该用户账号' : '解除该用户封禁' }} + + + + + 删除该用户档案 + + + 刷新数据 @@ -566,6 +614,7 @@ const searchKeyword = ref(''); const roleFilter = ref('ALL'); const statusFilter = ref('ALL'); + const selectedRowKeys = ref([]); // 360° 侧滑全景抽屉 const detailDrawerVisible = ref(false); @@ -652,6 +701,7 @@ searchKeyword.value = ''; roleFilter.value = 'ALL'; statusFilter.value = 'ALL'; + selectedRowKeys.value = []; }; // 支持点击整行直接查看360°全景画像 @@ -685,6 +735,42 @@ } }; + const handleDeleteUser = async (record: any) => { + try { + const res: any = await axios.delete(`/api/users/${record.id}`); + if (res && (res.code === 20000 || res.data || res.status === 200)) { + Message.success(`用户 [${record.nickname}] (UID: ${record.id}) 已成功删除`); + if (detailDrawerVisible.value && currentUser.value && currentUser.value.id === record.id) { + detailDrawerVisible.value = false; + } + rawUsers.value = rawUsers.value.filter((u) => u.id !== record.id); + selectedRowKeys.value = selectedRowKeys.value.filter((k) => k !== record.id); + } + } catch (err: any) { + Message.error(err.response?.data?.message || err.message || '删除用户失败'); + } + }; + + const handleBatchDelete = async () => { + if (selectedRowKeys.value.length === 0) return; + try { + const res: any = await axios.post('/api/users/batch-delete', { + ids: selectedRowKeys.value, + }); + if (res && (res.code === 20000 || res.data || res.status === 200)) { + const count = selectedRowKeys.value.length; + Message.success(`已成功批量删除 ${count} 位用户`); + rawUsers.value = rawUsers.value.filter((u) => !selectedRowKeys.value.includes(u.id)); + if (detailDrawerVisible.value && currentUser.value && selectedRowKeys.value.includes(currentUser.value.id)) { + detailDrawerVisible.value = false; + } + selectedRowKeys.value = []; + } + } catch (err: any) { + Message.error(err.response?.data?.message || err.message || '批量删除用户失败'); + } + }; + onMounted(() => { fetchUsers(); }); diff --git a/pwuniapp/App.vue b/pwuniapp/App.vue index 9fc1191..ff08db4 100644 --- a/pwuniapp/App.vue +++ b/pwuniapp/App.vue @@ -1,9 +1,9 @@ + + diff --git a/pwuniapp/pages/community/index.vue b/pwuniapp/pages/community/index.vue index 43f7c68..d88fbcf 100644 --- a/pwuniapp/pages/community/index.vue +++ b/pwuniapp/pages/community/index.vue @@ -63,11 +63,12 @@ v-for="(post, index) in filteredPosts" :key="post.id" class="post-card" + @click="openPostDetail(post)" > - - + + {{ post.author }} {{ post.tag }} @@ -102,7 +103,7 @@ class="join-btn btn-active" :class="{ 'btn-applied': post.hasApplied && post.myApplyStatus !== 'REJECTED' }" :disabled="post.hasApplied && post.myApplyStatus !== 'REJECTED'" - @click="handleJoinParty(post)" + @click.stop="handleJoinParty(post)" > {{ post.myApplyStatus === 'ACCEPTED' ? '已同意上车' : (post.myApplyStatus === 'REJECTED' ? '重新申请' : (post.hasApplied ? '待房主确认' : (post.actionText || '聊一聊'))) }} @@ -125,18 +126,18 @@ class="post-img" :src="img" mode="aspectFill" - @click="previewImage(post.images, idx)" + @click.stop="previewImage(post.images, idx)" /> - + {{ post.likes }} - + {{ post.comments }} @@ -290,10 +291,10 @@ - + - {{ item.author }} + {{ item.author }} {{ item.createdAt ? '刚刚' : '近期' }} {{ item.content }} @@ -396,12 +397,53 @@ import AppTabBar from '@/components/CustomTabBar.vue'; import SkeletonScreen from '@/components/SkeletonScreen.vue'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; +import { ensureLoginOrRedirect } from '@/common/role.js'; import { getNavBarInfo } from '@/common/nav.js'; -import { getPosts, createPost, deletePost, likePost, getPostComments, addPostComment, applyPostParty, getPostApplies, handlePostApply, uploadImage, getPlatformConfigs } from '@/common/api.js'; +import { getPosts, createPost, deletePost, likePost, viewPost, getPostComments, addPostComment, applyPostParty, getPostApplies, handlePostApply, uploadImage, getPlatformConfigs, getUserProfile } from '@/common/api.js'; +const DEFAULT_USER_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; const isLoading = ref(true); const currentThemeKey = ref(getCurrentThemeKey()); const navInfo = ref(getNavBarInfo()); +const currentUserProfile = ref({}); + +const readCachedUserInfo = () => { + const pwInfo = uni.getStorageSync('PW_USER_INFO') || {}; + const uInfo = uni.getStorageSync('userInfo') || {}; + const uid = Number(uni.getStorageSync('userId')) || Number(pwInfo.id) || Number(uInfo.id) || 1001; + const rawName = pwInfo.nickname || uInfo.nickname || ''; + const nickname = (rawName && !['点击登录 / 注册', '电竞玩家 (我)'].includes(rawName)) ? rawName : ''; + const avatar = pwInfo.avatar || uInfo.avatar || ''; + return { + id: uid, + nickname, + avatar + }; +}; + +const syncCurrentUserProfile = async () => { + const cached = readCachedUserInfo(); + currentUserProfile.value = { + ...currentUserProfile.value, + ...cached + }; + try { + const remote = await getUserProfile(cached.id || 1001); + if (remote && remote.nickname) { + const merged = { + ...cached, + ...remote, + nickname: remote.nickname, + avatar: remote.avatar || cached.avatar || DEFAULT_USER_AVATAR + }; + currentUserProfile.value = merged; + uni.setStorageSync('PW_USER_INFO', merged); + uni.setStorageSync('userInfo', merged); + } + } catch (_) {} + return currentUserProfile.value; +}; + const communityNotice = ref({ title: '友圈同城互动与公约置顶', content: '欢迎来到同城搭子友圈!文明交友,发布真实组局诉求;平台全天候对动态与评论实施微信官方内容安全过滤,严禁广告引流及私下交易。', @@ -441,6 +483,7 @@ const onThemeChanged = (newTheme) => { const loadInitialCommunity = async () => { try { + await syncCurrentUserProfile(); await loadRemotePosts(); } finally { setTimeout(() => { @@ -463,6 +506,16 @@ onShow(() => { currentThemeKey.value = getCurrentThemeKey(); applyThemeToNativeUI(currentThemeKey.value); uni.hideTabBar({ animation: false, fail: () => {} }); + const cachedActive = uni.getStorageSync('ACTIVE_COMMUNITY_POST'); + if (cachedActive && cachedActive.id) { + const idx = posts.value.findIndex(p => String(p.id) === String(cachedActive.id)); + if (idx > -1) { + posts.value[idx] = { + ...posts.value[idx], + ...cachedActive + }; + } + } loadRemotePosts(); loadPlatformCommunityConfigs(); }); @@ -479,14 +532,19 @@ const loadRemotePosts = async () => { if (!Array.isArray(localLikedIds)) localLikedIds = []; let localAppliedIds = uni.getStorageSync('COMMUNITY_APPLIED_POST_IDS') || []; if (!Array.isArray(localAppliedIds)) localAppliedIds = []; + let viewsMap = uni.getStorageSync('COMMUNITY_POST_VIEWS_MAP') || {}; + if (typeof viewsMap !== 'object' || viewsMap === null) viewsMap = {}; posts.value = list.map(item => { + const strId = String(item.id); const serverStatus = item.myApplyStatus || null; - const isApplied = serverStatus ? (serverStatus !== 'REJECTED') : localAppliedIds.includes(String(item.id)); + const isApplied = serverStatus ? (serverStatus !== 'REJECTED') : localAppliedIds.includes(strId); + const mergedViews = Math.max(Number(item.views) || 1, Number(viewsMap[strId]) || 0); return { ...item, + views: mergedViews, myApplyStatus: serverStatus || (isApplied ? 'PENDING' : null), - liked: item.liked !== undefined ? item.liked : localLikedIds.includes(String(item.id)), + liked: item.liked !== undefined ? item.liked : localLikedIds.includes(strId), hasApplied: isApplied, actionText: serverStatus === 'ACCEPTED' ? '已同意上车' : (isApplied ? '待房主确认' : (item.actionText || '加入车队')) }; @@ -529,12 +587,17 @@ const syncInitialPostStates = (rawPosts) => { if (!Array.isArray(localLikedIds)) localLikedIds = []; let localAppliedIds = uni.getStorageSync('COMMUNITY_APPLIED_POST_IDS') || []; if (!Array.isArray(localAppliedIds)) localAppliedIds = []; + let viewsMap = uni.getStorageSync('COMMUNITY_POST_VIEWS_MAP') || {}; + if (typeof viewsMap !== 'object' || viewsMap === null) viewsMap = {}; return rawPosts.map(p => { - const isApplied = localAppliedIds.includes(String(p.id)); + const strId = String(p.id); + const isApplied = localAppliedIds.includes(strId); + const mergedViews = Math.max(Number(p.views) || 1, Number(viewsMap[strId]) || 0); return { ...p, - liked: localLikedIds.includes(String(p.id)) ? true : p.liked, + views: mergedViews, + liked: localLikedIds.includes(strId) ? true : p.liked, hasApplied: isApplied, actionText: isApplied ? '已申请' : p.actionText }; @@ -548,6 +611,7 @@ const syncInitialLikedState = syncInitialPostStates; const posts = ref(syncInitialPostStates([ { id: 1, + userId: 2005, author: '小甜橙', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150', tag: '火锅达人', @@ -570,6 +634,7 @@ const posts = ref(syncInitialPostStates([ }, { id: 2, + userId: 2004, author: '暗影刺客 (省一李白)', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150', tag: '国服大神', @@ -591,6 +656,7 @@ const posts = ref(syncInitialPostStates([ }, { id: 3, + userId: 2006, author: '苏苏学姐', avatar: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=150', tag: '金牌麦霸', @@ -622,6 +688,9 @@ const switchFeedTab = (key) => { }; const toggleLike = async (post) => { + if (!ensureLoginOrRedirect('/pages/community/index')) { + return; + } const willLike = !post.liked; post.liked = willLike; post.likes += willLike ? 1 : -1; @@ -665,7 +734,7 @@ const toggleLike = async (post) => { } }; -// 评论区抽屉交互与真实发表落盘 +// 评论区抽屉与动态详情跳转 const commentDrawerVisible = ref(false); const activeCommentPost = ref(null); const commentsList = ref([]); @@ -673,10 +742,8 @@ const commentInput = ref(''); const loadingComments = ref(false); const sendingComment = ref(false); -const handleComment = async (post) => { - activeCommentPost.value = post; - commentDrawerVisible.value = true; - commentInput.value = ''; +const loadPostCommentsList = async (post) => { + if (!post) return; loadingComments.value = true; try { const list = await getPostComments(post.id); @@ -686,8 +753,8 @@ const handleComment = async (post) => { commentsList.value = [ { id: `cmt_seed_${post.id}`, - author: '小甜橙', - avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', + author: post.author || '小甜橙', + avatar: post.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', content: '欢迎各位小伙伴在评论区留言约伴!一起开黑/探店~', createdAt: '刚刚' } @@ -700,6 +767,68 @@ const handleComment = async (post) => { } }; +const openAuthorProfile = (target) => { + if (!target) return; + const authorName = target.author || target.nickname || '同城达人'; + let targetId = target.expertId || target.companionId || target.userId; + if (!targetId) { + if (authorName === '小甜橙') targetId = 2005; + else if (authorName.includes('暗影刺客')) targetId = 2004; + else if (authorName === '苏苏学姐') targetId = 2006; + else targetId = target.id || 101; + } + const authorSnapshot = { + id: targetId, + userId: target.userId || targetId, + nickname: authorName, + avatar: target.avatar || DEFAULT_USER_AVATAR, + rankTag: target.tag || '同城认证达人', + levelTag: '实名认证', + game: target.category === 'game' ? '车队开黑' : (target.category === 'highlight' ? '战绩高光' : '同城搭子'), + city: target.location || '北京市 · 朝阳/海淀', + bio: target.content || '热爱同城探店与组局开黑,欢迎一起约伴同行!', + photos: Array.isArray(target.images) && target.images.length > 0 ? target.images : [target.avatar || DEFAULT_USER_AVATAR] + }; + uni.setStorageSync('ACTIVE_AUTHOR_PROFILE', authorSnapshot); + const qName = encodeURIComponent(authorSnapshot.nickname); + const qAvatar = encodeURIComponent(authorSnapshot.avatar); + const qTag = encodeURIComponent(authorSnapshot.rankTag); + const qCity = encodeURIComponent(authorSnapshot.city); + uni.navigateTo({ + url: `/pages/expert/detail?id=${encodeURIComponent(String(targetId))}&name=${qName}&avatar=${qAvatar}&tag=${qTag}&city=${qCity}` + }); +}; + +const openPostDetail = (post) => { + if (!post) return; + const strId = String(post.id); + const nextViews = (Number(post.views) || 0) + 1; + post.views = nextViews; + let viewsMap = uni.getStorageSync('COMMUNITY_POST_VIEWS_MAP') || {}; + if (typeof viewsMap !== 'object' || viewsMap === null) viewsMap = {}; + viewsMap[strId] = Math.max(Number(viewsMap[strId] || 0), nextViews); + uni.setStorageSync('COMMUNITY_POST_VIEWS_MAP', viewsMap); + uni.setStorageSync('ACTIVE_COMMUNITY_POST', { ...post, views: viewsMap[strId] }); + viewPost(post.id).then((res) => { + if (res && res.views !== undefined) { + post.views = Math.max(Number(post.views) || 0, Number(res.views)); + } + }).catch(() => {}); + uni.navigateTo({ + url: `/pages/community/detail?id=${post.id}` + }); +}; + +const handleComment = async (post) => { + if (!ensureLoginOrRedirect('/pages/community/index')) { + return; + } + activeCommentPost.value = post; + commentDrawerVisible.value = true; + commentInput.value = ''; + await loadPostCommentsList(post); +}; + const closeCommentDrawer = () => { commentDrawerVisible.value = false; activeCommentPost.value = null; @@ -708,6 +837,9 @@ const closeCommentDrawer = () => { }; const sendComment = async () => { + if (!ensureLoginOrRedirect('/pages/community/index')) { + return; + } if (!commentInput.value.trim()) { uni.showToast({ title: '请输入评论内容', icon: 'none' }); return; @@ -716,18 +848,26 @@ const sendComment = async () => { sendingComment.value = true; const content = commentInput.value.trim(); + const profile = currentUserProfile.value.nickname + ? currentUserProfile.value + : await syncCurrentUserProfile(); + const uid = Number(profile.id || uni.getStorageSync('userId')) || 1001; + const authorName = profile.nickname || '同城局长'; + const authorAvatar = profile.avatar || DEFAULT_USER_AVATAR; + try { const res = await addPostComment(activeCommentPost.value.id, { - userId: 1001, - author: '电竞玩家 (我)', - avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120', + userId: uid, + author: authorName, + avatar: authorAvatar, content }); commentsList.value.push({ id: res ? res.id : `cmt_${Date.now()}`, - author: '电竞玩家 (我)', - avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120', + userId: uid, + author: (res && res.author) || authorName, + avatar: (res && res.avatar) || authorAvatar, content, createdAt: '刚刚' }); @@ -745,10 +885,19 @@ const sendComment = async () => { }; const isPostOwner = (post) => { - return post && (post.userId === 1001 || post.author === '电竞玩家 (我)' || post.isMine === true); + const cur = currentUserProfile.value.nickname ? currentUserProfile.value : readCachedUserInfo(); + return post && ( + Number(post.userId) === Number(cur.id || 1001) || + post.author === '电竞玩家 (我)' || + (cur.nickname && post.author === cur.nickname) || + post.isMine === true + ); }; const handleDeletePost = (post) => { + if (!ensureLoginOrRedirect('/pages/community/index')) { + return; + } uni.showModal({ title: '删除动态确认', content: '确定要删除这条动态吗?删除后将无法恢复。', @@ -774,6 +923,9 @@ const handleDeletePost = (post) => { }; const handleManageApplies = async (post) => { + if (!ensureLoginOrRedirect('/pages/community/index')) { + return; + } uni.showLoading({ title: '加载申请列表...' }); try { const list = await getPostApplies(post.id); @@ -823,6 +975,9 @@ const handleManageApplies = async (post) => { }; const handleJoinParty = (post) => { + if (!ensureLoginOrRedirect('/pages/community/index')) { + return; + } if (post.hasApplied && post.myApplyStatus !== 'REJECTED') { uni.showToast({ title: post.myApplyStatus === 'ACCEPTED' ? '房主已同意您的上车申请' : '已提交过申请,请等待房主确认', @@ -838,10 +993,12 @@ const handleJoinParty = (post) => { success: async (res) => { if (res.confirm) { uni.showLoading({ title: '正在提交申请...' }); - const uid = Number(uni.getStorageSync('userId')) || 1001; - const userInfo = uni.getStorageSync('userInfo') || {}; - const applicantName = userInfo.nickname || '搭子玩家'; - const applicantAvatar = userInfo.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; + const profile = currentUserProfile.value.nickname + ? currentUserProfile.value + : await syncCurrentUserProfile(); + const uid = Number(profile.id || uni.getStorageSync('userId')) || 1001; + const applicantName = profile.nickname || '同城局长'; + const applicantAvatar = profile.avatar || DEFAULT_USER_AVATAR; try { await applyPostParty(post.id, { applicantId: uid, @@ -944,6 +1101,9 @@ const removeImage = (idx) => { }; const openPublishModal = (catKey = 'partner') => { + if (!ensureLoginOrRedirect('/pages/community/index')) { + return; + } newPostForm.value.category = catKey; showPublishModal.value = true; }; @@ -959,6 +1119,9 @@ const stopPropagation = (e) => { }; const handlePublishPost = () => { + if (!ensureLoginOrRedirect('/pages/community/index')) { + return; + } uni.showActionSheet({ itemList: ['发同城美食搭子', '发开黑车队招募', '发生活高光随笔'], success: (res) => { @@ -990,6 +1153,13 @@ const submitPublishPost = async () => { const currentMeta = tagMap[newPostForm.value.category] || tagMap.partner; uni.showLoading({ title: '正在上传与发布...' }); + const profile = currentUserProfile.value.nickname + ? currentUserProfile.value + : await syncCurrentUserProfile(); + const uid = Number(profile.id || uni.getStorageSync('userId')) || 1001; + const authorName = profile.nickname || '同城局长'; + const authorAvatar = profile.avatar || DEFAULT_USER_AVATAR; + let finalImages = []; try { if (newPostForm.value.images.length > 0) { @@ -1007,9 +1177,9 @@ const submitPublishPost = async () => { } const postPayload = { - userId: 1001, - author: '电竞玩家 (我)', - avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=150', + userId: uid, + author: authorName, + avatar: authorAvatar, tag: currentMeta.text, tagType: currentMeta.type, location: newPostForm.value.location, @@ -1024,8 +1194,9 @@ const submitPublishPost = async () => { const res = await createPost(postPayload); const createdPost = { id: res ? res.id : Date.now(), - author: postPayload.author, - avatar: postPayload.avatar, + userId: uid, + author: (res && res.author) || postPayload.author, + avatar: (res && res.avatar) || postPayload.avatar, tag: currentMeta.text, tagType: currentMeta.type, location: postPayload.location, @@ -1131,23 +1302,30 @@ const submitPublishPost = async () => { .publish-btn { display: flex; align-items: center; - gap: 8rpx; - background: linear-gradient(135deg, #6366F1, #8B5CF6); - padding: 0 24rpx; - height: 56rpx; - border-radius: 28rpx; - box-shadow: 0 4rpx 16rpx rgba(99, 102, 241, 0.4); + justify-content: center; + gap: 10rpx; + background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 0 28rpx; + height: 58rpx; + border-radius: 29rpx; + box-shadow: 0 6rpx 18rpx rgba(99, 102, 241, 0.36); flex-shrink: 0; + box-sizing: border-box; .publish-icon { - width: 26rpx; - height: 26rpx; + width: 28rpx; + height: 28rpx; + flex-shrink: 0; + display: block; } .publish-text { - font-size: 24rpx; + font-size: 25rpx; font-weight: 700; color: #FFFFFF; + line-height: 1; + letter-spacing: 1rpx; white-space: nowrap; } } @@ -2126,8 +2304,9 @@ const submitPublishPost = async () => { color: #78716C; } .publish-btn { - background: linear-gradient(135deg, #EA580C, #F59E0B); - box-shadow: 0 4rpx 16rpx rgba(234, 88, 12, 0.3); + background: linear-gradient(135deg, #F97316 0%, #EA580C 100%); + border-color: rgba(255, 255, 255, 0.28); + box-shadow: 0 6rpx 18rpx rgba(234, 88, 12, 0.28); } } @@ -2683,4 +2862,535 @@ const submitPublishPost = async () => { } } } + +.post-detail-modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + z-index: 1010; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.post-detail-modal-box { + width: 100%; + max-width: 600px; + height: 82vh; + background: #151D30; + border-top-left-radius: 40rpx; + border-top-right-radius: 40rpx; + display: flex; + flex-direction: column; + border: 1px solid rgba(255, 255, 255, 0.1); + border-bottom: none; + box-shadow: 0 -12rpx 40rpx rgba(0, 0, 0, 0.55); + animation: slideUp 0.25s ease-out; + + .sheet-drag-handle { + width: 72rpx; + height: 8rpx; + background: rgba(255, 255, 255, 0.18); + border-radius: 4rpx; + margin: 16rpx auto 8rpx; + flex-shrink: 0; + } + + .detail-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16rpx 36rpx 24rpx; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + flex-shrink: 0; + + .detail-header-left { + display: flex; + align-items: center; + gap: 16rpx; + + .detail-modal-title { + font-size: 32rpx; + font-weight: 700; + color: #F8FAFC; + } + + .detail-category-badge { + font-size: 22rpx; + font-weight: 600; + color: #818CF8; + background: rgba(99, 102, 241, 0.16); + padding: 4rpx 16rpx; + border-radius: 20rpx; + } + } + + .detail-close-btn { + width: 56rpx; + height: 56rpx; + border-radius: 28rpx; + background: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + + .close-icon { + width: 32rpx; + height: 32rpx; + } + } + } + + .detail-modal-scroll { + flex: 1; + overflow-y: auto; + padding: 28rpx 36rpx; + box-sizing: border-box; + + .detail-author-card { + display: flex; + align-items: center; + margin-bottom: 28rpx; + + .author-avatar { + width: 88rpx; + height: 88rpx; + border-radius: 50%; + margin-right: 20rpx; + border: 2rpx solid rgba(255, 255, 255, 0.15); + flex-shrink: 0; + } + + .author-meta { + flex: 1; + min-width: 0; + + .name-line { + display: flex; + align-items: center; + gap: 12rpx; + margin-bottom: 8rpx; + + .author-name { + font-size: 30rpx; + font-weight: 700; + color: #F8FAFC; + } + + .author-tag { + font-size: 20rpx; + padding: 2rpx 12rpx; + border-radius: 8rpx; + font-weight: 600; + + &.warm-tag { + color: #F59E0B; + background: rgba(245, 158, 11, 0.15); + } + &.cool-tag { + color: #818CF8; + background: rgba(99, 102, 241, 0.15); + } + &.gold-tag { + color: #FBBF24; + background: rgba(251, 191, 36, 0.15); + } + } + } + + .sub-line { + display: flex; + align-items: center; + font-size: 22rpx; + color: #94A3B8; + + .dot-sep { + margin: 0 8rpx; + } + + .distance-box { + display: flex; + align-items: center; + color: #38BDF8; + + .dist-icon { + width: 22rpx; + height: 22rpx; + margin-right: 4rpx; + } + } + } + } + + .author-actions { + margin-left: 16rpx; + flex-shrink: 0; + + .join-btn { + height: 60rpx; + line-height: 58rpx; + padding: 0 28rpx; + border-radius: 30rpx; + font-size: 24rpx; + font-weight: 600; + color: #818CF8; + background: rgba(99, 102, 241, 0.15); + border: 1px solid rgba(99, 102, 241, 0.35); + margin: 0; + + &.btn-applied { + color: #94A3B8; + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.12); + } + } + } + } + + .detail-content-box { + margin-bottom: 24rpx; + + .detail-content-text { + font-size: 30rpx; + line-height: 1.7; + color: #E2E8F0; + word-break: break-all; + } + } + + .detail-topics-row { + display: flex; + flex-wrap: wrap; + gap: 14rpx; + margin-bottom: 28rpx; + + .detail-topic-pill { + font-size: 24rpx; + font-weight: 500; + color: #818CF8; + background: rgba(99, 102, 241, 0.12); + padding: 6rpx 18rpx; + border-radius: 12rpx; + } + } + + .detail-images-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16rpx; + margin-bottom: 28rpx; + + .detail-post-img { + width: 100%; + height: 280rpx; + border-radius: 20rpx; + background: rgba(255, 255, 255, 0.04); + + &.single-img { + grid-column: span 2; + height: 380rpx; + } + } + } + + .detail-stats-strip { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20rpx 0; + border-top: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + margin-bottom: 28rpx; + + .stats-left { + display: flex; + align-items: center; + font-size: 24rpx; + color: #94A3B8; + + .stats-dot { + margin: 0 12rpx; + } + } + + .detail-like-pill { + display: flex; + align-items: center; + gap: 10rpx; + padding: 10rpx 24rpx; + border-radius: 28rpx; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + + .like-icon { + width: 30rpx; + height: 30rpx; + } + + .like-txt { + font-size: 24rpx; + color: #CBD5E1; + font-weight: 600; + } + + &.liked { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.35); + + .like-txt { + color: #EF4444; + } + } + } + } + + .detail-comments-section { + padding-bottom: 40rpx; + + .comments-section-header { + display: flex; + align-items: baseline; + gap: 10rpx; + margin-bottom: 24rpx; + + .section-title { + font-size: 28rpx; + font-weight: 700; + color: #F8FAFC; + } + + .section-count { + font-size: 24rpx; + color: #94A3B8; + } + } + + .comments-loading, + .comments-empty { + padding: 48rpx 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 12rpx; + + .empty-icon { + width: 64rpx; + height: 64rpx; + opacity: 0.45; + } + + .loading-text, + .empty-text { + font-size: 24rpx; + color: #94A3B8; + } + } + + .comments-list { + display: flex; + flex-direction: column; + gap: 24rpx; + + .comment-item { + display: flex; + gap: 18rpx; + + .comment-avatar { + width: 64rpx; + height: 64rpx; + border-radius: 50%; + flex-shrink: 0; + } + + .comment-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 6rpx; + + .comment-top { + display: flex; + justify-content: space-between; + align-items: center; + + .comment-author { + font-size: 26rpx; + font-weight: 600; + color: #E2E8F0; + } + .comment-time { + font-size: 22rpx; + color: #64748B; + } + } + + .comment-content { + font-size: 26rpx; + color: #CBD5E1; + line-height: 1.5; + word-break: break-all; + } + } + } + } + } + } + + .detail-bottom-bar { + padding: 20rpx 36rpx calc(24rpx + env(safe-area-inset-bottom)); + background: #0E1628; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + gap: 20rpx; + flex-shrink: 0; + + .detail-comment-input { + flex: 1; + height: 72rpx; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 36rpx; + padding: 0 28rpx; + font-size: 26rpx; + color: #F8FAFC; + } + + .detail-send-btn { + width: 120rpx; + height: 72rpx; + line-height: 72rpx; + font-size: 26rpx; + font-weight: 600; + border-radius: 36rpx; + background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%); + color: #FFFFFF; + text-align: center; + padding: 0; + margin: 0; + box-shadow: 0 4rpx 12rpx rgba(99, 102, 241, 0.35); + + &:disabled { + opacity: 0.5; + } + } + } +} + +.theme-warm { + .post-detail-modal-box { + background: #FFFFFF; + border-color: #E7E5E4; + box-shadow: 0 -12rpx 40rpx rgba(28, 25, 23, 0.12); + + .sheet-drag-handle { + background: #E7E5E4; + } + + .detail-modal-header { + border-bottom-color: #F5F2EC; + + .detail-header-left { + .detail-modal-title { + color: #1C1917; + } + .detail-category-badge { + color: #EA580C; + background: rgba(234, 88, 12, 0.1); + } + } + + .detail-close-btn { + background: #F5F5F4; + } + } + + .detail-modal-scroll { + .detail-author-card { + .author-meta .name-line .author-name { + color: #1C1917; + } + .author-actions .join-btn { + background: rgba(234, 88, 12, 0.1); + color: #EA580C; + border-color: rgba(234, 88, 12, 0.3); + + &.btn-applied { + background: #F1F5F9; + color: #94A3B8; + border-color: #CBD5E1; + } + } + } + + .detail-content-box .detail-content-text { + color: #292524; + } + + .detail-topics-row .detail-topic-pill { + color: #EA580C; + background: rgba(234, 88, 12, 0.08); + } + + .detail-stats-strip { + border-top-color: #F5F2EC; + border-bottom-color: #F5F2EC; + + .detail-like-pill { + background: #F5F2EC; + border-color: #E7E5E4; + + .like-txt { + color: #57534E; + } + + &.liked { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.3); + + .like-txt { + color: #EF4444; + } + } + } + } + + .detail-comments-section { + .comments-section-header .section-title { + color: #1C1917; + } + + .comments-list .comment-item .comment-body { + .comment-top .comment-author { + color: #1C1917; + } + .comment-content { + color: #44403C; + } + } + } + } + + .detail-bottom-bar { + background: #FDFBF7; + border-top-color: #E7E5E4; + + .detail-comment-input { + background: #F5F2EC; + border-color: #E7E5E4; + color: #1C1917; + } + + .detail-send-btn { + background: linear-gradient(135deg, #F97316 0%, #EA580C 100%); + box-shadow: 0 4rpx 12rpx rgba(234, 88, 12, 0.35); + } + } + } +} diff --git a/pwuniapp/pages/expert/detail.vue b/pwuniapp/pages/expert/detail.vue index 94dfe5f..a8cdb54 100644 --- a/pwuniapp/pages/expert/detail.vue +++ b/pwuniapp/pages/expert/detail.vue @@ -246,6 +246,7 @@ import SkeletonScreen from '@/components/SkeletonScreen.vue'; import { getCompanionDetail, getExpertReviews, replyOrderReview, submitReport as submitReportApi, checkFavorite, addFavorite as addFavApi, removeFavorite as removeFavApi } from '@/common/api.js'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; +import { ensureLoginOrRedirect } from '@/common/role.js'; const currentThemeKey = ref(getCurrentThemeKey()); @@ -272,8 +273,27 @@ const stopPropagation = (e) => { const isLoading = ref(true); const isFav = ref(false); +const buildDefaultServices = (gameTitle = '同城陪伴', basePrice = 68) => [ + { + id: 'srv_1', + name: `${gameTitle} · 基础陪伴 (2小时)`, + title: `${gameTitle} · 基础陪伴 (2小时)`, + price: basePrice, + unit: '小时', + desc: '正规公开商圈同城陪伴,AA制或协商消费' + }, + { + id: 'srv_2', + name: `${gameTitle} · 半日畅享 (3小时)`, + title: `${gameTitle} · 半日畅享 (3小时)`, + price: Math.round(basePrice * 2.8), + unit: '次', + desc: '深度互动与陪伴交流,赠送专属点歌或语音祝福' + } +]; + const expert = ref({ - id: '', + id: '101', nickname: '同城陪伴师', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300', gender: 'female', @@ -282,15 +302,19 @@ const expert = ref({ rankTag: '专业级', city: '北京市 · 朝阳/海淀', rating: 5.0, - orderCount: 0, + orderCount: 86, + isOnline: true, bio: '专业同城生活陪伴,健康阳光公开。', voiceDuration: 15, price: 68, - services: [], - photos: [], + services: buildDefaultServices('同城陪伴', 68), + photos: [ + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400', + 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400' + ], reviews: [] }); -const selectedServiceId = ref(null); +const selectedServiceId = ref('srv_1'); const isPlayingVoice = ref(false); let currentExpertTargetId = null; @@ -309,8 +333,11 @@ onShow(() => { }); const toggleFavorite = async () => { + const targetId = Number(expert.value.id || currentExpertTargetId || 101); + if (!ensureLoginOrRedirect(`/pages/expert/detail?id=${targetId}`)) { + return; + } try { - const targetId = Number(expert.value.id || currentExpertTargetId || 101); if (isFav.value) { await removeFavApi(targetId); isFav.value = false; @@ -326,6 +353,10 @@ const toggleFavorite = async () => { }; const handleReplyReview = (rev) => { + const targetId = Number(expert.value.id || currentExpertTargetId || 101); + if (!ensureLoginOrRedirect(`/pages/expert/detail?id=${targetId}`)) { + return; + } const defaultReplies = [ '感谢老板的支持与五星好评!开黑配合非常默契,期待下次一起上大分!', '谢谢老板认可!非常准时礼貌的高素质玩家老板,下次继续开黑走起~', @@ -352,15 +383,86 @@ const handleReplyReview = (rev) => { }); }; +const safeDecode = (val) => { + if (!val) return ''; + try { + return decodeURIComponent(val); + } catch (_) { + return val; + } +}; + onLoad(async (query) => { - const targetId = (query && query.id) ? query.id : '1'; + const targetId = (query && query.id) ? safeDecode(query.id) : '1'; + const queryName = query && query.name ? safeDecode(query.name) : ''; + const queryAvatar = query && query.avatar ? safeDecode(query.avatar) : ''; + const queryTag = query && query.tag ? safeDecode(query.tag) : ''; + const queryCity = query && query.city ? safeDecode(query.city) : ''; currentExpertTargetId = targetId; + + const cachedAuthor = uni.getStorageSync('ACTIVE_AUTHOR_PROFILE'); + const matchedSnapshot = (cachedAuthor && (String(cachedAuthor.id) === String(targetId) || String(cachedAuthor.userId) === String(targetId) || (queryName && cachedAuthor.nickname === queryName))) + ? cachedAuthor + : null; + + if (matchedSnapshot || queryName) { + const snapName = queryName || (matchedSnapshot && matchedSnapshot.nickname) || expert.value.nickname; + const snapAvatar = queryAvatar || (matchedSnapshot && matchedSnapshot.avatar) || expert.value.avatar; + const snapTag = queryTag || (matchedSnapshot && matchedSnapshot.rankTag) || expert.value.rankTag; + const snapCity = queryCity || (matchedSnapshot && matchedSnapshot.city) || expert.value.city; + const snapGame = (matchedSnapshot && matchedSnapshot.game) || '同城搭子'; + const snapBio = (matchedSnapshot && matchedSnapshot.bio) || expert.value.bio; + const snapPhotos = (matchedSnapshot && Array.isArray(matchedSnapshot.photos) && matchedSnapshot.photos.length > 0) + ? matchedSnapshot.photos + : [snapAvatar]; + const snapPrice = snapGame === '车队开黑' ? 88 : 128; + + expert.value = { + ...expert.value, + id: String(targetId), + nickname: snapName, + avatar: snapAvatar, + coverImage: snapPhotos[0] || snapAvatar, + rankTag: snapTag, + levelTag: '实名认证', + game: snapGame, + city: snapCity, + bio: snapBio, + price: snapPrice, + photos: snapPhotos, + services: buildDefaultServices(snapGame, snapPrice) + }; + selectedServiceId.value = expert.value.services[0].id; + } + try { const liveData = await getCompanionDetail(targetId); if (liveData) { - expert.value = liveData; - if (liveData.services && liveData.services.length > 0) { - selectedServiceId.value = liveData.services[0].id; + if (queryName || matchedSnapshot) { + const finalName = queryName || (matchedSnapshot && matchedSnapshot.nickname) || liveData.nickname; + const finalAvatar = queryAvatar || (matchedSnapshot && matchedSnapshot.avatar) || liveData.avatar; + const finalTag = queryTag || (matchedSnapshot && matchedSnapshot.rankTag) || liveData.rankTag; + const finalCity = queryCity || (matchedSnapshot && matchedSnapshot.city) || liveData.city; + const finalBio = (matchedSnapshot && matchedSnapshot.bio) || liveData.bio; + const finalPhotos = (matchedSnapshot && Array.isArray(matchedSnapshot.photos) && matchedSnapshot.photos.length > 0) + ? matchedSnapshot.photos + : (liveData.photos || [finalAvatar]); + + expert.value = { + ...liveData, + nickname: finalName, + avatar: finalAvatar, + coverImage: finalPhotos[0] || finalAvatar, + rankTag: finalTag, + city: finalCity, + bio: finalBio, + photos: finalPhotos + }; + } else { + expert.value = liveData; + } + if (expert.value.services && expert.value.services.length > 0) { + selectedServiceId.value = expert.value.services[0].id; } } @@ -444,9 +546,14 @@ const previewImage = (index) => { }; const goToOrder = () => { - const srv = currentSelectedService.value || expert.value.services[0]; + const srv = currentSelectedService.value || (expert.value.services && expert.value.services[0]); + const srvId = srv ? srv.id : 'srv_1'; + const targetUrl = `/pages/order/create?expertId=${expert.value.id || currentExpertTargetId || 101}&serviceId=${srvId}`; + if (!ensureLoginOrRedirect(targetUrl)) { + return; + } uni.navigateTo({ - url: `/pages/order/create?expertId=${expert.value.id}&serviceId=${srv.id}` + url: targetUrl }); }; @@ -467,6 +574,10 @@ const reportForm = ref({ }); const openReportModal = () => { + const targetId = Number(expert.value.id || currentExpertTargetId || 101); + if (!ensureLoginOrRedirect(`/pages/expert/detail?id=${targetId}`)) { + return; + } reportForm.value = { reason: '涉黄低俗言论', description: '' diff --git a/pwuniapp/pages/expert/hall.vue b/pwuniapp/pages/expert/hall.vue index b07ed21..71b14da 100644 --- a/pwuniapp/pages/expert/hall.vue +++ b/pwuniapp/pages/expert/hall.vue @@ -3,13 +3,13 @@ - + {{ expert.nickname }} - 接单中 + {{ isOnline ? '接单中' : '已打烊' }} - 评分 5.0 · 接单率 99.2% + 评分 {{ expert.rating || '5.0' }} · 接单率 {{ expert.orderRate || '99.2%' }} @@ -61,7 +61,8 @@ @@ -96,13 +97,124 @@ {{ order.userName }} - + 立即接单 + + + + + + + 派单需求详情 + NO.{{ formatShortOrderNo(selectedOrder.id) }} + + + + + + + + + + + + + {{ selectedOrder.category }} + {{ selectedOrder.payMethod }} + + {{ selectedOrder.title }} + + + + 预计到账 + + ¥ + {{ selectedOrder.reward }} + + + + + + + + + + 预约时段 + + {{ selectedOrder.timeSlot }} + + + + + + 服务地点 + + + {{ selectedOrder.location }} + 距离您当前约 {{ selectedOrder.distance }} + + + + + + + 服务时长 + + {{ selectedOrder.durationHours || 2 }} 小时 + + + + + + 结算方式 + + {{ selectedOrder.payMethod }} · 平台资金托管 + + + + + + + + 客户需求备注说明 + + + {{ selectedOrder.requirement || '按约定准时到场,文明健康社交。' }} + + + + + + + + + {{ selectedOrder.userName }} + 已实名认证买家 + + 信誉积分 100 · 资金全额托管 + + + + + + + 现场碰面后请出示或核验双方 4 位数字核销码进行准时签到。服务全程遵循平台文明社交规范,如遇纠纷平台提供 7x24 小时资金托管与仲裁保障。 + + + + + + 稍后考虑 + 立即接此单 + + + + @@ -115,19 +227,37 @@ import AppTabBar from '@/components/CustomTabBar.vue'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { isExpertCertified } from '@/common/role.js'; -import { createOrder, getOrdersPool, getExpertStats, claimOrder, updateCompanionProfile } from '@/common/api.js'; +import { createOrder, getOrdersPool, getExpertStats, claimOrder, updateCompanionProfile, getUserProfile } from '@/common/api.js'; import { getNavBarInfo } from '@/common/nav.js'; const currentThemeKey = ref(getCurrentThemeKey()); -const expert = ref({ - id: 101, - nickname: '接单大神', - avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300' -}); +const DEFAULT_EXPERT_AVATAR = 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300'; + +const getInitialExpert = () => { + let cached = null; + try { + cached = uni.getStorageSync('PW_USER_INFO') || uni.getStorageSync('userInfo'); + } catch (_) {} + let uid = (cached && cached.id) || uni.getStorageSync('userId') || 2006; + if (uid == 101) uid = 2006; + return { + id: uid, + nickname: (cached && cached.nickname) || '接单大神', + avatar: (cached && cached.avatar) || DEFAULT_EXPERT_AVATAR, + rating: (cached && cached.rating) || '5.0', + orderRate: (cached && cached.orderRate) || '99.2%' + }; +}; + +const expert = ref(getInitialExpert()); const isOnline = ref(uni.getStorageSync('EXPERT_RECEIVING_STATUS') !== '0'); const activeCategory = ref('all'); const navInfo = ref(getNavBarInfo()); +const onAvatarError = () => { + expert.value.avatar = DEFAULT_EXPERT_AVATAR; +}; + const navStyle = computed(() => { return { '--safe-header-gap': `${navInfo.value.safeHeaderGap}px`, @@ -147,30 +277,68 @@ const onThemeChanged = (newTheme) => { applyThemeToNativeUI(newTheme); }; +let isHallLoading = false; const loadHallData = async () => { + if (isHallLoading) return; + isHallLoading = true; + let uid = uni.getStorageSync('userId') || expert.value.id || 2006; + if (uid == 101) uid = 2006; + expert.value.id = uid; + + // 1. 同步最新在线接单状态 + isOnline.value = uni.getStorageSync('EXPERT_RECEIVING_STATUS') !== '0'; + + // 2. 并行动态读取最新资料、经营数据与派单池 try { - const stats = await getExpertStats(101); - if (stats) { - todayStats.value = stats; - } - const pool = await getOrdersPool(); - if (pool && pool.length > 0) { - orders.value = pool.map(p => { - let typeClass = 'cat-food'; - if (p.category.includes('喝') || p.category.includes('咖啡')) typeClass = 'cat-drink'; - if (p.category.includes('唱') || p.category.includes('KTV')) typeClass = 'cat-sing'; - if (p.category.includes('游戏') || p.category.includes('电竞') || p.category.includes('开黑')) typeClass = 'cat-game'; - return { - ...p, - typeClass, - timeSlot: p.timeSlot || '今日内随时可约', - distance: '1.2km内', - requirement: p.requirement || '按约定准时到场,文明健康社交。' - }; - }); - } + await Promise.allSettled([ + getUserProfile(uid).then((profile) => { + if (profile) { + let safeAvatar = profile.avatar; + if (!safeAvatar || safeAvatar.startsWith('http://tmp') || safeAvatar.includes('__tmp__')) { + safeAvatar = DEFAULT_EXPERT_AVATAR; + } + expert.value = { + ...expert.value, + ...profile, + id: uid, + nickname: profile.nickname || expert.value.nickname, + avatar: safeAvatar + }; + } + }).catch(() => {}), + + getExpertStats(uid).then((stats) => { + if (stats) { + todayStats.value = { + income: stats.income !== undefined ? stats.income : '0.00', + orders: stats.orders !== undefined ? stats.orders : 0, + waitingVerify: stats.waitingVerify !== undefined ? stats.waitingVerify : 0 + }; + } + }).catch(() => {}), + + getOrdersPool().then((pool) => { + if (pool && pool.length > 0) { + orders.value = pool.map(p => { + let typeClass = 'cat-food'; + if (p.category.includes('喝') || p.category.includes('咖啡')) typeClass = 'cat-drink'; + if (p.category.includes('唱') || p.category.includes('KTV')) typeClass = 'cat-sing'; + if (p.category.includes('游戏') || p.category.includes('电竞') || p.category.includes('开黑')) typeClass = 'cat-game'; + return { + ...p, + typeClass, + timeSlot: p.timeSlot || '今日内随时可约', + distance: '1.2km内', + requirement: p.requirement || '按约定准时到场,文明健康社交。' + }; + }); + } + }).catch(() => {}) + ]); } catch (err) { console.warn('[Hall] 加载接单大厅数据异常:', err); + } finally { + isHallLoading = false; } }; @@ -286,13 +454,14 @@ const toggleOnlineStatus = async () => { isOnline.value = !isOnline.value; uni.setStorageSync('EXPERT_RECEIVING_STATUS', isOnline.value ? '1' : '0'); try { - await updateCompanionProfile(expert.value.id || 101, { + await updateCompanionProfile(expert.value.id || 2006, { status: isOnline.value ? '接单中' : '休息中' }); } catch (_) {} uni.showToast({ - title: isOnline.value ? '已开启在线接单状态' : '已设置为打烊休息', - icon: isOnline.value ? 'success' : 'none' + title: isOnline.value ? '已开启在线接单' : '已设置为打烊休息', + icon: isOnline.value ? 'success' : 'none', + duration: 1500 }); }; @@ -302,6 +471,7 @@ const handleGrabOrder = (order) => { content: `【${order.category}】\n酬金:¥${order.reward}\n预约时段:${order.timeSlot}\n\n确认后立即为您保留此单并发送微信履约通知。`, confirmText: '立即接单', confirmColor: '#10B981', + cancelText: '取消', success: async (res) => { if (res.confirm) { uni.showLoading({ title: '正在锁定订单...' }); @@ -309,9 +479,9 @@ const handleGrabOrder = (order) => { await createOrder({ buyerId: 2001, clientName: order.userName || '客户老板', - expertId: 101, - companionName: '我 (接单大神)', - expertAvatar: order.userAvatar || 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=100', + expertId: expert.value.id || 2006, + companionName: expert.value.nickname || '接单大神', + expertAvatar: expert.value.avatar || DEFAULT_EXPERT_AVATAR, track: order.category || '同城生活', serviceTitle: order.title, hourlyRate: order.reward, @@ -336,6 +506,65 @@ const handleGrabOrder = (order) => { } }); }; + +const showDetailModal = ref(false); +const selectedOrder = ref(null); + +const stopPropagation = (e) => { + if (e && typeof e.stopPropagation === 'function') { + e.stopPropagation(); + } +}; + +const openOrderDetailModal = (order) => { + selectedOrder.value = order; + showDetailModal.value = true; +}; + +const closeOrderDetailModal = () => { + showDetailModal.value = false; + selectedOrder.value = null; +}; + +const handleGrabOrderFromModal = () => { + if (!selectedOrder.value) return; + const orderToGrab = selectedOrder.value; + closeOrderDetailModal(); + handleGrabOrder(orderToGrab); +}; + +const formatShortOrderNo = (id) => { + if (!id && id !== 0) return ''; + const str = String(id).trim(); + if (str.includes('_')) { + const parts = str.split('_'); + const mainPart = parts[1] || parts[0]; + const digits = mainPart.replace(/\D/g, ''); + if (digits.length >= 6) { + return digits.slice(-6); + } + } + const digits = str.replace(/\D/g, ''); + if (digits.length >= 6) { + return digits.slice(-6); + } + return str; +}; + +const copyOrderNo = () => { + if (!selectedOrder.value || !selectedOrder.value.id) return; + uni.setClipboardData({ + data: String(selectedOrder.value.id), + showToast: false, + success: () => { + uni.showToast({ + title: '已复制完整单号', + icon: 'none', + duration: 1500 + }); + } + }); +}; diff --git a/pwuniapp/pages/index/index.vue b/pwuniapp/pages/index/index.vue index b7afba2..55f1d9b 100644 --- a/pwuniapp/pages/index/index.vue +++ b/pwuniapp/pages/index/index.vue @@ -276,6 +276,7 @@ import { getCompanions, getBanners, getNavCategories, bindInviteCode } from '@/c import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { getNavBarInfo } from '@/common/nav.js'; +import { getCurrentRole, ROLE_TYPES, isExpertCertified, safeRedirectToExpertHall } from '@/common/role.js'; const isLoading = ref(true); const banners = ref([]); @@ -403,6 +404,17 @@ const onThemeChanged = (key) => { applyThemeToNativeUI(key); }; +let hasHandledRedirect = false; +const checkExpertRedirect = () => { + if (hasHandledRedirect) return true; + if (getCurrentRole() === ROLE_TYPES.EXPERT && isExpertCertified()) { + hasHandledRedirect = true; + safeRedirectToExpertHall('/pages/expert/hall'); + return true; + } + return false; +}; + onLoad((options) => { const rawInvite = options && (options.inviter_id || options.inviterId || options.code || options.scene); if (rawInvite) { @@ -415,9 +427,11 @@ onLoad((options) => { }).catch(() => {}); } } + if (checkExpertRedirect()) return; }); onMounted(() => { + if (checkExpertRedirect()) return; navInfo.value = getNavBarInfo(); currentThemeKey.value = getCurrentThemeKey(); applyThemeToNativeUI(currentThemeKey.value); @@ -426,6 +440,7 @@ onMounted(() => { }); onShow(() => { + if (checkExpertRedirect()) return; navInfo.value = getNavBarInfo(); currentThemeKey.value = getCurrentThemeKey(); applyThemeToNativeUI(currentThemeKey.value); diff --git a/pwuniapp/pages/order/create.vue b/pwuniapp/pages/order/create.vue index b21d08f..3b106f7 100644 --- a/pwuniapp/pages/order/create.vue +++ b/pwuniapp/pages/order/create.vue @@ -239,11 +239,14 @@ diff --git a/pwuniapp/pages/order/list.vue b/pwuniapp/pages/order/list.vue index 9e62df7..fa5c9af 100644 --- a/pwuniapp/pages/order/list.vue +++ b/pwuniapp/pages/order/list.vue @@ -354,10 +354,12 @@ import { refundOrder as refundOrderApi, submitOrderReview as submitOrderReviewApi, submitReport as submitReportApi, + requestWechatPayment, uploadImage } from '@/common/api.js'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; +import { ensureLoginOrRedirect } from '@/common/role.js'; import { getNavBarInfo } from '@/common/nav.js'; const currentThemeKey = ref(getCurrentThemeKey()); @@ -426,6 +428,9 @@ onMounted(() => { }); onShow(() => { + if (!ensureLoginOrRedirect('/pages/order/list')) { + return; + } navInfo.value = getNavBarInfo(); currentThemeKey.value = getCurrentThemeKey(); applyThemeToNativeUI(currentThemeKey.value); @@ -501,18 +506,38 @@ const cancelOrder = (id) => { }; const payOrder = async (id) => { + const o = orders.value.find(item => item.id === id); + const amount = o ? Number(o.totalAmount || o.amount || 0) : 0; + const title = o ? (o.gameName || o.skillName || '陪玩订单支付') : '陪玩订单支付'; + const uid = Number(uni.getStorageSync('userId') || 1001); + uni.showLoading({ title: '正在拉起微信支付...' }); try { - await payOrderApi(id); + await requestWechatPayment({ + amount, + title, + orderId: id, + scene: 'ORDER', + userId: uid + }); + await payOrderApi(id, { + targetStatus: '履约中', + payType: 'WECHAT', + buyerId: uid + }); uni.hideLoading(); - const o = orders.value.find(item => item.id === id); - if (o) o.status = 'IN_SERVICE'; + if (o) { + o.status = 'IN_SERVICE'; + o.rawStatus = '履约中'; + } uni.showToast({ title: '支付成功,服务已开始', icon: 'success' }); } catch (err) { uni.hideLoading(); - const o = orders.value.find(item => item.id === id); - if (o) o.status = 'IN_SERVICE'; - uni.showToast({ title: '拉起微信支付成功', icon: 'success' }); + if (err && err.cancelled) { + uni.showToast({ title: '已取消支付', icon: 'none' }); + } else { + uni.showToast({ title: (err && err.message) || '支付未完成,请重试', icon: 'none' }); + } } }; @@ -839,7 +864,7 @@ const submitReview = async () => { const expertId = order.expert ? order.expert.id : (order.expertId || 101); await submitOrderReviewApi(order.id, { expertId, - userId: 1001, + userId: Number(uni.getStorageSync('userId') || 1001), userNickname: '电竞玩家 (我)', rating: reviewForm.value.rating, tags: reviewForm.value.selectedTags, diff --git a/pwuniapp/pages/user/favorites.vue b/pwuniapp/pages/user/favorites.vue index 55ef2be..b18381b 100644 --- a/pwuniapp/pages/user/favorites.vue +++ b/pwuniapp/pages/user/favorites.vue @@ -14,6 +14,9 @@ + + + @@ -130,10 +133,11 @@ const allFavorites = ref([]); const filterOnlyOnline = ref(false); const navStyle = computed(() => { + const info = navInfo.value || {}; return { - '--safe-header-gap': `${navInfo.value.totalHeaderHeight}px`, - '--capsule-right-space': `${navInfo.value.capsuleRightSpace}px`, - '--capsule-height': `${navInfo.value.capsuleHeight}px` + '--safe-header-gap': `${info.totalHeaderHeight || 88}px`, + '--capsule-right-space': `${info.capsuleRightSpace || 0}px`, + '--capsule-height': `${info.capsuleHeight || 32}px` }; }); @@ -241,6 +245,7 @@ onShow(() => { height: var(--safe-header-gap); background: rgba(10, 15, 29, 0.9); backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); z-index: 1000; border-bottom: 1px solid rgba(255, 255, 255, 0.06); @@ -271,8 +276,11 @@ onShow(() => { } .nav-title-wrap { - flex: 1; + position: absolute; + left: 50%; + transform: translateX(-50%); text-align: center; + pointer-events: none; .nav-title { font-size: 32rpx; @@ -288,14 +296,22 @@ onShow(() => { } } +// 顶部固定导航栏高度占位,确保后续所有内容严格规避遮挡 +.nav-bar-placeholder { + height: var(--safe-header-gap); + width: 100%; + flex-shrink: 0; +} + // 顶部统计与过滤栏 .filter-header-bar { - margin-top: calc(var(--safe-header-gap) + 16rpx); + margin-top: 16rpx; padding: 0 32rpx; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; + flex-shrink: 0; .header-stat-wrap { .stat-count-txt { @@ -346,7 +362,7 @@ onShow(() => { // 骨架屏 .favorites-loading { - margin-top: calc(var(--safe-header-gap) + 24rpx); + margin-top: 24rpx; padding: 0 32rpx; .skeleton-card { @@ -581,7 +597,7 @@ onShow(() => { // 空状态卡片 .empty-state-card { - margin-top: 120rpx; + margin-top: 80rpx; display: flex; flex-direction: column; align-items: center; diff --git a/pwuniapp/pages/user/index.vue b/pwuniapp/pages/user/index.vue index f77cf48..94745f3 100644 --- a/pwuniapp/pages/user/index.vue +++ b/pwuniapp/pages/user/index.vue @@ -152,6 +152,7 @@ 平台消息与通知 + {{ noticeSubscribe ? '提醒开启' : '已静音' }} @@ -223,27 +224,33 @@ - + 用户昵称 - 联系电话 / 微信号 + + 联系电话 / 微信号 + 支持11位手机号或微信号 + @@ -327,52 +334,6 @@ - - - - - 我的举报与维权进度 - - - - - - - - - 对象:{{ rpt.targetName || rpt.targetId }} · {{ rpt.reason }} - - {{ rpt.status || '待处理' }} - - - - 举报说明:{{ rpt.description }} - - - {{ rpt.createdAt }} - - 平台反馈:{{ rpt.handleNote || rpt.handleResult }} - - 平台安全专员正在核实处理中 - - - - - - 暂无举报与维权记录 - - - - - - @@ -393,13 +354,13 @@ import { getNavBarInfo } from '@/common/nav.js'; import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode } from '@/common/api.js'; const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; -const isLoggedIn = ref(true); +const initialUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('userId') || null) : null; +const isLoggedIn = ref(Boolean(initialUserId)); const showLoginModal = ref(false); // 我的收藏与我的举报反馈 const favoriteList = ref([]); const loadingFavorites = ref(false); -const showReportsModal = ref(false); const myReportsList = ref([]); const loadMyReports = async () => { @@ -412,10 +373,11 @@ const loadMyReports = async () => { } catch (_) {} }; -const openMyReportsModal = async () => { +const openMyReportsModal = () => { if (!ensureLoggedIn()) return; - await loadMyReports(); - showReportsModal.value = true; + uni.navigateTo({ + url: '/pages/user/reports' + }); }; // 系统与隐私设置 @@ -466,17 +428,17 @@ const DEMO_ACCOUNTS = [ ]; const user = ref({ - id: 1001, - nickname: '电竞玩家', - avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200', - gender: 'male', + id: initialUserId || '', + nickname: initialUserId ? '用户' : '点击登录 / 注册', + avatar: DEFAULT_GUEST_AVATAR, + gender: '保密', role: 'PLAYER', balance: '0.00', commission: '0.00', orderCount: 0, invitedCount: 0, - phone: '138****8888', - signature: '同城常驻朝阳 · 寻找靠谱搭子' + phone: '', + signature: initialUserId ? '同城常驻 · 寻找靠谱搭子' : '' }); const currentRole = ref(getCurrentRole()); const currentThemeKey = ref(getCurrentThemeKey()); @@ -518,9 +480,8 @@ const loadPlatformConfigs = async () => { }; const navStyle = computed(() => { - return { - '--safe-header-gap': `${navInfo.value.totalHeaderHeight}px` - }; + const gap = (navInfo.value && navInfo.value.totalHeaderHeight) || 88; + return `--safe-header-gap: ${gap}px; padding-top: ${gap}px;`; }); const onThemeChanged = (key) => { @@ -555,6 +516,8 @@ const handleLogout = () => { if (res.confirm) { uni.removeStorageSync('userInfo'); uni.removeStorageSync('userId'); + uni.removeStorageSync('PW_USER_INFO'); + uni.removeStorageSync('userToken'); uni.setStorageSync('user_has_logged_out', '1'); isLoggedIn.value = false; user.value = { @@ -579,8 +542,9 @@ const handleLogout = () => { }; const loadUserProfile = async (targetId) => { - const hasLoggedOut = uni.getStorageSync('user_has_logged_out'); - if (hasLoggedOut && !targetId) { + const storedUid = targetId || (typeof uni !== 'undefined' ? uni.getStorageSync('userId') : null); + const hasLoggedOut = typeof uni !== 'undefined' ? uni.getStorageSync('user_has_logged_out') : null; + if ((hasLoggedOut || !storedUid) && !targetId) { isLoggedIn.value = false; user.value = { id: '', @@ -591,13 +555,14 @@ const loadUserProfile = async (targetId) => { commission: '0.00', orderCount: 0, invitedCount: 0, - phone: '' + phone: '', + signature: '' }; return; } isLoggedIn.value = true; - let uid = targetId || uni.getStorageSync('userId') || 1001; + let uid = storedUid; if (uid == 101) { uid = 2006; uni.setStorageSync('userId', 2006); @@ -620,10 +585,13 @@ const loadUserProfile = async (targetId) => { } else if (profile.isExpert || (profile.role && profile.role.includes('大神'))) { setExpertCertStatus(CERT_STATUS.APPROVED); } - uni.setStorageSync('PW_USER_INFO', { + const syncedProfile = { ...(uni.getStorageSync('PW_USER_INFO') || {}), + ...(uni.getStorageSync('userInfo') || {}), ...user.value - }); + }; + uni.setStorageSync('PW_USER_INFO', syncedProfile); + uni.setStorageSync('userInfo', syncedProfile); // 若通过小程序太阳码海报进入带有待绑定的推荐人标识且当前尚未绑定上级,首次登录自动建立父子关联 const pendingInviter = uni.getStorageSync('PENDING_INVITER_CODE'); if (pendingInviter && !profile.inviterId) { @@ -670,6 +638,7 @@ onShow(() => { loadUserProfile(); loadPlatformConfigs(); privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1'; + noticeSubscribe.value = uni.getStorageSync('notice_subscribe') !== '0'; if (isLoggedIn.value) { loadFavorites(); loadMyReports(); @@ -735,6 +704,11 @@ const toggleRole = () => { icon: 'success', duration: 1200 }); + setTimeout(() => { + uni.reLaunch({ + url: '/pages/expert/hall' + }); + }, 500); } else { uni.showToast({ title: '已切回【发单人端】', @@ -762,8 +736,9 @@ const editForm = ref({ const editProfile = () => { if (!ensureLoggedIn()) return; + const initialNick = (user.value.nickname && user.value.nickname !== '点击登录 / 注册') ? user.value.nickname : ''; editForm.value = { - nickname: user.value.nickname || '', + nickname: initialNick, avatar: user.value.avatar || PRESET_AVATARS[0], phone: user.value.phone || '', gender: user.value.gender || '保密', @@ -772,6 +747,47 @@ const editProfile = () => { showEditModal.value = true; }; +const onNicknameInput = (e) => { + if (e && e.detail && typeof e.detail.value === 'string') { + editForm.value.nickname = e.detail.value; + } +}; + +const onPhoneInput = (e) => { + if (e && e.detail && typeof e.detail.value === 'string') { + editForm.value.phone = e.detail.value.replace(/[\s、,,。;;!!??]/g, ''); + } +}; + +const validateContact = (val) => { + if (!val) return { valid: true }; + const str = String(val).trim(); + + // 1. 中国大陆11位手机号 (以1开头的11位纯数字,第二位为3-9) + const isMobile = /^1[3-9]\d{9}$/.test(str); + if (isMobile) return { valid: true, type: 'phone' }; + + // 2. 微信标准微信号 (6-20位,以字母开头,仅包含字母、数字、下划线、减号) + const isWechat = /^[a-zA-Z][-_a-zA-Z0-9]{5,19}$/.test(str); + if (isWechat) return { valid: true, type: 'wechat' }; + + // 3. 历史未修改的脱敏展示掩码 (形如 138****8888) + const isMaskedMobile = /^1[3-9]\d\*{4}\d{4}$/.test(str); + if (isMaskedMobile) return { valid: true, type: 'masked' }; + + // 如果包含中文标点符号(如顿号、逗号) + if (/[\u3001\u3002\uff0c\uff1b\uff1f\uff01\s]/.test(str)) { + return { valid: false, message: '联系方式中不能包含标点符号或空格' }; + } + + // 如果是纯数字但不是11位 + if (/^\d+$/.test(str) && str.length !== 11) { + return { valid: false, message: '手机号码必须为11位纯数字' }; + } + + return { valid: false, message: '请输入有效的11位手机号或微信号' }; +}; + // 点击头像或名称行:已登录时直接打开个人资料设置弹窗,未登录时引导登录 const handleAvatarOrNameClick = () => { if (isLoggedIn.value) { @@ -821,10 +837,27 @@ const chooseCustomAvatar = () => { }; const saveProfile = async () => { - if (!editForm.value.nickname || !editForm.value.nickname.trim()) { + const finalNickname = editForm.value.nickname ? editForm.value.nickname.trim() : ''; + if (!finalNickname) { uni.showToast({ title: '昵称不能为空', icon: 'none' }); return; } + editForm.value.nickname = finalNickname; + + const finalPhone = editForm.value.phone ? editForm.value.phone.trim() : ''; + if (finalPhone) { + const contactCheck = validateContact(finalPhone); + if (!contactCheck.valid) { + uni.showToast({ + title: contactCheck.message || '请输入有效的11位手机号或微信号', + icon: 'none', + duration: 2000 + }); + return; + } + } + editForm.value.phone = finalPhone; + const currentAvatar = editForm.value.avatar || ''; if ( currentAvatar.startsWith('http://tmp/') || @@ -838,9 +871,9 @@ const saveProfile = async () => { uni.showLoading({ title: '正在保存资料...' }); try { const updated = await updateUserProfile(user.value.id || 1001, { - nickname: editForm.value.nickname.trim(), + nickname: finalNickname, avatar: editForm.value.avatar, - phone: editForm.value.phone, + phone: finalPhone, gender: editForm.value.gender || '保密', signature: editForm.value.signature ? editForm.value.signature.trim() : '' }); @@ -851,12 +884,19 @@ const saveProfile = async () => { ...updated }; } else { - user.value.nickname = editForm.value.nickname.trim(); + user.value.nickname = finalNickname; user.value.avatar = editForm.value.avatar; - user.value.phone = editForm.value.phone; + user.value.phone = finalPhone; user.value.gender = editForm.value.gender || '保密'; user.value.signature = editForm.value.signature ? editForm.value.signature.trim() : ''; } + const syncedProfile = { + ...(uni.getStorageSync('PW_USER_INFO') || {}), + ...(uni.getStorageSync('userInfo') || {}), + ...user.value + }; + uni.setStorageSync('PW_USER_INFO', syncedProfile); + uni.setStorageSync('userInfo', syncedProfile); showEditModal.value = false; uni.showToast({ title: '个人信息修改成功', @@ -887,54 +927,10 @@ const viewWalletLog = () => { }); }; -const handleRecharge = async () => { +const handleRecharge = () => { if (!ensureLoggedIn()) return; - let tiers = []; - try { - const list = await getRechargeTiers(); - if (Array.isArray(list) && list.length > 0) { - tiers = list; - } - } catch (_) {} - - if (tiers.length === 0) { - tiers = [ - { amount: 50, bonusAmount: 0, tag: '基础充值' }, - { amount: 100, bonusAmount: 5, tag: '热门畅享' }, - { amount: 200, bonusAmount: 15, tag: '超值加赠' }, - { amount: 500, bonusAmount: 50, tag: '尊享特惠' } - ]; - } - - const itemList = tiers.map((t) => { - const bonus = Number(t.bonusAmount) > 0 ? ` (赠¥${Number(t.bonusAmount)})` : ''; - const tag = t.tag ? ` [${t.tag}]` : ''; - return `充值 ¥${Number(t.amount)}${bonus}${tag}`; - }); - - uni.showActionSheet({ - itemList, - success: async (res) => { - const selected = tiers[res.tapIndex] || tiers[0]; - const amount = Number(selected.amount); - try { - const result = await rechargeBalance(1001, amount); - if (result && result.balance) { - user.value.balance = result.balance; - } - const bonusMsg = Number(selected.bonusAmount) > 0 ? `,加赠 ¥${Number(selected.bonusAmount)} 已同步入账` : ''; - uni.showToast({ - title: `充值 ¥${amount} 成功${bonusMsg}!`, - icon: 'success', - duration: 2000 - }); - } catch (err) { - uni.showToast({ - title: '充值失败,请稍后重试', - icon: 'none' - }); - } - } + uni.navigateTo({ + url: '/pages/user/recharge' }); }; @@ -1021,6 +1017,7 @@ const updateCacheSize = () => { // 2. 系统与隐私设置(跳转独立设置页面) const openSettingsModal = () => { + if (!ensureLoggedIn()) return; uni.navigateTo({ url: '/pages/user/settings' }); @@ -1816,6 +1813,22 @@ const navTo = (url) => { .edit-field { margin-bottom: 24rpx; + .field-label-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10rpx; + + .field-label { + margin-bottom: 0; + } + + .field-tip-text { + font-size: 20rpx; + color: #64748B; + } + } + .field-label { font-size: 24rpx; color: #94A3B8; @@ -2022,6 +2035,15 @@ const navTo = (url) => { } .edit-field { + .field-label-row { + .field-label { + color: #78716C; + } + .field-tip-text { + color: #A8A29E; + } + } + .field-label { color: #78716C; } diff --git a/pwuniapp/pages/user/recharge.vue b/pwuniapp/pages/user/recharge.vue new file mode 100644 index 0000000..e93e2e4 --- /dev/null +++ b/pwuniapp/pages/user/recharge.vue @@ -0,0 +1,1407 @@ + + + + + + + + + + 钱包充值 + + + + 账单明细 + + + + + + + + + + + + + + 当前可用余额 (元) + + + UID: {{ currentUserId }} + + + + + ¥ + {{ balanceParts.integer }} + .{{ balanceParts.decimal }} + + + + 充值后预计余额: + ¥{{ displayEstimatedBalance }} + (含本次加赠 ¥{{ currentBonusAmount }}) + + + + + + + + + 充值金额 + 充得多赠得多 · 即时到账 + + + 最高立赠 ¥{{ maxBonusTier.bonusAmount }} + + + + + + + + {{ tier.tag || ('赠¥' + tier.bonusAmount) }} + + + + ¥ + {{ tier.amount }} + + + + 赠送 ¥{{ tier.bonusAmount }} + + + 到账 ¥{{ tier.amount }} + + + + + + + + + + + + 输入其他金额 (元) + 支持 1 至 50,000 元任意充值 + + + + ¥ + + + + + + + + + 请输入合法的充值金额 (1 - 50000 元) + + 实付 ¥{{ currentPayAmount.toFixed(2) }},预计到账 ¥{{ currentTotalCredit.toFixed(2) }} + (已匹配活动加赠 ¥{{ currentBonusAmount }}) + + + + + + + + + 支付方式 + 官方官方推荐 + + + + + + + + + + 微信支付 + 极速直达 + + 微信官方安全收银台 · 秒级到账 + + + + + + + + + + 平台资金由微信支付合规商户托管,充值即刻生效入账 + + + + + + + + 充值服务说明与资金安全保障 + + + + · + 充值资金实时计入个人账户余额,可用于陪玩下单、组局报名及搭子开黑等全部消费场景; + + + · + 充值赠送金额将随本金一同入账,平台优先抵扣消费,赠送金额不支持提现; + + + · + 充值交易经由微信商户全链路加密风控保护,7x24小时全自动记账对账; + + + · + 如遇网络延迟未即时显示余额,请在个人中心下拉刷新或联系官方在线客服核验处理。 + + + + + + + + + + + + + 实付金额: + ¥ + {{ currentPayAmount.toFixed(2) }} + + + 预计到账 ¥{{ currentTotalCredit.toFixed(2) }} (赠 ¥{{ currentBonusAmount }}) + + + 预计实际到账 ¥{{ currentTotalCredit.toFixed(2) }} + + + + {{ isSubmitting ? '正在调起支付...' : '确认充值' }} + + + + + + + + diff --git a/pwuniapp/pages/user/reports.vue b/pwuniapp/pages/user/reports.vue new file mode 100644 index 0000000..78b6bf5 --- /dev/null +++ b/pwuniapp/pages/user/reports.vue @@ -0,0 +1,1190 @@ + + + + + + + + + + 我的举报与维权反馈 + + + + + + + + + + + + + + + + + + + + 安全风控与维权中心 + 7x24h 专员核查 · 守护绿色纯净社区 + + + + + 人工客服 + + + + + + {{ allReports.length }} 单 + 累计提交工单 + + + + {{ stats.pendingCount }} 单 + 专员核查中 + + + + {{ stats.resolvedCount }} 单 + 已办结反馈 + + + + + + + + + + {{ tab.label }} + {{ tab.count }} + + + + + + + + + + + + + + + + + 维权工单 + {{ formatReportNo(rpt.id) }} + + + + {{ rpt.status || '待处理' }} + + + + + + + + 举报对象 + 对象:{{ rpt.targetName || rpt.targetId || '违规内容' }} + + + {{ rpt.reason || '违规举报' }} + + + + + 举报说明: + {{ rpt.description }} + + + + + + + 提交时间 + {{ formatReportTime(rpt.createdAt) }} + + + + + + 平台风控处置反馈 + + + {{ getFeedbackText(rpt) }} + + + + + + + + + 已展示全部举报与维权记录 + + + + + + + + + + 暂无举报与维权记录 + 如在组局约玩或友圈互动中遇到违规行为,可在对应详情页发起举报,平台专员将第一时间核实处理 + 返回个人中心 + + + + + + + + + + + + diff --git a/pwuniapp/pages/user/settings.vue b/pwuniapp/pages/user/settings.vue index b0e7756..ac9daa6 100644 --- a/pwuniapp/pages/user/settings.vue +++ b/pwuniapp/pages/user/settings.vue @@ -186,15 +186,40 @@ 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'; const currentThemeKey = ref(getCurrentThemeKey()); const navInfo = ref(getNavBarInfo()); -// 本地设置状态 +const ESSENTIAL_STORAGE_KEYS = [ + 'peiwan_token', + 'peiwan_current_role', + 'app_theme_key', + 'privacy_hide_phone', + 'notice_subscribe', + 'userId', + 'userInfo', + 'user_has_logged_out', + 'expert_cert_status', + 'privacy_agreed', + 'cache_cleaned_snapshot_kb' +]; + +const checkIsLoggedIn = () => { + const hasLoggedOut = uni.getStorageSync('user_has_logged_out') === '1'; + if (hasLoggedOut) return false; + return true; +}; + +const currentUserId = computed(() => { + return Number(uni.getStorageSync('userId') || 1001); +}); + +// 本地与云端同步设置状态 const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1'); const noticeSubscribe = ref(uni.getStorageSync('notice_subscribe') !== '0'); -const cacheSizeText = ref('1.25 MB'); -const isLoggedIn = ref(!!uni.getStorageSync('peiwan_token')); +const cacheSizeText = ref('0 KB'); +const isLoggedIn = ref(checkIsLoggedIn()); // 顶部自定义导航栏适配 const navStyle = computed(() => { @@ -210,80 +235,149 @@ const switchActiveColor = computed(() => { return currentThemeKey.value === 'warm' ? '#EA580C' : '#6366F1'; }); -// 计算本地缓存大小 +// 真实计算可清理的本地临时缓存大小 const updateCacheSize = () => { try { const info = uni.getStorageInfoSync(); - const kb = info.currentSize || 0; - if (kb > 1024) { - cacheSizeText.value = `${(kb / 1024).toFixed(2)} MB`; + const allKeys = Array.isArray(info.keys) ? info.keys : []; + const tempKeys = allKeys.filter(k => !ESSENTIAL_STORAGE_KEYS.includes(k)); + const snapshotRaw = uni.getStorageSync('cache_cleaned_snapshot_kb'); + const hasCleanedBefore = snapshotRaw !== '' && snapshotRaw !== null && snapshotRaw !== undefined; + + let tempBytes = 0; + tempKeys.forEach((k) => { + try { + const val = uni.getStorageSync(k); + const serialized = typeof val === 'string' ? val : JSON.stringify(val || ''); + tempBytes += (k.length + serialized.length) * 2; + } catch (_) {} + }); + + const currentKb = Number(info.currentSize || 0); + if (tempBytes > 0) { + const kb = Math.max(1, Math.ceil(tempBytes / 1024)); + cacheSizeText.value = kb > 1024 ? `${(kb / 1024).toFixed(2)} MB` : `${kb} KB`; + } else if (!hasCleanedBefore && currentKb > 0) { + cacheSizeText.value = currentKb > 1024 ? `${(currentKb / 1024).toFixed(2)} MB` : `${currentKb} KB`; } else { - cacheSizeText.value = `${kb} KB`; + const diffKb = Math.max(0, currentKb - Number(snapshotRaw || 0)); + cacheSizeText.value = diffKb > 1024 ? `${(diffKb / 1024).toFixed(2)} MB` : `${diffKb} KB`; } } catch (_) { - cacheSizeText.value = '1.25 MB'; + cacheSizeText.value = '0 KB'; } }; -// 切换手机号脱敏隐藏 -const onToggleHidePhone = (e) => { - const val = e.detail.value; +// 同步远端用户的隐私与通知偏好 +const syncSettingsFromServer = async () => { + if (!isLoggedIn.value) return; + try { + const res = await getUserSettings(currentUserId.value); + if (res && typeof res === 'object') { + if (typeof res.privacyHidePhone === 'boolean') { + privacyHidePhone.value = res.privacyHidePhone; + } + if (typeof res.noticeSubscribe === 'boolean') { + noticeSubscribe.value = res.noticeSubscribe; + } + } + } catch (_) {} +}; + +// 切换手机号脱敏隐藏(同步本地与后端并广播全站脱敏事件) +const onToggleHidePhone = async (e) => { + const val = !!e.detail.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 + }); uni.showToast({ - title: val ? '已开启手机号隐藏' : '已关闭手机号隐藏', + title: val ? '已开启手机号脱敏保护' : '已关闭手机号脱敏保护', icon: 'none' }); }; -// 切换通知订阅 -const onToggleNotice = (e) => { - const val = e.detail.value; +// 切换通知订阅(微信小程序端支持唤起订阅授权,并同步服务端) +const onToggleNotice = async (e) => { + const val = !!e.detail.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 (_) {} + } + // #endif + + uni.$emit('notice-subscribe-change', val); + await updateUserSettings(currentUserId.value, { + privacyHidePhone: privacyHidePhone.value, + noticeSubscribe: val + }); uni.showToast({ - title: val ? '已开启服务提醒' : '已关闭服务提醒', + title: val ? '已开启订单与组局动态提醒' : '已关闭订单与组局动态提醒', icon: 'none' }); }; -// 一键清理本地缓存 +// 一键清理本地缓存(精准清理非核心临时键值与本地保存的临时文件) const clearLocalCache = () => { uni.showModal({ title: '清除缓存', - content: '清理将释放临时数据,保留登录账号与必要配置。确定继续?', + content: '清理将释放临时图片与历史交互缓存,保留当前登录账号与隐私配置。确定继续?', confirmText: '确定清理', cancelText: '取消', confirmColor: '#EF4444', success: (res) => { if (res.confirm) { try { - const token = uni.getStorageSync('peiwan_token'); - const userRole = uni.getStorageSync('peiwan_current_role'); - const themeKey = uni.getStorageSync('app_theme_key'); - const hidePhone = uni.getStorageSync('privacy_hide_phone'); - const noticeSub = uni.getStorageSync('notice_subscribe'); - const userId = uni.getStorageSync('userId'); - const userInfo = uni.getStorageSync('userInfo'); + const info = uni.getStorageInfoSync(); + const allKeys = Array.isArray(info.keys) ? info.keys : []; + let removedCount = 0; + allKeys.forEach((k) => { + if (!ESSENTIAL_STORAGE_KEYS.includes(k)) { + uni.removeStorageSync(k); + removedCount++; + } + }); - uni.clearStorageSync(); + // #ifdef MP-WEIXIN + if (typeof uni.getSavedFileList === 'function') { + uni.getSavedFileList({ + success: (fileRes) => { + if (fileRes && Array.isArray(fileRes.fileList)) { + fileRes.fileList.forEach((f) => { + uni.removeSavedFile({ filePath: f.filePath, fail: () => {} }); + }); + } + }, + fail: () => {} + }); + } + // #endif - if (token) uni.setStorageSync('peiwan_token', token); - if (userRole) uni.setStorageSync('peiwan_current_role', userRole); - if (themeKey) uni.setStorageSync('app_theme_key', themeKey); - if (hidePhone) uni.setStorageSync('privacy_hide_phone', hidePhone); - if (noticeSub) uni.setStorageSync('notice_subscribe', noticeSub); - if (userId) uni.setStorageSync('userId', userId); - if (userInfo) uni.setStorageSync('userInfo', userInfo); + const afterInfo = uni.getStorageInfoSync(); + uni.setStorageSync('cache_cleaned_snapshot_kb', Number(afterInfo.currentSize || 0)); + const finalInfo = uni.getStorageInfoSync(); + uni.setStorageSync('cache_cleaned_snapshot_kb', Number(finalInfo.currentSize || 0)); cacheSizeText.value = '0 KB'; uni.showToast({ - title: '缓存清理完毕', + title: removedCount > 0 ? `已清理 ${removedCount} 项临时缓存` : '缓存清理完毕 (0 KB)', icon: 'none' }); } catch (_) { + cacheSizeText.value = '0 KB'; uni.showToast({ - title: '清理成功', + title: '缓存清理完毕', icon: 'none' }); } @@ -306,11 +400,11 @@ const handleSwitchAccount = () => { }); }; -// 退出登录 +// 退出登录(与个人中心退出逻辑完全同步) const handleLogout = () => { uni.showModal({ title: '退出登录', - content: '确定退出当前账号登录吗?', + content: '退出登录后将清除本地会话,切换为未登录游客态。确定退出吗?', confirmText: '确认退出', cancelText: '取消', confirmColor: '#EF4444', @@ -319,9 +413,12 @@ const handleLogout = () => { uni.removeStorageSync('peiwan_token'); uni.removeStorageSync('peiwan_current_role'); uni.removeStorageSync('userInfo'); + uni.removeStorageSync('userId'); + uni.removeStorageSync('userToken'); + uni.setStorageSync('user_has_logged_out', '1'); isLoggedIn.value = false; uni.showToast({ - title: '已退出登录', + title: '已退出当前登录', icon: 'none' }); setTimeout(() => { @@ -352,8 +449,9 @@ onShow(() => { navInfo.value = getNavBarInfo(); privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1'; noticeSubscribe.value = uni.getStorageSync('notice_subscribe') !== '0'; - isLoggedIn.value = !!uni.getStorageSync('peiwan_token'); + isLoggedIn.value = checkIsLoggedIn(); updateCacheSize(); + syncSettingsFromServer(); }); diff --git a/server/src/core/pay/wechat-pay-v3.ts b/server/src/core/pay/wechat-pay-v3.ts new file mode 100644 index 0000000..0150396 --- /dev/null +++ b/server/src/core/pay/wechat-pay-v3.ts @@ -0,0 +1,215 @@ +// 已读取AGENTS.md +/** + * 微信支付 V3 (微信支付APIv3) 直连商户服务端客户端 + * - 使用商户 API 私钥 (apiclient_key.pem) 对请求做 RSA-SHA256 签名 + * - 使用微信支付平台公钥/平台证书 对回调做验签 + * - 使用 APIv3 密钥 对回调 resource 做 AES-256-GCM 解密 + * 该模块为纯函数/无状态,便于离线单元校验与复用。 + */ +import crypto from 'crypto'; +import https from 'https'; + +export interface WechatPayConfig { + appId: string; + mchId: string; + apiV3Key: string; + serialNo: string; + privateKey: string; + platformPublicKey?: string; + platformPublicKeyId?: string; + notifyUrl?: string; +} + +const WXPAY_HOST = 'api.mch.weixin.qq.com'; + +/** 解析微信支付平台公钥:兼容「公钥 PEM」与「平台证书 PEM」两种输入 */ +export function resolvePlatformKey(platformKeyOrCert?: string): crypto.KeyObject | null { + const pem = String(platformKeyOrCert || '').trim(); + if (!pem) return null; + try { + return crypto.createPublicKey(pem); + } catch (_) { + // 若为 X509 证书 PEM,则提取其中的公钥 + try { + return new crypto.X509Certificate(pem).publicKey; + } catch (_e) { + return null; + } + } +} + +/** 生成微信支付 V3 请求的 Authorization 头 */ +export function buildAuthorization(opts: { + method: string; + urlPath: string; + body: string; + cfg: WechatPayConfig; +}): string { + const timestamp = String(Math.floor(Date.now() / 1000)); + const nonceStr = crypto.randomBytes(16).toString('hex'); + const message = `${opts.method}\n${opts.urlPath}\n${timestamp}\n${nonceStr}\n${opts.body}\n`; + const signature = crypto + .createSign('RSA-SHA256') + .update(message) + .sign(opts.cfg.privateKey, 'base64'); + return ( + `WECHATPAY2-SHA256-RSA2048 ` + + `mchid="${opts.cfg.mchId}",` + + `nonce_str="${nonceStr}",` + + `signature="${signature}",` + + `timestamp="${timestamp}",` + + `serial_no="${opts.cfg.serialNo}"` + ); +} + +/** 底层 HTTPS 请求,返回状态码 / 响应头 / 原始文本 */ +export function httpsRequest(opts: { + method: string; + urlPath: string; + headers: Record; + body?: string; +}): Promise<{ statusCode: number; headers: any; body: string }> { + return new Promise((resolve, reject) => { + const req = https.request( + { + method: opts.method, + hostname: WXPAY_HOST, + path: opts.urlPath, + headers: opts.headers, + }, + (resp) => { + let data = ''; + resp.on('data', (chunk) => { + data += chunk; + }); + resp.on('end', () => + resolve({ statusCode: resp.statusCode || 0, headers: resp.headers, body: data }) + ); + } + ); + req.on('error', reject); + if (opts.body) req.write(opts.body); + req.end(); + }); +} + +/** JSAPI 统一下单,返回 prepay_id(并为小程序返回支付参数) */ +export async function createJsapiPrepay( + cfg: WechatPayConfig, + params: { outTradeNo: string; description: string; amountTotalCents: number; openid: string } +): Promise { + const urlPath = '/v3/pay/transactions/jsapi'; + const body = JSON.stringify({ + appid: cfg.appId, + mchid: cfg.mchId, + description: params.description, + out_trade_no: params.outTradeNo, + notify_url: cfg.notifyUrl, + amount: { total: params.amountTotalCents, currency: 'CNY' }, + payer: { openid: params.openid }, + }); + 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, + }); + if (resp.statusCode >= 400) { + throw new Error(`微信支付 JSAPI 下单失败 [${resp.statusCode}]: ${resp.body}`); + } + const parsed = JSON.parse(resp.body || '{}'); + if (!parsed.prepay_id) { + throw new Error(`微信支付未返回 prepay_id: ${resp.body}`); + } + return parsed.prepay_id as string; +} + +/** 使用 prepay_id 生成小程序 wx.requestPayment 所需参数并签名 */ +export function buildJsapiPayParams( + cfg: WechatPayConfig, + prepayId: string +): { timeStamp: string; nonceStr: string; package: string; signType: 'RSA'; paySign: string } { + const timeStamp = String(Math.floor(Date.now() / 1000)); + const nonceStr = crypto.randomBytes(16).toString('hex'); + const packageStr = `prepay_id=${prepayId}`; + const message = `${cfg.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`; + const paySign = crypto + .createSign('RSA-SHA256') + .update(message) + .sign(cfg.privateKey, 'base64'); + return { timeStamp, nonceStr, package: packageStr, signType: 'RSA', paySign }; +} + +/** 主动查单(用于支付成功后及时对账,避免仅依赖异步回调) */ +export async function queryOrderByOutTradeNo( + cfg: WechatPayConfig, + outTradeNo: string +): Promise { + const urlPath = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(cfg.mchId)}`; + 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; + timestamp: string; + nonce: string; + body: string; + signature: string; + serial: string; +}): boolean { + const { cfg, timestamp, nonce, body, signature } = params; + if (!timestamp || !nonce || !signature) return false; + const platformKey = resolvePlatformKey(cfg.platformPublicKey); + if (!platformKey) return false; + if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; + const message = `${timestamp}\n${nonce}\n${body}\n`; + try { + return crypto + .createVerify('RSA-SHA256') + .update(message) + .verify(platformKey, signature, 'base64'); + } catch (_) { + return false; + } +} + +/** 解密回调 resource(AES-256-GCM,key 为 32 位 APIv3 密钥) */ +export function decryptNotifyResource( + apiV3Key: string, + resource: { ciphertext: string; nonce: string; associated_data?: string } +): any { + const key = Buffer.from(String(apiV3Key), 'utf8'); + if (key.length !== 32) { + throw new Error('APIv3 密钥长度必须为 32 位'); + } + const data = Buffer.from(resource.ciphertext, 'base64'); + const authTag = data.subarray(data.length - 16); + const ciphertext = data.subarray(0, data.length - 16); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(resource.nonce, 'utf8')); + decipher.setAuthTag(authTag); + if (resource.associated_data) { + decipher.setAAD(Buffer.from(resource.associated_data, 'utf8')); + } + const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return JSON.parse(decrypted.toString('utf8')); +} diff --git a/server/src/core/security/content-security.service.ts b/server/src/core/security/content-security.service.ts index 4962f32..3d4bd8b 100644 --- a/server/src/core/security/content-security.service.ts +++ b/server/src/core/security/content-security.service.ts @@ -13,6 +13,16 @@ export interface SecurityCheckResult { message: string; } +/** + * 远端内容安全检测器签名(由服务端启动时注入真实的微信 msgSecCheck 调用)。 + * 返回 null 表示远端不可用/未配置,调用方应降级为本地词库结果。 + */ +export type RemoteTextChecker = ( + text: string, + openid?: string, + scene?: number +) => Promise; + export class ContentSecurityService { // 敏感词黑名单库(按涉黄低俗、涉政暴恐、欺诈引流、违规黑产分类) private static readonly SENSITIVE_KEYWORDS: { [category: string]: string[] } = { @@ -33,6 +43,20 @@ export class ContentSecurityService { // 动态自定义敏感词列表(支持管理后台增删改并实时热生效) private customKeywords: string[] = []; + // 远端真实审核器(微信官方 security.msgSecCheck),未注入时仅使用本地词库 + private remoteChecker?: RemoteTextChecker; + + /** + * 注入/替换远端真实内容安全审核器(微信 msgSecCheck) + */ + public setRemoteChecker(checker: RemoteTextChecker): void { + this.remoteChecker = checker; + } + + public hasRemoteChecker(): boolean { + return typeof this.remoteChecker === 'function'; + } + /** * 载入或更新动态敏感词黑名单 */ @@ -49,6 +73,27 @@ export class ContentSecurityService { return [...this.customKeywords]; } + /** + * 异步内容安全检测(真实对接微信 security.msgSecCheck) + * 双层防御:先跑本地词库即时拦截;本地放行且已注入远端审核器时, + * 再调用微信官方机器审核。远端异常/未配置时降级为本地结果(fail-open),避免阻断正常业务。 + * @param text 待检测文本 + * @param openid 用户微信 openid(供微信官方溯源,强烈建议传入) + * @param scene 场景值:1 资料;2 评论;3 论坛;4 社交日志 + */ + public async checkTextAsync(text: string, openid?: string, scene?: number): Promise { + const local = this.checkText(text, openid); + if (!local.pass) return local; + if (typeof this.remoteChecker !== 'function') return local; + try { + const remote = await this.remoteChecker(text, openid, scene); + if (remote) return remote; + } catch (_) { + // 远端异常时降级为本地词库结果 + } + return local; + } + /** * 检查文本内容安全 (security.msgSecCheck) * @param text 待检测文本(支持动态、评论、活动标题、个人简介等) diff --git a/server/src/core/security/user-token.ts b/server/src/core/security/user-token.ts new file mode 100644 index 0000000..e798202 --- /dev/null +++ b/server/src/core/security/user-token.ts @@ -0,0 +1,54 @@ +import * as crypto from 'crypto'; + +/** + * 小程序用户会话 token(HMAC-SHA256 签名,无状态)。 + * 目的是让服务端在资金/敏感写接口上以「服务端签发并验签的身份」为准, + * 杜绝客户端通过伪造 userId 冒充他人。 + * + * token 结构: base64url(JSON{uid,ts}) + "." + base64url(HMAC_SHA256(payload, secret)) + */ + +function base64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64url'); +} + +export function signUserToken( + userId: number, + secret: string, + ttlMs: number, + now: number = Date.now() +): string { + const payload = base64url(JSON.stringify({ uid: Number(userId), ts: Number(now) })); + const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url'); + return `${payload}.${sig}`; +} + +export function verifyUserToken( + token: string, + secret: string, + ttlMs: number, + now: number = Date.now() +): number | null { + const parts = String(token || '').split('.'); + if (parts.length !== 2) return null; + const [payload, sig] = parts; + if (!payload || !sig) return null; + + const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url'); + const sigBuf = Buffer.from(sig); + const expBuf = Buffer.from(expected); + if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return null; + + try { + const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + const uid = Number(data.uid); + const ts = Number(data.ts); + if (!uid || !ts) return null; + if (now - ts > ttlMs) return null; + // 拒绝明显来自未来(时钟漂移/伪造)的 token + if (ts - now > 5 * 60 * 1000) return null; + return uid; + } catch (_) { + return null; + } +} diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 1842b72..0d4a43b 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -27,6 +27,7 @@ export async function initDatabase(): Promise { title VARCHAR(255) NOT NULL, game_category VARCHAR(64) NOT NULL, location VARCHAR(255), + start_time VARCHAR(64) DEFAULT '', pay_type INT NOT NULL DEFAULT 1, total_fee_cents INT DEFAULT 0, per_person_fee_cents INT DEFAULT 0, @@ -38,6 +39,12 @@ export async function initDatabase(): Promise { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + try { + await conn.query(`ALTER TABLE activities ADD COLUMN start_time VARCHAR(64) DEFAULT '' AFTER location`); + } catch (_) { + // 字段已存在忽略 + } + try { await conn.query(`ALTER TABLE activities ADD COLUMN status VARCHAR(32) DEFAULT 'RECRUITING' AFTER price_adjust_rule`); } catch (_) { @@ -57,10 +64,24 @@ export async function initDatabase(): Promise { verified_by INT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_activity (activity_id), - INDEX idx_verify_code (verify_code) + INDEX idx_verify_code (verify_code), + UNIQUE KEY uq_activity_user (activity_id, user_id), + UNIQUE KEY uq_activity_verify (activity_id, verify_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + // 历史库补丁:为已存在的 participants 表补充唯一约束(防重复报名 / 防核销码冲突) + for (const uqSql of [ + 'ALTER TABLE participants ADD UNIQUE KEY uq_activity_user (activity_id, user_id)', + 'ALTER TABLE participants ADD UNIQUE KEY uq_activity_verify (activity_id, verify_code)' + ]) { + try { + await conn.query(uqSql); + } catch (_) { + // 约束已存在或历史数据存在重复,忽略(应用层仍会二次校验) + } + } + // 3. 陪玩大神档案表 await conn.query(` CREATE TABLE IF NOT EXISTS companions ( @@ -131,7 +152,9 @@ export async function initDatabase(): Promise { "ALTER TABLE orders ADD COLUMN coupon_discount DECIMAL(10,2) DEFAULT 0.00 AFTER amount", "ALTER TABLE orders ADD COLUMN paid_at DATETIME NULL AFTER created_at", "ALTER TABLE orders ADD COLUMN finish_applied_at DATETIME NULL AFTER paid_at", - "ALTER TABLE orders ADD COLUMN refund_amount DECIMAL(10,2) DEFAULT 0.00 AFTER coupon_discount" + "ALTER TABLE orders ADD COLUMN refund_amount DECIMAL(10,2) DEFAULT 0.00 AFTER coupon_discount", + "ALTER TABLE orders ADD COLUMN claimed_by INT NULL AFTER expert_id", + "ALTER TABLE orders ADD COLUMN claimed_at DATETIME NULL AFTER claimed_by" ]; for (const colSql of extraOrderCols) { try { @@ -194,6 +217,26 @@ export async function initDatabase(): Promise { await conn.query('ALTER TABLE withdrawals ADD COLUMN reason VARCHAR(255) DEFAULT ""'); } catch (_) {} + // 7.5 微信支付支付意图与对账表 (幂等入账的唯一依据) + await conn.query(` + CREATE TABLE IF NOT EXISTS payment_intents ( + id VARCHAR(64) PRIMARY KEY, + out_trade_no VARCHAR(64) UNIQUE NOT NULL, + scene VARCHAR(32) NOT NULL DEFAULT 'ORDER', + user_id INT NOT NULL DEFAULT 0, + order_id VARCHAR(64) DEFAULT '', + title VARCHAR(128) DEFAULT '', + amount_cents INT NOT NULL DEFAULT 0, + bonus_cents INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + transaction_id VARCHAR(64) DEFAULT '', + credited TINYINT NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + paid_at DATETIME NULL, + INDEX idx_user_scene (user_id, scene, status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `); + // 8. 线下合规场所库 await conn.query(` CREATE TABLE IF NOT EXISTS safety_places ( @@ -292,10 +335,18 @@ export async function initDatabase(): Promise { reply_time DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_order (order_id), - INDEX idx_expert (expert_id) + INDEX idx_expert (expert_id), + UNIQUE KEY uq_order_review (order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); + // 历史库补丁:同一订单仅允许一条评价,防止重复评价反复触发结算 + try { + await conn.query('ALTER TABLE order_reviews ADD UNIQUE KEY uq_order_review (order_id)'); + } catch (_) { + // 约束已存在或历史数据存在重复,忽略(应用层仍会二次校验) + } + try { await conn.query('ALTER TABLE order_reviews ADD COLUMN reply_content TEXT AFTER content'); } catch (_) {} @@ -895,6 +946,32 @@ async function seedInitialDataIfEmpty(conn: any): Promise { remark: '微信小程序订阅消息与服务通知模版ID配置' }); + const wechatPayConfigVal = JSON.stringify({ + mode: 'AUTO', + appId: 'wx0c929f7e54024e67', + appSecret: '63a412f3f73af8e73f6923943bf9683c', + mchId: '', + apiV3Key: '', + serialNo: '', + privateKey: '', + certPem: '', + platformPublicKeyId: '', + platformPublicKey: '', + notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify', + remark: '微信支付V3直连商户参数配置(配置完整后自动或手动切换至实盘JSAPI支付)' + }); + + const mapLocationConfigVal = JSON.stringify({ + provider: 'tencent', + mapKey: '', + secretKey: '', + serviceName: '腾讯位置服务 (Tencent LBS)', + enableChooseLocation: 1, + enableDistanceCalculation: 1, + defaultCity: '北京市', + remark: '腾讯位置服务 Key(需在 lbs.qq.com 申请并勾选 WebServiceAPI 与微信小程序 AppID: wx0c929f7e54024e67)' + }); + const [cfgItemRows]: any = await conn.query('SELECT COUNT(*) as count FROM platform_configs'); if (cfgItemRows[0].count === 0) { const customerServiceVal = JSON.stringify({ @@ -938,12 +1015,16 @@ async function seedInitialDataIfEmpty(conn: any): Promise { ('home_popup', '小程序首页运营全局弹窗', ?, '节日活动推广、新用户专享首单优惠全局浮层弹窗', 1), ('expert_rules', '大神技能标签库与定价区间规范', ?, '陪玩大神入驻审核、定价上下限限制与官方推荐技能标签库', 1), ('finance_rules', '平台抽成与推广分销规则', ?, '平台佣金抽成比例、二级分销返润与佣金提现门槛限制', 1), - ('activity_rules', '同城组局发单与预算预设规范', ?, '招募席位上下限、快捷单人赏金预设与包场预算梯队', 1); + ('activity_rules', '同城组局发单与预算预设规范', ?, '招募席位上下限、快捷单人赏金预设与包场预算梯队', 1), + ('message_template_rules', '微信服务通知与模板消息配置', ?, '微信订阅消息通知与状态模板配置', 1), + ('wechat_pay_config', '微信支付商户号与证书配置', ?, '微信支付V3商户号、APIv3密钥与证书参数配置', 1), + ('map_location_config', '地图定位与LBS服务配置', ?, '腾讯位置服务Key、选点与周边测距参数配置', 1); `, [ customerServiceVal, safetyNoticeVal, protocolVal, communityTopicsVal, communityNoticeVal, faqListVal, securityWordsVal, homePopupVal, expertRulesVal, - financeRulesVal, activityRulesVal + financeRulesVal, activityRulesVal, messageTemplateRulesVal, + wechatPayConfigVal, mapLocationConfigVal ]); } else { // 增量保证各核心配置项完整就绪 @@ -1003,6 +1084,40 @@ async function seedInitialDataIfEmpty(conn: any): Promise { VALUES ('message_template_rules', '微信服务通知与模板消息配置', ?, '微信订阅消息通知与状态模板配置', 1) `, [messageTemplateRulesVal]); } + if (!existingKeys.has('wechat_pay_config')) { + await conn.query(` + INSERT INTO platform_configs (config_key, name, value_json, description, status) + VALUES ('wechat_pay_config', '微信支付商户号与证书配置', ?, '微信支付V3商户号、APIv3密钥与证书参数配置', 1) + `, [wechatPayConfigVal]); + } else { + const [wpRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'wechat_pay_config'"); + if (wpRows && wpRows.length > 0) { + let curVal = wpRows[0].value_json; + if (typeof curVal === 'string') { + try { curVal = JSON.parse(curVal); } catch (_) {} + } + if (curVal && typeof curVal === 'object') { + let updated = false; + if (!curVal.appId || curVal.appId === 'wx_peiwan_miniapp') { + curVal.appId = 'wx0c929f7e54024e67'; + updated = true; + } + if (!curVal.appSecret) { + curVal.appSecret = '63a412f3f73af8e73f6923943bf9683c'; + updated = true; + } + if (updated) { + await conn.query("UPDATE platform_configs SET value_json = ? WHERE config_key = 'wechat_pay_config'", [JSON.stringify(curVal)]); + } + } + } + } + if (!existingKeys.has('map_location_config')) { + await conn.query(` + INSERT INTO platform_configs (config_key, name, value_json, description, status) + VALUES ('map_location_config', '地图定位与LBS服务配置', ?, '腾讯位置服务Key、选点与周边测距参数配置', 1) + `, [mapLocationConfigVal]); + } if (existingKeys.has('platform_protocol')) { const [protoRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'platform_protocol'"); if (protoRows && protoRows.length > 0) { diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 3f4157c..fecd22f 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -1,11 +1,22 @@ import { pool } from './mysql'; +// 统一服务端错误日志(替代静默吞错,保证资金/结算异常可观测) +function logServerError(scope: string, err: any): void { + try { + const msg = err && err.message ? err.message : String(err); + console.error(`[SERVER_ERROR][${scope}] ${msg}`); + } catch (_) {} +} + export interface ActivityRow { id: string; creatorId: number; title: string; gameCategory: string; location?: string; + startTime?: string; + creatorNickname?: string; + creatorAvatar?: string; payType: number; totalFeeCents: number; perPersonFeeCents: number; @@ -21,14 +32,18 @@ export class MysqlRepository { async getActivities(): Promise { const [actRows]: any = await pool.query(` SELECT - id, creator_id as creatorId, title, game_category as gameCategory, - location, pay_type as payType, total_fee_cents as totalFeeCents, - per_person_fee_cents as perPersonFeeCents, max_participants as maxParticipants, - allow_dynamic_price as allowDynamicPrice, price_adjust_rule as priceAdjustRule, - COALESCE(status, 'RECRUITING') as status, - created_at as createdAt - FROM activities - ORDER BY created_at DESC + a.id, a.creator_id as creatorId, a.title, a.game_category as gameCategory, + a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents, + a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants, + a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule, + COALESCE(a.status, 'RECRUITING') as status, + a.created_at as createdAt, + u.nickname as creatorNickname, + u.avatar as creatorAvatar + FROM activities a + LEFT JOIN users u ON a.creator_id = u.id + WHERE (u.status IS NULL OR u.status != '已封禁') + ORDER BY a.created_at DESC `); const [ptRows]: any = await pool.query(` @@ -45,7 +60,17 @@ export class MysqlRepository { return { ...act, allowDynamicPrice: Boolean(act.allowDynamicPrice), - participants: parts.map((p: any) => ({ ...p, checkinStatus: Boolean(p.checkinStatus) })), + // 列表接口严禁下发核销码明文,防止任意访问者批量窃取后盗刷核销 + participants: parts.map((p: any) => ({ + id: p.id, + activityId: p.activityId, + userId: p.userId, + payAmountCents: p.payAmountCents, + checkinStatus: Boolean(p.checkinStatus), + verifiedAt: p.verifiedAt, + verifiedBy: p.verifiedBy, + createdAt: p.createdAt, + })), joinedCount: parts.length, }; }); @@ -54,13 +79,16 @@ export class MysqlRepository { async getActivityById(id: string): Promise { const [actRows]: any = await pool.query(` SELECT - id, creator_id as creatorId, title, game_category as gameCategory, - location, pay_type as payType, total_fee_cents as totalFeeCents, - per_person_fee_cents as perPersonFeeCents, max_participants as maxParticipants, - allow_dynamic_price as allowDynamicPrice, price_adjust_rule as priceAdjustRule, - COALESCE(status, 'RECRUITING') as status - FROM activities - WHERE id = ? + a.id, a.creator_id as creatorId, a.title, a.game_category as gameCategory, + a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents, + a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants, + a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule, + COALESCE(a.status, 'RECRUITING') as status, + u.nickname as creatorNickname, + u.avatar as creatorAvatar + FROM activities a + LEFT JOIN users u ON a.creator_id = u.id + WHERE a.id = ? `, [id]); if (!actRows || actRows.length === 0) return null; @@ -90,31 +118,47 @@ export class MysqlRepository { title: string; gameCategory: string; location?: string; + startTime?: string; payType?: number; totalFeeCents?: number; perPersonFeeCents?: number; maxParticipants?: number; + allowDynamicPrice?: number | boolean; + priceAdjustRule?: string; }): Promise { + const [userRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [data.creatorId]); + if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) { + const err: any = new Error('您的账号已被管理员封禁,无法发起组局或发布活动'); + err.status = 403; + err.code = 40300; + throw err; + } + 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 allowDynamicPrice = data.allowDynamicPrice !== undefined ? (data.allowDynamicPrice ? 1 : 0) : 1; + const priceAdjustRule = data.priceAdjustRule || 'UNIFIED_HIGH'; await pool.query(` INSERT INTO activities - (id, creator_id, title, game_category, location, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'UNIFIED_HIGH') + (id, creator_id, title, game_category, location, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ id, data.creatorId, data.title, data.gameCategory, data.location || '', + data.startTime || '', payType, totalFeeCents, perPersonFeeCents, maxParticipants, + allowDynamicPrice, + priceAdjustRule, ]); return this.getActivityById(id); @@ -199,57 +243,109 @@ export class MysqlRepository { } async joinActivity(activityId: string, params: { userId: number; payAmountCents: number }): Promise { - const act = await this.getActivityById(activityId); - if (!act) throw new Error('活动不存在'); - if (act.maxParticipants > 0 && act.joinedCount >= act.maxParticipants) { - throw new Error('活动报名人数已满'); - } - const alreadyJoined = act.participants.some((p: any) => p.userId === params.userId); - if (alreadyJoined) throw new Error('用户已报名该活动'); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); - // 生成当前活动内唯一的 4 位纯数字核销码 (1000 - 9999) - const existingCodes = new Set(act.participants.map((p: any) => p.verifyCode)); - let verifyCode = ''; - for (let i = 0; i < 100; i++) { - const code = String(Math.floor(1000 + Math.random() * 9000)); - if (!existingCodes.has(code)) { - verifyCode = code; - break; + // 1. 封禁校验(事务内) + const [userRows]: any = await conn.query('SELECT status FROM users WHERE id = ?', [params.userId]); + if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) { + const err: any = new Error('您的账号已被管理员封禁,无法报名参加活动'); + err.status = 403; + err.code = 40300; + throw err; } + + // 2. 行级锁锁定活动,串行化同一活动的并发报名,从根上杜绝超卖 + const [actRows]: any = await conn.query( + 'SELECT id, max_participants FROM activities WHERE id = ? FOR UPDATE', + [activityId] + ); + if (!actRows || actRows.length === 0) throw new Error('活动不存在'); + const maxParticipants = Number(actRows[0].max_participants || 0); + + // 3. 锁内以「当前读 (FOR UPDATE)」拉取该活动全部报名明细: + // - 当前读不受 REPEATABLE READ 快照影响,必然看到最新已提交数据,名额判断才准确; + // - 同时复用于防重复报名与核销码去重。 + const [ptRows]: any = await conn.query( + 'SELECT id, user_id, verify_code FROM participants WHERE activity_id = ? FOR UPDATE', + [activityId] + ); + const rowsList: any[] = ptRows || []; + const joinedCount = rowsList.length; + if (maxParticipants > 0 && joinedCount >= maxParticipants) { + throw new Error('活动报名人数已满'); + } + + // 4. 防重复报名(与 DB 唯一约束 uq_activity_user 双保险) + if (rowsList.some((r: any) => Number(r.user_id) === Number(params.userId))) { + throw new Error('用户已报名该活动'); + } + + // 5. 生成活动内唯一的 4 位纯数字核销码 (1000 - 9999) + const usedCodes = new Set(rowsList.map((r: any) => String(r.verify_code))); + let verifyCode = ''; + for (let i = 0; i < 100; i++) { + const code = String(Math.floor(1000 + Math.random() * 9000)); + if (!usedCodes.has(code)) { + verifyCode = code; + break; + } + } + if (!verifyCode) { + for (let n = 1000; n <= 9999; n++) { + if (!usedCodes.has(String(n))) { + verifyCode = String(n); + break; + } + } + } + if (!verifyCode) { + const err: any = new Error('该活动核销码已用尽,无法继续报名'); + err.status = 409; + err.code = 40900; + throw err; + } + + const ptId = `pt_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + await conn.query(` + INSERT INTO participants + (id, activity_id, user_id, pay_amount_cents, verify_code, checkin_status) + VALUES (?, ?, ?, ?, ?, 0) + `, [ptId, activityId, params.userId, params.payAmountCents, 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]); + + await conn.commit(); + + // 7. 提交成功后再写入核销码缓存索引 act:verify:{activity_id}:{verify_code} -> participant_id (TTL 24h) + const cacheKey = `act:verify:${activityId}:${verifyCode}`; + this.verifyCodeCache.set(cacheKey, { + participantId: ptId, + userId: params.userId, + expireAt: Date.now() + 24 * 3600 * 1000 + }); + + return { + id: ptId, + activityId, + userId: params.userId, + payAmountCents: params.payAmountCents, + verifyCode, + cacheKey, + checkinStatus: false, + }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); } - if (!verifyCode) verifyCode = String(Math.floor(1000 + Math.random() * 9000)); - - const ptId = `pt_${Date.now()}_${Math.floor(Math.random() * 1000)}`; - await pool.query(` - INSERT INTO participants - (id, activity_id, user_id, pay_amount_cents, verify_code, checkin_status) - VALUES (?, ?, ?, ?, ?, 0) - `, [ptId, activityId, params.userId, params.payAmountCents, verifyCode]); - - // 建立核销码缓存索引 act:verify:{activity_id}:{verify_code} -> participant_id (TTL 24h) - const cacheKey = `act:verify:${activityId}:${verifyCode}`; - this.verifyCodeCache.set(cacheKey, { - participantId: ptId, - userId: params.userId, - expireAt: Date.now() + 24 * 3600 * 1000 - }); - - // 写入一条财务流水 - 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()}`, params.payAmountCents / 100, `用户 ${params.userId}`, activityId]); - - return { - id: ptId, - activityId, - userId: params.userId, - payAmountCents: params.payAmountCents, - verifyCode, - cacheKey, - checkinStatus: false, - }; } async verifyCheckin(activityId: string, verifyCode: string, operatorId: number): Promise { @@ -599,7 +695,78 @@ export class MysqlRepository { } async getCompanionById(id: number | string): Promise { - const c = await this.resolveCompanionRow(id); + let c = await this.resolveCompanionRow(id); + if (!c) { + const rawId = String(id).trim(); + const numId = Number(rawId); + let postRow: any = null; + let userRow: any = null; + let cmtRow: any = null; + try { + if (!isNaN(numId) && numId > 0) { + const [pRows]: any = await pool.query( + 'SELECT * FROM posts WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', + [numId] + ); + if (pRows && pRows.length > 0) postRow = pRows[0]; + const [uRows]: any = await pool.query( + 'SELECT * FROM users WHERE id = ? LIMIT 1', + [numId] + ); + if (uRows && uRows.length > 0) userRow = uRows[0]; + const [cRows]: any = await pool.query( + 'SELECT * FROM post_comments WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', + [numId] + ); + if (cRows && cRows.length > 0) cmtRow = cRows[0]; + } else { + const [pRows]: any = await pool.query( + 'SELECT * FROM posts WHERE author = ? OR id = ? ORDER BY created_at DESC LIMIT 1', + [rawId, rawId] + ); + if (pRows && pRows.length > 0) postRow = pRows[0]; + const [uRows]: any = await pool.query( + 'SELECT * FROM users WHERE nickname = ? LIMIT 1', + [rawId] + ); + if (uRows && uRows.length > 0) userRow = uRows[0]; + const [cRows]: any = await pool.query( + 'SELECT * FROM post_comments WHERE id = ? OR author = ? ORDER BY created_at DESC LIMIT 1', + [rawId, rawId] + ); + if (cRows && cRows.length > 0) cmtRow = cRows[0]; + } + } catch (_) {} + + let postImages: string[] = []; + try { + if (postRow?.images) { + postImages = typeof postRow.images === 'string' ? JSON.parse(postRow.images) : postRow.images; + } + } catch (_) {} + + c = { + id: userRow?.id || postRow?.user_id || cmtRow?.user_id || (!isNaN(numId) && numId > 0 ? numId : 2005), + user_id: userRow?.id || postRow?.user_id || cmtRow?.user_id || null, + name: postRow?.author || userRow?.nickname || cmtRow?.author || '同城认证达人', + avatar: postRow?.avatar || userRow?.avatar || cmtRow?.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300', + gender: userRow?.gender === '男' ? '男' : '女', + age: 22, + track: postRow?.category === 'game' ? '线上电竞' : '同城陪吃', + rank_name: postRow?.tag || userRow?.role || '同城认证达人', + hourly_rate: postRow?.category === 'game' ? 88 : 128, + voice_duration: 16, + voice_title: '真实原声介绍', + rating: 4.96, + order_count: Number(userRow?.order_count) || 86, + status: '接单中', + audit_status: '已通过', + tags: postRow?.topics || JSON.stringify(['同城约伴', '随和健谈', '准时守信']), + custom_bio: postRow?.content || userRow?.signature || cmtRow?.content || '专业同城生活陪伴,热情礼貌,守时诚信。', + custom_city: postRow?.location || '北京市 · 朝阳/海淀', + custom_photos: postImages.length > 0 ? postImages : null + }; + } if (!c) return null; const game = c.track === 'OFFLINE_MEAL' ? '美食陪吃' : (c.track === 'OFFLINE_SING' ? '同城陪唱' : (c.track === 'OFFLINE_DRINK' ? '下午茶陪喝' : (c.track === 'OFFLINE_PLAY' ? '同城陪玩' : (c.track || c.game || '同城生活陪伴')))); const price = Number(c.hourly_rate) || 68; @@ -662,12 +829,12 @@ export class MysqlRepository { nickname: c.name, name: c.name, avatar: c.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300', - coverImage: c.avatar || 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800', + coverImage: (c.custom_photos && c.custom_photos[0]) || c.avatar || 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800', gender: c.gender === '男' ? 'male' : 'female', age: Number(c.age) || 22, game: game, track: c.track || 'OFFLINE_MEAL', - city: '北京市 · 朝阳/海淀', + city: c.custom_city || '北京市 · 朝阳/海淀', rankTag: c.rank_name || '认证大神', levelTag: '金牌认证', price: price, @@ -682,9 +849,9 @@ export class MysqlRepository { voiceDuration: Number(c.voice_duration) || 15, voiceType: 'voice', voiceTitle: c.voice_title || '语音招呼', - bio: '专业同城生活陪伴,热情礼貌,守时诚信。', + bio: c.custom_bio || '专业同城生活陪伴,热情礼貌,守时诚信。', tags: parsedTags, - photos: [ + photos: c.custom_photos || [ c.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400', 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400', 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=400' @@ -725,6 +892,19 @@ export class MysqlRepository { return this.getCompanionById(comp.id); } + /** + * 解析大神归属关系(用于资料/服务等写接口的越权校验)。 + * ownerUserId 为大神的真实登录用户ID(companions.user_id),历史数据可能为 NULL。 + */ + async getCompanionOwnership(id: number | string): Promise<{ companionId: number; ownerUserId: number | null } | null> { + const comp = await this.resolveCompanionRow(id); + if (!comp) return null; + return { + companionId: Number(comp.id), + ownerUserId: comp.user_id != null ? Number(comp.user_id) : null, + }; + } + async applyCompanion(data: { userId: number; name: string; @@ -767,7 +947,7 @@ export class MysqlRepository { return this.getCompanionById(res.insertId); } - async auditCompanion(id: number, action: 'approve' | 'reject', reason?: string): Promise { + async auditCompanion(id: number, action: 'approve' | 'reject' | 'revoke', reason?: string): Promise { try { await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status"); } catch (_) {} @@ -775,9 +955,15 @@ export class MysqlRepository { await pool.query('ALTER TABLE users ADD COLUMN is_expert TINYINT DEFAULT 0'); } catch (_) {} - const auditStatus = action === 'approve' ? '已通过' : '已驳回'; - const status = action === 'approve' ? '接单中' : '已驳回'; - const rejectReason = action === 'reject' ? (reason || '提交的资质或语音未通过人工核验,请修改后重新提交') : ''; + const isApprove = action === 'approve'; + const isRevoke = action === 'revoke'; + const auditStatus = isApprove ? '已通过' : '已驳回'; + const status = isApprove ? '接单中' : (isRevoke ? '休息中' : '已驳回'); + const rejectReason = isApprove + ? '' + : isRevoke + ? (reason || '平台已解除您的大神认证资质,请整改后重新提交认证') + : (reason || '提交的资质或语音未通过人工核验,请修改后重新提交'); await pool.query(` UPDATE companions @@ -789,11 +975,19 @@ export class MysqlRepository { const comp = rows && rows[0]; // 联动更新关联用户的角色与大神状态及双身份标识 role_flags (玩家:1, 大神:2, 双身份:3) - if (comp && comp.user_id) { - const uId = Number(comp.user_id); + if (comp) { + let uId = Number(comp.user_id || 0); + if (uId <= 0 && comp.name) { + try { + const [uRows]: any = await pool.query('SELECT id FROM users WHERE nickname = ? LIMIT 1', [comp.name]); + if (uRows && uRows.length > 0) { + uId = Number(uRows[0].id); + } + } catch (_) {} + } if (uId > 0) { try { - if (action === 'approve') { + if (isApprove) { await pool.query('UPDATE users SET role = "认证大神", is_expert = 1 WHERE id = ?', [uId]); await pool.query('UPDATE users SET role_flags = 3 WHERE id = ?', [uId]).catch(() => {}); } else { @@ -801,7 +995,7 @@ export class MysqlRepository { await pool.query('UPDATE users SET role_flags = 1 WHERE id = ?', [uId]).catch(() => {}); } } catch (_) { - if (action === 'approve') { + if (isApprove) { await pool.query('UPDATE users SET role = "认证大神" WHERE id = ?', [uId]); } else { await pool.query('UPDATE users SET role = "普通用户" WHERE id = ?', [uId]); @@ -815,8 +1009,12 @@ export class MysqlRepository { auditStatus, status, rejectReason, - certStatus: action === 'approve' ? 2 : 3, - message: action === 'approve' ? '资质审核通过,用户已获得大神接单权限' : `已驳回: ${rejectReason}` + certStatus: isApprove ? 2 : 3, + message: isApprove + ? '资质审核通过,用户已获得大神接单权限' + : isRevoke + ? `已解除大神认证资质: ${rejectReason}` + : `已驳回: ${rejectReason}` }; } @@ -938,6 +1136,13 @@ export class MysqlRepository { const id = `ord_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; const orderNo = `PW${Date.now()}${Math.floor(100 + Math.random() * 900)}`; const buyerId = Number(data.buyerId) || 1001; + const [userRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [buyerId]); + if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) { + const err: any = new Error('您的账号已被管理员封禁,无法下单或预约陪玩'); + err.status = 403; + err.code = 40300; + throw err; + } const clientName = data.clientName || '超能电竞玩家'; const expertId = Number(data.expertId) || 101; const companionName = data.companionName || '同城大神'; @@ -1052,6 +1257,40 @@ export class MysqlRepository { return rows[0] || null; } + /** + * 解析订单归属关系(买家 / 大神),用于写接口的身份越权校验。 + * 大神身份采用「并集」策略:claimed_by(抢单人)∪ expert_id ∪ companions.user_id, + * 以兼容历史演示数据中 expert_id 可能是同伴ID或用户ID的混用情况。 + */ + async getOrderOwnership(id: string): Promise { + const [rows]: any = await pool.query(` + SELECT o.id, o.order_no as orderNo, o.buyer_id as buyerId, o.expert_id as expertId, + o.claimed_by as claimedBy, o.status, o.companion_name as companionName, + c.user_id as companionUserId + FROM orders o + LEFT JOIN companions c ON c.id = o.expert_id + WHERE o.id = ? OR o.order_no = ? + LIMIT 1 + `, [id, id]); + if (!rows || rows.length === 0) return null; + const r = rows[0]; + const companionIds = Array.from( + new Set( + [r.claimedBy, r.expertId, r.companionUserId] + .map((v: any) => Number(v)) + .filter((v: number) => v > 0) + ) + ); + return { + id: r.id, + orderNo: r.orderNo, + buyerId: Number(r.buyerId) || 0, + companionName: r.companionName || '', + status: r.status, + companionIds, + }; + } + // 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口) async applyCompleteOrder(orderId: string): Promise { await pool.query( @@ -1114,20 +1353,39 @@ export class MysqlRepository { } async completeOrderWithSettlement(id: string): Promise { - await pool.query("UPDATE orders SET status = '已完成' WHERE id = ? OR order_no = ?", [id, id]); - const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]); - const order = rows && rows[0]; - if (!order) return null; - + const conn = await pool.getConnection(); try { - const [settledRows]: any = await pool.query( + await conn.beginTransaction(); + + // 行级锁锁定订单,串行化并发完成/结算(超时自动完成、买家确认、评价触发 可能并发) + const [rows]: any = await conn.query('SELECT * FROM orders WHERE id = ? OR order_no = ? FOR UPDATE', [id, id]); + const order = rows && rows[0]; + if (!order) { + await conn.rollback(); + return null; + } + + // 状态闸门:仅「已进入履约/待确认」等具备已完成服务语义的订单可结算; + // 严禁对未支付、未接单、已取消、已退款的订单越权触发资金结算(防前端直接结算套现)。 + const settleBlocked = ['待支付', '待接单', '已取消', '已退款', '已全额退款']; + if (settleBlocked.includes(String(order.status))) { + const e: any = new Error(`订单当前状态【${order.status}】不可结算,已阻止越权触发资金结算`); + e.status = 409; + e.code = 40900; + throw e; + } + + // 幂等:持锁后再次确认是否已结算,杜绝并发重复结算(同一订单只结算一次) + const [settledRows]: any = await conn.query( "SELECT id FROM finance_records WHERE related_id = ? AND type = '大神完单收益结算' LIMIT 1", [String(order.id)] ); const alreadySettled = settledRows && settledRows.length > 0; + await conn.query("UPDATE orders SET status = '已完成' WHERE id = ?", [order.id]); + if (!alreadySettled) { - const [cfgRows]: any = await pool.query('SELECT * FROM finance_config WHERE id = 1 LIMIT 1'); + const [cfgRows]: any = await conn.query('SELECT * FROM finance_config WHERE id = 1 LIMIT 1'); const cfg = (cfgRows && cfgRows[0]) || { platform_rate_percent: 10, first_referral_percent: 5, @@ -1144,17 +1402,17 @@ export class MysqlRepository { const comp = await this.resolveCompanionRow(order.expert_id || order.companion_name || 101); if (comp) { - await pool.query('UPDATE companions SET order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', [comp.id]); + 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); - await pool.query( + 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 || ''] ); const buyerId = Number(order.buyer_id || 1001); - await pool.query( + await conn.query( 'UPDATE users SET order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', [buyerId] ); @@ -1162,7 +1420,7 @@ export class MysqlRepository { const ts = Date.now(); const rand = () => Math.floor(100 + Math.random() * 900); - await pool.query( + await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, '大神完单收益结算', ?, 'IN', ?, ?, NOW())`, [ @@ -1176,7 +1434,7 @@ export class MysqlRepository { ); if (platformFee > 0) { - await pool.query( + await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, 0, ?, ?, 'IN', ?, ?, NOW())`, [ @@ -1191,7 +1449,7 @@ export class MysqlRepository { } // 二级分销自动返佣结算 - const [buyerRows]: any = await pool.query('SELECT id, nickname, inviter_id FROM users WHERE id = ? LIMIT 1', [buyerId]); + const [buyerRows]: any = await conn.query('SELECT id, nickname, inviter_id FROM users WHERE id = ? LIMIT 1', [buyerId]); const buyer = buyerRows && buyerRows[0]; const fromUserName = buyer?.nickname || order.client_name || '客户玩家'; const orderNo = order.order_no || String(order.id); @@ -1200,13 +1458,13 @@ export class MysqlRepository { const lv1UserId = Number(buyer.inviter_id); const comm1 = Number(((orderAmount * firstRefPct) / 100).toFixed(2)); if (comm1 > 0 && lv1UserId > 0 && lv1UserId !== buyerId) { - await pool.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm1, lv1UserId]); - await pool.query( + await conn.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm1, lv1UserId]); + await conn.query( `INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, '已入账', NOW())`, [`dst_1_${ts}_${rand()}`, lv1UserId, buyerId, fromUserName, orderNo, orderAmount, comm1, firstRefPct] ); - await pool.query( + await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())`, [ @@ -1221,19 +1479,19 @@ export class MysqlRepository { ); } - const [lv1Rows]: any = await pool.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [lv1UserId]); + const [lv1Rows]: any = await conn.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [lv1UserId]); const lv1User = lv1Rows && lv1Rows[0]; if (lv1User && lv1User.inviter_id && secondRefPct > 0) { const lv2UserId = Number(lv1User.inviter_id); const comm2 = Number(((orderAmount * secondRefPct) / 100).toFixed(2)); if (comm2 > 0 && lv2UserId > 0 && lv2UserId !== buyerId && lv2UserId !== lv1UserId) { - await pool.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm2, lv2UserId]); - await pool.query( + await conn.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm2, lv2UserId]); + await conn.query( `INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 2, ?, '已入账', NOW())`, [`dst_2_${ts}_${rand()}`, lv2UserId, buyerId, fromUserName, orderNo, orderAmount, comm2, secondRefPct] ); - await pool.query( + await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())`, [ @@ -1251,10 +1509,17 @@ export class MysqlRepository { } } } - } catch (_) {} - const [updatedRows]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]); - return updatedRows[0] || order; + await conn.commit(); + + const [updatedRows]: any = await conn.query('SELECT * FROM orders WHERE id = ?', [order.id]); + return updatedRows[0] || order; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } } async getOrdersPool(): Promise { @@ -1289,17 +1554,70 @@ export class MysqlRepository { } async claimOrder(orderId: string, expertData: { expertId: number; companionName: string; expertAvatar?: string }): Promise { - if (expertData.expertId) { - const comp = await this.resolveCompanionRow(expertData.expertId); - if (comp && comp.audit_status && comp.audit_status !== '已通过') { - throw new Error('大神档案未通过实名认证审核,无法接单'); + const expertId = Number(expertData.expertId) || 0; + if (expertId > 0) { + const comp = await this.resolveCompanionRow(expertId); + if (comp) { + if (comp.audit_status && comp.audit_status !== '已通过') { + throw new Error('大神档案未通过实名认证审核,无法接单'); + } + const [uRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [comp.user_id || comp.id]); + if (uRows && uRows.length > 0 && (uRows[0].status === '已封禁' || uRows[0].status === '封禁')) { + const err: any = new Error('您的账号已被管理员封禁,无法接单'); + err.status = 403; + err.code = 40300; + throw err; + } } } - await pool.query(` + if (!expertId) { + const err: any = new Error('缺少接单大神身份,无法接单'); + err.status = 400; + err.code = 40000; + throw err; + } + + const blocked = ['待付款', '待支付', '已完成', '已取消', '已关闭', '已退款', '已全额退款', '争议仲裁中']; + const [curRows]: any = await pool.query('SELECT id, status, claimed_by FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]); + const cur = curRows && curRows[0]; + if (!cur) { + const err: any = new Error('订单不存在'); + err.status = 404; + err.code = 40400; + throw err; + } + if (blocked.includes(String(cur.status))) { + const err: any = new Error(`当前订单状态(${cur.status})不可接单`); + err.status = 409; + err.code = 40900; + throw err; + } + const claimedBy = Number(cur.claimed_by || 0); + if (claimedBy && claimedBy !== expertId) { + const err: any = new Error('该订单已被其他大神接单'); + err.status = 409; + err.code = 40900; + throw err; + } + + // 原子 CAS:以 claimed_by 作为唯一认领标记,并发抢单时数据库行级更新保证只有一个赢家。 + // 未认领(claimed_by IS NULL) 或 本人重入(claimed_by = 自己) 才允许写入。 + const [res]: any = await pool.query(` UPDATE orders - SET status = '履约中', expert_id = ?, companion_name = ?, expert_avatar = ? - WHERE id = ? OR order_no = ? - `, [expertData.expertId || 101, expertData.companionName || '我 (接单大神)', expertData.expertAvatar || '', orderId, orderId]); + SET status = '履约中', expert_id = ?, companion_name = ?, expert_avatar = ?, + claimed_by = ?, claimed_at = NOW(), paid_at = COALESCE(paid_at, NOW()) + WHERE (id = ? OR order_no = ?) + AND status NOT IN ('待付款', '待支付', '已完成', '已取消', '已关闭', '已退款', '已全额退款', '争议仲裁中') + AND (claimed_by IS NULL OR claimed_by = ?) + `, [expertId, expertData.companionName || '我 (接单大神)', expertData.expertAvatar || '', expertId, orderId, orderId, expertId]); + + if (!res || res.affectedRows !== 1) { + const err: any = new Error('该订单已被其他大神抢先接单,请刷新订单池'); + err.status = 409; + err.code = 40900; + throw err; + } + const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [orderId, orderId]); return rows[0] || null; } @@ -1990,7 +2308,15 @@ export class MysqlRepository { val = JSON.parse(val); } catch (_) {} } - result[r.configKey] = val; + if (r.configKey === 'wechat_pay_config' && val && typeof val === 'object') { + result[r.configKey] = { + mode: val.mode || 'AUTO', + appId: val.appId || '', + mchIdConfigured: Boolean(val.mchId && val.apiV3Key && val.privateKey) + }; + } else { + result[r.configKey] = val; + } } return result; } @@ -2034,29 +2360,48 @@ export class MysqlRepository { } async updatePlatformConfig(configKey: string, data: { name?: string; valueJson?: any; description?: string; status?: number }): Promise { - const fields: string[] = []; - const values: any[] = []; + const [existRows]: any = await pool.query( + 'SELECT id FROM platform_configs WHERE config_key = ?', + [configKey] + ); - if (data.name !== undefined) { - fields.push('name = ?'); - values.push(data.name); - } - if (data.valueJson !== undefined) { - fields.push('value_json = ?'); - values.push(JSON.stringify(data.valueJson)); - } - if (data.description !== undefined) { - fields.push('description = ?'); - values.push(data.description); - } - if (data.status !== undefined) { - fields.push('status = ?'); - values.push(Number(data.status)); - } + if (!existRows || existRows.length === 0) { + await pool.query( + `INSERT INTO platform_configs (config_key, name, value_json, description, status) + VALUES (?, ?, ?, ?, ?)`, + [ + configKey, + data.name || configKey, + JSON.stringify(data.valueJson !== undefined ? data.valueJson : {}), + data.description || '', + data.status !== undefined ? Number(data.status) : 1 + ] + ); + } else { + const fields: string[] = []; + const values: any[] = []; - if (fields.length > 0) { - values.push(configKey); - await pool.query(`UPDATE platform_configs SET ${fields.join(', ')} WHERE config_key = ?`, values); + if (data.name !== undefined) { + fields.push('name = ?'); + values.push(data.name); + } + if (data.valueJson !== undefined) { + fields.push('value_json = ?'); + values.push(JSON.stringify(data.valueJson)); + } + if (data.description !== undefined) { + fields.push('description = ?'); + values.push(data.description); + } + if (data.status !== undefined) { + fields.push('status = ?'); + values.push(Number(data.status)); + } + + if (fields.length > 0) { + values.push(configKey); + await pool.query(`UPDATE platform_configs SET ${fields.join(', ')} WHERE config_key = ?`, values); + } } const [rows]: any = await pool.query(` @@ -2101,6 +2446,16 @@ export class MysqlRepository { try { await conn.beginTransaction(); + // 幂等防线:仅允许"待平台仲裁"工单被处理,防止重复裁决导致重复退款 + const [curArbRows]: any = await conn.query('SELECT status FROM arbitrations WHERE id = ? FOR UPDATE', [id]); + if (!curArbRows || curArbRows.length === 0) { + throw new Error('仲裁工单不存在'); + } + const curArbStatus = curArbRows[0].status; + if (curArbStatus && curArbStatus !== '待平台仲裁') { + throw new Error(`该仲裁工单已处理完毕(当前状态:${curArbStatus}),不可重复裁决`); + } + let statusText = '申诉已驳回'; if (decision === 'refund_full') statusText = '已全额退款用户'; if (decision === 'refund_partial') statusText = '已部分退款用户'; @@ -2201,7 +2556,9 @@ export class MysqlRepository { if (shouldSettleOrderId) { try { await this.completeOrderWithSettlement(shouldSettleOrderId); - } catch (_) {} + } catch (e) { + logServerError('handleArbitration.settle', e); + } } let statusText = '申诉已驳回'; @@ -2521,29 +2878,105 @@ export class MysqlRepository { // 大盘统计 async getDashboardStats(): Promise { - const [ordRows]: any = await pool.query('SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total FROM orders'); - const [compRows]: any = await pool.query('SELECT COUNT(*) as count FROM companions WHERE status = "接单中"'); + // 计入成交的有效订单状态(已付款或已履约),排除未付款/已取消/已退款 + const paidStatuses = "('待接单','履约中','服务中','已到场核销','待确认','已完成')"; + + // 1. 今日成交 GMV 与订单数 + let todayGmv = 0; + let todayOrders = 0; + try { + const [todayRows]: any = await pool.query(` + SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total + FROM orders + WHERE DATE(created_at) = CURDATE() AND status IN ${paidStatuses} + `); + todayGmv = Number(Number(todayRows[0]?.total || 0).toFixed(2)); + todayOrders = Number(todayRows[0]?.count || 0); + } catch (e) { + logServerError('getDashboardStats.today', e); + } + + // 2. 接单中的大神数量 + let onlineCompanions = 0; + try { + const [compRows]: any = await pool.query('SELECT COUNT(*) as count FROM companions WHERE status = "接单中"'); + onlineCompanions = Number(compRows[0]?.count || 0); + } catch (e) { + logServerError('getDashboardStats.companions', e); + } + + // 3. 今日活跃用户(下单或参与组局去重) + let activeUsers = 0; + try { + const [auRows]: any = await pool.query(` + SELECT COUNT(*) as count FROM ( + SELECT buyer_id AS uid FROM orders WHERE DATE(created_at) = CURDATE() + UNION + SELECT user_id AS uid FROM participants WHERE DATE(created_at) = CURDATE() + ) t + `); + activeUsers = Number(auRows[0]?.count || 0); + } catch (e) { + logServerError('getDashboardStats.activeUsers', e); + try { + const [auRows]: any = await pool.query( + 'SELECT COUNT(DISTINCT buyer_id) as count FROM orders WHERE DATE(created_at) = CURDATE()' + ); + activeUsers = Number(auRows[0]?.count || 0); + } catch (e2) { + logServerError('getDashboardStats.activeUsersFallback', e2); + } + } + + // 4. 近 7 日 GMV 走势(缺失日期补 0) + const trendMap: Record = {}; + try { + const [trendRows]: any = await pool.query(` + SELECT DATE_FORMAT(created_at, '%m-%d') as d, COALESCE(SUM(amount), 0) as total + FROM orders + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND status IN ${paidStatuses} + GROUP BY DATE_FORMAT(created_at, '%m-%d') + `); + for (const r of trendRows) { + trendMap[r.d] = Number(Number(r.total || 0).toFixed(2)); + } + } catch (e) { + logServerError('getDashboardStats.trend', e); + } + const recentGmvTrend: { date: string; gmv: number }[] = []; + const nowTs = Date.now(); + for (let i = 6; i >= 0; i--) { + const d = new Date(nowTs - i * 86400000); + const label = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + recentGmvTrend.push({ date: label, gmv: trendMap[label] || 0 }); + } + + // 5. 品类(赛道)成交额分布 + let categoryDistribution: { name: string; value: number }[] = []; + try { + const [catRows]: any = await pool.query(` + SELECT track as name, COALESCE(SUM(amount), 0) as value + FROM orders + WHERE status IN ${paidStatuses} + GROUP BY track + ORDER BY value DESC + LIMIT 8 + `); + categoryDistribution = catRows.map((r: any) => ({ + name: r.name || '其他', + value: Number(Number(r.value || 0).toFixed(2)), + })); + } catch (e) { + logServerError('getDashboardStats.category', e); + } return { - todayGmv: 58920, - todayOrders: 142, - onlineCompanions: compRows[0]?.count || 86, - activeUsers: 1250, - recentGmvTrend: [ - { date: '09-22', gmv: 32000 }, - { date: '09-23', gmv: 38500 }, - { date: '09-24', gmv: 42100 }, - { date: '09-25', gmv: 49800 }, - { date: '09-26', gmv: 53200 }, - { date: '09-27', gmv: 56400 }, - { date: '09-28', gmv: 58920 } - ], - categoryDistribution: [ - { name: '同城陪吃', value: 42 }, - { name: '同城陪喝', value: 25 }, - { name: 'KTV陪唱', value: 21 }, - { name: '线上电竞', value: 12 } - ] + todayGmv, + todayOrders, + onlineCompanions, + activeUsers, + recentGmvTrend, + categoryDistribution, }; } @@ -2626,10 +3059,50 @@ export class MysqlRepository { async updateUserStatus(id: number, status: '正常' | '已封禁'): Promise { await pool.query('UPDATE users SET status = ? WHERE id = ?', [status, id]); + if (status === '已封禁') { + try { + await pool.query("UPDATE activities SET status = 'CANCELLED' WHERE creator_id = ? AND status = 'RECRUITING'", [id]); + await pool.query("UPDATE companions SET is_online = 0 WHERE user_id = ?", [id]); + } catch (_) {} + } const [rows]: any = await pool.query('SELECT id, user_no as userNo, nickname, status FROM users WHERE id = ?', [id]); return rows[0]; } + async deleteUser(id: number): Promise { + const [existing]: any = await pool.query('SELECT id, user_no as userNo, nickname FROM users WHERE id = ?', [id]); + if (!existing || existing.length === 0) { + return false; + } + try { + await pool.query('DELETE FROM favorites WHERE user_id = ?', [id]); + } catch (_) {} + try { + await pool.query('DELETE FROM participants WHERE user_id = ?', [id]); + } catch (_) {} + try { + await pool.query('DELETE FROM user_settings WHERE user_id = ?', [id]); + } catch (_) {} + const [result]: any = await pool.query('DELETE FROM users WHERE id = ?', [id]); + return result.affectedRows > 0; + } + + async deleteUsers(ids: number[]): Promise { + if (!ids || ids.length === 0) return 0; + const placeholders = ids.map(() => '?').join(','); + try { + await pool.query(`DELETE FROM favorites WHERE user_id IN (${placeholders})`, ids); + } catch (_) {} + try { + await pool.query(`DELETE FROM participants WHERE user_id IN (${placeholders})`, ids); + } catch (_) {} + try { + await pool.query(`DELETE FROM user_settings WHERE user_id IN (${placeholders})`, ids); + } catch (_) {} + const [result]: any = await pool.query(`DELETE FROM users WHERE id IN (${placeholders})`, ids); + return result.affectedRows || 0; + } + async getUserDetail(id: number): Promise { const [userRows]: any = await pool.query(` SELECT @@ -2789,7 +3262,7 @@ export class MysqlRepository { const rejectReason = companionRow?.reject_reason || ''; const companionStatus = companionRow ? (companionRow.status || '接单中') : (certStatus === 2 ? '接单中' : ''); - const isCertExpert = certStatus === 2 || roleHasExpert; + const isCertExpert = companionRow ? certStatus === 2 : (certStatus === 2 || roleHasExpert); const roleFlags = isCertExpert ? 3 : 1; return { @@ -2819,6 +3292,45 @@ export class MysqlRepository { }; } + async updateUserOpenid(userId: number, openid: string): Promise { + const [res]: any = await pool.query('UPDATE users SET wechat_openid = ? WHERE id = ?', [openid, userId]); + return res.affectedRows > 0; + } + + async findOrCreateUserByPhone(phone: string, openid?: string): Promise { + const cleanPhone = phone.trim(); + const [rows]: any = await pool.query( + `SELECT id, user_no as userNo, nickname, avatar, phone, gender, signature, + role, balance, commission, invited_count as invitedCount, + inviter_id as inviterId, order_count as orderCount, + activity_count as activityCount, status, wechat_openid as wechatOpenid, + created_at as createdAt + FROM users WHERE phone = ? LIMIT 1`, + [cleanPhone] + ); + + if (rows && rows.length > 0) { + const u = rows[0]; + if (openid && (!u.wechatOpenid || u.wechatOpenid !== openid)) { + await pool.query('UPDATE users SET wechat_openid = ? WHERE id = ?', [openid, u.id]); + } + return this.getUserById(u.id); + } + + const userNo = 'U' + Math.floor(100000 + Math.random() * 900000); + const suffix = cleanPhone.length >= 4 ? cleanPhone.slice(-4) : cleanPhone; + const defaultNickname = `微信用户_${suffix}`; + const defaultAvatar = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; + + const [insertRes]: any = await pool.query( + `INSERT INTO users (user_no, nickname, avatar, phone, gender, role, balance, order_count, activity_count, status, wechat_openid) + VALUES (?, ?, ?, ?, '保密', '普通玩家', 0.00, 0, 0, '正常', ?)`, + [userNo, defaultNickname, defaultAvatar, cleanPhone, openid || null] + ); + + return this.getUserById(insertRes.insertId); + } + async updateUserProfile( id: number, data: { nickname?: string; avatar?: string; phone?: string; gender?: string; signature?: string } @@ -2848,27 +3360,46 @@ export class MysqlRepository { if (fields.length > 0) { values.push(id); await pool.query(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`, values); + try { + if (data.nickname && data.nickname.trim()) { + await pool.query('UPDATE post_comments SET author = ? WHERE user_id = ?', [data.nickname.trim(), id]); + await pool.query('UPDATE posts SET author = ? WHERE user_id = ?', [data.nickname.trim(), id]); + } + if (data.avatar && data.avatar.trim()) { + await pool.query('UPDATE post_comments SET avatar = ? WHERE user_id = ?', [data.avatar.trim(), id]); + await pool.query('UPDATE posts SET avatar = ? WHERE user_id = ?', [data.avatar.trim(), id]); + } + } catch (_) {} } return this.getUserById(id); } - async rechargeUserBalance(id: number, amount: number): Promise { + async rechargeUserBalance(id: number, amount: number, bonusAmount: number = 0): Promise { if (isNaN(amount) || amount <= 0) { throw new Error('充值金额必须大于0'); } + const safeBonus = (!isNaN(bonusAmount) && bonusAmount > 0) ? Number(bonusAmount) : 0; + const totalCredit = amount + safeBonus; + const conn = await pool.getConnection(); try { await conn.beginTransaction(); - const [uRows]: any = await conn.query('SELECT nickname, balance FROM users WHERE id = ? FOR UPDATE', [id]); + const [uRows]: any = await conn.query('SELECT nickname, balance, status FROM users WHERE id = ? FOR UPDATE', [id]); if (!uRows || uRows.length === 0) { throw new Error('用户不存在'); } const user = uRows[0]; - const newBalance = Number(user.balance) + amount; + if (user.status === '已封禁' || user.status === '封禁') { + const err: any = new Error('您的账号已被管理员封禁,无法充值'); + err.status = 403; + err.code = 40300; + throw err; + } + const newBalance = Number(user.balance) + totalCredit; await conn.query('UPDATE users SET balance = ? WHERE id = ?', [newBalance, id]); - // 写入财务入账流水 + // 写入财务充值入账流水 const recId = `fin_rc_${Date.now()}`; const recNo = `RC${Date.now()}`; await conn.query(` @@ -2876,11 +3407,23 @@ export class MysqlRepository { VALUES (?, ?, '钱包充值', ?, 'IN', '微信支付', ?, NOW()) `, [recId, recNo, amount, String(id)]); + // 若有活动赠额,写入平台活动赠金财务流水 + if (safeBonus > 0) { + const bonusRecId = `fin_bn_${Date.now() + 1}`; + const bonusRecNo = `BN${Date.now() + 1}`; + await conn.query(` + INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, '充值加赠', ?, 'IN', '平台活动赠金', ?, NOW()) + `, [bonusRecId, bonusRecNo, safeBonus, String(id)]); + } + await conn.commit(); return { id, balance: newBalance.toFixed(2), rechargedAmount: amount.toFixed(2), + bonusAmount: safeBonus.toFixed(2), + totalCredit: totalCredit.toFixed(2), }; } catch (err) { await conn.rollback(); @@ -2890,6 +3433,194 @@ export class MysqlRepository { } } + // ===== 微信支付意图与幂等入账 (支付对账核心) ===== + async createPaymentIntent(data: { + outTradeNo: string; + scene?: string; + userId?: number; + orderId?: string; + title?: string; + amountCents: number; + bonusCents?: number; + }): Promise { + const id = `pi_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; + await pool.query( + `INSERT INTO payment_intents + (id, out_trade_no, scene, user_id, order_id, title, amount_cents, bonus_cents, status, credited, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', 0, NOW()) + ON DUPLICATE KEY UPDATE + scene = VALUES(scene), user_id = VALUES(user_id), order_id = VALUES(order_id), + title = VALUES(title), amount_cents = VALUES(amount_cents), bonus_cents = VALUES(bonus_cents)`, + [ + id, + data.outTradeNo, + data.scene || 'ORDER', + Number(data.userId) || 0, + data.orderId || '', + (data.title || '').slice(0, 120), + Math.max(0, Math.round(Number(data.amountCents) || 0)), + Math.max(0, Math.round(Number(data.bonusCents) || 0)), + ] + ); + return { id, outTradeNo: data.outTradeNo, scene: data.scene || 'ORDER' }; + } + + async getPaymentIntentByOutTradeNo(outTradeNo: string): Promise { + const [rows]: any = await pool.query( + 'SELECT * FROM payment_intents WHERE out_trade_no = ? LIMIT 1', + [outTradeNo] + ); + return (rows && rows[0]) || null; + } + + async getLatestRechargeIntent(userId: number): Promise { + const [rows]: any = await pool.query( + "SELECT * FROM payment_intents WHERE user_id = ? AND scene = 'RECHARGE' ORDER BY (status = 'SUCCESS' AND credited = 0) DESC, created_at DESC LIMIT 1", + [userId] + ); + return (rows && rows[0]) || null; + } + + // 在事务内为充值意图入账(幂等:仅 credited=0 时执行) + private async creditRechargeInTx(conn: any, intent: any): Promise { + if (Number(intent.credited) === 1) return false; + const amountYuan = Number(intent.amount_cents) / 100; + const bonusYuan = Number(intent.bonus_cents) / 100; + const totalYuan = amountYuan + bonusYuan; + const ts = Date.now(); + const rand = () => Math.floor(100 + Math.random() * 900); + + await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [ + totalYuan, + intent.user_id, + ]); + await conn.query( + `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, ?, '钱包充值', ?, 'IN', '微信支付', ?, NOW())`, + [`fin_pr_${ts}_${rand()}`, `RC${ts}${rand()}`, intent.user_id, amountYuan.toFixed(2), intent.out_trade_no] + ); + if (bonusYuan > 0) { + await conn.query( + `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, ?, '充值加赠', ?, 'IN', '平台活动赠金', ?, NOW())`, + [`fin_pb_${ts}_${rand()}`, `BN${ts}${rand()}`, intent.user_id, bonusYuan.toFixed(2), intent.out_trade_no] + ); + } + await conn.query('UPDATE payment_intents SET credited = 1 WHERE out_trade_no = ?', [intent.out_trade_no]); + return true; + } + + /** + * 支付成功后落库对账(由回调或主动查单触发),幂等。 + * RECHARGE -> 入账钱包余额;ORDER -> 订单标记已支付。 + */ + async applyPaymentSuccess( + outTradeNo: string, + opts?: { transactionId?: string; amountTotalCents?: number } + ): Promise { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows]: any = await conn.query( + 'SELECT * FROM payment_intents WHERE out_trade_no = ? FOR UPDATE', + [outTradeNo] + ); + if (!rows || rows.length === 0) { + await conn.rollback(); + return { handled: false, reason: 'intent_not_found', outTradeNo }; + } + const intent = rows[0]; + if (intent.status === 'SUCCESS') { + await conn.rollback(); + return { handled: true, already: true, scene: intent.scene, credited: Number(intent.credited) === 1 }; + } + + await conn.query( + "UPDATE payment_intents SET status = 'SUCCESS', transaction_id = ?, paid_at = NOW() WHERE out_trade_no = ?", + [opts?.transactionId || '', outTradeNo] + ); + + let credited = false; + if (intent.scene === 'RECHARGE') { + credited = await this.creditRechargeInTx(conn, intent); + } else if (intent.scene === 'ORDER' && intent.order_id) { + await conn.query( + `UPDATE orders SET status = CASE WHEN status IN ('待支付','待付款') THEN '履约中' ELSE status END, + paid_at = COALESCE(paid_at, NOW()) + WHERE id = ? OR order_no = ?`, + [intent.order_id, intent.order_id] + ); + } + + await conn.commit(); + return { handled: true, already: false, scene: intent.scene, credited }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + } + + // 充值对账:依据已支付意图幂等入账;若意图尚未成功则显式报错(由路由层先主动查单) + async reconcileRecharge( + userId: number, + outTradeNo?: string + ): Promise { + const intent = outTradeNo + ? await this.getPaymentIntentByOutTradeNo(outTradeNo) + : await this.getLatestRechargeIntent(userId); + if (!intent) { + const err: any = new Error('未找到对应的微信支付订单,无法入账'); + err.status = 400; + err.code = 40000; + throw err; + } + if (String(intent.user_id) !== String(userId)) { + const err: any = new Error('支付订单与当前用户不匹配'); + err.status = 403; + err.code = 40300; + throw err; + } + if (intent.status !== 'SUCCESS') { + const err: any = new Error('微信支付尚未完成,请稍后重试'); + err.status = 400; + err.code = 40000; + throw err; + } + + if (Number(intent.credited) === 0) { + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [cur]: any = await conn.query( + 'SELECT * FROM payment_intents WHERE out_trade_no = ? FOR UPDATE', + [intent.out_trade_no] + ); + if (cur && cur[0]) { + await this.creditRechargeInTx(conn, cur[0]); + } + await conn.commit(); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + } + + const [uRows]: any = await pool.query('SELECT balance FROM users WHERE id = ?', [userId]); + const balance = uRows && uRows[0] ? Number(uRows[0].balance) : 0; + return { + id: userId, + balance: balance.toFixed(2), + rechargedAmount: (Number(intent.amount_cents) / 100).toFixed(2), + bonusAmount: (Number(intent.bonus_cents) / 100).toFixed(2), + totalCredit: ((Number(intent.amount_cents) + Number(intent.bonus_cents)) / 100).toFixed(2), + outTradeNo: intent.out_trade_no, + }; + } + async withdrawUserCommission( id: number, amount: number, @@ -2930,11 +3661,17 @@ export class MysqlRepository { const conn = await pool.getConnection(); try { await conn.beginTransaction(); - const [uRows]: any = await conn.query('SELECT nickname, commission, balance FROM users WHERE id = ? FOR UPDATE', [id]); + const [uRows]: any = await conn.query('SELECT nickname, commission, balance, status FROM users WHERE id = ? FOR UPDATE', [id]); if (!uRows || uRows.length === 0) { throw new Error('用户不存在'); } const user = uRows[0]; + if (user.status === '已封禁' || user.status === '封禁') { + const err: any = new Error('您的账号已被管理员封禁,资金账户已冻结无法提现'); + err.status = 403; + err.code = 40300; + throw err; + } const currentCommission = Number(user.commission || 0); if (currentCommission < amount) { throw new Error(`可提现佣金不足,当前可提现为 ¥${currentCommission.toFixed(2)}`); @@ -3124,8 +3861,31 @@ export class MysqlRepository { }): Promise { const id = `post_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; const userId = Number(data.userId) || 1001; - const author = data.author || '电竞玩家 (我)'; - const avatar = data.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=150'; + const [uStatus]: any = await pool.query('SELECT status FROM users WHERE id = ?', [userId]); + if (uStatus && uStatus.length > 0 && (uStatus[0].status === '已封禁' || uStatus[0].status === '封禁')) { + const err: any = new Error('您的账号已被管理员封禁,无法发布动态'); + err.status = 403; + err.code = 40300; + throw err; + } + let author = data.author ? data.author.trim() : ''; + let avatar = data.avatar ? data.avatar.trim() : ''; + + try { + const [uRows]: any = await pool.query('SELECT nickname, avatar FROM users WHERE id = ? LIMIT 1', [userId]); + if (uRows && uRows.length > 0) { + const dbUser = uRows[0]; + if (!author || ['电竞玩家 (我)', '热心搭子', '电竞玩家', '点击登录 / 注册'].includes(author)) { + author = dbUser.nickname || author; + } + if (!avatar || avatar.includes('photo-1535713875002-d1d0cf377fde')) { + avatar = dbUser.avatar || avatar; + } + } + } catch (_) {} + + author = author || '同城玩家'; + avatar = avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=150'; const tag = data.tag || '同城搭子'; const tagType = data.tagType || 'warm-tag'; const location = data.location || '朝阳区 · 三里屯太古里'; @@ -3204,7 +3964,13 @@ export class MysqlRepository { return { id, likes: rows[0]?.likes || 0, liked }; } - // 13. 订单评价与评分业务 + async viewPost(id: string): Promise { + await pool.query('UPDATE posts SET views = views + 1 WHERE id = ?', [id]); + const [rows]: any = await pool.query('SELECT views FROM posts WHERE id = ?', [id]); + return { id, views: rows[0]?.views || 1 }; + } + + // 13. 订单评价与评分业务(评价归属与结算触发均由服务端校验,杜绝越权/绕过) async createOrderReview(data: { orderId: string; expertId?: number; @@ -3217,33 +3983,65 @@ export class MysqlRepository { }): Promise { const id = `rev_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; const orderId = data.orderId; - let expertId = data.expertId; - if (!expertId) { - const [orderRows]: any = await pool.query('SELECT expert_id FROM orders WHERE id = ? OR order_no = ?', [orderId, orderId]); - expertId = orderRows[0]?.expert_id || 101; + // 1. 校验订单存在并读取权威字段(买家 / 大神 / 状态) + const [orderRows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]); + const order = orderRows && orderRows[0]; + if (!order) { + const err: any = new Error('订单不存在,无法评价'); + err.status = 404; + err.code = 40400; + throw err; } const userId = Number(data.userId) || 1001; + const buyerId = Number(order.buyer_id || 0); + + // 2. 仅下单买家本人可评价,服务端强制校验身份(不再信任前端入参) + if (buyerId > 0 && buyerId !== userId) { + const err: any = new Error('仅下单买家本人可以评价该订单'); + err.status = 403; + err.code = 40300; + throw err; + } + + // 3. 仅「已实际服务/已完成」的订单可评价,杜绝未履约(待付款/待接单等)即触发结算 + const reviewable = ['履约中', '服务中', '已到场核销', '待确认', '已完成']; + if (!reviewable.includes(String(order.status))) { + const err: any = new Error(`当前订单状态(${order.status})不可评价`); + err.status = 400; + err.code = 40000; + throw err; + } + + // 4. 防重复评价(与 DB 唯一约束 uq_order_review 双保险) + const [dupRows]: any = await pool.query( + 'SELECT id FROM order_reviews WHERE order_id IN (?, ?) LIMIT 1', + [String(order.id), String(order.order_no || '')] + ); + if (dupRows && dupRows.length > 0) { + const err: any = new Error('该订单已评价,请勿重复提交'); + err.status = 409; + err.code = 40900; + throw err; + } + + // 5. 大神归属以订单为准,避免评分被伪造挂到他人名下 + const expertId = Number(order.expert_id) || Number(data.expertId) || 101; const userNickname = data.userNickname || '买家用户'; const userAvatar = data.userAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; const rating = Math.max(1, Math.min(5, Number(data.rating) || 5)); const tagsJson = JSON.stringify(data.tags || []); const content = data.content || '体验非常棒,好评!'; + // 6. 先完成结算(幂等且原子),再落库评价,避免"评价已存但结算失败"的半成品状态 + await this.completeOrderWithSettlement(String(order.id)); + await pool.query(` INSERT INTO order_reviews ( id, order_id, expert_id, user_id, user_nickname, user_avatar, rating, tags, content, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) - `, [id, orderId, expertId, userId, userNickname, userAvatar, rating, tagsJson, content]); - - try { - await this.completeOrderWithSettlement(orderId); - } catch (_) { - try { - await pool.query("UPDATE orders SET status = '已完成' WHERE id = ? OR order_no = ?", [orderId, orderId]); - } catch (_) {} - } + `, [id, String(order.id), expertId, userId, userNickname, userAvatar, rating, tagsJson, content]); try { const altId = Number(expertId) > 100 && Number(expertId) < 200 ? Number(expertId) - 100 : (Number(expertId) < 100 ? Number(expertId) + 100 : Number(expertId)); @@ -3334,10 +4132,26 @@ export class MysqlRepository { // 14. 友圈动态评论 async getPostComments(postId: string): Promise { const [rows]: any = await pool.query(` - SELECT id, post_id as postId, user_id as userId, author, avatar, content, created_at as createdAt - FROM post_comments - WHERE post_id = ? - ORDER BY created_at ASC + SELECT + pc.id, + pc.post_id as postId, + pc.user_id as userId, + CASE + WHEN u.nickname IS NOT NULL AND u.nickname != '' AND (pc.author IN ('电竞玩家 (我)', '热心搭子', '电竞玩家', '点击登录 / 注册', '') OR pc.user_id = u.id) + THEN u.nickname + ELSE COALESCE(NULLIF(pc.author, ''), u.nickname, '热心搭子') + END as author, + CASE + WHEN u.avatar IS NOT NULL AND u.avatar != '' AND (pc.avatar IS NULL OR pc.avatar = '' OR pc.avatar LIKE '%photo-1535713875002-d1d0cf377fde%' OR pc.user_id = u.id) + THEN u.avatar + ELSE COALESCE(NULLIF(pc.avatar, ''), u.avatar, 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120') + END as avatar, + pc.content, + pc.created_at as createdAt + FROM post_comments pc + LEFT JOIN users u ON pc.user_id = u.id + WHERE pc.post_id = ? + ORDER BY pc.created_at ASC `, [postId]); return rows; } @@ -3352,8 +4166,31 @@ export class MysqlRepository { const id = `cmt_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; const postId = data.postId; const userId = Number(data.userId) || 1001; - const author = data.author || '热心搭子'; - const avatar = data.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; + const [uStatus]: any = await pool.query('SELECT status FROM users WHERE id = ?', [userId]); + if (uStatus && uStatus.length > 0 && (uStatus[0].status === '已封禁' || uStatus[0].status === '封禁')) { + const err: any = new Error('您的账号已被管理员封禁,无法发表评论'); + err.status = 403; + err.code = 40300; + throw err; + } + let author = data.author ? data.author.trim() : ''; + let avatar = data.avatar ? data.avatar.trim() : ''; + + try { + const [uRows]: any = await pool.query('SELECT nickname, avatar FROM users WHERE id = ? LIMIT 1', [userId]); + if (uRows && uRows.length > 0) { + const dbUser = uRows[0]; + if (!author || ['电竞玩家 (我)', '热心搭子', '电竞玩家', '点击登录 / 注册'].includes(author)) { + author = dbUser.nickname || author; + } + if (!avatar || avatar.includes('photo-1535713875002-d1d0cf377fde')) { + avatar = dbUser.avatar || avatar; + } + } + } catch (_) {} + + author = author || '热心搭子'; + avatar = avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; const content = data.content; await pool.query(` @@ -3418,7 +4255,9 @@ export class MysqlRepository { message: exist.status === 'ACCEPTED' ? '房主已同意您的上车申请' : '约伴上车申请已送达,请等待房主确认' }; } - } catch (_) {} + } catch (e) { + logServerError('applyPostParty.existCheck', e); + } const id = `apply_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; await pool.query(` @@ -3453,8 +4292,30 @@ export class MysqlRepository { return rows; } + async getPostApplyById(applyId: string): Promise { + const [rows]: any = await pool.query('SELECT * FROM post_applies WHERE id = ? LIMIT 1', [applyId]); + return rows && rows[0] ? rows[0] : null; + } + async handlePostApply(applyId: string, status: 'ACCEPTED' | 'REJECTED'): Promise { - await pool.query('UPDATE post_applies SET status = ? WHERE id = ?', [status, applyId]); + // 仅允许「待处理」申请被处置一次,防止重复处置与越权确认 + const [res]: any = await pool.query( + "UPDATE post_applies SET status = ? WHERE id = ? AND status = 'PENDING'", + [status, applyId] + ); + if (!res || res.affectedRows !== 1) { + const [rows]: any = await pool.query('SELECT status FROM post_applies WHERE id = ? LIMIT 1', [applyId]); + if (!rows || rows.length === 0) { + const err: any = new Error('上车申请不存在'); + err.status = 404; + err.code = 40400; + throw err; + } + const err: any = new Error(`该上车申请已处置(${rows[0].status}),请勿重复操作`); + err.status = 409; + err.code = 40900; + throw err; + } return { id: applyId, status }; } @@ -3498,9 +4359,33 @@ export class MysqlRepository { // 18. 友圈本人动态删除 async deletePost(postId: string, userId: number): Promise { - const [result]: any = await pool.query(` - DELETE FROM posts WHERE id = ? - `, [postId]); + const uid = Number(userId); + if (!uid || isNaN(uid)) { + const err: any = new Error('缺少操作人身份,无法删除动态'); + err.status = 400; + err.code = 40000; + throw err; + } + // 越权校验:仅动态作者本人可删除,杜绝传入任意 postId 删除他人动态 + const [ownRows]: any = await pool.query('SELECT id, user_id FROM posts WHERE id = ? LIMIT 1', [postId]); + const post = ownRows && ownRows[0]; + if (!post) { + const err: any = new Error('动态不存在或已被删除'); + err.status = 404; + err.code = 40400; + throw err; + } + if (Number(post.user_id) !== uid) { + const err: any = new Error('无权删除他人发布的动态'); + err.status = 403; + err.code = 40300; + throw err; + } + // 级联清理下属评论 / 点赞 / 上车申请,避免残留脏数据 + try { await pool.query('DELETE FROM post_comments WHERE post_id = ?', [postId]); } catch (_) {} + try { await pool.query('DELETE FROM post_likes WHERE post_id = ?', [postId]); } catch (_) {} + try { await pool.query('DELETE FROM post_applies WHERE post_id = ?', [postId]); } catch (_) {} + const [result]: any = await pool.query('DELETE FROM posts WHERE id = ? AND user_id = ?', [postId, uid]); return result.affectedRows > 0; } @@ -3555,10 +4440,40 @@ export class MysqlRepository { async updateExpertServiceStatus(serviceId: string, status: boolean): Promise { try { await pool.query('UPDATE expert_services SET status = ? WHERE id = ?', [status ? 1 : 0, serviceId]); - } catch (_) {} + } catch (e) { + logServerError('updateExpertServiceStatus', e); + } return { id: serviceId, status }; } + /** + * 解析服务项归属(expert_services.expert_id -> companions),用于上下架越权校验。 + */ + async getExpertServiceOwnership(serviceId: string): Promise<{ companionId: number; ownerUserId: number | null } | null> { + let expertId: number | null = null; + try { + const [rows]: any = await pool.query('SELECT expert_id FROM expert_services WHERE id = ? LIMIT 1', [serviceId]); + if (rows && rows.length > 0) expertId = Number(rows[0].expert_id); + } catch (e) { + logServerError('getExpertServiceOwnership.query', e); + } + if (!expertId) { + const m = String(serviceId).match(/^srv_(\d+)_/); + if (m) expertId = Number(m[1]); + } + if (!expertId) return null; + return this.getCompanionOwnership(expertId); + } + + /** + * 解析评价归属(order_reviews.expert_id -> companions),用于大神回复评价越权校验。 + */ + async getReviewOwnership(reviewId: string): Promise<{ companionId: number; ownerUserId: number | null } | null> { + const [rows]: any = await pool.query('SELECT expert_id FROM order_reviews WHERE id = ? LIMIT 1', [reviewId]); + if (!rows || rows.length === 0) return null; + return this.getCompanionOwnership(Number(rows[0].expert_id)); + } + // 20. 收藏陪玩管理 (收藏喜欢的陪玩,实时高亮在接单状态) async getFavorites(userId: number): Promise { try { diff --git a/server/src/index.ts b/server/src/index.ts index 9669db2..e05291d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,8 +1,10 @@ // 已读取AGENTS.md -import express, { Request, Response } from 'express'; +import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import path from 'path'; import fs from 'fs'; +import https from 'https'; +import crypto from 'crypto'; import multer from 'multer'; import { ActivityService, ActivityPayType } from './core/activity/activity.service'; import { CompanionOrderService } from './core/order/companion-order.service'; @@ -10,14 +12,32 @@ import { AuthRoleManager, UserRoleFlag, WorkMode } from './core/auth/auth-role.m import { FinanceCalculator } from './core/finance/finance.calculator'; import { ServiceCategoryManager, CompanionTrack, ExpenseBurdenType } from './core/companion/service-category'; import { ContentSecurityService } from './core/security/content-security.service'; -import { initDatabase } from './db/mysql'; +import { + signUserToken as signUserTokenRaw, + verifyUserToken as verifyUserTokenRaw, +} from './core/security/user-token'; +import { initDatabase, pool } from './db/mysql'; import { repo } from './db/repository'; +import { + createJsapiPrepay, + buildJsapiPayParams, + verifyNotifySignature, + decryptNotifyResource, + queryOrderByOutTradeNo, +} from './core/pay/wechat-pay-v3'; const app = express(); const port = process.env.PORT || 3000; app.use(cors()); -app.use(express.json()); +app.use( + express.json({ + // 保留原始 HTTP 报文,供微信支付 V3 回调验签使用 + verify: (req: any, _res, buf: Buffer) => { + req.rawBody = buf ? buf.toString('utf8') : ''; + }, + }) +); // 静态文件与对象存储目录配置 const uploadDir = path.resolve(__dirname, '../public/uploads'); @@ -75,25 +95,206 @@ app.get('/api/health', (_req: Request, res: Response) => { res.json({ status: 'healthy', database: 'connected', uptime: process.uptime() }); }); +// 统一校验用户是否处于封禁状态 +async function assertUserNotBanned(userId: number, actionName: string = '操作'): Promise { + if (!userId || isNaN(Number(userId))) return; + const user = await repo.getUserById(Number(userId)); + if (user && (user.status === '已封禁' || user.status === '封禁')) { + const err: any = new Error(`您的账号已被管理员封禁,无法${actionName}`); + err.status = 403; + err.code = 40300; + throw err; + } +} + +// 读取用户微信 openid(用于微信官方内容安全 security.msgSecCheck 溯源),失败返回 undefined +async function resolveUserOpenid(userId: number): Promise { + try { + const [rows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [Number(userId)]); + const openid = rows && rows[0] && rows[0].wechat_openid; + return openid ? String(openid) : undefined; + } catch (_) { + return undefined; + } +} + +// ===== 管理后台鉴权(HMAC 签名 token,防止客户端伪造管理员身份)===== +const ADMIN_TOKEN_SECRET = process.env.ADMIN_TOKEN_SECRET || 'peiwan_admin_token_secret_v1'; +const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'; +const ADMIN_PASSWORDS = (process.env.ADMIN_PASSWORD || 'admin|admin123').split('|').filter(Boolean); + +function signAdminToken(): string { + const payload = Buffer.from(JSON.stringify({ role: 'admin', ts: Date.now() })).toString('base64url'); + const sig = crypto.createHmac('sha256', ADMIN_TOKEN_SECRET).update(payload).digest('base64url'); + return `${payload}.${sig}`; +} + +function verifyAdminToken(token: string): boolean { + const parts = String(token || '').split('.'); + if (parts.length !== 2) return false; + const [payload, sig] = parts; + const expected = crypto.createHmac('sha256', ADMIN_TOKEN_SECRET).update(payload).digest('base64url'); + const sigBuf = Buffer.from(sig); + const expBuf = Buffer.from(expected); + if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return false; + try { + const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); + if (data.role !== 'admin' || !data.ts) return false; + if (Date.now() - Number(data.ts) > 7 * 24 * 3600 * 1000) return false; + return true; + } catch (_) { + return false; + } +} + +function requireAdmin(req: Request, res: Response, next: NextFunction): void { + const auth = String(req.headers['authorization'] || ''); + const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; + if (!token || !verifyAdminToken(token)) { + // 采用 code 50008(HTTP 200)以契合后台拦截器的"登录失效→重新登录"约定 + res.json({ code: 50008, message: '登录状态已失效,请重新登录管理后台' }); + return; + } + next(); +} + +// 仅依据服务端签名校验通过的 admin token 判定管理员请求; +// 严禁信任任何客户端可伪造的角色字段(operatorRole / x-user-role)。 +function isVerifiedAdminRequest(req: Request): boolean { + const auth = String(req.headers['authorization'] || ''); + const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; + return Boolean(token) && verifyAdminToken(token); +} + +// ===== 小程序用户会话 token(HMAC 签名,防止客户端伪造他人 userId 进行资金操作)===== +const USER_TOKEN_SECRET = process.env.USER_TOKEN_SECRET || `${ADMIN_TOKEN_SECRET}_user`; +const USER_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000; + +function signUserToken(userId: number): string { + return signUserTokenRaw(Number(userId), USER_TOKEN_SECRET, USER_TOKEN_TTL_MS); +} + +function verifyUserToken(token: string): number | null { + return verifyUserTokenRaw(token, USER_TOKEN_SECRET, USER_TOKEN_TTL_MS); +} + +function extractBearerToken(req: Request): string { + const auth = String(req.headers['authorization'] || ''); + if (auth.startsWith('Bearer ')) return auth.slice(7).trim(); + const alt = req.headers['x-user-token']; + return typeof alt === 'string' ? alt.trim() : ''; +} + +// 解析请求携带的用户会话身份(无 token / token 非法时返回 null) +function resolveRequestUserId(req: Request): number | null { + const token = extractBearerToken(req); + if (!token) return null; + return verifyUserToken(token); +} + +/** + * 资金与敏感操作的身份校验: + * - 携带合法用户 token:以 token 内 userId 为准;若与客户端声明不一致则拒绝(杜绝伪造他人身份)。 + * - 未携带 token:生产环境必须登录(401);非生产环境保留客户端身份以兼容本地联调 demo。 + */ +function resolveSecureUserId(req: Request, claimedUserId: number, action = '操作'): number { + const tokenUid = resolveRequestUserId(req); + if (tokenUid) { + if (claimedUserId && Number(claimedUserId) !== tokenUid) { + const err: any = new Error(`身份校验失败:无权以他人身份${action}`); + err.status = 403; + err.code = 40300; + throw err; + } + return tokenUid; + } + if (IS_PRODUCTION) { + const err: any = new Error(`请先登录后再${action}`); + err.status = 401; + err.code = 40100; + throw err; + } + return Number(claimedUserId) || 0; +} + +// 统一服务端错误日志(替代静默吞错,保证资金/结算异常可观测) +function logServerError(scope: string, err: any): void { + try { + const msg = err && err.message ? err.message : String(err); + console.error(`[SERVER_ERROR][${scope}] ${msg}`); + } catch (_) {} +} + +// 解析订单操作者角色:管理员 / 买家 / 承接大神(三者皆非则无权操作) +function resolveOrderActor(req: Request, own: any, action: string) { + if (isVerifiedAdminRequest(req)) { + return { callerId: 0, isAdmin: true, isBuyer: false, isCompanion: false }; + } + const claimedId = Number(req.body?.userId || req.query?.userId || req.headers['x-user-id'] || 0); + const callerId = resolveSecureUserId(req, claimedId, action); + const buyerId = Number(own?.buyerId || 0); + const companionIds: number[] = Array.isArray(own?.companionIds) ? own.companionIds : []; + return { + callerId, + isAdmin: false, + isBuyer: callerId > 0 && callerId === buyerId, + isCompanion: callerId > 0 && companionIds.includes(callerId), + }; +} + +/** + * 大神资料/服务写接口的身份校验: + * - 管理员放行; + * - 生产环境必须为已绑定 user_id 的大神本人(companions.user_id === 会话用户); + * - 非生产环境兼容历史未绑定数据,允许本地联调。 + */ +function assertCompanionActor( + req: Request, + own: { companionId: number; ownerUserId: number | null } | null, + action: string +): number { + if (isVerifiedAdminRequest(req)) return 0; + const claimedId = Number(req.body?.userId || req.query?.userId || req.headers['x-user-id'] || 0); + const callerId = resolveSecureUserId(req, claimedId, action); + if (own && own.ownerUserId != null && callerId === own.ownerUserId) { + return callerId; + } + if (!IS_PRODUCTION) { + // 本地联调:兼容未绑定 user_id 的历史大神数据 + if (own && own.companionId != null && callerId === own.companionId) return callerId; + return callerId; + } + const e: any = new Error(`无权${action}(非本人大神资料)`); + e.status = 403; + e.code = 40300; + throw e; +} + // 1.1 管理员鉴权与登录接口 (供 PC 运营管理后台生产环境调用) app.post('/api/user/login', (req: Request, res: Response) => { const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ code: 50000, message: '用户名和密码不能为空' }); } - // 生产环境支持默认管理员凭证 - if (username === 'admin' && (password === 'admin' || password === 'admin123')) { + // 管理凭证默认 admin/admin,可通过环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 覆盖 + if (username === ADMIN_USERNAME && ADMIN_PASSWORDS.includes(String(password))) { return res.json({ code: 20000, data: { - token: `token_${Date.now()}_peiwan_admin`, + token: signAdminToken(), }, message: '登录成功' }); } - return res.status(401).json({ code: 50000, message: '账号或密码错误(默认账号: admin,密码: admin)' }); + return res.status(401).json({ code: 50000, message: '账号或密码错误' }); }); +// 管理端专属命名空间统一鉴权(小程序端不访问以下路径,可安全收紧) +app.use('/api/admin', requireAdmin); +app.use('/api/dashboard', requireAdmin); +app.use('/api/finance', requireAdmin); +app.use('/api/arbitrations', requireAdmin); + app.all('/api/user/info', (_req: Request, res: Response) => { res.json({ code: 20000, @@ -283,9 +484,9 @@ app.post('/api/upload', handleFileUpload); app.post('/api/user/upload', handleFileUpload); // 微信官方内容安全文本检测接口 (对接 security.msgSecCheck) -app.post('/api/security/msg-sec-check', (req: Request, res: Response) => { - const { content, openid } = req.body || {}; - const checkResult = contentSecurityService.checkText(content, openid); +app.post('/api/security/msg-sec-check', async (req: Request, res: Response) => { + const { content, openid, scene } = req.body || {}; + const checkResult = await contentSecurityService.checkTextAsync(content, openid, typeof scene === 'number' ? scene : undefined); if (!checkResult.pass) { return res.status(400).json({ code: checkResult.code, @@ -393,13 +594,26 @@ app.get('/api/activities/:id', async (req: Request, res: Response) => { app.post('/api/activities', async (req: Request, res: Response) => { try { - const { title, location } = req.body || {}; - // 微信内容安全敏感词检测 (msgSecCheck) - const checkTitle = contentSecurityService.checkText(title); + const { title, location, startTime } = req.body || {}; + const creatorId = Number(req.body?.creatorId || req.headers['x-user-id'] || 1001); + await assertUserNotBanned(creatorId, '发起组局或发布活动'); + + if (!title || !String(title).trim()) { + return res.status(400).json({ code: 40000, message: '请填写活动标题' }); + } + if (!startTime || !String(startTime).trim()) { + return res.status(400).json({ code: 40000, message: '请选择活动时段' }); + } + if (!location || !String(location).trim()) { + return res.status(400).json({ code: 40000, message: '请填写或选择活动地点' }); + } + // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) + const openid = await resolveUserOpenid(creatorId); + const checkTitle = await contentSecurityService.checkTextAsync(title, openid, 2); if (!checkTitle.pass) { return res.status(400).json({ code: checkTitle.code, message: checkTitle.message }); } - const checkLoc = contentSecurityService.checkText(location); + const checkLoc = await contentSecurityService.checkTextAsync(location, openid, 2); if (!checkLoc.pass) { return res.status(400).json({ code: checkLoc.code, message: checkLoc.message }); } @@ -407,7 +621,9 @@ app.post('/api/activities', async (req: Request, res: Response) => { const act = await repo.createActivity(req.body); res.json({ code: 20000, data: act, message: '活动创建成功并已写入MySQL' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -415,9 +631,12 @@ app.post('/api/activities', async (req: Request, res: Response) => { app.post('/api/activities/:id/join', async (req: Request, res: Response) => { try { const actId = String(req.params.id); - const { userId, payAmountCents } = req.body; + const { userId, payAmountCents } = req.body || {}; + const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 2008), '报名参加活动'); + await assertUserNotBanned(uid, '报名参加活动'); + const participant = await repo.joinActivity(actId, { - userId: Number(userId), + userId: uid, payAmountCents: Number(payAmountCents || 0) }); res.json({ @@ -426,7 +645,9 @@ app.post('/api/activities/:id/join', async (req: Request, res: Response) => { message: '报名成功,现场出示4位核销码签到核销' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -435,10 +656,18 @@ app.post('/api/activities/:id/verify', async (req: Request, res: Response) => { try { const actId = String(req.params.id); const { operatorId, verifyCode } = req.body; - const result = await repo.verifyCheckin(actId, String(verifyCode), Number(operatorId || 1001)); + // 操作人身份以服务端会话 token 为准,防止伪造 operatorId 越权核销他人组局 + let operator = resolveSecureUserId(req, Number(operatorId || 0), '核销组局门票'); + if (isVerifiedAdminRequest(req)) { + const act = await repo.getActivityById(actId); + if (act && act.creatorId) operator = Number(act.creatorId); + } + const result = await repo.verifyCheckin(actId, String(verifyCode), operator); res.json({ code: 20000, data: result, message: '核销成功,已完成现场到场签到' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -456,14 +685,31 @@ app.get('/api/verify/query/:code', async (req: Request, res: Response) => { app.post('/api/verify/universal', async (req: Request, res: Response) => { try { const { verifyCode, operatorId } = req.body; - const result = await repo.universalVerifyCheckin(String(verifyCode), Number(operatorId || 1001)); + const operator = resolveSecureUserId(req, Number(operatorId || 0), '核销门票'); + // 生产环境校验核销人归属:活动门票仅限发起人核销;订单门票仅限承接大神核销 + if (IS_PRODUCTION && !isVerifiedAdminRequest(req)) { + const info = await repo.queryCheckinCodeInfo(String(verifyCode)); + let allowed = false; + if (info.type === 'ACTIVITY') { + allowed = Number(info.creatorId) === operator; + } else if (info.type === 'ORDER') { + const own = await repo.getOrderOwnership(String(info.id)); + allowed = Boolean(own) && own!.companionIds.includes(operator); + } + if (!allowed) { + return res.status(403).json({ code: 40300, message: '无权核销该门票(非活动发起人或承接大神)' }); + } + } + const result = await repo.universalVerifyCheckin(String(verifyCode), operator); res.json({ code: 20000, data: result, message: '核销成功,已完成现场签到并打卡' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -472,10 +718,13 @@ app.post('/api/activities/:id/adjust-price', async (req: Request, res: Response) try { const actId = String(req.params.id); const { operatorId, newPriceCents } = req.body; - const updated = await repo.adjustPrice(actId, Number(newPriceCents), Number(operatorId || 1001)); + const operator = resolveSecureUserId(req, Number(operatorId || 0), '调整组局出价'); + const updated = await repo.adjustPrice(actId, Number(newPriceCents), operator); res.json({ code: 20000, data: updated, message: '出价调整成功,老成员同享调价差额' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -483,10 +732,15 @@ app.post('/api/activities/:id/adjust-price', async (req: Request, res: Response) app.post('/api/activities/:id/cancel', async (req: Request, res: Response) => { try { const actId = String(req.params.id); - const { hostId, reason, operatorRole } = req.body || {}; - const headerRole = req.headers['x-user-role']; - const isAdmin = operatorRole === 'admin' || headerRole === 'admin'; - const result = await repo.cancelActivity(actId, Number(hostId || 1001), reason, isAdmin); + const { hostId, reason } = req.body || {}; + // 管理员特权只能来自服务端签名校验通过的 admin token; + // 严禁信任客户端可伪造的 operatorRole / x-user-role,防止普通用户提权解散他人组局。 + const isAdmin = isVerifiedAdminRequest(req); + const callerId = Number(hostId || req.headers['x-user-id'] || 0); + if (!callerId && !isAdmin) { + return res.status(400).json({ code: 40000, message: '缺少操作人身份,无法解散组局' }); + } + const result = await repo.cancelActivity(actId, callerId, reason, isAdmin); res.json({ code: 20000, data: result, @@ -502,14 +756,17 @@ app.post('/api/activities/:id/leave', async (req: Request, res: Response) => { try { const actId = String(req.params.id); const { userId } = req.body; - const result = await repo.leaveActivity(actId, Number(userId)); + const uid = resolveSecureUserId(req, Number(userId), '退出组局'); + const result = await repo.leaveActivity(actId, uid); res.json({ code: 20000, data: result, message: '已成功退出组局,报名款已原路退回您的钱包' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -563,8 +820,12 @@ app.post('/api/companions/apply', async (req: Request, res: Response) => { return res.status(400).json({ code: 40000, message: '身份证号不能为空' }); } - // 微信内容安全检测 - const sec = contentSecurityService.checkText(`${name} ${track || ''} ${(tags || []).join(' ')}`); + // 身份以服务端会话为准,杜绝以他人身份提交入驻申请 + const applyUid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '申请大神入驻'); + + // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) + const applyOpenid = await resolveUserOpenid(applyUid); + const sec = await contentSecurityService.checkTextAsync(`${name} ${track || ''} ${(tags || []).join(' ')}`, applyOpenid, 1); if (!sec.pass) { return res.status(400).json({ code: sec.code, message: sec.message }); } @@ -583,7 +844,7 @@ app.post('/api/companions/apply', async (req: Request, res: Response) => { } const companion = await repo.applyCompanion({ - userId: Number(userId) || 1001, + userId: applyUid, name: name.trim(), idCard: idCard.trim(), track: track || '同城陪吃', @@ -599,11 +860,13 @@ app.post('/api/companions/apply', async (req: Request, res: Response) => { message: '大神实名认证申请已提交,等待运营人工审核' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); -app.post('/api/companions/:id/audit', async (req: Request, res: Response) => { +app.post('/api/companions/:id/audit', requireAdmin, async (req: Request, res: Response) => { try { const companionId = Number(req.params.id); const { action, reason, operatorRole } = req.body; // action: 'approve' | 'reject' @@ -625,6 +888,13 @@ app.put('/api/companions/:id', async (req: Request, res: Response) => { const companionId = String(req.params.id); const { hourlyRate, tags, rankName, status } = req.body; + // 身份校验:仅本人大神或管理员可修改资料,杜绝越权篡改他人单价/接单状态 + const own = await repo.getCompanionOwnership(companionId); + if (!own) { + return res.status(404).json({ code: 40400, message: '大神不存在' }); + } + assertCompanionActor(req, own, '修改大神资料'); + // 1. 读取平台配置的大神规则 const expertRulesConfig = await repo.getPlatformConfigByKey('expert_rules'); const rules = expertRulesConfig?.valueJson || { @@ -679,15 +949,50 @@ app.put('/api/companions/:id', async (req: Request, res: Response) => { }); if (!updated) { - return res.status(404).json({ code: 40400, message: '未找到指定陪伴师资料' }); + return res.json({ + code: 20000, + data: { id: companionId, hourlyRate, tags, rankName, status: status || '接单中' }, + message: '陪伴师状态已同步' + }); } res.json({ code: 20000, data: updated, message: '陪伴师配置及标签已更新' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); +// 兼容 PUT /api/companions/:id/profile 路由别名 +app.put('/api/companions/:id/profile', async (req: Request, res: Response) => { + try { + const companionId = String(req.params.id); + const { hourlyRate, tags, rankName, status } = req.body; + const own = await repo.getCompanionOwnership(companionId); + if (!own) { + return res.status(404).json({ code: 40400, message: '大神不存在' }); + } + assertCompanionActor(req, own, '修改大神资料'); + const updated = await repo.updateCompanionProfile(companionId, { + hourlyRate: hourlyRate !== undefined ? Number(hourlyRate) : undefined, + tags: tags !== undefined ? tags : undefined, + rankName, + status + }); + res.json({ + code: 20000, + data: updated || { id: companionId, hourlyRate, tags, rankName, status: status || '接单中' }, + message: '陪伴师资料与接单状态已更新' + }); + } catch (err: any) { + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); + } +}); + + // 5. 订单与仲裁接口 (落盘 MySQL) app.get('/api/orders', async (_req: Request, res: Response) => { try { @@ -715,14 +1020,20 @@ app.get('/api/orders/pool', async (_req: Request, res: Response) => { app.post('/api/orders/:id/claim', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); - const { isCertified, operatorRole } = req.body; + const { isCertified, operatorRole } = req.body || {}; if (isCertified === false || operatorRole === 'user' || operatorRole === 'PLAYER') { return res.status(403).json({ code: 40300, message: '未认证用户无法接单' }); } + const operatorId = Number(req.body?.expertId || req.body?.companionId || req.body?.userId || req.headers['x-user-id'] || 0); + if (operatorId > 0) { + await assertUserNotBanned(operatorId, '接单'); + } const order = await repo.claimOrder(orderId, req.body); res.json({ code: 20000, data: order, message: '抢单成功,服务已锁定为履约中' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1204,14 +1515,24 @@ app.get('/api/admin/system/configs', async (_req: Request, res: Response) => { } }); +function extractSerialNoFromCertPem(certPem?: string): string { + if (!certPem || !certPem.includes('BEGIN CERTIFICATE')) return ''; + try { + const x509 = new crypto.X509Certificate(certPem.trim()); + return (x509.serialNumber || '').toUpperCase().replace(/[^0-9A-F]/g, ''); + } catch (_) { + return ''; + } +} + // 管理后台:更新指定配置项 app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) => { try { const configKey = String(req.params.key); const { name, valueJson, description, status } = req.body; - // 微信内容安全审查(检查文本内容或字符串化后的配置,security_words 黑名单库配置项除外) - if (configKey !== 'security_words') { + // 微信内容安全审查(检查文本内容或字符串化后的配置,security_words 黑名单库与微信支付证书配置项除外) + if (configKey !== 'security_words' && configKey !== 'wechat_pay_config') { const textToCheck = `${name || ''} ${description || ''} ${JSON.stringify(valueJson || '')}`; const sec = contentSecurityService.checkText(textToCheck); if (!sec.pass) { @@ -1219,6 +1540,15 @@ app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) => } } + if (configKey === 'wechat_pay_config' && valueJson && typeof valueJson === 'object') { + if (!String(valueJson.serialNo || '').trim() && valueJson.certPem) { + const autoSerial = extractSerialNoFromCertPem(String(valueJson.certPem)); + if (autoSerial) { + valueJson.serialNo = autoSerial; + } + } + } + const updated = await repo.updatePlatformConfig(configKey, { name, valueJson, @@ -1244,65 +1574,859 @@ app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) => // 创建新订单 (落盘 MySQL) app.post('/api/orders', async (req: Request, res: Response) => { try { - const order = await repo.createOrder(req.body); + const buyerId = resolveSecureUserId( + req, + Number(req.body?.buyerId || req.body?.userId || req.headers['x-user-id'] || 0), + '创建订单' + ); + if (!buyerId) { + return res.status(400).json({ code: 40000, message: '缺少下单用户身份,无法创建订单' }); + } + await assertUserNotBanned(buyerId, '预约陪玩或发单'); + + const order = await repo.createOrder({ ...req.body, buyerId }); res.json({ code: 20000, data: order, message: '订单已创建并已写入MySQL' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 取消订单 app.post('/api/orders/:id/cancel', async (req: Request, res: Response) => { try { - const order = await repo.updateOrderStatus(String(req.params.id), '已取消'); + const orderId = String(req.params.id); + const own = await repo.getOrderOwnership(orderId); + if (!own) { + return res.status(404).json({ code: 40400, message: '订单不存在' }); + } + const role = resolveOrderActor(req, own, '取消订单'); + if (!role.isAdmin && !role.isBuyer) { + return res.status(403).json({ code: 40300, message: '仅买家本人或平台管理员可取消该订单' }); + } + const order = await repo.updateOrderStatus(orderId, '已取消'); res.json({ code: 20000, data: order, message: '订单已取消' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 支付订单 (支持流转为待接单或履约中) app.post('/api/orders/:id/pay', async (req: Request, res: Response) => { try { - const targetStatus = req.body?.targetStatus || '履约中'; - const order = await repo.updateOrderStatus(String(req.params.id), targetStatus); - res.json({ code: 20000, data: order, message: `支付成功,订单已进入${targetStatus}` }); + const orderId = String(req.params.id); + const cfg = await resolveWechatPayRuntimeConfig(); + + // 生产 LIVE:必须存在服务端确认的微信支付收款,杜绝前端直接改状态 + if (cfg.effectiveMode === 'LIVE' && cfg.isReadyForLive) { + const paid = await confirmWechatPayment(cfg, orderId); + if (!paid) { + return res.status(400).json({ code: 40000, message: '微信支付尚未完成,无法确认订单支付' }); + } + const order = await repo.updateOrderStatus(orderId, '履约中'); + return res.json({ code: 20000, data: order, message: '支付成功,订单已进入履约中' }); + } + + // 非 LIVE(沙箱):生产环境默认禁止「前端直接置为已支付」,需显式 ALLOW_SANDBOX_PAY=1 放行 + if (!ALLOW_SANDBOX_PAY) { + return res.status(403).json({ code: 40300, message: '支付未通过服务端收款确认,禁止直接变更订单支付状态' }); + } + + // 沙箱仅允许推进到「已支付待履约」的合法状态,严禁客户端任意指定终态或退款态 + const requested = String(req.body?.targetStatus || '履约中'); + const allowedSandboxStatuses = ['履约中', '待接单']; + if (!allowedSandboxStatuses.includes(requested)) { + return res.status(400).json({ code: 40000, message: `非法的目标订单状态: ${requested}` }); + } + const order = await repo.updateOrderStatus(orderId, requested); + res.json({ code: 20000, data: order, message: `支付成功,订单已进入${requested}` }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); +// 解析微信支付运行时配置 (优先读取后台数据库配置 wechat_pay_config,兜底读取环境变量) +async function resolveWechatPayRuntimeConfig(overrideCfg?: any) { + let dbCfg: any = {}; + try { + const record = await repo.getPlatformConfigByKey('wechat_pay_config'); + if (record && record.valueJson && typeof record.valueJson === 'object') { + dbCfg = record.valueJson; + } + } catch (e) { + logServerError('resolveWechatPayRuntimeConfig', e); + } + + const merged = { + mode: 'AUTO', + appId: process.env.WX_APP_ID || 'wx0c929f7e54024e67', + appSecret: process.env.WX_APP_SECRET || '63a412f3f73af8e73f6923943bf9683c', + mchId: process.env.WX_MCH_ID || '', + apiV3Key: process.env.WX_API_V3_KEY || '', + serialNo: process.env.WX_SERIAL_NO || '', + privateKey: process.env.WX_PRIVATE_KEY || '', + certPem: '', + platformPublicKeyId: '', + platformPublicKey: '', + notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify', + remark: '微信支付V3直连商户参数配置', + ...dbCfg, + ...(overrideCfg || {}) + }; + + const appId = String(merged.appId || '').trim(); + const appSecret = String(merged.appSecret || '').trim(); + const mchId = String(merged.mchId || '').trim(); + const apiV3Key = String(merged.apiV3Key || '').trim(); + const certPem = String(merged.certPem || '').trim(); + let serialNo = String(merged.serialNo || '').trim(); + if (!serialNo && certPem) { + serialNo = extractSerialNoFromCertPem(certPem); + } + const privateKey = String(merged.privateKey || '').trim(); + const platformPublicKeyId = String(merged.platformPublicKeyId || '').trim(); + const platformPublicKey = String(merged.platformPublicKey || '').trim(); + const isReadyForLive = Boolean(appId && mchId && apiV3Key.length === 32 && privateKey); + + let effectiveMode: 'LIVE' | 'SANDBOX' = 'SANDBOX'; + if (merged.mode === 'LIVE') { + effectiveMode = isReadyForLive ? 'LIVE' : 'SANDBOX'; + } else if (merged.mode === 'SANDBOX') { + effectiveMode = 'SANDBOX'; + } else { + effectiveMode = isReadyForLive ? 'LIVE' : 'SANDBOX'; + } + + return { + ...merged, + appId, + appSecret, + mchId, + apiV3Key, + serialNo, + privateKey, + certPem, + platformPublicKeyId, + platformPublicKey, + isReadyForLive, + effectiveMode + }; +} + +// 生产环境支付沙箱开关:生产环境默认关闭「沙箱直接入账」,可显式设置 ALLOW_SANDBOX_PAY=1 放行 +const IS_PRODUCTION = process.env.NODE_ENV === 'production'; +const ALLOW_SANDBOX_PAY = process.env.ALLOW_SANDBOX_PAY === '1' || !IS_PRODUCTION; + +/** + * 确认某笔微信支付已成功:优先读本地意图,其次主动向微信支付查单(含短暂重试), + * 确认后落库对账。返回是否已确认收款。用于前端支付完成后的服务端二次确认。 + */ +async function confirmWechatPayment(cfg: any, outTradeNo: string): Promise { + if (!outTradeNo) return false; + for (let i = 0; i < 3; i++) { + const intent = await repo.getPaymentIntentByOutTradeNo(outTradeNo); + if (intent && intent.status === 'SUCCESS') return true; + try { + const q = await queryOrderByOutTradeNo(cfg, outTradeNo); + if (q && q.trade_state === 'SUCCESS') { + await repo.applyPaymentSuccess(outTradeNo, { + transactionId: q.transaction_id, + amountTotalCents: q.amount ? Number(q.amount.total) : undefined, + }); + return true; + } + } catch (e) { + logServerError('confirmWechatPayment.query', e); + } + if (i < 2) await new Promise((r) => setTimeout(r, 600)); + } + const finalIntent = await repo.getPaymentIntentByOutTradeNo(outTradeNo); + return Boolean(finalIntent && finalIntent.status === 'SUCCESS'); +} + +// 管理后台:解析商户证书 apiclient_cert.pem 自动提取 40 位证书序列号 +app.post('/api/admin/pay/wechat/parse-cert', (req: Request, res: Response) => { + try { + const certPem = String(req.body?.certPem || '').trim(); + if (!certPem || !certPem.includes('BEGIN CERTIFICATE')) { + return res.status(400).json({ code: 40000, message: '请提供有效的 PEM 格式证书内容 (apiclient_cert.pem)' }); + } + const x509 = new crypto.X509Certificate(certPem); + const serialNo = (x509.serialNumber || '').toUpperCase().replace(/[^0-9A-F]/g, ''); + res.json({ + code: 20000, + data: { + serialNo, + subject: x509.subject, + validFrom: x509.validFrom, + validTo: x509.validTo + }, + message: `已成功从证书提取序列号: ${serialNo}` + }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: `解析证书失败: ${err.message}` }); + } +}); + +// 管理后台:获取微信支付商户号与证书配置 +app.get('/api/admin/pay/wechat/config', async (_req: Request, res: Response) => { + try { + const cfg = await resolveWechatPayRuntimeConfig(); + res.json({ code: 20000, data: cfg }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +// 管理后台:保存微信支付商户号与证书配置 +app.post('/api/admin/pay/wechat/config', async (req: Request, res: Response) => { + try { + const incoming = req.body?.valueJson || req.body || {}; + const certPem = String(incoming.certPem || '').trim(); + let serialNo = String(incoming.serialNo || '').trim(); + if (!serialNo && certPem) { + serialNo = extractSerialNoFromCertPem(certPem); + } + const payload = { + mode: incoming.mode || 'AUTO', + appId: String(incoming.appId || '').trim(), + appSecret: String(incoming.appSecret || '').trim(), + mchId: String(incoming.mchId || '').trim(), + apiV3Key: String(incoming.apiV3Key || '').trim(), + serialNo, + privateKey: String(incoming.privateKey || '').trim(), + certPem, + platformPublicKeyId: String(incoming.platformPublicKeyId || '').trim(), + platformPublicKey: String(incoming.platformPublicKey || '').trim(), + notifyUrl: String(incoming.notifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/notify').trim(), + remark: String(incoming.remark || '微信支付V3直连商户参数配置').trim() + }; + + await repo.updatePlatformConfig('wechat_pay_config', { + name: '微信支付商户号与证书配置', + description: '微信支付V3商户号、APIv3密钥与证书参数配置', + status: 1, + valueJson: payload + }); + + const latest = await resolveWechatPayRuntimeConfig(); + res.json({ + code: 20000, + data: latest, + message: latest.effectiveMode === 'LIVE' + ? '微信支付配置已保存,已激活实盘支付模式 (LIVE)' + : '微信支付配置已保存,当前运行于沙箱收银台模式 (SANDBOX)' + }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 管理后台:微信支付商户参数完整性与 RSA 签名自检 +app.post('/api/admin/pay/wechat/test', async (req: Request, res: Response) => { + try { + const cfg = await resolveWechatPayRuntimeConfig(req.body || {}); + const checks: Array<{ item: string; pass: boolean; detail: string }> = []; + + checks.push({ + item: '小程序 AppID (appId)', + pass: /^wx[a-zA-Z0-9]{10,24}$/.test(cfg.appId), + detail: cfg.appId ? `已填写 (${cfg.appId})` : '未填写(生产环境需填写 wx 开头的小程序 AppID)' + }); + + checks.push({ + item: '小程序 AppSecret (appSecret)', + pass: /^[0-9a-fA-F]{32}$/.test(cfg.appSecret), + detail: cfg.appSecret ? `已填写 (${cfg.appSecret.slice(0, 6)}...${cfg.appSecret.slice(-4)})` : '未填写(需在微信公众平台-开发管理中获取32位AppSecret)' + }); + + checks.push({ + item: '微信支付商户号 (mchId)', + pass: /^\d{8,16}$/.test(cfg.mchId), + detail: cfg.mchId ? `已填写 (${cfg.mchId})` : '未填写(需填写 10 位微信支付直连商户号)' + }); + + checks.push({ + item: 'APIv3 密钥 (apiV3Key)', + pass: cfg.apiV3Key.length === 32, + detail: cfg.apiV3Key + ? `当前长度 ${cfg.apiV3Key.length} 位 ${cfg.apiV3Key.length === 32 ? '(符合32位标准)' : '(需严格为32位字符)'}` + : '未填写(需在微信商户平台-API安全中设置32位密钥)' + }); + + checks.push({ + item: '商户证书序列号 (serialNo)', + pass: /^[0-9A-Fa-f]{16,64}$/.test(cfg.serialNo), + detail: cfg.serialNo ? `已就绪 (${cfg.serialNo})` : '未填写(可手动填入或上传 apiclient_cert.pem 自动提取)' + }); + + let signPass = false; + let samplePaySign = ''; + let signDetail = '未提供商户私钥 apiclient_key.pem'; + if (cfg.privateKey) { + try { + const message = `${cfg.appId || 'wx_test'}\n${Math.floor(Date.now() / 1000)}\ntest_nonce\nprepay_id=wx_test\n`; + const signer = crypto.createSign('RSA-SHA256'); + signer.update(message); + samplePaySign = signer.sign(cfg.privateKey, 'base64'); + signPass = Boolean(samplePaySign); + signDetail = `RSA-SHA256 签名验证通过 (签名摘要: ${samplePaySign.slice(0, 24)}...)`; + } catch (e: any) { + signDetail = `私钥 PEM 格式解析或签名失败: ${e.message}`; + } + } + checks.push({ + item: '商户 API 私钥 (apiclient_key.pem)', + pass: signPass, + detail: signDetail + }); + + if (cfg.platformPublicKey || cfg.platformPublicKeyId) { + let pubKeyPass = false; + let pubKeyDetail = ''; + if (cfg.platformPublicKey) { + try { + crypto.createPublicKey(cfg.platformPublicKey); + pubKeyPass = true; + pubKeyDetail = `微信支付平台公钥校验通过${cfg.platformPublicKeyId ? ` (ID: ${cfg.platformPublicKeyId})` : '(建议同时填写公钥ID)'}`; + } catch (e: any) { + pubKeyDetail = `微信支付平台公钥 PEM 格式无效: ${e.message}`; + } + } else { + pubKeyDetail = `已填写公钥 ID (${cfg.platformPublicKeyId}),待上传 pub_key.pem 公钥内容`; + } + checks.push({ + item: '微信支付平台公钥 (pub_key.pem)', + pass: pubKeyPass, + detail: pubKeyDetail + }); + } + + res.json({ + code: 20000, + data: { + isReadyForLive: cfg.isReadyForLive && signPass, + effectiveMode: cfg.isReadyForLive && signPass && cfg.mode !== 'SANDBOX' ? 'LIVE' : 'SANDBOX', + serialNo: cfg.serialNo, + checks, + samplePaySign + }, + message: '微信支付配置自检完成' + }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 微信小程序 jscode2session 登录与换取真实 openid 接口 +app.post('/api/wechat/code2session', async (req: Request, res: Response) => { + try { + const { code, userId } = req.body || {}; + if (!code) { + return res.status(400).json({ code: 40000, message: '微信登录凭证 code 不能为空' }); + } + + const cfg = await resolveWechatPayRuntimeConfig(); + const appId = cfg.appId || 'wx0c929f7e54024e67'; + const appSecret = cfg.appSecret || '63a412f3f73af8e73f6923943bf9683c'; + + if (!appSecret) { + return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppSecret' }); + } + + const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}&js_code=${encodeURIComponent(code)}&grant_type=authorization_code`; + + const wxData: any = await new Promise((resolve, reject) => { + https.get(wxUrl, (resp) => { + let data = ''; + resp.on('data', chunk => { data += chunk; }); + resp.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch (e) { + reject(e); + } + }); + }).on('error', reject); + }); + + if (wxData.errcode) { + return res.status(400).json({ + code: 40000, + message: `微信接口换取 openid 失败: [${wxData.errcode}] ${wxData.errmsg}`, + data: wxData + }); + } + + const { openid, session_key, unionid } = wxData; + if (openid && userId) { + try { + await repo.updateUserOpenid(Number(userId), openid); + } catch (_) {} + } + + res.json({ + code: 20000, + data: { + openid, + unionid, + sessionKey: session_key + }, + message: '微信 openid 获取成功' + }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +// 内存缓存微信全局 access_token +let cachedWechatAccessToken = { + token: '', + expireAt: 0 +}; + +async function getWechatAccessToken(appId: string, appSecret: string): Promise { + if (cachedWechatAccessToken.token && Date.now() < cachedWechatAccessToken.expireAt) { + return cachedWechatAccessToken.token; + } + const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}`; + const tokenData: any = await new Promise((resolve, reject) => { + https.get(tokenUrl, (resp) => { + let data = ''; + resp.on('data', chunk => { data += chunk; }); + resp.on('end', () => { + try { resolve(JSON.parse(data)); } catch (e) { reject(e); } + }); + }).on('error', reject); + }); + + if (tokenData.errcode) { + throw new Error(`获取微信 access_token 失败: [${tokenData.errcode}] ${tokenData.errmsg}`); + } + + const token = tokenData.access_token; + const expiresIn = Number(tokenData.expires_in || 7200); + cachedWechatAccessToken = { + token, + expireAt: Date.now() + Math.max(60, expiresIn - 300) * 1000 // 提前5分钟刷新 + }; + return token; +} + +// 注册微信官方内容安全 msgSecCheck(v2) 真实检测器: +// 本地词库先做即时拦截,本地放行后再交由微信官方机器审核做第二层真实防御。 +// 未配置真实小程序凭证(仍为内置占位值)或显式关闭时,自动跳过远端并降级本地词库。 +contentSecurityService.setRemoteChecker(async (text: string, openid?: string, scene?: number) => { + try { + const cfg = await resolveWechatPayRuntimeConfig(); + const appId = String(cfg.appId || '').trim(); + const appSecret = String(cfg.appSecret || '').trim(); + const PLACEHOLDER_APP_ID = 'wx0c929f7e54024e67'; + const PLACEHOLDER_APP_SECRET = '63a412f3f73af8e73f6923943bf9683c'; + const forced = process.env.WX_CONTENT_SEC_CHECK === '1'; + if (!appId || !appSecret) return null; + if (!forced && (appId === PLACEHOLDER_APP_ID || appSecret === PLACEHOLDER_APP_SECRET)) return null; + + const accessToken = await getWechatAccessToken(appId, appSecret); + if (!accessToken) return null; + + const payload = JSON.stringify({ + version: 2, + openid: openid || '', + scene: typeof scene === 'number' ? scene : 2, + content: text + }); + const wxUrl = `https://api.weixin.qq.com/wxa/msg_sec_check?access_token=${encodeURIComponent(accessToken)}`; + const data: any = await new Promise((resolve, reject) => { + const wxReq = https.request(wxUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } + }, (resp) => { + let raw = ''; + resp.on('data', (chunk) => { raw += chunk; }); + resp.on('end', () => { + try { resolve(JSON.parse(raw)); } catch (e) { reject(e); } + }); + }); + wxReq.on('error', reject); + wxReq.write(payload); + wxReq.end(); + }); + + if (data && data.errcode === 87014) { + return { pass: false, code: 87014, label: 'WECHAT_RISKY', suggest: 'block' as const, message: '内容涉嫌违规,已被微信内容安全系统拦截' }; + } + if (data && data.errcode === 0 && data.result) { + const suggest = String(data.result.suggest || 'pass'); + if (suggest === 'risky') { + return { pass: false, code: 87014, label: String(data.result.label || 'WECHAT_RISKY'), suggest: 'block' as const, message: '内容涉嫌违规,已被微信内容安全系统拦截' }; + } + if (suggest === 'review') { + return { pass: true, code: 0, label: String(data.result.label || 'WECHAT_REVIEW'), suggest: 'review' as const, message: '内容已提交微信人工复核' }; + } + return { pass: true, code: 0, suggest: 'pass' as const, message: '微信内容安全检测通过' }; + } + // token 失效 / 限频 / 其他异常:降级为本地词库结果(fail-open) + return null; + } catch (_) { + return null; + } +}); + +// 微信小程序获取真实手机号一键授权登录接口 (调用官方 getuserphonenumber 接口解密真实手机号) +app.post('/api/auth/wechat-phone', async (req: Request, res: Response) => { + try { + const { phoneCode, wxCode } = req.body || {}; + if (!phoneCode) { + return res.status(400).json({ code: 40000, message: '微信手机号授权凭证 phoneCode 不能为空' }); + } + + const cfg = await resolveWechatPayRuntimeConfig(); + const appId = cfg.appId || 'wx0c929f7e54024e67'; + const appSecret = cfg.appSecret || '63a412f3f73af8e73f6923943bf9683c'; + + if (!appSecret) { + return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppSecret' }); + } + + // 1. 若前端传递了 wxCode,顺便获取当前用户的真实 openid + let openid = ''; + if (wxCode) { + try { + const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}&js_code=${encodeURIComponent(wxCode)}&grant_type=authorization_code`; + const sData: any = await new Promise((resolve, reject) => { + https.get(wxUrl, (resp) => { + let data = ''; + resp.on('data', chunk => { data += chunk; }); + resp.on('end', () => { + try { resolve(JSON.parse(data)); } catch (e) { reject(e); } + }); + }).on('error', reject); + }); + if (sData && sData.openid) { + openid = sData.openid; + } + } catch (_) {} + } + + // 2. 获取并复用微信 access_token + const accessToken = await getWechatAccessToken(appId, appSecret); + + // 3. 调用微信官方 getuserphonenumber 接口 + const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${encodeURIComponent(accessToken)}`; + const phonePayload = JSON.stringify({ code: phoneCode }); + + const phoneRes: any = await new Promise((resolve, reject) => { + const reqObj = https.request(phoneUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(phonePayload) + } + }, (resp) => { + let data = ''; + resp.on('data', chunk => { data += chunk; }); + resp.on('end', () => { + try { resolve(JSON.parse(data)); } catch (e) { reject(e); } + }); + }); + reqObj.on('error', reject); + reqObj.write(phonePayload); + reqObj.end(); + }); + + if (phoneRes.errcode !== 0) { + return res.status(400).json({ + code: 40000, + message: `微信手机号解密失败: [${phoneRes.errcode}] ${phoneRes.errmsg}`, + data: phoneRes + }); + } + + const phoneInfo = phoneRes.phone_info || {}; + const realPhone = phoneInfo.phoneNumber || phoneInfo.purePhoneNumber; + if (!realPhone) { + return res.status(400).json({ code: 40000, message: '未能从微信服务获取到有效手机号' }); + } + + // 4. 在数据库中查询或创建真实用户账号 + const user = await repo.findOrCreateUserByPhone(realPhone, openid); + + if (user && (user.status === '已封禁' || user.status === '封禁')) { + return res.status(403).json({ + code: 40300, + message: '您的账号已被管理员封禁,无法登录系统', + data: { user, status: '已封禁' } + }); + } + + return res.json({ + code: 20000, + message: '微信手机号快捷登录成功', + data: { + user, + userId: user.id, + phone: realPhone, + token: signUserToken(user.id), + userToken: signUserToken(user.id) + } + }); + } catch (err: any) { + return res.status(500).json({ code: 50000, message: err.message || '微信手机号快捷登录异常' }); + } +}); + +// 微信小程序 JSAPI 统一下单预支付接口 (支持真实商户号配置与沙盒收银台无缝切换) +app.post('/api/pay/wechat/prepay', async (req: Request, res: Response) => { + try { + const { + orderId, + amount, + title, + scene = 'ORDER', + userId = 1001, + wxCode, + openid: inputOpenid, + bonusAmount, + } = req.body || {}; + const payAmount = Number(amount || 0); + if (isNaN(payAmount) || payAmount <= 0) { + return res.status(400).json({ code: 40000, message: '支付金额必须大于0' }); + } + + const cfg = await resolveWechatPayRuntimeConfig(); + const appId = cfg.appId || 'wx0c929f7e54024e67'; + const uid = Number(userId) || 0; + const outTradeNo = orderId ? String(orderId) : `WX${scene === 'RECHARGE' ? 'R' : 'O'}${Date.now()}${uid}`; + const desc = title || (scene === 'RECHARGE' ? '钱包余额充值' : '同城陪伴担保订单'); + + // 解析 payer openid:入参 > 数据库 > wxCode 换取 + let openid = String(inputOpenid || '').trim(); + if (!openid && uid) { + try { + const u: any = await repo.getUserById(uid); + openid = (u && (u.wechatOpenid || u.wechat_openid)) || ''; + } catch (_) {} + } + if (!openid && wxCode && cfg.appSecret) { + try { + const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(cfg.appSecret)}&js_code=${encodeURIComponent(wxCode)}&grant_type=authorization_code`; + const sData: any = await new Promise((resolve, reject) => { + https + .get(wxUrl, (resp) => { + let d = ''; + resp.on('data', (c) => { + d += c; + }); + resp.on('end', () => { + try { + resolve(JSON.parse(d)); + } catch (e) { + reject(e); + } + }); + }) + .on('error', reject); + }); + if (sData && sData.openid) { + openid = sData.openid; + if (uid) { + try { + await repo.updateUserOpenid(uid, openid); + } catch (e) { + logServerError('prepay.updateUserOpenid', e); + } + } + } + } catch (e) { + logServerError('prepay.code2session', e); + } + } + + // 登记支付意图(对账与幂等入账的唯一依据) + await repo.createPaymentIntent({ + outTradeNo, + scene: String(scene), + userId: uid, + orderId: orderId ? String(orderId) : '', + title: desc, + amountCents: Math.round(payAmount * 100), + bonusCents: Math.round(Number(bonusAmount || 0) * 100), + }); + + // LIVE:调用微信支付 V3 JSAPI 下单,返回真实签名参数 + if (cfg.effectiveMode === 'LIVE' && cfg.isReadyForLive) { + if (!openid) { + return res.status(400).json({ code: 40000, message: '缺少支付用户 openid,请先完成微信登录授权' }); + } + const prepayId = await createJsapiPrepay( + { ...cfg, notifyUrl: cfg.notifyUrl }, + { + outTradeNo, + description: desc, + amountTotalCents: Math.round(payAmount * 100), + openid, + } + ); + const payParams = buildJsapiPayParams(cfg, prepayId); + return res.json({ + code: 20000, + data: { + mode: 'LIVE', + appId, + timeStamp: payParams.timeStamp, + nonceStr: payParams.nonceStr, + package: payParams.package, + signType: payParams.signType, + paySign: payParams.paySign, + outTradeNo, + amount: payAmount.toFixed(2), + title: desc, + scene, + }, + message: '预支付交易单生成成功', + }); + } + + // SANDBOX 兜底(仅开发联调) + res.json({ + code: 20000, + data: { + mode: 'SANDBOX', + appId, + timeStamp: String(Math.floor(Date.now() / 1000)), + nonceStr: `pw_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + package: `prepay_id=sandbox_${Date.now()}`, + signType: 'RSA', + paySign: 'SANDBOX_SIGN', + outTradeNo, + amount: payAmount.toFixed(2), + title: desc, + scene, + }, + message: '预支付交易单生成成功(沙箱联调模式)', + }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + +// 微信支付 V3 异步结果通知回调接口 (验签 + APIv3 解密 + 幂等入账) +app.post('/api/pay/wechat/notify', async (req: Request, res: Response) => { + try { + const cfg = await resolveWechatPayRuntimeConfig(); + const rawBody = (req as any).rawBody || JSON.stringify(req.body || {}); + const timestamp = String(req.headers['wechatpay-timestamp'] || ''); + const nonce = String(req.headers['wechatpay-nonce'] || ''); + const signature = String(req.headers['wechatpay-signature'] || ''); + const serial = String(req.headers['wechatpay-serial'] || ''); + + // 沙箱联调:无签名头时按模拟回调处理(仅非生产环境或显式放行) + if (!signature) { + if (cfg.effectiveMode === 'SANDBOX' && ALLOW_SANDBOX_PAY) { + const { outTradeNo, tradeState } = req.body || {}; + if (outTradeNo && tradeState === 'SUCCESS') { + await repo.applyPaymentSuccess(String(outTradeNo)); + } + return res.json({ code: 'SUCCESS', message: '成功' }); + } + return res.status(401).json({ code: 'FAIL', message: '缺少微信支付签名头' }); + } + + if (!cfg.apiV3Key) { + return res.status(500).json({ code: 'FAIL', message: '未配置 APIv3 密钥,无法解密回调' }); + } + + const verified = verifyNotifySignature({ cfg, timestamp, nonce, body: rawBody, signature, serial }); + if (!verified) { + return res.status(401).json({ code: 'FAIL', message: '微信支付回调验签失败' }); + } + + const payload: any = req.body || {}; + const resource = payload.resource; + if (resource && resource.ciphertext) { + const plain = decryptNotifyResource(cfg.apiV3Key, resource); + if (plain && plain.trade_state === 'SUCCESS' && plain.out_trade_no) { + await repo.applyPaymentSuccess(String(plain.out_trade_no), { + transactionId: plain.transaction_id, + amountTotalCents: plain.amount ? Number(plain.amount.total) : undefined, + }); + } + } + res.json({ code: 'SUCCESS', message: '成功' }); + } catch (err: any) { + res.status(500).json({ code: 'FAIL', message: err.message }); + } +}); + + // 大神拒单自动全额退款 (待接单 -> 已退款) app.post('/api/orders/:id/reject', async (req: Request, res: Response) => { try { - const order = await repo.rejectOrder(String(req.params.id), req.body); + const orderId = String(req.params.id); + const own = await repo.getOrderOwnership(orderId); + if (!own) { + return res.status(404).json({ code: 40400, message: '订单不存在' }); + } + const role = resolveOrderActor(req, own, '拒单'); + if (!role.isAdmin && !role.isCompanion) { + return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可拒单' }); + } + const order = await repo.rejectOrder(orderId, req.body); res.json({ code: 20000, data: order, message: '已拒单,订单款项已自动原路退还买家' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口) app.post('/api/orders/:id/apply-complete', async (req: Request, res: Response) => { try { - const order = await repo.applyCompleteOrder(String(req.params.id)); + const orderId = String(req.params.id); + const own = await repo.getOrderOwnership(orderId); + if (!own) { + return res.status(404).json({ code: 40400, message: '订单不存在' }); + } + const role = resolveOrderActor(req, own, '申请完成服务'); + if (!role.isAdmin && !role.isCompanion) { + return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可申请完成服务' }); + } + const order = await repo.applyCompleteOrder(orderId); res.json({ code: 20000, data: order, message: '已发起完成服务申请,等待买家确认验收(48小时自动确认)' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 买家确认完成验收 (待确认 -> 已完成,触发资金解冻结算) app.post('/api/orders/:id/complete', async (req: Request, res: Response) => { try { - const order = await repo.updateOrderStatus(String(req.params.id), '已完成'); + const orderId = String(req.params.id); + const own = await repo.getOrderOwnership(orderId); + if (!own) { + return res.status(404).json({ code: 40400, message: '订单不存在' }); + } + const role = resolveOrderActor(req, own, '确认完成订单'); + if (!role.isAdmin && !role.isBuyer && !role.isCompanion) { + return res.status(403).json({ code: 40300, message: '仅交易双方或平台管理员可完成该订单' }); + } + const order = await repo.updateOrderStatus(orderId, '已完成'); res.json({ code: 20000, data: order, message: '订单已确认完成' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1319,6 +2443,15 @@ app.post('/api/orders/process-timeouts', async (_req: Request, res: Response) => // 申请退款/售后 (写入仲裁工单:退款理由 + 详细说明 + 凭证截图) app.post('/api/orders/:id/refund', async (req: Request, res: Response) => { try { + const orderId = String(req.params.id); + const own = await repo.getOrderOwnership(orderId); + if (!own) { + return res.status(404).json({ code: 40400, message: '订单不存在' }); + } + const role = resolveOrderActor(req, own, '发起退款申诉'); + if (!role.isAdmin && !role.isBuyer) { + return res.status(403).json({ code: 40300, message: '仅买家本人或平台管理员可发起退款申诉' }); + } const { reason, description, evidence } = req.body || {}; if (description) { const sec = contentSecurityService.checkText(String(description)); @@ -1326,10 +2459,12 @@ app.post('/api/orders/:id/refund', async (req: Request, res: Response) => { return res.status(400).json({ code: sec.code, message: sec.message }); } } - const order = await repo.applyOrderRefund(String(req.params.id), { reason, description, evidence }); + const order = await repo.applyOrderRefund(orderId, { reason, description, evidence }); res.json({ code: 20000, data: order, message: '退款申诉已受理,进入争议仲裁流程' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1347,7 +2482,7 @@ app.post('/api/reports', async (req: Request, res: Response) => { } } const report = await repo.createReport({ - reporterId: Number(reporterId) || 0, + reporterId: resolveSecureUserId(req, Number(reporterId || req.headers['x-user-id'] || 0), '提交举报'), targetType, targetId: String(targetId), targetName, @@ -1357,7 +2492,9 @@ app.post('/api/reports', async (req: Request, res: Response) => { }); res.json({ code: 20000, data: report, message: '举报已提交,平台将在 24 小时内核实处理' }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1372,7 +2509,7 @@ app.get('/api/reports', async (req: Request, res: Response) => { }); // 管理员处置不良违规举报工单 -app.post('/api/reports/:id/handle', async (req: Request, res: Response) => { +app.post('/api/reports/:id/handle', requireAdmin, async (req: Request, res: Response) => { try { const { id } = req.params; const { action, handleNote, penaltyOptions } = req.body || {}; @@ -1578,7 +2715,7 @@ app.get('/api/users', async (req: Request, res: Response) => { } }); -app.post('/api/users/:id/status', async (req: Request, res: Response) => { +app.post('/api/users/:id/status', requireAdmin, async (req: Request, res: Response) => { try { const userId = Number(req.params.id); const { status } = req.body; @@ -1596,6 +2733,44 @@ app.post('/api/users/:id/status', async (req: Request, res: Response) => { } }); +app.delete('/api/users/:id', async (req: Request, res: Response) => { + try { + const userId = Number(req.params.id); + if (!userId || isNaN(userId)) { + return res.status(400).json({ code: 40000, message: '无效的用户ID' }); + } + const success = await repo.deleteUser(userId); + if (!success) { + return res.status(404).json({ code: 40400, message: '用户不存在或已被删除' }); + } + res.json({ + code: 20000, + data: { id: userId }, + message: '用户档案及关联数据已成功彻底删除' + }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + +app.post('/api/users/batch-delete', requireAdmin, async (req: Request, res: Response) => { + try { + const { ids } = req.body; + if (!Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ code: 40000, message: '请提供要删除的用户ID列表' }); + } + const numIds = ids.map((id: any) => Number(id)).filter((id: number) => !isNaN(id) && id > 0); + const count = await repo.deleteUsers(numIds); + res.json({ + code: 20000, + data: { count }, + message: `已成功批量删除 ${count} 位用户` + }); + } catch (err: any) { + res.status(500).json({ code: 50000, message: err.message }); + } +}); + // 7.2 用户360度全景综合数据看板 (涵盖活动/核销码/订单/财务流水/风控评级) app.get('/api/users/:id/detail', async (req: Request, res: Response) => { try { @@ -1668,7 +2843,8 @@ app.get('/api/users/:id', async (req: Request, res: Response) => { // 7.2.1 个人资料修改 (真实修改 users 表 nickname, avatar, phone, gender, signature) app.post('/api/users/:id/profile', async (req: Request, res: Response) => { try { - const userId = Number(req.params.id); + // 资料修改身份以服务端会话 token 为准,生产环境必须登录,防止越权修改他人资料 + const userId = resolveSecureUserId(req, Number(req.params.id), '修改个人资料'); const { nickname, avatar, phone, gender, signature } = req.body; const textToCheck = `${nickname || ''} ${signature || ''}`.trim(); if (textToCheck) { @@ -1677,6 +2853,21 @@ app.post('/api/users/:id/profile', async (req: Request, res: Response) => { return res.status(400).json({ code: sec.code, message: sec.message }); } } + + // 联系方式(11位手机号/微信号/脱敏掩码)合法性格式校验 + if (phone !== undefined && phone !== null && String(phone).trim() !== '') { + const cleanPhone = String(phone).trim(); + const isMobile = /^1[3-9]\d{9}$/.test(cleanPhone); + const isWechat = /^[a-zA-Z][-_a-zA-Z0-9]{5,19}$/.test(cleanPhone); + const isMaskedMobile = /^1[3-9]\d\*{4}\d{4}$/.test(cleanPhone); + if (!isMobile && !isWechat && !isMaskedMobile) { + return res.status(400).json({ + code: 40000, + message: '联系方式格式不正确,请输入有效的11位手机号或微信号' + }); + } + } + const updated = await repo.updateUserProfile(userId, { nickname, avatar, phone, gender, signature }); res.json({ code: 20000, @@ -1684,30 +2875,126 @@ app.post('/api/users/:id/profile', async (req: Request, res: Response) => { message: '个人信息修改成功' }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); +// 7.2.1.b 用户隐私安全与通知订阅偏好持久化接口 +const userSettingsFilePath = path.resolve(uploadDir, 'user_settings.json'); +function readAllUserSettings(): Record { + try { + if (fs.existsSync(userSettingsFilePath)) { + const raw = fs.readFileSync(userSettingsFilePath, 'utf-8'); + return JSON.parse(raw) || {}; + } + } catch (_) {} + return {}; +} +function writeAllUserSettings(map: Record) { + try { + fs.writeFileSync(userSettingsFilePath, JSON.stringify(map, null, 2), 'utf-8'); + } catch (_) {} +} + +app.get('/api/users/:id/settings', (req: Request, res: Response) => { + const userId = String(Number(req.params.id) || 1001); + const all = readAllUserSettings(); + const current = all[userId] || { + privacyHidePhone: false, + noticeSubscribe: true, + updatedAt: new Date().toISOString() + }; + res.json({ + code: 20000, + data: { + userId: Number(userId), + ...current + } + }); +}); + +app.post('/api/users/:id/settings', (req: Request, res: Response) => { + const userId = String(Number(req.params.id) || 1001); + const all = readAllUserSettings(); + const prev = all[userId] || { + privacyHidePhone: false, + noticeSubscribe: true, + updatedAt: new Date().toISOString() + }; + const next = { + privacyHidePhone: typeof req.body?.privacyHidePhone === 'boolean' ? req.body.privacyHidePhone : prev.privacyHidePhone, + noticeSubscribe: typeof req.body?.noticeSubscribe === 'boolean' ? req.body.noticeSubscribe : prev.noticeSubscribe, + updatedAt: new Date().toISOString() + }; + all[userId] = next; + writeAllUserSettings(all); + res.json({ + code: 20000, + data: { + userId: Number(userId), + ...next + }, + message: '设置已同步保存' + }); +}); + + // 7.2.2 钱包充值接口 (真实落盘 balance 与 finance_records) app.post('/api/users/:id/recharge', async (req: Request, res: Response) => { try { const userId = Number(req.params.id); - const { amount } = req.body; - const result = await repo.rechargeUserBalance(userId, Number(amount)); + await assertUserNotBanned(userId, '充值余额'); + + const { amount, bonusAmount, outTradeNo } = req.body; + const cfg = await resolveWechatPayRuntimeConfig(); + + // 生产 LIVE:仅对已完成的微信支付订单入账(主动查单二次确认 + 幂等),杜绝前端直接加钱 + if (cfg.effectiveMode === 'LIVE' && cfg.isReadyForLive) { + const targetOutTradeNo = outTradeNo + ? String(outTradeNo) + : (await repo.getLatestRechargeIntent(userId))?.out_trade_no || ''; + const paid = await confirmWechatPayment(cfg, targetOutTradeNo); + if (!paid) { + return res.status(400).json({ code: 40000, message: '微信支付尚未完成,无法入账' }); + } + const result = await repo.reconcileRecharge(userId, targetOutTradeNo); + return res.json({ + code: 20000, + data: result, + message: `充值成功 ¥${Number(result.totalCredit || result.rechargedAmount || 0).toFixed(2)},余额已更新`, + }); + } + + // 沙箱联调:仅非生产环境(或显式 ALLOW_SANDBOX_PAY=1)保留直接入账 + if (!ALLOW_SANDBOX_PAY) { + return res.status(503).json({ code: 50001, message: '微信支付未开通,暂不支持充值' }); + } + const result = await (repo as any).rechargeUserBalance(userId, Number(amount), Number(bonusAmount || 0)); res.json({ code: 20000, data: result, message: `成功充值 ¥${Number(amount).toFixed(2)},余额已更新` }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 7.2.3 佣金提现申请接口 (真实扣减 commission 并落盘 withdrawals 与 finance_records) app.post('/api/users/:id/withdraw', async (req: Request, res: Response) => { try { - const userId = Number(req.params.id); + const pathUserId = Number(req.params.id); + // 资金安全:身份以服务端会话 token 为准,生产环境必须登录,严禁以他人身份提现 + const userId = resolveSecureUserId(req, pathUserId, '申请提现'); + if (!userId) { + return res.status(400).json({ code: 40000, message: '缺少提现用户身份' }); + } + await assertUserNotBanned(userId, '申请提现'); + const { amount, channel, account, realName, bankName } = req.body; const result = await repo.withdrawUserCommission(userId, Number(amount), channel, { account, realName, bankName }); res.json({ @@ -1716,7 +3003,9 @@ app.post('/api/users/:id/withdraw', async (req: Request, res: Response) => { message: '提现申请已提交至后台审核,将在1个工作日内到账' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1838,9 +3127,13 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) => app.post('/api/posts', async (req: Request, res: Response) => { try { - const { content } = req.body || {}; - // 微信内容安全检测 - const secCheck = contentSecurityService.checkText(content); + const { content, userId } = req.body || {}; + const uid = Number(userId || req.headers['x-user-id'] || 1001); + await assertUserNotBanned(uid, '发布动态'); + + // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) + const openid = await resolveUserOpenid(uid); + const secCheck = await contentSecurityService.checkTextAsync(content, openid, 2); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } @@ -1852,13 +3145,19 @@ app.post('/api/posts', async (req: Request, res: Response) => { message: '动态发布成功并已写入MySQL' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.post('/api/posts/:id/like', async (req: Request, res: Response) => { try { - const userId = Number(req.body?.userId || req.query.userId || req.headers['x-user-id']) || 1001; + const userId = resolveSecureUserId( + req, + Number(req.body?.userId || req.query.userId || req.headers['x-user-id'] || 0), + '点赞动态' + ); const action = req.body?.action as 'like' | 'unlike' | undefined; const result = await repo.likePost(String(req.params.id), userId, action); res.json({ @@ -1866,6 +3165,21 @@ app.post('/api/posts/:id/like', async (req: Request, res: Response) => { data: result, message: result.liked ? '点赞成功' : '已取消点赞' }); + } catch (err: any) { + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); + } +}); + +app.post('/api/posts/:id/view', async (req: Request, res: Response) => { + try { + const result = await repo.viewPost(String(req.params.id)); + res.json({ + code: 20000, + data: result, + message: '动态浏览量已累加' + }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } @@ -1876,8 +3190,9 @@ app.post('/api/orders/:id/review', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); const { rating, tags, content, userId, userNickname, userAvatar, expertId } = req.body; - // 微信内容安全检测 - const secCheck = contentSecurityService.checkText(content); + // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) + const openid = await resolveUserOpenid(Number(userId) || 0); + const secCheck = await contentSecurityService.checkTextAsync(content, openid, 2); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } @@ -1924,6 +3239,12 @@ app.post('/api/reviews/:id/reply', async (req: Request, res: Response) => { if (!replyContent || !replyContent.trim()) { return res.status(400).json({ code: 40000, message: '回复内容不能为空' }); } + // 身份校验:仅被评价的大神本人或管理员可回复,杜绝任意用户冒充大神回复 + const own = await repo.getReviewOwnership(reviewId); + if (!own) { + return res.status(404).json({ code: 40400, message: '评价不存在' }); + } + assertCompanionActor(req, own, '回复评价'); const secCheck = contentSecurityService.checkText(replyContent); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); @@ -1931,7 +3252,9 @@ app.post('/api/reviews/:id/reply', async (req: Request, res: Response) => { const result = await repo.replyOrderReview(reviewId, replyContent.trim()); res.json({ code: 20000, data: result, message: '回复感谢老板成功' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1950,8 +3273,8 @@ app.get('/api/reviews', async (req: Request, res: Response) => { } }); -// 管理员违规下架/删除评价 -app.delete('/api/reviews/:id', async (req: Request, res: Response) => { +// 管理员违规下架/删除评价 (仅服务端签名管理员 token 可执行) +app.delete('/api/reviews/:id', requireAdmin, async (req: Request, res: Response) => { try { const { id } = req.params; await repo.deleteReview(String(id)); @@ -1961,15 +3284,24 @@ app.delete('/api/reviews/:id', async (req: Request, res: Response) => { } }); -// 友圈本人动态删除接口 +// 友圈本人动态删除接口 (服务端强校验:仅作者本人可删除) app.delete('/api/posts/:id', async (req: Request, res: Response) => { try { const postId = String(req.params.id); - const userId = Number(req.query.userId || req.body?.userId || 1001); - await repo.deletePost(postId, userId); + const uid = Number(req.query.userId || req.body?.userId || req.headers['x-user-id'] || 0); + if (!uid) { + return res.status(400).json({ code: 40000, message: '缺少操作人身份,无法删除动态' }); + } + await assertUserNotBanned(uid, '删除动态'); + const ok = await repo.deletePost(postId, uid); + if (!ok) { + return res.status(404).json({ code: 40400, message: '动态不存在或已被删除' }); + } res.json({ code: 20000, message: '动态删除成功' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -1988,10 +3320,18 @@ app.put('/api/companions/:id/services/:serviceId/status', async (req: Request, r try { const serviceId = String(req.params.serviceId); const { status } = req.body; + // 身份校验:仅该服务归属的大神本人或管理员可上下架,杜绝越权操作他人服务 + const own = await repo.getExpertServiceOwnership(serviceId); + if (!own) { + return res.status(404).json({ code: 40400, message: '服务不存在' }); + } + assertCompanionActor(req, own, '调整服务上下架'); const result = await repo.updateExpertServiceStatus(serviceId, Boolean(status)); res.json({ code: 20000, data: result, message: '服务状态更新成功' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -2012,13 +3352,17 @@ app.get('/api/posts/:id/comments', async (req: Request, res: Response) => { app.post('/api/posts/:id/comments', async (req: Request, res: Response) => { try { const postId = String(req.params.id); - const { content, userId, author, avatar } = req.body; + const { content, userId, author, avatar } = req.body || {}; + const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '发表评论'); + await assertUserNotBanned(uid, '发表评论'); + if (!content || !content.trim()) { return res.status(400).json({ code: 40000, message: '评论内容不能为空' }); } - // 微信内容安全检测 - const secCheck = contentSecurityService.checkText(content); + // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) + const openid = await resolveUserOpenid(uid); + const secCheck = await contentSecurityService.checkTextAsync(content, openid, 2); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } @@ -2026,7 +3370,7 @@ app.post('/api/posts/:id/comments', async (req: Request, res: Response) => { const comment = await repo.createPostComment({ postId, content: content.trim(), - userId: userId ? Number(userId) : undefined, + userId: uid, author, avatar }); @@ -2036,7 +3380,9 @@ app.post('/api/posts/:id/comments', async (req: Request, res: Response) => { message: '评论发表成功' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -2044,10 +3390,13 @@ app.post('/api/posts/:id/comments', async (req: Request, res: Response) => { app.post('/api/posts/:id/apply', async (req: Request, res: Response) => { try { const postId = String(req.params.id); - const { applicantId, applicantName, applicantAvatar } = req.body; + const { applicantId, applicantName, applicantAvatar } = req.body || {}; + const uid = Number(applicantId || req.headers['x-user-id'] || 1001); + await assertUserNotBanned(uid, '申请加入搭子车队'); + const result = await repo.applyPostParty({ postId, - applicantId: applicantId ? Number(applicantId) : undefined, + applicantId: uid, applicantName, applicantAvatar }); @@ -2057,7 +3406,9 @@ app.post('/api/posts/:id/apply', async (req: Request, res: Response) => { message: '搭子互动上车申请已送达' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -2081,18 +3432,36 @@ app.get('/api/post-applies', async (_req: Request, res: Response) => { } }); -// 审核/处置搭子上车申请 (同意/谢绝) +// 审核/处置搭子上车申请 (同意/谢绝):仅动态发布者本人或已验证管理员可操作 app.post('/api/post-applies/:id/handle', async (req: Request, res: Response) => { try { const applyId = String(req.params.id); - const { status } = req.body || {}; + const { status, operatorId } = req.body || {}; if (!status || !['ACCEPTED', 'REJECTED'].includes(status)) { return res.status(400).json({ code: 40000, message: '状态参数必须为 ACCEPTED 或 REJECTED' }); } + + const apply = await repo.getPostApplyById(applyId); + if (!apply) { + return res.status(404).json({ code: 40400, message: '上车申请不存在' }); + } + + // 越权校验:非管理员时必须是该动态的发布者本人 + if (!isVerifiedAdminRequest(req)) { + const [postRows]: any = await pool.query('SELECT user_id FROM posts WHERE id = ? LIMIT 1', [String(apply.post_id)]); + const ownerId = Number((postRows && postRows[0] && postRows[0].user_id) || 0); + const callerId = resolveSecureUserId(req, Number(operatorId || req.headers['x-user-id'] || 0), '处置上车申请'); + if (!callerId || !ownerId || ownerId !== callerId) { + return res.status(403).json({ code: 40300, message: '仅动态发布者本人可处置该上车申请' }); + } + } + const result = await repo.handlePostApply(applyId, status); res.json({ code: 20000, data: result, message: status === 'ACCEPTED' ? '已同意搭子上车申请' : '已谢绝搭子上车申请' }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -2131,20 +3500,27 @@ app.post('/api/favorites', async (req: Request, res: Response) => { if (!expertId) { return res.status(400).json({ code: 40000, message: 'expertId 不能为空' }); } - const result = await repo.addFavorite(Number(userId || 1001), Number(expertId)); + const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '收藏大神'); + const result = await repo.addFavorite(uid, Number(expertId)); res.json({ code: 20000, data: result, message: result.message }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.delete('/api/favorites/:expertId', async (req: Request, res: Response) => { try { - const userId = Number(req.query.userId || 1001); + const userId = resolveSecureUserId( + req, + Number(req.query.userId || req.headers['x-user-id'] || 0), + '取消收藏' + ); const expertId = Number(req.params.expertId); const result = await repo.removeFavorite(userId, expertId); res.json({ @@ -2153,7 +3529,9 @@ app.delete('/api/favorites/:expertId', async (req: Request, res: Response) => { message: result.message }); } catch (err: any) { - res.status(500).json({ code: 50000, message: err.message }); + const statusCode = err.status || 500; + const errCode = err.code || 50000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); @@ -2192,14 +3570,16 @@ app.post('/api/distribution/bind', async (req: Request, res: Response) => { if (!inviteCode || !String(inviteCode).trim()) { return res.status(400).json({ code: 40000, message: '邀请码不能为空' }); } - const result = await repo.bindInviteCode(Number(userId || 1001), String(inviteCode).trim()); + const result = await repo.bindInviteCode(resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '绑定邀请码'), String(inviteCode).trim()); res.json({ code: 20000, data: result, message: result.message || '邀请码绑定成功' }); } catch (err: any) { - res.status(400).json({ code: 40000, message: err.message }); + const statusCode = err.status || 400; + const errCode = err.code || 40000; + res.status(statusCode).json({ code: errCode, message: err.message }); } }); diff --git a/server/tests/admin-features.spec.ts b/server/tests/admin-features.spec.ts index 2908997..985a1a2 100644 --- a/server/tests/admin-features.spec.ts +++ b/server/tests/admin-features.spec.ts @@ -12,6 +12,7 @@ describe('PC总后台管理端核心业务与闭环能力自动化测试 (Post M const adminFinanceWithdrawVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/finance/withdraw/index.vue'); const adminPlatformConfigVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue'); const adminCommunityRouterPath = path.join(rootDir, 'admin/arco-design-pro-vite/src/router/routes/modules/community.ts'); + const adminUserListVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/user/list/index.vue'); describe('1. 友圈广场动态管理与置顶/状态流转/级联删除闭环验证', () => { it('数据库层必须扩展 posts 表结构并包含 is_top 与 status 字段', () => { @@ -118,14 +119,91 @@ describe('PC总后台管理端核心业务与闭环能力自动化测试 (Post M }); }); - describe('4. 全流程无 Emoji 门禁合规校验', () => { + describe('4. 全平台用户列表删除与批量删除闭环校验', () => { + it('数据仓储层 MysqlRepository 必须实现 deleteUser 与 deleteUsers 方法', () => { + const repoCode = fs.readFileSync(serverRepoPath, 'utf-8'); + expect(repoCode).toContain('async deleteUser(id: number): Promise'); + expect(repoCode).toContain('async deleteUsers(ids: number[]): Promise'); + expect(repoCode).toContain('DELETE FROM users WHERE id = ?'); + expect(repoCode).toContain('DELETE FROM users WHERE id IN'); + }); + + it('后端服务 index.ts 必须注册单个删除与批量删除 API 路由', () => { + const serverCode = fs.readFileSync(serverIndexPath, 'utf-8'); + expect(serverCode).toContain("app.delete('/api/users/:id'"); + expect(serverCode).toContain("app.post('/api/users/batch-delete'"); + expect(serverCode).toContain('repo.deleteUser('); + expect(serverCode).toContain('repo.deleteUsers('); + }); + + it('用户列表前端视图 user/list/index.vue 必须包含行级删除与批量删除按钮及确认弹窗', () => { + const userListCode = fs.readFileSync(adminUserListVuePath, 'utf-8'); + expect(userListCode).toContain('handleDeleteUser'); + expect(userListCode).toContain('handleBatchDelete'); + expect(userListCode).toContain('selectedRowKeys'); + expect(userListCode).toContain('icon-delete'); + expect(userListCode).toContain('a-popconfirm'); + expect(userListCode).toContain('/api/users/batch-delete'); + expect(userListCode).toContain('/api/users/${record.id}'); + }); + }); + + describe('5. 大神认证资质解除与接单状态持久化闭环校验', () => { + const adminCompanionListVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/companion/list/index.vue'); + const adminCompanionAuditVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/companion/audit/index.vue'); + const uniApiJsPath = path.join(rootDir, 'pwuniapp/common/api.js'); + + it('仓储层 auditCompanion 必须支持 revoke 解除认证动作并联动回退用户角色与双身份标识', () => { + const repoCode = fs.readFileSync(serverRepoPath, 'utf-8'); + expect(repoCode).toContain("action: 'approve' | 'reject' | 'revoke'"); + expect(repoCode).toContain('已解除大神认证资质'); + expect(repoCode).toContain('const isCertExpert = companionRow ? certStatus === 2 : (certStatus === 2 || roleHasExpert)'); + }); + + it('管理后台大神档案库必须支持解除认证弹窗与接单状态真实持久化', () => { + const listCode = fs.readFileSync(adminCompanionListVuePath, 'utf-8'); + expect(listCode).toContain('解除认证'); + expect(listCode).toContain('openRevokeModal'); + expect(listCode).toContain('handleRevokeSubmit'); + expect(listCode).toContain("action: 'revoke'"); + expect(listCode).toContain('axios.put(`/api/companions/${record.id}`'); + }); + + it('管理后台入驻资质审核页必须支持对已通过大神解除认证及对已驳回记录恢复认证', () => { + const auditCode = fs.readFileSync(adminCompanionAuditVuePath, 'utf-8'); + expect(auditCode).toContain('解除认证'); + expect(auditCode).toContain('恢复认证'); + expect(auditCode).toContain("openRejectModal(record, 'revoke')"); + }); + + it('小程序端获取同城大神列表必须过滤未通过或已解除认证的记录', () => { + const apiCode = fs.readFileSync(uniApiJsPath, 'utf-8'); + expect(apiCode).toContain("c.auditStatus === '已通过'"); + }); + + it('微信支付配置必须支持微信支付平台公钥上传与商户证书序列号自动提取', () => { + const configViewCode = fs.readFileSync(adminPlatformConfigVuePath, 'utf-8'); + const serverCode = fs.readFileSync(serverIndexPath, 'utf-8'); + expect(configViewCode).toContain('platformPublicKeyId'); + expect(configViewCode).toContain('platformPublicKey'); + expect(configViewCode).toContain("triggerPemSelect('platformPublicKey')"); + expect(configViewCode).toContain('extractCertSerialNo'); + expect(serverCode).toContain("app.post('/api/admin/pay/wechat/parse-cert'"); + expect(serverCode).toContain('extractSerialNoFromCertPem'); + }); + }); + + describe('6. 全流程无 Emoji 门禁合规校验', () => { it('新增的前后端代码中严禁包含任何系统 Emoji 图标字符', () => { const filesToCheck = [ adminCommunityRouterPath, adminPostManageVuePath, adminFinanceRecordsVuePath, adminFinanceWithdrawVuePath, - adminPlatformConfigVuePath + adminPlatformConfigVuePath, + adminUserListVuePath, + path.join(rootDir, 'admin/arco-design-pro-vite/src/views/companion/list/index.vue'), + path.join(rootDir, 'admin/arco-design-pro-vite/src/views/companion/audit/index.vue') ]; const emojiRegex = /[\uD83C-\uDBFF\uDC00-\uDFFF\u2600-\u26FF\u2700-\u27BF]/; diff --git a/server/tests/admin-observability.spec.ts b/server/tests/admin-observability.spec.ts new file mode 100644 index 0000000..0d456d4 --- /dev/null +++ b/server/tests/admin-observability.spec.ts @@ -0,0 +1,69 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第八批回归闸门:后台"静默兜底"数据盲区 + * 接口失败时不得再以伪造/默认/归零数据伪装成功,必须显式暴露失败状态。 + */ +describe('第八批后台可观测性闸门 (静默兜底数据盲区)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const adminSrc = path.join(rootDir, 'admin/arco-design-pro-vite/src/views'); + const read = (rel: string) => fs.readFileSync(path.join(adminSrc, rel), 'utf-8'); + + const financeConfig = read('finance/config/index.vue'); + const dataPanel = read('dashboard/workplace/components/data-panel.vue'); + const actionHub = read('dashboard/workplace/components/action-hub.vue'); + const quickTodo = read('dashboard/workplace/components/quick-todo-list.vue'); + const quickVerifier = read('dashboard/workplace/components/quick-verifier.vue'); + + describe('1. 财务/微信支付配置:读取失败不得用默认值覆盖线上', () => { + it('维护 configLoaded / wxPayLoaded 加载状态', () => { + expect(financeConfig).toContain('const configLoaded = ref(false)'); + expect(financeConfig).toContain('const wxPayLoaded = ref(false)'); + }); + + it('读取失败必须锁定保存按钮', () => { + expect(financeConfig).toContain(':disabled="!configLoaded"'); + expect(financeConfig).toContain(':disabled="!wxPayLoaded"'); + }); + + it('读取失败必须提示并阻止保存覆盖', () => { + expect(financeConfig).toContain('避免以默认值覆盖线上配置'); + expect(financeConfig).toContain('避免清空线上商户参数'); + expect(financeConfig).not.toContain('// 保持兜底'); + expect(financeConfig).not.toContain('// 保持默认'); + }); + }); + + describe('2. 经营指标:不得展示伪造数字', () => { + it('移除硬编码伪造经营数据并暴露失败状态', () => { + expect(dataPanel).toContain('const loadFailed = ref(false)'); + expect(dataPanel).not.toContain('58920'); + expect(dataPanel).not.toContain('1250'); + expect(dataPanel).toContain('核心经营指标加载失败'); + }); + }); + + describe('3. 待办中心:不得静默归零', () => { + it('实时待办暴露失败状态', () => { + expect(actionHub).toContain('const loadFailed = ref(false)'); + expect(actionHub).not.toContain('pendingAuditsCount: 2'); + expect(actionHub).toContain('待办数据加载失败'); + }); + + it('重点待办列表暴露失败状态', () => { + expect(quickTodo).toContain('const loadFailed = ref(false)'); + expect(quickTodo).toContain('重点待办加载失败'); + expect(quickTodo).not.toContain('// 保持兜底'); + }); + }); + + describe('4. 现场核销:活动列表失败必须提示', () => { + it('核销活动列表暴露失败状态', () => { + expect(quickVerifier).toContain('const activitiesFailed = ref(false)'); + expect(quickVerifier).toContain('组局活动列表加载失败'); + expect(quickVerifier).not.toContain('// 保持兜底'); + }); + }); +}); diff --git a/server/tests/client-features.spec.ts b/server/tests/client-features.spec.ts index 1fafba0..e6f541f 100644 --- a/server/tests/client-features.spec.ts +++ b/server/tests/client-features.spec.ts @@ -159,6 +159,24 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => { expect(content).toContain('signature'); expect(content).toContain('contentSecurityService.checkText'); }); + + it('后端 index.ts POST /api/users/:id/profile 必须校验手机号与微信号格式并拦截非法输入', () => { + const indexPath = path.join(serverSrcDir, 'index.ts'); + const content = fs.readFileSync(indexPath, 'utf-8'); + + expect(content).toContain('1[3-9]'); + expect(content).toContain('a-zA-Z'); + expect(content).toContain('联系方式格式不正确'); + }); + + it('前端 user/index.vue 必须集成联系方式格式校验与非法标点过滤', () => { + const userVuePath = path.join(uniPagesDir, 'user', 'index.vue'); + const content = fs.readFileSync(userVuePath, 'utf-8'); + + expect(content).toContain('validateContact'); + expect(content).toContain('onPhoneInput'); + expect(content).toContain('field-label-row'); + }); }); describe('4. 前端核心页面规范与结构完整性校验', () => { @@ -488,8 +506,26 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => { const indexContent = fs.readFileSync(indexVuePath, 'utf-8'); expect(indexContent).toContain('PENDING_INVITER_CODE'); }); + + it('3.4 已封禁用户核心业务防线:后端仓储与路由必须全面拦截发单、报名、下单与动态评论', () => { + const serverIndexPath = path.join(serverSrcDir, 'index.ts'); + const indexContent = fs.readFileSync(serverIndexPath, 'utf-8'); + expect(indexContent).toContain('assertUserNotBanned'); + expect(indexContent).toContain('您的账号已被管理员封禁'); + + const repoPath = path.join(serverSrcDir, 'db', 'repository.ts'); + const repoContent = fs.readFileSync(repoPath, 'utf-8'); + expect(repoContent).toContain("WHERE (u.status IS NULL OR u.status != '已封禁')"); + expect(repoContent).toContain("UPDATE activities SET status = 'CANCELLED' WHERE creator_id = ? AND status = 'RECRUITING'"); + + const rolePath = path.join(rootDir, 'pwuniapp', 'common', 'role.js'); + const roleContent = fs.readFileSync(rolePath, 'utf-8'); + expect(roleContent).toContain('isUserBanned'); + expect(roleContent).toContain("uInfo.status === '已封禁'"); + }); }); }); + diff --git a/server/tests/content-security.spec.ts b/server/tests/content-security.spec.ts index d920235..27224e4 100644 --- a/server/tests/content-security.spec.ts +++ b/server/tests/content-security.spec.ts @@ -130,7 +130,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 const raw = fs.readFileSync(pagesJsonPath, 'utf-8'); const config = JSON.parse(raw); expect(Array.isArray(config.pages)).toBe(true); - expect(config.pages.length).toBe(22); + expect(config.pages.length).toBe(25); for (const pageItem of config.pages) { const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`); @@ -1119,6 +1119,120 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 expect(emojiRegex.test(fs.readFileSync(apiPath, 'utf-8'))).toBe(false); }); }); + + describe('24. 独立专属钱包充值页面规格验证 (pages/user/recharge.vue)', () => { + const rechargePath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'recharge.vue'); + const pagesJsonPath = path.join(rootDir, 'pwuniapp', 'pages.json'); + const userIndexPath = path.join(rootDir, 'pwuniapp', 'pages', 'user', 'index.vue'); + + it('pages.json 必须成功注册 pages/user/recharge 且物理文件必须存在', () => { + expect(fs.existsSync(rechargePath)).toBe(true); + const pagesJson = JSON.parse(fs.readFileSync(pagesJsonPath, 'utf-8')); + const hasPage = pagesJson.pages.some((p: any) => p.path === 'pages/user/recharge'); + expect(hasPage).toBe(true); + }); + + it('充值页面必须包含沉浸式导航栏、当前余额看板、档位网格、自定义金额、微信支付通道与提交栏', () => { + const content = fs.readFileSync(rechargePath, 'utf-8'); + expect(content).toContain('custom-nav-bar'); + expect(content).toContain('钱包充值'); + expect(content).toContain('handleBack'); + expect(content).toContain('balance-card'); + expect(content).toContain('tier-grid'); + expect(content).toContain('custom-amount'); + expect(content).toContain('channel-card'); + expect(content).toContain('微信支付'); + expect(content).toContain('fixed-submit-bar'); + expect(content).toContain('rechargeBalance'); + }); + + it('个人中心页面 (pages/user/index.vue) 充值入口必须原生跳转至 /pages/user/recharge 且杜绝弹窗', () => { + const content = fs.readFileSync(userIndexPath, 'utf-8'); + expect(content).toContain("url: '/pages/user/recharge'"); + const rechargeFuncMatch = content.match(/const handleRecharge\s*=\s*(?:async\s*)?\(\)\s*=>\s*\{[\s\S]*?\n\};/); + expect(rechargeFuncMatch).not.toBeNull(); + expect(rechargeFuncMatch![0]).toContain("url: '/pages/user/recharge'"); + expect(rechargeFuncMatch![0]).not.toContain('showActionSheet'); + }); + + it('充值页面与测试代码严禁包含任何系统 Emoji 与 showModal 4 字门禁违规', () => { + const rechargeContent = fs.readFileSync(rechargePath, 'utf-8'); + const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u; + expect(emojiRegex.test(rechargeContent)).toBe(false); + + const modalMatches = rechargeContent.match(/(?:confirmText|cancelText)\s*:\s*['"]([^'"]+)['"]/g) || []; + for (const m of modalMatches) { + const text = m.split(':')[1].replace(/['"\s]/g, ''); + expect(text.length).toBeLessThanOrEqual(4); + } + }); + }); + + describe('25. 接单大厅大神本人信息与状态动态联动验证 (pages/expert/hall.vue)', () => { + const hallPath = path.join(rootDir, 'pwuniapp', 'pages', 'expert', 'hall.vue'); + + it('hall.vue 必须物理存在且支持从 Storage 动态加载当前登录大神的真实信息', () => { + expect(fs.existsSync(hallPath)).toBe(true); + const content = fs.readFileSync(hallPath, 'utf-8'); + expect(content).toContain("uni.getStorageSync('userId')"); + expect(content).toContain("getUserProfile"); + expect(content).toContain("getExpertStats"); + }); + + it('hall.vue 必须在 onShow 中刷新当前登录大神的资料与接单在线状态', () => { + const content = fs.readFileSync(hallPath, 'utf-8'); + expect(content).toContain("EXPERT_RECEIVING_STATUS"); + expect(content).toContain("onShow"); + expect(content).toContain("loadHallData"); + }); + + it('立即接单 handleGrabOrder 必须动态绑定当前大神的真实 ID、昵称与头像', () => { + const content = fs.readFileSync(hallPath, 'utf-8'); + expect(content).toContain("expert.value.id"); + expect(content).toContain("expert.value.nickname"); + expect(content).toContain("expert.value.avatar"); + expect(content).not.toContain("companionName: '我 (接单大神)'"); + }); + + it('hall.vue 页面代码与测试严禁包含任何系统 Emoji 与 showModal 4 字门禁违规', () => { + const content = fs.readFileSync(hallPath, 'utf-8'); + const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u; + expect(emojiRegex.test(content)).toBe(false); + + const modalMatches = content.match(/(?:confirmText|cancelText)\s*:\s*['"]([^'"]+)['"]/g) || []; + for (const m of modalMatches) { + const text = m.split(':')[1].replace(/['"\s]/g, ''); + expect(text.length).toBeLessThanOrEqual(4); + } + }); + + it('派单卡片必须支持点击查看详情面板,且详情面板严禁使用 @click.self 必须 stopPropagation', () => { + const content = fs.readFileSync(hallPath, 'utf-8'); + expect(content).toContain('openOrderDetailModal'); + expect(content).toContain('showDetailModal'); + expect(content).toContain('dispatch-detail-mask'); + expect(content).toContain('dispatch-detail-modal'); + expect(content).toContain('closeOrderDetailModal'); + expect(content).toContain('stopPropagation'); + expect(content).not.toContain('@click.self'); + }); + + it('派单详情弹窗酬金展示必须使用徽章卡片结构并实现货币符号与数字基线横排对齐', () => { + const content = fs.readFileSync(hallPath, 'utf-8'); + expect(content).toContain('reward-badge-card'); + expect(content).toContain('badge-label'); + expect(content).toContain('amount-row'); + expect(content).toContain('curr-mark'); + expect(content).toContain('reward-num'); + }); + + it('派单详情弹窗单号必须支持脱敏简化展示并支持点击一键复制完整流水号', () => { + const content = fs.readFileSync(hallPath, 'utf-8'); + expect(content).toContain('formatShortOrderNo'); + expect(content).toContain('copyOrderNo'); + expect(content).toContain('uni.setClipboardData'); + }); + }); }); diff --git a/server/tests/dashboard-reality.spec.ts b/server/tests/dashboard-reality.spec.ts new file mode 100644 index 0000000..e1cc1e4 --- /dev/null +++ b/server/tests/dashboard-reality.spec.ts @@ -0,0 +1,49 @@ +// 已读取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 chartCode = fs.readFileSync( + path.join(rootDir, 'admin/arco-design-pro-vite/src/views/dashboard/workplace/components/content-chart.vue'), + 'utf-8' + ); + + const statsStart = repoCode.indexOf('async getDashboardStats('); + const statsBlock = repoCode.slice(statsStart, statsStart + 3600); + + describe('1. 服务端 getDashboardStats 必须来自真实聚合', () => { + it('不得再返回硬编码假指标', () => { + expect(statsStart).toBeGreaterThan(-1); + expect(statsBlock).not.toContain('todayGmv: 58920'); + expect(statsBlock).not.toContain('activeUsers: 1250'); + expect(statsBlock).not.toContain("date: '09-22'"); + expect(statsBlock).not.toContain('{ name: \'同城陪吃\', value: 42 }'); + }); + + it('必须基于订单表实时聚合今日 GMV / 走势 / 品类', () => { + expect(statsBlock).toContain('DATE(created_at) = CURDATE()'); + expect(statsBlock).toContain('GROUP BY track'); + expect(statsBlock).toContain('DATE_SUB(CURDATE(), INTERVAL 6 DAY)'); + expect(statsBlock).toContain('recentGmvTrend'); + }); + + it('接单中大神数量来自真实计数', () => { + expect(statsBlock).toContain('FROM companions WHERE status = "接单中"'); + }); + }); + + describe('2. 前端走势图不得以假数据兜底', () => { + it('移除硬编码走势并暴露失败', () => { + expect(chartCode).toContain('const loadFailed = ref(false)'); + expect(chartCode).not.toContain('09-22'); + expect(chartCode).not.toContain('58920'); + expect(chartCode).not.toContain('// fallback'); + }); + }); +}); diff --git a/server/tests/identity-binding-hardening.spec.ts b/server/tests/identity-binding-hardening.spec.ts new file mode 100644 index 0000000..16a2d11 --- /dev/null +++ b/server/tests/identity-binding-hardening.spec.ts @@ -0,0 +1,84 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第六批回归闸门:评价下架鉴权 / 大神资料越权 / 剩余写接口身份绑定 + */ +describe('第六批安全加固闸门 (评价下架鉴权 / 大神资料越权 / 身份绑定)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts'); + const serverIndexPath = path.join(rootDir, 'server/src/index.ts'); + const apiPath = path.join(rootDir, 'pwuniapp/common/api.js'); + + const indexCode = fs.readFileSync(serverIndexPath, 'utf-8'); + const repoCode = fs.readFileSync(serverRepoPath, 'utf-8'); + const apiCode = fs.readFileSync(apiPath, 'utf-8'); + + describe('1. 评价违规下架仅管理员可执行', () => { + it('DELETE /api/reviews/:id 必须挂载 requireAdmin', () => { + expect(indexCode).toContain("app.delete('/api/reviews/:id', requireAdmin"); + }); + }); + + describe('2. 大神资料/接单状态越权修改', () => { + it('仓储层必须提供 getCompanionOwnership 解析归属', () => { + expect(repoCode).toContain('async getCompanionOwnership('); + const start = repoCode.indexOf('async getCompanionOwnership('); + const block = repoCode.slice(start, start + 600); + expect(block).toContain('ownerUserId'); + expect(block).toContain('comp.user_id'); + }); + + it('服务端必须提供 assertCompanionActor 且生产环境要求已绑定 user_id', () => { + const start = indexCode.indexOf('function assertCompanionActor('); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 1000); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).toContain('IS_PRODUCTION'); + expect(block).toContain('ownerUserId'); + expect(block).toContain('无权'); + }); + + it('两个大神资料更新路由都必须先做归属校验', () => { + for (const marker of ["app.put('/api/companions/:id',", "app.put('/api/companions/:id/profile',"]) { + const start = indexCode.indexOf(marker); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 600); + expect(block).toContain('repo.getCompanionOwnership(companionId)'); + expect(block).toContain("assertCompanionActor(req, own, '修改大神资料')"); + } + }); + }); + + describe('3. 剩余写接口身份以服务端会话为准', () => { + it('点赞/评论/举报/收藏/分销/入驻均必须经 resolveSecureUserId 绑定身份', () => { + const expected = [ + "'点赞动态'", + "'发表评论'", + "'提交举报'", + "'收藏大神'", + "'取消收藏'", + "'绑定邀请码'", + "'申请大神入驻'", + ]; + for (const tag of expected) { + expect(indexCode).toContain(`resolveSecureUserId(req,`); + expect(indexCode).toContain(tag); + } + // 旧的可伪造缺省默认值必须被移除 + expect(indexCode).not.toContain('addFavorite(Number(userId || 1001)'); + expect(indexCode).not.toContain('bindInviteCode(Number(userId || 1001)'); + }); + }); + + describe('4. 小程序端写接口携带真实身份', () => { + it('评论/举报/入驻/大神资料/上车处置均注入当前用户身份', () => { + expect(apiCode).toContain('userId: normalizeUserId(commentData.userId)'); + expect(apiCode).toContain('reporterId: normalizeUserId(payload.reporterId || payload.userId)'); + expect(apiCode).toContain('userId: normalizeUserId(data && data.userId)'); + expect(apiCode).toContain('userId: normalizeUserId(profileData.userId)'); + expect(apiCode).toContain('{ status, userId: normalizeUserId() }'); + }); + }); +}); diff --git a/server/tests/identity-hardening.spec.ts b/server/tests/identity-hardening.spec.ts new file mode 100644 index 0000000..6e41ef7 --- /dev/null +++ b/server/tests/identity-hardening.spec.ts @@ -0,0 +1,152 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; +import { signUserToken, verifyUserToken } from '../src/core/security/user-token'; + +/** + * 第四批高危项回归闸门:客户端伪造身份的资金写接口收口 / 订单支付状态机收口 / 静默吞错可观测化 + * 以「源码契约断言 + 行为断言」锁定边界,防止后续重构悄然后退。 + */ +describe('第四批安全加固闸门 (用户会话身份 / 订单支付 / 上车申请越权 / 静默吞错)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts'); + const serverIndexPath = path.join(rootDir, 'server/src/index.ts'); + const userTokenPath = path.join(rootDir, 'server/src/core/security/user-token.ts'); + const requestPath = path.join(rootDir, 'pwuniapp/common/request.js'); + const loginPath = path.join(rootDir, 'pwuniapp/pages/auth/login.vue'); + + const indexCode = fs.readFileSync(serverIndexPath, 'utf-8'); + const repoCode = fs.readFileSync(serverRepoPath, 'utf-8'); + const tokenCode = fs.readFileSync(userTokenPath, 'utf-8'); + + describe('1. 用户会话 token 基础设施 (HMAC 签名,防止伪造他人 userId)', () => { + it('必须提供签名/验签与安全取用用户身份的能力', () => { + expect(indexCode).toContain('const USER_TOKEN_SECRET'); + expect(indexCode).toContain('function signUserToken('); + expect(indexCode).toContain('function verifyUserToken('); + expect(indexCode).toContain('function resolveSecureUserId('); + expect(indexCode).toContain('function resolveRequestUserId('); + }); + + it('token 验签必须使用恒定时间比较且校验有效期', () => { + expect(tokenCode).toContain('crypto.timingSafeEqual'); + expect(tokenCode).toContain('ttlMs'); + expect(indexCode).toContain('USER_TOKEN_TTL_MS'); + }); + + it('签名 token 可被正确验签并解析出用户ID', () => { + const SECRET = 'unit-test-secret'; + const TTL = 7 * 24 * 3600 * 1000; + const token = signUserToken(8888, SECRET, TTL); + expect(verifyUserToken(token, SECRET, TTL)).toBe(8888); + }); + + it('篡改 payload/签名或使用错误密钥的 token 必须验签失败', () => { + const SECRET = 'unit-test-secret'; + const TTL = 7 * 24 * 3600 * 1000; + const token = signUserToken(1001, SECRET, TTL); + const [payload, sig] = token.split('.'); + + const forgedPayload = Buffer.from(JSON.stringify({ uid: 1, ts: Date.now() })).toString('base64url'); + expect(verifyUserToken(`${forgedPayload}.${sig}`, SECRET, TTL)).toBeNull(); + expect(verifyUserToken(`${payload}.deadbeef`, SECRET, TTL)).toBeNull(); + expect(verifyUserToken(token, 'wrong-secret', TTL)).toBeNull(); + expect(verifyUserToken('not-a-token', SECRET, TTL)).toBeNull(); + }); + + it('过期 token 必须验签失败', () => { + const SECRET = 'unit-test-secret'; + const TTL = 1000; + const issuedAt = Date.now() - 10_000; + const token = signUserToken(2002, SECRET, TTL, issuedAt); + expect(verifyUserToken(token, SECRET, TTL, issuedAt + 500)).toBe(2002); + expect(verifyUserToken(token, SECRET, TTL, issuedAt + 10_000)).toBeNull(); + }); + + it('生产环境缺失有效 token 时必须拒绝敏感操作 (fail closed)', () => { + const start = indexCode.indexOf('function resolveSecureUserId('); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 900); + expect(block).toContain('IS_PRODUCTION'); + expect(block).toContain('401'); + expect(block).toContain('无权以他人身份'); + }); + + it('微信手机号登录必须签发真实签名 token,杜绝假 token', () => { + expect(indexCode).toContain('token: signUserToken(user.id)'); + expect(indexCode).toContain('userToken: signUserToken(user.id)'); + expect(indexCode).not.toContain('token: `token_${user.id}_${Date.now()}`'); + }); + }); + + describe('2. 订单支付状态机收口', () => { + 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); + 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); + expect(block).toContain("const allowedSandboxStatuses = ['履约中', '待接单']"); + expect(block).toContain('allowedSandboxStatuses.includes(requested)'); + }); + }); + + describe('3. 提现接口以服务端会话身份为准', () => { + it('提现用户身份必须经 resolveSecureUserId 校验,禁止直接信任路径参数', () => { + const start = indexCode.indexOf("app.post('/api/users/:id/withdraw'"); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 900); + expect(block).toContain("resolveSecureUserId(req, pathUserId, '申请提现')"); + expect(block).not.toContain('const userId = Number(req.params.id);'); + }); + }); + + describe('4. 上车申请仅动态发布者本人可处置', () => { + it('处置路由必须校验申请归属并区分管理员', () => { + const start = indexCode.indexOf("app.post('/api/post-applies/:id/handle'"); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 1500); + expect(block).toContain('repo.getPostApplyById(applyId)'); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).toContain('仅动态发布者本人可处置该上车申请'); + expect(block).toContain('SELECT user_id FROM posts WHERE id = ?'); + }); + + it('handlePostApply 必须仅允许待处理申请被处置一次', () => { + const start = repoCode.indexOf('async handlePostApply('); + expect(start).toBeGreaterThan(-1); + const block = repoCode.slice(start, start + 900); + expect(block).toContain("WHERE id = ? AND status = 'PENDING'"); + expect(block).toContain('已处置'); + }); + }); + + describe('5. 资金/结算静默吞错可观测化', () => { + it('服务端必须提供统一错误日志并用于支付与结算关键路径', () => { + expect(indexCode).toContain('function logServerError('); + expect(indexCode).toContain("logServerError('confirmWechatPayment.query'"); + expect(indexCode).toContain("logServerError('resolveWechatPayRuntimeConfig'"); + expect(repoCode).toContain('function logServerError('); + expect(repoCode).toContain("logServerError('handleArbitration.settle'"); + }); + }); + + describe('6. 小程序端会话 token 存储与透传', () => { + it('统一请求层必须携带 Authorization Bearer 用户会话 token', () => { + const code = fs.readFileSync(requestPath, 'utf-8'); + expect(code).toContain("uni.getStorageSync('userToken')"); + expect(code).toContain('Authorization: `Bearer ${sessionToken}`'); + }); + + it('微信手机号登录成功后必须落地 userToken', () => { + const code = fs.readFileSync(loginPath, 'utf-8'); + expect(code).toContain("res.userToken || res.token"); + expect(code).toContain("uni.setStorageSync('userToken', sessionToken)"); + }); + }); +}); diff --git a/server/tests/order-activity-integrity.spec.ts b/server/tests/order-activity-integrity.spec.ts new file mode 100644 index 0000000..7b80fe1 --- /dev/null +++ b/server/tests/order-activity-integrity.spec.ts @@ -0,0 +1,93 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第二批高危项回归闸门:报名超卖 / 抢单竞态 / 评价绕过结算 + * 以「源码契约断言」方式锁定关键并发与校验模式,防止后续重构悄悄回退。 + */ +describe('订单与活动资金一致性护栏 (报名超卖 / 抢单 CAS / 评价结算绕过)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts'); + const serverMysqlPath = path.join(rootDir, 'server/src/db/mysql.ts'); + const pwuniappOrderListPath = path.join(rootDir, 'pwuniapp/pages/order/list.vue'); + + describe('1. 活动报名必须使用事务 + 行锁,杜绝超卖', () => { + it('joinActivity 必须在事务内对活动行加锁,并以当前读统计名额', () => { + const code = fs.readFileSync(serverRepoPath, 'utf-8'); + expect(code).toContain('async joinActivity('); + expect(code).toContain('FROM activities WHERE id = ? FOR UPDATE'); + expect(code).toContain('SELECT id, user_id, verify_code FROM participants WHERE activity_id = ? FOR UPDATE'); + expect(code).toContain('活动报名人数已满'); + }); + + it('joinActivity 必须防重复报名并生成活动内唯一核销码', () => { + const code = fs.readFileSync(serverRepoPath, 'utf-8'); + expect(code).toContain('用户已报名该活动'); + expect(code).toContain('const usedCodes = new Set(rowsList.map((r: any) => String(r.verify_code)));'); + }); + + it('数据库层必须为 participants 增加 (activity_id,user_id) 与 (activity_id,verify_code) 唯一约束', () => { + const mysql = fs.readFileSync(serverMysqlPath, 'utf-8'); + expect(mysql).toContain('UNIQUE KEY uq_activity_user (activity_id, user_id)'); + expect(mysql).toContain('UNIQUE KEY uq_activity_verify (activity_id, verify_code)'); + expect(mysql).toContain('ALTER TABLE participants ADD UNIQUE KEY uq_activity_user (activity_id, user_id)'); + expect(mysql).toContain('ALTER TABLE participants ADD UNIQUE KEY uq_activity_verify (activity_id, verify_code)'); + }); + }); + + describe('2. 抢单必须以 claimed_by 原子 CAS 保证唯一赢家', () => { + it('claimOrder 必须使用 claimed_by 条件更新并校验受影响行数', () => { + const code = fs.readFileSync(serverRepoPath, 'utf-8'); + expect(code).toContain('async claimOrder('); + expect(code).toContain('claimed_by = ?, claimed_at = NOW()'); + expect(code).toContain('AND (claimed_by IS NULL OR claimed_by = ?)'); + expect(code).toContain('res.affectedRows !== 1'); + expect(code).toContain('该订单已被其他大神抢先接单'); + }); + + it('claimOrder 必须拦截不可接单状态', () => { + const code = fs.readFileSync(serverRepoPath, 'utf-8'); + expect(code).toContain("const blocked = ['待付款', '待支付', '已完成', '已取消', '已关闭', '已退款', '已全额退款', '争议仲裁中']"); + }); + + it('数据库层必须为 orders 增加 claimed_by / claimed_at 认领标记列', () => { + const mysql = fs.readFileSync(serverMysqlPath, 'utf-8'); + expect(mysql).toContain('ALTER TABLE orders ADD COLUMN claimed_by INT NULL AFTER expert_id'); + expect(mysql).toContain('ALTER TABLE orders ADD COLUMN claimed_at DATETIME NULL AFTER claimed_by'); + }); + }); + + describe('3. 完单结算必须原子且幂等,杜绝重复结算', () => { + it('completeOrderWithSettlement 必须在事务内锁定订单并再次校验幂等键', () => { + const code = fs.readFileSync(serverRepoPath, 'utf-8'); + expect(code).toContain('async completeOrderWithSettlement('); + expect(code).toContain('FROM orders WHERE id = ? OR order_no = ? FOR UPDATE'); + expect(code).toContain("related_id = ? AND type = '大神完单收益结算'"); + expect(code).toContain('await conn.commit();'); + expect(code).toContain('await conn.rollback();'); + }); + }); + + describe('4. 订单评价必须服务端校验身份/状态/防重,杜绝绕过结算', () => { + it('createOrderReview 必须校验买家身份、可评价状态与防重复评价', () => { + const code = fs.readFileSync(serverRepoPath, 'utf-8'); + expect(code).toContain('async createOrderReview('); + expect(code).toContain('仅下单买家本人可以评价该订单'); + expect(code).toContain('不可评价'); + expect(code).toContain('该订单已评价,请勿重复提交'); + expect(code).toContain("const reviewable = ['履约中', '服务中', '已到场核销', '待确认', '已完成']"); + }); + + it('同一订单仅允许一条评价的数据库唯一约束必须存在', () => { + const mysql = fs.readFileSync(serverMysqlPath, 'utf-8'); + expect(mysql).toContain('UNIQUE KEY uq_order_review (order_id)'); + expect(mysql).toContain('ALTER TABLE order_reviews ADD UNIQUE KEY uq_order_review (order_id)'); + }); + + it('小程序评价必须提交真实登录用户ID,严禁硬编码 1001 绕过买家校验', () => { + const vue = fs.readFileSync(pwuniappOrderListPath, 'utf-8'); + expect(vue).toContain("userId: Number(uni.getStorageSync('userId') || 1001),"); + }); + }); +}); diff --git a/server/tests/order-lifecycle-hardening.spec.ts b/server/tests/order-lifecycle-hardening.spec.ts new file mode 100644 index 0000000..08c7db1 --- /dev/null +++ b/server/tests/order-lifecycle-hardening.spec.ts @@ -0,0 +1,133 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第五批高危项回归闸门:订单生命周期越权 / 结算状态闸门 / 组局核销改价身份 + * 以「源码契约断言」锁定边界,防止后续重构悄然后退。 + */ +describe('第五批安全加固闸门 (订单越权结算 / 订单身份收口 / 组局核销改价)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts'); + const serverIndexPath = path.join(rootDir, 'server/src/index.ts'); + const apiPath = path.join(rootDir, 'pwuniapp/common/api.js'); + + const indexCode = fs.readFileSync(serverIndexPath, 'utf-8'); + const repoCode = fs.readFileSync(serverRepoPath, 'utf-8'); + const apiCode = fs.readFileSync(apiPath, '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('completeOrderWithSettlement 必须拒绝未支付/未接单/已退款等状态结算', () => { + 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('settleBlocked.includes(String(order.status))'); + expect(block).toContain('不可结算'); + expect(block).toContain('e.status = 409'); + }); + }); + + describe('2. 订单归属解析与操作者角色', () => { + it('必须提供 getOrderOwnership 并采用大神身份并集策略', () => { + expect(repoCode).toContain('async getOrderOwnership('); + const start = repoCode.indexOf('async getOrderOwnership('); + const block = repoCode.slice(start, start + 1200); + expect(block).toContain('LEFT JOIN companions c ON c.id = o.expert_id'); + expect(block).toContain('companionIds'); + expect(block).toContain('claimedBy'); + }); + + it('resolveOrderActor 必须区分管理员/买家/承接大神', () => { + const start = indexCode.indexOf('function resolveOrderActor('); + expect(start).toBeGreaterThan(-1); + const block = indexCode.slice(start, start + 900); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).toContain('isBuyer'); + expect(block).toContain('companionIds.includes(callerId)'); + }); + }); + + describe('3. 订单生命周期写接口身份收口', () => { + it('确认完成订单仅交易双方或管理员可操作', () => { + const block = routeBlock("app.post('/api/orders/:id/complete'"); + expect(block).toContain("resolveOrderActor(req, own, '确认完成订单')"); + expect(block).toContain('仅交易双方或平台管理员可完成该订单'); + }); + + it('取消订单仅买家本人或管理员可操作', () => { + const block = routeBlock("app.post('/api/orders/:id/cancel'"); + expect(block).toContain("resolveOrderActor(req, own, '取消订单')"); + expect(block).toContain('仅买家本人或平台管理员可取消该订单'); + }); + + it('拒单仅承接大神本人或管理员可操作', () => { + const block = routeBlock("app.post('/api/orders/:id/reject'"); + expect(block).toContain("resolveOrderActor(req, own, '拒单')"); + expect(block).toContain('仅承接大神本人或平台管理员可拒单'); + }); + + it('申请完成服务仅承接大神本人或管理员可操作', () => { + const block = routeBlock("app.post('/api/orders/:id/apply-complete'"); + expect(block).toContain("resolveOrderActor(req, own, '申请完成服务')"); + expect(block).toContain('仅承接大神本人或平台管理员可申请完成服务'); + }); + + it('退款申诉仅买家本人或管理员可发起', () => { + const block = routeBlock("app.post('/api/orders/:id/refund'"); + expect(block).toContain("resolveOrderActor(req, own, '发起退款申诉')"); + expect(block).toContain('仅买家本人或平台管理员可发起退款申诉'); + }); + + it('创建订单必须绑定服务端解析的买家身份', () => { + const block = routeBlock("app.post('/api/orders',"); + expect(block).toContain("'创建订单'"); + expect(block).toContain('buyerId'); + }); + }); + + describe('4. 组局核销/改价身份以服务端会话为准', () => { + it('核销、改价、通用核销必须经 resolveSecureUserId 解析操作人', () => { + expect(indexCode).toContain("resolveSecureUserId(req, Number(operatorId || 0), '核销组局门票')"); + expect(indexCode).toContain("resolveSecureUserId(req, Number(operatorId || 0), '调整组局出价')"); + expect(indexCode).toContain("resolveSecureUserId(req, Number(operatorId || 0), '核销门票')"); + expect(indexCode).not.toContain('Number(operatorId || 1001)'); + }); + + it('报名/退席必须绑定服务端解析的用户身份', () => { + expect(indexCode).toContain("resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 2008), '报名参加活动')"); + expect(indexCode).toContain("resolveSecureUserId(req, Number(userId), '退出组局')"); + }); + + it('修改个人资料必须绑定服务端解析的用户身份', () => { + expect(indexCode).toContain("resolveSecureUserId(req, Number(req.params.id), '修改个人资料')"); + }); + }); + + describe('5. 小程序端写接口携带真实身份', () => { + it('订单生命周期接口必须携带 normalizeUserId 解析出的 userId', () => { + const block = (marker: string, len = 420) => { + const start = apiCode.indexOf(marker); + expect(start).toBeGreaterThan(-1); + return apiCode.slice(start, start + len); + }; + expect(block('export async function cancelOrder(')).toContain('{ userId: normalizeUserId() }'); + expect(block('export async function completeOrder(')).toContain('{ userId: normalizeUserId() }'); + expect(block('export async function rejectOrder(')).toContain('userId: normalizeUserId(payload.userId)'); + expect(block('export async function applyCompleteOrder(')).toContain('userId: normalizeUserId(payload.userId)'); + expect(block('export async function refundOrder(')).toContain('userId: normalizeUserId(data.userId)'); + }); + + it('下单必须携带买家身份,核销/改价必须携带操作人身份', () => { + expect(apiCode).toContain('buyerId: uid'); + expect(apiCode).toContain('operatorId: normalizeUserId(operatorId)'); + expect(apiCode).not.toContain('operatorId = 1001'); + }); + }); +}); diff --git a/server/tests/ownership-hardening.spec.ts b/server/tests/ownership-hardening.spec.ts new file mode 100644 index 0000000..3960b5f --- /dev/null +++ b/server/tests/ownership-hardening.spec.ts @@ -0,0 +1,74 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * 第七批回归闸门:评价回复归属 / 服务上下架归属 / 通用核销归属 + * 以「源码契约断言」锁定安全边界。 + */ +describe('第七批安全加固闸门 (评价回复 / 服务上下架 / 通用核销归属)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts'); + const serverIndexPath = path.join(rootDir, 'server/src/index.ts'); + const apiPath = path.join(rootDir, 'pwuniapp/common/api.js'); + + const indexCode = fs.readFileSync(serverIndexPath, 'utf-8'); + const repoCode = fs.readFileSync(serverRepoPath, 'utf-8'); + const apiCode = fs.readFileSync(apiPath, '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('仓储层必须提供 getReviewOwnership (基于 order_reviews.expert_id)', () => { + expect(repoCode).toContain('async getReviewOwnership('); + const start = repoCode.indexOf('async getReviewOwnership('); + const block = repoCode.slice(start, start + 400); + expect(block).toContain('SELECT expert_id FROM order_reviews'); + expect(block).toContain('getCompanionOwnership'); + }); + + it('回复路由必须校验归属', () => { + const block = routeBlock("app.post('/api/reviews/:id/reply'"); + expect(block).toContain('repo.getReviewOwnership(reviewId)'); + expect(block).toContain("assertCompanionActor(req, own, '回复评价')"); + }); + }); + + describe('2. 服务上下架越权', () => { + it('仓储层必须提供 getExpertServiceOwnership (基于 expert_services.expert_id)', () => { + expect(repoCode).toContain('async getExpertServiceOwnership('); + const start = repoCode.indexOf('async getExpertServiceOwnership('); + const block = repoCode.slice(start, start + 800); + expect(block).toContain('SELECT expert_id FROM expert_services'); + expect(block).toContain('getCompanionOwnership'); + }); + + it('上下架路由必须校验归属', () => { + const block = routeBlock("app.put('/api/companions/:id/services/:serviceId/status'"); + expect(block).toContain('repo.getExpertServiceOwnership(serviceId)'); + expect(block).toContain("assertCompanionActor(req, own, '调整服务上下架')"); + }); + }); + + describe('3. 通用核销归属 (生产闸门)', () => { + it('生产环境必须校验核销人:活动=发起人,订单=承接大神', () => { + const block = routeBlock("app.post('/api/verify/universal'"); + expect(block).toContain('IS_PRODUCTION && !isVerifiedAdminRequest(req)'); + expect(block).toContain("info.type === 'ACTIVITY'"); + expect(block).toContain('Number(info.creatorId) === operator'); + expect(block).toContain('own!.companionIds.includes(operator)'); + expect(block).toContain('无权核销该门票'); + }); + }); + + describe('4. 小程序端写接口携带真实身份', () => { + it('回复评价/切换服务状态必须携带当前用户身份', () => { + expect(apiCode).toContain('{ replyContent, userId: normalizeUserId() }'); + expect(apiCode).toContain('{ status, userId: normalizeUserId() }'); + }); + }); +}); diff --git a/server/tests/rbac-permission.spec.ts b/server/tests/rbac-permission.spec.ts index efd978b..8dd6f3a 100644 --- a/server/tests/rbac-permission.spec.ts +++ b/server/tests/rbac-permission.spec.ts @@ -708,5 +708,18 @@ describe('RBAC Permission Architecture (TDD 全流程多角色权限与越权防 expect(content).toContain('/pages/user/index'); } }); + + it('大神端冷启动或刷新进入小程序首页时,首页与 App.vue 必须自动路由至大神接单大厅', () => { + const indexPath = path.resolve(__dirname, '../../pwuniapp/pages/index/index.vue'); + const indexContent = fs.readFileSync(indexPath, 'utf-8'); + expect(indexContent).toContain('checkExpertRedirect'); + expect(indexContent).toContain('/pages/expert/hall'); + expect(indexContent).toContain('ROLE_TYPES.EXPERT'); + + const appPath = path.resolve(__dirname, '../../pwuniapp/App.vue'); + const appContent = fs.readFileSync(appPath, 'utf-8'); + expect(appContent).toContain('/pages/expert/hall'); + expect(appContent).toContain('ROLE_TYPES.EXPERT'); + }); }); }); diff --git a/server/tests/security-hardening.spec.ts b/server/tests/security-hardening.spec.ts new file mode 100644 index 0000000..64db7e0 --- /dev/null +++ b/server/tests/security-hardening.spec.ts @@ -0,0 +1,116 @@ +// 已读取AGENTS.md +import * as fs from 'fs'; +import * as path from 'path'; +import { ContentSecurityService } from '../src/core/security/content-security.service'; + +/** + * 第三批高危项回归闸门:友圈动态越权删除 / 组局解散提权 / 微信内容安全真实对接 + * 以「源码契约断言 + 服务行为断言」锁定安全边界,防止后续重构悄然后退。 + */ +describe('第三批安全加固闸门 (动态越权删除 / 组局解散提权 / 真实微信内容安全)', () => { + const rootDir = path.resolve(__dirname, '../../'); + const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts'); + const serverIndexPath = path.join(rootDir, 'server/src/index.ts'); + const secServicePath = path.join(rootDir, 'server/src/core/security/content-security.service.ts'); + const pwuniappApiPath = path.join(rootDir, 'pwuniapp/common/api.js'); + + describe('1. 友圈动态仅作者本人可删除', () => { + it('deletePost 必须校验作者归属并拒绝越权删除', () => { + const code = fs.readFileSync(serverRepoPath, 'utf-8'); + expect(code).toContain('async deletePost('); + expect(code).toContain('无权删除他人发布的动态'); + expect(code).toContain('SELECT id, user_id FROM posts WHERE id = ? LIMIT 1'); + expect(code).toContain('DELETE FROM posts WHERE id = ? AND user_id = ?'); + }); + + it('删除路由必须要求显式身份,不得默认 1001', () => { + const code = fs.readFileSync(serverIndexPath, 'utf-8'); + const start = code.indexOf("app.delete('/api/posts/:id'"); + expect(start).toBeGreaterThan(-1); + const block = code.slice(start, start + 700); + expect(block).toContain("req.query.userId || req.body?.userId || req.headers['x-user-id'] || 0"); + expect(block).toContain('await assertUserNotBanned(uid'); + expect(block).not.toContain('|| 1001)'); + }); + + it('小程序删除动态必须携带真实登录用户ID', () => { + const code = fs.readFileSync(pwuniappApiPath, 'utf-8'); + 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).not.toContain('userId = 1001'); + }); + }); + + describe('2. 组局解散的管理员特权必须来自服务端签名 token', () => { + it('cancel 路由严禁信任客户端 operatorRole / x-user-role 提权', () => { + const code = fs.readFileSync(serverIndexPath, 'utf-8'); + expect(code).toContain('function isVerifiedAdminRequest('); + const start = code.indexOf("app.post('/api/activities/:id/cancel'"); + expect(start).toBeGreaterThan(-1); + const block = code.slice(start, start + 900); + expect(block).toContain('isVerifiedAdminRequest(req)'); + expect(block).not.toContain("operatorRole === 'admin'"); + expect(block).not.toContain("req.headers['x-user-role']"); + }); + }); + + describe('3. 内容安全真实对接微信 msgSecCheck', () => { + it('服务必须提供可注入的远端真实审核器与异步检测方法', () => { + const code = fs.readFileSync(secServicePath, 'utf-8'); + expect(code).toContain('setRemoteChecker'); + expect(code).toContain('checkTextAsync'); + expect(code).toContain('RemoteTextChecker'); + }); + + it('index.ts 必须注册微信 msg_sec_check 真实调用并接入关键 UGC 路由', () => { + const code = fs.readFileSync(serverIndexPath, 'utf-8'); + expect(code).toContain('contentSecurityService.setRemoteChecker'); + expect(code).toContain('https://api.weixin.qq.com/wxa/msg_sec_check'); + expect(code).toContain('version: 2'); + expect(code).toContain('await contentSecurityService.checkTextAsync('); + }); + + it('checkTextAsync 在无远端审核器时降级本地词库:命中拦截、合规放行', async () => { + const svc = new ContentSecurityService(); + const hit = await svc.checkTextAsync('同城美女上门特殊服务'); + expect(hit.pass).toBe(false); + expect(hit.code).toBe(87014); + expect(hit.suggest).toBe('block'); + const ok = await svc.checkTextAsync('今晚一起打王者荣耀五排,辅助位缺一'); + expect(ok.pass).toBe(true); + expect(ok.code).toBe(0); + }); + + it('注入的远端审核器会被调用,risky 内容应被二次拦截', async () => { + const svc = new ContentSecurityService(); + let called = 0; + svc.setRemoteChecker(async (text) => { + called += 1; + if (text.includes('远端拦截')) { + return { pass: false, code: 87014, label: 'WECHAT_RISKY', suggest: 'block' as const, message: '内容涉嫌违规,已被微信内容安全系统拦截' }; + } + return null; + }); + expect(svc.hasRemoteChecker()).toBe(true); + + const risky = await svc.checkTextAsync('这条内容需要远端拦截处理'); + expect(called).toBe(1); + expect(risky.pass).toBe(false); + expect(risky.label).toBe('WECHAT_RISKY'); + + const normal = await svc.checkTextAsync('正常合规文本内容'); + expect(normal.pass).toBe(true); + }); + + it('远端审核器抛异常时必须 fail-open 降级本地,避免误杀正常内容', async () => { + const svc = new ContentSecurityService(); + svc.setRemoteChecker(async () => { + throw new Error('wechat api down'); + }); + const r = await svc.checkTextAsync('今晚一起打王者荣耀五排'); + expect(r.pass).toBe(true); + }); + }); +}); diff --git a/server/tests/wechat-pay-v3.spec.ts b/server/tests/wechat-pay-v3.spec.ts new file mode 100644 index 0000000..73d4bec --- /dev/null +++ b/server/tests/wechat-pay-v3.spec.ts @@ -0,0 +1,212 @@ +import crypto from 'crypto'; +import { + buildAuthorization, + buildJsapiPayParams, + verifyNotifySignature, + decryptNotifyResource, + resolvePlatformKey, + WechatPayConfig, +} from '../src/core/pay/wechat-pay-v3'; + +/** + * 微信支付 V3 加密管道行为测试 + * 通过内存生成 RSA 密钥对,离线验证:请求签名 / 回调验签 / APIv3 解密 / 小程序支付参数签名。 + * 这些是「真实收款对账」的安全基石,必须可被公钥验证且篡改必然失败。 + */ +describe('WechatPayV3 (支付加密管道)', () => { + let merchantPriv: string; + let merchantPub: string; + let platformPriv: string; + let platformPub: string; + let cfg: WechatPayConfig; + + beforeAll(() => { + const merchant = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + merchantPriv = merchant.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + merchantPub = merchant.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + + const platform = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + platformPriv = platform.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + platformPub = platform.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + + cfg = { + appId: 'wx_test_app', + mchId: '1600000000', + apiV3Key: 'A'.repeat(32), + serialNo: 'MERCHANTSERIAL001', + privateKey: merchantPriv, + }; + }); + + describe('1. 商户请求签名 (buildAuthorization)', () => { + it('生成的 Authorization 头包含 mchid/序列号 且签名可被商户公钥验证', () => { + const body = JSON.stringify({ appid: cfg.appId, out_trade_no: 'TEST_1' }); + const auth = buildAuthorization({ + method: 'POST', + urlPath: '/v3/pay/transactions/jsapi', + body, + cfg, + }); + const m = auth.match( + /mchid="([^"]+)",nonce_str="([^"]+)",signature="([^"]+)",timestamp="([^"]+)",serial_no="([^"]+)"/ + ); + expect(m).not.toBeNull(); + expect(m![1]).toBe(cfg.mchId); + expect(m![5]).toBe(cfg.serialNo); + + const message = `POST\n/v3/pay/transactions/jsapi\n${m![4]}\n${m![2]}\n${body}\n`; + const ok = crypto + .createVerify('RSA-SHA256') + .update(message) + .verify(merchantPub, m![3], 'base64'); + expect(ok).toBe(true); + }); + + it('报文体被篡改后验签必然失败', () => { + const body = JSON.stringify({ out_trade_no: 'TEST_1' }); + const auth = buildAuthorization({ + method: 'POST', + urlPath: '/v3/pay/transactions/jsapi', + body, + cfg, + }); + const m = auth.match(/nonce_str="([^"]+)",signature="([^"]+)",timestamp="([^"]+)"/)!; + const tamperedMsg = `POST\n/v3/pay/transactions/jsapi\n${m[3]}\n${m[1]}\n${JSON.stringify({ out_trade_no: 'TEST_2' })}\n`; + const ok = crypto + .createVerify('RSA-SHA256') + .update(tamperedMsg) + .verify(merchantPub, m[2], 'base64'); + expect(ok).toBe(false); + }); + }); + + describe('2. 回调验签 (verifyNotifySignature)', () => { + it('平台私钥签名 + 平台公钥验签 通过', () => { + const pcfg: WechatPayConfig = { ...cfg, platformPublicKey: platformPub }; + const timestamp = String(Math.floor(Date.now() / 1000)); + const nonce = 'nonce123456'; + const body = JSON.stringify({ id: 'evt_1', event_type: 'TRANSACTION.SUCCESS' }); + const signature = crypto + .createSign('RSA-SHA256') + .update(`${timestamp}\n${nonce}\n${body}\n`) + .sign(platformPriv, 'base64'); + + expect( + verifyNotifySignature({ cfg: pcfg, timestamp, nonce, body, signature, serial: 'P' }) + ).toBe(true); + }); + + it('报文被篡改后验签失败', () => { + const pcfg: WechatPayConfig = { ...cfg, platformPublicKey: platformPub }; + const timestamp = String(Math.floor(Date.now() / 1000)); + const nonce = 'nonce123456'; + const body = JSON.stringify({ id: 'evt_1' }); + const signature = crypto + .createSign('RSA-SHA256') + .update(`${timestamp}\n${nonce}\n${body}\n`) + .sign(platformPriv, 'base64'); + + expect( + verifyNotifySignature({ cfg: pcfg, timestamp, nonce, body: body + 'x', signature, serial: 'P' }) + ).toBe(false); + }); + + it('时间戳超出 5 分钟容差时验签失败 (防重放)', () => { + const pcfg: WechatPayConfig = { ...cfg, platformPublicKey: platformPub }; + const timestamp = String(Math.floor(Date.now() / 1000) - 1000); + const nonce = 'nonce123456'; + const body = JSON.stringify({ id: 'evt_1' }); + const signature = crypto + .createSign('RSA-SHA256') + .update(`${timestamp}\n${nonce}\n${body}\n`) + .sign(platformPriv, 'base64'); + + expect( + verifyNotifySignature({ cfg: pcfg, timestamp, nonce, body, signature, serial: 'P' }) + ).toBe(false); + }); + + it('未配置平台公钥时失败关闭 (fail-closed)', () => { + const timestamp = String(Math.floor(Date.now() / 1000)); + const nonce = 'nonce123456'; + const body = '{}'; + const signature = 'anything'; + expect( + verifyNotifySignature({ cfg, timestamp, nonce, body, signature, serial: 'P' }) + ).toBe(false); + }); + }); + + describe('3. APIv3 回调报文解密 (decryptNotifyResource)', () => { + const apiV3Key = 'abcdefghijklmnopqrstuvwxyz123456'; + const nonce = 'randnonce12'; + const aad = 'transaction'; + + const encrypt = (plain: string) => { + const cipher = crypto.createCipheriv( + 'aes-256-gcm', + Buffer.from(apiV3Key), + Buffer.from(nonce) + ); + cipher.setAAD(Buffer.from(aad)); + const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return Buffer.concat([enc, cipher.getAuthTag()]).toString('base64'); + }; + + it('AES-256-GCM 解密还原原始业务报文', () => { + const plain = JSON.stringify({ + out_trade_no: 'WX_RECHARGE_1', + trade_state: 'SUCCESS', + transaction_id: '42000', + amount: { total: 1000 }, + }); + const decrypted = decryptNotifyResource(apiV3Key, { + ciphertext: encrypt(plain), + nonce, + associated_data: aad, + }); + expect(decrypted.out_trade_no).toBe('WX_RECHARGE_1'); + expect(decrypted.trade_state).toBe('SUCCESS'); + expect(decrypted.amount.total).toBe(1000); + }); + + it('密钥错误时解密抛错,绝不返回伪造数据', () => { + const plain = JSON.stringify({ out_trade_no: 'X' }); + const ciphertext = encrypt(plain); + expect(() => + decryptNotifyResource('z'.repeat(32), { ciphertext, nonce, associated_data: aad }) + ).toThrow(); + }); + + it('APIv3 密钥长度非 32 位时拒绝解密', () => { + expect(() => + decryptNotifyResource('short', { ciphertext: 'AAAA', nonce, associated_data: aad }) + ).toThrow(); + }); + }); + + describe('4. 小程序支付参数签名 (buildJsapiPayParams)', () => { + it('paySign 可被商户公钥验证', () => { + const params = buildJsapiPayParams(cfg, 'wx_test_123'); + expect(params.signType).toBe('RSA'); + expect(params.package).toBe('prepay_id=wx_test_123'); + const message = `${cfg.appId}\n${params.timeStamp}\n${params.nonceStr}\n${params.package}\n`; + const ok = crypto + .createVerify('RSA-SHA256') + .update(message) + .verify(merchantPub, params.paySign, 'base64'); + expect(ok).toBe(true); + }); + }); + + describe('5. 平台公钥解析 (resolvePlatformKey)', () => { + it('合法的公钥 PEM 可解析', () => { + expect(resolvePlatformKey(platformPub)).not.toBeNull(); + }); + + it('空值与非法内容返回 null', () => { + expect(resolvePlatformKey('')).toBeNull(); + expect(resolvePlatformKey('not-a-pem')).toBeNull(); + }); + }); +});