# QuyUn 项目功能与数据库字典（用于多租户改造）

本文档基于当前仓库代码静态分析整理（`frontend/` + `backend/`），目标是帮助你将“单实例/单运营方（单用户后台）”改造为“多租户（多运营方）”。

---

## 1. 仓库结构与技术栈

### 1.1 目录结构

- `backend/`：Go 后端（同时包含少量 `package.json` 依赖，但核心为 Go 服务）
- `frontend/admin/`：后台管理端（Vue3 + Vite）
- `frontend/wechat/`：微信 H5 端（Vue3 + Vite）

### 1.2 关键技术栈

**后端**

- Web 框架：`gofiber/fiber/v3`
- DI / 代码生成：`go.ipao.vip/atom`（路由文件为 `routes.gen.go`）
- 数据库：PostgreSQL（`lib/pq`、`pgx`），迁移：`pressly/goose`
- SQL Builder/ORM：`go-jet/jet`（`backend/database/table/*`、`backend/app/model/*.gen.go`）
- 任务队列：`riverqueue/river`（依赖表：`river_job` 等）
- 对象存储：阿里云 OSS（签名上传、签名下载、删除）
- 微信：网页授权（OAuth）、JS-SDK 签名、支付/退款回调（微信支付 v3）
- 监控链路：OpenTelemetry（存在 provider 但本文档不展开）

**前端**

- Vue 3 + Vite
- Admin：PrimeVue + Tailwind（并用少量 DaisyUI）
- WeChat H5：Tailwind + `weixin-js-sdk` + `xgplayer`（视频播放）

---

## 2. 运行时架构与路由总览

### 2.1 HTTP 服务入口与静态资源托管

后端启动入口：`backend/main.go`，命令为 `serve`（`backend/app/service/http/http.go`）。

HTTP 路由前缀固定为 `/v1`：

- API：统一挂载在 `/v1/*`
- 静态资源：
  - 后台管理端：`GET /admin*` → `App.DistAdmin` 指向的 `frontend/admin/dist`
  - 微信端：`GET /*` → `App.DistWeChat` 指向的 `frontend/wechat/dist`

这意味着项目典型部署形态是：**一个后端进程同时提供 API + 两套前端静态文件**。

### 2.2 认证/授权（非常影响多租户改造）

#### 2.2.1 微信端用户认证（Cookie Token + 重定向）

中间件：`backend/app/middlewares/mid_auth.go`

- 受保护范围：除以下路径外，均要求登录
  - `/v1/pay/callback/*`（支付回调免登录）
  - `/v1/auth/*`（授权流程免登录）
  - `/v1/admin/*`（后台 API 走另一套鉴权）
- 机制：
  - 读取 Cookie：`token`
  - 解析 JWT（`providers/jwt`），拿到 `UserID`
  - 查询用户（`users` 表）
  - 校验 `users.auth_token.expires_at`（微信 access token 过期则强制重登）
  - 未登录时：非 XHR 请求会重定向到 `/v1/auth/wechat?redirect=当前完整URL`；XHR 请求直接返回 401
- 认证通过后写入：`ctx.Locals("user", *model.Users)`

> 重要：当前系统的“用户”是**微信 OpenID 用户**，并且 `users.open_id` 在 DB 层为 `UNIQUE`（单租户假设）。

#### 2.2.2 后台管理端鉴权（硬编码账号 + 固定 UserID）

登录接口：`POST /v1/admin/auth`（`backend/app/http/admin/auth.go`）

- 用户名/密码硬编码在后端：`pl.yang` / `Xixi@0202`
- 登录成功签发 JWT，`UserID` 固定写死为 `-20140202`

中间件：`backend/app/middlewares/mid_auth_admin.go`

- 对 `/v1/admin/*` 生效（除 `/v1/admin/auth`）
- 从 Header `Authorization` 或 Query `token` 读取 JWT
- 校验 `jwt.UserID == -20140202`，否则 403

> 重要：这套后台体系是“单运营方/单管理员”的实现方式，多租户必须重构为“租户维度的后台账号体系”。

### 2.3 API 路由清单（从 `routes.gen.go` 汇总）

