大修改
This commit is contained in:
1 parent
a1cbc9404a
commit
e81481623c
47 files changed
+1453
-285
No files matched your search
@@ -33,6 +33,9 @@ pnpm-debug.log*
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# 部署脚本的本地配置(含服务器地址,禁止入库)
|
||||
deploy/deploy.config.ps1
|
||||
|
||||
# 小程序构建产物
|
||||
yelaymaths/miniprogram_npm/
|
||||
yelaymaths/project.private.config.json
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# 一键部署脚本
|
||||
|
||||
在本机运行一条命令,自动完成「打包源码 → 上传服务器 → 构建 → 重启进程」。
|
||||
|
||||
服务器上**不需要安装任何东西**,它复用的就是你已经能用的 SSH。
|
||||
|
||||
---
|
||||
|
||||
## 一次性配置
|
||||
|
||||
### 1. 填配置文件
|
||||
|
||||
编辑 `deploy/deploy.config.ps1`,需要改这几项:
|
||||
|
||||
| 配置项 | 填什么 | 怎么查 |
|
||||
|---|---|---|
|
||||
| `Host` | 服务器 IP 或域名 | — |
|
||||
| `Port` | SSH 端口 | 宝塔 → 安全 → SSH 端口 |
|
||||
| `User` | SSH 登录用户 | 一般是 `root` |
|
||||
| `NodeBin` | Node 的 bin 目录 | 宝塔 → 软件商店 → Node 版本管理器 |
|
||||
| `Api.RemotePath` | 服务端在服务器上的绝对路径 | 你部署时放的位置 |
|
||||
| `Admin.RemotePath` | 管理端在服务器上的绝对路径 | 同上 |
|
||||
| `Api.RestartCmd` | 重启服务端的命令 | 服务器上执行 `pm2 list` 看进程名 |
|
||||
| `Admin.RestartCmd` | 重启管理端的命令 | 同上 |
|
||||
| `Api.StartCmd` / `Admin.StartCmd` | 杀掉旧进程后把服务拉起来的命令 | 照抄模板;项目路径或端口变了才需要改 |
|
||||
| `Api.HealthUrl` / `Admin.HealthUrl` | 启动后的探活地址 | 照抄模板;端口变了才需要改 |
|
||||
| `NginxCacheDir` | Nginx 反向代理缓存目录 | 宝塔 → 软件商店 → Nginx → 配置修改,搜 `proxy_cache_path` |
|
||||
| `NginxBin` | Nginx 可执行文件路径 | 一般是 `/www/server/nginx/sbin/nginx` |
|
||||
|
||||
### 2. 配置免密登录(强烈建议)
|
||||
|
||||
不配的话,每次部署要输两次密码。配好之后全程无提示。
|
||||
|
||||
```powershell
|
||||
# 本机生成密钥(已生成过可跳过,直接第二次命令)
|
||||
ssh-keygen -t ed25519
|
||||
|
||||
# 把公钥传到服务器
|
||||
type $env:USERPROFILE\.ssh\id_ed25519.pub | ssh root@你的服务器 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
|
||||
```
|
||||
|
||||
### 3. 确认服务器上的 .env 已就位
|
||||
|
||||
脚本**不会上传** `.env`(这是故意的,避免覆盖服务器上的密钥配置)。
|
||||
|
||||
- 服务端:`yelaymaths_api/.env` 必须已在服务器上配好
|
||||
- 管理端:`yelaymaths_admin/.env.local` 必须已在服务器上配好
|
||||
- 注意 `NEXT_PUBLIC_*` 是**构建时**写死的,所以第一次构建前它就得在
|
||||
|
||||
---
|
||||
|
||||
## 日常使用
|
||||
|
||||
```powershell
|
||||
.\deploy\deploy.ps1 api # 只部署服务端
|
||||
.\deploy\deploy.ps1 admin # 只部署管理端
|
||||
.\deploy\deploy.ps1 # 两个都部署
|
||||
```
|
||||
|
||||
输出长这样:
|
||||
|
||||
```
|
||||
=== yelaymaths_api (server API) ===
|
||||
[1/5] Building locally: pnpm prisma:generate && pnpm build
|
||||
[2/5] Packing yelaymaths_api
|
||||
100.8 KB (src, prisma, tools, package.json, ...)
|
||||
[3/5] Uploading to /www/wwwroot/yelaymaths/yelaymaths_api/api
|
||||
[4/5] Running on server
|
||||
__DEPLOY_DONE__
|
||||
[5/5] Restarted
|
||||
Started, health check passed: http://127.0.0.1:3002/healthz
|
||||
Nginx proxy cache cleared and reloaded
|
||||
|
||||
All done in 12.0s
|
||||
```
|
||||
|
||||
### 其它参数
|
||||
|
||||
| 参数 | 作用 |
|
||||
|---|---|
|
||||
| `-DryRun` | 只打包、只显示会上传哪些文件,**不真的传**。第一次用建议先跑这个 |
|
||||
| `-WithInstall` | 额外安装依赖。**只在改了 `package.json` 时才需要** |
|
||||
|
||||
---
|
||||
|
||||
## 为什么默认不装依赖
|
||||
|
||||
服务器上的全局 pnpm 是 **8.x**,而本机是 10.x,两代锁文件格式不兼容——服务器直接读不了我们的 `pnpm-lock.yaml`。
|
||||
|
||||
而服务器上**不能升级 pnpm**(其他服务也在用)。
|
||||
|
||||
好在只有「装依赖」这一步需要版本匹配,**「构建」不需要**——`pnpm build` 只是执行 `package.json` 里的脚本,pnpm 8 也能跑。
|
||||
|
||||
所以日常部署走「上传源码 + 构建」,用服务器上现成的 `node_modules`,既不碰全局 pnpm,也快得多。
|
||||
|
||||
只有 `package.json` 变了才需要装依赖,这时用 `-WithInstall`,它会通过 `npx` 临时拉一个 10.27.0 的 pnpm 来跑,同样不影响全局。
|
||||
|
||||
---
|
||||
|
||||
## 上传哪些文件
|
||||
|
||||
脚本用的是**白名单**,只有列在 `DeployPaths` 里的路径会被打包。所以这些东西**结构上不可能**被传上去:
|
||||
|
||||
- `.env` / `.env.local`(服务器上的配置不会被覆盖)
|
||||
- `node_modules/`(依赖在服务器上装)
|
||||
- `dist/`、`.next/`(构建产物在服务器上生成)
|
||||
|
||||
服务端实际约 100 KB,管理端约 25 KB。
|
||||
|
||||
---
|
||||
|
||||
## 已知限制
|
||||
|
||||
**删除的文件不会同步删除。** 脚本是「覆盖式上传」,如果你在本地删掉某个源码文件,服务器上那份还在。
|
||||
|
||||
一般不影响运行(多余的文件不会被执行),但如果你发现改了名字的旧文件仍在作怪,手动上服务器删掉即可。
|
||||
|
||||
需要「完全同步」的场景(比如删文件、想要版本回滚),改用 git 推送部署更合适——那是另一个方案,需要时再说。
|
||||
|
||||
---
|
||||
|
||||
## 部署结束后会清一次 Nginx 缓存
|
||||
|
||||
宝塔在 `/www/server/nginx/conf/proxy.conf` 里对**所有**反向代理站点开了 `proxy_cache cache_one`,而 Next.js 给页面发的响应头是 `Cache-Control: s-maxage=31536000`——翻译过来就是「这一页允许缓存一年」。
|
||||
|
||||
两边一叠加,后果很隐蔽:新构建传上去、进程也重启了,用户访问到的还是缓存里的旧 HTML;而旧 HTML 引用的分包早被新构建删掉,请求直接 404,页面渲染到一半就断,表现成「某个区块整块消失」,看起来像代码写错了,实际跟代码毫无关系。
|
||||
|
||||
所以脚本在每次部署的最后会多做一步:
|
||||
|
||||
```bash
|
||||
find /www/server/nginx/proxy_cache_dir -mindepth 1 -delete # 清空缓存文件
|
||||
/www/server/nginx/sbin/nginx -s reload # 重载 Nginx
|
||||
```
|
||||
|
||||
服务端和管理端两条部署线都带这一步。缓存只影响响应速度,清空后第一次访问会回源到新构建,不会丢数据。
|
||||
|
||||
把 `NginxCacheDir` 留空(`''`)就会跳过这一步。
|
||||
|
||||
---
|
||||
|
||||
## 部署结束会把服务自动拉起来
|
||||
|
||||
杀掉旧进程不等于新进程会跑起来。之前这里是「只杀不拉」,所以每次部署完都要去宝塔面板点一下「启动」,忘了点线上就是 502。
|
||||
|
||||
现在每个应用多两项配置:
|
||||
|
||||
- `RestartCmd`:杀掉旧进程
|
||||
- `StartCmd`:把新进程拉起来(`setsid` + `nohup` + 重定向,SSH 会话断了也不受影响)
|
||||
- `HealthUrl`:拉起后探活,30 秒内没响应就让部署直接判失败
|
||||
|
||||
所以正常输出结尾会是:
|
||||
|
||||
```
|
||||
[5/5] Restarted
|
||||
Started, health check passed: http://127.0.0.1:3001/
|
||||
Nginx proxy cache cleared and reloaded
|
||||
```
|
||||
|
||||
进程日志写在服务器上:`/www/wwwlogs/yelaymaths-api.out.log`、`/www/wwwlogs/yelaymaths-admin.out.log`。
|
||||
|
||||
有一点要知道:进程是脚本拉起来的,不是宝塔面板拉起来的,所以**面板里那个项目的状态可能显示成「未启动」**,但服务本身是正常在跑的(脚本每次都会探活确认)。如果想让面板重新接管,在面板点一次「重启」即可,之后照常用面板管。
|
||||
|
||||
只有 `RestartCmd` 不想自己启动(比如服务由 PM2 或 systemd 托管),把 `StartCmd` 留空(`''`)就会退回「只杀不拉」的老行为。
|
||||
|
||||
---
|
||||
|
||||
## 出问题怎么排查
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|---|---|---|
|
||||
| `deploy.config.ps1 not found` | 没建配置文件 | 复制 `deploy.config.example.ps1` |
|
||||
| 一直提示输密码 | 没配免密登录 | 见上面第 2 步 |
|
||||
| `pnpm: command not found` | `NodeBin` 填错 | 在服务器上执行 `which pnpm` 看真实路径 |
|
||||
| `pm2: command not found` | PM2 不在 Node 的 bin 目录 | 服务器上执行 `which pm2`,把目录也加到 `NodeBin` |
|
||||
| 构建成功但页面没变 | 进程没重启,或 Nginx 缓存没清 | 检查 `RestartCmd`、`NginxCacheDir` 填对没有 |
|
||||
| 页面某个区块整块消失 | 缓存里的旧 HTML 引用了已 404 的旧分包 | 手动清一次 `NginxCacheDir` 并 reload |
|
||||
| 部署报 `service did not come up` | 新进程没起来 | 看 `/www/wwwlogs/yelaymaths-*.out.log` 的报错,多半是端口被占或依赖没装 |
|
||||
| `Permission denied` | 目标目录权限不对 | 服务器上 `chown -R root:root 目标目录` |
|
||||
@@ -0,0 +1,72 @@
|
||||
# Copy this file to deploy.config.ps1 and fill in your own values.
|
||||
# deploy.config.ps1 is gitignored, so your server address stays out of the repo.
|
||||
|
||||
$Config = @{
|
||||
# ---- SSH ----
|
||||
Host = '1.2.3.4' # server IP or domain
|
||||
Port = 22
|
||||
User = 'root'
|
||||
|
||||
# Node bin directory on the server (prepended to PATH during the remote build).
|
||||
# Find it in BT Panel -> Node version manager, e.g. /www/server/nodejs/v22.14.0/bin
|
||||
NodeBin = '/www/server/nodejs/v22/bin'
|
||||
|
||||
# ---- Nginx proxy cache ----
|
||||
# BT Panel enables `proxy_cache cache_one` globally in
|
||||
# /www/server/nginx/conf/proxy.conf, while Next.js answers with
|
||||
# `Cache-Control: s-maxage=31536000` — i.e. "this page may be cached for a year".
|
||||
# Nginx obeys, so a fresh build is never served; the cached HTML references chunks
|
||||
# that the new build already removed (404), and the page breaks halfway through.
|
||||
# deploy.ps1 clears this directory and reloads Nginx after every deploy.
|
||||
# Leave NginxCacheDir empty ('') to skip that step.
|
||||
NginxCacheDir = '/www/server/nginx/proxy_cache_dir'
|
||||
NginxBin = '/www/server/nginx/sbin/nginx'
|
||||
|
||||
# ---- yelaymaths_api ----
|
||||
Api = @{
|
||||
Key = 'api'
|
||||
Label = 'yelaymaths_api (server API)'
|
||||
LocalPath = 'yelaymaths_api'
|
||||
RemotePath = '/www/wwwroot/yelaymaths/yelaymaths_api'
|
||||
DeployPaths = @(
|
||||
'src', 'prisma', 'tools',
|
||||
'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
|
||||
'tsconfig.json', 'tsconfig.build.json', 'nest-cli.json',
|
||||
'.env.example'
|
||||
)
|
||||
InstallCmd = 'pnpm install --frozen-lockfile'
|
||||
BuildCmd = 'pnpm build'
|
||||
|
||||
# Restart command. Run `pm2 list` on the server to find the process name.
|
||||
# Leave empty ('') if you restart the process some other way.
|
||||
RestartCmd = 'pm2 restart yelaymaths-api'
|
||||
# Start command. Used when killing the old process is not enough
|
||||
# (BT Panel does not bring it back automatically). setsid + nohup keeps
|
||||
# the process alive after the SSH session ends. Leave empty to skip.
|
||||
# `node` resolves through the NodeBin PATH exported by the remote script.
|
||||
StartCmd = 'setsid nohup node dist/main.js >> /www/wwwlogs/yelaymaths-api.out.log 2>&1 < /dev/null &'
|
||||
# Probe after start. The deploy fails if this URL does not answer in 30s.
|
||||
HealthUrl = 'http://127.0.0.1:3002/healthz'
|
||||
}
|
||||
|
||||
# ---- yelaymaths_admin ----
|
||||
# NOTE: NEXT_PUBLIC_* values are inlined at build time, so .env.local must already
|
||||
# exist on the server before the first deploy. It is never uploaded (it is gitignored
|
||||
# and not in the whitelist).
|
||||
Admin = @{
|
||||
Key = 'admin'
|
||||
Label = 'yelaymaths_admin (admin console)'
|
||||
LocalPath = 'yelaymaths_admin'
|
||||
RemotePath = '/www/wwwroot/yelaymaths/yelaymaths_admin'
|
||||
DeployPaths = @(
|
||||
'src',
|
||||
'package.json', 'pnpm-lock.yaml',
|
||||
'tsconfig.json', 'next.config.ts', 'postcss.config.mjs'
|
||||
)
|
||||
InstallCmd = 'pnpm install --frozen-lockfile'
|
||||
BuildCmd = 'pnpm build'
|
||||
RestartCmd = 'pm2 restart yelaymaths-admin'
|
||||
StartCmd = 'setsid nohup node node_modules/next/dist/bin/next start -p 3001 >> /www/wwwlogs/yelaymaths-admin.out.log 2>&1 < /dev/null &'
|
||||
HealthUrl = 'http://127.0.0.1:3001/'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
One-command deploy for yelaymaths_api / yelaymaths_admin.
|
||||
|
||||
Packs source files locally, uploads over SSH, builds on the server and restarts the process.
|
||||
No service is installed on the server; it reuses the SSH access you already have.
|
||||
|
||||
Usage:
|
||||
.\deploy\deploy.ps1 api
|
||||
.\deploy\deploy.ps1 admin
|
||||
.\deploy\deploy.ps1 all
|
||||
.\deploy\deploy.ps1 api -DryRun # pack only, show what would be uploaded
|
||||
.\deploy\deploy.ps1 api -WithInstall # also install dependencies (only after package.json changes)
|
||||
|
||||
Files are packed from an explicit whitelist, so .env / node_modules / dist / .next
|
||||
are never uploaded and your server-side .env stays untouched.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('api', 'admin', 'all')]
|
||||
[string]$Target = 'all',
|
||||
[switch]$DryRun,
|
||||
[switch]$WithInstall
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Split-Path -Parent $scriptDir
|
||||
$configPath = Join-Path $scriptDir 'deploy.config.ps1'
|
||||
|
||||
if (-not (Test-Path $configPath)) {
|
||||
Write-Host 'ERROR: deploy.config.ps1 not found.' -ForegroundColor Red
|
||||
Write-Host 'Copy deploy.config.example.ps1 to deploy.config.ps1 and fill in your server details.'
|
||||
exit 1
|
||||
}
|
||||
. $configPath
|
||||
|
||||
foreach ($exe in @('ssh', 'scp', 'tar')) {
|
||||
if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) {
|
||||
throw "Missing required command: $exe (Windows 10+ ships OpenSSH and tar)"
|
||||
}
|
||||
}
|
||||
|
||||
$sshTarget = '{0}@{1}' -f $Config.User, $Config.Host
|
||||
|
||||
function Invoke-AppDeploy {
|
||||
param([hashtable]$App)
|
||||
|
||||
$local = Join-Path $repoRoot $App.LocalPath
|
||||
if (-not (Test-Path $local)) { throw "Local path not found: $local" }
|
||||
|
||||
# Whitelist: only include paths that exist. Everything else (secrets, build output)
|
||||
# is structurally impossible to upload.
|
||||
$items = @()
|
||||
foreach ($p in $App.DeployPaths) {
|
||||
if (Test-Path (Join-Path $local $p)) { $items += $p }
|
||||
}
|
||||
if ($items.Count -eq 0) { throw "Nothing to deploy for $($App.Key)" }
|
||||
|
||||
$excludes = @()
|
||||
foreach ($p in ($App.ExcludePaths ?? @())) { $excludes += "--exclude=$p" }
|
||||
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$tarName = 'yelay-{0}-{1}.tar.gz' -f $App.Key, $stamp
|
||||
$tarFile = Join-Path $env:TEMP $tarName
|
||||
$remoteTar = '/tmp/' + $tarName
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ('=== {0} ===' -f $App.Label) -ForegroundColor Cyan
|
||||
|
||||
# ---- 本地构建:把耗时的编译放在本机,服务器只接收产物 ----
|
||||
if ($App.LocalBuildCmd) {
|
||||
Write-Host ('[1/5] Building locally: {0}' -f $App.LocalBuildCmd)
|
||||
# 国内拉取 Prisma 引擎很慢,指向镜像
|
||||
$env:PRISMA_ENGINES_MIRROR = 'https://registry.npmmirror.com/-/binary/prisma'
|
||||
Push-Location $local
|
||||
try {
|
||||
Invoke-Expression $App.LocalBuildCmd
|
||||
if ($LASTEXITCODE -ne 0) { throw "Local build failed: $($App.LocalBuildCmd)" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Host '[1/5] Skipping local build'
|
||||
}
|
||||
|
||||
Write-Host ('[2/5] Packing {0}' -f $App.LocalPath)
|
||||
Push-Location $local
|
||||
try {
|
||||
& tar -czf $tarFile @excludes @items
|
||||
if ($LASTEXITCODE -ne 0) { throw 'tar failed' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
$sizeKb = [math]::Round((Get-Item $tarFile).Length / 1KB, 1)
|
||||
Write-Host (' {0} KB ({1})' -f $sizeKb, ($items -join ', '))
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host ' DryRun: nothing uploaded.'
|
||||
Remove-Item $tarFile -Force
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host ('[3/5] Uploading to {0}' -f $App.RemotePath)
|
||||
& scp -P $Config.Port -q $tarFile ('{0}:{1}' -f $sshTarget, $remoteTar)
|
||||
if ($LASTEXITCODE -ne 0) { throw 'scp failed' }
|
||||
|
||||
$install = ''
|
||||
if ($WithInstall) { $install = $App.InstallCmd }
|
||||
|
||||
# 部署前先在服务器上删掉指定的旧目录。
|
||||
#
|
||||
# 为什么必须要这一步:Next.js 会把预渲染好的页面缓存在 .next/cache 里,
|
||||
# 命中时直接返回缓存(响应头 x-nextjs-cache: HIT),不再读磁盘上的新文件。
|
||||
# 而 .next/cache 体积很大(约 73MB),上传时会排除掉,于是它永远是旧的 ——
|
||||
# 表现就是「代码传上去了、也重启了,页面却一直没变」。这里先删掉,让它重建。
|
||||
$cleanCmd = ''
|
||||
foreach ($p in ($App.CleanPaths ?? @())) {
|
||||
$cleanCmd += "rm -rf '$($App.RemotePath)/$p'`n"
|
||||
}
|
||||
|
||||
# 部署后清空 Nginx 反代缓存。
|
||||
#
|
||||
# 为什么必须清:宝塔在 /www/server/nginx/conf/proxy.conf 里全局开了
|
||||
# proxy_cache cache_one,而 Next.js 给静态页面发的响应头是
|
||||
# Cache-Control: s-maxage=31536000,等于告诉 Nginx「这一页可以存一年」。
|
||||
# 于是新构建传上去、进程也重启了,访问到的还是缓存的旧 HTML;旧 HTML
|
||||
# 引用的分包早被新构建删掉,一请求就是 404,页面渲染到一半就断,
|
||||
# 表现就是「怎么改都不生效」,甚至某个区块整块消失。
|
||||
# 清掉缓存目录再 reload,让它回源读新构建。
|
||||
$nginxCacheCmd = ''
|
||||
if ($Config.NginxCacheDir) {
|
||||
$nginxCacheCmd += "find '$($Config.NginxCacheDir)' -mindepth 1 -delete 2>/dev/null || true`n"
|
||||
if ($Config.NginxBin) {
|
||||
$nginxCacheCmd += "'$($Config.NginxBin)' -s reload 2>/dev/null || true`n"
|
||||
}
|
||||
}
|
||||
|
||||
# 启动命令。
|
||||
#
|
||||
# RestartCmd 只负责杀掉旧进程,宝塔不会自动把新进程拉起来,
|
||||
# 所以这里自己拉。先 sleep 2 是为了让刚被杀掉的进程彻底放开端口,
|
||||
# 否则新进程会 EADDRINUSE 起不来。
|
||||
$startCmd = ''
|
||||
if ($App.StartCmd) {
|
||||
$startCmd = "sleep 2`n$($App.StartCmd)`n"
|
||||
}
|
||||
|
||||
# 启动后探活:确认端口真的在响应,而不是「命令敲下去就当成功」。
|
||||
# 30 秒还没起来就让部署直接失败,免得又出现「显示部署成功、线上却是 502」。
|
||||
$healthCmd = ''
|
||||
if ($App.HealthUrl) {
|
||||
$healthCmd = "for i in {1..30}; do curl -sf -o /dev/null '$($App.HealthUrl)' && break; sleep 1; done`n"
|
||||
$healthCmd += "curl -sf -o /dev/null '$($App.HealthUrl)' || { echo 'ERROR: service did not come up: $($App.HealthUrl)'; exit 1; }`n"
|
||||
}
|
||||
|
||||
# Remote script is intentionally ASCII-only to avoid encoding issues over SSH.
|
||||
$remoteScript = @"
|
||||
set -e
|
||||
export PATH=$($Config.NodeBin):`$PATH
|
||||
mkdir -p '$($App.RemotePath)'
|
||||
cd '$($App.RemotePath)'
|
||||
$cleanCmd
|
||||
tar -xzf '$remoteTar'
|
||||
rm -f '$remoteTar'
|
||||
$install
|
||||
$($App.BuildCmd)
|
||||
$($App.RestartCmd)
|
||||
$startCmd
|
||||
$healthCmd
|
||||
$nginxCacheCmd
|
||||
echo '__DEPLOY_DONE__'
|
||||
"@
|
||||
|
||||
Write-Host '[4/5] Running on server'
|
||||
$remoteScript | & ssh -p $Config.Port $sshTarget 'bash -s'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'remote build or restart failed' }
|
||||
|
||||
Remove-Item $tarFile -Force -ErrorAction SilentlyContinue
|
||||
|
||||
if ($App.RestartCmd) {
|
||||
Write-Host '[5/5] Restarted' -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host '[5/5] Done' -ForegroundColor Green
|
||||
Write-Host (' NOTE: restart "{0}" from the BT panel now.' -f $App.Label) -ForegroundColor Yellow
|
||||
}
|
||||
if ($App.StartCmd) {
|
||||
Write-Host (' Started, health check passed: {0}' -f $App.HealthUrl) -ForegroundColor Green
|
||||
}
|
||||
if ($nginxCacheCmd) {
|
||||
Write-Host ' Nginx proxy cache cleared and reloaded' -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
$apps = switch ($Target) {
|
||||
'api' { @($Config.Api) }
|
||||
'admin' { @($Config.Admin) }
|
||||
'all' { @($Config.Api, $Config.Admin) }
|
||||
}
|
||||
|
||||
$started = Get-Date
|
||||
foreach ($app in $apps) { Invoke-AppDeploy -App $app }
|
||||
|
||||
if (-not $DryRun) {
|
||||
Write-Host ''
|
||||
Write-Host ('All done in {0:N1}s' -f ((Get-Date) - $started).TotalSeconds) -ForegroundColor Green
|
||||
}
|
||||
+10
-3
@@ -15,7 +15,7 @@ App({
|
||||
/**
|
||||
* 启动流程:配置再静默登录,最后拉已购课程。
|
||||
*
|
||||
* 每一步失败都不阻断启动:配置缺失时用兜底文案,登录失败时按未登录渲染。
|
||||
* 每一步失败都不阻断启动:配置缺失时页面按空值渲染,登录失败时按未登录渲染。
|
||||
* 旧版本在页面 data 里同步读缓存、而缓存由异步写入,首次安装必然报错,这里不再重蹈。
|
||||
*/
|
||||
bootstrap() {
|
||||
@@ -27,8 +27,15 @@ App({
|
||||
|
||||
api
|
||||
.login()
|
||||
.then(() => api.getEntitlements())
|
||||
.then((list) => store.setEntitlements(list))
|
||||
.then((res) => {
|
||||
// 静默登录只建立身份。用户从没主动登录过、或者已经退出,就不去拉已购课程,
|
||||
// 否则知识页会在「未登录」状态下又冒出课程列表。
|
||||
if (!res || !res.user || res.user.isLogin !== 1) {
|
||||
store.setEntitlements([]);
|
||||
return null;
|
||||
}
|
||||
return api.getEntitlements().then((list) => store.setEntitlements(list));
|
||||
})
|
||||
.catch((err) => console.warn('[app] 登录或权益加载失败', err)),
|
||||
];
|
||||
return Promise.all(tasks);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"pages/LoginOrOutDetails/LoginOrOutDetails",
|
||||
"pages/WebPageDetails/WebPageDetails",
|
||||
"pages/VideoDetails/VideoDetails",
|
||||
"pages/buy/buy",
|
||||
"pages/VideoDetailsPage/VideoDetailsPage",
|
||||
"pages/IndexNewCourseDetails/IndexNewCourseDetails",
|
||||
"pages/IndexFreeCourseDetails/IndexFreeCourseDetails",
|
||||
|
||||
@@ -28,7 +28,7 @@ Page({
|
||||
this.setData({
|
||||
username: user.nickname || '',
|
||||
HeadiconID: user.headiconId || 0,
|
||||
list: parseInt(store.cfg('profile.headicon_count', '50'), 10) || 50,
|
||||
list: parseInt(store.cfg('profile.headicon_count'), 10) || 0,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// pages/IndexNewCourseDetails/IndexNewCourseDetails.js
|
||||
const api = require('../../utils/api');
|
||||
const store = require('../../utils/store');
|
||||
|
||||
const app = getApp();
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -7,6 +10,8 @@ Page({
|
||||
courseCode: '',
|
||||
courseTitle: '',
|
||||
priceYuan: '',
|
||||
buyText: '',
|
||||
contactText: '',
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -27,9 +32,21 @@ Page({
|
||||
.catch((err) => console.error('[IndexNewCourseDetails] 加载失败', err));
|
||||
},
|
||||
|
||||
/** 复用付费课页面完成购买:未拥有时那里会展示价格与购买按钮。 */
|
||||
onShow() {
|
||||
app.ready().then(() => this.applyConfig());
|
||||
},
|
||||
|
||||
/** 按钮文案取自后台配置,后台改完小程序下拉刷新即可生效。 */
|
||||
applyConfig() {
|
||||
this.setData({
|
||||
buyText: store.cfg('course.button.buy'),
|
||||
contactText: store.cfg('course.button.contact'),
|
||||
});
|
||||
},
|
||||
|
||||
/** 购买统一走商业化页面。 */
|
||||
toBuy() {
|
||||
if (!this.data.courseCode) return;
|
||||
wx.navigateTo({ url: '/pages/VideoDetails/VideoDetails?code=' + this.data.courseCode });
|
||||
wx.navigateTo({ url: '/pages/buy/buy?code=' + this.data.courseCode });
|
||||
},
|
||||
});
|
||||
@@ -4,10 +4,10 @@
|
||||
<view class="fixed-btn-wrapper">
|
||||
<view class="btn-group">
|
||||
<button wx:if="{{courseCode}}" class="DianJiXiangQing1" bind:tap="toBuy">
|
||||
<text class="DianJiXiangQing2">立即购买</text>
|
||||
<text class="DianJiXiangQing2">{{buyText}}</text>
|
||||
</button>
|
||||
<button class="DianJiXiangQing1" open-type="contact" bindcontact="handleContact">
|
||||
<text class="DianJiXiangQing2">咨询客服</text>
|
||||
<text class="DianJiXiangQing2">{{contactText}}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -30,7 +30,8 @@ Page({
|
||||
|
||||
refresh() {
|
||||
const user = store.getUser();
|
||||
const registered = !!(user && user.nickname);
|
||||
// 「已登录」看的是用户主动登录过没有(服务端记着这一位),不是有没有 token
|
||||
const registered = !!(user && user.isLogin === 1);
|
||||
this.setData({
|
||||
loginStatue: registered,
|
||||
LoginTag: registered ? '退出登录' : '一键登录',
|
||||
@@ -49,13 +50,16 @@ Page({
|
||||
* 会把老用户在服务端的真实昵称覆盖掉。正确做法是先取服务端资料:
|
||||
* - 已有昵称 → 老用户,原样保留,不提交任何修改
|
||||
* - 没有昵称 → 首次注册,生成一个随机昵称
|
||||
*
|
||||
* activeLogin 负责把数据库里的「已登录」标记置 1——只有用户亲自点这里才会置 1,
|
||||
* 静默登录不碰它。它带鉴权,未登录时会自动完成静默登录,无需手动判断 token。
|
||||
*/
|
||||
onLoginBtnClick() {
|
||||
wx.showLoading({ title: '请稍候', mask: true });
|
||||
|
||||
// getMe 在未登录时会自动完成静默登录,无需手动判断 token
|
||||
api
|
||||
.getMe()
|
||||
.activeLogin()
|
||||
.then(() => api.getMe())
|
||||
.then((user) => {
|
||||
store.setUser(user);
|
||||
if (user.nickname) return user;
|
||||
|
||||
@@ -3,19 +3,18 @@ const api = require('../../utils/api');
|
||||
const store = require('../../utils/store');
|
||||
const format = require('../../utils/format');
|
||||
|
||||
const POLL_INTERVAL = 1500;
|
||||
const POLL_MAX = 8;
|
||||
const app = getApp();
|
||||
|
||||
Page({
|
||||
data: {
|
||||
code: '',
|
||||
owned: false,
|
||||
loading: true,
|
||||
buying: false,
|
||||
buyText: '',
|
||||
contactText: '',
|
||||
VideoTitle: '',
|
||||
VideoDetails: '',
|
||||
coverUrl: '',
|
||||
priceYuan: '',
|
||||
playdata: [],
|
||||
VideoPlayerSrc: '',
|
||||
HiddenVideoControl: true,
|
||||
@@ -29,11 +28,20 @@ Page({
|
||||
return;
|
||||
}
|
||||
this.setData({ code: code });
|
||||
this.load(code);
|
||||
},
|
||||
|
||||
/** 首次进入和从购买页返回都会走这里:买完回来要立刻变成已拥有。 */
|
||||
onShow() {
|
||||
if (this.data.code && this.data.owned) this.load(this.data.code);
|
||||
app.ready().then(() => this.applyConfig());
|
||||
if (this.data.code) this.load(this.data.code);
|
||||
},
|
||||
|
||||
/** 按钮文案取自后台配置,后台改完小程序下拉刷新即可生效。 */
|
||||
applyConfig() {
|
||||
this.setData({
|
||||
buyText: store.cfg('course.button.buy'),
|
||||
contactText: store.cfg('course.button.contact'),
|
||||
});
|
||||
},
|
||||
|
||||
load(code) {
|
||||
@@ -46,7 +54,6 @@ Page({
|
||||
VideoTitle: course.title || '',
|
||||
VideoDetails: course.intro || '',
|
||||
coverUrl: course.coverUrl || '',
|
||||
priceYuan: format.fenToYuan(course.priceFen),
|
||||
});
|
||||
if (course.owned) this.loadLessons(code);
|
||||
})
|
||||
@@ -96,57 +103,10 @@ Page({
|
||||
});
|
||||
},
|
||||
|
||||
/** 购买:下单 → 拉起支付 → 轮询订单状态确认发货。 */
|
||||
buyNow() {
|
||||
if (this.data.buying) return;
|
||||
if (typeof wx.requestVirtualPayment !== 'function') {
|
||||
wx.showToast({ title: '当前微信版本不支持支付,请升级微信', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setData({ buying: true });
|
||||
wx.showLoading({ title: '发起支付', mask: true });
|
||||
|
||||
api
|
||||
.createOrder(this.data.code)
|
||||
.then((order) => {
|
||||
wx.hideLoading();
|
||||
return api.requestVirtualPayment(order.payData).then(() => order.outTradeNo);
|
||||
})
|
||||
.then((outTradeNo) => this.waitForDelivery(outTradeNo))
|
||||
.catch((err) => {
|
||||
wx.hideLoading();
|
||||
this.setData({ buying: false });
|
||||
// 用户主动取消不算错误
|
||||
if (err && err.errMsg && err.errMsg.indexOf('cancel') >= 0) return;
|
||||
wx.showToast({ title: (err && err.message) || '支付失败', icon: 'none' });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 支付成功后权益由服务端发货推送发放,存在秒级延迟,这里轮询确认。
|
||||
* 即便轮询全部超时也不会丢单:服务端有定时查单兜底。
|
||||
*/
|
||||
waitForDelivery(outTradeNo) {
|
||||
let attempts = 0;
|
||||
const poll = () => {
|
||||
attempts += 1;
|
||||
return api.getOrder(outTradeNo).then((order) => {
|
||||
if (order.status === 2) {
|
||||
this.setData({ buying: false, owned: true });
|
||||
wx.showToast({ title: '购买成功', icon: 'success', mask: true });
|
||||
this.load(this.data.code);
|
||||
return;
|
||||
}
|
||||
if (attempts >= POLL_MAX || order.status === 3 || order.status === 4) {
|
||||
this.setData({ buying: false });
|
||||
wx.showToast({ title: '支付已提交,开通可能稍有延迟', icon: 'none', duration: 3000 });
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)).then(poll);
|
||||
});
|
||||
};
|
||||
return poll();
|
||||
/** 购买入口:详情页不做交易,统一跳商业化页面。 */
|
||||
goBuy() {
|
||||
if (!this.data.code) return;
|
||||
wx.navigateTo({ url: '/pages/buy/buy?code=' + this.data.code });
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</scroll-view>
|
||||
</block>
|
||||
|
||||
<!-- 未拥有:展示介绍与购买入口 -->
|
||||
<!-- 购买入口 -->
|
||||
<block wx:else>
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="jieshao">
|
||||
@@ -41,11 +41,11 @@
|
||||
|
||||
<view class="fixed-btn-wrapper">
|
||||
<view class="btn-group">
|
||||
<button class="DianJiXiangQing1" bind:tap="buyNow" disabled="{{buying}}">
|
||||
<text class="DianJiXiangQing2">{{buying ? '处理中…' : '立即购买 ¥' + priceYuan}}</text>
|
||||
<button class="DianJiXiangQing1" bind:tap="goBuy">
|
||||
<text class="DianJiXiangQing2">{{buyText}}</text>
|
||||
</button>
|
||||
<button class="DianJiXiangQing1" open-type="contact">
|
||||
<text class="DianJiXiangQing2">咨询客服</text>
|
||||
<text class="DianJiXiangQing2">{{contactText}}</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// pages/buy/buy.js
|
||||
//
|
||||
// 商业化页面:所有购买入口都落到这里。
|
||||
// 职责是「确认订单 → 拉起微信支付 → 确认发货」,课程详情不在这里展示。
|
||||
// UI 待设计,wxml 现在只有一个占位块,支付链路已经就位。
|
||||
const api = require('../../utils/api');
|
||||
const format = require('../../utils/format');
|
||||
const store = require('../../utils/store');
|
||||
|
||||
const POLL_INTERVAL = 1500;
|
||||
const POLL_MAX = 8;
|
||||
|
||||
Page({
|
||||
data: {
|
||||
code: '',
|
||||
loading: true,
|
||||
buying: false,
|
||||
owned: false,
|
||||
title: '',
|
||||
coverUrl: '',
|
||||
priceYuan: '',
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const code = options && options.code;
|
||||
// 没带课程号说明是误入(比如直接输路径打开),不该停在这个页面
|
||||
if (!code) {
|
||||
this.rejectOut('缺少课程参数');
|
||||
return;
|
||||
}
|
||||
this.setData({ code: code });
|
||||
this.load(code);
|
||||
},
|
||||
|
||||
load(code) {
|
||||
api
|
||||
.getCourse(code)
|
||||
.then((course) => {
|
||||
this.setData({
|
||||
loading: false,
|
||||
owned: course.owned,
|
||||
title: course.title || '',
|
||||
coverUrl: course.coverUrl || '',
|
||||
priceYuan: format.fenToYuan(course.priceFen),
|
||||
});
|
||||
// 已经拥有就不用再买了,避免重复下单
|
||||
if (course.owned) this.rejectOut('你已经拥有这门课程');
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setData({ loading: false });
|
||||
// 课程被下架或课程号拼错,后端会返回错误,这里按同样的方式退出
|
||||
this.rejectOut((err && err.message) || '课程不存在');
|
||||
});
|
||||
},
|
||||
|
||||
/** 进错页面(没带课程、课程不存在、已经买过):提示一句然后退出去。 */
|
||||
rejectOut(message) {
|
||||
wx.showToast({ title: message, icon: 'none' });
|
||||
setTimeout(() => this.back(), 1500);
|
||||
},
|
||||
|
||||
/** 退回上一页;没有上一页(直接打开)就回知识页,避免停在死页面。 */
|
||||
back() {
|
||||
if (getCurrentPages().length > 1) {
|
||||
wx.navigateBack();
|
||||
return;
|
||||
}
|
||||
wx.switchTab({ url: '/pages/kecheng/kecheng' });
|
||||
},
|
||||
|
||||
/** 购买:下单 → 拉起支付 → 轮询订单状态确认发货。 */
|
||||
buyNow() {
|
||||
if (this.data.buying || !this.data.code) return;
|
||||
|
||||
// 购买的前置条件:用户主动使用过登录功能。静默登录不算。
|
||||
if (!store.isLoggedIn()) {
|
||||
wx.showModal({
|
||||
title: '请先登录',
|
||||
content: '使用微信登录后才能购买课程',
|
||||
confirmText: '去登录',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
wx.navigateTo({ url: '/pages/LoginOrOutDetails/LoginOrOutDetails' });
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof wx.requestVirtualPayment !== 'function') {
|
||||
wx.showToast({ title: '当前微信版本不支持支付,请升级微信', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setData({ buying: true });
|
||||
wx.showLoading({ title: '发起支付', mask: true });
|
||||
|
||||
api
|
||||
.createOrder(this.data.code)
|
||||
.then((order) => {
|
||||
wx.hideLoading();
|
||||
return api.requestVirtualPayment(order.payData).then(() => order.outTradeNo);
|
||||
})
|
||||
.then((outTradeNo) => this.waitForDelivery(outTradeNo))
|
||||
.catch((err) => {
|
||||
wx.hideLoading();
|
||||
this.setData({ buying: false });
|
||||
// 用户主动取消不算错误
|
||||
if (err && err.errMsg && err.errMsg.indexOf('cancel') >= 0) return;
|
||||
wx.showToast({ title: (err && err.message) || '支付失败', icon: 'none' });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 支付成功后权益由服务端发货推送发放,存在秒级延迟,这里轮询确认。
|
||||
* 即便轮询全部超时也不会丢单:服务端有定时查单兜底。
|
||||
*/
|
||||
waitForDelivery(outTradeNo) {
|
||||
let attempts = 0;
|
||||
const poll = () => {
|
||||
attempts += 1;
|
||||
return api.getOrder(outTradeNo).then((order) => {
|
||||
if (order.status === 2) {
|
||||
this.setData({ buying: false, owned: true });
|
||||
wx.showToast({ title: '购买成功', icon: 'success', mask: true });
|
||||
setTimeout(() => this.back(), 1500);
|
||||
return;
|
||||
}
|
||||
if (attempts >= POLL_MAX || order.status === 3 || order.status === 4) {
|
||||
this.setData({ buying: false });
|
||||
wx.showToast({ title: '支付已提交,开通可能稍有延迟', icon: 'none', duration: 3000 });
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)).then(poll);
|
||||
});
|
||||
};
|
||||
return poll();
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return { title: '叶雷讲数学', path: '/pages/index/index' };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<!--pages/buy/buy.wxml-->
|
||||
|
||||
<!-- UI 待设计。支付链路(buyNow / waitForDelivery)已经在 buy.js 里就位,
|
||||
设计稿定了直接替换下面这个占位块即可。 -->
|
||||
<view class="buy-page">
|
||||
<text class="buy-placeholder">购买页(待设计)</text>
|
||||
</view>
|
||||
@@ -0,0 +1,14 @@
|
||||
/* pages/buy/buy.wxss */
|
||||
|
||||
/* 占位样式,UI 定稿后连同 wxml 一起替换 */
|
||||
.buy-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.buy-placeholder {
|
||||
color: #999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
@@ -33,10 +33,10 @@ Page({
|
||||
|
||||
applyConfig() {
|
||||
this.setData({
|
||||
title1: store.cfg('home.title.paid', '付费课程'),
|
||||
title2: store.cfg('home.title.trial', '试听课程'),
|
||||
title3: store.cfg('home.title.public', '公开课程'),
|
||||
footerText: store.cfg('home.footer_text', '我是有底线的!'),
|
||||
title1: store.cfg('home.title.paid'),
|
||||
title2: store.cfg('home.title.trial'),
|
||||
title3: store.cfg('home.title.public'),
|
||||
footerText: store.cfg('home.footer_text'),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -46,13 +46,19 @@ page {
|
||||
display: flex;
|
||||
flex-wrap: wrap;/* 允许内容换行 */
|
||||
justify-content:space-between;/* 水平方向上两端对齐 */
|
||||
/* 左右各留 20rpx,和卡片之间的 20rpx 间距相同,视觉上三边等宽 */
|
||||
width: 750rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
.shouyetupianlistcontent {
|
||||
/* display: inline; */
|
||||
padding: 0;
|
||||
margin: 20rpx;
|
||||
/* 水平间距交给容器 padding + space-between,这里只留出上下行距 */
|
||||
margin: 0 0 40rpx 0;
|
||||
height: 204rpx;
|
||||
width: 334rpx; /* 每行两个图片,设置宽度为 48% 可以有一定的间距 */
|
||||
/* (750 - 左右各 20 - 中间 20) / 2 = 345 */
|
||||
width: 345rpx;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
|
||||
@@ -60,9 +66,10 @@ page {
|
||||
.shouyetupianlistcontent01 {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 20rpx;
|
||||
margin: 0 0 40rpx 0;
|
||||
height: 260rpx;
|
||||
width: 730rpx;
|
||||
/* 容器内容区宽度,正好铺满 */
|
||||
width: 710rpx;
|
||||
overflow: hidden;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
|
||||
@@ -82,10 +89,11 @@ page {
|
||||
|
||||
.biaoti01 {
|
||||
display: flex;
|
||||
width: 750rpx;
|
||||
width: 100%;
|
||||
height: 60rpx;
|
||||
align-items: center;
|
||||
padding-left: 20rpx;
|
||||
/* 左侧对齐交给容器 padding,去掉后标题和小图/卡片左边缘对齐 */
|
||||
padding-left: 0;
|
||||
}
|
||||
.biaoti01img {
|
||||
width: 40rpx;
|
||||
@@ -125,13 +133,17 @@ page {
|
||||
display: inline;
|
||||
position: absolute;
|
||||
left: 300rpx;
|
||||
top: 35px;
|
||||
/* 原为 35px:再看它与标题的关系(下面那行 top:-15px),两者会重叠。
|
||||
标题框实际占到 40px,这里退到 90rpx(45px)之后才真正错开。 */
|
||||
top: 90rpx;
|
||||
align-items: center;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.indeximagelistmiaosu0102 {
|
||||
font-size: 20rpx;
|
||||
position: relative;
|
||||
top: -15px;
|
||||
/* 原为 -15px:这一步把简介往上提了 15px,正好压到标题上。
|
||||
容器已经让开了位置,这里不再需要负偏移。 */
|
||||
top: 0;
|
||||
width: 370rpx;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ Page({
|
||||
staticBase: config.STATIC_BASE_URL,
|
||||
hasCourse: false,
|
||||
ownedCourses: [],
|
||||
otherCourses: [],
|
||||
tipsHtml: '',
|
||||
},
|
||||
|
||||
@@ -21,6 +20,8 @@ Page({
|
||||
|
||||
onShow() {
|
||||
if (store.isLoggedIn()) this.refresh();
|
||||
// 未登录(含刚退出登录)不能留着一份别人的课程列表
|
||||
else this.setData({ ownedCourses: [], hasCourse: false });
|
||||
},
|
||||
|
||||
refresh() {
|
||||
@@ -33,13 +34,6 @@ Page({
|
||||
this.setData({ ownedCourses: list || [], hasCourse: (list || []).length > 0 });
|
||||
})
|
||||
.catch((err) => console.warn('[kecheng] 已购课程加载失败', err));
|
||||
|
||||
api
|
||||
.getCourses()
|
||||
.then((list) => {
|
||||
this.setData({ otherCourses: (list || []).filter((c) => !c.owned) });
|
||||
})
|
||||
.catch((err) => console.warn('[kecheng] 课程列表加载失败', err));
|
||||
},
|
||||
|
||||
loadTips() {
|
||||
@@ -56,12 +50,6 @@ Page({
|
||||
});
|
||||
},
|
||||
|
||||
toStoreCourse(e) {
|
||||
wx.navigateTo({
|
||||
url: '/pages/VideoDetails/VideoDetails?code=' + e.currentTarget.dataset.code,
|
||||
});
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return { title: '叶雷讲数学', path: '/pages/index/index' };
|
||||
},
|
||||
|
||||
@@ -20,18 +20,5 @@
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:if="{{otherCourses.length > 0}}">
|
||||
<view wx:for="{{otherCourses}}" wx:for-item="item" wx:for-index="index" wx:key="code"
|
||||
class="kecheng" bind:tap="toStoreCourse" data-code="{{item.code}}">
|
||||
<view class="kecheng_video_up">
|
||||
<image class="kecheng_img" src="{{item.coverUrl}}" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="kecheng_video_right">
|
||||
<text class="kecheng_title">{{item.title}}</text>
|
||||
<text class="kecheng_details">{{item.intro}}</text>
|
||||
</view>
|
||||
<text style="position: absolute; right: 24rpx; bottom: 24rpx; font-size: 24rpx; color: #ff6b00;">未开通</text>
|
||||
</view>
|
||||
</block>
|
||||
</scroll-view>
|
||||
|
||||
@@ -34,27 +34,32 @@ Page({
|
||||
},
|
||||
|
||||
applyConfig() {
|
||||
const qrcodeName = store.cfg('profile.qrcode_image', '');
|
||||
const qrcodeName = store.cfg('profile.qrcode_image');
|
||||
this.setData({
|
||||
version: store.cfg('site.version', 'V0.0.9'),
|
||||
text2: store.cfg('profile.menu.change_info', '修改资料'),
|
||||
text3: store.cfg('profile.menu.share', '好友分享'),
|
||||
text4: store.cfg('profile.menu.contact', '在线沟通'),
|
||||
text5: store.cfg('profile.menu.wechat', '添加微信:叶雷讲数学'),
|
||||
text6: store.cfg('profile.qrcode_caption', '长按识别二维码'),
|
||||
version: store.cfg('site.version'),
|
||||
text2: store.cfg('profile.menu.change_info'),
|
||||
text3: store.cfg('profile.menu.share'),
|
||||
text4: store.cfg('profile.menu.contact'),
|
||||
text5: store.cfg('profile.menu.wechat'),
|
||||
text6: store.cfg('profile.qrcode_caption'),
|
||||
qrcodeUrl: qrcodeName ? config.STATIC_BASE_URL + '/Images/WXCode/' + qrcodeName + '.png' : '',
|
||||
});
|
||||
},
|
||||
|
||||
applyUser() {
|
||||
const user = store.getUser();
|
||||
// 已登录 = 有 token 且设置过昵称;未设置昵称的仍是游客,与旧版语义一致
|
||||
const registered = !!(user && user.nickname);
|
||||
// 「已登录」看的是用户主动登录过没有(服务端记着这一位),不是有没有 token
|
||||
const registered = !!(user && user.isLogin === 1);
|
||||
this.setData({
|
||||
isLoggedIn: registered,
|
||||
userNameText: registered ? user.nickname : '未登录',
|
||||
loginBtnText: registered ? '退出登录' : '登录注册',
|
||||
headiconUrl: user && user.headiconUrl ? user.headiconUrl : config.STATIC_BASE_URL + config.PLACEHOLDER.USER,
|
||||
// 未登录一律用默认头像:静默登录仍会带回上次设置的头像,
|
||||
// 不挡住的话退出登录后还会显示用户自己的头像。
|
||||
headiconUrl:
|
||||
registered && user.headiconUrl
|
||||
? user.headiconUrl
|
||||
: config.STATIC_BASE_URL + config.PLACEHOLDER.USER,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!--pages/wode/wode.wxml-->
|
||||
<van-popup show="{{ showCode }}" bind:close="onCodeClose">
|
||||
<view class="code-container">
|
||||
<text class="tip-text">{{text6}}</text>
|
||||
<text wx:if="{{text6}}" class="tip-text">{{text6}}</text>
|
||||
<van-image
|
||||
show-menu-by-longpress="true"
|
||||
use-loading-slot
|
||||
|
||||
@@ -145,12 +145,23 @@ function logout() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户主动登录:把数据库里的「已登录」标记置 1。
|
||||
*
|
||||
* 静默登录(login)不会碰这个标记——它只负责建立身份。只有用户亲自点了登录,
|
||||
* 才算「主动使用过登录功能」,购买资格也以这个标记为准,而不是有没有 token。
|
||||
*/
|
||||
function activeLogin() {
|
||||
return request({ url: '/v1/auth/login', method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 首页内容 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const api = {
|
||||
staticUrl,
|
||||
login,
|
||||
logout,
|
||||
activeLogin,
|
||||
|
||||
getConfig: () => request({ url: '/v1/config', auth: false }),
|
||||
getBanners: () => request({ url: '/v1/banners', auth: false }),
|
||||
|
||||
@@ -49,7 +49,16 @@ const store = {
|
||||
wx.removeStorageSync(config.STORAGE.REFRESH_TOKEN);
|
||||
},
|
||||
|
||||
isLoggedIn: () => !!read(config.STORAGE.TOKEN, ''),
|
||||
/**
|
||||
* 是否「已登录」。
|
||||
*
|
||||
* 指用户主动使用过登录功能(标记由服务端返回、跟着 user 一起存),
|
||||
* 不是「本地有没有 token」——静默登录每次都会拿到 token,那不算登录。
|
||||
*/
|
||||
isLoggedIn: () => {
|
||||
const user = store.getUser();
|
||||
return !!(user && user.isLogin === 1);
|
||||
},
|
||||
|
||||
// ── 用户 ─────────────────────────────────────────────────────────────
|
||||
getUser() {
|
||||
@@ -77,10 +86,10 @@ const store = {
|
||||
notify();
|
||||
},
|
||||
|
||||
/** 取配置项,带兜底值,避免配置缺失时页面直接崩掉。 */
|
||||
cfg(key, fallback) {
|
||||
/** 取配置项。数据库里配什么就是什么,没配返回空字符串,由页面自己决定要不要渲染。 */
|
||||
cfg(key) {
|
||||
const value = store.getSiteConfig()[key];
|
||||
return value === undefined || value === '' ? (fallback === undefined ? '' : fallback) : value;
|
||||
return value === undefined ? '' : value;
|
||||
},
|
||||
|
||||
// ── 已购课程 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { api } from '@/lib/api';
|
||||
import { clearToken, isLoggedIn } from '@/lib/auth';
|
||||
import { ENV_LABEL, getViewEnv, setViewEnv, type DataSourceEnv } from '@/lib/env';
|
||||
import { RESOURCE_CONFIGS } from '@/lib/resources';
|
||||
|
||||
type DataSource = DataSourceEnv;
|
||||
|
||||
const FIXED_NAV = [
|
||||
{ href: '/', label: '数据看板' },
|
||||
{ href: '/users', label: '用户' },
|
||||
@@ -13,10 +17,41 @@ const FIXED_NAV = [
|
||||
{ href: '/settings', label: '系统配置' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 底部「数据源」区域预留的高度(px)。
|
||||
*
|
||||
* 这个区块用 position:fixed 钉在视口左下角,脱离文档流,
|
||||
* 所以要在这里留出等高的空白,免得它盖住最后几个菜单项。
|
||||
* 数值按内容量给足余量,字体缩放时也不至于被遮住。
|
||||
*/
|
||||
const FOOTER_RESERVE_PX = 290;
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
/** 管理端「查看哪个库」——只影响自己 */
|
||||
const [viewEnv, setView] = useState<DataSource>('production');
|
||||
/** 线上小程序实际读的库 */
|
||||
const [miniEnv, setMiniEnv] = useState<DataSource>('production');
|
||||
const [developAvailable, setDevelopAvailable] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadState = useCallback(() => {
|
||||
setView(getViewEnv());
|
||||
api
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setMiniEnv(s.miniProgramEnv === 'develop' ? 'develop' : 'production');
|
||||
setDevelopAvailable(s.developAvailable);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 取不到时按生产库显示,不阻塞页面 */
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
@@ -24,44 +59,194 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return;
|
||||
}
|
||||
setReady(true);
|
||||
}, [router]);
|
||||
loadState();
|
||||
}, [router, loadState]);
|
||||
|
||||
/** 只切自己的查看视角,不动线上数据来源。 */
|
||||
const changeViewEnv = (next: DataSource) => {
|
||||
if (next === viewEnv) return;
|
||||
setViewEnv(next);
|
||||
setView(next);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
/** 真正切换线上小程序的数据源,必须二次确认。 */
|
||||
const applyMiniProgramEnv = () => {
|
||||
const next: DataSource = miniEnv === 'production' ? 'develop' : 'production';
|
||||
setSwitching(true);
|
||||
setError('');
|
||||
api
|
||||
.switchMiniProgramDataSource(next)
|
||||
.then(() => {
|
||||
setMiniEnv(next);
|
||||
setConfirmOpen(false);
|
||||
window.location.reload();
|
||||
})
|
||||
.catch((err: { message: string }) => {
|
||||
setError(err.message || '切换失败');
|
||||
setSwitching(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-gray-400">加载中…</div>;
|
||||
}
|
||||
|
||||
const nextMiniEnv: DataSource = miniEnv === 'production' ? 'develop' : 'production';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="w-56 shrink-0 border-r border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-200 px-4 py-4">
|
||||
<div className="text-sm font-semibold text-gray-900">叶雷讲数学</div>
|
||||
<div className="text-xs text-gray-400">管理后台</div>
|
||||
</div>
|
||||
<nav className="p-2">
|
||||
{FIXED_NAV.map((item) => (
|
||||
<NavItem key={item.href} href={item.href} label={item.label} active={pathname === item.href} />
|
||||
))}
|
||||
<div className="min-h-screen">
|
||||
{/*
|
||||
侧边栏:固定定位、高度恒等于视口。导航区自己滚动。
|
||||
注意它只承载「导航」,底部数据源区不在它里面——见下面那个独立 fixed 区块。
|
||||
*/}
|
||||
<aside className="fixed left-0 top-0 z-30 h-screen w-60 border-r border-gray-200 bg-white">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="shrink-0 border-b border-gray-200 px-4 py-4">
|
||||
<div className="text-sm font-semibold text-gray-900">叶雷讲数学</div>
|
||||
<div className="text-xs text-gray-400">管理后台</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-4 pb-1 text-xs text-gray-400">内容管理</div>
|
||||
{RESOURCE_CONFIGS.map((r) => (
|
||||
<NavItem key={r.key} href={`/${r.key}`} label={r.title} active={pathname === `/${r.key}`} />
|
||||
))}
|
||||
</nav>
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{FIXED_NAV.map((item) => (
|
||||
<NavItem key={item.href} href={item.href} label={item.label} active={pathname === item.href} />
|
||||
))}
|
||||
<div className="px-3 pt-4 pb-1 text-xs text-gray-400">内容管理</div>
|
||||
{RESOURCE_CONFIGS.map((r) => (
|
||||
<NavItem key={r.key} href={`/${r.key}`} label={r.title} active={pathname === `/${r.key}`} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
clearToken();
|
||||
router.replace('/login');
|
||||
}}
|
||||
className="w-full rounded-md px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
{/* 给下面那个 fixed 区块让位,避免遮住最后几个菜单 */}
|
||||
<div className="shrink-0" style={{ height: FOOTER_RESERVE_PX }} aria-hidden="true" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
{/*
|
||||
数据源区:独立的 fixed 元素,钉在视口左下角。
|
||||
它不在侧边栏里,也不参与任何 flex 布局,因此页面长短、导航多少、窗口高低都影响不到它。
|
||||
*/}
|
||||
<div className="fixed bottom-0 left-0 z-40 w-60 border-t border-gray-200 bg-white p-2">
|
||||
<div className="px-3 pb-1 text-xs text-gray-400">查看的数据源</div>
|
||||
<div className="flex gap-1 px-2">
|
||||
{(['production', 'develop'] as DataSource[]).map((key) => {
|
||||
const disabled = key === 'develop' && !developAvailable;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => changeViewEnv(key)}
|
||||
disabled={disabled}
|
||||
title={disabled ? '服务端未配置开发库' : '只影响你自己的查看与编辑'}
|
||||
className={`flex-1 rounded-md border px-2 py-1.5 text-xs transition ${
|
||||
viewEnv === key
|
||||
? key === 'develop'
|
||||
? 'border-orange-400 bg-orange-50 font-medium text-orange-700'
|
||||
: 'border-blue-400 bg-blue-50 font-medium text-blue-700'
|
||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
{ENV_LABEL[key]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="px-3 pt-1 pb-2 text-[11px] leading-tight text-gray-400">
|
||||
只影响你自己的查看与编辑,不影响小程序
|
||||
</p>
|
||||
|
||||
<div className="border-t border-gray-100 px-3 pt-3 pb-1 text-xs text-gray-400">
|
||||
线上小程序数据源
|
||||
</div>
|
||||
<div className="px-2 pb-2">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 px-1 text-sm">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
miniEnv === 'develop' ? 'bg-orange-500' : 'bg-green-500'
|
||||
}`}
|
||||
/>
|
||||
<span className={miniEnv === 'develop' ? 'font-medium text-orange-700' : 'text-gray-700'}>
|
||||
{ENV_LABEL[miniEnv]}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={!developAvailable || switching}
|
||||
title={developAvailable ? undefined : '服务端未配置开发库'}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
切换到{ENV_LABEL[nextMiniEnv]}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
clearToken();
|
||||
router.replace('/login');
|
||||
}}
|
||||
className="w-full rounded-md px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main className="ml-60">
|
||||
{miniEnv === 'develop' && (
|
||||
<div className="border-b border-orange-300 bg-orange-100 px-6 py-2 text-sm font-medium text-orange-800">
|
||||
⚠ 线上小程序当前读的是<span className="font-bold">开发库</span>
|
||||
——所有用户打开小程序看到的都是开发库数据,测完请及时切回生产库。
|
||||
</div>
|
||||
)}
|
||||
{miniEnv !== 'develop' && viewEnv === 'develop' && (
|
||||
<div className="border-b border-blue-200 bg-blue-50 px-6 py-2 text-sm text-blue-800">
|
||||
你正在查看<span className="font-bold">开发库</span>的数据(仅影响你自己,小程序仍在读生产库)
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
|
||||
{confirmOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-5 shadow-xl">
|
||||
<h3 className="mb-3 text-base font-semibold text-gray-900">确认切换线上小程序数据源?</h3>
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<p>
|
||||
即将把线上小程序从
|
||||
<span className="mx-1 font-medium">{ENV_LABEL[miniEnv]}</span>
|
||||
切换到
|
||||
<span className="mx-1 font-medium text-orange-700">{ENV_LABEL[nextMiniEnv]}</span>。
|
||||
</p>
|
||||
{nextMiniEnv === 'develop' ? (
|
||||
<p className="rounded-md bg-orange-50 px-3 py-2 text-orange-800">
|
||||
切换后,<span className="font-medium">所有用户</span>在微信里打开小程序看到的都是开发库数据
|
||||
(旧开发库:132 个用户、3 门课程,首页两个栏目为空)。
|
||||
他们会以为课程丢失,请务必在测试结束后切回生产库。
|
||||
</p>
|
||||
) : (
|
||||
<p className="rounded-md bg-green-50 px-3 py-2 text-green-800">
|
||||
将恢复为生产库,用户看到的即真实数据。
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="rounded-md bg-red-50 px-3 py-2 text-red-700">{error}</p>}
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
disabled={switching}
|
||||
className="rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={applyMiniProgramEnv}
|
||||
disabled={switching}
|
||||
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700 disabled:opacity-50"
|
||||
>
|
||||
{switching ? '切换中…' : '确认切换'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { clearToken, getToken } from './auth';
|
||||
import { getViewEnv } from './env';
|
||||
|
||||
const BASE = (process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
|
||||
/**
|
||||
* 服务端地址。`NEXT_PUBLIC_*` 是**构建时写死**的,所以这个兜底值必须指向真实部署地址——
|
||||
* 之前兜底写了 127.0.0.1:3000,改成「本机构建、上传产物」之后,
|
||||
* 构建时读不到环境变量就落到这个兜底值,结果线上后台在拿访问者的电脑当服务器,全部请求失败。
|
||||
*
|
||||
* 需要指向别的环境时,构建前设置 NEXT_PUBLIC_API_BASE_URL 覆盖即可。
|
||||
*/
|
||||
const BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || 'https://yelaymaths-api.yeomai.site').replace(/\/+$/, '');
|
||||
|
||||
export interface ApiFailure {
|
||||
code: number;
|
||||
@@ -20,6 +28,8 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
// 管理端「查看哪个库」,只影响自己看到的和编辑的数据
|
||||
'x-yelay-env': getViewEnv(),
|
||||
...((init.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
@@ -94,8 +104,21 @@ export const api = {
|
||||
payEnabled: boolean;
|
||||
/** 0 现网 / 1 沙箱 */
|
||||
payEnv: number;
|
||||
/** 当前数据源 */
|
||||
dataEnv: string;
|
||||
/** 服务端是否配置了开发库 */
|
||||
developAvailable: boolean;
|
||||
/** 线上小程序当前读的库 */
|
||||
miniProgramEnv: string;
|
||||
}>('/admin/v1/stats'),
|
||||
|
||||
/** 切换**线上小程序**的数据源。影响所有用户,前端必须二次确认。 */
|
||||
switchMiniProgramDataSource: (env: 'production' | 'develop') =>
|
||||
request<{ miniProgramEnv: string }>('/admin/v1/data-source', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ env }),
|
||||
}),
|
||||
|
||||
listUsers: (page: number, pageSize: number, keyword: string) =>
|
||||
request<Paged<Record<string, unknown>>>(`/admin/v1/users${qs({ page, pageSize, keyword })}`),
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
export type DataSourceEnv = 'production' | 'develop';
|
||||
|
||||
const VIEW_ENV_KEY = 'yelay_admin_view_env';
|
||||
|
||||
/**
|
||||
* 管理端「查看哪个库」。
|
||||
*
|
||||
* 只存在浏览器本地,只影响你自己的查看与编辑视角,
|
||||
* 不会改变线上小程序的数据来源——那是另一个开关的事。
|
||||
*/
|
||||
export function getViewEnv(): DataSourceEnv {
|
||||
if (typeof window === 'undefined') return 'production';
|
||||
return window.localStorage.getItem(VIEW_ENV_KEY) === 'develop' ? 'develop' : 'production';
|
||||
}
|
||||
|
||||
export function setViewEnv(env: DataSourceEnv): void {
|
||||
window.localStorage.setItem(VIEW_ENV_KEY, env);
|
||||
}
|
||||
|
||||
export const ENV_LABEL: Record<DataSourceEnv, string> = {
|
||||
production: '生产库',
|
||||
develop: '开发库',
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
|
||||
@@ -3,6 +3,10 @@
|
||||
# 宝塔默认账号没有该权限,生产环境请用 prisma migrate deploy。
|
||||
DATABASE_URL="mysql://用户名:密码@127.0.0.1:3306/yelaymaths"
|
||||
|
||||
# 开发库连接串。配置后管理后台会出现「生产库 / 开发库」切换按钮;留空则按钮置灰。
|
||||
# 只影响管理后台的数据查看,小程序接口永远走 DATABASE_URL。
|
||||
DATABASE_URL_DEVELOP="mysql://用户名:密码@127.0.0.1:3306/yelaymaths_develop"
|
||||
|
||||
# 旧库(只读,仅迁移脚本使用)
|
||||
LEGACY_DATABASE_URL="mysql://用户名:密码@127.0.0.1:3306/api_yeomai_site"
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `users` ADD COLUMN `is_login` TINYINT NOT NULL DEFAULT 0;
|
||||
@@ -30,6 +30,9 @@ model User {
|
||||
session_key_updated_at DateTime? @map("session_key_updated_at")
|
||||
/// 1 正常 / 0 封禁
|
||||
status Int @default(1) @db.TinyInt
|
||||
/// 1 = 用户主动使用过登录功能(即认可/接受这个小程序),0 = 未登录或已退出。
|
||||
/// 和静默登录无关:静默登录只建立身份,不改这一位。购买前必须为 1。
|
||||
is_login Int @default(0) @map("is_login") @db.TinyInt
|
||||
registered_at DateTime @map("registered_at")
|
||||
last_login_at DateTime? @map("last_login_at")
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
/** 管理后台用来选择「查看哪个库」的请求头。只影响管理端自己的读写。 */
|
||||
export const ADMIN_ENV_HEADER = 'x-yelay-env';
|
||||
|
||||
export type DataSourceEnv = 'production' | 'develop';
|
||||
|
||||
/**
|
||||
* 从请求头取出管理端当前查看的数据源。
|
||||
*
|
||||
* 这个状态存在浏览器本地(localStorage),不落库也不写文件——
|
||||
* 因为「看哪个库」是每个管理员各自的视角,不该影响别人,更不该影响线上小程序。
|
||||
*
|
||||
* 默认 production,任何无法识别的值都按 production 处理(失败时偏向安全的一侧)。
|
||||
*/
|
||||
export const AdminEnvParam = createParamDecorator((_data: unknown, ctx: ExecutionContext): DataSourceEnv => {
|
||||
const req = ctx.switchToHttp().getRequest<Request>();
|
||||
const raw = req.headers[ADMIN_ENV_HEADER];
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
return value === 'develop' ? 'develop' : 'production';
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ const envSchema = z.object({
|
||||
|
||||
DATABASE_URL: z.string().min(1, '缺少数据库连接串'),
|
||||
|
||||
/**
|
||||
* 开发库连接串。留空则管理后台的多库切换不可用。
|
||||
* 仅在管理后台按请求头切换数据源时使用,小程序接口永远走 DATABASE_URL。
|
||||
*/
|
||||
DATABASE_URL_DEVELOP: z.string().default(''),
|
||||
|
||||
/** 对外域名 */
|
||||
API_BASE_URL: z.string().url(),
|
||||
STATIC_BASE_URL: z.string().url(),
|
||||
|
||||
@@ -12,7 +12,8 @@ export class HealthController {
|
||||
async health() {
|
||||
let db = 'ok';
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
// 固定查生产库:探活关心的是主库是否可用
|
||||
await this.prisma.main.$queryRaw`SELECT 1`;
|
||||
} catch {
|
||||
db = 'down';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { CurrentUser, Public, RequiresAudience, type AuthPrincipal } from '../../common/auth';
|
||||
import { AdminEnvParam, type DataSourceEnv } from '../../common/admin-env';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AdminService } from './admin.service';
|
||||
@@ -22,6 +23,7 @@ const grantSchema = z.object({
|
||||
note: z.string().max(255).optional(),
|
||||
});
|
||||
const refundSchema = z.object({ reason: z.string().min(1).max(100) });
|
||||
const dataSourceSchema = z.object({ env: z.enum(['production', 'develop']) });
|
||||
|
||||
/** 管理员登录。必须公开,否则登录接口本身会被守卫拦住。 */
|
||||
@Public()
|
||||
@@ -59,32 +61,49 @@ export class AdminController {
|
||||
constructor(private readonly admin: AdminService) {}
|
||||
|
||||
@Get('stats')
|
||||
stats() {
|
||||
return this.admin.stats();
|
||||
stats(@AdminEnvParam() env: DataSourceEnv) {
|
||||
return this.admin.stats(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换**线上小程序**的数据源。
|
||||
*
|
||||
* 与管理端左侧的「查看哪个库」完全不同:那个只改自己的视角,
|
||||
* 这个会真实影响所有用户在小程序里看到的内容,所以前端必须二次确认。
|
||||
*
|
||||
* 开关存在生产库,因此切到开发库后依然能切回来。
|
||||
*/
|
||||
@Put('data-source')
|
||||
switchMiniProgramDataSource(@Body() body: unknown) {
|
||||
const parsed = dataSourceSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误:env 只能是 production 或 develop');
|
||||
return this.admin.switchMiniProgramDataSource(parsed.data.env);
|
||||
}
|
||||
|
||||
// ── 用户 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Get('users')
|
||||
listUsers(
|
||||
@AdminEnvParam() env: DataSourceEnv,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
return this.admin.listUsers(Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
|
||||
return this.admin.listUsers(env, Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
updateUser(@Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
|
||||
updateUser(@AdminEnvParam() env: DataSourceEnv, @Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
|
||||
const parsed = updateUserSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误');
|
||||
return this.admin.updateUser(id, parsed.data);
|
||||
return this.admin.updateUser(env, id, parsed.data);
|
||||
}
|
||||
|
||||
// ── 通用内容 CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
@Get('data/:resource')
|
||||
listResource(
|
||||
@AdminEnvParam() env: DataSourceEnv,
|
||||
@Param('resource') resource: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@@ -92,67 +111,77 @@ export class AdminController {
|
||||
@Query('courseId') courseId?: string,
|
||||
) {
|
||||
const filter = courseId ? { course_id: Number(courseId) } : {};
|
||||
return this.admin.listResource(resource, Number(page) || 1, Number(pageSize) || 20, keyword ?? '', filter);
|
||||
return this.admin.listResource(env, resource, Number(page) || 1, Number(pageSize) || 20, keyword ?? '', filter);
|
||||
}
|
||||
|
||||
@Post('data/:resource')
|
||||
createResource(@Param('resource') resource: string, @Body() body: unknown) {
|
||||
return this.admin.createResource(resource, body);
|
||||
createResource(@AdminEnvParam() env: DataSourceEnv, @Param('resource') resource: string, @Body() body: unknown) {
|
||||
return this.admin.createResource(env, resource, body);
|
||||
}
|
||||
|
||||
@Patch('data/:resource/:id')
|
||||
updateResource(
|
||||
@AdminEnvParam() env: DataSourceEnv,
|
||||
@Param('resource') resource: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
return this.admin.updateResource(resource, id, body);
|
||||
return this.admin.updateResource(env, resource, id, body);
|
||||
}
|
||||
|
||||
@Delete('data/:resource/:id')
|
||||
deleteResource(@Param('resource') resource: string, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.admin.deleteResource(resource, id);
|
||||
deleteResource(
|
||||
@AdminEnvParam() env: DataSourceEnv,
|
||||
@Param('resource') resource: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.admin.deleteResource(env, resource, id);
|
||||
}
|
||||
|
||||
// ── 配置 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Get('settings')
|
||||
listSettings() {
|
||||
return this.admin.listSettings();
|
||||
listSettings(@AdminEnvParam() env: DataSourceEnv) {
|
||||
return this.admin.listSettings(env);
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
upsertSetting(@Body() body: unknown) {
|
||||
upsertSetting(@AdminEnvParam() env: DataSourceEnv, @Body() body: unknown) {
|
||||
const parsed = settingSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误');
|
||||
return this.admin.upsertSetting(parsed.data.key, parsed.data.value, parsed.data.remark);
|
||||
return this.admin.upsertSetting(env, parsed.data.key, parsed.data.value, parsed.data.remark);
|
||||
}
|
||||
|
||||
// ── 订单 / 权益 ────────────────────────────────────────────────────────
|
||||
|
||||
@Get('orders')
|
||||
listOrders(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('status') status?: string) {
|
||||
listOrders(
|
||||
@AdminEnvParam() env: DataSourceEnv,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const s = status === undefined || status === '' ? undefined : Number(status);
|
||||
return this.admin.listOrders(Number(page) || 1, Number(pageSize) || 20, Number.isFinite(s) ? s : undefined);
|
||||
return this.admin.listOrders(env, Number(page) || 1, Number(pageSize) || 20, Number.isFinite(s) ? s : undefined);
|
||||
}
|
||||
|
||||
@Post('orders/:id/refund')
|
||||
refundOrder(@Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
|
||||
refundOrder(@AdminEnvParam() env: DataSourceEnv, @Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
|
||||
const parsed = refundSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('请填写退款原因');
|
||||
return this.admin.refundOrder(id, parsed.data.reason);
|
||||
return this.admin.refundOrder(env, id, parsed.data.reason);
|
||||
}
|
||||
|
||||
@Get('entitlements')
|
||||
listEntitlements(@Query('userId', ParseIntPipe) userId: number) {
|
||||
return this.admin.listEntitlements(userId);
|
||||
listEntitlements(@AdminEnvParam() env: DataSourceEnv, @Query('userId', ParseIntPipe) userId: number) {
|
||||
return this.admin.listEntitlements(env, userId);
|
||||
}
|
||||
|
||||
@Post('entitlements')
|
||||
grantEntitlement(@Body() body: unknown) {
|
||||
grantEntitlement(@AdminEnvParam() env: DataSourceEnv, @Body() body: unknown) {
|
||||
const parsed = grantSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误');
|
||||
return this.admin.grantEntitlement({
|
||||
return this.admin.grantEntitlement(env, {
|
||||
userId: parsed.data.userId,
|
||||
courseId: parsed.data.courseId,
|
||||
expireAt: parsed.data.expireAt ?? null,
|
||||
@@ -161,8 +190,12 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Delete('entitlements')
|
||||
revokeEntitlement(@Query('userId', ParseIntPipe) userId: number, @Query('courseId', ParseIntPipe) courseId: number) {
|
||||
return this.admin.revokeEntitlement(userId, courseId);
|
||||
revokeEntitlement(
|
||||
@AdminEnvParam() env: DataSourceEnv,
|
||||
@Query('userId', ParseIntPipe) userId: number,
|
||||
@Query('courseId', ParseIntPipe) courseId: number,
|
||||
) {
|
||||
return this.admin.revokeEntitlement(env, userId, courseId);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import type { DataSourceEnv } from '../../common/admin-env';
|
||||
import { hashPassword, verifyPassword } from '../../common/crypto';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
@@ -27,8 +29,29 @@ export class AdminService {
|
||||
private readonly wxpay: WxPayService,
|
||||
) {}
|
||||
|
||||
private delegate(name: string): Delegate {
|
||||
const d = (this.prisma as unknown as Record<string, Delegate>)[name];
|
||||
/** 开发库是否可用(由 DATABASE_URL_DEVELOP 是否配置决定)。 */
|
||||
get developAvailable(): boolean {
|
||||
return this.prisma.hasDev;
|
||||
}
|
||||
|
||||
/** 小程序当前读的是哪个库。由「小程序数据源」那个独立开关决定。 */
|
||||
get miniProgramEnv(): string {
|
||||
return this.prisma.activeEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换**小程序**的数据源。
|
||||
*
|
||||
* 与管理端左侧的「查看哪个库」是两回事:那个只影响管理端自己看的数据,
|
||||
* 这个才真正改变线上小程序读哪个库,所以界面上必须二次确认。
|
||||
*/
|
||||
async switchMiniProgramDataSource(env: DataSourceEnv): Promise<{ miniProgramEnv: string }> {
|
||||
await this.prisma.setMiniProgramEnv(env);
|
||||
return { miniProgramEnv: env };
|
||||
}
|
||||
|
||||
private delegate(client: PrismaClient, name: string): Delegate {
|
||||
const d = (client as unknown as Record<string, Delegate>)[name];
|
||||
if (!d) throw AppError.notFound(`未知资源:${name}`);
|
||||
return d;
|
||||
}
|
||||
@@ -36,11 +59,12 @@ export class AdminService {
|
||||
// ── 登录 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async verifyAdmin(username: string, password: string): Promise<{ id: number; role: number; displayName: string | null }> {
|
||||
const admin = await this.prisma.adminUser.findUnique({ where: { username } });
|
||||
// 管理员账号固定在生产库:否则切到开发库后没有账号能登进来,就永远切不回去了
|
||||
const admin = await this.prisma.main.adminUser.findUnique({ where: { username } });
|
||||
if (!admin || admin.status !== 1) throw AppError.unauthorized('账号或密码错误');
|
||||
if (!verifyPassword(password, admin.password_hash)) throw AppError.unauthorized('账号或密码错误');
|
||||
|
||||
await this.prisma.adminUser.update({ where: { id: admin.id }, data: { last_login_at: new Date() } });
|
||||
await this.prisma.main.adminUser.update({ where: { id: admin.id }, data: { last_login_at: new Date() } });
|
||||
return { id: admin.id, role: admin.role, displayName: admin.display_name };
|
||||
}
|
||||
|
||||
@@ -49,12 +73,12 @@ export class AdminService {
|
||||
* 避免在生产环境留下一个可被随意调用的建号后门。
|
||||
*/
|
||||
async bootstrapAdmin(username: string, password: string): Promise<{ created: boolean }> {
|
||||
const count = await this.prisma.adminUser.count();
|
||||
const count = await this.prisma.main.adminUser.count();
|
||||
if (count > 0) return { created: false };
|
||||
if (username.length < 3 || password.length < 8) {
|
||||
throw AppError.badRequest('用户名至少 3 位,密码至少 8 位');
|
||||
}
|
||||
await this.prisma.adminUser.create({
|
||||
await this.prisma.main.adminUser.create({
|
||||
data: { username, password_hash: hashPassword(password), display_name: '超级管理员', role: 1 },
|
||||
});
|
||||
return { created: true };
|
||||
@@ -62,14 +86,15 @@ export class AdminService {
|
||||
|
||||
// ── 看板 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async stats() {
|
||||
async stats(env: DataSourceEnv) {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const [users, courses, orders, delivered, refunded, revenue] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.course.count({ where: { status: 1 } }),
|
||||
this.prisma.order.count(),
|
||||
this.prisma.order.count({ where: { status: OrderStatus.DELIVERED } }),
|
||||
this.prisma.order.count({ where: { status: OrderStatus.REFUNDED } }),
|
||||
this.prisma.order.aggregate({
|
||||
db.user.count(),
|
||||
db.course.count({ where: { status: 1 } }),
|
||||
db.order.count(),
|
||||
db.order.count({ where: { status: OrderStatus.DELIVERED } }),
|
||||
db.order.count({ where: { status: OrderStatus.REFUNDED } }),
|
||||
db.order.aggregate({
|
||||
where: { status: OrderStatus.DELIVERED },
|
||||
_sum: { amount_fen: true },
|
||||
}),
|
||||
@@ -84,27 +109,33 @@ export class AdminService {
|
||||
revenueFen: revenue._sum.amount_fen ?? 0,
|
||||
payEnabled: this.wxpay.enabled,
|
||||
payEnv: this.wxpay.env,
|
||||
/** 管理端当前正在查看的库 */
|
||||
dataEnv: env,
|
||||
/** 线上小程序当前读的库(由独立开关控制,与 dataEnv 无关) */
|
||||
miniProgramEnv: this.prisma.activeEnv,
|
||||
developAvailable: this.developAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async listUsers(page = 1, pageSize = 20, keyword = '') {
|
||||
async listUsers(env: DataSourceEnv, page = 1, pageSize = 20, keyword = '') {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const size = Math.min(Math.max(pageSize, 1), 100);
|
||||
const current = Math.max(page, 1);
|
||||
const where = keyword
|
||||
? { OR: [{ nickname: { contains: keyword } }, { openid: { contains: keyword } }] }
|
||||
: {};
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.user.findMany({
|
||||
const [items, total] = await db.$transaction([
|
||||
db.user.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
skip: (current - 1) * size,
|
||||
take: size,
|
||||
include: { _count: { select: { entitlements: true } } },
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
db.user.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -124,13 +155,13 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
async updateUser(id: number, input: { nickname?: string; headicon_id?: number; status?: number }) {
|
||||
return this.prisma.user.update({ where: { id }, data: input });
|
||||
async updateUser(env: DataSourceEnv, id: number, input: { nickname?: string; headicon_id?: number; status?: number }) {
|
||||
return this.prisma.clientFor(env).user.update({ where: { id }, data: input });
|
||||
}
|
||||
|
||||
// ── 通用内容 CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
async listResource(name: string, page = 1, pageSize = 20, keyword = '', filter: Record<string, unknown> = {}) {
|
||||
async listResource(env: DataSourceEnv, name: string, page = 1, pageSize = 20, keyword = '', filter: Record<string, unknown> = {}) {
|
||||
const readonly = READONLY_RESOURCES[name];
|
||||
const def: ResourceDef | undefined = RESOURCES[name];
|
||||
if (!readonly && !def) throw AppError.notFound(`未知资源:${name}`);
|
||||
@@ -143,7 +174,7 @@ export class AdminService {
|
||||
...(keyword && searchField ? { [searchField]: { contains: keyword } } : {}),
|
||||
};
|
||||
|
||||
const d = this.delegate(readonly ? readonly.delegate : def!.delegate);
|
||||
const d = this.delegate(this.prisma.clientFor(env), readonly ? readonly.delegate : def!.delegate);
|
||||
const [items, total] = await Promise.all([
|
||||
d.findMany({ where, orderBy: readonly ? readonly.orderBy : def!.orderBy, skip: (current - 1) * size, take: size }),
|
||||
d.count({ where }),
|
||||
@@ -152,15 +183,17 @@ export class AdminService {
|
||||
return { items, total, page: current, pageSize: size };
|
||||
}
|
||||
|
||||
async createResource(name: string, payload: unknown) {
|
||||
async createResource(env: DataSourceEnv, name: string, payload: unknown) {
|
||||
const def = RESOURCES[name];
|
||||
if (!def) throw AppError.notFound(`资源不可创建:${name}`);
|
||||
const parsed = def.create.safeParse(payload);
|
||||
if (!parsed.success) throw AppError.badRequest(formatZodError(parsed.error));
|
||||
return this.delegate(def.delegate).create({ data: { ...def.defaults, ...(parsed.data as Record<string, unknown>) } });
|
||||
return this.delegate(this.prisma.clientFor(env), def.delegate).create({
|
||||
data: { ...def.defaults, ...(parsed.data as Record<string, unknown>) },
|
||||
});
|
||||
}
|
||||
|
||||
async updateResource(name: string, id: number, payload: unknown) {
|
||||
async updateResource(env: DataSourceEnv, name: string, id: number, payload: unknown) {
|
||||
const def = RESOURCES[name];
|
||||
if (!def) throw AppError.notFound(`资源不可修改:${name}`);
|
||||
|
||||
@@ -173,24 +206,24 @@ export class AdminService {
|
||||
for (const field of def.immutable ?? []) delete data[field];
|
||||
if (Object.keys(data).length === 0) throw AppError.badRequest('没有需要修改的内容');
|
||||
|
||||
return this.delegate(def.delegate).update({ where: { id }, data });
|
||||
return this.delegate(this.prisma.clientFor(env), def.delegate).update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async deleteResource(name: string, id: number): Promise<{ deleted: true }> {
|
||||
async deleteResource(env: DataSourceEnv, name: string, id: number): Promise<{ deleted: true }> {
|
||||
const def = RESOURCES[name];
|
||||
if (!def) throw AppError.notFound(`资源不可删除:${name}`);
|
||||
await this.delegate(def.delegate).delete({ where: { id } });
|
||||
await this.delegate(this.prisma.clientFor(env), def.delegate).delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// ── 配置 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async listSettings() {
|
||||
return this.prisma.setting.findMany({ orderBy: { id: 'asc' } });
|
||||
async listSettings(env: DataSourceEnv) {
|
||||
return this.prisma.clientFor(env).setting.findMany({ orderBy: { id: 'asc' } });
|
||||
}
|
||||
|
||||
async upsertSetting(key: string, value: string, remark?: string) {
|
||||
return this.prisma.setting.upsert({
|
||||
async upsertSetting(env: DataSourceEnv, key: string, value: string, remark?: string) {
|
||||
return this.prisma.clientFor(env).setting.upsert({
|
||||
where: { key },
|
||||
create: { key, value, remark: remark ?? null },
|
||||
update: { value, remark: remark ?? undefined },
|
||||
@@ -199,20 +232,21 @@ export class AdminService {
|
||||
|
||||
// ── 订单与权益 ─────────────────────────────────────────────────────────
|
||||
|
||||
async listOrders(page = 1, pageSize = 20, status?: number) {
|
||||
async listOrders(env: DataSourceEnv, page = 1, pageSize = 20, status?: number) {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const size = Math.min(Math.max(pageSize, 1), 100);
|
||||
const current = Math.max(page, 1);
|
||||
const where = status === undefined ? {} : { status };
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.order.findMany({
|
||||
const [items, total] = await db.$transaction([
|
||||
db.order.findMany({
|
||||
where,
|
||||
orderBy: { created_at: 'desc' },
|
||||
skip: (current - 1) * size,
|
||||
take: size,
|
||||
include: { user: { select: { nickname: true, openid: true } }, course: { select: { title: true } } },
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
db.order.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -237,8 +271,13 @@ export class AdminService {
|
||||
}
|
||||
|
||||
/** 发起退款(仅非 iOS 终端)。退款成功由退款推送回写订单状态。 */
|
||||
async refundOrder(orderId: number, reason: string) {
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { user: true } });
|
||||
async refundOrder(env: DataSourceEnv, orderId: number, reason: string) {
|
||||
// 开发库的订单在微信侧不存在,发退款请求必然失败,直接拦住
|
||||
if (env === 'develop') {
|
||||
throw AppError.badRequest('当前数据源是开发库,订单在微信侧不存在,无法退款');
|
||||
}
|
||||
|
||||
const order = await this.prisma.clientFor(env).order.findUnique({ where: { id: orderId }, include: { user: true } });
|
||||
if (!order) throw AppError.notFound('订单不存在');
|
||||
if (order.status !== OrderStatus.DELIVERED) throw AppError.conflict('只有已开通的订单可以退款');
|
||||
|
||||
@@ -252,8 +291,8 @@ export class AdminService {
|
||||
return { submitted: true, providerResponse: result };
|
||||
}
|
||||
|
||||
async listEntitlements(userId: number) {
|
||||
const rows = await this.prisma.entitlement.findMany({
|
||||
async listEntitlements(env: DataSourceEnv, userId: number) {
|
||||
const rows = await this.prisma.clientFor(env).entitlement.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { granted_at: 'desc' },
|
||||
include: { course: { select: { title: true, code: true } } },
|
||||
@@ -271,15 +310,19 @@ export class AdminService {
|
||||
}
|
||||
|
||||
/** 手动发放权益(赠课、补发、老用户补救)。 */
|
||||
async grantEntitlement(input: { userId: number; courseId: number; expireAt?: Date | null; note?: string }) {
|
||||
async grantEntitlement(
|
||||
env: DataSourceEnv,
|
||||
input: { userId: number; courseId: number; expireAt?: Date | null; note?: string },
|
||||
) {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const [user, course] = await Promise.all([
|
||||
this.prisma.user.findUnique({ where: { id: input.userId } }),
|
||||
this.prisma.course.findUnique({ where: { id: input.courseId } }),
|
||||
db.user.findUnique({ where: { id: input.userId } }),
|
||||
db.course.findUnique({ where: { id: input.courseId } }),
|
||||
]);
|
||||
if (!user) throw AppError.notFound('用户不存在');
|
||||
if (!course) throw AppError.notFound('课程不存在');
|
||||
|
||||
return this.prisma.entitlement.upsert({
|
||||
return db.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: input.userId, course_id: input.courseId } },
|
||||
create: {
|
||||
user_id: input.userId,
|
||||
@@ -296,8 +339,8 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
async revokeEntitlement(userId: number, courseId: number): Promise<{ deleted: true }> {
|
||||
await this.prisma.entitlement.delete({
|
||||
async revokeEntitlement(env: DataSourceEnv, userId: number, courseId: number): Promise<{ deleted: true }> {
|
||||
await this.prisma.clientFor(env).entitlement.delete({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
});
|
||||
return { deleted: true };
|
||||
|
||||
@@ -27,6 +27,17 @@ export class AuthController {
|
||||
return this.auth.refresh(parsed.data.refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户主动登录。
|
||||
*
|
||||
* wx-login 是静默登录,只建立身份、不动「已登录」标记;
|
||||
* 用户在小程序里点「一键登录」才会走这里,这一位同时也是购买的前置条件。
|
||||
*/
|
||||
@Post('login')
|
||||
activeLogin(@CurrentUser() user: AuthPrincipal): Promise<{ isLogin: number }> {
|
||||
return this.auth.setActiveLogin(user.id);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
logout(@CurrentUser() user: AuthPrincipal): Promise<null> {
|
||||
return this.auth.logOut(user.id);
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface LoginResult {
|
||||
headiconId: number;
|
||||
headiconUrl: string | null;
|
||||
isNew: boolean;
|
||||
/** 1 = 用户主动使用过登录功能;静默登录不会改这一位 */
|
||||
isLogin: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,18 +45,18 @@ export class AuthService {
|
||||
if (!code) throw AppError.badRequest('缺少 code');
|
||||
|
||||
const session = await this.wechat.code2Session(code);
|
||||
const existing = await this.prisma.user.findUnique({ where: { openid: session.openid } });
|
||||
const existing = await this.prisma.active.user.findUnique({ where: { openid: session.openid } });
|
||||
|
||||
// sessionKey 必须托管:虚拟支付的用户态签名依赖它,且只在登录时能拿到
|
||||
const now = new Date();
|
||||
const sessionKeyEnc = encryptSecret(session.sessionKey, this.cfg.SESSION_KEY_ENC_SECRET);
|
||||
|
||||
const user = existing
|
||||
? await this.prisma.user.update({
|
||||
? await this.prisma.active.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { wx_session_key: sessionKeyEnc, session_key_updated_at: now, last_login_at: now },
|
||||
})
|
||||
: await this.prisma.user.create({
|
||||
: await this.prisma.active.user.create({
|
||||
data: {
|
||||
openid: session.openid,
|
||||
wx_session_key: sessionKeyEnc,
|
||||
@@ -77,6 +79,7 @@ export class AuthService {
|
||||
headiconUrl:
|
||||
user.headicon_id > 0 ? staticUrls.headicon(this.cfg.STATIC_BASE_URL, user.headicon_id) : null,
|
||||
isNew: !existing,
|
||||
isLogin: user.is_login,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -93,16 +96,31 @@ export class AuthService {
|
||||
} catch {
|
||||
throw AppError.unauthorized('登录已过期,请重新进入小程序');
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: sub } });
|
||||
const user = await this.prisma.active.user.findUnique({ where: { id: sub } });
|
||||
if (!user || user.status !== 1) throw AppError.unauthorized();
|
||||
return { accessToken: await this.sign(user.id, 'user', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET) };
|
||||
}
|
||||
|
||||
/** 退出:清掉服务端托管的 sessionKey,前端丢弃 token。 */
|
||||
async logOut(userId: number): Promise<null> {
|
||||
await this.prisma.user.updateMany({
|
||||
/**
|
||||
* 用户主动登录:把「已登录」标记置 1。
|
||||
*
|
||||
* 只有用户在小程序里亲自点过登录才会走这里。这一位代表用户主动使用过登录功能
|
||||
* (也就是认可了这个小程序),跟技术上有没有 token 是两回事——静默登录永远
|
||||
* 不改它。购买的前置条件就是这一位为 1。
|
||||
*/
|
||||
async setActiveLogin(userId: number): Promise<{ isLogin: number }> {
|
||||
await this.prisma.active.user.updateMany({
|
||||
where: { id: userId },
|
||||
data: { wx_session_key: null, session_key_updated_at: null },
|
||||
data: { is_login: 1 },
|
||||
});
|
||||
return { isLogin: 1 };
|
||||
}
|
||||
|
||||
/** 退出:清掉服务端托管的 sessionKey,并把「已登录」标记置 0。 */
|
||||
async logOut(userId: number): Promise<null> {
|
||||
await this.prisma.active.user.updateMany({
|
||||
where: { id: userId },
|
||||
data: { is_login: 0, wx_session_key: null, session_key_updated_at: null },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class CoursesService {
|
||||
}
|
||||
|
||||
private async ownedCourseIds(userId: number): Promise<Set<number>> {
|
||||
const rows = await this.prisma.entitlement.findMany({
|
||||
const rows = await this.prisma.active.entitlement.findMany({
|
||||
where: { user_id: userId },
|
||||
select: { course_id: true },
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export class CoursesService {
|
||||
/** 课程列表。含未购买课程,供小程序做购买入口。 */
|
||||
async list(userId: number) {
|
||||
const [courses, owned] = await Promise.all([
|
||||
this.prisma.course.findMany({
|
||||
this.prisma.active.course.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
include: { _count: { select: { lessons: true } } },
|
||||
@@ -49,10 +49,10 @@ export class CoursesService {
|
||||
}
|
||||
|
||||
async detail(userId: number, code: string) {
|
||||
const course = await this.prisma.course.findUnique({ where: { code } });
|
||||
const course = await this.prisma.active.course.findUnique({ where: { code } });
|
||||
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
||||
|
||||
const owned = await this.prisma.entitlement.findUnique({
|
||||
const owned = await this.prisma.active.entitlement.findUnique({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ export class CoursesService {
|
||||
async lessons(userId: number, code: string) {
|
||||
const course = await this.requireEntitlement(userId, code);
|
||||
|
||||
const rows = await this.prisma.lesson.findMany({
|
||||
const rows = await this.prisma.active.lesson.findMany({
|
||||
where: { course_id: course.id },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
@@ -92,7 +92,7 @@ export class CoursesService {
|
||||
async playUrl(userId: number, code: string, chapterId: string, playId: string) {
|
||||
const course = await this.requireEntitlement(userId, code);
|
||||
|
||||
const lesson = await this.prisma.lesson.findFirst({
|
||||
const lesson = await this.prisma.active.lesson.findFirst({
|
||||
where: { course_id: course.id, chapter_id: chapterId, play_id: playId },
|
||||
});
|
||||
if (!lesson) throw AppError.notFound('课时不存在');
|
||||
@@ -107,7 +107,7 @@ export class CoursesService {
|
||||
|
||||
/** 免费试听课。公开内容,直接给静态地址,不签名。 */
|
||||
async freeLessons(seriesNo?: number) {
|
||||
const rows = await this.prisma.freeLesson.findMany({
|
||||
const rows = await this.prisma.active.freeLesson.findMany({
|
||||
where: seriesNo === undefined ? undefined : { series_no: seriesNo },
|
||||
orderBy: [{ series_no: 'asc' }, { sort_order: 'asc' }],
|
||||
});
|
||||
@@ -128,11 +128,11 @@ export class CoursesService {
|
||||
|
||||
/** 取课程并校验权益,是唯一的鉴权入口。 */
|
||||
private async requireEntitlement(userId: number, code: string) {
|
||||
const course = await this.prisma.course.findUnique({ where: { code } });
|
||||
const course = await this.prisma.active.course.findUnique({ where: { code } });
|
||||
if (!course) throw AppError.notFound('课程不存在');
|
||||
if (course.status !== 1) throw AppError.offline();
|
||||
|
||||
const owned = await this.prisma.entitlement.findUnique({
|
||||
const owned = await this.prisma.active.entitlement.findUnique({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
||||
});
|
||||
if (!owned) throw AppError.noEntitlement();
|
||||
|
||||
@@ -18,14 +18,14 @@ export class HomeService {
|
||||
|
||||
/** 站点配置,返回 key → value 的扁平对象。 */
|
||||
async getConfig(): Promise<Record<string, string>> {
|
||||
const rows = await this.prisma.setting.findMany();
|
||||
const rows = await this.prisma.active.setting.findMany();
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) map[row.key] = row.value ?? '';
|
||||
return map;
|
||||
}
|
||||
|
||||
async getBanners() {
|
||||
const rows = await this.prisma.banner.findMany({
|
||||
const rows = await this.prisma.active.banner.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export class HomeService {
|
||||
}
|
||||
|
||||
async getHomeCourses() {
|
||||
const rows = await this.prisma.homeCourse.findMany({
|
||||
const rows = await this.prisma.active.homeCourse.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
include: { course: { select: { code: true, title: true } } },
|
||||
@@ -56,7 +56,7 @@ export class HomeService {
|
||||
}
|
||||
|
||||
async getHomeFreeCourses() {
|
||||
const rows = await this.prisma.homeFreeCourse.findMany({
|
||||
const rows = await this.prisma.active.homeFreeCourse.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
@@ -74,15 +74,15 @@ export class HomeService {
|
||||
const current = Math.max(page, 1);
|
||||
const where = { status: 1 };
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.article.findMany({
|
||||
const [items, total] = await this.prisma.active.$transaction([
|
||||
this.prisma.active.article.findMany({
|
||||
where,
|
||||
// 用 published_at + id 排序,替代旧库对 varchar 网页id 的字符串排序
|
||||
orderBy: [{ published_at: 'desc' }, { id: 'desc' }],
|
||||
skip: (current - 1) * size,
|
||||
take: size,
|
||||
}),
|
||||
this.prisma.article.count({ where }),
|
||||
this.prisma.active.article.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -101,7 +101,7 @@ export class HomeService {
|
||||
}
|
||||
|
||||
async getArticle(id: number) {
|
||||
const row = await this.prisma.article.findFirst({ where: { id, status: 1 } });
|
||||
const row = await this.prisma.active.article.findFirst({ where: { id, status: 1 } });
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -116,7 +116,7 @@ export class HomeService {
|
||||
|
||||
async getTips(): Promise<{ content: string; title: string | null }> {
|
||||
// 旧前端读的是 tip_key=0 那条
|
||||
const row = await this.prisma.courseTip.findUnique({ where: { tip_key: 0 } });
|
||||
const row = await this.prisma.active.courseTip.findUnique({ where: { tip_key: 0 } });
|
||||
return { content: row?.content ?? '', title: row?.title ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +36,25 @@ export class OrdersService {
|
||||
throw AppError.badRequest('购买数量不合法');
|
||||
}
|
||||
|
||||
const course = await this.prisma.course.findUnique({ where: { code: courseCode } });
|
||||
// 购买的硬性前置条件:用户必须主动使用过登录功能。
|
||||
// 静默登录只建立身份,不算「登录」,所以这里查的是数据库里的标记而不是 token。
|
||||
const buyer = await this.prisma.active.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { is_login: true },
|
||||
});
|
||||
if (!buyer || buyer.is_login !== 1) {
|
||||
throw AppError.forbidden('请先使用微信登录后再购买');
|
||||
}
|
||||
|
||||
const course = await this.prisma.active.course.findUnique({ where: { code: courseCode } });
|
||||
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
||||
|
||||
const owned = await this.prisma.entitlement.findUnique({
|
||||
const owned = await this.prisma.active.entitlement.findUnique({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
||||
});
|
||||
if (owned) throw AppError.conflict('你已拥有该课程');
|
||||
|
||||
const product = await this.prisma.product.findFirst({
|
||||
const product = await this.prisma.active.product.findFirst({
|
||||
where: { course_id: course.id, status: 1 },
|
||||
});
|
||||
if (!product) throw AppError.notFound('该课程尚未配置支付商品');
|
||||
@@ -52,7 +62,7 @@ export class OrdersService {
|
||||
const outTradeNo = this.newOutTradeNo();
|
||||
const amountFen = product.price_fen * quantity;
|
||||
|
||||
await this.prisma.order.create({
|
||||
await this.prisma.active.order.create({
|
||||
data: {
|
||||
out_trade_no: outTradeNo,
|
||||
user_id: userId,
|
||||
@@ -91,7 +101,7 @@ export class OrdersService {
|
||||
|
||||
/** 查询自己的订单状态。前端支付返回后用它确认是否已发货。 */
|
||||
async status(userId: number, outTradeNo: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
const order = await this.prisma.active.order.findUnique({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
include: { course: { select: { code: true, title: true } } },
|
||||
});
|
||||
@@ -113,7 +123,7 @@ export class OrdersService {
|
||||
|
||||
/** 我的订单列表。 */
|
||||
async list(userId: number) {
|
||||
const rows = await this.prisma.order.findMany({
|
||||
const rows = await this.prisma.active.order.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 100,
|
||||
|
||||
@@ -58,7 +58,7 @@ export class PaymentsService {
|
||||
const openid = notify.OpenId ?? '';
|
||||
if (!outTradeNo || !wxOrderId) throw new Error('推送缺少 OutTradeNo 或 MchOrderNo');
|
||||
|
||||
const order = await this.prisma.order.findUnique({
|
||||
const order = await this.prisma.active.order.findUnique({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
include: { user: true },
|
||||
});
|
||||
@@ -89,7 +89,7 @@ export class PaymentsService {
|
||||
const parsed = this.parser.parse(rawXml) as { xml?: { OutTradeNo?: string } };
|
||||
const outTradeNo = parsed.xml?.OutTradeNo ?? '';
|
||||
if (outTradeNo) {
|
||||
await this.prisma.order.updateMany({
|
||||
await this.prisma.active.order.updateMany({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
|
||||
});
|
||||
@@ -109,7 +109,7 @@ export class PaymentsService {
|
||||
async reconcilePendingOrders(): Promise<number> {
|
||||
if (!this.wxpay.enabled) return 0;
|
||||
|
||||
const pending = await this.prisma.order.findMany({
|
||||
const pending = await this.prisma.active.order.findMany({
|
||||
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
|
||||
include: { user: true },
|
||||
orderBy: { created_at: 'asc' },
|
||||
@@ -144,7 +144,7 @@ export class PaymentsService {
|
||||
|
||||
/** 取用户 sessionKey(明文)。不存在时提示重新登录。 */
|
||||
async requireSessionKey(userId: number): Promise<string> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const user = await this.prisma.active.user.findUnique({ where: { id: userId } });
|
||||
if (!user?.wx_session_key) throw AppError.unauthorized('请重新进入小程序后再试');
|
||||
const key = decryptSecret(user.wx_session_key, this.cfg.SESSION_KEY_ENC_SECRET);
|
||||
if (!key) throw new AppError(ErrorCode.INTERNAL, 'sessionKey 解密失败', 500);
|
||||
@@ -160,8 +160,8 @@ export class PaymentsService {
|
||||
wxOrderId: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.order.update({
|
||||
await this.prisma.active.$transaction([
|
||||
this.prisma.active.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: OrderStatus.DELIVERED,
|
||||
@@ -170,7 +170,7 @@ export class PaymentsService {
|
||||
delivered_at: now,
|
||||
},
|
||||
}),
|
||||
this.prisma.entitlement.upsert({
|
||||
this.prisma.active.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
create: {
|
||||
user_id: userId,
|
||||
@@ -186,12 +186,12 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
private async logNotify(rawXml: string): Promise<number> {
|
||||
const row = await this.prisma.paymentNotifyLog.create({ data: { raw_body: rawXml } });
|
||||
const row = await this.prisma.active.paymentNotifyLog.create({ data: { raw_body: rawXml } });
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private async markNotify(id: number, processed: number, errMsg: string): Promise<void> {
|
||||
await this.prisma.paymentNotifyLog.update({
|
||||
await this.prisma.active.paymentNotifyLog.update({
|
||||
where: { id },
|
||||
data: { processed, err_msg: errMsg.slice(0, 255) },
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export class UsersService {
|
||||
) {}
|
||||
|
||||
async me(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const user = await this.prisma.active.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.status !== 1) throw AppError.unauthorized();
|
||||
|
||||
const base = this.cfg.STATIC_BASE_URL;
|
||||
@@ -24,6 +24,8 @@ export class UsersService {
|
||||
headiconUrl: user.headicon_id > 0 ? staticUrls.headicon(base, user.headicon_id) : null,
|
||||
registeredAt: user.registered_at,
|
||||
lastLoginAt: user.last_login_at,
|
||||
/** 1 = 用户主动使用过登录功能;购买前置条件 */
|
||||
isLogin: user.is_login,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,13 +53,13 @@ export class UsersService {
|
||||
|
||||
if (Object.keys(data).length === 0) throw AppError.badRequest('没有需要修改的内容');
|
||||
|
||||
await this.prisma.user.update({ where: { id: userId }, data });
|
||||
await this.prisma.active.user.update({ where: { id: userId }, data });
|
||||
return this.me(userId);
|
||||
}
|
||||
|
||||
/** 我的课程。 */
|
||||
async entitlements(userId: number) {
|
||||
const rows = await this.prisma.entitlement.findMany({
|
||||
const rows = await this.prisma.active.entitlement.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { granted_at: 'desc' },
|
||||
include: { course: true },
|
||||
@@ -76,10 +78,10 @@ export class UsersService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 头像总数取自 settings,缺省 50。 */
|
||||
/** 头像总数取自 settings。没配就是 0,不兜底。 */
|
||||
private async getHeadiconMax(): Promise<number> {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: 'profile.headicon_count' } });
|
||||
const row = await this.prisma.active.setting.findUnique({ where: { key: 'profile.headicon_count' } });
|
||||
const n = Number(row?.value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 50;
|
||||
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,140 @@
|
||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { DataSourceEnv } from '../common/admin-env';
|
||||
import { APP_CONFIG, type AppConfig } from '../config/config';
|
||||
|
||||
/** 当前数据源开关存放的配置键。 */
|
||||
export const DATA_SOURCE_SETTING_KEY = 'system.active_datasource';
|
||||
|
||||
export type { DataSourceEnv };
|
||||
|
||||
/** 开关同步间隔。改完最多 5 秒在所有实例生效。 */
|
||||
const REFRESH_INTERVAL_MS = 5000;
|
||||
|
||||
/**
|
||||
* 数据库连接管理。**刻意不继承 PrismaClient,而是持有连接实例。**
|
||||
*
|
||||
* 原因(踩过一次,会导致所有接口 500):
|
||||
* `PrismaClient` 的构造函数返回的是 **Proxy**,模型委托(user / setting / order …)
|
||||
* 只存在于那个 Proxy 上。若写成 `class PrismaService extends PrismaClient`,
|
||||
* 子类方法里的 `this` 指向的是**原始对象**,`return this` 拿到的对象上没有模型委托,
|
||||
* 于是 `this.prisma.active.setting` 变成 undefined,接口直接抛
|
||||
* `Cannot read properties of undefined (reading 'findMany')`。
|
||||
*
|
||||
* `new PrismaClient()` 出来的才是带委托的 Proxy,所以这里把它存成字段返回。
|
||||
*
|
||||
* 数据源开关存在**生产库的 settings 表**——这个开关本身不能存在被切换的那个库中,
|
||||
* 否则切过去之后就再也读不到它了。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
export class PrismaService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 延迟连接:即使数据库暂时不可用也允许进程启动,由具体请求暴露问题
|
||||
/** 生产库连接(也是管理员账号与开关本身的存放处)。 */
|
||||
readonly main: PrismaClient;
|
||||
|
||||
/** 开发库连接;未配置 DATABASE_URL_DEVELOP 时为 null。 */
|
||||
readonly dev: PrismaClient | null;
|
||||
|
||||
private current: DataSourceEnv = 'production';
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(@Inject(APP_CONFIG) cfg: AppConfig) {
|
||||
this.main = new PrismaClient();
|
||||
this.dev = cfg.DATABASE_URL_DEVELOP
|
||||
? new PrismaClient({ datasources: { db: { url: cfg.DATABASE_URL_DEVELOP } } })
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 是否配置了开发库。没配置时小程序数据源开关不可用。 */
|
||||
get hasDev(): boolean {
|
||||
return this.dev !== null;
|
||||
}
|
||||
|
||||
get activeEnv(): DataSourceEnv {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序当前使用的连接,由全局开关决定。
|
||||
* 小程序侧业务代码统一用 `this.prisma.active.xxx`。
|
||||
*/
|
||||
get active(): PrismaClient {
|
||||
return this.current === 'develop' && this.dev ? this.dev : this.main;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定数据源取连接。管理后台用它实现「各看各的」,
|
||||
* 与全局开关互不影响。
|
||||
*/
|
||||
clientFor(env: DataSourceEnv): PrismaClient {
|
||||
if (env === 'develop') {
|
||||
if (!this.dev) throw new Error('服务端未配置开发库(DATABASE_URL_DEVELOP)');
|
||||
return this.dev;
|
||||
}
|
||||
return this.main;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换**小程序**的数据源。写入生产库的开关,并立即在本进程生效。
|
||||
* 管理后台的「查看哪个库」不走这里,那是每个人各自的视角。
|
||||
*/
|
||||
async setMiniProgramEnv(env: DataSourceEnv): Promise<DataSourceEnv> {
|
||||
if (env === 'develop' && !this.dev) {
|
||||
throw new Error('服务端未配置开发库(DATABASE_URL_DEVELOP)');
|
||||
}
|
||||
await this.main.setting.upsert({
|
||||
where: { key: DATA_SOURCE_SETTING_KEY },
|
||||
create: { key: DATA_SOURCE_SETTING_KEY, value: env, remark: '小程序当前使用的数据源' },
|
||||
update: { value: env },
|
||||
});
|
||||
this.current = env;
|
||||
this.logger.log(`小程序数据源已切换为 ${env}`);
|
||||
return env;
|
||||
}
|
||||
|
||||
/** 从生产库读取开关。读失败时保持当前值,避免数据库抖动导致意外切库。 */
|
||||
private async refreshActiveEnv(): Promise<void> {
|
||||
try {
|
||||
await this.$connect();
|
||||
} catch (err) {
|
||||
this.logger.error(`数据库连接失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
const row = await this.main.setting.findUnique({ where: { key: DATA_SOURCE_SETTING_KEY } });
|
||||
const next: DataSourceEnv = row?.value === 'develop' && this.dev ? 'develop' : 'production';
|
||||
if (next !== this.current) {
|
||||
this.logger.log(`小程序数据源变更:${this.current} → ${next}`);
|
||||
this.current = next;
|
||||
}
|
||||
} catch {
|
||||
// 保持现状
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 延迟连接:数据库暂时不可用也允许进程启动,由具体请求暴露问题
|
||||
try {
|
||||
await this.main.$connect();
|
||||
} catch (err) {
|
||||
this.logger.error(`主库连接失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (this.dev) {
|
||||
try {
|
||||
await this.dev.$connect();
|
||||
this.logger.log('开发库已连接,可切换小程序数据源');
|
||||
} catch (err) {
|
||||
this.logger.error(`开发库连接失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.refreshActiveEnv();
|
||||
this.logger.log(`小程序当前数据源:${this.current}`);
|
||||
|
||||
this.timer = setInterval(() => void this.refreshActiveEnv(), REFRESH_INTERVAL_MS);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
await this.main.$disconnect();
|
||||
if (this.dev) await this.dev.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,12 @@ export function parseTupleList(payload: string): SqlValue[][] {
|
||||
|
||||
/** 取出某张表全部 INSERT 行。表不存在时返回空数组。 */
|
||||
export function readTable(sql: string, table: string): SqlValue[][] {
|
||||
const re = new RegExp('INSERT INTO `' + table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '` VALUES (.*?);\\n', 'gs');
|
||||
// 结尾同时兼容 LF 与 CRLF:Windows 上导出的 dump 是 CRLF,
|
||||
// 只写 \n 会全部匹配失败,表现为「所有表都是空数组」这种静默错误。
|
||||
const re = new RegExp(
|
||||
'INSERT INTO `' + table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '` VALUES (.*?);(?:\\r?\\n|$)',
|
||||
'gs',
|
||||
);
|
||||
const rows: SqlValue[][] = [];
|
||||
for (const m of sql.matchAll(re)) {
|
||||
rows.push(...parseTupleList(m[1]!));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDataset } from './build';
|
||||
import { DEFAULT_DUMP_PATH, loadLegacyDump } from './load';
|
||||
import { readTable } from './parser';
|
||||
import {
|
||||
HOME_COURSE_TITLE_MAP,
|
||||
SETTINGS_KEY_MAP,
|
||||
@@ -153,3 +154,21 @@ describe('buildDataset(真实旧库数据)', () => {
|
||||
expect(all).toContain('https://data.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTable 换行兼容', () => {
|
||||
const lf = "INSERT INTO `t` VALUES (1,'a');\n";
|
||||
const crlf = "INSERT INTO `t` VALUES (1,'a');\r\nINSERT INTO `t` VALUES (2,'b');\r\n";
|
||||
|
||||
it('LF 换行的 dump 能解析', () => {
|
||||
expect(readTable(lf, 't')).toEqual([[1, 'a']]);
|
||||
});
|
||||
|
||||
// 回归:只匹配 \n 时,Windows 导出的 CRLF dump 会全部读成空数组,
|
||||
// 迁移会静默产出空数据(真实踩过)
|
||||
it('CRLF 换行的 dump 能解析(Windows 导出的格式)', () => {
|
||||
expect(readTable(crlf, 't')).toEqual([
|
||||
[1, 'a'],
|
||||
[2, 'b'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,9 @@ export function transformUser(row: Row) {
|
||||
nickname: strOrNull(row.用户名)?.slice(0, 32) ?? null,
|
||||
headicon_id: int(row.HeadiconID),
|
||||
status: 1,
|
||||
// 旧表「是否登录」:'1' 表示用户主动使用过登录功能。新版静默登录不会改这一位,
|
||||
// 所以迁移时必须原样搬过来,否则老用户会被当成没登录过。
|
||||
is_login: str(row.是否登录) === '1' ? 1 : 0,
|
||||
registered_at: registeredAt ?? new Date(0),
|
||||
last_login_at: parseLegacyDate(row.最后登录日期),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user