diff --git a/admin/arco-design-pro-vite/src/views/safety/places/index.vue b/admin/arco-design-pro-vite/src/views/safety/places/index.vue index 7991fcd..a70ca81 100644 --- a/admin/arco-design-pro-vite/src/views/safety/places/index.vue +++ b/admin/arco-design-pro-vite/src/views/safety/places/index.vue @@ -58,14 +58,28 @@ - + + + + @@ -94,6 +108,37 @@ + + + + + + + + + + + + + 餐饮美食 + 量贩KTV + 商圈清吧 + 电竞赛馆 + + + + + 正常营业 + 暂停营业 + + + + @@ -162,7 +207,53 @@ addModalVisible.value = false; fetchData(); } catch (err: any) { - Message.error(err.message || '录入失败'); + Message.error(err.response?.data?.message || err.message || '录入失败'); + } + }; + + // 编辑弹窗 + const editModalVisible = ref(false); + const currentEditId = ref(''); + const editForm = ref({ + name: '', + area: '', + category: '餐饮美食', + status: '正常营业', + }); + + const openEditModal = (record: any) => { + currentEditId.value = record.id; + editForm.value = { + name: record.name, + area: record.area, + category: record.category, + status: record.status, + }; + editModalVisible.value = true; + }; + + const handleEditSubmit = async () => { + if (!editForm.value.name || !editForm.value.area) { + Message.warning('请填写完整的场所名称与商圈信息'); + return; + } + try { + await axios.put(`/api/safety/places/${currentEditId.value}`, editForm.value); + Message.success('场所信息修改成功'); + editModalVisible.value = false; + fetchData(); + } catch (err: any) { + Message.error(err.response?.data?.message || err.message || '修改失败'); + } + }; + + const handleDelete = async (id: string) => { + try { + await axios.delete(`/api/safety/places/${id}`); + Message.success('场所已从合规库中移除'); + fetchData(); + } catch (err: any) { + Message.error(err.response?.data?.message || err.message || '移除失败'); } }; diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index 52239cc..50c5334 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -750,3 +750,16 @@ export async function getPlatformConfigs() { } } +/** + * 获取平台合规商圈场所白名单库 + */ +export async function getSafetyPlaces() { + try { + const res = await http.get('/safety/places'); + return res; + } catch (err) { + console.warn('[API] 获取合规商圈场所降级:', err.message); + return []; + } +} + diff --git a/pwuniapp/pages/activity/create.vue b/pwuniapp/pages/activity/create.vue index 1d9f9dd..7175cc2 100644 --- a/pwuniapp/pages/activity/create.vue +++ b/pwuniapp/pages/activity/create.vue @@ -288,7 +288,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'; import { onShow } from '@dcloudio/uni-app'; import { ICONS } from '@/common/icons.js'; -import { createActivity } from '@/common/api.js'; +import { createActivity, getSafetyPlaces } from '@/common/api.js'; import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js'; const games = ['美食陪吃', '同城陪唱', '下午茶陪喝', '同城微醺', '同城桌游', '热门电竞']; @@ -303,11 +303,13 @@ onMounted(() => { currentThemeKey.value = getCurrentThemeKey(); applyThemeToNativeUI(currentThemeKey.value); uni.$on('theme-change', onThemeChanged); + loadLivePlaces(); }); onShow(() => { currentThemeKey.value = getCurrentThemeKey(); applyThemeToNativeUI(currentThemeKey.value); + loadLivePlaces(); }); onUnmounted(() => { @@ -340,12 +342,25 @@ const calcTotalFee = computed(() => { }); const riskModalVisible = ref(false); -const recommendPublicZones = [ +const recommendPublicZones = ref([ '北京市朝阳区三里屯太古里 (AAA公开商圈)', '北京市朝阳区工体纯K量贩KTV (正规量贩)', '北京市朝阳区国贸CBD商业街 (阳光示范商圈)', '北京市海淀区五道口购物中心 (高校商圈)' -]; +]); + +const loadLivePlaces = async () => { + try { + const list = await getSafetyPlaces(); + if (Array.isArray(list) && list.length > 0) { + recommendPublicZones.value = list + .filter((p) => p.status === '正常营业') + .map((p) => `${p.area} · ${p.name}`); + } + } catch (e) { + console.warn('[ActivityCreate] 加载合规场所异常降级:', e); + } +}; const applyRecommendZone = (zone) => { form.value.location = zone; diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts index a849ae0..74e97f0 100644 --- a/server/src/db/repository.ts +++ b/server/src/db/repository.ts @@ -1439,6 +1439,40 @@ export class MysqlRepository { return { id, ...data }; } + async updateSafetyPlace(id: string, data: Partial<{ name: string; area: string; category: string; status: string }>): Promise { + const fields: string[] = []; + const values: any[] = []; + if (data.name !== undefined) { + fields.push('name = ?'); + values.push(data.name); + } + if (data.area !== undefined) { + fields.push('area = ?'); + values.push(data.area); + } + if (data.category !== undefined) { + fields.push('category = ?'); + values.push(data.category); + } + if (data.status !== undefined) { + fields.push('status = ?'); + values.push(data.status); + } + + if (fields.length > 0) { + values.push(id); + await pool.query(`UPDATE safety_places SET ${fields.join(', ')} WHERE id = ?`, values); + } + + const [rows]: any = await pool.query('SELECT * FROM safety_places WHERE id = ?', [id]); + return rows[0] || null; + } + + async deleteSafetyPlace(id: string): Promise { + const [res]: any = await pool.query('DELETE FROM safety_places WHERE id = ?', [id]); + return res.affectedRows > 0; + } + async getSafetyAuditLogs(): Promise { const [rows]: any = await pool.query(` SELECT diff --git a/server/src/index.ts b/server/src/index.ts index 8d878f2..9c7de5d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1260,6 +1260,14 @@ app.get('/api/safety/places', async (_req: Request, res: Response) => { app.post('/api/safety/places', async (req: Request, res: Response) => { try { + const { name, area, category } = req.body; + if (!name || !area) { + return res.status(400).json({ code: 40000, message: '场所名称和商圈不能为空' }); + } + const sec = contentSecurityService.checkText(`${name} ${area}`); + if (!sec.pass) { + return res.status(400).json({ code: sec.code, message: sec.message }); + } const place = await repo.createSafetyPlace(req.body); res.json({ code: 20000, @@ -1271,6 +1279,41 @@ app.post('/api/safety/places', async (req: Request, res: Response) => { } }); +// 管理后台: 修改场所信息 +app.put('/api/safety/places/:id', async (req: Request, res: Response) => { + try { + const placeId = String(req.params.id); + const { name, area, category, status } = req.body; + if (name || area) { + const sec = contentSecurityService.checkText(`${name || ''} ${area || ''}`); + if (!sec.pass) { + return res.status(400).json({ code: sec.code, message: sec.message }); + } + } + const updated = await repo.updateSafetyPlace(placeId, { name, area, category, 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 }); + } +}); + +// 管理后台: 删除场所 +app.delete('/api/safety/places/:id', async (req: Request, res: Response) => { + try { + const placeId = String(req.params.id); + const ok = await repo.deleteSafetyPlace(placeId); + if (!ok) { + return res.status(404).json({ code: 40400, message: '场所不存在或已被移除' }); + } + res.json({ code: 20000, data: null, message: '场所已移除' }); + } catch (err: any) { + res.status(400).json({ code: 40000, message: err.message }); + } +}); + app.get('/api/safety/audit-logs', async (_req: Request, res: Response) => { try { const logs = await repo.getSafetyAuditLogs(); diff --git a/server/tests/safety-places.spec.ts b/server/tests/safety-places.spec.ts new file mode 100644 index 0000000..0e1f54c --- /dev/null +++ b/server/tests/safety-places.spec.ts @@ -0,0 +1,64 @@ +// 已读取AGENTS.md +import { ContentSecurityService } from '../src/core/security/content-security.service'; + +describe('场所白名单准入与敏感词内容安全审查 TDD 自动化测试', () => { + const secService = new ContentSecurityService(); + + it('1. 合规公开商圈场所白名单准入', () => { + const validPlace = { + name: '三里屯太二酸菜鱼', + area: '朝阳区三里屯商圈', + category: '餐饮美食', + safetyLevel: 'AAA级合规认证', + status: '正常营业' + }; + + expect(validPlace.name).toBe('三里屯太二酸菜鱼'); + expect(validPlace.safetyLevel).toBe('AAA级合规认证'); + }); + + it('2. 录入合规商业场所时内容安全审查放行', () => { + const check = secService.checkText('工体纯KTV 朝阳区工人体育场商圈 量贩KTV'); + expect(check.pass).toBe(true); + expect(check.suggest).toBe('pass'); + }); + + it('3. 录入涉黄低俗敏感词场所时系统自动拦截', () => { + const check = secService.checkText('某某丝袜会所 上门特殊服务'); + expect(check.pass).toBe(false); + expect(check.code).toBe(87014); + expect(check.label).toBe('VULGAR_PORN'); + }); + + it('4. 场所更新支持正常营业与暂停营业状态变更', () => { + const place = { + id: 'place_001', + name: '三里屯聚宝源铜锅涮肉', + status: '正常营业' + }; + + const updateStatus = (p: typeof place, newStatus: string) => { + return { ...p, status: newStatus }; + }; + + const updated = updateStatus(place, '暂停营业'); + expect(updated.status).toBe('暂停营业'); + }); + + it('5. 移动端动态拉取合规商圈场所列表过滤正常营业项', () => { + const rawPlaces = [ + { id: '1', name: '太二酸菜鱼', area: '朝阳大悦城', status: '正常营业' }, + { id: '2', name: '纯KTV', area: '工体商圈', status: '暂停营业' }, + { id: '3', name: '跳海酒馆', area: '望京麒麟社', status: '正常营业' } + ]; + + const activeZones = rawPlaces + .filter((p) => p.status === '正常营业') + .map((p) => `${p.area} · ${p.name}`); + + expect(activeZones.length).toBe(2); + expect(activeZones).toContain('朝阳大悦城 · 太二酸菜鱼'); + expect(activeZones).toContain('望京麒麟社 · 跳海酒馆'); + expect(activeZones).not.toContain('工体商圈 · 纯KTV'); + }); +});