feat: security hardening, wechat pay v3 platform cert integration, companion certification revocation, and audit closed-loop

这个提交包含在:
xcbyy
2026-10-16 17:38:17 +08:00
父节点 3bf38db8b6
当前提交 78117bb4e1
共有 58 个文件被更改,包括 12971 次插入 和 952 次删除

查看文件

@@ -0,0 +1,215 @@
// 已读取AGENTS.md
/**
* 微信支付 V3 (微信支付APIv3) 直连商户服务端客户端
* - 使用商户 API 私钥 (apiclient_key.pem) 对请求做 RSA-SHA256 签名
* - 使用微信支付平台公钥/平台证书 对回调做验签
* - 使用 APIv3 密钥 对回调 resource 做 AES-256-GCM 解密
* 该模块为纯函数/无状态,便于离线单元校验与复用。
*/
import crypto from 'crypto';
import https from 'https';
export interface WechatPayConfig {
appId: string;
mchId: string;
apiV3Key: string;
serialNo: string;
privateKey: string;
platformPublicKey?: string;
platformPublicKeyId?: string;
notifyUrl?: string;
}
const WXPAY_HOST = 'api.mch.weixin.qq.com';
/** 解析微信支付平台公钥:兼容「公钥 PEM」与「平台证书 PEM」两种输入 */
export function resolvePlatformKey(platformKeyOrCert?: string): crypto.KeyObject | null {
const pem = String(platformKeyOrCert || '').trim();
if (!pem) return null;
try {
return crypto.createPublicKey(pem);
} catch (_) {
// 若为 X509 证书 PEM,则提取其中的公钥
try {
return new crypto.X509Certificate(pem).publicKey;
} catch (_e) {
return null;
}
}
}
/** 生成微信支付 V3 请求的 Authorization 头 */
export function buildAuthorization(opts: {
method: string;
urlPath: string;
body: string;
cfg: WechatPayConfig;
}): string {
const timestamp = String(Math.floor(Date.now() / 1000));
const nonceStr = crypto.randomBytes(16).toString('hex');
const message = `${opts.method}\n${opts.urlPath}\n${timestamp}\n${nonceStr}\n${opts.body}\n`;
const signature = crypto
.createSign('RSA-SHA256')
.update(message)
.sign(opts.cfg.privateKey, 'base64');
return (
`WECHATPAY2-SHA256-RSA2048 ` +
`mchid="${opts.cfg.mchId}",` +
`nonce_str="${nonceStr}",` +
`signature="${signature}",` +
`timestamp="${timestamp}",` +
`serial_no="${opts.cfg.serialNo}"`
);
}
/** 底层 HTTPS 请求,返回状态码 / 响应头 / 原始文本 */
export function httpsRequest(opts: {
method: string;
urlPath: string;
headers: Record<string, string>;
body?: string;
}): Promise<{ statusCode: number; headers: any; body: string }> {
return new Promise((resolve, reject) => {
const req = https.request(
{
method: opts.method,
hostname: WXPAY_HOST,
path: opts.urlPath,
headers: opts.headers,
},
(resp) => {
let data = '';
resp.on('data', (chunk) => {
data += chunk;
});
resp.on('end', () =>
resolve({ statusCode: resp.statusCode || 0, headers: resp.headers, body: data })
);
}
);
req.on('error', reject);
if (opts.body) req.write(opts.body);
req.end();
});
}
/** JSAPI 统一下单,返回 prepay_id(并为小程序返回支付参数) */
export async function createJsapiPrepay(
cfg: WechatPayConfig,
params: { outTradeNo: string; description: string; amountTotalCents: number; openid: string }
): Promise<string> {
const urlPath = '/v3/pay/transactions/jsapi';
const body = JSON.stringify({
appid: cfg.appId,
mchid: cfg.mchId,
description: params.description,
out_trade_no: params.outTradeNo,
notify_url: cfg.notifyUrl,
amount: { total: params.amountTotalCents, currency: 'CNY' },
payer: { openid: params.openid },
});
const authorization = buildAuthorization({ method: 'POST', urlPath, body, cfg });
const resp = await httpsRequest({
method: 'POST',
urlPath,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: authorization,
'User-Agent': 'peiwan-server/1.0',
},
body,
});
if (resp.statusCode >= 400) {
throw new Error(`微信支付 JSAPI 下单失败 [${resp.statusCode}]: ${resp.body}`);
}
const parsed = JSON.parse(resp.body || '{}');
if (!parsed.prepay_id) {
throw new Error(`微信支付未返回 prepay_id: ${resp.body}`);
}
return parsed.prepay_id as string;
}
/** 使用 prepay_id 生成小程序 wx.requestPayment 所需参数并签名 */
export function buildJsapiPayParams(
cfg: WechatPayConfig,
prepayId: string
): { timeStamp: string; nonceStr: string; package: string; signType: 'RSA'; paySign: string } {
const timeStamp = String(Math.floor(Date.now() / 1000));
const nonceStr = crypto.randomBytes(16).toString('hex');
const packageStr = `prepay_id=${prepayId}`;
const message = `${cfg.appId}\n${timeStamp}\n${nonceStr}\n${packageStr}\n`;
const paySign = crypto
.createSign('RSA-SHA256')
.update(message)
.sign(cfg.privateKey, 'base64');
return { timeStamp, nonceStr, package: packageStr, signType: 'RSA', paySign };
}
/** 主动查单(用于支付成功后及时对账,避免仅依赖异步回调) */
export async function queryOrderByOutTradeNo(
cfg: WechatPayConfig,
outTradeNo: string
): Promise<any> {
const urlPath = `/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}?mchid=${encodeURIComponent(cfg.mchId)}`;
const authorization = buildAuthorization({ method: 'GET', urlPath, body: '', cfg });
const resp = await httpsRequest({
method: 'GET',
urlPath,
headers: {
Accept: 'application/json',
Authorization: authorization,
'User-Agent': 'peiwan-server/1.0',
},
});
if (resp.statusCode >= 400) {
throw new Error(`微信支付查单失败 [${resp.statusCode}]: ${resp.body}`);
}
return JSON.parse(resp.body || '{}');
}
/** 校验微信支付回调签名(使用平台公钥/平台证书),时间戳容差 5 分钟 */
export function verifyNotifySignature(params: {
cfg: WechatPayConfig;
timestamp: string;
nonce: string;
body: string;
signature: string;
serial: string;
}): boolean {
const { cfg, timestamp, nonce, body, signature } = params;
if (!timestamp || !nonce || !signature) return false;
const platformKey = resolvePlatformKey(cfg.platformPublicKey);
if (!platformKey) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const message = `${timestamp}\n${nonce}\n${body}\n`;
try {
return crypto
.createVerify('RSA-SHA256')
.update(message)
.verify(platformKey, signature, 'base64');
} catch (_) {
return false;
}
}
/** 解密回调 resource(AES-256-GCM,key 为 32 位 APIv3 密钥) */
export function decryptNotifyResource(
apiV3Key: string,
resource: { ciphertext: string; nonce: string; associated_data?: string }
): any {
const key = Buffer.from(String(apiV3Key), 'utf8');
if (key.length !== 32) {
throw new Error('APIv3 密钥长度必须为 32 位');
}
const data = Buffer.from(resource.ciphertext, 'base64');
const authTag = data.subarray(data.length - 16);
const ciphertext = data.subarray(0, data.length - 16);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(resource.nonce, 'utf8'));
decipher.setAuthTag(authTag);
if (resource.associated_data) {
decipher.setAAD(Buffer.from(resource.associated_data, 'utf8'));
}
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return JSON.parse(decrypted.toString('utf8'));
}

