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 }})
+
+
+ 新增候选标签
+
+
+
+
+
+
+
+
+
+ 解析并导入标签库
+
+
+
+
+
+
+
@@ -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);
+ });
+ });
});