This commit is contained in:
2025-12-15 17:55:32 +08:00
commit 28ab17324d
170 changed files with 18373 additions and 0 deletions

248
specs/API.md Normal file
View File

@@ -0,0 +1,248 @@
# 新项目 API 规格(/t/:tenant_code/v1
## 0. 通用约定
### 0.1 Base URL 与租户
- Base`/t/:tenant_code/v1`
- `tenant_code` 校验:服务端对路径段做 `lower()` 后校验 `^[a-z0-9_-]+$`,并查表确认租户存在且启用
### 0.2 认证
- WeChat H5Cookie 会话(例如 `token`),请求需携带 `withCredentials`
- Admin`Authorization: Bearer <token>`
### 0.3 响应
- 成功:`200`(或 201/204JSON
- 业务错误:`400`(含错误信息)
- 未登录:`401`
- 无权限:`403`
- 不存在:`404`
---
## 1. WeChat OAuth
### 1.1 发起授权
`GET /auth/wechat?redirect=<url>`
- 行为302 跳转到微信授权 URL回调为 `/t/:tenant_code/v1/auth/login`
### 1.2 授权回调
`GET /auth/login?code=<code>&state=<state>&redirect=<url>`
- 行为:
- 获取 openid 与用户资料
- `(tenant_id, open_id)` 获取或创建 `users`
- 写入 Cookie 会话
- 302 回跳 `redirect`
---
## 2. WeChat H5 内容与用户
### 2.1 曲谱列表(仅已发布)
`GET /posts`
Query
- `page`(默认 1
- `limit`(默认 10
- `keyword`(可选)
Response示例结构
```json
{
"items": [
{
"id": 1,
"title": "标题",
"description": "简介",
"price": 19900,
"discount": 80,
"views": 10,
"likes": 0,
"tags": ["tag"],
"head_images": ["https://signed-url..."],
"bought": false,
"recharge_wechat": "联系微信(可选)"
}
],
"total": 123,
"page": 1,
"limit": 10
}
```
### 2.2 曲谱详情(仅已发布)
`GET /posts/:id/show`
Response`PostItem`,额外含 `content`
### 2.3 获取播放 URL
`GET /posts/:id/play`
Response
```json
{ "url": "https://signed-url..." }
```
规则:未购买返回 `metas.short=true` 的视频 URL已购买返回 `metas.short=false` 的视频 URL。
### 2.4 我的已购
`GET /posts/mine`
Query`page``limit``keyword`(可选)
Response同分页结构
### 2.5 余额购买
`POST /posts/:id/buy`
Response余额支付成功
- 可直接返回 `{ "ok": true }` 或返回订单信息(由你最终 UI/交互决定)
错误:
- 余额不足:`400`message 建议为“余额不足,请联系管理员充值”
- 已购买:`400`
### 2.6 用户资料
`GET /users/profile`
Response
```json
{ "id": 1001, "created_at": "2025-01-01T00:00:00Z", "username": "xx", "avatar": "url", "balance": 10000 }
```
### 2.7 修改用户名
`PUT /users/username`
Body
```json
{ "username": "新昵称" }
```
约束trim 后不能为空;最大 12 个字符(按 rune 计)。
### 2.8 JS-SDK 配置
`GET /wechats/js-sdk?url=<current_page_url_without_hash>`
ResponseJS-SDK 签名数据(结构按前端 `weixin-js-sdk` 需要输出)
---
## 3. Admin租户后台
### 3.1 登录
`POST /admin/auth`
Body
```json
{ "username": "admin", "password": "******" }
```
Response
```json
{ "token": "..." }
```
### 3.2 仪表盘统计
`GET /admin/statistics`
Response
```json
{
"post_draft": 0,
"post_published": 0,
"media": 0,
"order": 0,
"user": 0,
"amount": 0
}
```
### 3.3 媒体库
`GET /admin/medias?page=&limit=&keyword=`
`GET /admin/medias/:id`302 跳转到 OSS 签名 URL
`DELETE /admin/medias/:id`:删除 OSS 对象并软删/删 DB 记录(最终由你决定)
### 3.4 上传(预签名)
`GET /admin/uploads/pre-uploaded-check/:md5.:ext?mime=<mime>`
Response
```json
{ "exists": false, "pre_sign": { "...": "..." } }
```
说明:
- `md5` 在租户内去重
- OSS Key`quyun/<tenant_uuid>/<md5>.<ext>`
`POST /admin/uploads/post-uploaded-action`
Body
```json
{ "originalName": "a.mp4", "md5": "...", "mimeType": "video/mp4", "size": 123 }
```
### 3.5 曲谱管理
`GET /admin/posts?page=&limit=&keyword=`
`POST /admin/posts``PUT /admin/posts/:id` Body示例
```json
{
"title": "标题",
"head_images": [1,2,3],
"price": 19900,
"discount": 80,
"introduction": "简介",
"content": "正文",
"status": 1,
"medias": [10,11,12]
}
```
`GET /admin/posts/:id`:返回曲谱 + medias 列表
`DELETE /admin/posts/:id`
`POST /admin/posts/:id/send-to/:userId`:赠送曲谱(写入授权记录)
### 3.6 用户管理
`GET /admin/users?page=&limit=&keyword=`
`GET /admin/users/:id`
`GET /admin/users/:id/articles?page=&limit=`
`POST /admin/users/:id/balance`
Body
```json
{ "balance": 10000 }
```
### 3.7 订单管理
`GET /admin/orders?page=&limit=&order_number=&user_id=`
`POST /admin/orders/:id/refund`:仅余额订单可退款

151
specs/DB.sql Normal file
View File

@@ -0,0 +1,151 @@
-- 新项目数据库 DDLPostgreSQL
-- 目标多租户tenant_id 全表隔离)+ tenant_uuid 用于 OSS Keyquyun/<tenant_uuid>/<md5>.<ext>
-- 注意tenant_uuid 由业务代码生成写入(不使用 DB 扩展默认值)
BEGIN;
-- 1) 租户
CREATE TABLE IF NOT EXISTS tenants (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(64) NOT NULL,
tenant_uuid UUID NOT NULL,
name VARCHAR(128) NOT NULL DEFAULT '',
status INT2 NOT NULL DEFAULT 0,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- tenant_code不区分大小写写入/查询均 lower并限制字符集 a-z0-9_-
ALTER TABLE tenants
ADD CONSTRAINT tenants_tenant_code_format
CHECK (tenant_code ~ '^[A-Za-z0-9_-]+$');
CREATE UNIQUE INDEX IF NOT EXISTS ux_tenants_tenant_code_lower
ON tenants (lower(tenant_code));
CREATE UNIQUE INDEX IF NOT EXISTS ux_tenants_tenant_uuid
ON tenants (tenant_uuid);
-- 2) 租户后台账号
CREATE TABLE IF NOT EXISTS admin_users (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
username VARCHAR(64) NOT NULL,
password_hash TEXT NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'admin',
status INT2 NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_admin_users_tenant_username
ON admin_users (tenant_id, lower(username));
-- 3) 用户(微信 openid 用户)
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
status INT2 NOT NULL DEFAULT 0,
open_id VARCHAR(128) NOT NULL,
username VARCHAR(128) NOT NULL DEFAULT '',
avatar TEXT,
metas JSONB NOT NULL DEFAULT '{}'::jsonb,
auth_token JSONB NOT NULL DEFAULT '{}'::jsonb,
balance BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_users_tenant_openid
ON users (tenant_id, open_id);
CREATE INDEX IF NOT EXISTS ix_users_tenant_id
ON users (tenant_id, id);
-- 4) 媒体
CREATE TABLE IF NOT EXISTS medias (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
name VARCHAR(255) NOT NULL DEFAULT '',
mime_type VARCHAR(128) NOT NULL DEFAULT '',
size BIGINT NOT NULL DEFAULT 0,
path VARCHAR(512) NOT NULL DEFAULT '',
metas JSONB NOT NULL DEFAULT '{}'::jsonb,
hash VARCHAR(64) NOT NULL DEFAULT ''
);
-- 租户内按 md5 去重
CREATE UNIQUE INDEX IF NOT EXISTS ux_medias_tenant_hash
ON medias (tenant_id, hash);
CREATE INDEX IF NOT EXISTS ix_medias_tenant_id
ON medias (tenant_id, id);
-- 5) 曲谱/内容
CREATE TABLE IF NOT EXISTS posts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
status INT2 NOT NULL DEFAULT 0,
title VARCHAR(128) NOT NULL,
head_images JSONB NOT NULL DEFAULT '[]'::jsonb,
description VARCHAR(256) NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
price BIGINT NOT NULL DEFAULT 0,
discount INT2 NOT NULL DEFAULT 100,
views BIGINT NOT NULL DEFAULT 0,
likes BIGINT NOT NULL DEFAULT 0,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
assets JSONB NOT NULL DEFAULT '[]'::jsonb
);
CREATE INDEX IF NOT EXISTS ix_posts_tenant_status_deleted
ON posts (tenant_id, status, deleted_at);
-- 6) 授权关系(购买/赠送)
CREATE TABLE IF NOT EXISTS user_posts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
price BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_user_posts_tenant_user_post
ON user_posts (tenant_id, user_id, post_id);
CREATE INDEX IF NOT EXISTS ix_user_posts_tenant_user
ON user_posts (tenant_id, user_id, id);
-- 7) 订单(仅余额)
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
order_no VARCHAR(64) NOT NULL,
price BIGINT NOT NULL DEFAULT 0,
discount INT2 NOT NULL DEFAULT 100,
currency VARCHAR(10) NOT NULL DEFAULT 'CNY',
payment_method VARCHAR(50) NOT NULL DEFAULT 'balance',
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status INT2 NOT NULL DEFAULT 0,
meta JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_orders_tenant_order_no
ON orders (tenant_id, order_no);
CREATE INDEX IF NOT EXISTS ix_orders_tenant_user
ON orders (tenant_id, user_id, id);
COMMIT;

