diff --git a/docs/部署指南.md b/docs/部署指南.md index 24a3c1a..7d106ce 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -166,14 +166,31 @@ Images/Placeholder/weixin.png 付费视频的鉴权由服务端 `/media/*` 签名通道提供(地址有效期 30 分钟,HLS 的 `.ts` 分片也逐行带签名)。 但如果 `Course/` 目录同时被数据域名公开暴露,任何人猜到路径就能直接下载,这套防护等于没有。 -**在数据端站点的 Nginx 配置里加一条:** +注意 `Course/` 目录里有两类东西,规则要区分对待: + +| 路径 | 内容 | 处理 | +|---|---|---| +| `Course/{课程code}.png` | 课程封面图 | **必须公开**,小程序课程列表要用 | +| `Course/{code}/{章节}/{播放}/playlist.m3u8` | 付费视频 | **必须挡住** | + +**在数据端站点的 Nginx 配置里加这两条(顺序不能反):** ```nginx -location ^~ /Course/ { +# Course 目录下的图片(课程封面)放行 +location ~* ^/Course/.*\.(png|jpe?g|webp|gif)$ { + try_files $uri =404; +} + +# Course 目录下的其它内容(视频切片、播放列表)一律拒绝 +location ~ ^/Course/ { deny all; } ``` +> 两条都用正则 `location`,放行规则写在前面。Nginx 对正则 location 按书写顺序匹配, +> 第一条命中就不再往下走。**不能用 `location ^~ /Course/`** —— `^~` 优先级高于正则, +> 会跳过放行规则,导致课程封面也变成 403。 + 免费内容(`Images/`、`WebPages/`、`FreeVideos/`)保持公开即可。 > 若后续改用 CDN,把 `MediaService.buildSignedUrl` 换成 CDN 厂商的签名方式,并在 CDN 侧对 `Course/` 开鉴权。 @@ -241,6 +258,7 @@ location ^~ /Course/ { - [ ] 未购买课程看不到课时列表(直接请求接口返回 3000 错误码) - [ ] 支付一笔小额订单,课程正确开通 - [ ] 管理后台能看到这笔订单,状态为「已开通」 +- [ ] **数据端已加 `Course/` 防盗链规则**,且「知识」页课程封面正常显示(200) --- diff --git a/yelaymaths/package-lock.json b/yelaymaths/package-lock.json new file mode 100644 index 0000000..1b8a5d4 --- /dev/null +++ b/yelaymaths/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "yelaymaths", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yelaymaths", + "version": "1.0.0", + "dependencies": { + "@vant/weapp": "^1.11.7" + } + }, + "node_modules/@vant/weapp": { + "version": "1.11.7", + "resolved": "https://registry.npmjs.org/@vant/weapp/-/weapp-1.11.7.tgz", + "integrity": "sha512-Rwn9BBnb4kHSV4XmvBicwtd42J+amEUfnFDcXJsGNPNX4a9c/DoT6YLsm4X1wB2+sQbdiQsbFBLAvGRBxCkD8g==", + "license": "MIT" + } + } +} diff --git a/yelaymaths/pages/LoginOrOutDetails/LoginOrOutDetails.js b/yelaymaths/pages/LoginOrOutDetails/LoginOrOutDetails.js index 90f4d00..1d40589 100644 --- a/yelaymaths/pages/LoginOrOutDetails/LoginOrOutDetails.js +++ b/yelaymaths/pages/LoginOrOutDetails/LoginOrOutDetails.js @@ -41,21 +41,37 @@ Page({ }); }, + /** + * 登录 / 注册。 + * + * 关键:不能凭本地缓存决定昵称。 + * 退出登录会清空本地缓存,此时若直接生成随机昵称并提交, + * 会把老用户在服务端的真实昵称覆盖掉。正确做法是先取服务端资料: + * - 已有昵称 → 老用户,原样保留,不提交任何修改 + * - 没有昵称 → 首次注册,生成一个随机昵称 + */ onLoginBtnClick() { - const nickname = this.data.userInfo.nickName || randomNickname(); wx.showLoading({ title: '请稍候', mask: true }); + // getMe 在未登录时会自动完成静默登录,无需手动判断 token api - .updateMe({ nickname: nickname }) + .getMe() .then((user) => { store.setUser(user); + if (user.nickname) return user; + return api.updateMe({ nickname: randomNickname() }).then((created) => { + store.setUser(created); + return created; + }); + }) + .then(() => { wx.hideLoading(); wx.showToast({ title: '成功', icon: 'success', mask: true, duration: 1500 }); setTimeout(() => wx.switchTab({ url: '/pages/wode/wode' }), 1500); }) .catch((err) => { wx.hideLoading(); - wx.showToast({ title: err.message || '登录失败', icon: 'none', mask: true }); + wx.showToast({ title: (err && err.message) || '登录失败', icon: 'none', mask: true }); }); }, @@ -76,4 +92,3 @@ Page({ }); }, }); - diff --git a/yelaymaths/utils/api.js b/yelaymaths/utils/api.js index 6848b50..1577b96 100644 --- a/yelaymaths/utils/api.js +++ b/yelaymaths/utils/api.js @@ -11,8 +11,8 @@ const config = require('../config/index'); const store = require('./store'); -/** 进行中的刷新,避免并发 401 触发多次刷新 */ -let refreshing = null; +/** 进行中的会话恢复,避免并发 401 触发多次恢复流程 */ +let recovering = null; function staticUrl(path) { if (!path) return ''; @@ -52,14 +52,14 @@ function rawRequest(options) { }); } -/** 带鉴权的请求。token 失效时刷新一次并重试。 */ +/** 带鉴权的请求。token 失效时自动恢复会话并重试一次。 */ function request(options) { return rawRequest(options).then(({ statusCode, body }) => { if (body.code === 0) return body.data; const needLogin = statusCode === 401 || body.code === 2000 || body.code === 2001; if (needLogin && options.auth !== false && options.retried !== true) { - return refreshAccessToken() + return recoverSession() .then(() => request(Object.assign({}, options, { retried: true }))) .catch(() => { store.reset(); @@ -71,27 +71,41 @@ function request(options) { }); } +/** + * 恢复会话。两级兜底: + * 1. 用 refreshToken 换新 accessToken(快,不打扰用户) + * 2. refreshToken 也没有或已失效时,重新走一次微信静默登录 + * + * 第 2 级是必需的:「退出登录」会清空全部 token, + * 此后任何需要鉴权的操作(例如再次点「登录注册」改昵称)都必须先重新登录, + * 否则必然 401。 + */ +function recoverSession() { + if (recovering) return recovering; + + recovering = refreshAccessToken() + .catch(() => login()) + .finally(() => { + recovering = null; + }); + + return recovering; +} + function refreshAccessToken() { - if (refreshing) return refreshing; const refreshToken = store.getRefreshToken(); if (!refreshToken) return Promise.reject({ code: 2000, message: '未登录' }); - refreshing = rawRequest({ + return rawRequest({ url: '/v1/auth/refresh', method: 'POST', data: { refreshToken }, auth: false, - }) - .then(({ body }) => { - if (body.code !== 0) throw { code: 2000, message: '登录已过期' }; - store.setTokens(body.data.accessToken); - return body.data.accessToken; - }) - .finally(() => { - refreshing = null; - }); - - return refreshing; + }).then(({ body }) => { + if (body.code !== 0) throw { code: 2000, message: '登录已过期' }; + store.setTokens(body.data.accessToken); + return body.data.accessToken; + }); } // ── 登录 ────────────────────────────────────────────────────────────────── @@ -195,4 +209,3 @@ function requestVirtualPayment(payData) { } module.exports = Object.assign(api, { requestVirtualPayment }); - diff --git a/yelaymaths/utils/store.js b/yelaymaths/utils/store.js index 57e41c8..549daad 100644 --- a/yelaymaths/utils/store.js +++ b/yelaymaths/utils/store.js @@ -108,9 +108,9 @@ const store = { reset() { memory.user = null; - memory.siteConfig = null; memory.entitlements = []; store.clearTokens(); + // 站点配置与登录态无关,保留;否则退出登录后页面文案会退回默认值 wx.removeStorageSync(config.STORAGE.USER); wx.removeStorageSync(config.STORAGE.ENTITLEMENTS); notify(); @@ -118,4 +118,3 @@ const store = { }; module.exports = store; - diff --git a/yelaymaths_api/.env.example b/yelaymaths_api/.env.example index 4b51158..fa05b06 100644 --- a/yelaymaths_api/.env.example +++ b/yelaymaths_api/.env.example @@ -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" diff --git a/yelaymaths_api/package.json b/yelaymaths_api/package.json index a819c6b..f9cd150 100644 --- a/yelaymaths_api/package.json +++ b/yelaymaths_api/package.json @@ -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", diff --git a/yelaymaths_api/pnpm-lock.yaml b/yelaymaths_api/pnpm-lock.yaml index 6c5a8a8..9c7501d 100644 --- a/yelaymaths_api/pnpm-lock.yaml +++ b/yelaymaths_api/pnpm-lock.yaml @@ -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 diff --git a/yelaymaths_api/src/config/config.ts b/yelaymaths_api/src/config/config.ts index b57315c..e359fb1 100644 --- a/yelaymaths_api/src/config/config.ts +++ b/yelaymaths_api/src/config/config.ts @@ -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; diff --git a/yelaymaths_api/src/modules/media/media.service.ts b/yelaymaths_api/src/modules/media/media.service.ts index 3468f06..b77d0ef 100644 --- a/yelaymaths_api/src/modules/media/media.service.ts +++ b/yelaymaths_api/src/modules/media/media.service.ts @@ -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 { + 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 { - 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((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[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); } } - diff --git a/yelaymaths_api/src/modules/users/users.service.ts b/yelaymaths_api/src/modules/users/users.service.ts index abbe3a5..16e0e10 100644 --- a/yelaymaths_api/src/modules/users/users.service.ts +++ b/yelaymaths_api/src/modules/users/users.service.ts @@ -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; } } - diff --git a/yelaymaths_api/tools/legacy/migrate.ts b/yelaymaths_api/tools/legacy/migrate.ts index 0e07df2..774b9f2 100644 --- a/yelaymaths_api/tools/legacy/migrate.ts +++ b/yelaymaths_api/tools/legacy/migrate.ts @@ -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 { const dryRun = flag('dry-run'); @@ -108,14 +111,41 @@ async function main(): Promise { // ── 写库 ─────────────────────────────────────────────────────────────── console.log('\n── 写入新库 ────────────────────────────'); + + type Delegates = Record Promise }>; + 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 Promise }>; - 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 });