#### 2.3.1 微信端（非 admin）API

路由文件：`backend/app/http/routes.gen.go`

- `GET /v1/auth/wechat`：发起微信网页授权（重定向到微信授权页）
- `GET /v1/auth/login`：微信回调，换取 openid + 用户信息，写入 Cookie `token`，再重定向回 `redirect`
- `GET /v1/posts`：曲谱列表（仅已发布），支持 keyword 搜索，返回是否已购买、封面图 OSS 签名 URL
- `GET /v1/posts/:id/show`：曲谱详情（仅已发布），返回购买态、封面图 URL
- `GET /v1/posts/:id/play`：获取可播放视频 URL（未购买返回“短视频/试听”，已购买返回“完整版”）
- `GET /v1/posts/mine`：我的已购曲谱列表
- `POST /v1/posts/:id/buy`：购买（当前实现主路径为“余额支付”）
- `GET /v1/users/profile`：当前用户资料 + 余额
- `PUT /v1/users/username`：修改用户名（最多 12 字符）
- `GET /v1/wechats/js-sdk`：获取 JS-SDK 签名配置
- `POST /v1/pay/callback/:channel`：支付/退款回调入口（免登录），内部将回调入队列异步处理

#### 2.3.2 后台管理端（admin）API

路由文件：`backend/app/http/admin/routes.gen.go`

- `POST /v1/admin/auth`：后台登录（硬编码账号），返回 Token

媒体库：

- `GET /v1/admin/medias`：媒体列表（分页 + keyword）
- `GET /v1/admin/medias/:id`：媒体预览（302 跳转到 OSS 签名 URL）
- `DELETE /v1/admin/medias/:id`：删除 OSS 文件 + DB 记录

上传：

- `GET /v1/admin/uploads/pre-uploaded-check/:md5.:ext?mime=...`：按 md5 判断是否已存在；不存在则返回 OSS 预签名 PUT URL
- `POST /v1/admin/uploads/post-uploaded-action`：上传完成回调：写入 `medias`；若为 `video/mp4` 触发下载/转码类任务

曲谱：

- `GET /v1/admin/posts`：曲谱列表（分页 + keyword），附带销量（购买次数）
- `POST /v1/admin/posts`：创建曲谱（包含封面 head_images 与媒体 assets）
- `PUT /v1/admin/posts/:id`：编辑曲谱
- `DELETE /v1/admin/posts/:id`：硬删除
- `GET /v1/admin/posts/:id`：曲谱详情（附带 medias 列表）
- `POST /v1/admin/posts/:id/send-to/:userId`：赠送曲谱（写入 `user_posts`，price=-1）

用户：

- `GET /v1/admin/users`：用户列表（分页 + keyword）
- `GET /v1/admin/users/:id`：用户详情
- `GET /v1/admin/users/:id/articles`：用户已购曲谱（分页）
- `POST /v1/admin/users/:id/balance`：后台给用户充值余额（单位：分）

订单：

- `GET /v1/admin/orders`：订单列表（分页 + 按订单号/用户过滤），返回附带 `post_title`、`username`
- `POST /v1/admin/orders/:id/refund`：退款（余额支付直接退余额 + 撤销权限；微信支付走退款 API 并等待回调）

统计：

- `GET /v1/admin/statistics`：仪表盘统计（草稿/已发布、媒体数、已完成订单数、用户数、已完成订单金额汇总）

---

## 3. 前端功能梳理

### 3.1 Admin（`frontend/admin`）

**路由（`frontend/admin/src/router.js`，base 为 `/admin/`）**

- `/`：Dashboard（统计）
- `/medias`：媒体库列表（预览/下载/删除）
- `/medias/uploads`：媒体上传（MD5 去重 + OSS 预签名上传 + 上传后回调）
- `/posts`：曲谱列表（创建/编辑/删除/赠送）
- `/posts/create`：创建曲谱（选择封面图 ≤3、选择媒体资源、设置价格/折扣/状态）
- `/posts/edit/:id`：编辑曲谱
- `/users`：用户列表（可充值）
- `/users/:id`：用户详情 + 已购列表
- `/orders`：订单列表 + 退款
- `/login`：后台登录页

