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)
这个提交包含在:
xcbyy
2026-10-19 14:46:05 +08:00
父节点 ca7f849b14
当前提交 500aac306e
共有 7 个文件被更改,包括 619 次插入 和 57 次删除

查看文件

@@ -660,6 +660,16 @@ export async function initDatabase(): Promise<void> {
) 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]> = [

查看文件

@@ -4978,58 +4978,163 @@ 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: '对线细节、走位身法与团战意识深度剖析复盘' }
];
/** 确保 expert_services 表与索引存在(懒建表) */
async ensureExpertServicesTable(): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS expert_services (
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(`
CREATE TABLE IF NOT EXISTS expert_services (
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;
await pool.query('ALTER TABLE expert_services ADD INDEX idx_srv_expert (expert_id)');
} catch (_) {
// 索引已存在:忽略
}
}
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> {
try {
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 友圈动态互动评论区接口
app.get('/api/posts/:id/comments', async (req: Request, res: Response) => {
try {