From e36e80c39956107debcc2c79f26e8a2d1d92ca57 Mon Sep 17 00:00:00 2001
From: xcbyy <2578898740@qq.com>
Date: Sat, 10 Oct 2026 09:52:42 +0800
Subject: [PATCH] feat(safety): support full CRUD for approved commercial
places and dynamic sync to uniapp
---
.../src/views/safety/places/index.vue | 97 ++++++++++++++++++-
pwuniapp/common/api.js | 13 +++
pwuniapp/pages/activity/create.vue | 21 +++-
server/src/db/repository.ts | 34 +++++++
server/src/index.ts | 43 ++++++++
server/tests/safety-places.spec.ts | 64 ++++++++++++
6 files changed, 266 insertions(+), 6 deletions(-)
create mode 100644 server/tests/safety-places.spec.ts
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');
+ });
+});