**API 调用方式**

- Axios baseURL：`/v1`（`frontend/admin/src/api/httpClient.js`）
- Token：本地存储 `__token`，请求头写入 `Authorization: Bearer <token>`

### 3.2 WeChat H5（`frontend/wechat`）

**路由（`frontend/wechat/src/router.js`）**

- `/`：曲谱列表（无限滚动 + 搜索）
- `/posts/:id`：曲谱详情（视频播放 + 购买按钮 + 分享）
- `/purchased`：已购列表（顶部固定播放器 + 列表点播）
- `/profile`：个人信息 + 余额

**API 调用方式**

- Axios baseURL：`/v1`，并且 `withCredentials: true`（携带 Cookie `token`）
- 401 时前端自动跳转 `/v1/auth/wechat?redirect=<当前URL>`（`frontend/wechat/src/api/client.js`）

**注意：接口路径不一致**

`frontend/wechat/src/api/userApi.js` 的更新接口调用 `PUT /users/profile`，但后端实际是 `PUT /v1/users/username`。

---

## 4. 后端业务流程（核心用例）

### 4.1 微信登录/注册流程

1) 前端/中间件发现未登录 → 重定向到 `/v1/auth/wechat?redirect=...`
2) `/v1/auth/wechat` 生成微信授权 URL（回调到 `/v1/auth/login`，并透传 redirect）
3) `/v1/auth/login`：
   - `code` 换取 `openid` 与 `access_token`
   - 获取“稳定版 token”（stable_token）
   - 拉取用户信息（失败则生成随机昵称/头像）
   - `users` 表按 `open_id` 查询，不存在则创建；存在则更新用户名/头像/metas/auth_token
   - 生成 JWT（claims 里只使用了 `UserID`），写 Cookie：`token`

### 4.2 曲谱内容模型与呈现

曲谱（`posts`）本质是一个“商品/内容条目”，它的媒体资产在 `posts.assets` 里记录（JSON 数组），每个 asset 指向 `medias.id`。

- 封面图：`posts.head_images`（媒体 ID 数组）→ 后端转为 OSS 签名 URL 数组返回
- 播放：`GET /v1/posts/:id/play`
  - 若未购买：`preview=true`
  - 在 `posts.assets` 中选择 `Type=="video/mp4"` 且 `asset.Metas.Short == preview` 的媒体作为播放源
  - 生成带过期时间的 OSS 签名 URL（预览与正式片过期时间不同）

### 4.3 购买/订单/权限授予

核心表：

- `orders`：订单记录（支付状态 + 支付/退款回调内容写入 `orders.meta`）
- `user_posts`：用户与曲谱的授予关系（购买、赠送）

购买接口：`POST /v1/posts/:id/buy`

- 先检查 `user_posts` 是否已有记录（避免重复购买）
- 创建 `orders`（状态 pending）
- 当前主路径：余额足够则走余额支付，写入 `orders.meta.cost_balance` 并投递 `BalancePayNotify` 任务，随后返回一个特殊响应（`appId: "balance"`）
- 代码中存在“余额不足时走微信 JSAPI 支付”的逻辑，但当前版本在余额不足时提前 `return`，导致后续微信支付逻辑不可达（这属于现状问题，改多租户时建议一并梳理修正）。

### 4.4 支付/退款回调（异步任务）

回调入口：`POST /v1/pay/callback/:channel`

- `TRANSACTION.SUCCESS` → 入队 `WechatPayNotify`
- `REFUND.*` → 入队 `WechatRefundNotify`

任务处理（均运行在 River 队列里）：

- `WechatPayNotify`：校验金额、更新订单状态为 completed、扣减余额（若 cost_balance>0）、写入 `user_posts` 授权
- `BalancePayNotify`：余额支付完成，更新订单状态 completed、扣减余额、写入 `user_posts`
- `WechatRefundNotify`：订单状态按退款状态更新；退款成功则撤销 `user_posts`

---

## 5. 数据库字典（PostgreSQL）

### 5.1 迁移与生成代码来源

