feat(config): implement dynamic security blocklist and home marketing popup with admin controls
这个提交包含在:
@@ -239,6 +239,145 @@
|
||||
</a-card>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- Tab 6: 自定义敏感词与私下引流过滤库 (P2) -->
|
||||
<a-tab-pane key="security_words_config" title="引流风控与敏感词">
|
||||
<div class="tab-pane-content">
|
||||
<a-alert type="warning" style="margin-bottom: 20px">
|
||||
在此维护平台自定义敏感词与私下交易拦截词库(如联系方式变体、私下转账、违规引流等)。管理员保存后系统后端实时热加载生效,小程序端发帖、评论及订单备注将实时阻断拦截。
|
||||
</a-alert>
|
||||
|
||||
<a-card title="自定义敏感词黑名单库" :bordered="false" style="background: var(--color-fill-2)">
|
||||
<template #extra>
|
||||
<a-button type="outline" size="small" @click="addSecurityWord">
|
||||
<template #icon><icon-plus /></template>
|
||||
添加违规词
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-space wrap size="medium">
|
||||
<a-tag
|
||||
v-for="(word, index) in securityWordsList"
|
||||
:key="index"
|
||||
color="red"
|
||||
closable
|
||||
size="large"
|
||||
@close="removeSecurityWord(index)"
|
||||
>
|
||||
{{ word }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="快捷批量录入敏感词(多个词请用换行或逗号隔开)">
|
||||
<a-textarea
|
||||
v-model="batchWordsInput"
|
||||
placeholder="如:\n私下微信交易\n线下现金\n加v优惠"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-button type="secondary" size="small" @click="handleBatchAddWords">
|
||||
解析并导入违规词库
|
||||
</a-button>
|
||||
</a-form>
|
||||
|
||||
<div style="margin-top: 24px">
|
||||
<a-button type="primary" :loading="saving" @click="saveSecurityWordsConfig">
|
||||
保存敏感词库(实时热生效)
|
||||
</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- Tab 7: 首页运营全局弹窗 (P3) -->
|
||||
<a-tab-pane key="home_popup_config" title="首页运营全局弹窗">
|
||||
<div class="tab-pane-content">
|
||||
<a-alert type="info" style="margin-bottom: 20px">
|
||||
配置小程序首页首次或每日打开时弹出的营销/活动全局浮层弹窗(如新人首单特惠礼包、节日专属组局返现、官方安全温馨提示等)。
|
||||
</a-alert>
|
||||
|
||||
<a-card title="全局浮层营销弹窗配置" :bordered="false" style="background: var(--color-fill-2)">
|
||||
<a-form :model="homePopupForm.valueJson" layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="弹窗开关">
|
||||
<a-switch v-model="homePopupForm.valueJson.enabled" checked-text="开启弹窗" unchecked-text="关闭隐藏" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="弹出频次规则">
|
||||
<a-select v-model="homePopupForm.valueJson.frequency">
|
||||
<a-option value="once_daily">每天首次打开弹出一次(推荐)</a-option>
|
||||
<a-option value="once_ever">每个用户仅弹出一次</a-option>
|
||||
<a-option value="always">每次冷启动打开均弹出</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="弹窗主标题">
|
||||
<a-input v-model="homePopupForm.valueJson.title" placeholder="如:新人专享同城陪伴好礼" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="弹窗副标题/引导语">
|
||||
<a-input v-model="homePopupForm.valueJson.subtitle" placeholder="如:注册即送20元无门槛立减券" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="弹窗海报图片 URL">
|
||||
<div style="display: flex; gap: 12px; align-items: flex-start; width: 100%;">
|
||||
<a-input v-model="homePopupForm.valueJson.imageUrl" placeholder="可直接输入图片公网URL或上传" style="flex: 1;" />
|
||||
<a-upload
|
||||
action="/api/upload"
|
||||
name="file"
|
||||
:show-file-list="false"
|
||||
@success="handlePopupUploadSuccess"
|
||||
@error="handlePopupUploadError"
|
||||
>
|
||||
<template #upload-button>
|
||||
<a-button type="outline">
|
||||
<template #icon><icon-upload /></template>
|
||||
上传海报
|
||||
</a-button>
|
||||
</template>
|
||||
</a-upload>
|
||||
</div>
|
||||
<div v-if="homePopupForm.valueJson.imageUrl" style="margin-top: 10px;">
|
||||
<a-image :src="homePopupForm.valueJson.imageUrl" :height="120" fit="cover" style="border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" />
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="按钮操作文本">
|
||||
<a-input v-model="homePopupForm.valueJson.btnText" placeholder="如:立即领券组局" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="点击跳转路径 (小程序内部页面)">
|
||||
<a-input v-model="homePopupForm.valueJson.link" placeholder="如:/pages/activity/list 或 /pages/user/index" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div style="margin-top: 16px">
|
||||
<a-button type="primary" :loading="saving" @click="saveHomePopupConfig">
|
||||
保存首页弹窗配置
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</div>
|
||||
@@ -336,6 +475,82 @@ const removeFaqItem = (index: number) => {
|
||||
faqList.value.splice(index, 1);
|
||||
};
|
||||
|
||||
// P2 敏感词库
|
||||
const securityWordsList = ref<string[]>([
|
||||
'加微信私聊', '私下走微信', '走线下免佣金', '线下给现金', '私下单',
|
||||
'联系方式v', 'vx号', '微信转账', '私加好友', '线下私约'
|
||||
]);
|
||||
const batchWordsInput = ref('');
|
||||
|
||||
const addSecurityWord = () => {
|
||||
const newWord = window.prompt('请输入需要拦截的违规敏感词');
|
||||
if (newWord && newWord.trim()) {
|
||||
const w = newWord.trim();
|
||||
if (!securityWordsList.value.includes(w)) {
|
||||
securityWordsList.value.push(w);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSecurityWord = (index: number) => {
|
||||
securityWordsList.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const handleBatchAddWords = () => {
|
||||
if (!batchWordsInput.value.trim()) {
|
||||
Message.warning('请输入需要批量导入的敏感词文本');
|
||||
return;
|
||||
}
|
||||
const splitWords = batchWordsInput.value
|
||||
.split(/[\n,,\s]+/)
|
||||
.map(w => w.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let addedCount = 0;
|
||||
splitWords.forEach(w => {
|
||||
if (!securityWordsList.value.includes(w)) {
|
||||
securityWordsList.value.push(w);
|
||||
addedCount++;
|
||||
}
|
||||
});
|
||||
batchWordsInput.value = '';
|
||||
Message.success(`成功解析并新增了 ${addedCount} 个敏感词`);
|
||||
};
|
||||
|
||||
// P3 首页全局弹窗
|
||||
const homePopupForm = reactive({
|
||||
name: '小程序首页运营全局弹窗',
|
||||
description: '节日活动推广、新用户专享首单优惠全局浮层弹窗',
|
||||
status: 1,
|
||||
valueJson: {
|
||||
enabled: true,
|
||||
title: '新人专享同城陪伴好礼',
|
||||
subtitle: '注册即送20元无门槛立减券,首单专享特惠',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=800',
|
||||
link: '/pages/activity/list',
|
||||
btnText: '立即领券组局',
|
||||
frequency: 'once_daily'
|
||||
}
|
||||
});
|
||||
|
||||
const handlePopupUploadSuccess = (fileItem: any) => {
|
||||
const response = fileItem.response;
|
||||
if (response && response.code === 20000 && response.data?.url) {
|
||||
let url = response.data.url;
|
||||
if (url.startsWith('/uploads/')) {
|
||||
url = `https://peiww.gynhc.com${url}`;
|
||||
}
|
||||
homePopupForm.valueJson.imageUrl = url;
|
||||
Message.success('弹窗海报上传成功并已持久化');
|
||||
} else {
|
||||
Message.warning('上传完成,但响应格式异常');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopupUploadError = () => {
|
||||
Message.error('图片上传失败,请检查文件大小及网络');
|
||||
};
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
@@ -371,6 +586,15 @@ const fetchConfigs = async () => {
|
||||
topicsList.value = Array.isArray(item.valueJson) ? item.valueJson : [];
|
||||
} else if (item.configKey === 'faq_list') {
|
||||
faqList.value = Array.isArray(item.valueJson) ? item.valueJson : [];
|
||||
} else if (item.configKey === 'security_words') {
|
||||
if (Array.isArray(item.valueJson)) {
|
||||
securityWordsList.value = item.valueJson;
|
||||
}
|
||||
} else if (item.configKey === 'home_popup') {
|
||||
homePopupForm.name = item.name;
|
||||
homePopupForm.description = item.description;
|
||||
homePopupForm.status = item.status;
|
||||
homePopupForm.valueJson = { ...homePopupForm.valueJson, ...(item.valueJson || {}) };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -436,6 +660,19 @@ const saveFaqConfig = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const saveSecurityWordsConfig = () => {
|
||||
saveConfig('security_words', {
|
||||
name: '私下引流与违规自定义敏感词库',
|
||||
description: '全平台发帖、评论及订单防私下交易关键词实时过滤',
|
||||
status: 1,
|
||||
valueJson: securityWordsList.value
|
||||
});
|
||||
};
|
||||
|
||||
const saveHomePopupConfig = () => {
|
||||
saveConfig('home_popup', homePopupForm);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfigs();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<view v-if="visible" class="popup-mask" @touchmove.stop.prevent>
|
||||
<view class="popup-card" :class="{ 'theme-warm': currentThemeKey === 'warm' }">
|
||||
<!-- 弹窗顶图海报 (可选) -->
|
||||
<view class="popup-image-wrap" v-if="config.imageUrl">
|
||||
<image class="popup-img" :src="resolvedImageUrl" mode="aspectFill" />
|
||||
<view class="popup-image-overlay"></view>
|
||||
</view>
|
||||
|
||||
<!-- 弹窗文字与操作主体 -->
|
||||
<view class="popup-content">
|
||||
<view class="popup-badge">官方活动福利</view>
|
||||
<view class="popup-title">{{ config.title || '新人专享同城好礼' }}</view>
|
||||
<view class="popup-sub">{{ config.subtitle || '多重优惠券与高颜值陪玩搭子已就绪' }}</view>
|
||||
|
||||
<view class="popup-actions">
|
||||
<button class="btn-primary-action btn-active" @click="handleAction">
|
||||
{{ config.btnText || '立即领券组局' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 悬浮关闭圆圈按钮 -->
|
||||
<view class="popup-close-btn btn-active" @click="handleClose">
|
||||
<image class="close-icon" :src="ICONS.close" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey } from '@/common/theme.js';
|
||||
import { getPlatformConfigs } from '@/common/api.js';
|
||||
|
||||
const visible = ref(false);
|
||||
const config = ref({
|
||||
enabled: false,
|
||||
title: '',
|
||||
subtitle: '',
|
||||
imageUrl: '',
|
||||
link: '/pages/activity/list',
|
||||
btnText: '立即参与',
|
||||
frequency: 'once_daily'
|
||||
});
|
||||
|
||||
const currentThemeKey = ref(getCurrentThemeKey());
|
||||
|
||||
const resolvedImageUrl = computed(() => {
|
||||
const url = config.value.imageUrl;
|
||||
if (!url) return '';
|
||||
if (url.startsWith('/uploads/')) {
|
||||
return `https://peiww.gynhc.com${url}`;
|
||||
}
|
||||
return url;
|
||||
});
|
||||
|
||||
const checkAndDisplayPopup = async () => {
|
||||
try {
|
||||
const configs = await getPlatformConfigs();
|
||||
if (!configs || !configs.home_popup) return;
|
||||
const pConfig = configs.home_popup;
|
||||
if (!pConfig.enabled) return;
|
||||
|
||||
config.value = { ...config.value, ...pConfig };
|
||||
|
||||
// 根据频次判断是否展示
|
||||
const storageKey = 'peiwan_home_popup_last_shown';
|
||||
const lastShown = uni.getStorageSync(storageKey);
|
||||
const now = Date.now();
|
||||
|
||||
if (pConfig.frequency === 'once_ever') {
|
||||
if (lastShown) return;
|
||||
} else if (pConfig.frequency === 'once_daily') {
|
||||
if (lastShown) {
|
||||
const lastDate = new Date(Number(lastShown)).toDateString();
|
||||
const nowDate = new Date(now).toDateString();
|
||||
if (lastDate === nowDate) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟 600ms 展示,保证首屏骨架屏转场顺畅
|
||||
setTimeout(() => {
|
||||
visible.value = true;
|
||||
uni.setStorageSync(storageKey, String(now));
|
||||
}, 600);
|
||||
} catch (err) {
|
||||
console.warn('[Popup] 读取弹窗配置异常:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
visible.value = false;
|
||||
const link = config.value.link || '/pages/activity/list';
|
||||
if (link.startsWith('/pages/activity/list') || link.startsWith('/pages/order/list') || link.startsWith('/pages/user/index')) {
|
||||
uni.switchTab({ url: link });
|
||||
} else {
|
||||
uni.navigateTo({ url: link });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkAndDisplayPopup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.popup-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 40rpx;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.popup-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 600rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 36rpx;
|
||||
overflow: visible;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.3);
|
||||
animation: scaleUp 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.popup-image-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 320rpx;
|
||||
border-radius: 36rpx 36rpx 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popup-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.popup-image-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80rpx;
|
||||
background: linear-gradient(to top, #FFFFFF, transparent);
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
padding: 36rpx 40rpx 44rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popup-badge {
|
||||
display: inline-block;
|
||||
padding: 6rpx 20rpx;
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
color: #6366F1;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 38rpx;
|
||||
font-weight: 800;
|
||||
color: #0F172A;
|
||||
margin-bottom: 12rpx;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.popup-sub {
|
||||
font-size: 26rpx;
|
||||
color: #64748B;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 36rpx;
|
||||
}
|
||||
|
||||
.btn-primary-action {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, #6366F1 0%, #4F46E5 100%);
|
||||
color: #FFFFFF;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10rpx 24rpx rgba(99, 102, 241, 0.35);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.popup-close-btn {
|
||||
position: absolute;
|
||||
bottom: -96rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.btn-active:active {
|
||||
transform: scale(0.96);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes scaleUp {
|
||||
from { opacity: 0; transform: scale(0.85); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -120,6 +120,9 @@
|
||||
|
||||
<!-- 微信小程序用户隐私保护指引弹窗 (微信合规) -->
|
||||
<privacy-modal />
|
||||
|
||||
<!-- 首页运营全局活动营销弹窗 (配置化可控) -->
|
||||
<home-popup-modal />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -129,6 +132,7 @@ import { onShow } from '@dcloudio/uni-app';
|
||||
import CustomTabBar from '@/components/CustomTabBar.vue';
|
||||
import SkeletonScreen from '@/components/SkeletonScreen.vue';
|
||||
import PrivacyModal from '@/components/PrivacyModal.vue';
|
||||
import HomePopupModal from '@/components/HomePopupModal.vue';
|
||||
import { getCompanions, getBanners, getNavCategories } from '@/common/api.js';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||
|
||||
@@ -30,6 +30,25 @@ export class ContentSecurityService {
|
||||
]
|
||||
};
|
||||
|
||||
// 动态自定义敏感词列表(支持管理后台增删改并实时热生效)
|
||||
private customKeywords: string[] = [];
|
||||
|
||||
/**
|
||||
* 载入或更新动态敏感词黑名单
|
||||
*/
|
||||
public setCustomKeywords(keywords: string[]): void {
|
||||
if (Array.isArray(keywords)) {
|
||||
this.customKeywords = keywords.map(k => k.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前生效的所有自定义敏感词
|
||||
*/
|
||||
public getCustomKeywords(): string[] {
|
||||
return [...this.customKeywords];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文本内容安全 (security.msgSecCheck)
|
||||
* @param text 待检测文本(支持动态、评论、活动标题、个人简介等)
|
||||
@@ -47,6 +66,21 @@ export class ContentSecurityService {
|
||||
|
||||
const cleanText = text.replace(/[\s\r\n\t\-_,.,。!?]/g, '').toLowerCase();
|
||||
|
||||
// 1. 优先检测管理后台运营配置的自定义违规词库
|
||||
for (const kw of this.customKeywords) {
|
||||
const cleanKw = kw.replace(/[\s\r\n\t\-_,.,。!?]/g, '').toLowerCase();
|
||||
if (cleanText.includes(cleanKw)) {
|
||||
return {
|
||||
pass: false,
|
||||
code: 87014,
|
||||
label: 'CUSTOM_BLOCKLIST',
|
||||
suggest: 'block',
|
||||
message: `内容含有涉嫌违规或敏感词汇[${kw}],已被系统拦截,请文明发言`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检测系统内置的核心安全敏感词库
|
||||
for (const [category, keywords] of Object.entries(ContentSecurityService.SENSITIVE_KEYWORDS)) {
|
||||
for (const kw of keywords) {
|
||||
const cleanKw = kw.replace(/[\s\r\n\t\-_,.,。!?]/g, '').toLowerCase();
|
||||
|
||||
@@ -686,6 +686,21 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
}
|
||||
]);
|
||||
|
||||
const securityWordsVal = JSON.stringify([
|
||||
'加微信私聊', '私下走微信', '走线下免佣金', '线下给现金', '私下单',
|
||||
'联系方式v', 'vx号', '微信转账', '私加好友', '线下私约'
|
||||
]);
|
||||
|
||||
const homePopupVal = JSON.stringify({
|
||||
enabled: true,
|
||||
title: '新人专享同城陪伴好礼',
|
||||
subtitle: '注册即送20元无门槛立减券,首单专享特惠',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?w=800',
|
||||
link: '/pages/activity/list',
|
||||
btnText: '立即领券组局',
|
||||
frequency: 'once_daily' // once_daily | always | once_ever
|
||||
});
|
||||
|
||||
const [cfgItemRows]: any = await conn.query('SELECT COUNT(*) as count FROM platform_configs');
|
||||
if (cfgItemRows[0].count === 0) {
|
||||
const customerServiceVal = JSON.stringify({
|
||||
@@ -720,10 +735,12 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
('platform_protocol', '平台服务协议与合规规范', ?, '小程序端服务协议与隐私政策详情', 1),
|
||||
('community_topics', '友圈热门推荐话题库', ?, '友圈广场发帖推荐话题与热门标签', 1),
|
||||
('community_notice', '友圈社区置顶头条公告', ?, '友圈广场顶部置顶横幅公告与社区公约', 1),
|
||||
('faq_list', '帮助中心与常见问题FAQ', ?, '小程序端帮助中心常见问答与指引', 1);
|
||||
`, [customerServiceVal, safetyNoticeVal, protocolVal, communityTopicsVal, communityNoticeVal, faqListVal]);
|
||||
('faq_list', '帮助中心与常见问题FAQ', ?, '小程序端帮助中心常见问答与指引', 1),
|
||||
('security_words', '私下引流与违规自定义敏感词库', ?, '全平台发帖、评论及订单防私下交易关键词实时过滤', 1),
|
||||
('home_popup', '小程序首页运营全局弹窗', ?, '节日活动推广、新用户专享首单优惠全局浮层弹窗', 1);
|
||||
`, [customerServiceVal, safetyNoticeVal, protocolVal, communityTopicsVal, communityNoticeVal, faqListVal, securityWordsVal, homePopupVal]);
|
||||
} else {
|
||||
// 增量保证 community_topics, community_notice, faq_list 存在
|
||||
// 增量保证各核心配置项完整就绪
|
||||
const [existingRows]: any = await conn.query('SELECT config_key FROM platform_configs');
|
||||
const existingKeys = new Set(existingRows.map((r: any) => r.config_key));
|
||||
if (!existingKeys.has('community_topics')) {
|
||||
@@ -744,5 +761,17 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
VALUES ('faq_list', '帮助中心与常见问题FAQ', ?, '小程序端帮助中心常见问答与指引', 1)
|
||||
`, [faqListVal]);
|
||||
}
|
||||
if (!existingKeys.has('security_words')) {
|
||||
await conn.query(`
|
||||
INSERT INTO platform_configs (config_key, name, value_json, description, status)
|
||||
VALUES ('security_words', '私下引流与违规自定义敏感词库', ?, '全平台发帖、评论及订单防私下交易关键词实时过滤', 1)
|
||||
`, [securityWordsVal]);
|
||||
}
|
||||
if (!existingKeys.has('home_popup')) {
|
||||
await conn.query(`
|
||||
INSERT INTO platform_configs (config_key, name, value_json, description, status)
|
||||
VALUES ('home_popup', '小程序首页运营全局弹窗', ?, '节日活动推广、新用户专享首单优惠全局浮层弹窗', 1)
|
||||
`, [homePopupVal]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,6 +1279,23 @@ export class MysqlRepository {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getPlatformConfigByKey(configKey: string): Promise<any> {
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT id, config_key as configKey, name, value_json as valueJson, description, status
|
||||
FROM platform_configs
|
||||
WHERE config_key = ?
|
||||
`, [configKey]);
|
||||
if (!rows || rows.length === 0) return null;
|
||||
const r = rows[0];
|
||||
let val = r.valueJson;
|
||||
if (typeof val === 'string') {
|
||||
try {
|
||||
val = JSON.parse(val);
|
||||
} catch (_) {}
|
||||
}
|
||||
return { ...r, valueJson: val };
|
||||
}
|
||||
|
||||
async getAdminPlatformConfigs(): Promise<any[]> {
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT id, config_key as configKey, name, value_json as valueJson, description, status,
|
||||
|
||||
@@ -1078,11 +1078,13 @@ app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) =>
|
||||
const configKey = String(req.params.key);
|
||||
const { name, valueJson, description, status } = req.body;
|
||||
|
||||
// 微信内容安全审查(检查文本内容或字符串化后的配置)
|
||||
const textToCheck = `${name || ''} ${description || ''} ${JSON.stringify(valueJson || '')}`;
|
||||
const sec = contentSecurityService.checkText(textToCheck);
|
||||
if (!sec.pass) {
|
||||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||||
// 微信内容安全审查(检查文本内容或字符串化后的配置,security_words 黑名单库配置项除外)
|
||||
if (configKey !== 'security_words') {
|
||||
const textToCheck = `${name || ''} ${description || ''} ${JSON.stringify(valueJson || '')}`;
|
||||
const sec = contentSecurityService.checkText(textToCheck);
|
||||
if (!sec.pass) {
|
||||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await repo.updatePlatformConfig(configKey, {
|
||||
@@ -1096,6 +1098,11 @@ app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) =>
|
||||
return res.status(404).json({ code: 40400, message: '配置项不存在' });
|
||||
}
|
||||
|
||||
// 若更新的是自定义敏感词库,实时热更新内存过滤引擎
|
||||
if (configKey === 'security_words' && Array.isArray(valueJson)) {
|
||||
contentSecurityService.setCustomKeywords(valueJson);
|
||||
}
|
||||
|
||||
res.json({ code: 20000, data: updated, message: '系统配置更新成功' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
@@ -1616,6 +1623,13 @@ async function bootstrap() {
|
||||
try {
|
||||
await initDatabase();
|
||||
console.log('[Peiwan Server] MySQL 数据库核心表及种子数据已校验就绪');
|
||||
|
||||
// 载入自定义敏感词过滤库
|
||||
const secWordsConfig = await repo.getPlatformConfigByKey('security_words');
|
||||
if (secWordsConfig && Array.isArray(secWordsConfig.valueJson)) {
|
||||
contentSecurityService.setCustomKeywords(secWordsConfig.valueJson);
|
||||
console.log(`[Peiwan Server] 已成功装载 ${secWordsConfig.valueJson.length} 条自定义安全拦截词`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Peiwan Server] MySQL 初始化连接发生警告:', err);
|
||||
}
|
||||
|
||||
@@ -156,4 +156,34 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('5. 动态自定义引流违规词库与首页运营全局弹窗组件规范验证', () => {
|
||||
it('支持动态加载自定义违规引流词并即时热拦截', () => {
|
||||
secService.setCustomKeywords(['私下走微信', '线下给现金', '联系方式v']);
|
||||
expect(secService.getCustomKeywords()).toEqual(['私下走微信', '线下给现金', '联系方式v']);
|
||||
|
||||
const blocked1 = secService.checkText('加个好友,我们私下走微信吧');
|
||||
expect(blocked1.pass).toBe(false);
|
||||
expect(blocked1.code).toBe(87014);
|
||||
expect(blocked1.message).toContain('私下走微信');
|
||||
|
||||
const blocked2 = secService.checkText('活动到了现场线下给现金可以吗');
|
||||
expect(blocked2.pass).toBe(false);
|
||||
expect(blocked2.code).toBe(87014);
|
||||
expect(blocked2.message).toContain('线下给现金');
|
||||
|
||||
const passText = secService.checkText('今晚一起去望京合生麒麟社玩桌游');
|
||||
expect(passText.pass).toBe(true);
|
||||
});
|
||||
|
||||
it('首页必须挂载 HomePopupModal 全局运营浮层弹窗组件', () => {
|
||||
const homePopupPath = path.join(rootDir, 'pwuniapp', 'components', 'HomePopupModal.vue');
|
||||
expect(fs.existsSync(homePopupPath)).toBe(true);
|
||||
|
||||
const indexContent = fs.readFileSync(path.join(rootDir, 'pwuniapp', 'pages', 'index', 'index.vue'), 'utf-8');
|
||||
expect(indexContent).toContain('home-popup-modal');
|
||||
expect(indexContent).toContain('HomePopupModal');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户