feat(safety): add expert skill tags and price range constraints configuration
这个提交包含在:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户