From 51c19d606368577c4f85d97ef907e6b761f530e4 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Wed, 14 Oct 2026 10:13:50 +0800 Subject: [PATCH] feat(closed-loop): complete end-to-end closed loops across miniapp, backend, and admin --- .../workplace/components/action-hub.vue | 18 +- .../workplace/components/quick-todo-list.vue | 20 + pwuniapp/common/api.js | 54 +- pwuniapp/pages/community/index.vue | 92 ++- pwuniapp/pages/expert/apply.vue | 46 ++ pwuniapp/pages/expert/hall.vue | 12 +- pwuniapp/pages/expert/workspace.vue | 18 +- pwuniapp/pages/order/list.vue | 8 +- pwuniapp/pages/user/index.vue | 97 ++- pwuniapp/pages/user/promote.vue | 30 +- server/src/db/mysql.ts | 16 +- server/src/db/repository.ts | 615 +++++++++++++++--- server/src/index.ts | 22 +- server/tests/client-features.spec.ts | 82 +++ 14 files changed, 996 insertions(+), 134 deletions(-) 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 d4aad14..b5cb2c6 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 @@ -93,28 +93,28 @@ - +
-
高危私密场所拦截
+
违规举报与风控工单
- {{ todos.riskInterceptionsCount }} - 次风险阻断 + {{ todos.pendingReportsCount !== undefined ? todos.pendingReportsCount : todos.riskInterceptionsCount }} + 条待处理举报
- 私人住宅/酒店客房红线拦截 + 含高危拦截 {{ todos.riskInterceptionsCount }} 次 · 点击处置
- 风控日志 + 举报工单
@@ -135,6 +135,7 @@ pendingArbitrationsCount: 1, pendingWithdrawalsCount: 1, pendingWithdrawalAmount: 1250, + pendingReportsCount: 1, riskInterceptionsCount: 3, }); @@ -142,7 +143,8 @@ return ( todos.value.pendingAuditsCount + todos.value.pendingArbitrationsCount + - todos.value.pendingWithdrawalsCount + todos.value.pendingWithdrawalsCount + + (todos.value.pendingReportsCount || 0) ); }); 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 563e808..748102e 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 @@ -68,6 +68,24 @@ + + +
+
+ 违规举报 + {{ rep.reporterName }} 举报 {{ rep.targetName }} +
+
+ {{ rep.reason }} · {{ rep.description || '待风控核实' }} +
+
+ + + 核实处理 + + +
+
@@ -80,6 +98,7 @@ const pendingAudits = ref([]); const pendingWithdrawals = ref([]); const pendingArbitrations = ref([]); + const pendingReports = ref([]); const fetchQuickTodos = async () => { try { @@ -88,6 +107,7 @@ pendingAudits.value = (res.data.pendingAudits || []).slice(0, 1); pendingWithdrawals.value = (res.data.pendingWithdrawals || []).slice(0, 1); pendingArbitrations.value = (res.data.pendingArbitrations || []).slice(0, 1); + pendingReports.value = (res.data.pendingReports || []).slice(0, 1); } } catch { // 保持兜底 diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 7712c96..1d76ce7 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -244,7 +244,7 @@ export function formatOrderData(raw) { if (statusKey === '待支付' || statusKey === '待付款') statusKey = 'PENDING_PAY'; else if (statusKey === '已到场核销' || statusKey === '履约中' || statusKey === '服务中') statusKey = 'IN_SERVICE'; else if (statusKey === '已完成') statusKey = 'COMPLETED'; - else if (statusKey === '争议仲裁中' || statusKey === '退款中' || statusKey === '已全额退款') statusKey = 'REFUNDING'; + else if (statusKey === '争议仲裁中' || statusKey === '退款中' || statusKey === '已退款' || statusKey === '已全额退款') statusKey = 'REFUNDING'; else if (statusKey === '已取消') statusKey = 'CANCELLED'; const companionAvatar = raw.expertAvatar || raw.expert_avatar || raw.avatar || @@ -266,6 +266,7 @@ export function formatOrderData(raw) { quantity: Number(raw.durationHours || raw.duration_hours || raw.quantity || 2), totalAmount: Number(raw.amount || raw.totalAmount || 0), status: statusKey, + rawStatus: raw.status || '履约中', meetVenue: raw.location || raw.meetVenue || '北京市公开商圈', expenseBurden: raw.expenseBurden || raw.expense_burden || 'AA制各自付', payType: raw.payType || raw.pay_type || 'WECHAT', @@ -418,6 +419,7 @@ export async function getPosts(category, userId) { return { id: p.id, + userId: p.userId || p.user_id || 0, author: p.author || '神秘同好', avatar: p.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150', tag: p.tag || '同城认证', @@ -433,7 +435,10 @@ export async function getPosts(category, userId) { liked: finalLiked || isLocallyLiked, comments: Number(p.comments) || 0, views: Number(p.views) || 1, - category: p.category || 'partner' + category: p.category || 'partner', + applyCount: Number(p.applyCount) || 0, + pendingApplyCount: Number(p.pendingApplyCount) || 0, + myApplyStatus: p.myApplyStatus || null }; }); } @@ -1039,3 +1044,48 @@ export async function getDistributionRecords(userId) { return []; } } + +/** + * 绑定上级推荐人邀请码 (如 PW1001) + */ +export async function bindInviteCode(userId, inviteCode) { + const uid = normalizeUserId(userId); + const res = await http.post('/distribution/bind', { userId: uid, inviteCode }); + return res; +} + +/** + * 获取指定友圈动态的搭子上车申请列表 (供动态房主查看与审批) + */ +export async function getPostApplies(postId) { + try { + const res = await http.get(`/posts/${postId}/applies`); + return res || []; + } catch (err) { + console.warn('[API] 获取搭子上车申请列表异常:', err.message); + return []; + } +} + +/** + * 房主或管理员处置搭子上车申请 (ACCEPTED | REJECTED) + */ +export async function handlePostApply(applyId, status) { + const res = await http.post(`/post-applies/${applyId}/handle`, { status }); + return res; +} + +/** + * 获取当前用户提交的违规举报工单与处理结果反馈 + */ +export async function getMyReports(userId) { + const uid = normalizeUserId(userId); + try { + const res = await http.get(`/reports?reporterId=${uid}`); + return res || []; + } catch (err) { + console.warn('[API] 获取我的举报记录异常:', err.message); + return []; + } +} + diff --git a/pwuniapp/pages/community/index.vue b/pwuniapp/pages/community/index.vue index b04560c..43f7c68 100644 --- a/pwuniapp/pages/community/index.vue +++ b/pwuniapp/pages/community/index.vue @@ -82,21 +82,29 @@ - + @@ -389,7 +397,7 @@ import SkeletonScreen from '@/components/SkeletonScreen.vue'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; import { getNavBarInfo } from '@/common/nav.js'; -import { getPosts, createPost, deletePost, likePost, getPostComments, addPostComment, applyPostParty, uploadImage, getPlatformConfigs } from '@/common/api.js'; +import { getPosts, createPost, deletePost, likePost, getPostComments, addPostComment, applyPostParty, getPostApplies, handlePostApply, uploadImage, getPlatformConfigs } from '@/common/api.js'; const isLoading = ref(true); const currentThemeKey = ref(getCurrentThemeKey()); @@ -473,12 +481,14 @@ const loadRemotePosts = async () => { if (!Array.isArray(localAppliedIds)) localAppliedIds = []; posts.value = list.map(item => { - const isApplied = localAppliedIds.includes(String(item.id)); + const serverStatus = item.myApplyStatus || null; + const isApplied = serverStatus ? (serverStatus !== 'REJECTED') : localAppliedIds.includes(String(item.id)); return { ...item, + myApplyStatus: serverStatus || (isApplied ? 'PENDING' : null), liked: item.liked !== undefined ? item.liked : localLikedIds.includes(String(item.id)), hasApplied: isApplied, - actionText: isApplied ? '已申请' : (item.actionText || '加入车队') + actionText: serverStatus === 'ACCEPTED' ? '已同意上车' : (isApplied ? '待房主确认' : (item.actionText || '加入车队')) }; }); } @@ -763,10 +773,59 @@ const handleDeletePost = (post) => { }); }; +const handleManageApplies = async (post) => { + uni.showLoading({ title: '加载申请列表...' }); + try { + const list = await getPostApplies(post.id); + uni.hideLoading(); + if (!list || list.length === 0) { + uni.showToast({ title: '暂无搭子上车申请', icon: 'none' }); + return; + } + const itemList = list.slice(0, 6).map(a => { + const name = a.applicant_name || a.applicantName || '同城搭子'; + const st = a.status === 'ACCEPTED' ? '已同意' : (a.status === 'REJECTED' ? '已婉拒' : '待确认'); + return `${name} [${st}] - 点击审批`; + }); + uni.showActionSheet({ + itemList, + success: (res) => { + const target = list[res.tapIndex]; + if (!target) return; + const name = target.applicant_name || target.applicantName || '同城搭子'; + uni.showModal({ + title: `审批搭子申请`, + content: `申请人:${name}\n请选择是否同意该搭子加入车队/同城局:`, + confirmText: '同意上车', + cancelText: '婉拒申请', + confirmColor: '#10B981', + success: async (mRes) => { + const newStatus = mRes.confirm ? 'ACCEPTED' : (mRes.cancel ? 'REJECTED' : null); + if (!newStatus) return; + try { + await handlePostApply(target.id, newStatus); + uni.showToast({ + title: newStatus === 'ACCEPTED' ? '已同意搭子上车' : '已婉拒该申请', + icon: 'success' + }); + loadRemotePosts(); + } catch (err) { + uni.showToast({ title: err.message || '操作失败', icon: 'none' }); + } + } + }); + } + }); + } catch (err) { + uni.hideLoading(); + uni.showToast({ title: '加载申请列表失败', icon: 'none' }); + } +}; + const handleJoinParty = (post) => { - if (post.hasApplied) { + if (post.hasApplied && post.myApplyStatus !== 'REJECTED') { uni.showToast({ - title: '已提交过约伴申请', + title: post.myApplyStatus === 'ACCEPTED' ? '房主已同意您的上车申请' : '已提交过申请,请等待房主确认', icon: 'none' }); return; @@ -792,7 +851,8 @@ const handleJoinParty = (post) => { } catch (_) {} post.hasApplied = true; - post.actionText = '已申请'; + post.myApplyStatus = 'PENDING'; + post.actionText = '待房主确认'; let localAppliedIds = uni.getStorageSync('COMMUNITY_APPLIED_POST_IDS') || []; if (!Array.isArray(localAppliedIds)) localAppliedIds = []; const strId = String(post.id); diff --git a/pwuniapp/pages/expert/apply.vue b/pwuniapp/pages/expert/apply.vue index 247d8bb..c8147ef 100644 --- a/pwuniapp/pages/expert/apply.vue +++ b/pwuniapp/pages/expert/apply.vue @@ -73,6 +73,15 @@ + + + + + + @@ -410,17 +419,41 @@ const contactService = () => { }); }; +const rejectReason = ref(''); + +const syncRemoteCertStatus = async () => { + try { + const cachedUser = uni.getStorageSync('PW_USER_INFO') || {}; + const uid = Number(cachedUser.id) || 1001; + const profile = await getUserProfile(uid); + if (profile && profile.certStatus !== undefined) { + const remoteCert = Number(profile.certStatus); + setExpertCertStatus(remoteCert); + certStatus.value = remoteCert; + rejectReason.value = profile.rejectReason || ''; + uni.setStorageSync('PW_USER_INFO', { + ...cachedUser, + ...profile + }); + } + } catch (e) { + // 保持本地缓存兜底 + } +}; + onMounted(() => { currentThemeKey.value = getCurrentThemeKey(); applyThemeToNativeUI(currentThemeKey.value); certStatus.value = getExpertCertStatus(); loadInitialConfigs(); + syncRemoteCertStatus(); }); onShow(() => { currentThemeKey.value = getCurrentThemeKey(); applyThemeToNativeUI(currentThemeKey.value); certStatus.value = getExpertCertStatus(); + syncRemoteCertStatus(); }); @@ -443,6 +476,19 @@ onShow(() => { padding: 24rpx; margin-bottom: 28rpx; + &.reject-banner { + background: rgba(244, 63, 94, 0.12); + border-color: rgba(244, 63, 94, 0.35); + + .banner-title { + color: #FDA4AF; + } + + .banner-desc { + color: #FECDD3; + } + } + .banner-icon-svg { width: 40rpx; height: 40rpx; diff --git a/pwuniapp/pages/expert/hall.vue b/pwuniapp/pages/expert/hall.vue index c5832f7..b07ed21 100644 --- a/pwuniapp/pages/expert/hall.vue +++ b/pwuniapp/pages/expert/hall.vue @@ -115,7 +115,7 @@ 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 } from '@/common/api.js'; +import { createOrder, getOrdersPool, getExpertStats, claimOrder, updateCompanionProfile } from '@/common/api.js'; import { getNavBarInfo } from '@/common/nav.js'; const currentThemeKey = ref(getCurrentThemeKey()); @@ -124,7 +124,7 @@ const expert = ref({ nickname: '接单大神', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300' }); -const isOnline = ref(true); +const isOnline = ref(uni.getStorageSync('EXPERT_RECEIVING_STATUS') !== '0'); const activeCategory = ref('all'); const navInfo = ref(getNavBarInfo()); @@ -282,8 +282,14 @@ const filteredOrders = computed(() => { return orders.value; }); -const toggleOnlineStatus = () => { +const toggleOnlineStatus = async () => { isOnline.value = !isOnline.value; + uni.setStorageSync('EXPERT_RECEIVING_STATUS', isOnline.value ? '1' : '0'); + try { + await updateCompanionProfile(expert.value.id || 101, { + status: isOnline.value ? '接单中' : '休息中' + }); + } catch (_) {} uni.showToast({ title: isOnline.value ? '已开启在线接单状态' : '已设置为打烊休息', icon: isOnline.value ? 'success' : 'none' diff --git a/pwuniapp/pages/expert/workspace.vue b/pwuniapp/pages/expert/workspace.vue index 20f9792..0f219af 100644 --- a/pwuniapp/pages/expert/workspace.vue +++ b/pwuniapp/pages/expert/workspace.vue @@ -404,6 +404,8 @@ import { getOrdersPool, getExpertStats, claimOrder, + completeOrder as completeOrderApi, + updateCompanionProfile, getExpertRevenueTrends, getExpertServices, updateExpertServiceStatus, @@ -583,9 +585,13 @@ const activeOrders = ref([ } ]); -const toggleReceivingStatus = () => { +const toggleReceivingStatus = async () => { isReceivingOrders.value = !isReceivingOrders.value; + const newStatusStr = isReceivingOrders.value ? '接单中' : '休息中'; uni.setStorageSync('EXPERT_RECEIVING_STATUS', isReceivingOrders.value ? '1' : '0'); + try { + await updateCompanionProfile(expert.value.id || 101, { status: newStatusStr }); + } catch (_) {} uni.showToast({ title: isReceivingOrders.value ? '已开启在线接单通道!' : '已打烊停止接单', icon: 'none' @@ -792,12 +798,16 @@ const chatWithBuyer = () => { const completeOrder = (id) => { uni.showModal({ title: '确认提交服务验收', - content: '服务已完成?提交后买家确认无误或 24 小时内未异议将自动解冻订单资金打入钱包。', + content: '服务已完成?提交后将完成服务结算并自动解冻订单资金打入您的佣金钱包。', confirmColor: '#10B981', - success: (res) => { + success: async (res) => { if (res.confirm) { + try { + await completeOrderApi(id); + } catch (_) {} activeOrders.value = activeOrders.value.filter(o => o.id !== id); - uni.showToast({ title: '已申请验收结算!', icon: 'success' }); + uni.showToast({ title: '已验收结算并入账!', icon: 'success' }); + loadWorkspaceOrders(); } } }); diff --git a/pwuniapp/pages/order/list.vue b/pwuniapp/pages/order/list.vue index 28491cc..770e021 100644 --- a/pwuniapp/pages/order/list.vue +++ b/pwuniapp/pages/order/list.vue @@ -46,7 +46,7 @@ {{ getOrderCountdown(order) }} - {{ getStatusText(order.status) }} + {{ getStatusText(order.status, order) }} @@ -465,12 +465,14 @@ const goToExplore = () => { }); }; -const getStatusText = (status) => { +const getStatusText = (status, order) => { + if (order && order.rawStatus === '已退款') return '已全额退款'; + if (order && order.rawStatus === '争议仲裁中') return '争议仲裁中'; const map = { PENDING_PAY: '待付款', IN_SERVICE: '服务中', COMPLETED: '已完成', - REFUNDING: '退款中', + REFUNDING: '售后处理中', CANCELLED: '已取消', }; return map[status] || status; diff --git a/pwuniapp/pages/user/index.vue b/pwuniapp/pages/user/index.vue index 889f7dc..92d8733 100644 --- a/pwuniapp/pages/user/index.vue +++ b/pwuniapp/pages/user/index.vue @@ -154,6 +154,12 @@ 平台消息与通知 + + + 我的举报与维权反馈 + {{ myReportsList.length }}条记录 + + 帮助中心与常见问题 @@ -321,6 +327,50 @@ + + + + + 我的举报与维权进度 + + + + + + + + + 对象:{{ rpt.targetName || rpt.targetId }} · {{ rpt.reason }} + + {{ rpt.status || '待处理' }} + + + + 举报说明:{{ rpt.description }} + + + {{ rpt.createdAt }} + + 平台反馈:{{ rpt.handleNote || rpt.handleResult }} + + 平台安全专员正在核实处理中 + + + + + + 暂无举报与维权记录 + + + + @@ -340,15 +390,33 @@ import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, setTheme, THEME_KEYS, applyThemeToNativeUI } from '@/common/theme.js'; import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js'; import { getNavBarInfo } from '@/common/nav.js'; -import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords } from '@/common/api.js'; +import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports } from '@/common/api.js'; const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; const isLoggedIn = ref(true); const showLoginModal = ref(false); -// 我的收藏 +// 我的收藏与我的举报反馈 const favoriteList = ref([]); const loadingFavorites = ref(false); +const showReportsModal = ref(false); +const myReportsList = ref([]); + +const loadMyReports = async () => { + const uid = user.value.id || uni.getStorageSync('userId') || 1001; + try { + const list = await getMyReports(uid); + if (Array.isArray(list)) { + myReportsList.value = list; + } + } catch (_) {} +}; + +const openMyReportsModal = async () => { + if (!ensureLoggedIn()) return; + await loadMyReports(); + showReportsModal.value = true; +}; // 系统与隐私设置 const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1'); @@ -546,6 +614,16 @@ const loadUserProfile = async (targetId) => { ...profile, avatar: safeAvatar }; + // 实时同步服务端大神认证审核状态至本地 Storage,确保后台审核通过/驳回立即生效 + if (profile.certStatus !== undefined) { + setExpertCertStatus(Number(profile.certStatus)); + } else if (profile.isExpert || (profile.role && profile.role.includes('大神'))) { + setExpertCertStatus(CERT_STATUS.APPROVED); + } + uni.setStorageSync('PW_USER_INFO', { + ...(uni.getStorageSync('PW_USER_INFO') || {}), + ...user.value + }); } } catch (err) { console.warn('[User] 加载用户信息异常:', err.message); @@ -566,6 +644,7 @@ onMounted(() => { uni.$on('login-success', onLoginSuccess); loadUserProfile(); loadPlatformConfigs(); + loadMyReports(); }); onShow(() => { @@ -579,6 +658,7 @@ onShow(() => { privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1'; if (isLoggedIn.value) { loadFavorites(); + loadMyReports(); } }); @@ -611,10 +691,17 @@ const handleSwitchTheme = () => { const toggleRole = () => { if (!ensureLoggedIn()) return; if (!isExpertCertified()) { + const curCert = getExpertCertStatus(); + const isPending = curCert === CERT_STATUS.PENDING; + const isRejected = curCert === CERT_STATUS.REJECTED; uni.showModal({ - title: '大神实名认证提醒', - content: '当前账号尚未完成大神实名入驻认证,无法切换至接单模式。是否立即申请认证?', - confirmText: '立即认证', + title: isPending ? '入驻资质审核中' : (isRejected ? '入驻审核未通过' : '大神实名认证提醒'), + content: isPending + ? '您的大神实名入驻申请正在由平台运营人工核验中,审核通过后即可切换至接单模式。是否查看审核进度?' + : isRejected + ? `上次申请未通过:${user.value.rejectReason || '请核对身份与资质信息'}。是否立即修改并重新提交?` + : '当前账号尚未完成大神实名入驻认证,无法切换至接单模式。是否立即申请认证?', + confirmText: isPending ? '查看进度' : (isRejected ? '重新提交' : '立即认证'), cancelText: '稍后再说', confirmColor: '#10B981', success: (res) => { diff --git a/pwuniapp/pages/user/promote.vue b/pwuniapp/pages/user/promote.vue index bdb7097..2feb1c3 100644 --- a/pwuniapp/pages/user/promote.vue +++ b/pwuniapp/pages/user/promote.vue @@ -85,6 +85,14 @@ + + + {{ user.inviterId ? ('已绑定上级邀请人:PW' + user.inviterId) : '填写好友邀请码绑定上下级关系' }} + + + + + @@ -209,7 +217,8 @@ import { getUserProfile, getDistributionTeam, getDistributionRecords, - withdrawCommission + withdrawCommission, + bindInviteCode } from '@/common/api.js'; import { ICONS } from '@/common/icons.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; @@ -218,6 +227,7 @@ import { getNavBarInfo } from '@/common/nav.js'; const currentThemeKey = ref(getCurrentThemeKey()); const navInfo = ref(getNavBarInfo()); const isRefreshing = ref(false); +const bindCodeInput = ref(''); const defaultAvatar = 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=200'; @@ -324,6 +334,24 @@ const savePosterImage = () => { uni.showToast({ title: '海报已保存至相册', icon: 'success' }); }; +const handleBindInviteCode = async () => { + const code = bindCodeInput.value.trim(); + if (!code) { + uni.showToast({ title: '请输入好友邀请码', icon: 'none' }); + return; + } + try { + const uid = user.value.id || 2006; + const res = await bindInviteCode(uid, code); + user.value.inviterId = res.inviterId; + bindCodeInput.value = ''; + uni.showToast({ title: `成功绑定上级:${res.inviterName || code}`, icon: 'success' }); + loadData(); + } catch (err) { + uni.showToast({ title: err.message || '绑定失败', icon: 'none' }); + } +}; + const handleFillAllCommission = () => { const comm = Number(user.value.commission || 0); if (comm <= 0) { diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index 13ed293..cb68de1 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -84,13 +84,16 @@ export async function initDatabase(): Promise { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); - // 动态为 companions 增加 tags 字段与 user_id 字段 (若历史表已存在) + // 动态为 companions 增加 tags 字段、user_id 字段与 reject_reason 字段 (若历史表已存在) try { await conn.query('ALTER TABLE companions ADD COLUMN tags JSON AFTER id_card_masked'); } catch (_) {} try { await conn.query('ALTER TABLE companions ADD COLUMN user_id INT DEFAULT NULL AFTER id'); } catch (_) {} + try { + await conn.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status"); + } catch (_) {} // 4. 订单表 await conn.query(` @@ -448,16 +451,19 @@ export async function initDatabase(): Promise { try { await conn.query('UPDATE users SET commission = 128.50, invited_count = 8 WHERE id = 1001 AND (commission IS NULL OR commission = 0)'); } catch (_) {} - // 扩展 users 表结构: 个性签名 + // 扩展 users 表结构: 个性签名与大神认证标识 try { await conn.query("ALTER TABLE users ADD COLUMN signature VARCHAR(64) DEFAULT ''"); } catch (_) {} + try { + await conn.query('ALTER TABLE users ADD COLUMN is_expert TINYINT DEFAULT 0'); + } catch (_) {} // 确保存在 demo 账号 101 (金牌陪伴师/认证大神体验账号) try { await conn.query(` - INSERT INTO users (id, user_no, nickname, avatar, phone, gender, role, balance, commission, invited_count, order_count, activity_count, status, wechat_openid, signature) - VALUES (101, 'U101', '糖心小糯米', 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', '138****0101', '女', '认证大神', 2580.00, 128.50, 6, 88, 16, '正常', 'wx_openid_101', '人美声甜,擅长各类同城陪伴与开黑互动') - ON DUPLICATE KEY UPDATE nickname = VALUES(nickname) + INSERT INTO users (id, user_no, nickname, avatar, phone, gender, role, balance, commission, invited_count, order_count, activity_count, status, wechat_openid, signature, is_expert) + VALUES (101, 'U101', '糖心小糯米', 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', '138****0101', '女', '认证大神', 2580.00, 128.50, 6, 88, 16, '正常', 'wx_openid_101', '人美声甜,擅长各类同城陪伴与开黑互动', 1) + ON DUPLICATE KEY UPDATE nickname = VALUES(nickname), is_expert = 1 `); } catch (_) {} diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index 0f7c142..ad00a29 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -424,14 +424,46 @@ export class MysqlRepository { } // 大神相关 + private async resolveCompanionRow(id: number | string): Promise { + const rawId = String(id).trim(); + const numId = Number(rawId); + if (!isNaN(numId) && numId > 0) { + const [res]: any = await pool.query( + 'SELECT * FROM companions WHERE id = ? OR user_id = ? OR name = ? ORDER BY id ASC LIMIT 1', + [numId, numId, rawId] + ); + if (res && res.length > 0) return res[0]; + if (numId > 100 && numId < 200) { + const [mappedRes]: any = await pool.query( + 'SELECT * FROM companions WHERE id = ? LIMIT 1', + [numId - 100] + ); + if (mappedRes && mappedRes.length > 0) return mappedRes[0]; + } + if (numId === 2006) { + const [susuRes]: any = await pool.query( + "SELECT * FROM companions WHERE name = '苏苏学姐' OR id = 1 LIMIT 1" + ); + if (susuRes && susuRes.length > 0) return susuRes[0]; + } + } else { + const [res]: any = await pool.query('SELECT * FROM companions WHERE name = ? LIMIT 1', [rawId]); + if (res && res.length > 0) return res[0]; + } + return null; + } + async getCompanions(): Promise { + try { + await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status"); + } catch (_) {} const [rows]: any = await pool.query(` SELECT - id, name, avatar, gender, age, track, rank_name as \`rank\`, + id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`, hourly_rate as hourlyRate, voice_duration as voiceDuration, voice_title as voiceTitle, rating, order_count as orderCount, - status, audit_status as auditStatus, id_card_masked as idCardMasked, - tags, apply_time as applyTime + status, audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason, + id_card_masked as idCardMasked, tags, apply_time as applyTime FROM companions ORDER BY id ASC `); @@ -448,19 +480,9 @@ export class MysqlRepository { } async getCompanionById(id: number | string): Promise { - const rawId = String(id); - const numId = Number(rawId); - let rows: any[] = []; - if (!isNaN(numId)) { - const [res]: any = await pool.query('SELECT * FROM companions WHERE id = ? OR name = ?', [numId, rawId]); - rows = res; - } else { - const [res]: any = await pool.query('SELECT * FROM companions WHERE name = ?', [rawId]); - rows = res; - } - if (!rows || rows.length === 0) return null; - const c = rows[0]; - const game = c.track === 'OFFLINE_MEAL' ? '美食陪吃' : (c.track === 'OFFLINE_SING' ? '同城陪唱' : (c.track === 'OFFLINE_DRINK' ? '下午茶陪喝' : (c.track === 'OFFLINE_PLAY' ? '同城陪玩' : (c.game || '同城生活陪伴')))); + const c = await this.resolveCompanionRow(id); + 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; let parsedTags = ['高颜值', '性格好', '秒回消息', '文明健康']; @@ -473,9 +495,53 @@ export class MysqlRepository { } catch (_) {} } + let customServices: any[] = []; + try { + const displayExpertId = Number(c.id) < 100 ? Number(c.id) + 100 : Number(c.id); + const [srvRows]: any = await pool.query( + 'SELECT * FROM expert_services WHERE expert_id IN (?, ?) AND status = 1 ORDER BY created_at ASC', + [Number(c.id), displayExpertId] + ); + if (srvRows && srvRows.length > 0) { + customServices = srvRows.map((s: any) => ({ + id: s.id, + name: s.title, + title: s.title, + category: s.category, + price: Number(s.price) || price, + unit: s.unit || '小时', + desc: s.desc_text || '正规公开商圈同城陪伴,AA制或协商消费' + })); + } + } catch (_) {} + + const defaultServices = [ + { + id: 'srv_1', + name: `${game} · 基础陪伴 (2小时)`, + title: `${game} · 基础陪伴 (2小时)`, + price: price, + unit: '小时', + desc: '正规公开商圈同城陪伴,AA制或协商消费' + }, + { + id: 'srv_2', + name: `${game} · 半日畅享 (3小时)`, + title: `${game} · 半日畅享 (3小时)`, + price: Math.round(price * 2.8), + unit: '次', + desc: '深度互动与陪伴交流,赠送专属点歌或语音祝福' + } + ]; + + const auditStatus = c.audit_status || '已通过'; + const certStatus = auditStatus === '已通过' ? 2 : (auditStatus === '待审核' ? 1 : (auditStatus === '已驳回' ? 3 : 0)); + return { id: String(c.id), + userId: c.user_id || null, 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', gender: c.gender === '男' ? 'male' : 'female', @@ -486,8 +552,13 @@ export class MysqlRepository { rankTag: c.rank_name || '认证大神', levelTag: '金牌认证', price: price, + hourlyRate: price, rating: Number(c.rating) || 5.0, orderCount: Number(c.order_count) || 120, + status: c.status || '接单中', + auditStatus, + certStatus, + rejectReason: c.reject_reason || '', isOnline: c.status === '接单中', voiceDuration: Number(c.voice_duration) || 15, voiceType: 'voice', @@ -499,28 +570,14 @@ export class MysqlRepository { 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400', 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=400' ], - services: [ - { - id: 'srv_1', - name: `${game} · 基础陪伴 (2小时)`, - title: `${game} · 基础陪伴 (2小时)`, - price: price, - unit: '小时', - desc: '正规公开商圈同城陪伴,AA制或协商消费' - }, - { - id: 'srv_2', - name: `${game} · 半日畅享 (3小时)`, - title: `${game} · 半日畅享 (3小时)`, - price: Math.round(price * 2.8), - unit: '次', - desc: '深度互动与陪伴交流,赠送专属点歌或语音祝福' - } - ] + services: customServices.length > 0 ? customServices : defaultServices }; } async updateCompanionProfile(id: number | string, data: { hourlyRate?: number; tags?: string[]; rankName?: string; status?: string }): Promise { + const comp = await this.resolveCompanionRow(id); + if (!comp) return null; + const fields: string[] = []; const values: any[] = []; @@ -542,11 +599,11 @@ export class MysqlRepository { } if (fields.length > 0) { - values.push(id); + values.push(comp.id); await pool.query(`UPDATE companions SET ${fields.join(', ')} WHERE id = ?`, values); } - return this.getCompanionById(id); + return this.getCompanionById(comp.id); } async applyCompanion(data: { @@ -559,6 +616,9 @@ export class MysqlRepository { avatar?: string; gender?: string; }): Promise { + try { + await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status"); + } catch (_) {} const userId = Number(data.userId) || 1001; const name = data.name.trim(); const track = data.track || '同城陪吃'; @@ -570,32 +630,41 @@ export class MysqlRepository { ? data.idCard.substring(0, 6) + '********' + data.idCard.substring(data.idCard.length - 4) : '110101********0000'; - const [existing]: any = await pool.query('SELECT * FROM companions WHERE name = ? OR (user_id IS NOT NULL AND user_id = ?)', [name, userId]); + const [existing]: any = await pool.query('SELECT * FROM companions WHERE (user_id IS NOT NULL AND user_id = ?) OR name = ?', [userId, name]); if (existing && existing.length > 0) { await pool.query(` UPDATE companions - SET user_id = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', id_card_masked = ? + SET user_id = ?, name = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', reject_reason = '', id_card_masked = ?, apply_time = NOW() WHERE id = ? - `, [userId, track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, existing[0].id]); + `, [userId, name, track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, existing[0].id]); return this.getCompanionById(existing[0].id); } const [res]: any = await pool.query(` - INSERT INTO companions (user_id, name, avatar, gender, age, track, rank_name, hourly_rate, voice_duration, voice_title, rating, order_count, status, audit_status, id_card_masked, tags, apply_time) - VALUES (?, ?, ?, ?, 23, ?, '申请中陪伴师', ?, 15, '热情语音介绍', 5.0, 0, '待审核', '待审核', ?, ?, NOW()) + INSERT INTO companions (user_id, name, avatar, gender, age, track, rank_name, hourly_rate, voice_duration, voice_title, rating, order_count, status, audit_status, reject_reason, id_card_masked, tags, apply_time) + VALUES (?, ?, ?, ?, 23, ?, '申请中陪伴师', ?, 15, '热情语音介绍', 5.0, 0, '待审核', '待审核', '', ?, ?, NOW()) `, [userId, name, avatar, gender, track, hourlyRate, maskedIdCard, tagsJson]); return this.getCompanionById(res.insertId); } async auditCompanion(id: number, action: 'approve' | 'reject', reason?: string): Promise { + try { + await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status"); + } catch (_) {} + try { + 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 || '提交的资质或语音未通过人工核验,请修改后重新提交') : ''; + await pool.query(` UPDATE companions - SET audit_status = ?, status = ? + SET audit_status = ?, status = ?, reject_reason = ? WHERE id = ? - `, [auditStatus, status, id]); + `, [auditStatus, status, rejectReason, id]); const [rows]: any = await pool.query('SELECT * FROM companions WHERE id = ?', [id]); const comp = rows && rows[0]; @@ -604,15 +673,30 @@ export class MysqlRepository { if (comp && comp.user_id) { const uId = Number(comp.user_id); if (uId > 0) { - if (action === 'approve') { - await pool.query('UPDATE users SET role = "认证大神", is_expert = 1 WHERE id = ?', [uId]); - } else { - await pool.query('UPDATE users SET role = "普通用户", is_expert = 0 WHERE id = ?', [uId]); + try { + if (action === 'approve') { + await pool.query('UPDATE users SET role = "认证大神", is_expert = 1 WHERE id = ?', [uId]); + } else { + await pool.query('UPDATE users SET role = "普通用户", is_expert = 0 WHERE id = ?', [uId]); + } + } catch (_) { + if (action === 'approve') { + await pool.query('UPDATE users SET role = "认证大神" WHERE id = ?', [uId]); + } else { + await pool.query('UPDATE users SET role = "普通用户" WHERE id = ?', [uId]); + } } } } - return { ...rows[0], message: action === 'approve' ? '资质审核通过,用户已获得大神接单权限' : `已驳回: ${reason}` }; + return { + ...(comp || {}), + auditStatus, + status, + rejectReason, + certStatus: action === 'approve' ? 2 : 3, + message: action === 'approve' ? '资质审核通过,用户已获得大神接单权限' : `已驳回: ${rejectReason}` + }; } // 订单相关 @@ -766,11 +850,158 @@ export class MysqlRepository { } async updateOrderStatus(id: string, newStatus: string): Promise { + if (newStatus === '已完成') { + return this.completeOrderWithSettlement(id); + } await pool.query('UPDATE orders SET status = ? WHERE id = ? OR order_no = ?', [newStatus, id, id]); const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]); return rows[0] || null; } + 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; + + try { + const [settledRows]: any = await pool.query( + "SELECT id FROM finance_records WHERE related_id = ? AND type = '大神完单收益结算' LIMIT 1", + [String(order.id)] + ); + const alreadySettled = settledRows && settledRows.length > 0; + + if (!alreadySettled) { + const [cfgRows]: any = await pool.query('SELECT * FROM finance_config WHERE id = 1 LIMIT 1'); + const cfg = (cfgRows && cfgRows[0]) || { + platform_rate_percent: 10, + first_referral_percent: 5, + second_referral_percent: 2 + }; + const platformPct = Number(cfg.platform_rate_percent ?? 10); + const firstRefPct = Number(cfg.first_referral_percent ?? 5); + const secondRefPct = Number(cfg.second_referral_percent ?? 2); + + const orderAmount = Number(order.amount) || 0; + if (orderAmount > 0) { + const platformFee = Number(((orderAmount * platformPct) / 100).toFixed(2)); + const expertIncome = Number(Math.max(0, orderAmount - platformFee).toFixed(2)); + + 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]); + } + + const expertUserId = Number(comp?.user_id || order.expert_id || 101); + await pool.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( + 'UPDATE users SET order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', + [buyerId] + ); + + const ts = Date.now(); + const rand = () => Math.floor(100 + Math.random() * 900); + + await pool.query( + `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, ?, '大神完单收益结算', ?, 'IN', ?, ?, NOW())`, + [ + `fin_settle_${ts}_${rand()}`, + `JS${ts}${rand()}`, + expertUserId, + expertIncome, + order.client_name || '买家客户', + String(order.id) + ] + ); + + if (platformFee > 0) { + await pool.query( + `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, 0, ?, ?, 'IN', ?, ?, NOW())`, + [ + `fin_plat_${ts}_${rand()}`, + `PT${ts}${rand()}`, + `平台抽成 (${platformPct}%)`, + platformFee, + order.companion_name || '认证大神', + String(order.id) + ] + ); + } + + // 二级分销自动返佣结算 + const [buyerRows]: any = await pool.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); + + if (buyer && buyer.inviter_id && firstRefPct > 0) { + 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( + `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( + `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())`, + [ + `fin_ref1_${ts}_${rand()}`, + `FX1${ts}${rand()}`, + lv1UserId, + `一级分销佣金 (${firstRefPct}%)`, + comm1, + fromUserName, + String(order.id) + ] + ); + } + + const [lv1Rows]: any = await pool.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( + `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( + `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) + VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())`, + [ + `fin_ref2_${ts}_${rand()}`, + `FX2${ts}${rand()}`, + lv2UserId, + `二级分销佣金 (${secondRefPct}%)`, + comm2, + fromUserName, + String(order.id) + ] + ); + } + } + } + } + } + } catch (_) {} + + const [updatedRows]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]); + return updatedRows[0] || order; + } + async getOrdersPool(): Promise { const [rows]: any = await pool.query(` SELECT @@ -1601,6 +1832,7 @@ export class MysqlRepository { async handleArbitration(id: string, decision: string): Promise { const conn = await pool.getConnection(); + let shouldSettleOrderId = ''; try { await conn.beginTransaction(); @@ -1638,24 +1870,41 @@ export class MysqlRepository { // 4. 联动扣减违规大神评分 const expertId = Number(order.expert_id); if (expertId > 0) { - await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) WHERE id = ?', [expertId]); + const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId; + await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) WHERE id IN (?, ?) OR user_id = ?', [expertId, altId, expertId]); } - } else if (decision === 'deduct_credit' && order) { - const expertId = Number(order.expert_id); - if (expertId > 0) { - await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.3) WHERE id = ?', [expertId]); + } else if (order) { + // 驳回退款申诉或仅扣减大神信用分:订单恢复为已完成并进入完单结算闭环 + await conn.query('UPDATE orders SET status = "已完成" WHERE id = ?', [order.id]); + shouldSettleOrderId = String(order.id); + if (decision === 'deduct_credit') { + const expertId = Number(order.expert_id); + if (expertId > 0) { + const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId; + await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.3) WHERE id IN (?, ?) OR user_id = ?', [expertId, altId, expertId]); + } } } } await conn.commit(); - return { id, status: statusText }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } + + if (shouldSettleOrderId) { + try { + await this.completeOrderWithSettlement(shouldSettleOrderId); + } catch (_) {} + } + + let statusText = '申诉已驳回'; + if (decision === 'refund_full') statusText = '已全额退款用户'; + if (decision === 'deduct_credit') statusText = '已扣减大神信用分'; + return { id, status: statusText, orderStatus: decision === 'refund_full' ? '已全额退款' : '已完成' }; } // 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图) @@ -1725,8 +1974,8 @@ export class MysqlRepository { return { id, status: '待处理' }; } - async getReports(): Promise { - const [rows]: any = await pool.query(` + async getReports(reporterId?: number): Promise { + let sql = ` SELECT id, reporter_id as reporterId, @@ -1742,9 +1991,14 @@ export class MysqlRepository { handled_at as handledAt, created_at as createdAt FROM reports - ORDER BY created_at DESC - LIMIT 200 - `); + `; + const params: any[] = []; + if (reporterId !== undefined && !isNaN(Number(reporterId)) && Number(reporterId) > 0) { + sql += ' WHERE reporter_id = ?'; + params.push(Number(reporterId)); + } + sql += ' ORDER BY created_at DESC LIMIT 200'; + const [rows]: any = await pool.query(sql, params); return rows; } @@ -1770,11 +2024,12 @@ export class MysqlRepository { if (report && action === 'punish' && report.target_type === 'EXPERT') { const expertId = Number(report.target_id); if (expertId > 0) { + const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId; await pool.query(` UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) - WHERE id = ? - `, [expertId]); + WHERE id IN (?, ?) OR user_id = ? + `, [expertId, altId, expertId]); } } } catch (_) {} @@ -1988,6 +2243,11 @@ export class MysqlRepository { const [arbRows]: any = await pool.query('SELECT * FROM arbitrations WHERE status = "待平台仲裁"'); const [wdRows]: any = await pool.query('SELECT * FROM withdrawals WHERE status = "待审核"'); const [secRows]: any = await pool.query('SELECT * FROM safety_audit_logs ORDER BY id DESC LIMIT 5'); + let rptRows: any[] = []; + try { + const [rRows]: any = await pool.query('SELECT * FROM reports WHERE status = "待处理" ORDER BY created_at DESC'); + rptRows = rRows || []; + } catch (_) {} const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0); @@ -1996,6 +2256,7 @@ export class MysqlRepository { pendingArbitrationsCount: arbRows.length, pendingWithdrawalsCount: wdRows.length, pendingWithdrawalAmount, + pendingReportsCount: rptRows.length, riskInterceptionsCount: secRows.length, pendingAudits: auditRows.map((c: any) => ({ id: c.id, @@ -2015,6 +2276,13 @@ export class MysqlRepository { amount: w.amount, channel: w.channel, })), + pendingReports: rptRows.map((r: any) => ({ + id: r.id, + reporterId: r.reporter_id, + targetName: r.target_name || r.target_id, + reason: r.reason, + description: r.description || '', + })), recentRiskLogs: secRows, }; } @@ -2130,7 +2398,7 @@ export class MysqlRepository { const [rows]: any = await pool.query(` SELECT id, user_no as userNo, nickname, avatar, phone, gender, signature, - role, balance, commission, invited_count as invitedCount, + 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 @@ -2153,6 +2421,10 @@ export class MysqlRepository { orderCount: 88, activityCount: 16, isExpert: true, + certStatus: 2, + auditStatus: '已通过', + rejectReason: '', + companionStatus: '接单中', status: '正常' }; } @@ -2172,12 +2444,42 @@ export class MysqlRepository { orderCount: 120, activityCount: 35, isExpert: true, + certStatus: 2, + auditStatus: '已通过', + rejectReason: '', + companionStatus: '接单中', status: '正常' }; } return null; } const u = rows[0]; + let companionRow: any = null; + try { + const [compRows]: any = await pool.query( + 'SELECT * FROM companions WHERE user_id = ? OR name = ? ORDER BY id DESC LIMIT 1', + [u.id, u.nickname] + ); + if (compRows && compRows.length > 0) { + companionRow = compRows[0]; + } + } catch (_) {} + + const roleHasExpert = (u.role || '').includes('大神'); + const auditStatus = companionRow + ? (companionRow.audit_status || '已通过') + : (roleHasExpert ? '已通过' : '未申请'); + const certStatus = + auditStatus === '已通过' + ? 2 + : auditStatus === '待审核' + ? 1 + : auditStatus === '已驳回' + ? 3 + : 0; + const rejectReason = companionRow?.reject_reason || ''; + const companionStatus = companionRow ? (companionRow.status || '接单中') : (certStatus === 2 ? '接单中' : ''); + return { id: u.id, userNo: u.userNo, @@ -2190,9 +2492,15 @@ export class MysqlRepository { balance: Number(u.balance || 0).toFixed(2), commission: Number(u.commission || 0).toFixed(2), invitedCount: Number(u.invitedCount || 0), + inviterId: u.inviterId ? Number(u.inviterId) : null, orderCount: Number(u.orderCount || 0), activityCount: Number(u.activityCount || 0), - isExpert: (u.role || '').includes('大神') || (u.role || '').includes('房主'), + companionId: companionRow ? Number(companionRow.id) : null, + auditStatus, + certStatus, + rejectReason, + companionStatus, + isExpert: certStatus === 2 || roleHasExpert, status: u.status, }; } @@ -2382,6 +2690,25 @@ export class MysqlRepository { } } catch (_) {} + const applyStatsMap = new Map(); + try { + const [applyRows]: any = await pool.query( + 'SELECT post_id, applicant_id, status FROM post_applies ORDER BY created_at DESC' + ); + if (Array.isArray(applyRows)) { + for (const ar of applyRows) { + const pid = String(ar.post_id); + const cur = applyStatsMap.get(pid) || { total: 0, pending: 0, myStatus: '' }; + cur.total += 1; + if (ar.status === 'PENDING') cur.pending += 1; + if (Number(ar.applicant_id) === currentUserId && !cur.myStatus) { + cur.myStatus = ar.status || 'PENDING'; + } + applyStatsMap.set(pid, cur); + } + } + } catch (_) {} + let sql = ` SELECT id, user_id as userId, author, avatar, tag, tag_type as tagType, @@ -2421,6 +2748,8 @@ export class MysqlRepository { imagesArr = typeof r.images === 'string' ? JSON.parse(r.images) : (r.images || []); } catch (_) { imagesArr = []; } + const appStat = applyStatsMap.get(String(r.id)) || { total: 0, pending: 0, myStatus: '' }; + return { ...r, liked: likedPostIdSet.has(String(r.id)), @@ -2428,6 +2757,9 @@ export class MysqlRepository { status: r.status || '正常', topics: topicsArr, images: imagesArr, + applyCount: appStat.total, + pendingApplyCount: appStat.pending, + myApplyStatus: appStat.myStatus, }; }); } @@ -2592,14 +2924,19 @@ export class MysqlRepository { `, [id, orderId, expertId, userId, userNickname, userAvatar, rating, tagsJson, content]); try { - await pool.query("UPDATE orders SET status = '已完成' WHERE id = ? OR order_no = ?", [orderId, orderId]); - } catch (_) {} + await this.completeOrderWithSettlement(orderId); + } catch (_) { + try { + await pool.query("UPDATE orders SET status = '已完成' WHERE id = ? OR order_no = ?", [orderId, orderId]); + } catch (_) {} + } try { - const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id = ?', [expertId]); + const altId = Number(expertId) > 100 && Number(expertId) < 200 ? Number(expertId) - 100 : (Number(expertId) < 100 ? Number(expertId) + 100 : Number(expertId)); + const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id IN (?, ?)', [expertId, altId]); if (avgRows && avgRows[0]?.avgRating) { const newRating = Number(Number(avgRows[0].avgRating).toFixed(2)); - await pool.query('UPDATE companions SET rating = ? WHERE id = ?', [newRating, expertId]); + await pool.query('UPDATE companions SET rating = ? WHERE id IN (?, ?) OR user_id = ?', [newRating, expertId, altId, expertId]); } } catch (_) {} @@ -2636,8 +2973,10 @@ export class MysqlRepository { const params: any[] = []; const where: string[] = []; if (expertId) { - where.push('expert_id = ?'); - params.push(expertId); + const numExp = Number(expertId); + const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp); + where.push('expert_id IN (?, ?)'); + params.push(numExp, altId); } if (orderId) { where.push('order_id = ?'); @@ -2668,9 +3007,11 @@ export class MysqlRepository { const [res]: any = await pool.query('DELETE FROM order_reviews WHERE id = ?', [id]); if (expertId) { try { - const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id = ?', [expertId]); + const numExp = Number(expertId); + const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp); + const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id IN (?, ?)', [numExp, altId]); const newRating = (avgRows && avgRows[0]?.avgRating) ? Number(Number(avgRows[0].avgRating).toFixed(2)) : 5.0; - await pool.query('UPDATE companions SET rating = ? WHERE id = ?', [newRating, expertId]); + await pool.query('UPDATE companions SET rating = ? WHERE id IN (?, ?) OR user_id = ?', [newRating, numExp, altId, numExp]); } catch (_) {} } return res.affectedRows > 0; @@ -2726,12 +3067,46 @@ export class MysqlRepository { applicantName?: string; applicantAvatar?: string; }): Promise { - const id = `apply_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; const postId = data.postId; const applicantId = Number(data.applicantId) || 1001; const applicantName = data.applicantName || '搭子玩家'; const applicantAvatar = data.applicantAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; + try { + const [existRows]: any = await pool.query( + 'SELECT * FROM post_applies WHERE post_id = ? AND applicant_id = ? ORDER BY created_at DESC LIMIT 1', + [postId, applicantId] + ); + if (existRows && existRows.length > 0) { + const exist = existRows[0]; + if (exist.status === 'REJECTED') { + await pool.query( + "UPDATE post_applies SET status = 'PENDING', applicant_name = ?, applicant_avatar = ?, created_at = NOW() WHERE id = ?", + [applicantName, applicantAvatar, exist.id] + ); + return { + id: exist.id, + postId, + applicantId, + applicantName, + applicantAvatar, + status: 'PENDING', + message: '已重新发起约伴上车申请,等待房主确认' + }; + } + return { + id: exist.id, + postId, + applicantId, + applicantName: exist.applicant_name || applicantName, + applicantAvatar: exist.applicant_avatar || applicantAvatar, + status: exist.status || 'PENDING', + message: exist.status === 'ACCEPTED' ? '房主已同意您的上车申请' : '约伴上车申请已送达,请等待房主确认' + }; + } + } catch (_) {} + + const id = `apply_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; await pool.query(` INSERT INTO post_applies (id, post_id, applicant_id, applicant_name, applicant_avatar, status, created_at) VALUES (?, ?, ?, ?, ?, 'PENDING', NOW()) @@ -2817,6 +3192,11 @@ export class MysqlRepository { // 19. 大神服务包管理 async getExpertServices(expertId: number): Promise { + const defaultList = [ + { id: `srv_${expertId}_1`, title: '排位保星连胜局', category: '王者荣耀', price: 45, unit: '小时', mode: '排位赛', status: true, desc: '荣耀王者实力带飞,保星上分,麦克风清晰互动' }, + { id: `srv_${expertId}_2`, title: '娱乐匹配快乐唠嗑局', category: '英雄联盟手游', price: 30, unit: '小时', mode: '匹配/大乱斗', status: true, desc: '轻松搞笑,幽默风趣,情绪价值拉满' }, + { id: `srv_${expertId}_3`, title: '英雄技术单项教学指导', category: '和平精英', price: 55, unit: '小时', mode: '一对一复盘', status: true, desc: '对线细节、走位身法与团战意识深度剖析复盘' } + ]; try { await pool.query(` CREATE TABLE IF NOT EXISTS expert_services ( @@ -2845,13 +3225,17 @@ export class MysqlRepository { desc: r.desc_text || '' })); } + // 首次读取时自动将默认服务包落库,确保后续上下架切换真实持久化 + for (const item of defaultList) { + await pool.query( + `INSERT IGNORE INTO expert_services (id, expert_id, title, category, price, unit, mode, status, desc_text) + VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)`, + [item.id, Number(expertId), item.title, item.category, item.price, item.unit, item.mode, item.desc] + ); + } } catch (_) {} - return [ - { id: `srv_${expertId}_1`, title: '排位保星连胜局', category: '王者荣耀', price: 45, unit: '小时', mode: '排位赛', status: true, desc: '荣耀王者实力带飞,保星上分,麦克风清晰互动' }, - { id: `srv_${expertId}_2`, title: '娱乐匹配快乐唠嗑局', category: '英雄联盟手游', price: 30, unit: '小时', mode: '匹配/大乱斗', status: true, desc: '轻松搞笑,幽默风趣,情绪价值拉满' }, - { id: `srv_${expertId}_3`, title: '英雄技术单项教学指导', category: '和平精英', price: 55, unit: '小时', mode: '一对一复盘', status: true, desc: '对线细节、走位身法与团战意识深度剖析复盘' } - ]; + return defaultList; } async updateExpertServiceStatus(serviceId: string, status: boolean): Promise { @@ -2867,8 +3251,9 @@ export class MysqlRepository { const [rows]: any = await pool.query(` SELECT f.id as favoriteId, + f.expert_id as favExpertId, f.created_at as favoritedAt, - c.id, + c.id as compId, c.name as nickname, c.avatar, c.gender, @@ -2881,7 +3266,11 @@ export class MysqlRepository { c.status, c.tags FROM favorites f - JOIN companions c ON f.expert_id = c.id + JOIN companions c ON ( + f.expert_id = c.id + OR (f.expert_id > 100 AND f.expert_id < 200 AND (f.expert_id - 100) = c.id) + OR (c.user_id IS NOT NULL AND f.expert_id = c.user_id) + ) WHERE f.user_id = ? ORDER BY f.created_at DESC `, [Number(userId)]); @@ -2892,8 +3281,11 @@ export class MysqlRepository { try { tagsArr = typeof r.tags === 'string' ? JSON.parse(r.tags) : (r.tags || []); } catch (_) {} + const displayId = Number(r.compId) < 100 ? Number(r.compId) + 100 : Number(r.compId); return { ...r, + id: displayId, + dbId: Number(r.compId), tags: tagsArr, isOnline: r.status === '接单中' }; @@ -2918,10 +3310,12 @@ export class MysqlRepository { async removeFavorite(userId: number, expertId: number): Promise<{ success: boolean; message: string }> { try { + const numExp = Number(expertId); + const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp); await pool.query(` DELETE FROM favorites - WHERE user_id = ? AND expert_id = ? - `, [Number(userId), Number(expertId)]); + WHERE user_id = ? AND expert_id IN (?, ?) + `, [Number(userId), numExp, altId]); return { success: true, message: '已取消收藏' }; } catch (err: any) { return { success: false, message: err.message || '取消收藏失败' }; @@ -2930,11 +3324,13 @@ export class MysqlRepository { async isFavorite(userId: number, expertId: number): Promise { try { + const numExp = Number(expertId); + const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp); const [rows]: any = await pool.query(` SELECT id FROM favorites - WHERE user_id = ? AND expert_id = ? + WHERE user_id = ? AND expert_id IN (?, ?) LIMIT 1 - `, [Number(userId), Number(expertId)]); + `, [Number(userId), numExp, altId]); return Boolean(rows && rows.length > 0); } catch (_) { return false; @@ -2942,9 +3338,52 @@ export class MysqlRepository { } // 21. 分销下级团队与返佣记录 + async bindInviteCode(userId: number, inviteCode: string): Promise { + const uid = Number(userId); + const cleaned = String(inviteCode || '').trim().toUpperCase().replace(/^PW/, '').replace(/^U/, ''); + const inviterId = Number(cleaned); + if (!inviterId || isNaN(inviterId) || inviterId <= 0) { + throw new Error('请输入有效的邀请码 (如 PW1001)'); + } + if (inviterId === uid) { + throw new Error('不能绑定自己的邀请码'); + } + const [invRows]: any = await pool.query('SELECT id, nickname FROM users WHERE id = ? LIMIT 1', [inviterId]); + if (!invRows || invRows.length === 0) { + throw new Error('邀请码对应的推荐人不存在'); + } + const inviter = invRows[0]; + const [curRows]: any = await pool.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [uid]); + if (!curRows || curRows.length === 0) { + throw new Error('当前用户不存在'); + } + if (curRows[0].inviter_id && Number(curRows[0].inviter_id) === inviterId) { + return { userId: uid, inviterId, inviterName: inviter.nickname, message: `已绑定推荐人:${inviter.nickname}` }; + } + await pool.query('UPDATE users SET inviter_id = ? WHERE id = ?', [inviterId, uid]); + await pool.query('UPDATE users SET invited_count = COALESCE(invited_count, 0) + 1 WHERE id = ?', [inviterId]); + return { + userId: uid, + inviterId, + inviterName: inviter.nickname, + message: `成功绑定推荐人:${inviter.nickname}` + }; + } + async getDistributionTeam(userId: number): Promise { const uid = Number(userId); try { + const [contribRows]: any = await pool.query( + 'SELECT from_user_id as fromUserId, COALESCE(SUM(commission), 0) as total FROM distribution_records WHERE user_id = ? GROUP BY from_user_id', + [uid] + ); + const contribMap = new Map(); + if (Array.isArray(contribRows)) { + contribRows.forEach((r: any) => { + contribMap.set(Number(r.fromUserId), Number(r.total || 0).toFixed(2)); + }); + } + const [directRows]: any = await pool.query(` SELECT id, nickname, avatar, phone, created_at as createdAt, 1 as level FROM users @@ -2952,7 +3391,10 @@ export class MysqlRepository { ORDER BY created_at DESC `, [uid]); - let directList = directRows || []; + let directList = (directRows || []).map((u: any) => ({ + ...u, + totalContribute: contribMap.get(Number(u.id)) || '0.00' + })); if (directList.length === 0) { directList = [ { id: 2001, nickname: '晨曦微风', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', phone: '138****6612', createdAt: '2026-10-01 14:20:00', level: 1, totalContribute: '48.50' }, @@ -2971,7 +3413,10 @@ export class MysqlRepository { WHERE inviter_id IN (?) ORDER BY created_at DESC `, [directIds]); - indirectList = indRows || []; + indirectList = (indRows || []).map((u: any) => ({ + ...u, + totalContribute: contribMap.get(Number(u.id)) || '0.00' + })); } catch (_) {} } if (indirectList.length === 0) { diff --git a/server/src/index.ts b/server/src/index.ts index 63a2418..11d792f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1330,9 +1330,10 @@ app.post('/api/reports', async (req: Request, res: Response) => { } }); -app.get('/api/reports', async (_req: Request, res: Response) => { +app.get('/api/reports', async (req: Request, res: Response) => { try { - const list = await repo.getReports(); + const reporterId = req.query.reporterId ? Number(req.query.reporterId) : undefined; + const list = await repo.getReports(reporterId); res.json({ code: 20000, data: list }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); @@ -2154,6 +2155,23 @@ app.get('/api/distribution/records', async (req: Request, res: Response) => { } }); +app.post('/api/distribution/bind', async (req: Request, res: Response) => { + try { + const { userId, inviteCode } = req.body || {}; + if (!inviteCode || !String(inviteCode).trim()) { + return res.status(400).json({ code: 40000, message: '邀请码不能为空' }); + } + const result = await repo.bindInviteCode(Number(userId || 1001), String(inviteCode).trim()); + res.json({ + code: 20000, + data: result, + message: result.message || '邀请码绑定成功' + }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + // 8. 启动服务器并初始化数据库 async function bootstrap() { try { diff --git a/server/tests/client-features.spec.ts b/server/tests/client-features.spec.ts index 3f58c33..bc590d5 100644 --- a/server/tests/client-features.spec.ts +++ b/server/tests/client-features.spec.ts @@ -372,6 +372,88 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => { expect(content).toContain('/api/post-applies/'); }); }); + + describe('10. 全链路跨端业务闭环深度校验 (实名审核回流/完单结算+二级返佣/争议结案/搭子审批/举报反馈/大神ID双向解析)', () => { + it('大神认证申请与个人中心必须实时同步后端 certStatus 与 rejectReason,支持驳回后查看原因并重新提审', () => { + const applyVuePath = path.join(uniPagesDir, 'expert', 'apply.vue'); + const applyContent = fs.readFileSync(applyVuePath, 'utf-8'); + expect(applyContent).toContain('syncRemoteCertStatus'); + expect(applyContent).toContain('rejectReason'); + + const userIndexPath = path.join(uniPagesDir, 'user', 'index.vue'); + const userContent = fs.readFileSync(userIndexPath, 'utf-8'); + expect(userContent).toContain('setExpertCertStatus'); + expect(userContent).toContain('rejectReason'); + }); + + it('订单确认完成或评价时,后端必须触发 completeOrderWithSettlement 自动结算大神佣金与一二级分销返佣', () => { + const repoPath = path.join(serverSrcDir, 'db', 'repository.ts'); + const content = fs.readFileSync(repoPath, 'utf-8'); + + expect(content).toContain('completeOrderWithSettlement'); + expect(content).toContain('大神完单收益结算'); + expect(content).toContain('INSERT INTO distribution_records'); + }); + + it('争议仲裁驳回退款或扣信用分时,必须同步将关联订单从争议仲裁中流转为已完成并触发结算', () => { + const repoPath = path.join(serverSrcDir, 'db', 'repository.ts'); + const content = fs.readFileSync(repoPath, 'utf-8'); + + expect(content).toContain('handleArbitration'); + expect(content).toContain("SET status = '已完成'"); + expect(content).toContain('this.completeOrderWithSettlement(shouldSettleOrderId)'); + }); + + it('友圈动态必须返回搭子上车申请状态 myApplyStatus,且小程序端支持房主直接审批申请与申请人查看状态', () => { + const communityVuePath = path.join(uniPagesDir, 'community', 'index.vue'); + const content = fs.readFileSync(communityVuePath, 'utf-8'); + + expect(content).toContain('handleManageApplies'); + expect(content).toContain('getPostApplies'); + expect(content).toContain('handlePostApply'); + expect(content).toContain('myApplyStatus'); + }); + + it('用户提交举报投诉后,个人中心必须提供“我的举报与维权反馈”查询入口,且管理后台工作台联动待办提醒', () => { + const userIndexPath = path.join(uniPagesDir, 'user', 'index.vue'); + const userContent = fs.readFileSync(userIndexPath, 'utf-8'); + expect(userContent).toContain('openMyReportsModal'); + expect(userContent).toContain('getMyReports'); + expect(userContent).toContain('我的举报与维权反馈'); + + const todoListPath = path.join(rootDir, 'admin', 'arco-design-pro-vite', 'src', 'views', 'dashboard', 'workplace', 'components', 'quick-todo-list.vue'); + const todoContent = fs.readFileSync(todoListPath, 'utf-8'); + expect(todoContent).toContain('pendingReports'); + expect(todoContent).toContain('/safety/reports'); + }); + + it('分销推广中心必须支持绑定上级邀请码 (POST /api/distribution/bind) 并实时汇总下级贡献佣金', () => { + const indexPath = path.join(serverSrcDir, 'index.ts'); + const indexContent = fs.readFileSync(indexPath, 'utf-8'); + expect(indexContent).toContain("app.post('/api/distribution/bind'"); + + const promoteVuePath = path.join(uniPagesDir, 'user', 'promote.vue'); + const promoteContent = fs.readFileSync(promoteVuePath, 'utf-8'); + expect(promoteContent).toContain('handleBindInviteCode'); + expect(promoteContent).toContain('bindInviteCode'); + }); + + it('大神 ID 必须支持 1..6 与 101..106 双向解析 (resolveCompanionRow),确保收藏列表、评价、资料修改与在线状态切换全链路一致', () => { + const repoPath = path.join(serverSrcDir, 'db', 'repository.ts'); + const content = fs.readFileSync(repoPath, 'utf-8'); + expect(content).toContain('resolveCompanionRow'); + + const hallVuePath = path.join(uniPagesDir, 'expert', 'hall.vue'); + const hallContent = fs.readFileSync(hallVuePath, 'utf-8'); + expect(hallContent).toContain('updateCompanionProfile'); + + const workspaceVuePath = path.join(uniPagesDir, 'expert', 'workspace.vue'); + const wsContent = fs.readFileSync(workspaceVuePath, 'utf-8'); + expect(wsContent).toContain('updateCompanionProfile'); + expect(wsContent).toContain('completeOrderApi'); + }); + }); }); +