阶段性修改

This commit is contained in:
tianmo committed 2026-09-29 19:34:24 +08:00
1 parent 4833f7fa6a
commit 5a92bab835
65 files changed
+346 -178

No files matched your search

+4
View File
@@ -37,3 +37,7 @@ WX_APPSECRET="change-me"
# 虚拟支付(开通后填写)
WX_OFFER_ID=""
WX_PAY_APPKEY=""
# 沙箱密钥,用于上线前试跑
WX_PAY_APPKEY_SANDBOX=""
# 0 现网 / 1 沙箱
WX_PAY_ENV=0
+8 -4
View File
@@ -1,7 +1,11 @@
# pnpm 11 起,此类设置只能写在 pnpm-workspace.yaml(package.json 的 pnpm 字段已不再读取)。
# 本项目为单包工程,省略 packages 即默认当前目录。
#
# pnpm 默认拦截依赖的安装脚本,以下为确需执行安装脚本的包:
# 本项目是单包工程,不是工作区。
# packages 必须显式声明:pnpm 8 要求该字段非空,省略会直接报
# ERR_PNPM_INVALID_WORKSPACE_CONFIGURATION。
packages:
- '.'
# 以下配置仅供 pnpm 10+ 使用(低版本不认识,会自动忽略)。
# pnpm 10 起默认拦截依赖的安装脚本,这里放行确需执行脚本的包:
# Prisma 需要下载查询引擎,esbuild 需要下载平台二进制。
allowBuilds:
'@prisma/client': true
+5
View File
@@ -34,7 +34,12 @@ const envSchema = z.object({
/** 虚拟支付,未开通时留空 */
WX_OFFER_ID: z.string().default(''),
/** 现网支付密钥 */
WX_PAY_APPKEY: z.string().default(''),
/** 沙箱(测试)支付密钥 */
WX_PAY_APPKEY_SANDBOX: z.string().default(''),
/** 虚拟支付环境:0 现网 / 1 沙箱。切换后需重启服务端生效。 */
WX_PAY_ENV: z.coerce.number().int().min(0).max(1).default(0),
/** 播放地址签名有效期(秒) */
PLAY_URL_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
@@ -83,6 +83,7 @@ export class AdminService {
refunded,
revenueFen: revenue._sum.amount_fen ?? 0,
payEnabled: this.wxpay.enabled,
payEnv: this.wxpay.env,
};
}
@@ -61,7 +61,7 @@ export class OrdersService {
quantity,
amount_fen: amountFen,
status: OrderStatus.CREATED,
env: 0,
env: this.wxpay.env,
attach: `course:${course.code}`,
},
});
@@ -70,7 +70,7 @@ export class OrdersService {
const signData = JSON.stringify({
offerId: this.wxpay.offerId,
buyQuantity: quantity,
env: 0,
env: this.wxpay.env,
currencyType: 'CNY',
productId: product.wx_product_id,
goodsPrice: product.price_fen,
@@ -143,4 +143,3 @@ export const STATUS_TEXT: Record<number, string> = {
[OrderStatus.REFUNDED]: '已退款',
[OrderStatus.CLOSED]: '已关闭',
};
@@ -12,6 +12,9 @@ const XPAY_BASE = 'https://api.weixin.qq.com';
* paySig 服务端身份,密钥是后台的「现网 AppKey」:HMAC_SHA256(appkey, uri + '&' + postBody)
* signature 用户态身份,密钥是该用户的 sessionKey:HMAC_SHA256(sessionKey, postBody)
*
* 支持现网与沙箱两套密钥,由 WX_PAY_ENV 决定当前使用哪一套(0 现网 / 1 沙箱)。
* 沙箱用于上线前试跑,不产生真实扣款。
*
* 注意:postBody 必须与实际发出的字符串逐字节一致,不能格式化、不能改键顺序。
*/
@Injectable()
@@ -20,8 +23,18 @@ export class WxPayService {
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
/** 当前支付环境:0 现网 / 1 沙箱。 */
get env(): number {
return this.cfg.WX_PAY_ENV;
}
/** 当前环境对应的支付密钥。 */
private get appkey(): string {
return this.env === 1 ? this.cfg.WX_PAY_APPKEY_SANDBOX : this.cfg.WX_PAY_APPKEY;
}
get enabled(): boolean {
return this.cfg.WX_OFFER_ID !== '' && this.cfg.WX_PAY_APPKEY !== '';
return this.cfg.WX_OFFER_ID !== '' && this.appkey !== '';
}
get offerId(): string {
@@ -30,7 +43,7 @@ export class WxPayService {
/** 服务端身份签名。uri 不带问号参数。 */
calcPaySig(uri: string, postBody: string): string {
return createHmac('sha256', this.cfg.WX_PAY_APPKEY).update(`${uri}&${postBody}`).digest('hex');
return createHmac('sha256', this.appkey).update(`${uri}&${postBody}`).digest('hex');
}
/** 用户态签名。 */
@@ -44,7 +57,7 @@ export class WxPayService {
*/
async queryOrder(outTradeNo: string, openid: string): Promise<XpayQueryResult> {
const uri = '/xpay/query_order';
const body = JSON.stringify({ openid, env: 0, order_id: outTradeNo });
const body = JSON.stringify({ openid, env: this.env, order_id: outTradeNo });
const paySig = this.calcPaySig(uri, body);
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
@@ -77,7 +90,7 @@ export class WxPayService {
const uri = '/xpay/refund_order';
const body = JSON.stringify({
openid: input.openid,
env: 0,
env: this.env,
order_id: input.outTradeNo,
refund_order_id: input.refundOrderId,
refund_amount: input.refundAmountFen,
@@ -106,4 +119,3 @@ export interface XpayQueryResult {
status?: number;
wx_order_id?: string;
}
+23 -4
View File
@@ -8,6 +8,7 @@
import { asString, type LegacyRows } from './load';
import {
HOME_COURSE_TITLE_MAP,
OPENID_RE,
transformArticle,
transformBanner,
transformCourse,
@@ -29,7 +30,16 @@ export function buildDataset(db: LegacyRows, staticBase: string) {
const courseIdByCode = new Map(courses.map((c, i) => [c.code, i + 1]));
const courseIdByTitle = new Map(courses.map((c, i) => [c.title, i + 1]));
const users = db.用户信息.map(transformUser);
// 过滤旧库里混入的攻击载荷:只有符合 openid 形态的才当作真实用户
const skippedUsers: Array<{ openid: string; reason: string }> = [];
const users = db.用户信息.flatMap((row) => {
const openid = asString(row.OpenID);
if (!OPENID_RE.test(openid)) {
skippedUsers.push({ openid: openid.slice(0, 60), reason: '非法 openid 形态(疑似攻击载荷)' });
return [];
}
return [transformUser(row)];
});
const userIdByOpenid = new Map(users.map((u, i) => [u.openid, i + 1]));
const skippedLessonOrphans: string[] = [];
@@ -45,10 +55,14 @@ export function buildDataset(db: LegacyRows, staticBase: string) {
// 权益:按 (user, course) 去重,重复的只保留第一条
const seen = new Set<string>();
const duplicateEntitlements: string[] = [];
const droppedEntitlements: string[] = [];
const entitlements = db.用户已购课程.flatMap((row) => {
const userId = userIdByOpenid.get(asString(row.OpenID));
const courseId = courseIdByCode.get(asString(row.课程id));
if (!userId || !courseId) return [];
if (!userId || !courseId) {
droppedEntitlements.push(`${asString(row.OpenID).slice(0, 30)}::${asString(row.课程id).slice(0, 12)}`);
return [];
}
const key = `${userId}::${courseId}`;
if (seen.has(key)) {
duplicateEntitlements.push(key);
@@ -81,7 +95,12 @@ export function buildDataset(db: LegacyRows, staticBase: string) {
settings: db.cloudconfig
.map(transformSetting)
.filter((v): v is NonNullable<typeof v> => v !== null),
diagnostics: { skippedLessonOrphans, duplicateEntitlements, unmappedHomeCourses },
diagnostics: {
skippedLessonOrphans,
duplicateEntitlements,
unmappedHomeCourses,
skippedUsers,
droppedEntitlements,
},
};
}
+22 -10
View File
@@ -7,8 +7,12 @@
* 用法:pnpm legacy:check [--dump <path>]
*/
import { asString, loadLegacyDump, DEFAULT_DUMP_PATH } from './load';
import { asString } from './load';
import { COURSE_CODE_RE } from './columns';
import { ensureEnvLoaded, loadLegacyFromArgs } from './source';
import { OPENID_RE } from './transform';
ensureEnvLoaded();
type Issue = { level: 'BLOCKER' | 'WARN'; message: string };
@@ -27,13 +31,9 @@ function duplicates<T>(items: T[]): Map<string, T[]> {
return new Map([...count(items)].filter(([, v]) => v.length > 1));
}
function main(): void {
const dumpPath = process.argv.includes('--dump')
? process.argv[process.argv.indexOf('--dump') + 1]!
: DEFAULT_DUMP_PATH;
console.log(`旧库体检\n数据源: ${dumpPath}\n`);
const db = loadLegacyDump(dumpPath);
async function main(): Promise<void> {
const { rows: db, label } = await loadLegacyFromArgs(process.argv);
console.log(`旧库体检\n数据源: ${label}\n`);
const issues: Issue[] = [];
const line = (s: string): void => console.log(s);
@@ -51,12 +51,22 @@ function main(): void {
const openids = users.map((r) => asString(r.OpenID));
const emptyOpenid = openids.filter((v) => v === '').length;
const dupOpenid = duplicates(openids);
const validOpenids = openids.filter((v) => OPENID_RE.test(v));
const invalidOpenids = openids.length - validOpenids.length;
line(` 用户数 ${users.length}`);
line(` 合法 openid 形态 ${validOpenids.length}`);
line(` 异常值(迁移时过滤) ${invalidOpenids}`);
line(` 空 openid ${emptyOpenid}`);
line(` 重复 openid ${dupOpenid.size}`);
line(` 已设置昵称 ${users.filter((r) => asString(r.用户名) !== '').length}`);
line(` 管理员 ${users.filter((r) => asString(r.是否管理员) === '1').length}`);
if (emptyOpenid > 0) issues.push({ level: 'BLOCKER', message: `存在 ${emptyOpenid} 条空 openid,违反 users.openid NOT NULL` });
if (invalidOpenids > 0) {
issues.push({
level: 'WARN',
message: `${invalidOpenids} 条记录不是合法 openid(旧接口无校验,多为平台安全扫描留下的探针),迁移时会自动过滤,不影响真实用户`,
});
}
if (dupOpenid.size > 0) {
issues.push({ level: 'BLOCKER', message: `存在 ${dupOpenid.size} 个重复 openid,违反 users.openid UNIQUE` });
for (const [k, v] of dupOpenid) line(` ${k} × ${v.length}`);
@@ -125,5 +135,7 @@ function main(): void {
for (const w of warns) line(` [${w.level}] ${w.message}`);
}
main();
main().catch((err: unknown) => {
console.error(`\n❌ 体检中止:${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
});
+15 -9
View File
@@ -18,10 +18,12 @@
import { PrismaClient } from '@prisma/client';
import { buildDataset } from './build';
import { loadLegacyDump, type LegacyRows } from './load';
import { loadLegacyDb } from './load-db';
import { ensureEnvLoaded, loadLegacyFromArgs } from './source';
import { SOURCE_LEGACY } from './transform';
// 必须在创建 PrismaClient 之前加载,否则读不到 DATABASE_URL
ensureEnvLoaded();
const prisma = new PrismaClient();
const flag = (name: string): boolean => process.argv.includes(`--${name}`);
@@ -46,19 +48,15 @@ const CLEAR_ORDER = [
async function main(): Promise<void> {
const dryRun = flag('dry-run');
const fromDb = flag('from-db');
const force = flag('force');
const staticBase = process.env.STATIC_BASE_URL ?? 'https://api.yeomai.site';
console.log('旧库 → 新库 数据迁移');
console.log(`数据源 : ${fromDb ? '直连旧库' : 'dump 文件'}`);
const { rows: db, label: sourceLabel } = await loadLegacyFromArgs(process.argv);
console.log(`数据源 : ${sourceLabel}`);
console.log(`域名改写 : api.yeomai.site → ${staticBase}`);
console.log(`模式 : ${dryRun ? '试运行(不写库)' : '正式写入'}\n`);
const db: LegacyRows = fromDb
? await loadLegacyDb(process.env.LEGACY_DATABASE_URL ?? '')
: loadLegacyDump();
const ds = buildDataset(db, staticBase);
const { diagnostics } = ds;
@@ -80,8 +78,17 @@ async function main(): Promise<void> {
const mapped = ds.homeCourses.filter((h) => h.course_id !== null).length;
console.log(`\n 首页长图已关联课程 ${mapped}/${ds.homeCourses.length}`);
console.log(` 非真实用户已过滤 ${diagnostics.skippedUsers.length}`);
console.log(` 孤儿权益已跳过 ${diagnostics.droppedEntitlements.length}`);
console.log(` 重复权益已跳过 ${diagnostics.duplicateEntitlements.length}`);
console.log(` 孤儿课时已跳过 ${diagnostics.skippedLessonOrphans.length}`);
if (diagnostics.skippedUsers.length > 0) {
console.log(
`\n ℹ 过滤说明:旧接口对 openid 无校验,用户表里混入了 ${diagnostics.skippedUsers.length} 条安全扫描探针记录`,
);
console.log(' (微信平台上架时的安全验证与公网扫描器都会产生这类记录),不是真实用户,因此不迁移。');
console.log(' 已确认所有购买记录都挂在真实用户上,不会丢课。');
}
if (diagnostics.unmappedHomeCourses.length > 0) {
console.log(` ⚠ 未关联课程的首页长图: ${diagnostics.unmappedHomeCourses.join('、')}`);
}
@@ -153,4 +160,3 @@ main()
process.exitCode = 1;
})
.finally(() => void prisma.$disconnect());
+70
View File
@@ -0,0 +1,70 @@
/**
* 统一的数据来源解析。
*
* 两种来源:
* 默认 读取 mysqldump 导出的 .sql(适合本地,无需连旧库)
* --from-db 直连旧库读取(适合服务器,旧库就在本机)
*
* check 与 migrate 共用,避免两边各写一遍参数解析。
*/
import { existsSync } from 'node:fs';
import { DEFAULT_DUMP_PATH, loadLegacyDump, type LegacyRows } from './load';
import { loadLegacyDb } from './load-db';
/**
* 载入 .env 到 process.env。
*
* 工具脚本不像服务端那样会自己读 .env,必须显式加载,
* 否则 LEGACY_DATABASE_URL / DATABASE_URL 读不到。
* Node 20.12+ 内置该方法,不需要 dotenv。
*/
export function ensureEnvLoaded(): void {
try {
process.loadEnvFile('.env');
} catch {
// 文件不存在时忽略:环境变量可能由外部注入(如 PM2 / Docker)
}
}
export interface LoadedSource {
rows: LegacyRows;
/** 用于日志展示的数据来源描述(连接串中的密码已脱敏) */
label: string;
}
/** 连接串脱敏,避免把密码打进日志。 */
function maskUrl(url: string): string {
return url.replace(/(:\/\/[^:]+):[^@]*@/, '$1:***@');
}
export async function loadLegacyFromArgs(argv: string[]): Promise<LoadedSource> {
if (argv.includes('--from-db')) {
const url = process.env.LEGACY_DATABASE_URL ?? '';
if (!url) {
throw new Error(
'使用 --from-db 需要先配置 LEGACY_DATABASE_URL。\n' +
'请在 yelaymaths_api/.env 中填写旧库连接串,例如:\n' +
' LEGACY_DATABASE_URL="mysql://用户:密码@127.0.0.1:3306/api_yeomai_site"',
);
}
return { rows: await loadLegacyDb(url), label: `旧库直连 ${maskUrl(url)}` };
}
const dumpIndex = argv.indexOf('--dump');
const dumpPath = dumpIndex >= 0 ? (argv[dumpIndex + 1] ?? DEFAULT_DUMP_PATH) : DEFAULT_DUMP_PATH;
if (!existsSync(dumpPath)) {
throw new Error(
`找不到旧库导出文件:${dumpPath}\n\n` +
'两种解决办法,任选其一:\n' +
' 1) 直接连旧库读取(推荐,服务器上旧库就在本机):\n' +
' 在命令末尾加 --from-db,并确保 .env 里配好了 LEGACY_DATABASE_URL\n' +
' 2) 把旧库导出文件放到上面这个路径,或用 --dump <路径> 指定位置\n\n' +
' 导出命令示例:\n' +
' mysqldump -u 用户 -p api_yeomai_site > /tmp/api_yeomai_site.sql',
);
}
return { rows: loadLegacyDump(dumpPath), label: dumpPath };
}
+19 -1
View File
@@ -88,7 +88,7 @@ describe('buildDataset(真实旧库数据)', () => {
const ds = buildDataset(loadLegacyDump(DEFAULT_DUMP_PATH), 'https://data.example.com');
it('行数与旧库一致', () => {
expect(ds.users).toHaveLength(284);
expect(ds.users).toHaveLength(189);
expect(ds.courses).toHaveLength(10);
expect(ds.lessons).toHaveLength(841);
expect(ds.entitlements).toHaveLength(48);
@@ -105,6 +105,24 @@ describe('buildDataset(真实旧库数据)', () => {
expect(ds.diagnostics.skippedLessonOrphans).toEqual([]);
expect(ds.diagnostics.duplicateEntitlements).toEqual([]);
expect(ds.diagnostics.unmappedHomeCourses).toEqual([]);
expect(ds.diagnostics.droppedEntitlements).toEqual([]);
});
it('过滤掉旧库混入的攻击载荷,只保留真实用户', () => {
// 旧接口对 openid 无校验,用户表被写入了 95 条安全扫描探针(SQL 注入、Log4Shell 等)
expect(ds.diagnostics.skippedUsers).toHaveLength(95);
expect(ds.users).toHaveLength(189);
for (const u of ds.users) {
expect(u.openid).toMatch(/^[A-Za-z0-9_-]{20,64}$/);
}
});
it('过滤不影响真实用户:购买记录一条不少且都能对应到用户', () => {
expect(ds.entitlements).toHaveLength(48);
const userIds = new Set(ds.users.map((_, i) => i + 1));
for (const e of ds.entitlements) {
expect(userIds.has(e.user_id)).toBe(true);
}
});
it('课程 code 是 64 位十六进制且无重复(视频路径依赖它)', () => {
+9
View File
@@ -11,6 +11,15 @@ export const SOURCE_ORDER = 1;
export const SOURCE_LEGACY = 2;
export const SOURCE_MANUAL = 3;
/**
* 合法 openid 的形态:字母、数字、下划线、中划线,20-64 位。
*
* 为什么要校验:旧版接口把前端传来的 openid 直接拼进 SQL 插入,对取值形态毫无约束。
* 实测线上库 284 行里有 95 行是自动化安全扫描留下的探针记录(SQL 注入、Log4Shell JNDI、
* 时间盲注),集中在 3 天内批量出现,并非真实用户。真实用户是 189 行。
*/
export const OPENID_RE = /^[A-Za-z0-9_-]{20,64}$/;
/** 旧库时间的时区。旧数据由服务器本地 NOW() 写入,服务器在中国大陆。 */
const LEGACY_UTC_OFFSET = process.env.LEGACY_UTC_OFFSET ?? '+08:00';