feat(safety): add expert skill tags and price range constraints configuration

这个提交包含在:
xcbyy
2026-10-10 13:56:38 +08:00
父节点 2e726e125d
当前提交 d7f5c31762
共有 5 个文件被更改,包括 351 次插入 和 5 次删除

查看文件

@@ -378,6 +378,97 @@
</a-card>
</div>
</a-tab-pane>
<!-- Tab 8: 大神资质与定价规则 (P4) -->
<a-tab-pane key="expert_rules_config" title="大神资质与定价规则">
<div class="tab-pane-content">
<a-alert type="success" style="margin-bottom: 20px">
在此设定陪伴师/陪玩大神的入驻审核规范、服务单价保护区间(防止恶意低价或超常标价),以及全平台官方认证候选技能标签库。
</a-alert>
<a-card title="大神资质与价格控制规则" :bordered="false" style="background: var(--color-fill-2)">
<a-form :model="expertRulesForm.valueJson" layout="vertical">
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="最低服务单价 (元/小时)">
<a-input-number v-model="expertRulesForm.valueJson.minHourlyRate" :min="1" :max="1000" placeholder="如 30" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="最高服务单价 (元/小时)">
<a-input-number v-model="expertRulesForm.valueJson.maxHourlyRate" :min="10" :max="5000" placeholder="如 500" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="推荐默认初始单价 (元/小时)">
<a-input-number v-model="expertRulesForm.valueJson.defaultHourlyRate" :min="10" :max="1000" placeholder="如 88" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="每位大神最多可选标签数">
<a-input-number v-model="expertRulesForm.valueJson.maxTagsCount" :min="1" :max="10" placeholder="如 6" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="实名认证强校验门禁">
<a-switch v-model="expertRulesForm.valueJson.requireRealName" checked-text="必须实名" unchecked-text="免实名" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="允许大神提交个性标签">
<a-switch v-model="expertRulesForm.valueJson.allowCustomTags" checked-text="允许" unchecked-text="仅限候选库" />
</a-form-item>
</a-col>
</a-row>
<a-divider />
<div style="margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 600; font-size: 14px;">官方认证技能候选标签库 ({{ expertCandidateTags.length }})</span>
<a-button type="outline" size="small" @click="addCandidateTag">
<template #icon><icon-plus /></template>
新增候选标签
</a-button>
</div>
<div style="margin-bottom: 16px;">
<a-space wrap size="medium">
<a-tag
v-for="(tag, index) in expertCandidateTags"
:key="index"
color="arcoblue"
closable
size="large"
@close="removeCandidateTag(index)"
>
{{ tag }}
</a-tag>
</a-space>
</div>
<a-form-item label="快捷批量录入技能标签(多个标签用换行或逗号隔开)">
<a-textarea
v-model="batchTagsInput"
placeholder="如:\n国服王者\n声优达人\n幽默风趣\n暖心倾听"
:auto-size="{ minRows: 3, maxRows: 6 }"
/>
</a-form-item>
<a-button type="secondary" size="small" @click="handleBatchAddTags">
解析并导入标签库
</a-button>
<div style="margin-top: 24px">
<a-button type="primary" :loading="saving" @click="saveExpertRulesConfig">
保存大神资质与定价规则
</a-button>
</div>
</a-form>
</a-card>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
@@ -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<string[]>([
'国服王者', '声优达人', '幽默风趣', '随叫随到', '暖心倾听',
'百星野王', '金牌探店', '量贩麦霸', '微醺搭子', '绅士礼貌',
'活力开朗', '耐心教学', '高胜率车头', '拍照好看'
]);
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;
}
}
});
}

查看文件

@@ -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 {

查看文件

@@ -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);
});
});
});