- Goose 迁移：`backend/database/migrations/*.sql`
- Jet 表定义（由 DB 反射/生成）：`backend/database/table/*.go`
- Model（CRUD + 部分自定义逻辑）：`backend/app/model/*`
- 类型映射（jsonb → struct、int2 → enum）：`backend/database/transform.yaml` + `backend/database/fields/*`

### 5.2 业务表一览

| 表 | 作用 |
|---|---|
| `users` | 微信用户主体 + 余额 + 微信 token 信息 |
| `posts` | 曲谱/内容商品 |
| `medias` | 媒体资源（OSS 路径、hash 去重、媒体元信息） |
| `user_posts` | 用户-曲谱的授予关系（购买/赠送） |
| `orders` | 订单记录（支付/退款状态与元数据） |

此外还有 `migrations`（Goose 使用）以及 River 队列表（`river_job` 等，属于基础设施表）。

### 5.3 枚举（int2/int）取值定义

**posts.status（`fields.PostStatus`）**

- `0`：draft
- `1`：published

**users.status（`fields.UserStatus`）**

- `0`：ok
- `1`：banned
- `2`：blocked

**orders.status（`fields.OrderStatus`）**

- `0`：pending
- `1`：paid（当前代码主要使用 `pending/completed/refund_*`，该值可能历史遗留）
- `2`：refund_success
- `3`：refund_closed
- `4`：refund_processing
- `5`：refund_abnormal
- `6`：cancelled
- `7`：completed

### 5.4 `users` 表

迁移：`backend/database/migrations/20250322103119_create_users.sql`、`20250430014015_alter_user.sql`、`20250512113213_alter_user.sql`

字段（按迁移语义整理）：

- `id int8`：主键（序列起始被设置为 1000）
- `created_at timestamp not null default now()`
- `updated_at timestamp not null default now()`
- `deleted_at timestamp null`：软删除
- `status int2 not null default 0`：见 `UserStatus`
- `open_id varchar(128) not null unique`：微信 OpenID（单租户假设的关键约束）
- `username varchar(128) not null`
- `avatar text null`
- `metas jsonb not null default '{}'`：用户资料（见下方 JSON 结构）
- `auth_token jsonb not null default '{}'`：微信 token（见下方 JSON 结构）
- `balance int8 not null default 0`：余额（单位：分）

`users.metas`（`fields.UserMetas`）：

- `city/country/province`：地区
- `head_image_url`：头像
- `nickname`：昵称
- `sex`：性别
- `privilege[]`：特权列表

`users.auth_token`（`fields.UserAuthToken`）：

- `stable_access_token`、`stable_expires_at`
- `access_token`、`expires_at`
- `refresh_token`、`scope`、`is_snapshotuser`

### 5.5 `medias` 表

迁移：`backend/database/migrations/20250321112535_create_medias.sql`

- `id int8`：主键
- `created_at timestamp not null default now()`
- `name varchar(255) not null default ''`：原始文件名
- `mime_type varchar(128) not null default ''`
- `size int8 not null default 0`
- `path varchar(255) not null default ''`：OSS 对象 key（实际业务中通常为 `quyun/<md5>.<ext>`）
- `metas jsonb not null default '{}'`：媒体元信息
- `hash varchar(64) not null default ''`：上传 md5（用于去重）

`medias.metas`（`fields.MediaMetas`）：

- `parent_hash`：关联源文件（例如转码后的视频/封面与原视频关联）
- `short bool`：是否为“试听/预览版”
- `duration int64`：时长（秒）

### 5.6 `posts` 表

迁移：`backend/database/migrations/20250322100215_create_posts.sql`

- `id int8`：主键
- `created_at timestamp not null default now()`
- `updated_at timestamp not null default now()`
- `deleted_at timestamp null`：软删除
- `status int2 not null default 0`：见 `PostStatus`
- `title varchar(128) not null`
- `head_images jsonb not null default '[]'`：封面媒体 ID 数组（int64）
- `description varchar(256) not null`：简介（admin 表单里叫 introduction）
- `content text not null`：正文（微信端详情里展示在 `content`）
- `price int8 not null default 0`：单位：分
- `discount int2 not null default 100`：折扣百分比（0~100）
- `views int8 not null default 0`
- `likes int8 not null default 0`
- `tags jsonb default '{}'`：标签（代码期望是 `[]string`）
- `assets jsonb default '{}'`：媒体资产（代码期望是 `[]MediaAsset`）

