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)
这个提交包含在:
@@ -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 友圈动态互动评论区接口
|
||||
app.get('/api/posts/:id/comments', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
在新工单中引用
屏蔽一个用户