feat(closed-loop): complete end-to-end closed loops across miniapp, backend, and admin

这个提交包含在:
xcbyy
2026-10-14 10:13:50 +08:00
父节点 f8e81d2df4
当前提交 51c19d6063
共有 14 个文件被更改,包括 996 次插入 和 134 次删除

查看文件

@@ -93,28 +93,28 @@
</div>
</a-grid-item>
<!-- 4. 高危场所风控拦截 -->
<!-- 4. 违规举报与高危风控 -->
<a-grid-item :span="{ xs: 24, sm: 12, md: 6, lg: 6, xl: 6 }">
<div
class="decision-card purple-card"
@click="router.push('/safety/audit-log')"
@click="router.push('/safety/reports')"
>
<div class="card-icon-wrap">
<icon-safe class="card-icon" />
</div>
<div class="card-body">
<div class="card-label">高危私密场所拦截</div>
<div class="card-label">违规举报与风控工单</div>
<div class="card-num">
{{ todos.riskInterceptionsCount }}
<span class="unit">次风险阻断</span>
{{ todos.pendingReportsCount !== undefined ? todos.pendingReportsCount : todos.riskInterceptionsCount }}
<span class="unit">条待处理举报</span>
</div>
<div class="card-hint">
私人住宅/酒店客房红线拦截
含高危拦截 {{ todos.riskInterceptionsCount }} 次 · 点击处置
</div>
</div>
<div class="card-action">
<a-button type="outline" size="mini">
风控日志
举报工单
</a-button>
</div>
</div>
@@ -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)
);
});

查看文件

@@ -68,6 +68,24 @@
</router-link>
</div>
</div>
<!-- 4. 待核实违规举报 -->
<div v-for="rep in pendingReports" :key="rep.id" class="todo-item arb-item">
<div class="item-header">
<a-tag color="purple" size="small">违规举报</a-tag>
<span class="item-name">{{ rep.reporterName }} 举报 {{ rep.targetName }}</span>
</div>
<div class="item-desc">
{{ rep.reason }} · {{ rep.description || '待风控核实' }}
</div>
<div class="item-actions">
<router-link to="/safety/reports">
<a-button type="primary" size="mini">
核实处理
</a-button>
</router-link>
</div>
</div>
</div>
</a-card>
</template>
@@ -80,6 +98,7 @@
const pendingAudits = ref<any[]>([]);
const pendingWithdrawals = ref<any[]>([]);
const pendingArbitrations = ref<any[]>([]);
const pendingReports = ref<any[]>([]);
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 {
// 保持兜底

查看文件

@@ -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 [];
}
}

查看文件

@@ -82,21 +82,29 @@
</view>
</view>
<view class="author-actions">
<button
v-if="isPostOwner(post)"
class="btn-delete-post btn-active"
@click.stop="handleDeletePost(post)"
>
删除
</button>
<template v-if="isPostOwner(post)">
<button
class="join-btn btn-active"
style="margin-right: 12rpx;"
@click.stop="handleManageApplies(post)"
>
申请({{ post.applyCount || 0 }})
</button>
<button
class="btn-delete-post btn-active"
@click.stop="handleDeletePost(post)"
>
删除
</button>
</template>
<button
v-else
class="join-btn btn-active"
:class="{ 'btn-applied': post.hasApplied }"
:disabled="post.hasApplied"
:class="{ 'btn-applied': post.hasApplied && post.myApplyStatus !== 'REJECTED' }"
:disabled="post.hasApplied && post.myApplyStatus !== 'REJECTED'"
@click="handleJoinParty(post)"
>
{{ post.hasApplied ? '已申请' : (post.actionText || '聊一聊') }}
{{ post.myApplyStatus === 'ACCEPTED' ? '已同意上车' : (post.myApplyStatus === 'REJECTED' ? '重新申请' : (post.hasApplied ? '待房主确认' : (post.actionText || '聊一聊'))) }}
</button>
</view>
</view>
@@ -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);

查看文件

@@ -73,6 +73,15 @@
<!-- 3. 状态三:未认证 / 重新申请 (NOT_APPLIED / REJECTED) -->
<view class="form-container" v-else>
<!-- 审核驳回反馈横幅 (当后台驳回时展示具体原因并引导修改重提) -->
<view class="notice-banner reject-banner" v-if="certStatus === CERT_STATUS.REJECTED">
<image class="banner-icon-svg" :src="ICONS.alert" mode="aspectFit" />
<view class="banner-content">
<text class="banner-title">上次入驻审核未通过,请修改后重新提交</text>
<text class="banner-desc">驳回原因:{{ rejectReason || '提交的资质或语音未通过人工核验,请核对后重新提交' }}</text>
</view>
</view>
<!-- 平台合规与安全保障提示横幅 -->
<view class="notice-banner">
<image class="banner-icon-svg" :src="ICONS.shield" mode="aspectFit" />
@@ -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();
});
</script>
@@ -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;

查看文件

@@ -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'

查看文件

@@ -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();
}
}
});

查看文件