`posts.assets`（`fields.MediaAsset[]`）元素结构：

- `type string`：mime_type（例如 `video/mp4`）
- `media int64`：对应 `medias.id`
- `metas MediaMetas`：冗余一份媒体 metas（用于直接判断 short/duration）
- `mark *string`：预留字段（当前业务未见强依赖）

> 注意：迁移里 `tags/assets` 默认值是 `{}`（object），但代码将其当作数组解析。真实运行依赖于写入时覆盖该字段；多租户改造时建议顺手把默认值修正为 `[]` 并清洗历史数据。

### 5.7 `user_posts` 表

迁移：`backend/database/migrations/20250322103243_create_user_posts.sql`

- `id int8`：主键
- `created_at timestamp not null default now()`
- `updated_at timestamp not null default now()`
- `user_id int8 not null`
- `post_id int8 not null`
- `price int8 not null`：购买/赠送时记录（赠送场景 `price=-1`）

> 注意：无外键、无唯一约束（同一用户重复 post 的防重由代码实现）；多租户/一致性增强时建议加唯一索引与外键。

### 5.8 `orders` 表

迁移：`backend/database/migrations/20250410130530_create_orders.sql`

- `id int8`：主键
- `created_at timestamp not null default now()`
- `updated_at timestamp not null default now()`
- `order_no varchar(64) not null`：系统订单号（当前用时间戳字符串）
- `sub_order_no varchar(64) not null default ''`：子订单号（当前等于 order_no）
- `transaction_id varchar(64) not null default ''`：微信支付交易号
- `refund_transaction_id varchar(64) not null default ''`：微信退款单号
- `price int8 not null default 0`：单位：分
- `discount int2 not null default 100`
- `currency varchar(10) not null default 'CNY'`
- `payment_method varchar(50) not null default 'wechatpay'`：实际可能为 `balance` 或微信回调的 trade_type
- `post_id int8 not null`
- `user_id int8 not null`
- `status int2 not null`：见 `OrderStatus`
- `meta jsonb not null default '{}'`

`orders.meta`（`fields.OrderMeta`）：

- `pay_notify`：微信支付回调解密内容（结构体较大）
- `refund_resp`：发起退款 API 响应
- `refund_notify`：微信退款回调解密内容
- `cost_balance int64`：混合支付时使用余额金额（单位：分）

---

## 6. 单租户假设与“多租户化”切入点清单

### 6.1 当前显式/隐式单租户点

- DB 约束：`users.open_id UNIQUE`（同一 openid 只能存在一条用户记录）
- Admin 登录：硬编码账号/密码；JWT `UserID` 写死 `-20140202`（没有“租户管理员表”）
- OSS 路径：`providers/ali/oss_client.go` 固定把对象放到 `quyun/` 前缀；上传模块也固定 `UPLOAD_PATH = "quyun"`
- 业务表均缺少租户字段：`posts/medias/orders/user_posts/users` 都没有 `tenant_id`
- 业务查询没有任何“租户过滤”（例如 `admin/posts` 列表直接全表扫描）

### 6.2 值得注意：JWT Claims 已预留租户字段

`backend/providers/jwt/jwt.go` 的 `BaseClaims` 已包含：

- `tenant string`
- `tenant_id int64`

但当前业务仅用到 `user_id`，并未实现“解析租户上下文 → 写入 claims → 数据隔离”。

---

## 7. 多租户改造建议（面向本项目的可落地方案）

下面给的是“从当前代码出发”的推荐路线，优先保证：改动面可控、隔离强、后续可扩展。

### 7.1 明确租户边界与识别方式（建议先定这个）

对该项目而言，最常见的租户识别方式：

1) **按域名**（推荐）：`tenantA.example.com`、`tenantB.example.com`  
2) **按路径前缀**：`/t/:tenant/...`（会影响前端路由与静态文件托管）  
3) **按请求头**：`X-Tenant-ID`（适合 B 端 API，但微信 H5 场景常常不便）  
4) **按二维码/推广链接参数**：首次进入携带 tenant，再写入 cookie/localStorage（需要防篡改与校验）

