feat(safety): add expert skill tags and price range constraints configuration
这个提交包含在:
@@ -79,10 +79,16 @@ export async function initDatabase(): Promise<void> {
|
||||
status VARCHAR(32) DEFAULT '接单中',
|
||||
audit_status VARCHAR(32) DEFAULT '已通过',
|
||||
id_card_masked VARCHAR(32),
|
||||
tags JSON,
|
||||
apply_time DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// 动态为 companions 增加 tags 字段 (若历史表已存在)
|
||||
try {
|
||||
await conn.query('ALTER TABLE companions ADD COLUMN tags JSON AFTER id_card_masked');
|
||||
} catch (_) {}
|
||||
|
||||
// 4. 订单表
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
@@ -701,6 +707,20 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
frequency: 'once_daily' // once_daily | always | once_ever
|
||||
});
|
||||
|
||||
const expertRulesVal = JSON.stringify({
|
||||
minHourlyRate: 30,
|
||||
maxHourlyRate: 500,
|
||||
defaultHourlyRate: 88,
|
||||
requireRealName: true,
|
||||
allowCustomTags: true,
|
||||
maxTagsCount: 6,
|
||||
candidateTags: [
|
||||
'国服王者', '声优达人', '幽默风趣', '随叫随到', '暖心倾听',
|
||||
'百星野王', '金牌探店', '量贩麦霸', '微醺搭子', '绅士礼貌',
|
||||
'活力开朗', '耐心教学', '高胜率车头', '拍照好看'
|
||||
]
|
||||
});
|
||||
|
||||
const [cfgItemRows]: any = await conn.query('SELECT COUNT(*) as count FROM platform_configs');
|
||||
if (cfgItemRows[0].count === 0) {
|
||||
const customerServiceVal = JSON.stringify({
|
||||
@@ -737,8 +757,9 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
('community_notice', '友圈社区置顶头条公告', ?, '友圈广场顶部置顶横幅公告与社区公约', 1),
|
||||
('faq_list', '帮助中心与常见问题FAQ', ?, '小程序端帮助中心常见问答与指引', 1),
|
||||
('security_words', '私下引流与违规自定义敏感词库', ?, '全平台发帖、评论及订单防私下交易关键词实时过滤', 1),
|
||||
('home_popup', '小程序首页运营全局弹窗', ?, '节日活动推广、新用户专享首单优惠全局浮层弹窗', 1);
|
||||
`, [customerServiceVal, safetyNoticeVal, protocolVal, communityTopicsVal, communityNoticeVal, faqListVal, securityWordsVal, homePopupVal]);
|
||||
('home_popup', '小程序首页运营全局弹窗', ?, '节日活动推广、新用户专享首单优惠全局浮层弹窗', 1),
|
||||
('expert_rules', '大神技能标签库与定价区间规范', ?, '陪玩大神入驻审核、定价上下限限制与官方推荐技能标签库', 1);
|
||||
`, [customerServiceVal, safetyNoticeVal, protocolVal, communityTopicsVal, communityNoticeVal, faqListVal, securityWordsVal, homePopupVal, expertRulesVal]);
|
||||
} else {
|
||||
// 增量保证各核心配置项完整就绪
|
||||
const [existingRows]: any = await conn.query('SELECT config_key FROM platform_configs');
|
||||
@@ -773,5 +794,11 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
VALUES ('home_popup', '小程序首页运营全局弹窗', ?, '节日活动推广、新用户专享首单优惠全局浮层弹窗', 1)
|
||||
`, [homePopupVal]);
|
||||
}
|
||||
if (!existingKeys.has('expert_rules')) {
|
||||
await conn.query(`
|
||||
INSERT INTO platform_configs (config_key, name, value_json, description, status)
|
||||
VALUES ('expert_rules', '大神技能标签库与定价区间规范', ?, '陪玩大神入驻审核、定价上下限限制与官方推荐技能标签库', 1)
|
||||
`, [expertRulesVal]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,11 +431,20 @@ export class MysqlRepository {
|
||||
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,
|
||||
apply_time as applyTime
|
||||
tags, apply_time as applyTime
|
||||
FROM companions
|
||||
ORDER BY id ASC
|
||||
`);
|
||||
return rows;
|
||||
return rows.map((r: any) => {
|
||||
let tagsArr = [];
|
||||
try {
|
||||
tagsArr = typeof r.tags === 'string' ? JSON.parse(r.tags) : (r.tags || []);
|
||||
} catch (_) {}
|
||||
return {
|
||||
...r,
|
||||
tags: tagsArr
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getCompanionById(id: number | string): Promise<any> {
|
||||
@@ -453,6 +462,17 @@ export class MysqlRepository {
|
||||
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 price = Number(c.hourly_rate) || 68;
|
||||
|
||||
let parsedTags = ['高颜值', '性格好', '秒回消息', '文明健康'];
|
||||
if (c.tags) {
|
||||
try {
|
||||
const t = typeof c.tags === 'string' ? JSON.parse(c.tags) : c.tags;
|
||||
if (Array.isArray(t) && t.length > 0) {
|
||||
parsedTags = t;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(c.id),
|
||||
nickname: c.name,
|
||||
@@ -473,7 +493,7 @@ export class MysqlRepository {
|
||||
voiceType: 'voice',
|
||||
voiceTitle: c.voice_title || '语音招呼',
|
||||
bio: '专业同城生活陪伴,热情礼貌,守时诚信。',
|
||||
tags: ['高颜值', '性格好', '秒回消息', '文明健康'],
|
||||
tags: parsedTags,
|
||||
photos: [
|
||||
c.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400',
|
||||
'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400',
|
||||
@@ -500,6 +520,34 @@ export class MysqlRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async updateCompanionProfile(id: number | string, data: { hourlyRate?: number; tags?: string[]; rankName?: string; status?: string }): Promise<any> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (data.hourlyRate !== undefined) {
|
||||
fields.push('hourly_rate = ?');
|
||||
values.push(Number(data.hourlyRate));
|
||||
}
|
||||
if (data.tags !== undefined) {
|
||||
fields.push('tags = ?');
|
||||
values.push(JSON.stringify(data.tags));
|
||||
}
|
||||
if (data.rankName !== undefined) {
|
||||
fields.push('rank_name = ?');
|
||||
values.push(data.rankName);
|
||||
}
|
||||
if (data.status !== undefined) {
|
||||
fields.push('status = ?');
|
||||
values.push(data.status);
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
values.push(id);
|
||||
await pool.query(`UPDATE companions SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
}
|
||||
|
||||
return this.getCompanionById(id);
|
||||
}
|
||||
|
||||
async auditCompanion(id: number, action: 'approve' | 'reject', reason?: string): Promise<any> {
|
||||
const auditStatus = action === 'approve' ? '已通过' : '已驳回';
|
||||
|
||||
@@ -566,6 +566,75 @@ app.post('/api/companions/:id/audit', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 大神服务单价、技能标签与资料修改 (校验价格边界与标签库规范)
|
||||
app.put('/api/companions/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companionId = String(req.params.id);
|
||||
const { hourlyRate, tags, rankName, status } = req.body;
|
||||
|
||||
// 1. 读取平台配置的大神规则
|
||||
const expertRulesConfig = await repo.getPlatformConfigByKey('expert_rules');
|
||||
const rules = expertRulesConfig?.valueJson || {
|
||||
minHourlyRate: 30,
|
||||
maxHourlyRate: 500,
|
||||
maxTagsCount: 6,
|
||||
};
|
||||
|
||||
// 2. 校验服务单价上下限
|
||||
if (hourlyRate !== undefined) {
|
||||
const rateNum = Number(hourlyRate);
|
||||
if (isNaN(rateNum) || rateNum <= 0) {
|
||||
return res.status(400).json({ code: 40000, message: '服务单价必须为有效正数' });
|
||||
}
|
||||
if (rateNum < (rules.minHourlyRate || 30)) {
|
||||
return res.status(400).json({
|
||||
code: 40000,
|
||||
message: `服务单价不能低于平台最低限价 ¥${rules.minHourlyRate || 30}/小时`
|
||||
});
|
||||
}
|
||||
if (rateNum > (rules.maxHourlyRate || 500)) {
|
||||
return res.status(400).json({
|
||||
code: 40000,
|
||||
message: `服务单价不能超过平台最高限价 ¥${rules.maxHourlyRate || 500}/小时`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 校验技能标签
|
||||
if (tags !== undefined) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return res.status(400).json({ code: 40000, message: '技能标签必须为数组格式' });
|
||||
}
|
||||
const maxCount = rules.maxTagsCount || 6;
|
||||
if (tags.length > maxCount) {
|
||||
return res.status(400).json({ code: 40000, message: `技能标签最多选择或设置 ${maxCount} 个` });
|
||||
}
|
||||
// 敏感词安全校验
|
||||
const textToCheck = tags.join(' ');
|
||||
const sec = contentSecurityService.checkText(textToCheck);
|
||||
if (!sec.pass) {
|
||||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 执行更新
|
||||
const updated = await repo.updateCompanionProfile(companionId, {
|
||||
hourlyRate: hourlyRate !== undefined ? Number(hourlyRate) : undefined,
|
||||
tags: tags !== undefined ? tags : undefined,
|
||||
rankName,
|
||||
status
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ code: 40400, message: '未找到指定陪伴师资料' });
|
||||
}
|
||||
|
||||
res.json({ code: 20000, data: updated, message: '陪伴师配置及标签已更新' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 订单与仲裁接口 (落盘 MySQL)
|
||||
app.get('/api/orders', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
在新工单中引用
屏蔽一个用户