22 KiB
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
- 读取 Cookie:
- 认证通过后写入:
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或 Querytoken读取 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 + 用户信息,写入 Cookietoken,再重定向回redirectGET /v1/posts:曲谱列表(仅已发布),支持 keyword 搜索,返回是否已购买、封面图 OSS 签名 URLGET /v1/posts/:id/show:曲谱详情(仅已发布),返回购买态、封面图 URLGET /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 URLPOST /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、usernamePOST /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(携带 Cookietoken) - 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 微信登录/注册流程
- 前端/中间件发现未登录 → 重定向到
/v1/auth/wechat?redirect=... /v1/auth/wechat生成微信授权 URL(回调到/v1/auth/login,并透传 redirect)/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→ 入队WechatPayNotifyREFUND.*→ 入队WechatRefundNotify
任务处理(均运行在 River 队列里):
WechatPayNotify:校验金额、更新订单状态为 completed、扣减余额(若 cost_balance>0)、写入user_posts授权BalancePayNotify:余额支付完成,更新订单状态 completed、扣减余额、写入user_postsWechatRefundNotify:订单状态按退款状态更新;退款成功则撤销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:draft1:published
users.status(fields.UserStatus)
0:ok1:banned2:blocked
orders.status(fields.OrderStatus)
0:pending1:paid(当前代码主要使用pending/completed/refund_*,该值可能历史遗留)2:refund_success3:refund_closed4:refund_processing5:refund_abnormal6:cancelled7: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:见UserStatusopen_id varchar(128) not null unique:微信 OpenID(单租户假设的关键约束)username varchar(128) not nullavatar text nullmetas 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_ataccess_token、expires_atrefresh_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 0path 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:见PostStatustitle varchar(128) not nullhead_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 0likes int8 not null default 0tags jsonb default '{}':标签(代码期望是[]string)assets jsonb default '{}':媒体资产(代码期望是[]MediaAsset)
posts.assets(fields.MediaAsset[])元素结构:
type string:mime_type(例如video/mp4)media int64:对应medias.idmetas 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 nullpost_id int8 not nullprice 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 100currency varchar(10) not null default 'CNY'payment_method varchar(50) not null default 'wechatpay':实际可能为balance或微信回调的 trade_typepost_id int8 not nulluser_id int8 not nullstatus int2 not null:见OrderStatusmeta 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 stringtenant_id int64
但当前业务仅用到 user_id,并未实现“解析租户上下文 → 写入 claims → 数据隔离”。
7. 多租户改造建议(面向本项目的可落地方案)
下面给的是“从当前代码出发”的推荐路线,优先保证:改动面可控、隔离强、后续可扩展。
7.1 明确租户边界与识别方式(建议先定这个)
对该项目而言,最常见的租户识别方式:
- 按域名(推荐):
tenantA.example.com、tenantB.example.com - 按路径前缀:
/t/:tenant/...(会影响前端路由与静态文件托管) - 按请求头:
X-Tenant-ID(适合 B 端 API,但微信 H5 场景常常不便) - 按二维码/推广链接参数:首次进入携带 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_idposts.tenant_idmedias.tenant_idorders.tenant_iduser_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. 建议你下一步提供的信息(能让多租户设计更准确)
为了把“多租户模式”落到你期望的形态,建议你确认并告诉我:
- 租户识别方式:域名 / 路径前缀 / header / 邀请链接?
- 微信与支付配置:租户独立还是共享?
- 租户隔离级别:仅逻辑隔离(tenant_id)还是需要“库/Schema 隔离”?
- 你期望的后台账号体系:每租户多个管理员?是否需要角色权限?
确认后我可以按本仓库的结构直接给出:迁移脚本 + middleware + model 查询改造点 + 前端配套改造清单。