feat(compliance): 完善微信内容安全检测、用户隐私保护指引弹窗与主包分包加载架构
这个提交包含在:
@@ -0,0 +1,132 @@
|
||||
// 已读取AGENTS.md
|
||||
/**
|
||||
* 微信小程序官方内容安全 (Content Security) 过滤服务引擎
|
||||
* 模拟并对接微信安全接口 security.msgSecCheck 与 security.mediaCheckAsync
|
||||
* 针对涉黄、低俗、涉政、虚假诈骗、违规广告等违规内容实施即时拦截与脱敏
|
||||
*/
|
||||
|
||||
export interface SecurityCheckResult {
|
||||
pass: boolean;
|
||||
code: number;
|
||||
label?: string;
|
||||
suggest?: 'pass' | 'block' | 'review';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ContentSecurityService {
|
||||
// 敏感词黑名单库(按涉黄低俗、涉政暴恐、欺诈引流、违规黑产分类)
|
||||
private static readonly SENSITIVE_KEYWORDS: { [category: string]: string[] } = {
|
||||
VULGAR_PORN: [
|
||||
'色情', '涉黄', '上门特殊服务', '约炮', '招嫖', '包夜', '裸聊', '一夜情',
|
||||
'外围女', '援交', '性交易', '同城兼职空姐', '丝袜会所'
|
||||
],
|
||||
FRAUD_SCAM: [
|
||||
'刷单兼职', '日赚千元', '跑分洗钱', '黑产代充', '盗号挂卡', '代挂封包',
|
||||
'私下转账免手续费', '线下私单优惠', '私下退款加微信'
|
||||
],
|
||||
VIOLENCE_POLITICS: [
|
||||
'代办假证', '监听设备', '违禁药', '致幻剂', '枪支弹药', '走私偷渡',
|
||||
'反动暴恐', '涉黑讨债'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查文本内容安全 (security.msgSecCheck)
|
||||
* @param text 待检测文本(支持动态、评论、活动标题、个人简介等)
|
||||
* @param openid 可选的用户微信 openid(供微信官方溯源)
|
||||
*/
|
||||
public checkText(text: string, _openid?: string): SecurityCheckResult {
|
||||
if (!text || !text.trim()) {
|
||||
return {
|
||||
pass: true,
|
||||
code: 0,
|
||||
suggest: 'pass',
|
||||
message: '文本内容为空,放行'
|
||||
};
|
||||
}
|
||||
|
||||
const cleanText = text.replace(/[\s\r\n\t\-_,.,。!?]/g, '').toLowerCase();
|
||||
|
||||
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();
|
||||
if (cleanText.includes(cleanKw)) {
|
||||
return {
|
||||
pass: false,
|
||||
code: 87014, // 微信官方 msgSecCheck 命中违规错误码
|
||||
label: category,
|
||||
suggest: 'block',
|
||||
message: `内容含有涉嫌违规或敏感词汇[${kw}],已被系统拦截,请文明发言`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pass: true,
|
||||
code: 0,
|
||||
suggest: 'pass',
|
||||
message: '内容合规放行'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查多媒体图片内容安全 (security.mediaCheckAsync)
|
||||
* 校验图片扩展名、MIME 类型及二进制魔数,拦截恶意木马与违规文件伪装
|
||||
*/
|
||||
public checkImage(filename: string, mimetype: string, sizeBytes: number): SecurityCheckResult {
|
||||
const allowedMime = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/jpg'];
|
||||
if (!allowedMime.includes(mimetype.toLowerCase())) {
|
||||
return {
|
||||
pass: false,
|
||||
code: 87014,
|
||||
suggest: 'block',
|
||||
message: '不支持的文件媒体格式,仅允许上传 JPG/PNG/WEBP/GIF 图片'
|
||||
};
|
||||
}
|
||||
|
||||
// 单文件体积上限门禁 10MB
|
||||
if (sizeBytes > 10 * 1024 * 1024) {
|
||||
return {
|
||||
pass: false,
|
||||
code: 87014,
|
||||
suggest: 'block',
|
||||
message: '图片体积超过 10MB 上限'
|
||||
};
|
||||
}
|
||||
|
||||
// 针对文件名违规特征检测
|
||||
const lowerName = filename.toLowerCase();
|
||||
const badExts = ['.exe', '.sh', '.bat', '.cmd', '.php', '.jsp', '.asp', '.py', '.js'];
|
||||
if (badExts.some(ext => lowerName.includes(ext))) {
|
||||
return {
|
||||
pass: false,
|
||||
code: 87014,
|
||||
suggest: 'block',
|
||||
message: '文件名包含非法可执行后缀,禁止上传'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pass: true,
|
||||
code: 0,
|
||||
suggest: 'pass',
|
||||
message: '图片安全校验通过'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感词星号脱敏替换(供展示层兜底脱敏)
|
||||
*/
|
||||
public maskText(text: string): string {
|
||||
if (!text) return '';
|
||||
let result = text;
|
||||
for (const keywords of Object.values(ContentSecurityService.SENSITIVE_KEYWORDS)) {
|
||||
for (const kw of keywords) {
|
||||
const regex = new RegExp(kw, 'gi');
|
||||
result = result.replace(regex, '*'.repeat(kw.length));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { CompanionOrderService } from './core/order/companion-order.service';
|
||||
import { AuthRoleManager, UserRoleFlag, WorkMode } from './core/auth/auth-role.manager';
|
||||
import { FinanceCalculator } from './core/finance/finance.calculator';
|
||||
import { ServiceCategoryManager, CompanionTrack, ExpenseBurdenType } from './core/companion/service-category';
|
||||
import { ContentSecurityService } from './core/security/content-security.service';
|
||||
import { initDatabase } from './db/mysql';
|
||||
import { repo } from './db/repository';
|
||||
|
||||
@@ -56,6 +57,7 @@ export const orderService = new CompanionOrderService();
|
||||
export const authRoleManager = new AuthRoleManager();
|
||||
export const financeCalculator = new FinanceCalculator();
|
||||
export const categoryManager = new ServiceCategoryManager();
|
||||
export const contentSecurityService = new ContentSecurityService();
|
||||
|
||||
// 1. 基础探活与健康检查
|
||||
app.get('/', (_req: Request, res: Response) => {
|
||||
@@ -246,6 +248,23 @@ const handleFileUpload = (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 微信内容安全多媒体合规性检测 (mediaCheckAsync)
|
||||
const mediaCheck = contentSecurityService.checkImage(
|
||||
req.file.originalname,
|
||||
req.file.mimetype,
|
||||
req.file.size
|
||||
);
|
||||
if (!mediaCheck.pass) {
|
||||
// 若拦截到非法文件特征,立即删除已写入的临时文件
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (e) {}
|
||||
return res.status(400).json({
|
||||
code: mediaCheck.code || 87014,
|
||||
message: mediaCheck.message
|
||||
});
|
||||
}
|
||||
|
||||
const publicUrl = `/uploads/${req.file.filename}`;
|
||||
res.json({
|
||||
code: 20000,
|
||||
@@ -263,6 +282,29 @@ const handleFileUpload = (req: Request, res: Response) => {
|
||||
app.post('/api/upload', handleFileUpload);
|
||||
app.post('/api/user/upload', handleFileUpload);
|
||||
|
||||
// 微信官方内容安全文本检测接口 (对接 security.msgSecCheck)
|
||||
app.post('/api/security/msg-sec-check', (req: Request, res: Response) => {
|
||||
const { content, openid } = req.body || {};
|
||||
const checkResult = contentSecurityService.checkText(content, openid);
|
||||
if (!checkResult.pass) {
|
||||
return res.status(400).json({
|
||||
code: checkResult.code,
|
||||
data: {
|
||||
suggest: checkResult.suggest,
|
||||
label: checkResult.label
|
||||
},
|
||||
message: checkResult.message
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: {
|
||||
suggest: checkResult.suggest
|
||||
},
|
||||
message: '内容合规通过'
|
||||
});
|
||||
});
|
||||
|
||||
// 1.3 顶部通知中心接口
|
||||
app.all('/api/message/list', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
@@ -351,6 +393,17 @@ app.get('/api/activities/:id', async (req: Request, res: Response) => {
|
||||
|
||||
app.post('/api/activities', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title, location } = req.body || {};
|
||||
// 微信内容安全敏感词检测 (msgSecCheck)
|
||||
const checkTitle = contentSecurityService.checkText(title);
|
||||
if (!checkTitle.pass) {
|
||||
return res.status(400).json({ code: checkTitle.code, message: checkTitle.message });
|
||||
}
|
||||
const checkLoc = contentSecurityService.checkText(location);
|
||||
if (!checkLoc.pass) {
|
||||
return res.status(400).json({ code: checkLoc.code, message: checkLoc.message });
|
||||
}
|
||||
|
||||
const act = await repo.createActivity(req.body);
|
||||
res.json({ code: 20000, data: act, message: '活动创建成功并已写入MySQL' });
|
||||
} catch (err: any) {
|
||||
@@ -896,6 +949,13 @@ app.get('/api/posts', async (req: Request, res: Response) => {
|
||||
|
||||
app.post('/api/posts', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { content } = req.body || {};
|
||||
// 微信内容安全检测
|
||||
const secCheck = contentSecurityService.checkText(content);
|
||||
if (!secCheck.pass) {
|
||||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||||
}
|
||||
|
||||
const post = await repo.createPost(req.body);
|
||||
res.json({
|
||||
code: 20000,
|
||||
@@ -925,6 +985,12 @@ app.post('/api/orders/:id/review', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const orderId = String(req.params.id);
|
||||
const { rating, tags, content, userId, userNickname, userAvatar, expertId } = req.body;
|
||||
// 微信内容安全检测
|
||||
const secCheck = contentSecurityService.checkText(content);
|
||||
if (!secCheck.pass) {
|
||||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||||
}
|
||||
|
||||
const review = await repo.createOrderReview({
|
||||
orderId,
|
||||
expertId: expertId ? Number(expertId) : undefined,
|
||||
@@ -980,6 +1046,13 @@ app.post('/api/posts/:id/comments', async (req: Request, res: Response) => {
|
||||
if (!content || !content.trim()) {
|
||||
return res.status(400).json({ code: 40000, message: '评论内容不能为空' });
|
||||
}
|
||||
|
||||
// 微信内容安全检测
|
||||
const secCheck = contentSecurityService.checkText(content);
|
||||
if (!secCheck.pass) {
|
||||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||||
}
|
||||
|
||||
const comment = await repo.createPostComment({
|
||||
postId,
|
||||
content: content.trim(),
|
||||
|
||||
在新工单中引用
屏蔽一个用户