查看文件

@@ -13,6 +13,16 @@ export interface SecurityCheckResult {
message: string;
}
/**
* 远端内容安全检测器签名(由服务端启动时注入真实的微信 msgSecCheck 调用)。
* 返回 null 表示远端不可用/未配置,调用方应降级为本地词库结果。
*/
export type RemoteTextChecker = (
text: string,
openid?: string,
scene?: number
) => Promise<SecurityCheckResult | null>;
export class ContentSecurityService {
// 敏感词黑名单库(按涉黄低俗、涉政暴恐、欺诈引流、违规黑产分类)
private static readonly SENSITIVE_KEYWORDS: { [category: string]: string[] } = {
@@ -33,6 +43,20 @@ export class ContentSecurityService {
// 动态自定义敏感词列表(支持管理后台增删改并实时热生效)
private customKeywords: string[] = [];
// 远端真实审核器(微信官方 security.msgSecCheck),未注入时仅使用本地词库
private remoteChecker?: RemoteTextChecker;
/**
* 注入/替换远端真实内容安全审核器(微信 msgSecCheck)
*/
public setRemoteChecker(checker: RemoteTextChecker): void {
this.remoteChecker = checker;
}
public hasRemoteChecker(): boolean {
return typeof this.remoteChecker === 'function';
}
/**
* 载入或更新动态敏感词黑名单
*/
@@ -49,6 +73,27 @@ export class ContentSecurityService {
return [...this.customKeywords];
}
/**
* 异步内容安全检测(真实对接微信 security.msgSecCheck)
* 双层防御:先跑本地词库即时拦截;本地放行且已注入远端审核器时,
* 再调用微信官方机器审核。远端异常/未配置时降级为本地结果(fail-open),避免阻断正常业务。
* @param text 待检测文本
* @param openid 用户微信 openid(供微信官方溯源,强烈建议传入)
* @param scene 场景值:1 资料;2 评论;3 论坛;4 社交日志
*/
public async checkTextAsync(text: string, openid?: string, scene?: number): Promise<SecurityCheckResult> {
const local = this.checkText(text, openid);
if (!local.pass) return local;
if (typeof this.remoteChecker !== 'function') return local;
try {
const remote = await this.remoteChecker(text, openid, scene);
if (remote) return remote;
} catch (_) {
// 远端异常时降级为本地词库结果
}
return local;
}
/**
* 检查文本内容安全 (security.msgSecCheck)
* @param text 待检测文本(支持动态、评论、活动标题、个人简介等)

查看文件

@@ -0,0 +1,54 @@
import * as crypto from 'crypto';
/**
* 小程序用户会话 token(HMAC-SHA256 签名,无状态)。
* 目的是让服务端在资金/敏感写接口上以「服务端签发并验签的身份」为准,
* 杜绝客户端通过伪造 userId 冒充他人。
*
* token 结构: base64url(JSON{uid,ts}) + "." + base64url(HMAC_SHA256(payload, secret))
*/
function base64url(input: Buffer | string): string {
return Buffer.from(input).toString('base64url');
}
export function signUserToken(
userId: number,
secret: string,
ttlMs: number,
now: number = Date.now()
): string {
const payload = base64url(JSON.stringify({ uid: Number(userId), ts: Number(now) }));
const sig = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
export function verifyUserToken(
token: string,
secret: string,
ttlMs: number,
now: number = Date.now()
): number | null {
const parts = String(token || '').split('.');
if (parts.length !== 2) return null;
const [payload, sig] = parts;
if (!payload || !sig) return null;
const expected = crypto.createHmac('sha256', secret).update(payload).digest('base64url');
const sigBuf = Buffer.from(sig);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return null;
try {
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
const uid = Number(data.uid);
const ts = Number(data.ts);
if (!uid || !ts) return null;
if (now - ts > ttlMs) return null;
// 拒绝明显来自未来(时钟漂移/伪造)的 token
if (ts - now > 5 * 60 * 1000) return null;
return uid;
} catch (_) {
return null;
}
}

查看文件

@@ -27,6 +27,7 @@ export async function initDatabase(): Promise<void> {
title VARCHAR(255) NOT NULL,
game_category VARCHAR(64) NOT NULL,
location VARCHAR(255),
start_time VARCHAR(64) DEFAULT '',
pay_type INT NOT NULL DEFAULT 1,
total_fee_cents INT DEFAULT 0,
per_person_fee_cents INT DEFAULT 0,
@@ -38,6 +39,12 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await conn.query(`ALTER TABLE activities ADD COLUMN start_time VARCHAR(64) DEFAULT '' AFTER location`);
} catch (_) {
// 字段已存在忽略
}
try {
await conn.query(`ALTER TABLE activities ADD COLUMN status VARCHAR(32) DEFAULT 'RECRUITING' AFTER price_adjust_rule`);
} catch (_) {
@@ -57,10 +64,24 @@ export async function initDatabase(): Promise<void> {
verified_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_activity (activity_id),
INDEX idx_verify_code (verify_code)
INDEX idx_verify_code (verify_code),
UNIQUE KEY uq_activity_user (activity_id, user_id),
UNIQUE KEY uq_activity_verify (activity_id, verify_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 历史库补丁:为已存在的 participants 表补充唯一约束(防重复报名 / 防核销码冲突)
for (const uqSql of [
'ALTER TABLE participants ADD UNIQUE KEY uq_activity_user (activity_id, user_id)',
'ALTER TABLE participants ADD UNIQUE KEY uq_activity_verify (activity_id, verify_code)'
]) {
try {
await conn.query(uqSql);
} catch (_) {
// 约束已存在或历史数据存在重复,忽略(应用层仍会二次校验)
}
}
// 3. 陪玩大神档案表
await conn.query(`
CREATE TABLE IF NOT EXISTS companions (
@@ -131,7 +152,9 @@ export async function initDatabase(): Promise<void> {
"ALTER TABLE orders ADD COLUMN coupon_discount DECIMAL(10,2) DEFAULT 0.00 AFTER amount",
"ALTER TABLE orders ADD COLUMN paid_at DATETIME NULL AFTER created_at",
"ALTER TABLE orders ADD COLUMN finish_applied_at DATETIME NULL AFTER paid_at",
"ALTER TABLE orders ADD COLUMN refund_amount DECIMAL(10,2) DEFAULT 0.00 AFTER coupon_discount"
"ALTER TABLE orders ADD COLUMN refund_amount DECIMAL(10,2) DEFAULT 0.00 AFTER coupon_discount",
"ALTER TABLE orders ADD COLUMN claimed_by INT NULL AFTER expert_id",
"ALTER TABLE orders ADD COLUMN claimed_at DATETIME NULL AFTER claimed_by"
];
for (const colSql of extraOrderCols) {
try {
@@ -194,6 +217,26 @@ export async function initDatabase(): Promise<void> {
await conn.query('ALTER TABLE withdrawals ADD COLUMN reason VARCHAR(255) DEFAULT ""');
} catch (_) {}
// 7.5 微信支付支付意图与对账表 (幂等入账的唯一依据)
await conn.query(`
CREATE TABLE IF NOT EXISTS payment_intents (
id VARCHAR(64) PRIMARY KEY,
out_trade_no VARCHAR(64) UNIQUE NOT NULL,
scene VARCHAR(32) NOT NULL DEFAULT 'ORDER',
user_id INT NOT NULL DEFAULT 0,
order_id VARCHAR(64) DEFAULT '',
title VARCHAR(128) DEFAULT '',
amount_cents INT NOT NULL DEFAULT 0,
bonus_cents INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
transaction_id VARCHAR(64) DEFAULT '',
credited TINYINT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
paid_at DATETIME NULL,
INDEX idx_user_scene (user_id, scene, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 8. 线下合规场所库
await conn.query(`
CREATE TABLE IF NOT EXISTS safety_places (
@@ -292,10 +335,18 @@ export async function initDatabase(): Promise<void> {
reply_time DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_order (order_id),
INDEX idx_expert (expert_id)
INDEX idx_expert (expert_id),
UNIQUE KEY uq_order_review (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 历史库补丁:同一订单仅允许一条评价,防止重复评价反复触发结算
try {
await conn.query('ALTER TABLE order_reviews ADD UNIQUE KEY uq_order_review (order_id)');
} catch (_) {
// 约束已存在或历史数据存在重复,忽略(应用层仍会二次校验)
}
try {
await conn.query('ALTER TABLE order_reviews ADD COLUMN reply_content TEXT AFTER content');
} catch (_) {}
@@ -895,6 +946,32 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
remark: '微信小程序订阅消息与服务通知模版ID配置'
});
const wechatPayConfigVal = JSON.stringify({
mode: 'AUTO',
appId: 'wx0c929f7e54024e67',
appSecret: '63a412f3f73af8e73f6923943bf9683c',
mchId: '',
apiV3Key: '',
serialNo: '',
privateKey: '',
certPem: '',
platformPublicKeyId: '',
platformPublicKey: '',
notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify',
remark: '微信支付V3直连商户参数配置(配置完整后自动或手动切换至实盘JSAPI支付)'
});
const mapLocationConfigVal = JSON.stringify({
provider: 'tencent',
mapKey: '',
secretKey: '',
serviceName: '腾讯位置服务 (Tencent LBS)',
enableChooseLocation: 1,
enableDistanceCalculation: 1,
defaultCity: '北京市',
remark: '腾讯位置服务 Key(需在 lbs.qq.com 申请并勾选 WebServiceAPI 与微信小程序 AppID: wx0c929f7e54024e67)'
});
const [cfgItemRows]: any = await conn.query('SELECT COUNT(*) as count FROM platform_configs');
if (cfgItemRows[0].count === 0) {
const customerServiceVal = JSON.stringify({
@@ -938,12 +1015,16 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
('home_popup', '小程序首页运营全局弹窗', ?, '节日活动推广、新用户专享首单优惠全局浮层弹窗', 1),
('expert_rules', '大神技能标签库与定价区间规范', ?, '陪玩大神入驻审核、定价上下限限制与官方推荐技能标签库', 1),
('finance_rules', '平台抽成与推广分销规则', ?, '平台佣金抽成比例、二级分销返润与佣金提现门槛限制', 1),
('activity_rules', '同城组局发单与预算预设规范', ?, '招募席位上下限、快捷单人赏金预设与包场预算梯队', 1);
('activity_rules', '同城组局发单与预算预设规范', ?, '招募席位上下限、快捷单人赏金预设与包场预算梯队', 1),
('message_template_rules', '微信服务通知与模板消息配置', ?, '微信订阅消息通知与状态模板配置', 1),
('wechat_pay_config', '微信支付商户号与证书配置', ?, '微信支付V3商户号、APIv3密钥与证书参数配置', 1),
('map_location_config', '地图定位与LBS服务配置', ?, '腾讯位置服务Key、选点与周边测距参数配置', 1);
`, [
customerServiceVal, safetyNoticeVal, protocolVal,
communityTopicsVal, communityNoticeVal, faqListVal,
securityWordsVal, homePopupVal, expertRulesVal,
financeRulesVal, activityRulesVal
financeRulesVal, activityRulesVal, messageTemplateRulesVal,
wechatPayConfigVal, mapLocationConfigVal
]);
} else {
// 增量保证各核心配置项完整就绪
@@ -1003,6 +1084,40 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
VALUES ('message_template_rules', '微信服务通知与模板消息配置', ?, '微信订阅消息通知与状态模板配置', 1)
`, [messageTemplateRulesVal]);
}
if (!existingKeys.has('wechat_pay_config')) {
await conn.query(`
INSERT INTO platform_configs (config_key, name, value_json, description, status)
VALUES ('wechat_pay_config', '微信支付商户号与证书配置', ?, '微信支付V3商户号、APIv3密钥与证书参数配置', 1)
`, [wechatPayConfigVal]);
} else {
const [wpRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'wechat_pay_config'");
if (wpRows && wpRows.length > 0) {
let curVal = wpRows[0].value_json;
if (typeof curVal === 'string') {
try { curVal = JSON.parse(curVal); } catch (_) {}
}
if (curVal && typeof curVal === 'object') {
let updated = false;
if (!curVal.appId || curVal.appId === 'wx_peiwan_miniapp') {
curVal.appId = 'wx0c929f7e54024e67';
updated = true;
}
if (!curVal.appSecret) {
curVal.appSecret = '63a412f3f73af8e73f6923943bf9683c';
updated = true;
}
if (updated) {
await conn.query("UPDATE platform_configs SET value_json = ? WHERE config_key = 'wechat_pay_config'", [JSON.stringify(curVal)]);
}
}
}
}
if (!existingKeys.has('map_location_config')) {
await conn.query(`
INSERT INTO platform_configs (config_key, name, value_json, description, status)
VALUES ('map_location_config', '地图定位与LBS服务配置', ?, '腾讯位置服务Key、选点与周边测距参数配置', 1)
`, [mapLocationConfigVal]);
}
if (existingKeys.has('platform_protocol')) {
const [protoRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'platform_protocol'");
if (protoRows && protoRows.length > 0) {

文件差异内容过多而无法显示 加载差异

文件差异内容过多而无法显示 加载差异