feat: security hardening, wechat pay v3 platform cert integration, companion certification revocation, and audit closed-loop
这个提交包含在:
215
server/src/core/pay/wechat-pay-v3.ts
普通文件
215
server/src/core/pay/wechat-pay-v3.ts
普通文件
@@ -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) {
|
||||
|
||||
文件差异内容过多而无法显示
加载差异
1578
server/src/index.ts
1578
server/src/index.ts
文件差异内容过多而无法显示
加载差异
在新工单中引用
屏蔽一个用户