126
specs/PRD.md Normal file
View File

@@ -0,0 +1,126 @@
# 新项目 PRD多租户 + 微信登录/分享 + 余额支付)
## 1. 范围与约束
### 1.1 多租户(从第一天开始)
- 所有站点与 API 都在路径前缀下:`/t/:tenant_code/...`
- `tenant_code` 规则:不区分大小写,允许字符集 `a-z0-9_-`
- 系统内部以 `tenant_uuid`UUID作为租户在 OSS 的存储分区标识
- OSS Key`quyun/<tenant_uuid>/<md5>.<ext>`
- 数据隔离:所有业务表均包含 `tenant_id`,所有查询必须带 `tenant_id` 过滤
### 1.2 微信能力
- 保留微信网页授权登录OAuth、JS-SDK 签名与分享
- 移除:微信支付/退款/回调(多租户版本完全不支持,历史也不兼容)
### 1.3 支付能力
- 仅支持:余额支付(单位:分),后台可充值余额
- 可退款:仅余额订单(退款 = 返还余额 + 撤销授权)
### 1.4 技术栈约束
- 后端开发语言GolangGo
- 前端技术栈Vue 3 + Vite
- UI 组件库推荐PrimeVue配合 TailwindCSS性能较好且扩展/主题定制能力强)
---
## 2. 角色与端
### 2.1 WeChat H5 端C 端用户)
- 访问曲谱列表、搜索
- 查看曲谱详情
- 播放未购买只能播放“预览版short=true已购买播放“完整版short=false
- 余额购买
- 查看已购列表(快速播放)
- 查看个人资料与余额
- 分享JS-SDK
### 2.2 Admin 管理端(租户运营人员)
- 登录(租户维度账号)
- 仪表盘统计
- 媒体库管理:上传(预签名)、列表、预览、删除
- 曲谱管理:创建/编辑/发布/草稿、绑定媒体资源、设置封面≤3
- 用户管理:列表、详情、查看已购、给用户充值余额
- 订单管理:列表、退款(余额订单)
- 运营操作:赠送曲谱给指定用户
---
## 3. 核心业务对象
### 3.1 曲谱Post
- 字段标题、简介、正文、价格、折扣0~100、状态draft/published
- 关联媒体:
- `head_images`:封面媒体 ID 列表≤3
- `assets`:媒体资产数组(见 MediaAsset包含视频/音频/文件等
### 3.2 媒体Media
- 字段name、mime_type、size、hash(md5)、path(OSS key)、metas
- metas`short`(是否预览资源)、`duration``parent_hash`
- hash 去重:在租户维度内去重(同租户相同 md5 视为同一资源)
### 3.3 授权UserPosts
- 记录用户购买/赠送后获得的曲谱访问权
- 唯一约束:同一租户同一用户同一曲谱只能有一条
### 3.4 订单Order
- 仅余额订单:`payment_method = balance`
- 状态:`pending/completed/refund_success/cancelled`(具体枚举在技术规格中确定)
---
## 4. 关键流程
### 4.1 租户识别
- 进入任意页面时,根据 URL 中的 `:tenant_code` 识别租户
- 服务端需校验:
- code 正则:`^[a-z0-9_-]+$`(不区分大小写)
- code 存在且启用
- 解析成功后,将 `tenant_id``tenant_uuid` 注入到请求上下文
### 4.2 微信登录
1) 未登录请求 API → 401XHR或 302 → `/t/:tenant/v1/auth/wechat?redirect=...`
2) `/auth/wechat` 生成微信授权地址(回调到 `/t/:tenant/v1/auth/login` 并透传 redirect
3) `/auth/login` 换取 openid + 用户信息,按 `(tenant_id, open_id)` 获取或创建用户,签发会话 tokencookie
### 4.3 播放策略(预览/完整版)
- assets 中选择 `type == "video/mp4"` 的资源:
- 未购买:选择 `metas.short == true`
- 已购买:选择 `metas.short == false`
- 媒体 URL 通过 OSS 预签名下发
### 4.4 余额购买与授权
- 下单:创建订单 `pending`
- 校验余额足够:
- 足够:扣减余额 → 订单 `completed` → 写入 `user_posts`
- 不足:返回业务错误(提示联系管理员充值)
### 4.5 余额退款
- 仅允许对 `completed + payment_method=balance` 的订单退款
- 退款动作:
- 返还余额
- 删除/撤销 `user_posts` 授权
- 订单标记为 `refund_success`
---
## 5. 非功能性要求(简版)
- 所有写操作必须带租户隔离与鉴权校验
- 关键唯一约束在 DB 层实现(避免并发重复授权/重复订单号)
- 租户 UUID 由业务代码生成并写入(不依赖 DB 扩展)

