feat(expert): 大神服务包自助维护(新增/编辑/删除/上下架)+ 去掉演示服务包
- 后端:新增 POST/PUT/DELETE /api/companions/:id/services[...](仅本人大神或管理员),含名称/单价区间(平台定价规范)/长度/内容安全/每人20个上限校验 - 仓储:createExpertService/updateExpertService/deleteExpertService/countExpertServices/getCompanionByUserId;移除"首次读取自动生成 3 个演示服务包",并在建表后一次性清理历史演示数据 - 新增 GET /api/expert/me:工作台按登录用户解析真实大神档案(不再固定演示 id 101) - 小程序:api.js 增加 getMyExpertProfile/createExpertService/updateExpertService/deleteExpertService;工作台新增「新增服务」入口、每项「编辑/删除」按钮与服务包表单弹窗,收益/服务统计改用真实大神 id - 新增回归护栏 expert-service-crud.spec.ts(93 套件/662 用例全绿);冒烟 9/9(含跨大神越权 403、低价 400)
这个提交包含在:
@@ -150,6 +150,12 @@ MYSQL_DATABASE=peiwan
|
|||||||
- `40037` 排查:点后台「**拉取小程序模板列表(核对ID)**」,会显示当前 appId(脱敏)与其下真实存在的订阅消息模板;若后台填写的 ID 不在列表中,即为 appId 与建模板的小程序不一致(或用了公众号/服务号的模板ID)。用列表里的真实 ID 覆盖即可。
|
- `40037` 排查:点后台「**拉取小程序模板列表(核对ID)**」,会显示当前 appId(脱敏)与其下真实存在的订阅消息模板;若后台填写的 ID 不在列表中,即为 appId 与建模板的小程序不一致(或用了公众号/服务号的模板ID)。用列表里的真实 ID 覆盖即可。
|
||||||
6. 订阅消息为**一次性**:用户授权一次只能收 1 条;高频场景需每次动作前请求授权(代码已在下单/报名/提现前请求)。
|
6. 订阅消息为**一次性**:用户授权一次只能收 1 条;高频场景需每次动作前请求授权(代码已在下单/报名/提现前请求)。
|
||||||
|
|
||||||
|
### 大神服务包自助维护(已内置)
|
||||||
|
- 大神工作台可**自助新增/编辑/删除/上下架**服务包(`POST|PUT|DELETE /api/companions/:id/services[...]`,仅本人大神或管理员);
|
||||||
|
- 校验:名称必填 ≤40 字、单价须落在平台定价规范区间(`expert_rules` 的 min/maxHourlyRate)、说明 ≤200 字、通过内容安全检测、每人上限 20 个;
|
||||||
|
- `GET /api/expert/me` 用于工作台解析当前登录用户的大神档案(不再使用演示 id);
|
||||||
|
- 已移除"首次读取自动生成 3 个演示服务包"的旧逻辑,并在建表后一次性清理历史演示服务包。
|
||||||
|
|
||||||
### 站内消息通知中心(已内置,无需额外配置)
|
### 站内消息通知中心(已内置,无需额外配置)
|
||||||
- 关键节点(订单开始服务/接单/拒单/申请完成/完成结算/提现到账/报名成功/退款受理)都会写入 `user_notifications`;
|
- 关键节点(订单开始服务/接单/拒单/申请完成/完成结算/提现到账/报名成功/退款受理)都会写入 `user_notifications`;
|
||||||
- 小程序「个人中心 → 消息通知」可查看历史与未读数(`GET /api/notifications`、`POST /api/notifications/read`);
|
- 小程序「个人中心 → 消息通知」可查看历史与未读数(`GET /api/notifications`、`POST /api/notifications/read`);
|
||||||
|
|||||||
@@ -1034,6 +1034,44 @@ export async function updateExpertServiceStatus(expertId = 101, serviceId, statu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户的大神档案(大神工作台使用)
|
||||||
|
*/
|
||||||
|
export async function getMyExpertProfile(userId) {
|
||||||
|
const uid = normalizeUserId(userId);
|
||||||
|
try {
|
||||||
|
const res = await http.get(`/expert/me?userId=${uid}`);
|
||||||
|
return res || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[API] 获取大神档案异常:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增服务包(仅本人大神)
|
||||||
|
*/
|
||||||
|
export async function createExpertService(expertId, payload = {}) {
|
||||||
|
const res = await http.post(`/companions/${Number(expertId)}/services`, { ...payload, userId: normalizeUserId(payload.userId) });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑服务包(整体覆盖,仅本人大神)
|
||||||
|
*/
|
||||||
|
export async function updateExpertService(expertId, serviceId, payload = {}) {
|
||||||
|
const res = await http.put(`/companions/${Number(expertId)}/services/${serviceId}`, { ...payload, userId: normalizeUserId(payload.userId) });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除服务包(仅本人大神)
|
||||||
|
*/
|
||||||
|
export async function deleteExpertService(expertId, serviceId) {
|
||||||
|
const res = await http.delete(`/companions/${Number(expertId)}/services/${serviceId}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户收藏的陪玩列表
|
* 获取用户收藏的陪玩列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -156,6 +156,7 @@
|
|||||||
<text class="sec-title">陪玩服务管理 ({{ expertServices.length }})</text>
|
<text class="sec-title">陪玩服务管理 ({{ expertServices.length }})</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="service-actions">
|
<view class="service-actions">
|
||||||
|
<button class="btn-wd-records btn-active" @click="openServiceForm()">新增服务</button>
|
||||||
<button class="btn-wd-records btn-active" @click="goToWithdrawPage">收益提现</button>
|
<button class="btn-wd-records btn-active" @click="goToWithdrawPage">收益提现</button>
|
||||||
<button class="btn-wd-records btn-active" @click="openWithdrawRecords">提现记录</button>
|
<button class="btn-wd-records btn-active" @click="openWithdrawRecords">提现记录</button>
|
||||||
</view>
|
</view>
|
||||||
@@ -173,6 +174,10 @@
|
|||||||
<view class="srv-price-row">
|
<view class="srv-price-row">
|
||||||
<text class="srv-price">¥{{ srv.price }}</text>
|
<text class="srv-price">¥{{ srv.price }}</text>
|
||||||
<text class="srv-unit">/{{ srv.unit }}</text>
|
<text class="srv-unit">/{{ srv.unit }}</text>
|
||||||
|
<view class="srv-ops">
|
||||||
|
<text class="srv-op btn-active" @click.stop="openServiceForm(srv)">编辑</text>
|
||||||
|
<text class="srv-op srv-op-danger btn-active" @click.stop="removeService(srv)">删除</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -386,6 +391,48 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 服务包新增/编辑表单弹窗 -->
|
||||||
|
<view class="verify-modal-mask" v-if="serviceFormVisible" @click="serviceFormVisible = false">
|
||||||
|
<view class="verify-modal-content" @click.stop="stopPropagation">
|
||||||
|
<view class="modal-header">
|
||||||
|
<text class="modal-title">{{ serviceForm.id ? '编辑服务包' : '新增服务包' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="service-form-body">
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">服务名称</text>
|
||||||
|
<input class="form-input" v-model="serviceForm.title" maxlength="40" placeholder="如:排位保星连胜局" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">服务类目</text>
|
||||||
|
<input class="form-input" v-model="serviceForm.category" maxlength="32" placeholder="如:王者荣耀 / 同城陪伴" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">单价(元/{{ serviceForm.unit || '小时' }})</text>
|
||||||
|
<input class="form-input" v-model="serviceForm.price" type="digit" placeholder="如:45" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">计价单位</text>
|
||||||
|
<input class="form-input" v-model="serviceForm.unit" maxlength="16" placeholder="小时 / 局 / 次" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">服务模式</text>
|
||||||
|
<input class="form-input" v-model="serviceForm.mode" maxlength="32" placeholder="如:排位赛 / 一对一复盘" />
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">服务说明</text>
|
||||||
|
<textarea class="form-textarea" v-model="serviceForm.desc" maxlength="200" placeholder="介绍服务内容与亮点(最多 200 字)" />
|
||||||
|
</view>
|
||||||
|
<text class="form-hint">单价需符合平台定价规范(可在平台配置中查看上下限)。</text>
|
||||||
|
</view>
|
||||||
|
<view class="modal-actions">
|
||||||
|
<button class="btn-modal-cancel btn-active" @click="serviceFormVisible = false">取消</button>
|
||||||
|
<button class="btn-modal-primary btn-active" :disabled="serviceSaving" @click="submitServiceForm">
|
||||||
|
{{ serviceSaving ? '提交中…' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 底部切回玩家端 -->
|
<!-- 底部切回玩家端 -->
|
||||||
<view class="bottom-switch-wrap">
|
<view class="bottom-switch-wrap">
|
||||||
<button class="btn-back-player btn-active" @click="switchBackToPlayer">
|
<button class="btn-back-player btn-active" @click="switchBackToPlayer">
|
||||||
@@ -414,6 +461,10 @@ import {
|
|||||||
getExpertRevenueTrends,
|
getExpertRevenueTrends,
|
||||||
getExpertServices,
|
getExpertServices,
|
||||||
updateExpertServiceStatus,
|
updateExpertServiceStatus,
|
||||||
|
getMyExpertProfile,
|
||||||
|
createExpertService,
|
||||||
|
updateExpertService,
|
||||||
|
deleteExpertService,
|
||||||
getUserWithdrawals
|
getUserWithdrawals
|
||||||
} from '@/common/api.js';
|
} from '@/common/api.js';
|
||||||
|
|
||||||
@@ -491,20 +542,113 @@ const maxTrendValue = computed(() => {
|
|||||||
|
|
||||||
// 大神服务包管理状态
|
// 大神服务包管理状态
|
||||||
const expertServices = ref([]);
|
const expertServices = ref([]);
|
||||||
|
const serviceFormVisible = ref(false);
|
||||||
|
const serviceSaving = ref(false);
|
||||||
|
const serviceForm = ref({ id: '', title: '', category: '', price: '', unit: '小时', mode: '', desc: '' });
|
||||||
|
|
||||||
|
const currentExpertId = () => Number(expert.value.id) || 0;
|
||||||
|
|
||||||
|
// 解析当前登录用户的大神档案(避免工作台固定使用演示 id)
|
||||||
|
const loadExpertProfile = async () => {
|
||||||
|
try {
|
||||||
|
const profile = await getMyExpertProfile();
|
||||||
|
if (profile && profile.id) {
|
||||||
|
expert.value = {
|
||||||
|
...expert.value,
|
||||||
|
id: Number(profile.id),
|
||||||
|
nickname: profile.name || expert.value.nickname,
|
||||||
|
avatar: profile.avatar || expert.value.avatar,
|
||||||
|
track: profile.track || '',
|
||||||
|
hourlyRate: Number(profile.hourlyRate) || expert.value.hourlyRate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
const loadExpertServices = async () => {
|
const loadExpertServices = async () => {
|
||||||
try {
|
try {
|
||||||
const list = await getExpertServices(101);
|
const list = await getExpertServices(currentExpertId());
|
||||||
if (list && list.length > 0) {
|
expertServices.value = Array.isArray(list) ? list : [];
|
||||||
expertServices.value = list;
|
} catch (_) {
|
||||||
|
expertServices.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openServiceForm = (srv) => {
|
||||||
|
if (srv) {
|
||||||
|
serviceForm.value = {
|
||||||
|
id: srv.id,
|
||||||
|
title: srv.title,
|
||||||
|
category: srv.category,
|
||||||
|
price: String(srv.price),
|
||||||
|
unit: srv.unit || '小时',
|
||||||
|
mode: srv.mode || '',
|
||||||
|
desc: srv.desc || ''
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
serviceForm.value = { id: '', title: '', category: '', price: '', unit: '小时', mode: '', desc: '' };
|
||||||
|
}
|
||||||
|
serviceFormVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitServiceForm = async () => {
|
||||||
|
const f = serviceForm.value;
|
||||||
|
if (!String(f.title || '').trim()) {
|
||||||
|
uni.showToast({ title: '请填写服务名称', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const price = Number(f.price);
|
||||||
|
if (!price || price <= 0) {
|
||||||
|
uni.showToast({ title: '请填写有效单价', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
title: String(f.title).trim(),
|
||||||
|
category: String(f.category || '').trim(),
|
||||||
|
price,
|
||||||
|
unit: String(f.unit || '').trim() || '小时',
|
||||||
|
mode: String(f.mode || '').trim(),
|
||||||
|
desc: String(f.desc || '').trim()
|
||||||
|
};
|
||||||
|
serviceSaving.value = true;
|
||||||
|
try {
|
||||||
|
if (f.id) {
|
||||||
|
await updateExpertService(currentExpertId(), f.id, payload);
|
||||||
|
} else {
|
||||||
|
await createExpertService(currentExpertId(), payload);
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
serviceFormVisible.value = false;
|
||||||
|
uni.showToast({ title: f.id ? '服务包已更新' : '服务包已创建', icon: 'none' });
|
||||||
|
await loadExpertServices();
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: err.message || '保存失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
serviceSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeService = (srv) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除服务包',
|
||||||
|
content: `确认删除「${srv.title}」?删除后买家将无法预约该服务。`,
|
||||||
|
confirmColor: '#FF5F57',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return;
|
||||||
|
try {
|
||||||
|
await deleteExpertService(currentExpertId(), srv.id);
|
||||||
|
uni.showToast({ title: '已删除', icon: 'none' });
|
||||||
|
await loadExpertServices();
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: err.message || '删除失败', icon: 'none' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleServiceStatus = async (item) => {
|
const toggleServiceStatus = async (item) => {
|
||||||
const newStatus = !item.status;
|
const newStatus = !item.status;
|
||||||
try {
|
try {
|
||||||
await updateExpertServiceStatus(101, item.id, newStatus);
|
await updateExpertServiceStatus(currentExpertId(), item.id, newStatus);
|
||||||
item.status = newStatus;
|
item.status = newStatus;
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: newStatus ? `【${item.title}】已上架接单` : `【${item.title}】已暂停下架`,
|
title: newStatus ? `【${item.title}】已上架接单` : `【${item.title}】已暂停下架`,
|
||||||
@@ -637,7 +781,10 @@ const fillDemoCode = () => {
|
|||||||
|
|
||||||
const loadWorkspaceOrders = async () => {
|
const loadWorkspaceOrders = async () => {
|
||||||
try {
|
try {
|
||||||
const stats = await getExpertStats(101);
|
// 先解析真实大神档案,避免工作台使用演示 id
|
||||||
|
await loadExpertProfile();
|
||||||
|
const eid = currentExpertId() || 101;
|
||||||
|
const stats = await getExpertStats(eid);
|
||||||
if (stats) {
|
if (stats) {
|
||||||
if (stats.income !== undefined) todayIncome.value = stats.income;
|
if (stats.income !== undefined) todayIncome.value = stats.income;
|
||||||
if (stats.orders !== undefined) todayOrdersCount.value = stats.orders;
|
if (stats.orders !== undefined) todayOrdersCount.value = stats.orders;
|
||||||
@@ -672,7 +819,7 @@ const loadWorkspaceOrders = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trends = await getExpertRevenueTrends(101);
|
const trends = await getExpertRevenueTrends(currentExpertId() || 101);
|
||||||
if (trends && (trends.daily || trends.weekly || trends.monthly)) {
|
if (trends && (trends.daily || trends.weekly || trends.monthly)) {
|
||||||
rawTrendsData.value = trends;
|
rawTrendsData.value = trends;
|
||||||
}
|
}
|
||||||
@@ -769,7 +916,7 @@ const confirmCheckin = async () => {
|
|||||||
const grabOrder = async (order) => {
|
const grabOrder = async (order) => {
|
||||||
try {
|
try {
|
||||||
await claimOrder(order.id, {
|
await claimOrder(order.id, {
|
||||||
expertId: 101,
|
expertId: currentExpertId() || 101,
|
||||||
companionName: '我 (接单大神)',
|
companionName: '我 (接单大神)',
|
||||||
expertAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150'
|
expertAvatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150'
|
||||||
});
|
});
|
||||||
@@ -2009,4 +2156,60 @@ const switchBackToPlayer = () => {
|
|||||||
color: #64748B;
|
color: #64748B;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 服务包操作与表单弹窗 */
|
||||||
|
.srv-ops {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.srv-op {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6366F1;
|
||||||
|
padding: 4rpx 8rpx;
|
||||||
|
}
|
||||||
|
.srv-op-danger {
|
||||||
|
color: #FF5F57;
|
||||||
|
}
|
||||||
|
.service-form-body {
|
||||||
|
padding: 8rpx 4rpx 4rpx;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
width: 180rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94A3B8;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 68rpx;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
.form-textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 120rpx;
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #E2E8F0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #64748B;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -660,6 +660,16 @@ export async function initDatabase(): Promise<void> {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 一次性清理:历史版本会自动为大神生成 3 个演示服务包(现已改为大神自助维护),删除演示数据
|
||||||
|
try {
|
||||||
|
await conn.query(`
|
||||||
|
DELETE FROM expert_services
|
||||||
|
WHERE title IN ('排位保星连胜局', '娱乐匹配快乐唠嗑局', '英雄技术单项教学指导')
|
||||||
|
`);
|
||||||
|
} catch (_) {
|
||||||
|
// expert_services 尚未创建:忽略
|
||||||
|
}
|
||||||
|
|
||||||
// 慢查询优化:为高频查询列补充索引(幂等:已存在则忽略;表不存在也忽略)
|
// 慢查询优化:为高频查询列补充索引(幂等:已存在则忽略;表不存在也忽略)
|
||||||
// 注意:必须在所有建表语句之后执行,确保目标表已存在。
|
// 注意:必须在所有建表语句之后执行,确保目标表已存在。
|
||||||
const indexDefs: Array<[string, string, string]> = [
|
const indexDefs: Array<[string, string, string]> = [
|
||||||
|
|||||||
@@ -4978,58 +4978,163 @@ export class MysqlRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 19. 大神服务包管理
|
// 19. 大神服务包管理
|
||||||
async getExpertServices(expertId: number): Promise<any[]> {
|
/** 确保 expert_services 表与索引存在(懒建表) */
|
||||||
const defaultList = [
|
async ensureExpertServicesTable(): Promise<void> {
|
||||||
{ id: `srv_${expertId}_1`, title: '排位保星连胜局', category: '王者荣耀', price: 45, unit: '小时', mode: '排位赛', status: true, desc: '荣耀王者实力带飞,保星上分,麦克风清晰互动' },
|
await pool.query(`
|
||||||
{ id: `srv_${expertId}_2`, title: '娱乐匹配快乐唠嗑局', category: '英雄联盟手游', price: 30, unit: '小时', mode: '匹配/大乱斗', status: true, desc: '轻松搞笑,幽默风趣,情绪价值拉满' },
|
CREATE TABLE IF NOT EXISTS expert_services (
|
||||||
{ id: `srv_${expertId}_3`, title: '英雄技术单项教学指导', category: '和平精英', price: 55, unit: '小时', mode: '一对一复盘', status: true, desc: '对线细节、走位身法与团战意识深度剖析复盘' }
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
];
|
expert_id INT NOT NULL,
|
||||||
|
title VARCHAR(64) NOT NULL,
|
||||||
|
category VARCHAR(32) NOT NULL,
|
||||||
|
price DECIMAL(10, 2) NOT NULL,
|
||||||
|
unit VARCHAR(16) DEFAULT '小时',
|
||||||
|
mode VARCHAR(32) DEFAULT '排位/匹配',
|
||||||
|
status INT DEFAULT 1,
|
||||||
|
desc_text TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
try {
|
try {
|
||||||
await pool.query(`
|
await pool.query('ALTER TABLE expert_services ADD INDEX idx_srv_expert (expert_id)');
|
||||||
CREATE TABLE IF NOT EXISTS expert_services (
|
} catch (_) {
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
// 索引已存在:忽略
|
||||||
expert_id INT NOT NULL,
|
}
|
||||||
title VARCHAR(64) NOT NULL,
|
|
||||||
category VARCHAR(32) NOT NULL,
|
|
||||||
price DECIMAL(10, 2) NOT NULL,
|
|
||||||
unit VARCHAR(16) DEFAULT '小时',
|
|
||||||
mode VARCHAR(32) DEFAULT '排位/匹配',
|
|
||||||
status INT DEFAULT 1,
|
|
||||||
desc_text TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
try {
|
|
||||||
await pool.query('ALTER TABLE expert_services ADD INDEX idx_srv_expert (expert_id)');
|
|
||||||
} catch (_) {
|
|
||||||
// 索引已存在:忽略
|
|
||||||
}
|
|
||||||
const [rows]: any = await pool.query('SELECT * FROM expert_services WHERE expert_id = ? ORDER BY created_at ASC', [expertId]);
|
|
||||||
if (rows && rows.length > 0) {
|
|
||||||
return rows.map((r: any) => ({
|
|
||||||
id: r.id,
|
|
||||||
title: r.title,
|
|
||||||
category: r.category,
|
|
||||||
price: Number(r.price),
|
|
||||||
unit: r.unit || '小时',
|
|
||||||
mode: r.mode || '排位/匹配',
|
|
||||||
status: r.status === 1,
|
|
||||||
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 defaultList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mapExpertService(r: any): any {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
category: r.category,
|
||||||
|
price: Number(r.price),
|
||||||
|
unit: r.unit || '小时',
|
||||||
|
mode: r.mode || '排位/匹配',
|
||||||
|
status: r.status === 1,
|
||||||
|
desc: r.desc_text || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取某大神的服务包列表(无服务时返回空数组,不再自动生成演示服务包) */
|
||||||
|
async getExpertServices(expertId: number): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
await this.ensureExpertServicesTable();
|
||||||
|
const [rows]: any = await pool.query(
|
||||||
|
'SELECT * FROM expert_services WHERE expert_id = ? ORDER BY created_at ASC, id ASC',
|
||||||
|
[expertId]
|
||||||
|
);
|
||||||
|
return (rows || []).map((r: any) => this.mapExpertService(r));
|
||||||
|
} catch (e) {
|
||||||
|
logServerError('getExpertServices', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async countExpertServices(expertId: number): Promise<number> {
|
||||||
|
try {
|
||||||
|
const [rows]: any = await pool.query('SELECT COUNT(*) as c FROM expert_services WHERE expert_id = ?', [Number(expertId)]);
|
||||||
|
return Number((rows && rows[0] && rows[0].c) || 0);
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExpertServiceById(serviceId: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
await this.ensureExpertServicesTable();
|
||||||
|
const [rows]: any = await pool.query('SELECT * FROM expert_services WHERE id = ? LIMIT 1', [String(serviceId)]);
|
||||||
|
return rows && rows.length > 0 ? this.mapExpertService(rows[0]) : null;
|
||||||
|
} catch (e) {
|
||||||
|
logServerError('getExpertServiceById', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createExpertService(
|
||||||
|
expertId: number,
|
||||||
|
data: { title: string; category: string; price: number; unit?: string; mode?: string; desc?: string; status?: boolean }
|
||||||
|
): Promise<any> {
|
||||||
|
await this.ensureExpertServicesTable();
|
||||||
|
const id = `srv_${Number(expertId)}_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO expert_services (id, expert_id, title, category, price, unit, mode, status, desc_text, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
Number(expertId),
|
||||||
|
String(data.title),
|
||||||
|
String(data.category),
|
||||||
|
Number(data.price),
|
||||||
|
String(data.unit || '小时'),
|
||||||
|
String(data.mode || '排位/匹配'),
|
||||||
|
data.status === false ? 0 : 1,
|
||||||
|
String(data.desc || ''),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return this.getExpertServiceById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateExpertService(
|
||||||
|
serviceId: string,
|
||||||
|
data: { title?: string; category?: string; price?: number; unit?: string; mode?: string; desc?: string; status?: boolean }
|
||||||
|
): Promise<any | null> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
if (data.title !== undefined) {
|
||||||
|
fields.push('title = ?');
|
||||||
|
values.push(String(data.title));
|
||||||
|
}
|
||||||
|
if (data.category !== undefined) {
|
||||||
|
fields.push('category = ?');
|
||||||
|
values.push(String(data.category));
|
||||||
|
}
|
||||||
|
if (data.price !== undefined) {
|
||||||
|
fields.push('price = ?');
|
||||||
|
values.push(Number(data.price));
|
||||||
|
}
|
||||||
|
if (data.unit !== undefined) {
|
||||||
|
fields.push('unit = ?');
|
||||||
|
values.push(String(data.unit));
|
||||||
|
}
|
||||||
|
if (data.mode !== undefined) {
|
||||||
|
fields.push('mode = ?');
|
||||||
|
values.push(String(data.mode));
|
||||||
|
}
|
||||||
|
if (data.desc !== undefined) {
|
||||||
|
fields.push('desc_text = ?');
|
||||||
|
values.push(String(data.desc));
|
||||||
|
}
|
||||||
|
if (data.status !== undefined) {
|
||||||
|
fields.push('status = ?');
|
||||||
|
values.push(data.status ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (fields.length === 0) return this.getExpertServiceById(serviceId);
|
||||||
|
values.push(String(serviceId));
|
||||||
|
await pool.query(`UPDATE expert_services SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||||
|
return this.getExpertServiceById(serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteExpertService(serviceId: string): Promise<boolean> {
|
||||||
|
const [r]: any = await pool.query('DELETE FROM expert_services WHERE id = ?', [String(serviceId)]);
|
||||||
|
return Number((r && r.affectedRows) || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按登录用户解析其大神档案(大神工作台自助管理用) */
|
||||||
|
async getCompanionByUserId(userId: number): Promise<any | null> {
|
||||||
|
const uid = Number(userId) || 0;
|
||||||
|
if (uid <= 0) return null;
|
||||||
|
try {
|
||||||
|
const [rows]: any = await pool.query(
|
||||||
|
'SELECT * FROM companions WHERE user_id = ? ORDER BY id ASC LIMIT 1',
|
||||||
|
[uid]
|
||||||
|
);
|
||||||
|
return rows && rows.length > 0 ? rows[0] : null;
|
||||||
|
} catch (e) {
|
||||||
|
logServerError('getCompanionByUserId', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async updateExpertServiceStatus(serviceId: string, status: boolean): Promise<any> {
|
async updateExpertServiceStatus(serviceId: string, status: boolean): Promise<any> {
|
||||||
try {
|
try {
|
||||||
await pool.query('UPDATE expert_services SET status = ? WHERE id = ?', [status ? 1 : 0, serviceId]);
|
await pool.query('UPDATE expert_services SET status = ? WHERE id = ?', [status ? 1 : 0, serviceId]);
|
||||||
|
|||||||
@@ -4360,6 +4360,158 @@ app.put('/api/companions/:id/services/:serviceId/status', async (req: Request, r
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 大神工作台:读取当前登录用户的大神档案(用于自助管理服务包/收益)
|
||||||
|
app.get('/api/expert/me', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const uid = resolveSecureUserId(
|
||||||
|
req,
|
||||||
|
Number(req.query.userId || req.headers['x-user-id'] || 0),
|
||||||
|
'查看大神工作台'
|
||||||
|
);
|
||||||
|
const comp = await repo.getCompanionByUserId(uid);
|
||||||
|
if (!comp) {
|
||||||
|
return res.json({ code: 20000, data: null, message: '尚未入驻大神' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
code: 20000,
|
||||||
|
data: {
|
||||||
|
id: Number(comp.id),
|
||||||
|
name: comp.name,
|
||||||
|
avatar: comp.avatar,
|
||||||
|
track: comp.track,
|
||||||
|
hourlyRate: Number(comp.hourly_rate) || 0,
|
||||||
|
status: comp.status,
|
||||||
|
auditStatus: comp.audit_status,
|
||||||
|
isCompanion: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.status || 400;
|
||||||
|
const errCode = err.code || 40000;
|
||||||
|
res.status(statusCode).json({ code: errCode, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 服务包数量上限(按大神的服务项个数)
|
||||||
|
const SERVICE_MAX_PER_EXPERT = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验服务包提交内容(新增/编辑共用):名称、类目、单价区间(平台规则)、长度与内容安全。
|
||||||
|
* requireAll=true 表示名称与单价必填(新增与整体覆盖编辑)。
|
||||||
|
*/
|
||||||
|
async function validateExpertServicePayload(
|
||||||
|
req: Request
|
||||||
|
): Promise<{ title: string; category: string; price: number; unit: string; mode: string; desc: string }> {
|
||||||
|
const body = req.body || {};
|
||||||
|
const title = String(body.title == null ? '' : body.title).trim();
|
||||||
|
const category = String(body.category == null ? '' : body.category).trim() || '同城陪伴';
|
||||||
|
const unit = String(body.unit == null ? '' : body.unit).trim() || '小时';
|
||||||
|
const mode = String(body.mode == null ? '' : body.mode).trim() || '排位/匹配';
|
||||||
|
const desc = String(body.desc == null ? '' : body.desc).trim();
|
||||||
|
const price = Number(body.price);
|
||||||
|
|
||||||
|
const bad = (msg: string) => {
|
||||||
|
const e: any = new Error(msg);
|
||||||
|
e.status = 400;
|
||||||
|
e.code = 40000;
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!title) throw bad('服务名称不能为空');
|
||||||
|
if (title.length > 40) throw bad('服务名称最多 40 个字符');
|
||||||
|
if (category.length > 32) throw bad('服务类目最多 32 个字符');
|
||||||
|
if (unit.length > 16) throw bad('计价单位最多 16 个字符');
|
||||||
|
if (mode.length > 32) throw bad('服务模式最多 32 个字符');
|
||||||
|
if (desc.length > 200) throw bad('服务说明最多 200 个字符');
|
||||||
|
if (!Number.isFinite(price) || price <= 0) throw bad('服务单价必须为有效正数');
|
||||||
|
|
||||||
|
const rulesRec = await repo.getPlatformConfigByKey('expert_rules');
|
||||||
|
const rules = (rulesRec && rulesRec.valueJson) || {};
|
||||||
|
const minRate = Number(rules.minHourlyRate) || 30;
|
||||||
|
const maxRate = Number(rules.maxHourlyRate) || 500;
|
||||||
|
if (price < minRate || price > maxRate) {
|
||||||
|
throw bad(`服务单价需在 ¥${minRate} ~ ¥${maxRate} 之间(平台定价规范)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sec = contentSecurityService.checkText(`${title} ${category} ${desc}`);
|
||||||
|
if (!sec.pass) {
|
||||||
|
const e: any = new Error(sec.message || '内容包含违规信息,请调整后重试');
|
||||||
|
e.status = 400;
|
||||||
|
e.code = sec.code || 40000;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, category, price, unit, mode, desc };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大神自助新增服务包
|
||||||
|
app.post('/api/companions/:id/services', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companionId = Number(req.params.id);
|
||||||
|
const own = await repo.getCompanionOwnership(companionId);
|
||||||
|
if (!own) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '大神不存在' });
|
||||||
|
}
|
||||||
|
assertCompanionActor(req, own, '新增服务包');
|
||||||
|
const existingCount = await repo.countExpertServices(companionId);
|
||||||
|
if (existingCount >= SERVICE_MAX_PER_EXPERT) {
|
||||||
|
return res.status(400).json({ code: 40000, message: `服务包数量已达上限(${SERVICE_MAX_PER_EXPERT} 个),请先删除部分服务` });
|
||||||
|
}
|
||||||
|
const payload = await validateExpertServicePayload(req);
|
||||||
|
const created = await repo.createExpertService(companionId, payload);
|
||||||
|
res.json({ code: 20000, data: created, message: '服务包已创建' });
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.status || 400;
|
||||||
|
const errCode = err.code || 40000;
|
||||||
|
res.status(statusCode).json({ code: errCode, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 大神自助编辑服务包(整体覆盖)
|
||||||
|
app.put('/api/companions/:id/services/:serviceId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companionId = Number(req.params.id);
|
||||||
|
const serviceId = String(req.params.serviceId);
|
||||||
|
const own = await repo.getExpertServiceOwnership(serviceId);
|
||||||
|
if (!own) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '服务不存在' });
|
||||||
|
}
|
||||||
|
if (Number(own.companionId) !== companionId) {
|
||||||
|
return res.status(403).json({ code: 40300, message: '该服务不属于当前大神' });
|
||||||
|
}
|
||||||
|
assertCompanionActor(req, own, '编辑服务包');
|
||||||
|
const payload = await validateExpertServicePayload(req);
|
||||||
|
const updated = await repo.updateExpertService(serviceId, payload);
|
||||||
|
res.json({ code: 20000, data: updated, message: '服务包已更新' });
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.status || 400;
|
||||||
|
const errCode = err.code || 40000;
|
||||||
|
res.status(statusCode).json({ code: errCode, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 大神自助删除服务包
|
||||||
|
app.delete('/api/companions/:id/services/:serviceId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const companionId = Number(req.params.id);
|
||||||
|
const serviceId = String(req.params.serviceId);
|
||||||
|
const own = await repo.getExpertServiceOwnership(serviceId);
|
||||||
|
if (!own) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '服务不存在' });
|
||||||
|
}
|
||||||
|
if (Number(own.companionId) !== companionId) {
|
||||||
|
return res.status(403).json({ code: 40300, message: '该服务不属于当前大神' });
|
||||||
|
}
|
||||||
|
assertCompanionActor(req, own, '删除服务包');
|
||||||
|
await repo.deleteExpertService(serviceId);
|
||||||
|
res.json({ code: 20000, data: true, message: '服务包已删除' });
|
||||||
|
} catch (err: any) {
|
||||||
|
const statusCode = err.status || 400;
|
||||||
|
const errCode = err.code || 40000;
|
||||||
|
res.status(statusCode).json({ code: errCode, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 7.6 友圈动态互动评论区接口
|
// 7.6 友圈动态互动评论区接口
|
||||||
app.get('/api/posts/:id/comments', async (req: Request, res: Response) => {
|
app.get('/api/posts/:id/comments', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// 已读取AGENTS.md
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第七十七批:大神服务包自助维护(新增/编辑/删除 + 校验)+ 去演示服务包。
|
||||||
|
*/
|
||||||
|
describe('第七十七批大神服务包自助维护闸门', () => {
|
||||||
|
const root = path.resolve(__dirname, '../../');
|
||||||
|
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
|
||||||
|
const repo = fs.readFileSync(path.join(root, 'server/src/db/repository.ts'), 'utf-8');
|
||||||
|
const api = fs.readFileSync(path.join(root, 'pwuniapp/common/api.js'), 'utf-8');
|
||||||
|
const workspace = fs.readFileSync(path.join(root, 'pwuniapp/pages/expert/workspace.vue'), 'utf-8');
|
||||||
|
|
||||||
|
it('仓储提供真实 CRUD 与按用户解析大神', () => {
|
||||||
|
expect(repo).toContain('async createExpertService(');
|
||||||
|
expect(repo).toContain('async updateExpertService(');
|
||||||
|
expect(repo).toContain('async deleteExpertService(');
|
||||||
|
expect(repo).toContain('async countExpertServices(');
|
||||||
|
expect(repo).toContain('async getCompanionByUserId(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不再自动生成演示服务包', () => {
|
||||||
|
expect(repo).not.toContain('排位保星连胜局');
|
||||||
|
expect(repo).not.toContain('娱乐匹配快乐唠嗑局');
|
||||||
|
expect(repo).not.toContain('英雄技术单项教学指导');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('后端提供新增/编辑/删除路由与校验', () => {
|
||||||
|
expect(indexCode).toContain("app.post('/api/companions/:id/services'");
|
||||||
|
expect(indexCode).toContain("app.put('/api/companions/:id/services/:serviceId'");
|
||||||
|
expect(indexCode).toContain("app.delete('/api/companions/:id/services/:serviceId'");
|
||||||
|
expect(indexCode).toContain('validateExpertServicePayload');
|
||||||
|
expect(indexCode).toContain('SERVICE_MAX_PER_EXPERT');
|
||||||
|
expect(indexCode).toContain("app.get('/api/expert/me'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('小程序工作台具备自助维护入口并解析真实大神 id', () => {
|
||||||
|
expect(api).toContain('export async function createExpertService(');
|
||||||
|
expect(api).toContain('export async function updateExpertService(');
|
||||||
|
expect(api).toContain('export async function deleteExpertService(');
|
||||||
|
expect(api).toContain('export async function getMyExpertProfile(');
|
||||||
|
expect(workspace).toContain('currentExpertId');
|
||||||
|
expect(workspace).toContain('openServiceForm');
|
||||||
|
expect(workspace).toContain('removeService');
|
||||||
|
expect(workspace).toContain('getMyExpertProfile');
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户