From d7f5c3176216185127a2aad1c531410501f6b70b Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Sat, 10 Oct 2026 13:56:38 +0800 Subject: [PATCH] feat(safety): add expert skill tags and price range constraints configuration --- .../views/safety/platform-config/index.vue | 166 ++++++++++++++++++ server/src/db/mysql.ts | 31 +++- server/src/db/repository.ts | 54 +++++- server/src/index.ts | 69 ++++++++ server/tests/content-security.spec.ts | 36 ++++ 5 files changed, 351 insertions(+), 5 deletions(-) diff --git a/admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue b/admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue index 560eb86..9c055d2 100644 --- a/admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue +++ b/admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue @@ -378,6 +378,97 @@ + + + +
+ + 在此设定陪伴师/陪玩大神的入驻审核规范、服务单价保护区间(防止恶意低价或超常标价),以及全平台官方认证候选技能标签库。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 官方认证技能候选标签库 ({{ expertCandidateTags.length }}) + + + 新增候选标签 + +
+ +
+ + + {{ tag }} + + +
+ + + + + + 解析并导入标签库 + + +
+ + 保存大神资质与定价规则 + +
+
+
+
+
@@ -551,6 +642,73 @@ const handlePopupUploadError = () => { Message.error('图片上传失败,请检查文件大小及网络'); }; +// P4 大神资质与定价规则 +const expertRulesForm = reactive({ + name: '大神技能标签库与定价区间规范', + description: '陪玩大神入驻审核、定价上下限限制与官方推荐技能标签库', + status: 1, + valueJson: { + minHourlyRate: 30, + maxHourlyRate: 500, + defaultHourlyRate: 88, + requireRealName: true, + allowCustomTags: true, + maxTagsCount: 6, + candidateTags: [ + '国服王者', '声优达人', '幽默风趣', '随叫随到', '暖心倾听', + '百星野王', '金牌探店', '量贩麦霸', '微醺搭子', '绅士礼貌', + '活力开朗', '耐心教学', '高胜率车头', '拍照好看' + ] + } +}); + +const expertCandidateTags = ref([ + '国服王者', '声优达人', '幽默风趣', '随叫随到', '暖心倾听', + '百星野王', '金牌探店', '量贩麦霸', '微醺搭子', '绅士礼貌', + '活力开朗', '耐心教学', '高胜率车头', '拍照好看' +]); +const batchTagsInput = ref(''); + +const addCandidateTag = () => { + const newTag = window.prompt('请输入新的技能候选标签(如:国服第一中单)'); + if (newTag && newTag.trim()) { + const t = newTag.trim(); + if (!expertCandidateTags.value.includes(t)) { + expertCandidateTags.value.push(t); + } + } +}; + +const removeCandidateTag = (index: number) => { + expertCandidateTags.value.splice(index, 1); +}; + +const handleBatchAddTags = () => { + if (!batchTagsInput.value.trim()) { + Message.warning('请输入需要批量导入的技能标签文本'); + return; + } + const splitTags = batchTagsInput.value + .split(/[\n,,\s]+/) + .map(t => t.trim()) + .filter(Boolean); + + let addedCount = 0; + splitTags.forEach(t => { + if (!expertCandidateTags.value.includes(t)) { + expertCandidateTags.value.push(t); + addedCount++; + } + }); + batchTagsInput.value = ''; + Message.success(`成功解析并新增了 ${addedCount} 个技能标签`); +}; + +const saveExpertRulesConfig = () => { + expertRulesForm.valueJson.candidateTags = expertCandidateTags.value; + saveConfig('expert_rules', expertRulesForm); +}; + const fetchConfigs = async () => { loading.value = true; try { @@ -595,6 +753,14 @@ const fetchConfigs = async () => { homePopupForm.description = item.description; homePopupForm.status = item.status; homePopupForm.valueJson = { ...homePopupForm.valueJson, ...(item.valueJson || {}) }; + } else if (item.configKey === 'expert_rules') { + expertRulesForm.name = item.name; + expertRulesForm.description = item.description; + expertRulesForm.status = item.status; + expertRulesForm.valueJson = { ...expertRulesForm.valueJson, ...(item.valueJson || {}) }; + if (Array.isArray(expertRulesForm.valueJson.candidateTags)) { + expertCandidateTags.value = expertRulesForm.valueJson.candidateTags; + } } }); } diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts index be31e23..f9223ce 100644 --- a/server/src/db/mysql.ts +++ b/server/src/db/mysql.ts @@ -79,10 +79,16 @@ export async function initDatabase(): Promise { 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 { 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 { ('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 { 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]); + } } } diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index cdf0128..fc70708 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -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 { @@ -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 { + 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 { const auditStatus = action === 'approve' ? '已通过' : '已驳回'; diff --git a/server/src/index.ts b/server/src/index.ts index a53b605..ec28a98 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { diff --git a/server/tests/content-security.spec.ts b/server/tests/content-security.spec.ts index af03240..8370139 100644 --- a/server/tests/content-security.spec.ts +++ b/server/tests/content-security.spec.ts @@ -185,5 +185,41 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安 expect(indexContent).toContain('HomePopupModal'); }); }); + + describe('6. 大神技能标签库与定价区间规范配置校验 (expert_rules)', () => { + it('MySQL 数据库与种子数据必须注册 expert_rules 配置项且字段完整', () => { + const mysqlPath = path.join(rootDir, 'server', 'src', 'db', 'mysql.ts'); + const content = fs.readFileSync(mysqlPath, 'utf-8'); + expect(content).toContain('expert_rules'); + expect(content).toContain('minHourlyRate'); + expect(content).toContain('maxHourlyRate'); + expect(content).toContain('candidateTags'); + }); + + it('管理后台 platform-config 必须包含 Tab 8 大神资质与定价规则可视化面板', () => { + const adminConfigPath = path.join(rootDir, 'admin', 'arco-design-pro-vite', 'src', 'views', 'safety', 'platform-config', 'index.vue'); + const content = fs.readFileSync(adminConfigPath, 'utf-8'); + expect(content).toContain('expert_rules_config'); + expect(content).toContain('大神资质与定价规则'); + expect(content).toContain('expertRulesForm'); + expect(content).toContain('expertCandidateTags'); + }); + + it('服务端路由必须提供 PUT /api/companions/:id 接口并强校验价格上下限与敏感标签', () => { + const serverIndexPath = path.join(rootDir, 'server', 'src', 'index.ts'); + const content = fs.readFileSync(serverIndexPath, 'utf-8'); + expect(content).toContain("app.put('/api/companions/:id'"); + expect(content).toContain('minHourlyRate'); + expect(content).toContain('maxHourlyRate'); + expect(content).toContain('contentSecurityService.checkText'); + }); + + it('测试套件与新增代码严禁任何 Emoji 违规', () => { + const serverIndexPath = path.join(rootDir, 'server', 'src', 'index.ts'); + const content = fs.readFileSync(serverIndexPath, 'utf-8'); + const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|[\u2600-\u27BF])/u; + expect(emojiRegex.test(content)).toBe(false); + }); + }); });