80
specs/ROUTING.md Normal file
View File

@@ -0,0 +1,80 @@
# 新项目路由与部署规则(多租户路径前缀)
## 1. 路由总览
### 1.1 约定
- 租户前缀:`/t/:tenant_code/`(不区分大小写;服务端统一按 `lower()` 识别)
- API`/t/:tenant_code/v1/...`
- Admin SPA`/t/:tenant_code/admin/...`
- WeChat SPA`/t/:tenant_code/...`(除 `v1``admin` 子路径)
### 1.2 为什么必须这样分层
- WeChat/后台前端都是 SPA通常需要一个 catch-all 回退到 `index.html`
- 同时 API 又需要精确匹配,必须确保 API 路由优先于静态路由
---
## 2. 后端HTTP Server路由注册顺序建议
以 Fiber 为例,推荐顺序:
1) `GET /t/:tenant_code/v1/...`:注册所有 API并注入 TenantContext 中间件)
2) `GET /t/:tenant_code/admin*`:回退到 Admin 的 `index.html`(并正确设置静态资源 base
3) `GET /t/:tenant_code/*`:回退到 WeChat 的 `index.html`
> 重点:不要用全局 `GET /*` 直接接管,否则会吞掉 API 与 admin。
---
## 3. 前端Admin
### 3.1 Router base
- `createWebHistory("/t/<tenant_code>/admin/")`
### 3.2 API baseURL 推导
- 运行时从 `location.pathname` 提取 `tenant_code`
- axios `baseURL = "/t/<tenant_code>/v1"`
### 3.3 Token
- `Authorization: Bearer <token>`(租户后台登录返回)
---
## 4. 前端WeChat H5
### 4.1 Router base
- `createWebHistory("/t/<tenant_code>/")`
### 4.2 API baseURL 与 Cookie
- axios `baseURL = "/t/<tenant_code>/v1"`
- `withCredentials = true`(携带 cookie 会话)
### 4.3 未登录跳转
- 401 时跳:`/t/<tenant_code>/v1/auth/wechat?redirect=<encodeURIComponent(currentUrl)>`
---
## 5. tenant_code 提取规则(前端共用)
从路径 `/t/<tenant_code>/...` 提取第二段:
- `tenant_code = decodeURIComponent(pathname.split('/')[2] || '')`
- 使用时建议 `tenant_code.toLowerCase()`
- 允许字符集:`a-z0-9_-`(不允许空)
---
## 6. OSS Key 规则
- 统一格式:`quyun/<tenant_uuid>/<md5>.<ext>`
- `tenant_uuid` 来自 `tenants.tenant_uuid`
- `md5` 来自上传文件内容 hash