# 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 ` ### 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/.`) - `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//quyun/.` 或 `tenants//...` 并同步改动: - 预签名上传:`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 查询改造点 + 前端配套改造清单。