微信场景强依赖“回跳 redirect”，因此建议：**域名识别租户** 或 “二维码/链接识别租户 + 服务端签名 state 防篡改”。

### 7.2 数据库层：新增租户表 + 为业务表补 `tenant_id`

建议新增：

- `tenants`：`id`、`code`、`name`、`status`、`created_at`、`updated_at`、配置项（可 JSONB）
- `admin_users`（或 `tenant_users`）：租户后台账号体系（账号/密码 hash/角色/tenant_id）

为现有业务表增加：

- `users.tenant_id`
- `posts.tenant_id`
- `medias.tenant_id`
- `orders.tenant_id`
- `user_posts.tenant_id`

并建立必要约束/索引（至少）：

- `users`: `UNIQUE(tenant_id, open_id)`（替代当前全局唯一）
- `posts`: `(tenant_id, id)` 常用过滤索引；如有需要加 `(tenant_id, status, deleted_at)`
- `medias`: `UNIQUE(tenant_id, hash)`（上传去重在租户内进行）
- `orders`: `UNIQUE(tenant_id, order_no)`（避免多租户下时间戳订单号冲突）
- `user_posts`: `UNIQUE(tenant_id, user_id, post_id)`（彻底防重）

### 7.3 应用层：租户上下文注入与强制过滤

建议新增一个最先执行的 middleware：

- 解析租户（host/path/header）
- 写入 `ctx.Locals("tenant", tenant)`（包含 `tenant_id`）
- 后续所有 model 查询都必须带 tenant 条件

落点示例（按当前代码组织方式）：

- `middlewares` 新增 `TenantResolve` 并在 `Group("v1").Use(...)` 的最前面插入
- `Auth` 中间件里查用户时改为：`WHERE tenant_id = ? AND id = ?`
- 微信登录 `GetUserByOpenIDOrCreate` 改为 tenant 维度：`WHERE tenant_id=? AND open_id=?`
- Admin 路由统一加 tenant 过滤（例如 `/admin/posts` 只看当前租户）

### 7.4 OAuth/支付：租户与微信配置关系

必须提前决策：**每个租户是否独立微信 AppID/支付商户**？

- 若每租户独立：`tenants` 里存微信配置（AppID/AppSecret/MchID/ApiV3Key/...），并按租户动态初始化 client（当前 providers 是“全局单例配置”）
- 若共享同一个公众号/商户：仍然要做数据隔离，但需要在订单号、回调处理里带上 tenant 信息（例如 `order_no` 编码 tenant 前缀，或在 `meta` 中存 tenant_id 并保证可反查）

### 7.5 OSS：对象 Key 增加租户前缀

当前固定前缀 `quyun/`。多租户建议改为：

- `tenants/<tenant_code>/quyun/<md5>.<ext>` 或 `tenants/<tenant_id>/...`

并同步改动：

- 预签名上传：`PreUploadCheck` 返回的 Key
- 上传完成回调写入 `medias.path`
- 后续签名下载/删除统一使用新 path

### 7.6 队列任务：必须携带 tenant 上下文

目前任务参数里不包含 tenant。多租户后建议：

- Job Args 增加 `TenantID`（或可解析的 `TenantCode`）
- Worker 执行时所有 DB 查询都加 tenant 过滤
- OSS 操作也按 tenant 前缀

否则会出现：回调/任务在多租户环境下“写错库、扣错余额、发错权限”的高危问题。

---

## 8. 建议你下一步提供的信息（能让多租户设计更准确）

为了把“多租户模式”落到你期望的形态，建议你确认并告诉我：

1) 租户识别方式：域名 / 路径前缀 / header / 邀请链接？
2) 微信与支付配置：租户独立还是共享？
3) 租户隔离级别：仅逻辑隔离（tenant_id）还是需要“库/Schema 隔离”？
4) 你期望的后台账号体系：每租户多个管理员？是否需要角色权限？

确认后我可以按本仓库的结构直接给出：迁移脚本 + middleware + model 查询改造点 + 前端配套改造清单。
