小程序修正
This commit is contained in:
1 parent
5a92bab835
commit
6ae4c109ce
12 files changed
+232
-62
No files matched your search
@@ -30,6 +30,10 @@ PLAY_URL_TTL_SECONDS=1800
|
||||
# 课程数据端本地路径(媒体文件根目录)
|
||||
DATA_ROOT="../yelaymaths_data.yeomai.site"
|
||||
|
||||
# 媒体上游地址。媒体文件不在本机时填写,留空则读 DATA_ROOT 本地磁盘。
|
||||
# 例:MEDIA_UPSTREAM="http://127.0.0.1:2525"
|
||||
MEDIA_UPSTREAM=""
|
||||
|
||||
# 微信小程序(沿用现有 AppID)
|
||||
WX_APPID="wx3fbaf897825bf04f"
|
||||
WX_APPSECRET="change-me"
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/schedule": "^5.0.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"express": "^5.2.1",
|
||||
"fast-xml-parser": "^4.5.1",
|
||||
"mysql2": "^3.11.5",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
|
||||
Generated
+3
@@ -29,6 +29,9 @@ importers:
|
||||
'@prisma/client':
|
||||
specifier: ^6.0.0
|
||||
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1(supports-color@8.1.1)
|
||||
fast-xml-parser:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.7
|
||||
|
||||
@@ -49,6 +49,13 @@ const envSchema = z.object({
|
||||
|
||||
/** 课程数据端根目录(媒体文件所在目录) */
|
||||
DATA_ROOT: z.string().min(1).default('../yelaymaths_data.yeomai.site'),
|
||||
|
||||
/**
|
||||
* 媒体上游地址。媒体文件不在本机时填写,例如通过隧道映射到本机的地址:
|
||||
* MEDIA_UPSTREAM="http://127.0.0.1:2525"
|
||||
* 留空则从 DATA_ROOT 读本地磁盘。
|
||||
*/
|
||||
MEDIA_UPSTREAM: z.string().default(''),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import path from 'node:path';
|
||||
import type { Response } from 'express';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
@@ -23,9 +25,45 @@ const REWRITABLE = /\.(ts|m3u8|key|mp4|jpg|jpeg|png|webp)(\?.*)?$/i;
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly root: string;
|
||||
private readonly upstream: string;
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {
|
||||
this.root = path.resolve(process.cwd(), cfg.DATA_ROOT);
|
||||
this.upstream = (cfg.MEDIA_UPSTREAM ?? '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体文件不一定在本机。
|
||||
* 配了 MEDIA_UPSTREAM 就走上游(例如另一台服务器经隧道映射到本机的端口),
|
||||
* 否则读 DATA_ROOT 下的本地文件。
|
||||
*/
|
||||
private upstreamUrl(relativePath: string): string {
|
||||
const encoded = relativePath.split('/').map(encodeURIComponent).join('/');
|
||||
return `${this.upstream}/${encoded}`;
|
||||
}
|
||||
|
||||
/** 取文本内容(播放列表需要整体读入后重写)。 */
|
||||
private async readText(relativePath: string): Promise<string | null> {
|
||||
if (this.upstream) {
|
||||
const res = await fetch(this.upstreamUrl(relativePath));
|
||||
if (!res.ok) return null;
|
||||
return res.text();
|
||||
}
|
||||
const abs = this.resolve(relativePath);
|
||||
return abs ? readFile(abs, 'utf8') : null;
|
||||
}
|
||||
|
||||
private notFound(res: Response, detail = '资源不存在'): void {
|
||||
res.status(404).json({ code: 1001, message: detail, data: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径合法性检查(纵深防御)。
|
||||
* 签名本身已经能挡住伪造请求,这里再拦一道目录穿越,
|
||||
* 避免上游服务器被 `../` 探到数据目录之外。
|
||||
*/
|
||||
private isSafePath(relativePath: string): boolean {
|
||||
return relativePath.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
|
||||
}
|
||||
|
||||
private sign(relativePath: string, expiresAt: number): string {
|
||||
@@ -95,28 +133,47 @@ export class MediaService {
|
||||
|
||||
/** 把文件写入响应。播放列表会先重写。 */
|
||||
async send(res: Response, relativePath: string): Promise<void> {
|
||||
const abs = this.resolve(relativePath);
|
||||
if (!abs) {
|
||||
res.status(404).json({ code: 1001, message: '资源不存在', data: null });
|
||||
if (!this.isSafePath(relativePath)) {
|
||||
this.notFound(res, '非法路径');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(abs).toLowerCase();
|
||||
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
const ext = path.extname(relativePath).toLowerCase();
|
||||
const mime = MIME[ext] ?? 'application/octet-stream';
|
||||
|
||||
// 播放列表:必须整体读入,因为要把签名透传到每一行分片引用上
|
||||
if (ext === '.m3u8') {
|
||||
const content = await readFile(abs, 'utf8');
|
||||
const content = await this.readText(relativePath);
|
||||
if (content === null) {
|
||||
this.notFound(res);
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
res.send(this.rewritePlaylist(content, relativePath));
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(abs);
|
||||
stream.on('error', reject);
|
||||
stream.on('end', resolve);
|
||||
stream.pipe(res);
|
||||
});
|
||||
// 其余资源(分片、密钥、封面图):按字节透传,不整块读入内存
|
||||
if (this.upstream) {
|
||||
const upstreamRes = await fetch(this.upstreamUrl(relativePath));
|
||||
if (!upstreamRes.ok || !upstreamRes.body) {
|
||||
this.notFound(res, `上游返回 ${upstreamRes.status}`);
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', upstreamRes.headers.get('content-type') ?? mime);
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
await pipeline(Readable.fromWeb(upstreamRes.body as Parameters<typeof Readable.fromWeb>[0]), res);
|
||||
return;
|
||||
}
|
||||
|
||||
const abs = this.resolve(relativePath);
|
||||
if (!abs) {
|
||||
this.notFound(res);
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
await pipeline(createReadStream(abs), res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ export class UsersService {
|
||||
if (input.nickname !== undefined) {
|
||||
const nickname = input.nickname.trim();
|
||||
if (nickname.length === 0) throw AppError.badRequest('昵称不能为空');
|
||||
if (nickname.length > 8) throw AppError.badRequest('昵称最多 8 个字符');
|
||||
// 上限取 20:旧数据里自动生成的昵称是「用户 + 8 位随机」共 10 字符,
|
||||
// 而小程序手输框限制 8 字符。服务端要容纳两者,取一个有余量的值。
|
||||
// 数据库列是 VARCHAR(32),不会溢出。
|
||||
if (nickname.length > 20) throw AppError.badRequest('昵称最多 20 个字符');
|
||||
data.nickname = nickname;
|
||||
}
|
||||
|
||||
@@ -80,4 +83,3 @@ export class UsersService {
|
||||
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 50;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,23 +28,26 @@ const prisma = new PrismaClient();
|
||||
|
||||
const flag = (name: string): boolean => process.argv.includes(`--${name}`);
|
||||
|
||||
/** 清空顺序:先子后父。 */
|
||||
const CLEAR_ORDER = [
|
||||
'paymentNotifyLog',
|
||||
'order',
|
||||
'entitlement',
|
||||
'lesson',
|
||||
'homeCourse',
|
||||
'freeLesson',
|
||||
'homeFreeCourse',
|
||||
'banner',
|
||||
'article',
|
||||
'courseTip',
|
||||
'setting',
|
||||
'product',
|
||||
'course',
|
||||
'user',
|
||||
] as const;
|
||||
/**
|
||||
* 清空顺序:先子后父(有外键,顺序不能乱)。
|
||||
* 同时给出真实表名,用于重置自增。
|
||||
*/
|
||||
const CLEAR_ORDER: Array<{ model: string; table: string }> = [
|
||||
{ model: 'paymentNotifyLog', table: 'payment_notify_log' },
|
||||
{ model: 'order', table: 'orders' },
|
||||
{ model: 'entitlement', table: 'entitlements' },
|
||||
{ model: 'lesson', table: 'lessons' },
|
||||
{ model: 'homeCourse', table: 'home_courses' },
|
||||
{ model: 'freeLesson', table: 'free_lessons' },
|
||||
{ model: 'homeFreeCourse', table: 'home_free_courses' },
|
||||
{ model: 'banner', table: 'banners' },
|
||||
{ model: 'article', table: 'articles' },
|
||||
{ model: 'courseTip', table: 'course_tips' },
|
||||
{ model: 'setting', table: 'settings' },
|
||||
{ model: 'product', table: 'products' },
|
||||
{ model: 'course', table: 'courses' },
|
||||
{ model: 'user', table: 'users' },
|
||||
];
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const dryRun = flag('dry-run');
|
||||
@@ -108,14 +111,41 @@ async function main(): Promise<void> {
|
||||
|
||||
// ── 写库 ───────────────────────────────────────────────────────────────
|
||||
console.log('\n── 写入新库 ────────────────────────────');
|
||||
|
||||
type Delegates = Record<string, { deleteMany: () => Promise<unknown> }>;
|
||||
const delegates = prisma as unknown as Delegates;
|
||||
|
||||
// 清空与重置自增必须放在事务外:MySQL 的 ALTER TABLE 会隐式提交事务。
|
||||
for (const { model } of CLEAR_ORDER) await delegates[model]!.deleteMany();
|
||||
|
||||
// 关键:deleteMany 不会重置自增计数器。
|
||||
// 上一次失败的迁移虽然回滚了数据,但计数器已经前进,
|
||||
// 导致再次插入时 id 不是从 1 开始,与 buildDataset 里按顺序推导的映射对不上,
|
||||
// 进而触发外键约束失败。
|
||||
for (const { table } of CLEAR_ORDER) {
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE \`${table}\` AUTO_INCREMENT = 1`);
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
type Delegates = Record<string, { deleteMany: () => Promise<unknown> }>;
|
||||
const delegates = tx as unknown as Delegates;
|
||||
for (const model of CLEAR_ORDER) await delegates[model]!.deleteMany();
|
||||
|
||||
await tx.course.createMany({ data: ds.courses });
|
||||
await tx.user.createMany({ data: ds.users });
|
||||
|
||||
// 校验实际自增 id 与推导出的映射一致。
|
||||
// 若不一致,lessons / entitlements 会引用到错误的课程——这类错误外键查不出来,
|
||||
// 但会让用户点开 A 课程看到 B 课程的内容,所以必须显式拦住。
|
||||
const createdCourses = await tx.course.findMany({ select: { id: true, code: true } });
|
||||
const idByCode = new Map(createdCourses.map((c) => [c.code, c.id]));
|
||||
ds.courses.forEach((course, index) => {
|
||||
const actual = idByCode.get(course.code);
|
||||
if (actual !== index + 1) {
|
||||
throw new Error(
|
||||
`课程 id 与预期不符:${course.title} 期望 id=${index + 1},实际 id=${actual ?? '缺失'}。` +
|
||||
'为避免课程数据错位,迁移已中止。请清空新库后重跑。',
|
||||
);
|
||||
}
|
||||
});
|
||||
await tx.lesson.createMany({ data: ds.lessons });
|
||||
await tx.entitlement.createMany({ data: ds.entitlements });
|
||||
await tx.homeCourse.createMany({ data: ds.homeCourses });
|
||||
|
||||
Reference in new issue
Block a user