小程序修正
This commit is contained in:
1 parent
5a92bab835
commit
6ae4c109ce
12 files changed
+232
-62
No files matched your search
+20
-2
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+21
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+31
-18
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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