@@ -46,7 +46,7 @@
<text class="countdown-txt">{{ getOrderCountdown(order) }}</text>
</view>
<view class="status-pill" :class="order.status">
{{ getStatusText(order.status) }}
{{ getStatusText(order.status, order) }}
</view>
</view>
</view>
@@ -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;

查看文件

@@ -154,6 +154,12 @@
<text class="row-label">平台消息与通知</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view>
<view class="list-row btn-active" @click="openMyReportsModal">
<image class="row-svg" :src="ICONS.alert" mode="aspectFit" />
<text class="row-label">我的举报与维权反馈</text>
<text class="row-sub-tag" v-if="myReportsList.length > 0">{{ myReportsList.length }}条记录</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view>
<view class="list-row btn-active" @click="openFaq">
<image class="row-svg" :src="ICONS.messageSquare" mode="aspectFit" />
<text class="row-label">帮助中心与常见问题</text>
@@ -321,6 +327,50 @@
</view>
</view>
<!-- 我的举报与维权进度反馈弹窗 -->
<view class="profile-edit-modal-mask" v-if="showReportsModal" @click="showReportsModal = false">
<view class="profile-edit-modal withdraw-records-modal-box" @click.stop>
<view class="modal-header">
<text class="modal-title">我的举报与维权进度</text>
<view class="btn-close btn-active" @click="showReportsModal = false">
<image class="close-svg" :src="ICONS.close" mode="aspectFit" />
</view>
</view>
<scroll-view scroll-y class="records-modal-scroll" v-if="myReportsList.length > 0">
<view v-for="rpt in myReportsList" :key="rpt.id" class="wd-card-row">
<view class="wd-row-top">
<text class="wd-no">对象:{{ rpt.targetName || rpt.targetId }} · {{ rpt.reason }}</text>
<text
class="wd-badge"
:class="{
pending: rpt.status === '待处理',
success: rpt.status === '已核实处罚',
rejected: rpt.status === '已驳回'
}"
>
{{ rpt.status || '待处理' }}
</text>
</view>
<view class="wd-row-center" v-if="rpt.description">
<text class="wd-ch">举报说明:{{ rpt.description }}</text>
</view>
<view class="wd-row-bot">
<text class="wd-time">{{ rpt.createdAt }}</text>
<text class="wd-reason" v-if="rpt.handleNote || rpt.handleResult">
平台反馈:{{ rpt.handleNote || rpt.handleResult }}
</text>
<text class="wd-time" v-else>平台安全专员正在核实处理中</text>
</view>
</view>
</scroll-view>
<view class="empty-records-box" v-else>
<image class="empty-icon-svg" :src="ICONS.shield" mode="aspectFit" />
<text class="empty-txt">暂无举报与维权记录</text>
</view>
</view>
</view>
<!-- 底部自定义导航栏 (动态自适应发单人4项 / 接单人3项) -->
@@ -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) => {

查看文件

@@ -85,6 +85,14 @@
<button class="btn-poster-action secondary btn-active" @click="copyInviteLink">复制邀请链接</button>
<button class="btn-poster-action primary btn-active" @click="savePosterImage">保存海报至相册</button>
</view>
<view class="bind-inviter-card" style="margin-top: 24rpx; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 20rpx; padding: 20rpx 24rpx; display: flex; align-items: center; justify-content: space-between;">
<text style="font-size: 24rpx; color: #94A3B8;">{{ user.inviterId ? ('已绑定上级邀请人:PW' + user.inviterId) : '填写好友邀请码绑定上下级关系' }}</text>
<view v-if="!user.inviterId" style="display: flex; align-items: center; gap: 12rpx;">
<input v-model="bindCodeInput" placeholder="如 PW2001" placeholder-class="field-placeholder" style="width: 180rpx; height: 56rpx; background: rgba(0,0,0,0.25); border-radius: 12rpx; padding: 0 16rpx; font-size: 24rpx; color: #F8FAFC;" />
<button class="btn-active" style="margin: 0; height: 56rpx; line-height: 56rpx; padding: 0 24rpx; font-size: 24rpx; border-radius: 12rpx; background: #6366F1; color: #FFFFFF;" @click="handleBindInviteCode">绑定</button>
</view>
</view>
</view>
<!-- Tab 2: 下级团队列表 -->
@@ -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) {

查看文件

@@ -84,13 +84,16 @@ export async function initDatabase(): Promise<void> {
) 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<void> {
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 (_) {}

查看文件

@@ -424,14 +424,46 @@ export class MysqlRepository {
}
// 大神相关
private async resolveCompanionRow(id: number | string): Promise<any | null> {
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<any[]> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any[]> {
const [rows]: any = await pool.query(`
SELECT
@@ -1601,6 +1832,7 @@ export class MysqlRepository {
async handleArbitration(id: string, decision: string): Promise<any> {
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<any[]> {
const [rows]: any = await pool.query(`
async getReports(reporterId?: number): Promise<any[]> {
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<string, { total: number; pending: number; myStatus: string }>();
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<any> {
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<any[]> {
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<any> {
@@ -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<boolean> {
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<any> {
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<any> {
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<number, string>();
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) {

查看文件

@@ -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 {

查看文件

@@ -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');
});
});
});