功能更新
This commit is contained in:
86
README.md
86
README.md
@@ -1,70 +1,68 @@
|
||||
# nl-pms-api
|
||||
|
||||
code-count(view 桌面端)的文件存储服务:客户端凭密钥上传图片,换取可公开访问的
|
||||
http URL。管理员(云端账号 id=1)在 view 的「文件存储」配置里把存储方式设为
|
||||
「服务器」并填入本服务地址与密钥后,内容图片(`imageMode=server`)与头像会上传到
|
||||
这里,Markdown / 头像直接引用返回的 URL —— 跨设备、跨团队都能访问,不再依赖本地路径。
|
||||
code-count(view 桌面端)的 **云同步 + 文件存储** HTTP 服务。桌面端不再直连 MySQL:
|
||||
登录一次拿到 JWT 后,同步、团队、文件上传共用同一 Bearer,无需二次登录。
|
||||
|
||||
技术栈:Go + gin + gorm,MySQL 与 view 同步服务共用 `code_count` 库(新表 `pms_files`)。
|
||||
技术栈:Go + gin + gorm。分层:`router → controller → service → commonservice → model`。
|
||||
MySQL 库 `code_count` 的 DDL 以本仓库 [`init.sql`](./init.sql) 为唯一来源。
|
||||
|
||||
## 接口
|
||||
## 鉴权
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/healthz` | 无 | 健康检查(view「测试连接」调用) |
|
||||
| POST | `/api/v1/files` | `Authorization: Bearer <api_key>` | multipart 上传:`file` 必填,`kind`(avatar\|content)、`userId`、`teamId` 可选;返回 `{id, name, url, size, mime, teamId}` |
|
||||
| GET | `/api/v1/files` | 同上 | 素材库列表:`scope=mine\|team\|all` + `userId` + `teamId` + `page` + `pageSize`;mine 看自己,team 需为该团队 owner/admin,all 仅超管(userId=1);返回 `{total, items}`(含上传者 `username`) |
|
||||
| DELETE | `/api/v1/files/:id` | 同上 | 删除素材(记录+磁盘文件):本人、超管 id=1、或该文件归属团队的 owner/admin |
|
||||
| GET | `/files/*path` | 无 | 文件公开访问(路径含 128 位随机 hex,不可枚举) |
|
||||
| 方式 | 说明 |
|
||||
|---|---|
|
||||
| 公开 | `GET /healthz`;`POST /api/v1/auth/register\|login\|refresh` |
|
||||
| JWT | 其余 `/api/v1/*`:`Authorization: Bearer <accessToken>` |
|
||||
| 刷新 | `POST /api/v1/auth/refresh` 用 `refreshToken` 换新双令牌 |
|
||||
|
||||
上传约束:默认单文件 ≤ 20MB(`max_upload_mb`);按内容嗅探只接受 jpeg/png/gif/webp;
|
||||
同一归属(`userId`+`teamId`)重复上传相同内容直接复用已有记录(秒传),
|
||||
不同归属各自落盘,保证删除自己的素材不影响他人引用。
|
||||
配置项:`jwt_secret`(必填)、`access_ttl_hours`(默认 2)、`refresh_ttl_days`(默认 30)。
|
||||
|
||||
身份模型:沿用 code-count 的内网信任模型 —— 客户端自报 `userId`/`teamId`,
|
||||
服务端按 code_count 库的 `team_members`/`users` 判定管理范围(防误操作,不防伪造)。
|
||||
身份从 JWT claims 解析(`user_id` / `username`),**不再信任客户端自报 userId**。
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8788/healthz
|
||||
curl -H "Authorization: Bearer change-me" -F "file=@a.png" -F "kind=content" \
|
||||
http://127.0.0.1:8788/api/v1/files
|
||||
```
|
||||
## 主要接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/healthz` | 健康检查 |
|
||||
| POST | `/api/v1/auth/register` | 注册 `{username,password}` |
|
||||
| POST | `/api/v1/auth/login` | 登录 → `{accessToken,refreshToken,userId,username}` |
|
||||
| POST | `/api/v1/auth/refresh` | 刷新 `{refreshToken}` |
|
||||
| POST | `/api/v1/auth/change-password` | 改密 `{oldPassword,newPassword}` |
|
||||
| POST | `/api/v1/sync/push` | `{table,rows}` LWW 推送 |
|
||||
| GET | `/api/v1/sync/pull` | `table` + `cursor` 增量拉取 |
|
||||
| GET/PUT | `/api/v1/settings/:name` | 用户/全局设置 KV |
|
||||
| GET | `/api/v1/settings?prefix=` | 按前缀批量(如 `fest_img:`) |
|
||||
| GET | `/api/v1/settings/global/:name` | 读管理员全局设置 |
|
||||
| GET/PUT | `/api/v1/profile` | 公开资料 |
|
||||
| GET | `/api/v1/notices` | 团队通知 `after=` |
|
||||
| * | `/api/v1/teams...` | 团队/任务/日报/摘要 |
|
||||
| POST/GET/DELETE | `/api/v1/files` | 图片上传/列表/删除(JWT) |
|
||||
| GET | `/files/*path` | 文件公开访问(随机路径) |
|
||||
|
||||
上传约束:默认单文件 ≤ 20MB;仅 jpeg/png/gif/webp;同归属秒传去重。
|
||||
|
||||
## 配置
|
||||
|
||||
复制 `config.example.yaml` 为 `config.yaml` 后修改(启动可用 `-config` 指定路径)。
|
||||
必填:`api_key`(客户端上传密钥)、`mysql.dsn`;`base_url` 建议填客户端可达的地址,
|
||||
留空则按请求 Host 推断。
|
||||
复制 `config.example.yaml` 为 `config.yaml` 后修改。必填:`jwt_secret`、`mysql.dsn`;
|
||||
`base_url` 建议填客户端可达地址(拼文件 URL)。
|
||||
|
||||
## 数据库迁移约定(重要)
|
||||
## 数据库迁移约定
|
||||
|
||||
- **仅 dev 环境自动迁移**:`config.yaml` 里 `env: dev` 时,启动执行 `AutoMigrate`。
|
||||
- **生产绝不迁移**:`env: prod`(默认)启动只检查 `pms_files` 表是否存在,
|
||||
缺表直接报错退出,不执行任何 DDL。部署 / 升级前先手工执行:
|
||||
- **仅 dev**(`env: dev`)启动 AutoMigrate。
|
||||
- **生产绝不迁移**:缺表直接退出。部署前:
|
||||
|
||||
```bash
|
||||
mysql -u root -p < init.sql
|
||||
```
|
||||
|
||||
`init.sql` 可重复执行(`CREATE TABLE IF NOT EXISTS`),索引名与 gorm 默认命名一致,
|
||||
避免 dev / prod 两套 schema 漂移。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 开发(自动迁移)
|
||||
go run . -config config.yaml # config.yaml 里 env: dev
|
||||
|
||||
# 生产(先执行 init.sql,再构建部署)
|
||||
go build -o bin/nl-pms-api . # Windows 产出 bin/nl-pms-api.exe
|
||||
go build -o bin/nl-pms-api .
|
||||
./bin/nl-pms-api -config /etc/nl-pms-api/config.yaml
|
||||
```
|
||||
|
||||
Windows 可用 nssm / 计划任务托管,Linux 建议 systemd;服务本身无状态,
|
||||
备份只需 `uploads/` 目录与 `pms_files` 表。
|
||||
## 与桌面端的关系
|
||||
|
||||
## 安全权衡(内网定位)
|
||||
|
||||
- 上传密钥经 view 的 `sync_settings` 明文下发给所有登录客户端(与节日背景图同机制),
|
||||
按内网工具定位设计;暴露公网需自行加 HTTPS 反代与更强的凭证体系。
|
||||
- 图片 GET 公开:`<img>` 标签无法携带鉴权头,靠随机路径保证不可枚举。
|
||||
view 打包配置 `build/sync.defaults.json` 的 `baseUrl` 指向本服务。
|
||||
登录后本地存 `sync_access_token` / `sync_refresh_token`;文件存储「服务器」模式只需填本服务 `baseUrl`,鉴权用登录 JWT。
|
||||
|
||||
@@ -3,13 +3,25 @@
|
||||
# prod 绝不执行任何 DDL,缺表直接退出 —— 部署前先手工执行 init.sql。
|
||||
env: prod
|
||||
listen: ":8788"
|
||||
# 拼接文件访问 URL 的前缀,必须是客户端可达的地址(如 http://192.168.1.10:8788);
|
||||
# 留空则按每次请求的 Host 推断。
|
||||
base_url: ""
|
||||
# 客户端上传密钥:view 管理员在「文件存储」配置里填同一个值(Authorization: Bearer <api_key>)。
|
||||
api_key: "change-me"
|
||||
# 拼接文件访问 URL 的前缀,必须是客户端可达的地址(含反代前缀);
|
||||
# 例:https://o-api.nailaoyun.cn/pms-api ;留空则按每次请求的 Host 推断。
|
||||
base_url: "https://o-api.nailaoyun.cn/pms-api"
|
||||
# 反代若不剥路径前缀(1Panel 常见),填 /pms-api;若反代已把 /pms-api 剥掉再转发,则留空。
|
||||
base_path: "/pms-api"
|
||||
# JWT 签发密钥:access / refresh token 共用,务必使用足够长的随机串。
|
||||
jwt_secret: "change-me-to-a-long-random-secret"
|
||||
# access token 有效小时数(默认 2);refresh token 有效天数(默认 30)。
|
||||
access_ttl_hours: 2
|
||||
refresh_ttl_days: 30
|
||||
storage_dir: "./uploads"
|
||||
# 普通图片上传上限(MB)。
|
||||
max_upload_mb: 20
|
||||
# 客户端发版安装包上传上限(MB)。
|
||||
max_release_mb: 200
|
||||
# 可信反代(CIDR 或 IP);填了才会信任 X-Forwarded-For 解析真实客户端 IP。
|
||||
# trusted_proxies:
|
||||
# - 127.0.0.1
|
||||
# - 10.0.0.0/8
|
||||
mysql:
|
||||
# 与 view 同步服务共用 code_count 库;账号需要对 pms_files 表的 SELECT/INSERT 权限。
|
||||
# 与 view 桌面端共用 code_count 库;账号需要对业务表的 SELECT/INSERT/UPDATE 权限。
|
||||
dsn: "root:root@tcp(127.0.0.1:3306)/code_count?charset=utf8mb4&parseTime=false&loc=Local"
|
||||
|
||||
5
go.mod
5
go.mod
@@ -5,6 +5,8 @@ go 1.25.5
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
golang.org/x/crypto v0.55.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
@@ -12,6 +14,7 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
@@ -36,6 +39,7 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pquerna/otp v1.5.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -43,7 +47,6 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
@@ -37,6 +39,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -71,6 +75,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
|
||||
310
init.sql
310
init.sql
@@ -1,17 +1,239 @@
|
||||
-- ============================================================
|
||||
-- nl-pms-api 初始化脚本(MySQL 5.7+ / 8.x)
|
||||
-- ============================================================
|
||||
-- nl-pms-api / code-count 共用库初始化脚本(MySQL 5.7+ / 8.x)
|
||||
-- 用法:mysql -u root -p < init.sql
|
||||
-- 合并自 view/init.sql(业务同步表)+ pms_files(文件元数据)。
|
||||
-- 生产环境服务启动不执行任何建表/迁移(DDL),部署前必须先执行本脚本;
|
||||
-- 仅 dev 环境(config.yaml 里 env: dev)启动时才 AutoMigrate。
|
||||
-- 与 view 同步服务共用 code_count 库(该库由 view/init.sql 创建,这里兜底建库)。
|
||||
-- 默认账号:liqi / qiqi991012(bcrypt 哈希存储,可在应用内注册新账号)
|
||||
-- ============================================================
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS code_count DEFAULT CHARSET utf8mb4;
|
||||
USE code_count;
|
||||
|
||||
-- 应用账号(密码为 bcrypt 哈希)
|
||||
CREATE TABLE IF NOT EXISTS users(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
|
||||
username VARCHAR(64) NOT NULL UNIQUE COMMENT '登录名',
|
||||
password_hash VARCHAR(100) NOT NULL COMMENT 'bcrypt 密码哈希',
|
||||
totp_secret VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'TOTP Base32 密钥(仅管理员)',
|
||||
totp_enabled TINYINT NOT NULL DEFAULT 0 COMMENT '是否已启用 TOTP',
|
||||
last_login_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近登录 IP',
|
||||
last_seen_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近活跃时间 RFC3339',
|
||||
ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT '禁止使用 AI',
|
||||
disabled TINYINT NOT NULL DEFAULT 0 COMMENT '禁用账号',
|
||||
created_at VARCHAR(32) NOT NULL COMMENT '注册时间(RFC3339)'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用账号';
|
||||
|
||||
-- Todo 同步表(按 user_id 隔离,LWW 以 updated_at 判定)
|
||||
-- history 为生命周期轨迹 JSON:[{"status":"open","at":"..."},...],记录每次进入某状态的时间
|
||||
-- team_id>0 表示该条已共享给对应团队(团队管理员可见),0 为私密
|
||||
CREATE TABLE IF NOT EXISTS sync_todos(
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
uuid CHAR(36) NOT NULL COMMENT '客户端生成的全局唯一ID',
|
||||
title TEXT NOT NULL COMMENT '标题',
|
||||
content MEDIUMTEXT NOT NULL COMMENT '正文(Markdown,可含内嵌图片)',
|
||||
project_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联项目名(展示用)',
|
||||
due_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '截止时间',
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'medium' COMMENT '优先级:low/medium/high',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open' COMMENT '状态:open/doing/done/cancelled',
|
||||
history MEDIUMTEXT NOT NULL COMMENT '生命周期轨迹 JSON',
|
||||
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '共享团队ID,0=私密',
|
||||
created_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
|
||||
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间(LWW)',
|
||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记:1=已删',
|
||||
PRIMARY KEY(user_id, uuid),
|
||||
KEY idx_sync_todos_updated(user_id, updated_at),
|
||||
KEY idx_sync_todos_team(team_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='待办同步表';
|
||||
|
||||
-- 工单同步表
|
||||
CREATE TABLE IF NOT EXISTS sync_tickets(
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
uuid CHAR(36) NOT NULL COMMENT '客户端生成的全局唯一ID',
|
||||
title TEXT NOT NULL COMMENT '标题',
|
||||
description MEDIUMTEXT NOT NULL COMMENT '描述(Markdown)',
|
||||
type VARCHAR(16) NOT NULL DEFAULT 'task' COMMENT '类型:task/bug/feature 等',
|
||||
project_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '关联项目名',
|
||||
start_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '开始时间',
|
||||
due_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '截止时间',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open' COMMENT '状态',
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'medium' COMMENT '优先级',
|
||||
history MEDIUMTEXT NOT NULL COMMENT '生命周期轨迹 JSON',
|
||||
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '共享团队ID,0=私密',
|
||||
created_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '创建时间',
|
||||
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间(LWW)',
|
||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
|
||||
PRIMARY KEY(user_id, uuid),
|
||||
KEY idx_sync_tickets_updated(user_id, updated_at),
|
||||
KEY idx_sync_tickets_team(team_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单同步表';
|
||||
|
||||
-- 记事本同步表
|
||||
CREATE TABLE IF NOT EXISTS sync_notes(
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
uuid CHAR(36) NOT NULL COMMENT '客户端生成的全局唯一ID',
|
||||
content MEDIUMTEXT NOT NULL COMMENT '记事本正文',
|
||||
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间(LWW)',
|
||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
|
||||
PRIMARY KEY(user_id, uuid),
|
||||
KEY idx_sync_notes_updated(user_id, updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='记事本同步表';
|
||||
|
||||
-- 按用户存储的同步设置(如加密盐 enc_salt、加密后的 AI API Key api_keys)。
|
||||
-- api_keys 的值为 AES-256-GCM 密文(密钥由登录密码派生),服务器无法解密。
|
||||
-- 另有全局资源行:日历节日背景图存为 fest_img:<节日名>,统一挂在管理员账号
|
||||
-- (id=1)名下 —— 管理员在应用内上传推送,所有账号登录后拉取展示。
|
||||
CREATE TABLE IF NOT EXISTS sync_settings(
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID(全局资源挂在管理员 id=1)',
|
||||
name VARCHAR(64) NOT NULL COMMENT '设置键名',
|
||||
value MEDIUMTEXT NOT NULL COMMENT '设置值(明文或密文)',
|
||||
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间',
|
||||
PRIMARY KEY(user_id, name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户同步设置键值表';
|
||||
|
||||
-- 旧版本升级:早期脚本建的 content/description 为 TEXT(64KB),
|
||||
-- 待办/工单内容支持 Markdown 内嵌图片后需要 MEDIUMTEXT;重复执行无副作用。
|
||||
ALTER TABLE sync_todos MODIFY content MEDIUMTEXT NOT NULL COMMENT '正文(Markdown,可含内嵌图片)';
|
||||
ALTER TABLE sync_tickets MODIFY description MEDIUMTEXT NOT NULL COMMENT '描述(Markdown)';
|
||||
|
||||
-- 旧版本升级:补 history 生命周期列(不存在时才添加,重复执行无副作用)。
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND COLUMN_NAME='history')=0,
|
||||
'ALTER TABLE sync_todos ADD COLUMN history MEDIUMTEXT NOT NULL COMMENT ''生命周期轨迹 JSON'' AFTER status', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='history')=0,
|
||||
'ALTER TABLE sync_tickets ADD COLUMN history MEDIUMTEXT NOT NULL COMMENT ''生命周期轨迹 JSON'' AFTER priority', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- ============================================================
|
||||
-- 团队协作(v2.1):资料 / 团队 / 团队任务 / 日报 / 通知
|
||||
-- ============================================================
|
||||
|
||||
-- 用户公开资料(同服务器用户互相可见:昵称/头衔/技术栈标签/头像缩略图)
|
||||
CREATE TABLE IF NOT EXISTS user_profiles(
|
||||
user_id BIGINT PRIMARY KEY COMMENT '对应用户ID',
|
||||
nickname VARCHAR(64) NOT NULL DEFAULT '' COMMENT '昵称',
|
||||
title VARCHAR(64) NOT NULL DEFAULT '' COMMENT '头衔/职位',
|
||||
email VARCHAR(128) NOT NULL DEFAULT '' COMMENT '公开邮箱',
|
||||
bio VARCHAR(500) NOT NULL DEFAULT '' COMMENT '个人简介',
|
||||
tech_tags VARCHAR(1000) NOT NULL DEFAULT '[]' COMMENT '技术栈标签 JSON 数组',
|
||||
avatar_thumb MEDIUMTEXT NOT NULL COMMENT '头像缩略图(dataURL 或 URL)',
|
||||
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户公开资料';
|
||||
|
||||
-- 用户头像历史(与账号关联,多端可回选;每人保留最近若干条)
|
||||
CREATE TABLE IF NOT EXISTS user_avatar_history(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '历史记录ID',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
mode VARCHAR(16) NOT NULL DEFAULT 'url' COMMENT '存储模式:url | base64',
|
||||
value MEDIUMTEXT NOT NULL COMMENT '头像内容(URL 或 dataURL)',
|
||||
created_at VARCHAR(32) NOT NULL COMMENT '写入时间(RFC3339)',
|
||||
KEY idx_uah_user_id(user_id, id DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户头像历史';
|
||||
|
||||
-- 团队(digest_time:日报 AI 摘要自动生成时间 HH:MM)
|
||||
CREATE TABLE IF NOT EXISTS teams(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '团队ID',
|
||||
name VARCHAR(64) NOT NULL COMMENT '团队名称',
|
||||
owner_id BIGINT NOT NULL COMMENT '创建者用户ID',
|
||||
digest_time VARCHAR(8) NOT NULL DEFAULT '21:00' COMMENT '日报 AI 摘要自动生成时间 HH:MM',
|
||||
ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT '禁止团队使用 AI',
|
||||
created_at VARCHAR(32) NOT NULL COMMENT '创建时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队';
|
||||
|
||||
-- 团队成员(role: owner | admin | member)
|
||||
CREATE TABLE IF NOT EXISTS team_members(
|
||||
team_id BIGINT NOT NULL COMMENT '团队ID',
|
||||
user_id BIGINT NOT NULL COMMENT '成员用户ID',
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'member' COMMENT '角色:owner/admin/member',
|
||||
joined_at VARCHAR(32) NOT NULL COMMENT '加入时间',
|
||||
PRIMARY KEY(team_id, user_id),
|
||||
KEY idx_team_members_user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队成员';
|
||||
|
||||
-- 团队任务/工单(服务器唯一真相,在线操作;kind: todo | ticket)
|
||||
CREATE TABLE IF NOT EXISTS team_tasks(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '任务ID',
|
||||
team_id BIGINT NOT NULL COMMENT '所属团队ID',
|
||||
kind VARCHAR(16) NOT NULL DEFAULT 'todo' COMMENT '种类:todo/ticket',
|
||||
title TEXT NOT NULL COMMENT '标题',
|
||||
description MEDIUMTEXT NOT NULL COMMENT '描述',
|
||||
priority VARCHAR(16) NOT NULL DEFAULT 'medium' COMMENT '优先级',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'open' COMMENT '状态',
|
||||
creator_id BIGINT NOT NULL COMMENT '创建者用户ID',
|
||||
assignee_id BIGINT NOT NULL DEFAULT 0 COMMENT '指派人用户ID,0=未指派',
|
||||
start_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '开始时间',
|
||||
due_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '截止时间',
|
||||
urged_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '最近催办时间',
|
||||
history MEDIUMTEXT NOT NULL COMMENT '生命周期轨迹 JSON',
|
||||
updated_at VARCHAR(32) NOT NULL COMMENT '最后更新时间',
|
||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标记',
|
||||
KEY idx_team_tasks_team(team_id, deleted),
|
||||
KEY idx_team_tasks_assignee(assignee_id, deleted)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队任务/工单';
|
||||
|
||||
-- 团队日报(date 为本地日期 YYYY-MM-DD)
|
||||
CREATE TABLE IF NOT EXISTS team_reports(
|
||||
team_id BIGINT NOT NULL COMMENT '团队ID',
|
||||
user_id BIGINT NOT NULL COMMENT '提交人用户ID',
|
||||
date CHAR(10) NOT NULL COMMENT '日报日期 YYYY-MM-DD',
|
||||
content MEDIUMTEXT NOT NULL COMMENT '日报正文(Markdown)',
|
||||
submitted_at VARCHAR(32) NOT NULL COMMENT '提交/更新时间',
|
||||
PRIMARY KEY(team_id, user_id, date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队日报';
|
||||
|
||||
-- 团队日报 AI 摘要(管理员客户端生成,全员可见)
|
||||
CREATE TABLE IF NOT EXISTS team_digests(
|
||||
team_id BIGINT NOT NULL COMMENT '团队ID',
|
||||
date CHAR(10) NOT NULL COMMENT '摘要对应日期 YYYY-MM-DD',
|
||||
content MEDIUMTEXT NOT NULL COMMENT 'AI 摘要正文(Markdown)',
|
||||
provider VARCHAR(32) NOT NULL DEFAULT '' COMMENT '生成所用 AI 提供商',
|
||||
generated_at VARCHAR(32) NOT NULL COMMENT '生成时间',
|
||||
PRIMARY KEY(team_id, date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队日报 AI 摘要';
|
||||
|
||||
-- 团队通知(指派/催办/成员变动等;客户端同步时按 to_user 增量拉取转本地消息)
|
||||
CREATE TABLE IF NOT EXISTS team_notices(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '通知ID',
|
||||
team_id BIGINT NOT NULL COMMENT '团队ID',
|
||||
to_user BIGINT NOT NULL COMMENT '接收用户ID',
|
||||
from_user BIGINT NOT NULL COMMENT '发送用户ID',
|
||||
kind VARCHAR(16) NOT NULL COMMENT '通知类型',
|
||||
ref_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联对象ID',
|
||||
content TEXT NOT NULL COMMENT '通知正文',
|
||||
created_at VARCHAR(32) NOT NULL COMMENT '创建时间',
|
||||
KEY idx_team_notices_to(to_user, id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团队通知';
|
||||
|
||||
-- 旧版本升级:个人待办/工单支持按条共享到团队(team_id=0 私密;重复执行无副作用)。
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND COLUMN_NAME='team_id')=0,
|
||||
'ALTER TABLE sync_todos ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''共享团队ID,0=私密'' AFTER history', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='team_id')=0,
|
||||
'ALTER TABLE sync_tickets ADD COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''共享团队ID,0=私密'' AFTER history', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_todos' AND INDEX_NAME='idx_sync_todos_team')=0,
|
||||
'ALTER TABLE sync_todos ADD KEY idx_sync_todos_team(team_id)', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='sync_tickets' AND INDEX_NAME='idx_sync_tickets_team')=0,
|
||||
'ALTER TABLE sync_tickets ADD KEY idx_sync_tickets_team(team_id)', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- 默认账号 liqi(密码 qiqi991012 的 bcrypt 哈希);已存在则跳过,不会覆盖改过的密码
|
||||
INSERT IGNORE INTO users(username, password_hash, created_at)
|
||||
VALUES ('liqi', '$2a$10$XWBtGPu9xYRyr8diFEfvBeEHRkO6pa3CDduE09OurJztuXVcJZOB2', '2026-08-11T13:20:00Z');
|
||||
|
||||
-- ============================================================
|
||||
-- 文件存储元数据(nl-pms-api)
|
||||
-- ============================================================
|
||||
|
||||
-- 已上传文件元数据(文件内容落盘在 storage_dir,本表只存相对路径与摘要)
|
||||
-- 归属按 (user_id, team_id) 记录:素材库据此划定"本人 / 团队管理员 / 超管 id=1"的管理范围。
|
||||
-- 索引名与 gorm AutoMigrate 的默认命名保持一致,避免 dev/prod 两套 schema 漂移。
|
||||
CREATE TABLE IF NOT EXISTS pms_files(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(191) NOT NULL, -- 存储相对路径,如 2026/08/13/<随机hex>.jpg
|
||||
@@ -19,7 +241,7 @@ CREATE TABLE IF NOT EXISTS pms_files(
|
||||
mime VARCHAR(64) NOT NULL DEFAULT '',
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
sha256 CHAR(64) NOT NULL DEFAULT '', -- 内容摘要,同归属秒传去重
|
||||
user_id BIGINT NOT NULL DEFAULT 0, -- 上传者(code_count 库 users 表 id,0 为未知)
|
||||
user_id BIGINT NOT NULL DEFAULT 0, -- 上传者(users 表 id,0 为未知)
|
||||
team_id BIGINT NOT NULL DEFAULT 0, -- 上传时所在团队(teams 表 id,0 为个人)
|
||||
kind VARCHAR(16) NOT NULL DEFAULT '', -- avatar | content
|
||||
created_at VARCHAR(32) NOT NULL DEFAULT '',
|
||||
@@ -27,7 +249,7 @@ CREATE TABLE IF NOT EXISTS pms_files(
|
||||
KEY idx_pms_files_sha256(sha256),
|
||||
KEY idx_pms_files_user_id(user_id),
|
||||
KEY idx_pms_files_team_id(team_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='已上传文件元数据';
|
||||
|
||||
-- 旧版本升级:补 team_id 归属列与 user_id/team_id 索引(不存在才添加,重复执行无副作用)。
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
@@ -42,3 +264,79 @@ SET @sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='pms_files' AND INDEX_NAME='idx_pms_files_team_id')=0,
|
||||
'ALTER TABLE pms_files ADD KEY idx_pms_files_team_id(team_id)', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- ============================================================
|
||||
-- 管理员后台 / TOTP / 日活 / AI 用量 / 发版(v2.2)
|
||||
-- ============================================================
|
||||
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='totp_secret')=0,
|
||||
'ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''TOTP Base32 密钥(仅管理员)'' AFTER password_hash', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='totp_enabled')=0,
|
||||
'ALTER TABLE users ADD COLUMN totp_enabled TINYINT NOT NULL DEFAULT 0 COMMENT ''是否已启用 TOTP'' AFTER totp_secret', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='last_login_ip')=0,
|
||||
'ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''最近登录 IP'' AFTER totp_enabled', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='last_seen_at')=0,
|
||||
'ALTER TABLE users ADD COLUMN last_seen_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''最近活跃时间 RFC3339'' AFTER last_login_ip', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='ai_banned')=0,
|
||||
'ALTER TABLE users ADD COLUMN ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT ''禁止使用 AI'' AFTER last_seen_at', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='disabled')=0,
|
||||
'ALTER TABLE users ADD COLUMN disabled TINYINT NOT NULL DEFAULT 0 COMMENT ''禁用账号'' AFTER ai_banned', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='teams' AND COLUMN_NAME='ai_banned')=0,
|
||||
'ALTER TABLE teams ADD COLUMN ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT ''禁止团队使用 AI'' AFTER digest_time', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_daily_active(
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
active_date CHAR(10) NOT NULL COMMENT '活跃日期 YYYY-MM-DD',
|
||||
last_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '当日最近 IP',
|
||||
last_seen_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '当日最近活跃时间',
|
||||
PRIMARY KEY(user_id, active_date),
|
||||
KEY idx_uda_date(active_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户日活';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_daily(
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '团队ID,0=个人',
|
||||
usage_date CHAR(10) NOT NULL COMMENT '用量日期 YYYY-MM-DD',
|
||||
provider VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'AI 提供商',
|
||||
prompt_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '提示 token',
|
||||
completion_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '补全 token',
|
||||
calls INT NOT NULL DEFAULT 0 COMMENT '调用次数',
|
||||
estimated TINYINT NOT NULL DEFAULT 0 COMMENT '是否含估算数据',
|
||||
PRIMARY KEY(user_id, team_id, usage_date, provider),
|
||||
KEY idx_aud_date(usage_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI Token 日汇总';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_releases(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '发版ID',
|
||||
version VARCHAR(32) NOT NULL COMMENT '版本号 semver',
|
||||
channel VARCHAR(16) NOT NULL DEFAULT 'stable' COMMENT '渠道',
|
||||
filename VARCHAR(255) NOT NULL COMMENT '存储相对路径',
|
||||
sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '文件 SHA256',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '字节大小',
|
||||
changelog MEDIUMTEXT NOT NULL COMMENT '更新说明',
|
||||
created_at VARCHAR(32) NOT NULL COMMENT '上传时间',
|
||||
is_latest TINYINT NOT NULL DEFAULT 0 COMMENT '是否当前最新',
|
||||
UNIQUE KEY idx_app_releases_ver_ch(version, channel),
|
||||
KEY idx_app_releases_latest(channel, is_latest)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端发版';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_stepup(
|
||||
user_id BIGINT PRIMARY KEY COMMENT '管理员用户ID',
|
||||
token_hash CHAR(64) NOT NULL COMMENT 'stepup token SHA256',
|
||||
client_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '签发时 IP',
|
||||
expires_at VARCHAR(32) NOT NULL COMMENT '过期时间 RFC3339'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员敏感操作二次验证';
|
||||
|
||||
23
internal/commonservice/ctx.go
Normal file
23
internal/commonservice/ctx.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package commonservice
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// Gin context 中由 RequireJWT 写入的键名。
|
||||
const (
|
||||
CtxUserID = "user_id"
|
||||
CtxUsername = "username"
|
||||
)
|
||||
|
||||
// UserID 从 gin 上下文读取当前登录用户 ID。
|
||||
func UserID(c *gin.Context) int64 {
|
||||
v, _ := c.Get(CtxUserID)
|
||||
id, _ := v.(int64)
|
||||
return id
|
||||
}
|
||||
|
||||
// Username 从 gin 上下文读取当前登录用户名。
|
||||
func Username(c *gin.Context) string {
|
||||
v, _ := c.Get(CtxUsername)
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
32
internal/commonservice/errors.go
Normal file
32
internal/commonservice/errors.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package commonservice
|
||||
|
||||
// AppError 是带稳定错误码与 HTTP 状态的业务错误,控制器原样返回 {"error":"CODE"}。
|
||||
type AppError struct {
|
||||
Code string
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
|
||||
// AsAppError 从 error 中取出 AppError;非业务错误返回 false。
|
||||
func AsAppError(err error) (*AppError, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
if ae, ok := err.(*AppError); ok {
|
||||
return ae, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func BadRequest(code string) *AppError { return &AppError{Code: code, Status: 400} }
|
||||
func Unauthorized(code string) *AppError { return &AppError{Code: code, Status: 401} }
|
||||
func Forbidden(code string) *AppError { return &AppError{Code: code, Status: 403} }
|
||||
func NotFound(code string) *AppError { return &AppError{Code: code, Status: 404} }
|
||||
func Conflict(code string) *AppError { return &AppError{Code: code, Status: 409} }
|
||||
func Internal(code string) *AppError { return &AppError{Code: code, Status: 500} }
|
||||
67
internal/commonservice/jwt.go
Normal file
67
internal/commonservice/jwt.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package commonservice
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims 是 access / refresh token 的载荷。
|
||||
type Claims struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Type string `json:"type"` // access | refresh
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// IssueAccess 签发 access token。
|
||||
func IssueAccess(secret string, userID int64, username string, ttlHours int) (string, error) {
|
||||
if ttlHours <= 0 {
|
||||
ttlHours = 2
|
||||
}
|
||||
return issue(secret, userID, username, "access", time.Duration(ttlHours)*time.Hour)
|
||||
}
|
||||
|
||||
// IssueRefresh 签发 refresh token。
|
||||
func IssueRefresh(secret string, userID int64, username string, ttlDays int) (string, error) {
|
||||
if ttlDays <= 0 {
|
||||
ttlDays = 30
|
||||
}
|
||||
return issue(secret, userID, username, "refresh", time.Duration(ttlDays)*24*time.Hour)
|
||||
}
|
||||
|
||||
func issue(secret string, userID int64, username, typ string, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Type: typ,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return t.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// Parse 校验并解析 JWT;typ 非空时要求 claims.Type 匹配。
|
||||
func Parse(secret, token, typ string) (*Claims, error) {
|
||||
parsed, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
if t.Method != jwt.SigningMethodHS256 {
|
||||
return nil, Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !parsed.Valid {
|
||||
return nil, Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
claims, ok := parsed.Claims.(*Claims)
|
||||
if !ok || claims.UserID <= 0 {
|
||||
return nil, Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
if typ != "" && claims.Type != typ {
|
||||
return nil, Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
55
internal/commonservice/team.go
Normal file
55
internal/commonservice/team.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package commonservice
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// TeamRole 查询用户在团队中的角色;非成员返回空串。
|
||||
func TeamRole(db *gorm.DB, teamID, userID int64) (string, error) {
|
||||
if teamID <= 0 || userID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
var role string
|
||||
err := db.Table("team_members").Select("role").
|
||||
Where("team_id = ? AND user_id = ?", teamID, userID).
|
||||
Scan(&role).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// TeamRoleRank 角色权重:owner=3 admin=2 member=1。
|
||||
func TeamRoleRank(role string) int {
|
||||
switch role {
|
||||
case "owner":
|
||||
return 3
|
||||
case "admin":
|
||||
return 2
|
||||
case "member":
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// IsTeamAdmin 该用户是否为团队 owner/admin。
|
||||
func IsTeamAdmin(db *gorm.DB, teamID, userID int64) bool {
|
||||
if teamID <= 0 || userID <= 0 {
|
||||
return false
|
||||
}
|
||||
var n int64
|
||||
db.Table("team_members").
|
||||
Where("team_id = ? AND user_id = ? AND role IN ('owner','admin')", teamID, userID).
|
||||
Count(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// RequireTeamRole 校验最低角色,返回实际角色。
|
||||
func RequireTeamRole(db *gorm.DB, teamID, userID int64, min string) (string, error) {
|
||||
role, err := TeamRole(db, teamID, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if TeamRoleRank(role) < TeamRoleRank(min) {
|
||||
return role, Forbidden("TEAM_FORBIDDEN")
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
125
internal/commonservice/util.go
Normal file
125
internal/commonservice/util.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package commonservice
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nl-pms-api/internal/config"
|
||||
)
|
||||
|
||||
// AdminUserID 是 code_count 体系的超级管理员账号(users.id=1)。
|
||||
const AdminUserID int64 = 1
|
||||
|
||||
// ParseID 把字符串解析为非负 int64;非法或负数视为 0。
|
||||
func ParseID(s string) int64 {
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Clip 按字节截断字符串到最多 n 字节。
|
||||
func Clip(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ClipRunes 按 rune 截断到最多 n 个字符。
|
||||
func ClipRunes(s string, n int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) > n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// NowRFC 返回当前 UTC 的 RFC3339 时间串(与 view 库惯例一致)。
|
||||
func NowRFC() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// PublicURL 拼接文件公开访问地址:优先配置的 base_url,否则用 requestHost(可含 scheme://host)。
|
||||
func PublicURL(cfg *config.Config, requestHost, name string) string {
|
||||
base := cfg.BaseURL
|
||||
if base == "" {
|
||||
base = strings.TrimRight(requestHost, "/")
|
||||
if !strings.Contains(base, "://") {
|
||||
base = "http://" + base
|
||||
}
|
||||
}
|
||||
return base + "/files/" + name
|
||||
}
|
||||
|
||||
// NormalizeKind 规范化文件用途:avatar | content,其余为空。
|
||||
func NormalizeKind(k string) string {
|
||||
if k == "avatar" || k == "content" {
|
||||
return k
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// StoredName 生成不可枚举的存储相对路径(日期目录 + 128 位随机 hex)。
|
||||
func StoredName(ext string) string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return time.Now().UTC().Format("2006/01/02") + "/" + hex.EncodeToString(b) + ext
|
||||
}
|
||||
|
||||
// MapStr 从通用 JSON map 取字符串字段。
|
||||
func MapStr(m map[string]any, key string) string {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case []byte:
|
||||
return string(x)
|
||||
case float64:
|
||||
if x == float64(int64(x)) {
|
||||
return strconv.FormatInt(int64(x), 10)
|
||||
}
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
case bool:
|
||||
if x {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
default:
|
||||
return fmt.Sprint(x)
|
||||
}
|
||||
}
|
||||
|
||||
// MapInt64 从通用 JSON map 取 int64 字段。
|
||||
func MapInt64(m map[string]any, key string) int64 {
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return 0
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(x, 10, 64)
|
||||
return n
|
||||
case bool:
|
||||
if x {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,21 @@ type Config struct {
|
||||
Listen string `yaml:"listen"`
|
||||
// BaseURL 拼接文件访问 URL 的前缀(客户端可达的地址);留空则按请求 Host 推断。
|
||||
BaseURL string `yaml:"base_url"`
|
||||
// APIKey 是客户端上传凭证:请求需带 Authorization: Bearer <api_key>。
|
||||
APIKey string `yaml:"api_key"`
|
||||
StorageDir string `yaml:"storage_dir"`
|
||||
MaxUploadMB int64 `yaml:"max_upload_mb"`
|
||||
MySQL struct {
|
||||
// BasePath 反代保留的 URL 前缀(如 /pms-api)。1Panel 等不剥前缀时必填;留空挂在根路径。
|
||||
BasePath string `yaml:"base_path"`
|
||||
// JWTSecret 签发/校验 access、refresh token 的密钥,不允许留空。
|
||||
JWTSecret string `yaml:"jwt_secret"`
|
||||
// AccessTTLHours access token 有效小时数,默认 2。
|
||||
AccessTTLHours int `yaml:"access_ttl_hours"`
|
||||
// RefreshTTLDays refresh token 有效天数,默认 30。
|
||||
RefreshTTLDays int `yaml:"refresh_ttl_days"`
|
||||
StorageDir string `yaml:"storage_dir"`
|
||||
MaxUploadMB int64 `yaml:"max_upload_mb"`
|
||||
// MaxReleaseMB 发版安装包上传上限(MB),默认 200。
|
||||
MaxReleaseMB int64 `yaml:"max_release_mb"`
|
||||
// TrustedProxies 可信反代 CIDR/IP,用于正确解析 ClientIP;空则不信任 X-Forwarded-For。
|
||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||
MySQL struct {
|
||||
DSN string `yaml:"dsn"`
|
||||
} `yaml:"mysql"`
|
||||
}
|
||||
@@ -28,7 +38,10 @@ type Config struct {
|
||||
// MaxUploadBytes 返回单文件字节上限。
|
||||
func (c *Config) MaxUploadBytes() int64 { return c.MaxUploadMB << 20 }
|
||||
|
||||
// Load 读取并校验配置,缺省值:prod / :8788 / ./uploads / 20MB。
|
||||
// MaxReleaseBytes 返回发版包字节上限。
|
||||
func (c *Config) MaxReleaseBytes() int64 { return c.MaxReleaseMB << 20 }
|
||||
|
||||
// Load 读取并校验配置,缺省值:prod / :8788 / ./uploads / 20MB / access 2h / refresh 30d。
|
||||
func Load(path string) (*Config, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -50,10 +63,29 @@ func Load(path string) (*Config, error) {
|
||||
if c.MaxUploadMB <= 0 {
|
||||
c.MaxUploadMB = 20
|
||||
}
|
||||
if c.MaxReleaseMB <= 0 {
|
||||
c.MaxReleaseMB = 200
|
||||
}
|
||||
if c.AccessTTLHours <= 0 {
|
||||
c.AccessTTLHours = 2
|
||||
}
|
||||
if c.RefreshTTLDays <= 0 {
|
||||
c.RefreshTTLDays = 30
|
||||
}
|
||||
c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
|
||||
c.APIKey = strings.TrimSpace(c.APIKey)
|
||||
if c.APIKey == "" {
|
||||
return nil, errors.New("配置缺少 api_key(客户端上传凭证,不允许留空开放上传)")
|
||||
c.BasePath = strings.TrimSpace(c.BasePath)
|
||||
if c.BasePath != "" {
|
||||
if !strings.HasPrefix(c.BasePath, "/") {
|
||||
c.BasePath = "/" + c.BasePath
|
||||
}
|
||||
c.BasePath = strings.TrimRight(c.BasePath, "/")
|
||||
if c.BasePath == "/" {
|
||||
c.BasePath = ""
|
||||
}
|
||||
}
|
||||
c.JWTSecret = strings.TrimSpace(c.JWTSecret)
|
||||
if c.JWTSecret == "" {
|
||||
return nil, errors.New("配置缺少 jwt_secret(JWT 签发密钥,不允许留空)")
|
||||
}
|
||||
if strings.TrimSpace(c.MySQL.DSN) == "" {
|
||||
return nil, errors.New("配置缺少 mysql.dsn")
|
||||
|
||||
244
internal/controller/admin.go
Normal file
244
internal/controller/admin.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/middleware"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// AdminController 运营后台。
|
||||
type AdminController struct {
|
||||
Admin *service.AdminService
|
||||
Sec *service.AdminSecurityService
|
||||
Rel *service.ReleaseService
|
||||
Act *service.ActivityService
|
||||
}
|
||||
|
||||
func (h *AdminController) Overview(c *gin.Context) {
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "14"))
|
||||
out, err := h.Admin.Overview(days)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AdminController) ListUsers(c *gin.Context) {
|
||||
items, err := h.Admin.ListUsers()
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminController) PatchUser(c *gin.Context) {
|
||||
id := commonservice.ParseID(c.Param("id"))
|
||||
var req struct {
|
||||
AIBanned *int `json:"aiBanned"`
|
||||
Disabled *int `json:"disabled"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.PatchUser(commonservice.UserID(c), id, req.AIBanned, req.Disabled); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminController) ListTeams(c *gin.Context) {
|
||||
items, err := h.Admin.ListTeams()
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminController) PatchTeam(c *gin.Context) {
|
||||
id := commonservice.ParseID(c.Param("id"))
|
||||
var req struct {
|
||||
AIBanned *int `json:"aiBanned"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.PatchTeam(commonservice.UserID(c), id, req.AIBanned); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminController) TOTPStatus(c *gin.Context) {
|
||||
out, err := h.Sec.Status(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AdminController) TOTPSetupBegin(c *gin.Context) {
|
||||
out, err := h.Sec.SetupBegin(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AdminController) TOTPSetupConfirm(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Sec.SetupConfirm(commonservice.UserID(c), req.Code); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminController) StepUp(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
token, exp, err := h.Sec.StepUp(commonservice.UserID(c), req.Code, middleware.ClientIP(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stepupToken": token,
|
||||
"expiresAt": exp.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminController) ListReleases(c *gin.Context) {
|
||||
items, err := h.Rel.List(c.Query("channel"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminController) UploadRelease(c *gin.Context) {
|
||||
version := c.PostForm("version")
|
||||
channel := c.PostForm("channel")
|
||||
changelog := c.PostForm("changelog")
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
row, err := h.Rel.Upload(version, channel, changelog, f, fh.Size)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
|
||||
func (h *AdminController) PublishRelease(c *gin.Context) {
|
||||
id := commonservice.ParseID(c.Param("id"))
|
||||
row, err := h.Rel.Publish(id)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
|
||||
// ActivityController 日活心跳。
|
||||
type ActivityController struct {
|
||||
Svc *service.ActivityService
|
||||
}
|
||||
|
||||
func (h *ActivityController) Ping(c *gin.Context) {
|
||||
if err := h.Svc.Ping(commonservice.UserID(c), middleware.ClientIP(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AIController AI 策略与用量上报。
|
||||
type AIController struct {
|
||||
Admin *service.AdminService
|
||||
}
|
||||
|
||||
func (h *AIController) Policy(c *gin.Context) {
|
||||
out, err := h.Admin.GetAIPolicy(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AIController) Usage(c *gin.Context) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
Estimated bool `json:"estimated"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.ReportAIUsage(commonservice.UserID(c), req.TeamID, req.Provider, req.PromptTokens, req.CompletionTokens, req.Estimated); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AppController 客户端更新检查/下载。
|
||||
type AppController struct {
|
||||
Rel *service.ReleaseService
|
||||
}
|
||||
|
||||
func (h *AppController) Latest(c *gin.Context) {
|
||||
out, err := h.Rel.Latest(c.DefaultQuery("channel", "stable"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AppController) Download(c *gin.Context) {
|
||||
row, f, err := h.Rel.OpenFile(c.Param("version"), c.DefaultQuery("channel", "stable"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", `attachment; filename="`+row.Version+`-installer.exe"`)
|
||||
c.Header("X-Content-SHA256", row.SHA256)
|
||||
http.ServeContent(c.Writer, c.Request, row.Version+"-installer.exe", time.Time{}, f)
|
||||
}
|
||||
77
internal/controller/auth.go
Normal file
77
internal/controller/auth.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/middleware"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// AuthController 认证相关 HTTP 绑定。
|
||||
type AuthController struct {
|
||||
Svc *service.AuthService
|
||||
}
|
||||
|
||||
func (h *AuthController) Register(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.Register(req.Username, req.Password); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthController) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.Login(req.Username, req.Password, middleware.ClientIP(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AuthController) Refresh(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.Refresh(req.RefreshToken, middleware.ClientIP(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AuthController) ChangePassword(c *gin.Context) {
|
||||
var req struct {
|
||||
OldPassword string `json:"oldPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.ChangePassword(commonservice.UserID(c), req.OldPassword, req.NewPassword); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
75
internal/controller/file.go
Normal file
75
internal/controller/file.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// FileController 文件上传/列表/删除。
|
||||
type FileController struct {
|
||||
Svc *service.FileService
|
||||
}
|
||||
|
||||
func (h *FileController) Upload(c *gin.Context) {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_READ_FAILED"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
out, err := h.Svc.Upload(
|
||||
commonservice.UserID(c),
|
||||
commonservice.ParseID(c.PostForm("teamId")),
|
||||
c.PostForm("kind"),
|
||||
fh.Filename,
|
||||
f,
|
||||
fh.Size,
|
||||
requestHost(c),
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *FileController) List(c *gin.Context) {
|
||||
total, items, err := h.Svc.List(
|
||||
commonservice.UserID(c),
|
||||
c.Query("scope"),
|
||||
commonservice.ParseID(c.Query("teamId")),
|
||||
commonservice.ParseID(c.Query("page")),
|
||||
commonservice.ParseID(c.Query("pageSize")),
|
||||
requestHost(c),
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "items": items})
|
||||
}
|
||||
|
||||
func (h *FileController) Delete(c *gin.Context) {
|
||||
if err := h.Svc.Delete(commonservice.UserID(c), commonservice.ParseID(c.Param("id"))); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func requestHost(c *gin.Context) string {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + c.Request.Host
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package handler_test
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -17,35 +17,49 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/config"
|
||||
"nl-pms-api/internal/model"
|
||||
"nl-pms-api/internal/router"
|
||||
)
|
||||
|
||||
const testKey = "test-key"
|
||||
const testSecret = "test-jwt-secret"
|
||||
|
||||
// newTestServer 起一个内存 SQLite 后端的完整路由,并铺好 users / team_members 假数据:
|
||||
// 1=admin(超管)、2=alice(团队 10 owner)、3=bob(团队 10 member)。
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *gorm.DB, *config.Config) {
|
||||
// newTestServer 起内存 SQLite 后端完整路由,并铺好 users / team_members:
|
||||
// 1=admin、2=alice(团队 10 owner)、3=bob(团队 10 member)。
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *gorm.DB, *config.Config, map[int64]string) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.File{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.File{}, &model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
db.Exec(`CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT)`)
|
||||
db.Exec(`CREATE TABLE team_members(team_id INTEGER, user_id INTEGER, role TEXT)`)
|
||||
db.Exec(`INSERT INTO users(id, username) VALUES(1,'admin'),(2,'alice'),(3,'bob')`)
|
||||
db.Exec(`INSERT INTO users(id, username, password_hash, created_at) VALUES(1,'admin','x','t'),(2,'alice','x','t'),(3,'bob','x','t')`)
|
||||
db.Exec(`INSERT INTO team_members(team_id,user_id,role) VALUES(10,2,'owner'),(10,3,'member')`)
|
||||
cfg := &config.Config{Env: "dev", APIKey: testKey, StorageDir: t.TempDir(), MaxUploadMB: 20}
|
||||
cfg := &config.Config{
|
||||
Env: "dev",
|
||||
JWTSecret: testSecret,
|
||||
StorageDir: t.TempDir(),
|
||||
MaxUploadMB: 20,
|
||||
AccessTTLHours: 2,
|
||||
RefreshTTLDays: 30,
|
||||
}
|
||||
tokens := map[int64]string{}
|
||||
for id, name := range map[int64]string{1: "admin", 2: "alice", 3: "bob"} {
|
||||
tok, err := commonservice.IssueAccess(testSecret, id, name, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
tokens[id] = tok
|
||||
}
|
||||
srv := httptest.NewServer(router.New(cfg, db))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, db, cfg
|
||||
return srv, db, cfg, tokens
|
||||
}
|
||||
|
||||
// pngBytes 生成一张纯色 PNG;tone 不同则内容(sha256)不同。
|
||||
func pngBytes(t *testing.T, tone uint8) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
@@ -68,7 +82,7 @@ type uploadResp struct {
|
||||
Mime string `json:"mime"`
|
||||
}
|
||||
|
||||
func upload(t *testing.T, url, key string, data []byte, userID, teamID string) (*http.Response, uploadResp) {
|
||||
func upload(t *testing.T, url, token string, data []byte, teamID string) (*http.Response, uploadResp) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
@@ -80,13 +94,12 @@ func upload(t *testing.T, url, key string, data []byte, userID, teamID string) (
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_ = w.WriteField("kind", "content")
|
||||
_ = w.WriteField("userId", userID)
|
||||
_ = w.WriteField("teamId", teamID)
|
||||
_ = w.Close()
|
||||
req, _ := http.NewRequest("POST", url+"/api/v1/files", &body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
if key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -98,10 +111,10 @@ func upload(t *testing.T, url, key string, data []byte, userID, teamID string) (
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func listFiles(t *testing.T, base, scope, userID, teamID string) (int, int64, []map[string]any) {
|
||||
func listFiles(t *testing.T, base, token, scope, teamID string) (int, int64, []map[string]any) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", base+"/api/v1/files?scope="+scope+"&userId="+userID+"&teamId="+teamID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testKey)
|
||||
req, _ := http.NewRequest("GET", base+"/api/v1/files?scope="+scope+"&teamId="+teamID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
@@ -115,10 +128,10 @@ func listFiles(t *testing.T, base, scope, userID, teamID string) (int, int64, []
|
||||
return resp.StatusCode, out.Total, out.Items
|
||||
}
|
||||
|
||||
func deleteFile(t *testing.T, base string, id int64, userID string) int {
|
||||
func deleteFile(t *testing.T, base, token string, id int64) int {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("DELETE", base+"/api/v1/files/"+jsonNum(id)+"?userId="+userID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+testKey)
|
||||
req, _ := http.NewRequest("DELETE", base+"/api/v1/files/"+jsonNum(id), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
@@ -133,7 +146,7 @@ func jsonNum(n int64) string {
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("healthz: %v %v", err, resp)
|
||||
@@ -141,26 +154,25 @@ func TestHealthz(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUploadRequiresAuth(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
if resp, _ := upload(t, srv.URL, "", pngBytes(t, 200), "2", "0"); resp.StatusCode != http.StatusUnauthorized {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
if resp, _ := upload(t, srv.URL, "", pngBytes(t, 200), "0"); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := upload(t, srv.URL, "wrong", pngBytes(t, 200), "2", "0"); resp.StatusCode != http.StatusUnauthorized {
|
||||
if resp, _ := upload(t, srv.URL, "wrong", pngBytes(t, 200), "0"); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadServeAndScopedDedupe(t *testing.T) {
|
||||
srv, db, cfg := newTestServer(t)
|
||||
srv, db, cfg, tokens := newTestServer(t)
|
||||
data := pngBytes(t, 200)
|
||||
resp, out := upload(t, srv.URL, testKey, data, "2", "10")
|
||||
resp, out := upload(t, srv.URL, tokens[2], data, "10")
|
||||
if resp.StatusCode != 200 || out.Name == "" || out.Mime != "image/png" {
|
||||
t.Fatalf("upload failed: %d %+v", resp.StatusCode, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(out.Name))); err != nil {
|
||||
t.Fatalf("file not on disk: %v", err)
|
||||
}
|
||||
// 公开访问返回原始内容
|
||||
got, err := http.Get(srv.URL + "/files/" + out.Name)
|
||||
if err != nil || got.StatusCode != 200 {
|
||||
t.Fatalf("serve: %v %v", err, got)
|
||||
@@ -170,13 +182,11 @@ func TestUploadServeAndScopedDedupe(t *testing.T) {
|
||||
if !bytes.Equal(served.Bytes(), data) {
|
||||
t.Fatal("served content mismatch")
|
||||
}
|
||||
// 同归属(user=2, team=10)重复上传 → 秒传复用同一条记录
|
||||
_, again := upload(t, srv.URL, testKey, data, "2", "10")
|
||||
_, again := upload(t, srv.URL, tokens[2], data, "10")
|
||||
if again.ID != out.ID || again.Name != out.Name {
|
||||
t.Fatalf("same-owner dedupe failed: %+v vs %+v", again, out)
|
||||
}
|
||||
// 不同归属(user=3)上传同内容 → 独立记录独立文件,删除互不影响
|
||||
_, other := upload(t, srv.URL, testKey, data, "3", "10")
|
||||
_, other := upload(t, srv.URL, tokens[3], data, "10")
|
||||
if other.ID == out.ID || other.Name == out.Name {
|
||||
t.Fatalf("cross-owner upload should create its own record: %+v", other)
|
||||
}
|
||||
@@ -188,75 +198,66 @@ func TestUploadServeAndScopedDedupe(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUploadRejectsNonImage(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
if resp, _ := upload(t, srv.URL, testKey, []byte("plain text, not an image"), "2", "0"); resp.StatusCode != http.StatusUnsupportedMediaType {
|
||||
srv, _, _, tokens := newTestServer(t)
|
||||
if resp, _ := upload(t, srv.URL, tokens[2], []byte("plain text, not an image"), "0"); resp.StatusCode != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("want 415, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListScopes(t *testing.T) {
|
||||
srv, _, _ := newTestServer(t)
|
||||
upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10") // alice 团队图 x2
|
||||
upload(t, srv.URL, testKey, pngBytes(t, 20), "2", "10")
|
||||
upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "10") // bob 团队图
|
||||
upload(t, srv.URL, testKey, pngBytes(t, 40), "3", "0") // bob 个人图
|
||||
srv, _, _, tokens := newTestServer(t)
|
||||
upload(t, srv.URL, tokens[2], pngBytes(t, 10), "10")
|
||||
upload(t, srv.URL, tokens[2], pngBytes(t, 20), "10")
|
||||
upload(t, srv.URL, tokens[3], pngBytes(t, 30), "10")
|
||||
upload(t, srv.URL, tokens[3], pngBytes(t, 40), "0")
|
||||
|
||||
if code, total, items := listFiles(t, srv.URL, "mine", "2", "0"); code != 200 || total != 2 || len(items) != 2 {
|
||||
if code, total, items := listFiles(t, srv.URL, tokens[2], "mine", "0"); code != 200 || total != 2 || len(items) != 2 {
|
||||
t.Fatalf("mine(alice): code=%d total=%d n=%d", code, total, len(items))
|
||||
}
|
||||
// 团队视角:owner 可见团队 3 张(含上传者用户名)
|
||||
code, total, items := listFiles(t, srv.URL, "team", "2", "10")
|
||||
code, total, items := listFiles(t, srv.URL, tokens[2], "team", "10")
|
||||
if code != 200 || total != 3 {
|
||||
t.Fatalf("team(owner): code=%d total=%d", code, total)
|
||||
}
|
||||
if items[0]["username"] == "" {
|
||||
t.Fatalf("team items should carry username: %+v", items[0])
|
||||
}
|
||||
// member 无权看团队素材
|
||||
if code, _, _ := listFiles(t, srv.URL, "team", "3", "10"); code != http.StatusForbidden {
|
||||
if code, _, _ := listFiles(t, srv.URL, tokens[3], "team", "10"); code != http.StatusForbidden {
|
||||
t.Fatalf("team(member) should be 403, got %d", code)
|
||||
}
|
||||
// all 仅超管
|
||||
if code, total, _ := listFiles(t, srv.URL, "all", "1", "0"); code != 200 || total != 4 {
|
||||
if code, total, _ := listFiles(t, srv.URL, tokens[1], "all", "0"); code != 200 || total != 4 {
|
||||
t.Fatalf("all(admin): code=%d total=%d", code, total)
|
||||
}
|
||||
if code, _, _ := listFiles(t, srv.URL, "all", "2", "0"); code != http.StatusForbidden {
|
||||
if code, _, _ := listFiles(t, srv.URL, tokens[2], "all", "0"); code != http.StatusForbidden {
|
||||
t.Fatalf("all(non-admin) should be 403, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePermissions(t *testing.T) {
|
||||
srv, _, cfg := newTestServer(t)
|
||||
_, aliceTeam := upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10")
|
||||
_, bobTeam := upload(t, srv.URL, testKey, pngBytes(t, 20), "3", "10")
|
||||
_, bobOwn := upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "0")
|
||||
_, aliceOwn := upload(t, srv.URL, testKey, pngBytes(t, 40), "2", "0")
|
||||
srv, _, cfg, tokens := newTestServer(t)
|
||||
_, aliceTeam := upload(t, srv.URL, tokens[2], pngBytes(t, 10), "10")
|
||||
_, bobTeam := upload(t, srv.URL, tokens[3], pngBytes(t, 20), "10")
|
||||
_, bobOwn := upload(t, srv.URL, tokens[3], pngBytes(t, 30), "0")
|
||||
_, aliceOwn := upload(t, srv.URL, tokens[2], pngBytes(t, 40), "0")
|
||||
|
||||
// member 不能删别人的(即使同团队)
|
||||
if code := deleteFile(t, srv.URL, aliceTeam.ID, "3"); code != http.StatusForbidden {
|
||||
if code := deleteFile(t, srv.URL, tokens[3], aliceTeam.ID); code != http.StatusForbidden {
|
||||
t.Fatalf("member deleting other's file should be 403, got %d", code)
|
||||
}
|
||||
// 团队 owner 可删团队内他人上传的
|
||||
if code := deleteFile(t, srv.URL, bobTeam.ID, "2"); code != 200 {
|
||||
if code := deleteFile(t, srv.URL, tokens[2], bobTeam.ID); code != 200 {
|
||||
t.Fatalf("owner deleting team file should be 200, got %d", code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(bobTeam.Name))); !os.IsNotExist(err) {
|
||||
t.Fatal("deleted file should be removed from disk")
|
||||
}
|
||||
// owner 不能删团队外的个人文件
|
||||
if code := deleteFile(t, srv.URL, bobOwn.ID, "2"); code != http.StatusForbidden {
|
||||
if code := deleteFile(t, srv.URL, tokens[2], bobOwn.ID); code != http.StatusForbidden {
|
||||
t.Fatalf("owner deleting personal file outside team should be 403, got %d", code)
|
||||
}
|
||||
// 本人可删自己的
|
||||
if code := deleteFile(t, srv.URL, bobOwn.ID, "3"); code != 200 {
|
||||
if code := deleteFile(t, srv.URL, tokens[3], bobOwn.ID); code != 200 {
|
||||
t.Fatalf("self delete should be 200, got %d", code)
|
||||
}
|
||||
// 超管可删任何
|
||||
if code := deleteFile(t, srv.URL, aliceOwn.ID, "1"); code != 200 {
|
||||
if code := deleteFile(t, srv.URL, tokens[1], aliceOwn.ID); code != 200 {
|
||||
t.Fatalf("admin delete should be 200, got %d", code)
|
||||
}
|
||||
// 已删除 → 404
|
||||
if code := deleteFile(t, srv.URL, aliceOwn.ID, "1"); code != http.StatusNotFound {
|
||||
if code := deleteFile(t, srv.URL, tokens[1], aliceOwn.ID); code != http.StatusNotFound {
|
||||
t.Fatalf("double delete should be 404, got %d", code)
|
||||
}
|
||||
}
|
||||
25
internal/controller/notice.go
Normal file
25
internal/controller/notice.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// NoticeController 团队通知。
|
||||
type NoticeController struct {
|
||||
Svc *service.NoticeService
|
||||
}
|
||||
|
||||
func (h *NoticeController) List(c *gin.Context) {
|
||||
after := commonservice.ParseID(c.Query("after"))
|
||||
rows, err := h.Svc.ListNotices(commonservice.UserID(c), after)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": rows})
|
||||
}
|
||||
74
internal/controller/profile.go
Normal file
74
internal/controller/profile.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// ProfileController 用户资料。
|
||||
type ProfileController struct {
|
||||
Svc *service.ProfileService
|
||||
AvatarHist *service.AvatarHistoryService
|
||||
}
|
||||
|
||||
func (h *ProfileController) Get(c *gin.Context) {
|
||||
out, err := h.Svc.Get(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *ProfileController) Put(c *gin.Context) {
|
||||
var req service.ProfileDTO
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.Put(commonservice.UserID(c), req)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ListAvatars GET /profile/avatars
|
||||
func (h *ProfileController) ListAvatars(c *gin.Context) {
|
||||
out, err := h.AvatarHist.List(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// PushAvatar POST /profile/avatars body: {mode,value}
|
||||
func (h *ProfileController) PushAvatar(c *gin.Context) {
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.AvatarHist.Push(commonservice.UserID(c), req.Mode, req.Value)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// DeleteAvatar DELETE /profile/avatars/:id
|
||||
func (h *ProfileController) DeleteAvatar(c *gin.Context) {
|
||||
if err := h.AvatarHist.Delete(commonservice.UserID(c), commonservice.ParseID(c.Param("id"))); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
29
internal/controller/respond.go
Normal file
29
internal/controller/respond.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
)
|
||||
|
||||
// writeErr 统一把业务错误写成 {"error":"CODE"}。
|
||||
func writeErr(c *gin.Context, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if ae, ok := commonservice.AsAppError(err); ok {
|
||||
c.JSON(ae.Status, gin.H{"error": ae.Code})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
|
||||
}
|
||||
|
||||
func bindJSON(c *gin.Context, dst any) bool {
|
||||
if err := c.ShouldBindJSON(dst); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
62
internal/controller/settings.go
Normal file
62
internal/controller/settings.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// SettingsController 用户/全局设置。
|
||||
type SettingsController struct {
|
||||
Svc *service.SettingsService
|
||||
}
|
||||
|
||||
func (h *SettingsController) Get(c *gin.Context) {
|
||||
if prefix := c.Query("prefix"); prefix != "" {
|
||||
rows, err := h.Svc.ListByPrefix(commonservice.UserID(c), prefix)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": rows})
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "SETTING_NAME_REQUIRED"})
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetSetting(commonservice.UserID(c), name)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
|
||||
func (h *SettingsController) Put(c *gin.Context) {
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.PutSetting(commonservice.UserID(c), c.Param("name"), req.Value, req.UpdatedAt); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *SettingsController) GetGlobal(c *gin.Context) {
|
||||
row, err := h.Svc.GetGlobal(c.Param("name"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
40
internal/controller/sync.go
Normal file
40
internal/controller/sync.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// SyncController 同步推拉。
|
||||
type SyncController struct {
|
||||
Svc *service.SyncService
|
||||
}
|
||||
|
||||
func (h *SyncController) Push(c *gin.Context) {
|
||||
var req struct {
|
||||
Table string `json:"table"`
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
n, err := h.Svc.Push(req.Table, commonservice.UserID(c), req.Rows)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"pushed": n})
|
||||
}
|
||||
|
||||
func (h *SyncController) Pull(c *gin.Context) {
|
||||
rows, err := h.Svc.Pull(c.Query("table"), commonservice.UserID(c), c.Query("cursor"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rows": rows})
|
||||
}
|
||||
263
internal/controller/team.go
Normal file
263
internal/controller/team.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// TeamController 团队协作 HTTP 绑定。
|
||||
type TeamController struct {
|
||||
Svc *service.TeamService
|
||||
}
|
||||
|
||||
func (h *TeamController) Create(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.Create(commonservice.UserID(c), req.Name)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *TeamController) List(c *gin.Context) {
|
||||
out, err := h.Svc.List(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
func (h *TeamController) Rename(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.Rename(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Name); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) SetDigestTime(c *gin.Context) {
|
||||
var req struct {
|
||||
DigestTime string `json:"digestTime"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.SetDigestTime(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.DigestTime); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) Dissolve(c *gin.Context) {
|
||||
if err := h.Svc.Dissolve(commonservice.ParseID(c.Param("id")), commonservice.UserID(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) Leave(c *gin.Context) {
|
||||
if err := h.Svc.Leave(commonservice.ParseID(c.Param("id")), commonservice.UserID(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) Members(c *gin.Context) {
|
||||
out, err := h.Svc.Members(commonservice.ParseID(c.Param("id")), commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
func (h *TeamController) Invite(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.Invite(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Username, req.Role); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) SetRole(c *gin.Context) {
|
||||
var req struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.SetRole(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), commonservice.ParseID(c.Param("userId")), req.Role); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) RemoveMember(c *gin.Context) {
|
||||
if err := h.Svc.RemoveMember(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), commonservice.ParseID(c.Param("userId"))); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskSave(c *gin.Context) {
|
||||
var req service.TeamTaskDTO
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
req.TeamID = commonservice.ParseID(c.Param("id"))
|
||||
if tid := commonservice.ParseID(c.Param("taskId")); tid > 0 {
|
||||
req.ID = tid
|
||||
}
|
||||
out, err := h.Svc.TaskSave(commonservice.UserID(c), req)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskSetStatus(c *gin.Context) {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.TaskSetStatus(commonservice.ParseID(c.Param("id")), commonservice.ParseID(c.Param("taskId")), commonservice.UserID(c), req.Status); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskUrge(c *gin.Context) {
|
||||
if err := h.Svc.TaskUrge(commonservice.ParseID(c.Param("id")), commonservice.ParseID(c.Param("taskId")), commonservice.UserID(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskDelete(c *gin.Context) {
|
||||
if err := h.Svc.TaskDelete(commonservice.ParseID(c.Param("id")), commonservice.ParseID(c.Param("taskId")), commonservice.UserID(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskList(c *gin.Context) {
|
||||
out, err := h.Svc.TaskList(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), c.Query("filter"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
func (h *TeamController) SharedItems(c *gin.Context) {
|
||||
out, err := h.Svc.SharedItems(commonservice.ParseID(c.Param("id")), commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
func (h *TeamController) UrgeShared(c *gin.Context) {
|
||||
var req struct {
|
||||
Kind string `json:"kind"`
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.UrgeShared(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Kind, req.UUID); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) ReportSubmit(c *gin.Context) {
|
||||
var req struct {
|
||||
Date string `json:"date"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.ReportSubmit(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Date, req.Content); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) ReportBoardGet(c *gin.Context) {
|
||||
out, err := h.Svc.ReportBoardGet(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), c.Param("date"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *TeamController) ReportUrge(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.ReportUrge(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.UserID, req.Date); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) DigestSave(c *gin.Context) {
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.DigestSave(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), c.Param("date"), req.Content, req.Provider); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// Open 连接 MySQL 并按环境处理表结构:
|
||||
// dev 环境自动迁移 pms_files;prod 严禁任何 DDL,缺表直接报错退出(提示执行 init.sql)。
|
||||
// dev 自动迁移 File + User(及可选业务表);prod 严禁 DDL,缺 users/pms_files 直接报错。
|
||||
func Open(cfg *config.Config) (*gorm.DB, error) {
|
||||
lv := logger.Warn
|
||||
if cfg.Env == "dev" {
|
||||
@@ -24,11 +24,33 @@ func Open(cfg *config.Config) (*gorm.DB, error) {
|
||||
return nil, fmt.Errorf("连接 MySQL 失败: %w", err)
|
||||
}
|
||||
if cfg.Env == "dev" {
|
||||
if err := db.AutoMigrate(&model.File{}); err != nil {
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.File{},
|
||||
&model.SyncTodo{},
|
||||
&model.SyncTicket{},
|
||||
&model.SyncNote{},
|
||||
&model.SyncSetting{},
|
||||
&model.UserProfile{},
|
||||
&model.UserAvatarHistory{},
|
||||
&model.Team{},
|
||||
&model.TeamMember{},
|
||||
&model.TeamTask{},
|
||||
&model.TeamReport{},
|
||||
&model.TeamDigest{},
|
||||
&model.TeamNotice{},
|
||||
&model.UserDailyActive{},
|
||||
&model.AIUsageDaily{},
|
||||
&model.AppRelease{},
|
||||
&model.AdminStepup{},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("dev 自动迁移失败: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.User{}) {
|
||||
return nil, errors.New("缺少 users 表:生产环境启动不执行迁移,请先手工执行 init.sql")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.File{}) {
|
||||
return nil, errors.New("缺少 pms_files 表:生产环境启动不执行迁移,请先手工执行 init.sql")
|
||||
}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/config"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// adminUserID 是 code_count 体系的超级管理员账号(users.id=1),可见/可管全部文件。
|
||||
const adminUserID = 1
|
||||
|
||||
// FileHandler 提供图片上传与素材库管理:内容落盘 storage_dir,元数据入 pms_files 表。
|
||||
// 身份沿用 code-count 的内网信任模型:客户端自报 userId/teamId,服务端据
|
||||
// code_count 库的 team_members / users 表判定管理范围(防误操作,不防伪造)。
|
||||
type FileHandler struct {
|
||||
DB *gorm.DB
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
// extByMime 是允许上传的图片类型白名单(按内容嗅探判定,不信任扩展名)。
|
||||
var extByMime = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
// Upload 处理 POST /api/v1/files:multipart 字段 file 必填,kind(avatar|content)、
|
||||
// userId、teamId 可选。同一归属(userId+teamId)重复上传同内容直接复用已有记录(秒传);
|
||||
// 不同归属各自落盘,保证"删除自己的素材"不影响他人。
|
||||
// 成功返回 {id, name, url, size, mime},url 可直接放进 <img src> / Markdown。
|
||||
func (h *FileHandler) Upload(c *gin.Context) {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
|
||||
return
|
||||
}
|
||||
if fh.Size > h.Cfg.MaxUploadBytes() {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"})
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_READ_FAILED"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, h.Cfg.MaxUploadBytes()+1))
|
||||
if err != nil || int64(len(data)) > h.Cfg.MaxUploadBytes() {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"})
|
||||
return
|
||||
}
|
||||
mime := http.DetectContentType(data)
|
||||
ext, ok := extByMime[mime]
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "UNSUPPORTED_TYPE"})
|
||||
return
|
||||
}
|
||||
uid := parseID(c.PostForm("userId"))
|
||||
tid := parseID(c.PostForm("teamId"))
|
||||
sum := hex.EncodeToString(func() []byte { s := sha256.Sum256(data); return s[:] }())
|
||||
var rec model.File
|
||||
if h.DB.Where("sha256 = ? AND user_id = ? AND team_id = ?", sum, uid, tid).First(&rec).Error == nil {
|
||||
c.JSON(http.StatusOK, h.fileResponse(c, rec))
|
||||
return
|
||||
}
|
||||
name := storedName(ext)
|
||||
full := filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(full, data, 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
|
||||
return
|
||||
}
|
||||
rec = model.File{
|
||||
Name: name,
|
||||
Original: clip(filepath.Base(fh.Filename), 255),
|
||||
Mime: mime,
|
||||
Size: int64(len(data)),
|
||||
SHA256: sum,
|
||||
UserID: uid,
|
||||
TeamID: tid,
|
||||
Kind: normalizeKind(c.PostForm("kind")),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := h.DB.Create(&rec).Error; err != nil {
|
||||
os.Remove(full)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h.fileResponse(c, rec))
|
||||
}
|
||||
|
||||
// fileItem 是素材库列表项(pms_files 联 users 取上传者名)。
|
||||
type fileItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Original string `json:"original"`
|
||||
Mime string `json:"mime"`
|
||||
Size int64 `json:"size"`
|
||||
UserID int64 `json:"userId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
Kind string `json:"kind"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Username string `json:"username"`
|
||||
URL string `json:"url" gorm:"-"`
|
||||
}
|
||||
|
||||
// List 处理 GET /api/v1/files:素材库分页列表。
|
||||
// scope=mine 看自己(任何登录用户);scope=team 看指定团队(需为该团队 owner/admin);
|
||||
// scope=all 看全部(仅 userId=1)。按 id 倒序分页。
|
||||
func (h *FileHandler) List(c *gin.Context) {
|
||||
uid := parseID(c.Query("userId"))
|
||||
if uid <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "USER_REQUIRED"})
|
||||
return
|
||||
}
|
||||
var where func(*gorm.DB) *gorm.DB
|
||||
switch c.Query("scope") {
|
||||
case "mine":
|
||||
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.user_id = ?", uid) }
|
||||
case "team":
|
||||
tid := parseID(c.Query("teamId"))
|
||||
if !h.isTeamAdmin(tid, uid) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.team_id = ?", tid) }
|
||||
case "all":
|
||||
if uid != adminUserID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
where = func(db *gorm.DB) *gorm.DB { return db }
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_SCOPE"})
|
||||
return
|
||||
}
|
||||
page := parseID(c.Query("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
size := parseID(c.Query("pageSize"))
|
||||
if size < 1 || size > 100 {
|
||||
size = 24
|
||||
}
|
||||
var total int64
|
||||
if err := h.DB.Table("pms_files").Scopes(where).Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"})
|
||||
return
|
||||
}
|
||||
items := []fileItem{}
|
||||
err := h.DB.Table("pms_files").Scopes(where).
|
||||
Select("pms_files.id, pms_files.name, pms_files.original, pms_files.mime, pms_files.size, pms_files.user_id, pms_files.team_id, pms_files.kind, pms_files.created_at, COALESCE(u.username,'') AS username").
|
||||
Joins("LEFT JOIN users u ON u.id = pms_files.user_id").
|
||||
Order("pms_files.id DESC").Limit(int(size)).Offset(int((page - 1) * size)).
|
||||
Scan(&items).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"})
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
items[i].URL = h.publicURL(c, items[i].Name)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "items": items})
|
||||
}
|
||||
|
||||
// Delete 处理 DELETE /api/v1/files/:id:删除记录与磁盘文件。
|
||||
// 允许:本人、超管(userId=1)、该文件归属团队的 owner/admin。
|
||||
func (h *FileHandler) Delete(c *gin.Context) {
|
||||
uid := parseID(c.Query("userId"))
|
||||
id := parseID(c.Param("id"))
|
||||
var rec model.File
|
||||
if h.DB.First(&rec, id).Error != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "NOT_FOUND"})
|
||||
return
|
||||
}
|
||||
allowed := uid == adminUserID ||
|
||||
(uid > 0 && rec.UserID == uid) ||
|
||||
(rec.TeamID > 0 && h.isTeamAdmin(rec.TeamID, uid))
|
||||
if !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
if err := h.DB.Delete(&model.File{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "DELETE_FAILED"})
|
||||
return
|
||||
}
|
||||
// 每条记录独占一个文件(name 唯一),删记录即可删盘;失败不影响结果(孤儿文件可人工清理)。
|
||||
_ = os.Remove(filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(rec.Name)))
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// isTeamAdmin 查 code_count 库的 team_members:该用户是否为团队 owner/admin。
|
||||
func (h *FileHandler) isTeamAdmin(teamID, userID int64) bool {
|
||||
if teamID <= 0 || userID <= 0 {
|
||||
return false
|
||||
}
|
||||
var n int64
|
||||
h.DB.Table("team_members").
|
||||
Where("team_id = ? AND user_id = ? AND role IN ('owner','admin')", teamID, userID).
|
||||
Count(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func (h *FileHandler) fileResponse(c *gin.Context, f model.File) gin.H {
|
||||
return gin.H{"id": f.ID, "name": f.Name, "url": h.publicURL(c, f.Name), "size": f.Size, "mime": f.Mime, "teamId": f.TeamID}
|
||||
}
|
||||
|
||||
// publicURL 拼接文件公开访问地址:优先配置的 base_url,否则按本次请求推断。
|
||||
func (h *FileHandler) publicURL(c *gin.Context, name string) string {
|
||||
base := h.Cfg.BaseURL
|
||||
if base == "" {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
base = scheme + "://" + c.Request.Host
|
||||
}
|
||||
return base + "/files/" + name
|
||||
}
|
||||
|
||||
// storedName 生成不可枚举的存储相对路径(日期目录 + 128 位随机 hex),
|
||||
// 文件公开可读但路径不可猜测,等效 capability URL。
|
||||
func storedName(ext string) string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return time.Now().UTC().Format("2006/01/02") + "/" + hex.EncodeToString(b) + ext
|
||||
}
|
||||
|
||||
func normalizeKind(k string) string {
|
||||
if k == "avatar" || k == "content" {
|
||||
return k
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseID(s string) int64 {
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
125
internal/middleware/admin.go
Normal file
125
internal/middleware/admin.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderAdminStepUp = "X-Admin-StepUp"
|
||||
CtxClientIP = "client_ip"
|
||||
)
|
||||
|
||||
// AttachClientIP 把解析后的客户端 IP 写入上下文。
|
||||
func AttachClientIP() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set(CtxClientIP, strings.TrimSpace(c.ClientIP()))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ClientIP 读取中间件写入的客户端 IP。
|
||||
func ClientIP(c *gin.Context) string {
|
||||
v, _ := c.Get(CtxClientIP)
|
||||
s, _ := v.(string)
|
||||
if s == "" {
|
||||
return strings.TrimSpace(c.ClientIP())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RejectDisabled 拒绝已禁用账号(在 JWT 之后)。
|
||||
func RejectDisabled(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := commonservice.UserID(c)
|
||||
if uid <= 0 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
var u model.User
|
||||
if err := db.Select("id", "disabled").First(&u, uid).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
|
||||
return
|
||||
}
|
||||
if u.Disabled != 0 {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ACCOUNT_DISABLED"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin 仅允许 users.id=1。
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if commonservice.UserID(c) != commonservice.AdminUserID {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdminStepUp 校验 2 小时内有效的动态码二次验证,且 IP 与签发时一致。
|
||||
func RequireAdminStepUp(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if commonservice.UserID(c) != commonservice.AdminUserID {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(c.GetHeader(HeaderAdminStepUp))
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
|
||||
return
|
||||
}
|
||||
ip := ClientIP(c)
|
||||
var row model.AdminStepup
|
||||
if err := db.First(&row, commonservice.AdminUserID).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
if row.TokenHash != hash {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
|
||||
return
|
||||
}
|
||||
exp, err := time.Parse(time.RFC3339, row.ExpiresAt)
|
||||
if err != nil || time.Now().UTC().After(exp) {
|
||||
_ = db.Delete(&model.AdminStepup{}, commonservice.AdminUserID).Error
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
|
||||
return
|
||||
}
|
||||
if row.ClientIP != "" && ip != "" && row.ClientIP != ip {
|
||||
_ = db.Delete(&model.AdminStepup{}, commonservice.AdminUserID).Error
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "ADMIN_IP_CHANGED",
|
||||
"message": "检测到 IP 变化,存在账号被盗风险,请重新输入动态码",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireStepUpForGlobalSetting 仅当写入全局设置键时要求 stepup。
|
||||
func RequireStepUpForGlobalSetting(db *gorm.DB) gin.HandlerFunc {
|
||||
inner := RequireAdminStepUp(db)
|
||||
return func(c *gin.Context) {
|
||||
name := strings.TrimSpace(c.Param("name"))
|
||||
if name == "file_storage" || strings.HasPrefix(name, "fest_img:") {
|
||||
inner(c)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RequireAPIKey 校验 Authorization: Bearer <api_key>(常数时间比较),不匹配返回 401。
|
||||
func RequireAPIKey(key string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
|
||||
if key == "" || subtle.ConstantTimeCompare([]byte(token), []byte(key)) != 1 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
29
internal/middleware/jwt.go
Normal file
29
internal/middleware/jwt.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
)
|
||||
|
||||
// RequireJWT 校验 Authorization: Bearer <access token>,并把 user_id/username 写入上下文。
|
||||
func RequireJWT(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
|
||||
if token == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
|
||||
return
|
||||
}
|
||||
claims, err := commonservice.Parse(secret, token, "access")
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
|
||||
return
|
||||
}
|
||||
c.Set(commonservice.CtxUserID, claims.UserID)
|
||||
c.Set(commonservice.CtxUsername, claims.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
50
internal/model/admin.go
Normal file
50
internal/model/admin.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
// UserDailyActive 对应 user_daily_active。
|
||||
type UserDailyActive struct {
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
ActiveDate string `gorm:"primaryKey;size:10" json:"activeDate"`
|
||||
LastIP string `gorm:"column:last_ip;size:64;not null;default:''" json:"lastIp"`
|
||||
LastSeenAt string `gorm:"column:last_seen_at;size:32;not null;default:''" json:"lastSeenAt"`
|
||||
}
|
||||
|
||||
func (UserDailyActive) TableName() string { return "user_daily_active" }
|
||||
|
||||
// AIUsageDaily 对应 ai_usage_daily。
|
||||
type AIUsageDaily struct {
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
TeamID int64 `gorm:"primaryKey;default:0" json:"teamId"`
|
||||
UsageDate string `gorm:"primaryKey;size:10" json:"usageDate"`
|
||||
Provider string `gorm:"primaryKey;size:32;not null;default:''" json:"provider"`
|
||||
PromptTokens int64 `gorm:"not null;default:0" json:"promptTokens"`
|
||||
CompletionTokens int64 `gorm:"not null;default:0" json:"completionTokens"`
|
||||
Calls int `gorm:"not null;default:0" json:"calls"`
|
||||
Estimated int `gorm:"not null;default:0" json:"estimated"`
|
||||
}
|
||||
|
||||
func (AIUsageDaily) TableName() string { return "ai_usage_daily" }
|
||||
|
||||
// AppRelease 对应 app_releases。
|
||||
type AppRelease struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Version string `gorm:"size:32;not null" json:"version"`
|
||||
Channel string `gorm:"size:16;not null;default:stable" json:"channel"`
|
||||
Filename string `gorm:"size:255;not null" json:"filename"`
|
||||
SHA256 string `gorm:"column:sha256;size:64;not null;default:''" json:"sha256"`
|
||||
SizeBytes int64 `gorm:"column:size_bytes;not null;default:0" json:"sizeBytes"`
|
||||
Changelog string `gorm:"type:mediumtext;not null" json:"changelog"`
|
||||
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
|
||||
IsLatest int `gorm:"column:is_latest;not null;default:0" json:"isLatest"`
|
||||
}
|
||||
|
||||
func (AppRelease) TableName() string { return "app_releases" }
|
||||
|
||||
// AdminStepup 对应 admin_stepup。
|
||||
type AdminStepup struct {
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
TokenHash string `gorm:"column:token_hash;size:64;not null" json:"-"`
|
||||
ClientIP string `gorm:"column:client_ip;size:64;not null;default:''" json:"clientIp"`
|
||||
ExpiresAt string `gorm:"column:expires_at;size:32;not null" json:"expiresAt"`
|
||||
}
|
||||
|
||||
func (AdminStepup) TableName() string { return "admin_stepup" }
|
||||
12
internal/model/avatar_history.go
Normal file
12
internal/model/avatar_history.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
// UserAvatarHistory 用户头像历史,对应 user_avatar_history。
|
||||
type UserAvatarHistory struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID int64 `gorm:"not null;index:idx_uah_user_id" json:"userId"`
|
||||
Mode string `gorm:"size:16;not null;default:url" json:"mode"` // url | base64
|
||||
Value string `gorm:"type:mediumtext;not null" json:"value"`
|
||||
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (UserAvatarHistory) TableName() string { return "user_avatar_history" }
|
||||
15
internal/model/profile.go
Normal file
15
internal/model/profile.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
// UserProfile 对应 user_profiles。
|
||||
type UserProfile struct {
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
|
||||
Title string `gorm:"size:64;not null;default:''" json:"title"`
|
||||
Email string `gorm:"size:128;not null;default:''" json:"email"`
|
||||
Bio string `gorm:"size:500;not null;default:''" json:"bio"`
|
||||
TechTags string `gorm:"size:1000;not null;default:'[]'" json:"techTags"`
|
||||
AvatarThumb string `gorm:"type:mediumtext;not null" json:"avatarThumb"`
|
||||
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (UserProfile) TableName() string { return "user_profiles" }
|
||||
62
internal/model/sync.go
Normal file
62
internal/model/sync.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package model
|
||||
|
||||
// SyncTodo 对应 sync_todos。
|
||||
type SyncTodo struct {
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
UUID string `gorm:"primaryKey;size:36" json:"uuid"`
|
||||
Title string `gorm:"type:text;not null" json:"title"`
|
||||
Content string `gorm:"type:mediumtext;not null" json:"content"`
|
||||
ProjectName string `gorm:"size:255;not null;default:''" json:"projectName"`
|
||||
DueAt string `gorm:"size:32;not null;default:''" json:"dueAt"`
|
||||
Priority string `gorm:"size:16;not null;default:medium" json:"priority"`
|
||||
Status string `gorm:"size:16;not null;default:open" json:"status"`
|
||||
History string `gorm:"type:mediumtext;not null" json:"history"`
|
||||
TeamID int64 `gorm:"not null;default:0;index" json:"teamId"`
|
||||
CreatedAt string `gorm:"size:32;not null;default:''" json:"createdAt"`
|
||||
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
|
||||
Deleted int `gorm:"not null;default:0" json:"deleted"`
|
||||
}
|
||||
|
||||
func (SyncTodo) TableName() string { return "sync_todos" }
|
||||
|
||||
// SyncTicket 对应 sync_tickets。
|
||||
type SyncTicket struct {
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
UUID string `gorm:"primaryKey;size:36" json:"uuid"`
|
||||
Title string `gorm:"type:text;not null" json:"title"`
|
||||
Description string `gorm:"type:mediumtext;not null" json:"description"`
|
||||
Type string `gorm:"size:16;not null;default:task" json:"type"`
|
||||
ProjectName string `gorm:"size:255;not null;default:''" json:"projectName"`
|
||||
StartAt string `gorm:"size:32;not null;default:''" json:"startAt"`
|
||||
DueAt string `gorm:"size:32;not null;default:''" json:"dueAt"`
|
||||
Status string `gorm:"size:16;not null;default:open" json:"status"`
|
||||
Priority string `gorm:"size:16;not null;default:medium" json:"priority"`
|
||||
History string `gorm:"type:mediumtext;not null" json:"history"`
|
||||
TeamID int64 `gorm:"not null;default:0;index" json:"teamId"`
|
||||
CreatedAt string `gorm:"size:32;not null;default:''" json:"createdAt"`
|
||||
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
|
||||
Deleted int `gorm:"not null;default:0" json:"deleted"`
|
||||
}
|
||||
|
||||
func (SyncTicket) TableName() string { return "sync_tickets" }
|
||||
|
||||
// SyncNote 对应 sync_notes。
|
||||
type SyncNote struct {
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
UUID string `gorm:"primaryKey;size:36" json:"uuid"`
|
||||
Content string `gorm:"type:mediumtext;not null" json:"content"`
|
||||
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
|
||||
Deleted int `gorm:"not null;default:0" json:"deleted"`
|
||||
}
|
||||
|
||||
func (SyncNote) TableName() string { return "sync_notes" }
|
||||
|
||||
// SyncSetting 对应 sync_settings。
|
||||
type SyncSetting struct {
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
Name string `gorm:"primaryKey;size:64" json:"name"`
|
||||
Value string `gorm:"type:mediumtext;not null" json:"value"`
|
||||
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SyncSetting) TableName() string { return "sync_settings" }
|
||||
80
internal/model/team.go
Normal file
80
internal/model/team.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package model
|
||||
|
||||
// Team 对应 teams。
|
||||
type Team struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
OwnerID int64 `gorm:"not null" json:"ownerId"`
|
||||
DigestTime string `gorm:"size:8;not null;default:21:00" json:"digestTime"`
|
||||
AIBanned int `gorm:"column:ai_banned;not null;default:0" json:"aiBanned"`
|
||||
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (Team) TableName() string { return "teams" }
|
||||
|
||||
// TeamMember 对应 team_members。
|
||||
type TeamMember struct {
|
||||
TeamID int64 `gorm:"primaryKey" json:"teamId"`
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
Role string `gorm:"size:16;not null;default:member" json:"role"`
|
||||
JoinedAt string `gorm:"size:32;not null" json:"joinedAt"`
|
||||
}
|
||||
|
||||
func (TeamMember) TableName() string { return "team_members" }
|
||||
|
||||
// TeamTask 对应 team_tasks。
|
||||
type TeamTask struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
TeamID int64 `gorm:"not null;index" json:"teamId"`
|
||||
Kind string `gorm:"size:16;not null;default:todo" json:"kind"`
|
||||
Title string `gorm:"type:text;not null" json:"title"`
|
||||
Description string `gorm:"type:mediumtext;not null" json:"description"`
|
||||
Priority string `gorm:"size:16;not null;default:medium" json:"priority"`
|
||||
Status string `gorm:"size:16;not null;default:open" json:"status"`
|
||||
CreatorID int64 `gorm:"not null" json:"creatorId"`
|
||||
AssigneeID int64 `gorm:"not null;default:0;index" json:"assigneeId"`
|
||||
StartAt string `gorm:"size:32;not null;default:''" json:"startAt"`
|
||||
DueAt string `gorm:"size:32;not null;default:''" json:"dueAt"`
|
||||
UrgedAt string `gorm:"size:32;not null;default:''" json:"urgedAt"`
|
||||
History string `gorm:"type:mediumtext;not null" json:"history"`
|
||||
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
|
||||
Deleted int `gorm:"not null;default:0" json:"deleted"`
|
||||
}
|
||||
|
||||
func (TeamTask) TableName() string { return "team_tasks" }
|
||||
|
||||
// TeamReport 对应 team_reports。
|
||||
type TeamReport struct {
|
||||
TeamID int64 `gorm:"primaryKey" json:"teamId"`
|
||||
UserID int64 `gorm:"primaryKey" json:"userId"`
|
||||
Date string `gorm:"primaryKey;size:10" json:"date"`
|
||||
Content string `gorm:"type:mediumtext;not null" json:"content"`
|
||||
SubmittedAt string `gorm:"size:32;not null" json:"submittedAt"`
|
||||
}
|
||||
|
||||
func (TeamReport) TableName() string { return "team_reports" }
|
||||
|
||||
// TeamDigest 对应 team_digests。
|
||||
type TeamDigest struct {
|
||||
TeamID int64 `gorm:"primaryKey" json:"teamId"`
|
||||
Date string `gorm:"primaryKey;size:10" json:"date"`
|
||||
Content string `gorm:"type:mediumtext;not null" json:"content"`
|
||||
Provider string `gorm:"size:32;not null;default:''" json:"provider"`
|
||||
GeneratedAt string `gorm:"size:32;not null" json:"generatedAt"`
|
||||
}
|
||||
|
||||
func (TeamDigest) TableName() string { return "team_digests" }
|
||||
|
||||
// TeamNotice 对应 team_notices。
|
||||
type TeamNotice struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
TeamID int64 `gorm:"not null" json:"teamId"`
|
||||
ToUser int64 `gorm:"not null;index" json:"toUser"`
|
||||
FromUser int64 `gorm:"not null" json:"fromUser"`
|
||||
Kind string `gorm:"size:16;not null" json:"kind"`
|
||||
RefID string `gorm:"size:64;not null;default:''" json:"refId"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (TeamNotice) TableName() string { return "team_notices" }
|
||||
17
internal/model/user.go
Normal file
17
internal/model/user.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
// User 对应 users 表。
|
||||
type User struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"size:100;not null" json:"-"`
|
||||
TOTPSecret string `gorm:"column:totp_secret;size:64;not null;default:''" json:"-"`
|
||||
TOTPEnabled int `gorm:"column:totp_enabled;not null;default:0" json:"totpEnabled"`
|
||||
LastLoginIP string `gorm:"column:last_login_ip;size:64;not null;default:''" json:"lastLoginIp"`
|
||||
LastSeenAt string `gorm:"column:last_seen_at;size:32;not null;default:''" json:"lastSeenAt"`
|
||||
AIBanned int `gorm:"column:ai_banned;not null;default:0" json:"aiBanned"`
|
||||
Disabled int `gorm:"not null;default:0" json:"disabled"`
|
||||
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
@@ -7,36 +7,129 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/config"
|
||||
"nl-pms-api/internal/handler"
|
||||
"nl-pms-api/internal/controller"
|
||||
"nl-pms-api/internal/middleware"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// New 组装路由:
|
||||
// - GET /healthz 健康检查(公开,view「测试连接」用)
|
||||
// - POST /api/v1/files 上传图片(Bearer 密钥鉴权)
|
||||
// - GET /api/v1/files 素材库列表(scope=mine|team|all,按角色限定范围)
|
||||
// - DELETE /api/v1/files/:id 删除素材(本人 / 超管 id=1 / 团队 owner|admin)
|
||||
// - GET /files/*filepath 文件公开访问(img 标签无法带鉴权头;随机文件名不可枚举)
|
||||
// New 组装路由:公开 healthz/auth,JWT 保护同步/设置/资料/团队/文件/后台。
|
||||
// 若配置了 base_path(如 /pms-api),全部路由挂在该前缀下,适配不剥路径的反代。
|
||||
func New(cfg *config.Config, db *gorm.DB) *gin.Engine {
|
||||
if cfg.Env != "dev" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
r.MaxMultipartMemory = 8 << 20
|
||||
r.Use(gin.Logger(), gin.Recovery(), middleware.AttachClientIP())
|
||||
r.MaxMultipartMemory = 32 << 20
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
if len(cfg.TrustedProxies) > 0 {
|
||||
_ = r.SetTrustedProxies(cfg.TrustedProxies)
|
||||
} else {
|
||||
// 空切片=不信任任何反代,ClientIP 用 RemoteAddr(避免默认信任全部)
|
||||
_ = r.SetTrustedProxies([]string{})
|
||||
}
|
||||
|
||||
root := r.Group(cfg.BasePath)
|
||||
|
||||
root.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "nl-pms-api"})
|
||||
})
|
||||
|
||||
h := &handler.FileHandler{DB: db, Cfg: cfg}
|
||||
api := r.Group("/api/v1", middleware.RequireAPIKey(cfg.APIKey))
|
||||
api.POST("/files", h.Upload)
|
||||
api.GET("/files", h.List)
|
||||
api.DELETE("/files/:id", h.Delete)
|
||||
authSvc := &service.AuthService{DB: db, Cfg: cfg}
|
||||
authC := &controller.AuthController{Svc: authSvc}
|
||||
syncC := &controller.SyncController{Svc: &service.SyncService{DB: db}}
|
||||
settingsC := &controller.SettingsController{Svc: &service.SettingsService{DB: db}}
|
||||
profileC := &controller.ProfileController{
|
||||
Svc: &service.ProfileService{DB: db},
|
||||
AvatarHist: &service.AvatarHistoryService{DB: db},
|
||||
}
|
||||
noticeC := &controller.NoticeController{Svc: &service.NoticeService{DB: db}}
|
||||
fileC := &controller.FileController{Svc: &service.FileService{DB: db, Cfg: cfg}}
|
||||
teamC := &controller.TeamController{Svc: &service.TeamService{DB: db}}
|
||||
|
||||
files := r.Group("/files", func(c *gin.Context) {
|
||||
// 文件名含随机 hex,内容不可变,允许长缓存。
|
||||
adminSvc := &service.AdminService{DB: db}
|
||||
secSvc := &service.AdminSecurityService{DB: db}
|
||||
relSvc := &service.ReleaseService{DB: db, Cfg: cfg}
|
||||
actSvc := &service.ActivityService{DB: db}
|
||||
adminC := &controller.AdminController{Admin: adminSvc, Sec: secSvc, Rel: relSvc, Act: actSvc}
|
||||
actC := &controller.ActivityController{Svc: actSvc}
|
||||
aiC := &controller.AIController{Admin: adminSvc}
|
||||
appC := &controller.AppController{Rel: relSvc}
|
||||
|
||||
pub := root.Group("/api/v1")
|
||||
pub.POST("/auth/register", authC.Register)
|
||||
pub.POST("/auth/login", authC.Login)
|
||||
pub.POST("/auth/refresh", authC.Refresh)
|
||||
|
||||
api := root.Group("/api/v1", middleware.RequireJWT(cfg.JWTSecret), middleware.RejectDisabled(db))
|
||||
api.POST("/auth/change-password", authC.ChangePassword)
|
||||
|
||||
api.POST("/sync/push", syncC.Push)
|
||||
api.GET("/sync/pull", syncC.Pull)
|
||||
|
||||
api.GET("/settings", settingsC.Get) // ?prefix=fest_img:
|
||||
api.GET("/settings/global/:name", settingsC.GetGlobal)
|
||||
api.GET("/settings/:name", settingsC.Get)
|
||||
api.PUT("/settings/:name", middleware.RequireStepUpForGlobalSetting(db), settingsC.Put)
|
||||
|
||||
api.GET("/profile", profileC.Get)
|
||||
api.PUT("/profile", profileC.Put)
|
||||
api.GET("/profile/avatars", profileC.ListAvatars)
|
||||
api.POST("/profile/avatars", profileC.PushAvatar)
|
||||
api.DELETE("/profile/avatars/:id", profileC.DeleteAvatar)
|
||||
|
||||
api.GET("/notices", noticeC.List)
|
||||
|
||||
api.POST("/teams", teamC.Create)
|
||||
api.GET("/teams", teamC.List)
|
||||
api.PUT("/teams/:id", teamC.Rename)
|
||||
api.PUT("/teams/:id/digest-time", teamC.SetDigestTime)
|
||||
api.DELETE("/teams/:id", teamC.Dissolve)
|
||||
api.POST("/teams/:id/leave", teamC.Leave)
|
||||
api.GET("/teams/:id/members", teamC.Members)
|
||||
api.POST("/teams/:id/invite", teamC.Invite)
|
||||
api.PUT("/teams/:id/members/:userId/role", teamC.SetRole)
|
||||
api.DELETE("/teams/:id/members/:userId", teamC.RemoveMember)
|
||||
api.POST("/teams/:id/tasks", teamC.TaskSave)
|
||||
api.PUT("/teams/:id/tasks/:taskId", teamC.TaskSave)
|
||||
api.PUT("/teams/:id/tasks/:taskId/status", teamC.TaskSetStatus)
|
||||
api.POST("/teams/:id/tasks/:taskId/urge", teamC.TaskUrge)
|
||||
api.DELETE("/teams/:id/tasks/:taskId", teamC.TaskDelete)
|
||||
api.GET("/teams/:id/tasks", teamC.TaskList)
|
||||
api.GET("/teams/:id/shared", teamC.SharedItems)
|
||||
api.POST("/teams/:id/shared/urge", teamC.UrgeShared)
|
||||
api.POST("/teams/:id/reports", teamC.ReportSubmit)
|
||||
api.GET("/teams/:id/reports/:date", teamC.ReportBoardGet)
|
||||
api.POST("/teams/:id/reports/urge", teamC.ReportUrge)
|
||||
api.PUT("/teams/:id/digests/:date", teamC.DigestSave)
|
||||
|
||||
api.POST("/files", fileC.Upload)
|
||||
api.GET("/files", fileC.List)
|
||||
api.DELETE("/files/:id", fileC.Delete)
|
||||
|
||||
api.POST("/activity/ping", actC.Ping)
|
||||
api.GET("/ai/policy", aiC.Policy)
|
||||
api.POST("/ai/usage", aiC.Usage)
|
||||
api.GET("/app/latest", appC.Latest)
|
||||
api.GET("/app/download/:version", appC.Download)
|
||||
|
||||
admin := api.Group("/admin", middleware.RequireAdmin())
|
||||
admin.GET("/totp/status", adminC.TOTPStatus)
|
||||
admin.POST("/totp/setup", adminC.TOTPSetupBegin)
|
||||
admin.POST("/totp/confirm", adminC.TOTPSetupConfirm)
|
||||
admin.POST("/stepup", adminC.StepUp)
|
||||
admin.GET("/stats/overview", adminC.Overview)
|
||||
admin.GET("/users", adminC.ListUsers)
|
||||
admin.GET("/teams", adminC.ListTeams)
|
||||
admin.GET("/releases", adminC.ListReleases)
|
||||
|
||||
adminWrite := admin.Group("", middleware.RequireAdminStepUp(db))
|
||||
adminWrite.PATCH("/users/:id", adminC.PatchUser)
|
||||
adminWrite.PATCH("/teams/:id", adminC.PatchTeam)
|
||||
adminWrite.POST("/releases", adminC.UploadRelease)
|
||||
adminWrite.POST("/releases/:id/publish", adminC.PublishRelease)
|
||||
|
||||
files := root.Group("/files", func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
})
|
||||
files.Static("/", cfg.StorageDir)
|
||||
|
||||
44
internal/service/activity.go
Normal file
44
internal/service/activity.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// ActivityService 日活埋点。
|
||||
type ActivityService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// Ping 记录今日活跃并刷新 users.last_seen_at / last_login_ip。
|
||||
func (s *ActivityService) Ping(userID int64, clientIP string) error {
|
||||
if userID <= 0 {
|
||||
return commonservice.Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
now := commonservice.NowRFC()
|
||||
today := time.Now().In(time.Local).Format("2006-01-02")
|
||||
ip := strings.TrimSpace(clientIP)
|
||||
row := model.UserDailyActive{
|
||||
UserID: userID,
|
||||
ActiveDate: today,
|
||||
LastIP: ip,
|
||||
LastSeenAt: now,
|
||||
}
|
||||
if err := s.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "active_date"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"last_ip", "last_seen_at"}),
|
||||
}).Create(&row).Error; err != nil {
|
||||
return commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
_ = s.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
||||
"last_seen_at": now,
|
||||
"last_login_ip": ip,
|
||||
}).Error
|
||||
return nil
|
||||
}
|
||||
306
internal/service/admin.go
Normal file
306
internal/service/admin.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// AdminService 运营后台:统计、用户、团队。
|
||||
type AdminService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
type StatsPoint struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type TokenPoint struct {
|
||||
Date string `json:"date"`
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
Calls int64 `json:"calls"`
|
||||
}
|
||||
|
||||
type OverviewStats struct {
|
||||
UserCount int64 `json:"userCount"`
|
||||
TeamCount int64 `json:"teamCount"`
|
||||
DAUToday int64 `json:"dauToday"`
|
||||
DAUSeries []StatsPoint `json:"dauSeries"`
|
||||
TokenToday TokenPoint `json:"tokenToday"`
|
||||
TokenSeries []TokenPoint `json:"tokenSeries"`
|
||||
}
|
||||
|
||||
// Overview 概览统计。
|
||||
func (s *AdminService) Overview(days int) (*OverviewStats, error) {
|
||||
if days <= 0 || days > 90 {
|
||||
days = 14
|
||||
}
|
||||
out := &OverviewStats{
|
||||
DAUSeries: []StatsPoint{},
|
||||
TokenSeries: []TokenPoint{},
|
||||
}
|
||||
s.DB.Model(&model.User{}).Count(&out.UserCount)
|
||||
s.DB.Model(&model.Team{}).Count(&out.TeamCount)
|
||||
|
||||
today := time.Now().In(time.Local).Format("2006-01-02")
|
||||
s.DB.Model(&model.UserDailyActive{}).Where("active_date = ?", today).Count(&out.DAUToday)
|
||||
|
||||
start := time.Now().In(time.Local).AddDate(0, 0, -(days - 1)).Format("2006-01-02")
|
||||
|
||||
type dauRow struct {
|
||||
ActiveDate string
|
||||
Cnt int64
|
||||
}
|
||||
var dauRows []dauRow
|
||||
s.DB.Model(&model.UserDailyActive{}).
|
||||
Select("active_date, COUNT(*) AS cnt").
|
||||
Where("active_date >= ?", start).
|
||||
Group("active_date").
|
||||
Order("active_date").
|
||||
Scan(&dauRows)
|
||||
dauMap := map[string]int64{}
|
||||
for _, r := range dauRows {
|
||||
dauMap[r.ActiveDate] = r.Cnt
|
||||
}
|
||||
|
||||
type tokRow struct {
|
||||
UsageDate string
|
||||
PromptTokens int64
|
||||
CompletionTokens int64
|
||||
Calls int64
|
||||
}
|
||||
var tokRows []tokRow
|
||||
s.DB.Model(&model.AIUsageDaily{}).
|
||||
Select("usage_date, SUM(prompt_tokens) AS prompt_tokens, SUM(completion_tokens) AS completion_tokens, SUM(calls) AS calls").
|
||||
Where("usage_date >= ?", start).
|
||||
Group("usage_date").
|
||||
Order("usage_date").
|
||||
Scan(&tokRows)
|
||||
tokMap := map[string]tokRow{}
|
||||
for _, r := range tokRows {
|
||||
tokMap[r.UsageDate] = r
|
||||
}
|
||||
|
||||
for i := 0; i < days; i++ {
|
||||
d := time.Now().In(time.Local).AddDate(0, 0, -(days - 1 - i)).Format("2006-01-02")
|
||||
out.DAUSeries = append(out.DAUSeries, StatsPoint{Date: d, Count: dauMap[d]})
|
||||
t := tokMap[d]
|
||||
pt := TokenPoint{
|
||||
Date: d,
|
||||
PromptTokens: t.PromptTokens,
|
||||
CompletionTokens: t.CompletionTokens,
|
||||
Calls: t.Calls,
|
||||
}
|
||||
out.TokenSeries = append(out.TokenSeries, pt)
|
||||
if d == today {
|
||||
out.TokenToday = pt
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type AdminUserItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
AIBanned int `json:"aiBanned"`
|
||||
Disabled int `json:"disabled"`
|
||||
LastLoginIP string `json:"lastLoginIp"`
|
||||
LastSeenAt string `json:"lastSeenAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ListUsers 用户列表。
|
||||
func (s *AdminService) ListUsers() ([]AdminUserItem, error) {
|
||||
var users []model.User
|
||||
if err := s.DB.Order("id ASC").Find(&users).Error; err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
ids := make([]int64, 0, len(users))
|
||||
for _, u := range users {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
nick := map[int64]string{}
|
||||
if len(ids) > 0 {
|
||||
var profiles []model.UserProfile
|
||||
s.DB.Where("user_id IN ?", ids).Find(&profiles)
|
||||
for _, p := range profiles {
|
||||
nick[p.UserID] = p.Nickname
|
||||
}
|
||||
}
|
||||
out := make([]AdminUserItem, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, AdminUserItem{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: nick[u.ID],
|
||||
AIBanned: u.AIBanned,
|
||||
Disabled: u.Disabled,
|
||||
LastLoginIP: u.LastLoginIP,
|
||||
LastSeenAt: u.LastSeenAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PatchUser 更新用户 ai_banned / disabled(不可禁用自己)。
|
||||
func (s *AdminService) PatchUser(actorID, targetID int64, aiBanned, disabled *int) error {
|
||||
if actorID != commonservice.AdminUserID {
|
||||
return commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
if targetID <= 0 {
|
||||
return commonservice.BadRequest("BAD_REQUEST")
|
||||
}
|
||||
if targetID == commonservice.AdminUserID && disabled != nil && *disabled != 0 {
|
||||
return commonservice.BadRequest("CANNOT_DISABLE_ADMIN")
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if aiBanned != nil {
|
||||
if *aiBanned != 0 {
|
||||
updates["ai_banned"] = 1
|
||||
} else {
|
||||
updates["ai_banned"] = 0
|
||||
}
|
||||
}
|
||||
if disabled != nil {
|
||||
if *disabled != 0 {
|
||||
updates["disabled"] = 1
|
||||
} else {
|
||||
updates["disabled"] = 0
|
||||
}
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return commonservice.BadRequest("BAD_REQUEST")
|
||||
}
|
||||
res := s.DB.Model(&model.User{}).Where("id = ?", targetID).Updates(updates)
|
||||
if res.Error != nil {
|
||||
return commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return commonservice.NotFound("NOT_FOUND")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type AdminTeamItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID int64 `json:"ownerId"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
AIBanned int `json:"aiBanned"`
|
||||
Members int64 `json:"members"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ListTeams 团队列表。
|
||||
func (s *AdminService) ListTeams() ([]AdminTeamItem, error) {
|
||||
var teams []model.Team
|
||||
if err := s.DB.Order("id ASC").Find(&teams).Error; err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
out := make([]AdminTeamItem, 0, len(teams))
|
||||
for _, t := range teams {
|
||||
item := AdminTeamItem{
|
||||
ID: t.ID, Name: t.Name, OwnerID: t.OwnerID,
|
||||
AIBanned: t.AIBanned, CreatedAt: t.CreatedAt,
|
||||
}
|
||||
var u model.User
|
||||
if s.DB.Select("username").First(&u, t.OwnerID).Error == nil {
|
||||
item.OwnerName = u.Username
|
||||
}
|
||||
s.DB.Model(&model.TeamMember{}).Where("team_id = ?", t.ID).Count(&item.Members)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PatchTeam 更新团队 ai_banned。
|
||||
func (s *AdminService) PatchTeam(actorID, teamID int64, aiBanned *int) error {
|
||||
if actorID != commonservice.AdminUserID {
|
||||
return commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
if teamID <= 0 || aiBanned == nil {
|
||||
return commonservice.BadRequest("BAD_REQUEST")
|
||||
}
|
||||
v := 0
|
||||
if *aiBanned != 0 {
|
||||
v = 1
|
||||
}
|
||||
res := s.DB.Model(&model.Team{}).Where("id = ?", teamID).Update("ai_banned", v)
|
||||
if res.Error != nil {
|
||||
return commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return commonservice.NotFound("NOT_FOUND")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AIPolicy 当前用户是否允许使用 AI。
|
||||
type AIPolicy struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// GetAIPolicy 综合用户禁用/禁 AI 与所属团队禁 AI。
|
||||
func (s *AdminService) GetAIPolicy(userID int64) (*AIPolicy, error) {
|
||||
var u model.User
|
||||
if err := s.DB.Select("id", "disabled", "ai_banned").First(&u, userID).Error; err != nil {
|
||||
return nil, commonservice.Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
if u.Disabled != 0 {
|
||||
return &AIPolicy{Allowed: false, Reason: "ACCOUNT_DISABLED"}, nil
|
||||
}
|
||||
if u.AIBanned != 0 {
|
||||
return &AIPolicy{Allowed: false, Reason: "USER_AI_BANNED"}, nil
|
||||
}
|
||||
var n int64
|
||||
s.DB.Table("team_members").
|
||||
Joins("JOIN teams ON teams.id = team_members.team_id").
|
||||
Where("team_members.user_id = ? AND teams.ai_banned = 1", userID).
|
||||
Count(&n)
|
||||
if n > 0 {
|
||||
return &AIPolicy{Allowed: false, Reason: "TEAM_AI_BANNED"}, nil
|
||||
}
|
||||
return &AIPolicy{Allowed: true}, nil
|
||||
}
|
||||
|
||||
// ReportAIUsage 累加当日 token 用量。
|
||||
func (s *AdminService) ReportAIUsage(userID, teamID int64, provider string, prompt, completion int64, estimated bool) error {
|
||||
if userID <= 0 {
|
||||
return commonservice.Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
provider = strings.TrimSpace(provider)
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
if prompt < 0 {
|
||||
prompt = 0
|
||||
}
|
||||
if completion < 0 {
|
||||
completion = 0
|
||||
}
|
||||
today := time.Now().In(time.Local).Format("2006-01-02")
|
||||
est := 0
|
||||
if estimated {
|
||||
est = 1
|
||||
}
|
||||
q := `INSERT INTO ai_usage_daily(user_id,team_id,usage_date,provider,prompt_tokens,completion_tokens,calls,estimated)
|
||||
VALUES(?,?,?,?,?,?,1,?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
prompt_tokens=prompt_tokens+VALUES(prompt_tokens),
|
||||
completion_tokens=completion_tokens+VALUES(completion_tokens),
|
||||
calls=calls+1,
|
||||
estimated=IF(VALUES(estimated)=1,1,estimated)`
|
||||
if err := s.DB.Exec(q, userID, teamID, today, provider, prompt, completion, est).Error; err != nil {
|
||||
return commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
174
internal/service/admin_security.go
Normal file
174
internal/service/admin_security.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
adminStepupTTL = 2 * time.Hour
|
||||
totpIssuer = "年糕崽崽PMS"
|
||||
totpPendingPrefix = "pending:"
|
||||
)
|
||||
|
||||
// AdminSecurityService 管理员 TOTP 与敏感操作 stepup。
|
||||
type AdminSecurityService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
type TOTPStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Pending bool `json:"pending"`
|
||||
OTPAuth string `json:"otpauth,omitempty"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
// Status 返回 TOTP 绑定状态;若未启用且无 pending,不自动生成。
|
||||
func (s *AdminSecurityService) Status(userID int64) (*TOTPStatus, error) {
|
||||
if userID != commonservice.AdminUserID {
|
||||
return nil, commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
var u model.User
|
||||
if err := s.DB.First(&u, userID).Error; err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
st := &TOTPStatus{Enabled: u.TOTPEnabled != 0}
|
||||
if !st.Enabled && strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
|
||||
secret := strings.TrimPrefix(u.TOTPSecret, totpPendingPrefix)
|
||||
st.Pending = true
|
||||
st.Secret = secret
|
||||
st.OTPAuth = buildOTPAuth(u.Username, secret)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// SetupBegin 生成待确认的 TOTP 密钥(覆盖未确认的 pending)。
|
||||
func (s *AdminSecurityService) SetupBegin(userID int64) (*TOTPStatus, error) {
|
||||
if userID != commonservice.AdminUserID {
|
||||
return nil, commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
var u model.User
|
||||
if err := s.DB.First(&u, userID).Error; err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
if u.TOTPEnabled != 0 {
|
||||
return nil, commonservice.Conflict("TOTP_ALREADY_ENABLED")
|
||||
}
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: totpIssuer,
|
||||
AccountName: u.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("INTERNAL")
|
||||
}
|
||||
secret := key.Secret()
|
||||
if err := s.DB.Model(&u).Update("totp_secret", totpPendingPrefix+secret).Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
return &TOTPStatus{
|
||||
Enabled: false,
|
||||
Pending: true,
|
||||
Secret: secret,
|
||||
OTPAuth: key.URL(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetupConfirm 用动态码确认绑定。
|
||||
func (s *AdminSecurityService) SetupConfirm(userID int64, code string) error {
|
||||
if userID != commonservice.AdminUserID {
|
||||
return commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
var u model.User
|
||||
if err := s.DB.First(&u, userID).Error; err != nil {
|
||||
return commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
if u.TOTPEnabled != 0 {
|
||||
return commonservice.Conflict("TOTP_ALREADY_ENABLED")
|
||||
}
|
||||
if !strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
|
||||
return commonservice.BadRequest("TOTP_SETUP_REQUIRED")
|
||||
}
|
||||
secret := strings.TrimPrefix(u.TOTPSecret, totpPendingPrefix)
|
||||
if !totp.Validate(code, secret) {
|
||||
return commonservice.BadRequest("TOTP_INVALID")
|
||||
}
|
||||
return s.DB.Model(&u).Updates(map[string]any{
|
||||
"totp_secret": secret,
|
||||
"totp_enabled": 1,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// StepUp 校验动态码并签发 2h stepup token(绑定 IP)。
|
||||
func (s *AdminSecurityService) StepUp(userID int64, code, clientIP string) (string, time.Time, error) {
|
||||
if userID != commonservice.AdminUserID {
|
||||
return "", time.Time{}, commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
var u model.User
|
||||
if err := s.DB.First(&u, userID).Error; err != nil {
|
||||
return "", time.Time{}, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
if u.TOTPEnabled == 0 || u.TOTPSecret == "" || strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
|
||||
return "", time.Time{}, commonservice.BadRequest("TOTP_NOT_ENABLED")
|
||||
}
|
||||
if !totp.Validate(code, u.TOTPSecret) {
|
||||
return "", time.Time{}, commonservice.BadRequest("TOTP_INVALID")
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", time.Time{}, commonservice.Internal("INTERNAL")
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
expires := time.Now().UTC().Add(adminStepupTTL)
|
||||
row := model.AdminStepup{
|
||||
UserID: userID,
|
||||
TokenHash: hex.EncodeToString(sum[:]),
|
||||
ClientIP: strings.TrimSpace(clientIP),
|
||||
ExpiresAt: expires.Format(time.RFC3339),
|
||||
}
|
||||
if err := s.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"token_hash", "client_ip", "expires_at"}),
|
||||
}).Create(&row).Error; err != nil {
|
||||
return "", time.Time{}, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
// 更新管理员最近登录 IP,便于审计
|
||||
_ = s.DB.Model(&u).Updates(map[string]any{
|
||||
"last_login_ip": row.ClientIP,
|
||||
"last_seen_at": commonservice.NowRFC(),
|
||||
}).Error
|
||||
return token, expires, nil
|
||||
}
|
||||
|
||||
// ClearStepUp 作废二次验证。
|
||||
func (s *AdminSecurityService) ClearStepUp(userID int64) error {
|
||||
if userID != commonservice.AdminUserID {
|
||||
return commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
return s.DB.Delete(&model.AdminStepup{}, userID).Error
|
||||
}
|
||||
|
||||
func buildOTPAuth(account, secret string) string {
|
||||
// otpauth URL:issuer/account 需编码
|
||||
label := url.PathEscape(totpIssuer) + ":" + url.PathEscape(account)
|
||||
q := url.Values{}
|
||||
q.Set("secret", secret)
|
||||
q.Set("issuer", totpIssuer)
|
||||
q.Set("algorithm", "SHA1")
|
||||
q.Set("digits", "6")
|
||||
q.Set("period", "30")
|
||||
return "otpauth://totp/" + label + "?" + q.Encode()
|
||||
}
|
||||
142
internal/service/auth.go
Normal file
142
internal/service/auth.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/config"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// AuthService 注册 / 登录 / 刷新 / 改密。
|
||||
type AuthService struct {
|
||||
DB *gorm.DB
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
// AuthTokens 登录/刷新返回的令牌与用户信息。
|
||||
type AuthTokens struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func validAccount(username, password string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if len(username) < 3 || len(username) > 64 {
|
||||
return commonservice.BadRequest("SYNC_USERNAME_INVALID")
|
||||
}
|
||||
if len(password) < 6 {
|
||||
return commonservice.BadRequest("SYNC_PASSWORD_TOO_SHORT")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register 注册新账号。
|
||||
func (s *AuthService) Register(username, password string) error {
|
||||
if err := validAccount(username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
var n int64
|
||||
s.DB.Model(&model.User{}).Where("username = ?", username).Count(&n)
|
||||
if n > 0 {
|
||||
return commonservice.Conflict("SYNC_USER_EXISTS")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return commonservice.Internal("INTERNAL")
|
||||
}
|
||||
u := model.User{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
CreatedAt: commonservice.NowRFC(),
|
||||
}
|
||||
if err := s.DB.Create(&u).Error; err != nil {
|
||||
// 并发注册由唯一索引兜底。
|
||||
return commonservice.Conflict("SYNC_USER_EXISTS")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login 校验账号并签发双令牌。
|
||||
func (s *AuthService) Login(username, password, clientIP string) (*AuthTokens, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
var u model.User
|
||||
if err := s.DB.Where("username = ?", username).First(&u).Error; err != nil {
|
||||
return nil, commonservice.Unauthorized("SYNC_BAD_CREDENTIALS")
|
||||
}
|
||||
if u.Disabled != 0 {
|
||||
return nil, commonservice.Forbidden("ACCOUNT_DISABLED")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
|
||||
return nil, commonservice.Unauthorized("SYNC_BAD_CREDENTIALS")
|
||||
}
|
||||
tok, err := s.issueTokens(u.ID, u.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = (&ActivityService{DB: s.DB}).Ping(u.ID, clientIP)
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// Refresh 用 refresh token 换新的双令牌。
|
||||
func (s *AuthService) Refresh(refreshToken, clientIP string) (*AuthTokens, error) {
|
||||
claims, err := commonservice.Parse(s.Cfg.JWTSecret, strings.TrimSpace(refreshToken), "refresh")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var u model.User
|
||||
if err := s.DB.First(&u, claims.UserID).Error; err != nil {
|
||||
return nil, commonservice.Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
if u.Disabled != 0 {
|
||||
return nil, commonservice.Forbidden("ACCOUNT_DISABLED")
|
||||
}
|
||||
tok, err := s.issueTokens(u.ID, u.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = (&ActivityService{DB: s.DB}).Ping(u.ID, clientIP)
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// ChangePassword 在线修改密码。
|
||||
func (s *AuthService) ChangePassword(userID int64, oldPassword, newPassword string) error {
|
||||
if len(newPassword) < 6 {
|
||||
return commonservice.BadRequest("SYNC_PASSWORD_TOO_SHORT")
|
||||
}
|
||||
var u model.User
|
||||
if err := s.DB.First(&u, userID).Error; err != nil {
|
||||
return commonservice.Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(oldPassword)) != nil {
|
||||
return commonservice.BadRequest("SYNC_OLD_PASSWORD_WRONG")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return commonservice.Internal("INTERNAL")
|
||||
}
|
||||
return s.DB.Model(&u).Update("password_hash", string(hash)).Error
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTokens(userID int64, username string) (*AuthTokens, error) {
|
||||
access, err := commonservice.IssueAccess(s.Cfg.JWTSecret, userID, username, s.Cfg.AccessTTLHours)
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("INTERNAL")
|
||||
}
|
||||
refresh, err := commonservice.IssueRefresh(s.Cfg.JWTSecret, userID, username, s.Cfg.RefreshTTLDays)
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("INTERNAL")
|
||||
}
|
||||
return &AuthTokens{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
}, nil
|
||||
}
|
||||
107
internal/service/avatar_history.go
Normal file
107
internal/service/avatar_history.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
const avatarHistoryLimit = 12
|
||||
|
||||
// AvatarHistoryService 用户头像历史(与账号关联,多端可回选)。
|
||||
type AvatarHistoryService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// AvatarHistoryItem 对外一条历史。
|
||||
type AvatarHistoryItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Mode string `json:"mode"`
|
||||
Value string `json:"value"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// List 按时间倒序返回本人历史(最多 avatarHistoryLimit 条)。
|
||||
func (s *AvatarHistoryService) List(userID int64) ([]AvatarHistoryItem, error) {
|
||||
var rows []model.UserAvatarHistory
|
||||
if err := s.DB.Where("user_id = ?", userID).Order("id DESC").Limit(avatarHistoryLimit).Find(&rows).Error; err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
out := make([]AvatarHistoryItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, AvatarHistoryItem{ID: r.ID, Mode: r.Mode, Value: r.Value, CreatedAt: r.CreatedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Push 追加一条;同 value 去重后置顶;超出上限删最旧。
|
||||
func (s *AvatarHistoryService) Push(userID int64, mode, value string) ([]AvatarHistoryItem, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, commonservice.BadRequest("AVATAR_VALUE_REQUIRED")
|
||||
}
|
||||
mode = strings.TrimSpace(strings.ToLower(mode))
|
||||
if mode != "url" && mode != "base64" && mode != "path" {
|
||||
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||
mode = "url"
|
||||
} else {
|
||||
mode = "base64"
|
||||
}
|
||||
}
|
||||
// path 仅本机有效,不入库线上历史
|
||||
if mode == "path" {
|
||||
return s.List(userID)
|
||||
}
|
||||
// 防爆:单条过大拒绝(约 1.5MB 文本)
|
||||
if len(value) > 1_500_000 {
|
||||
return nil, commonservice.BadRequest("AVATAR_VALUE_TOO_LARGE")
|
||||
}
|
||||
now := commonservice.NowRFC()
|
||||
tx := s.DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := tx.Where("user_id = ? AND value = ?", userID, value).Delete(&model.UserAvatarHistory{}).Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
row := model.UserAvatarHistory{UserID: userID, Mode: mode, Value: value, CreatedAt: now}
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
// 超出上限:删掉最旧的多余行
|
||||
var ids []int64
|
||||
if err := tx.Model(&model.UserAvatarHistory{}).Where("user_id = ?", userID).
|
||||
Order("id DESC").Limit(500).Pluck("id", &ids).Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
if len(ids) > avatarHistoryLimit {
|
||||
drop := ids[avatarHistoryLimit:]
|
||||
if err := tx.Where("user_id = ? AND id IN ?", userID, drop).Delete(&model.UserAvatarHistory{}).Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
return s.List(userID)
|
||||
}
|
||||
|
||||
// Delete 删除本人一条历史。
|
||||
func (s *AvatarHistoryService) Delete(userID, id int64) error {
|
||||
if id <= 0 {
|
||||
return commonservice.BadRequest("ID_REQUIRED")
|
||||
}
|
||||
res := s.DB.Where("user_id = ? AND id = ?", userID, id).Delete(&model.UserAvatarHistory{})
|
||||
if res.Error != nil {
|
||||
return commonservice.Internal("DELETE_FAILED")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return commonservice.NotFound("NOT_FOUND")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
173
internal/service/file.go
Normal file
173
internal/service/file.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/config"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// FileService 图片上传与素材库管理。
|
||||
type FileService struct {
|
||||
DB *gorm.DB
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
var extByMime = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
// UploadResult 上传成功响应。
|
||||
type UploadResult struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
Mime string `json:"mime"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
}
|
||||
|
||||
// FileItem 素材库列表项。
|
||||
type FileItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Original string `json:"original"`
|
||||
Mime string `json:"mime"`
|
||||
Size int64 `json:"size"`
|
||||
UserID int64 `json:"userId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
Kind string `json:"kind"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Username string `json:"username"`
|
||||
URL string `json:"url" gorm:"-"`
|
||||
}
|
||||
|
||||
// Upload 保存图片;userID 来自 JWT,teamID/kind 来自表单。
|
||||
func (s *FileService) Upload(userID, teamID int64, kind, original string, r io.Reader, sizeHint int64, requestHost string) (*UploadResult, error) {
|
||||
if sizeHint > s.Cfg.MaxUploadBytes() {
|
||||
return nil, &commonservice.AppError{Code: "FILE_TOO_LARGE", Status: http.StatusRequestEntityTooLarge}
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(r, s.Cfg.MaxUploadBytes()+1))
|
||||
if err != nil || int64(len(data)) > s.Cfg.MaxUploadBytes() {
|
||||
return nil, &commonservice.AppError{Code: "FILE_TOO_LARGE", Status: http.StatusRequestEntityTooLarge}
|
||||
}
|
||||
mime := http.DetectContentType(data)
|
||||
ext, ok := extByMime[mime]
|
||||
if !ok {
|
||||
return nil, &commonservice.AppError{Code: "UNSUPPORTED_TYPE", Status: http.StatusUnsupportedMediaType}
|
||||
}
|
||||
sum := hex.EncodeToString(func() []byte { h := sha256.Sum256(data); return h[:] }())
|
||||
var rec model.File
|
||||
if s.DB.Where("sha256 = ? AND user_id = ? AND team_id = ?", sum, userID, teamID).First(&rec).Error == nil {
|
||||
return s.toUploadResult(rec, requestHost), nil
|
||||
}
|
||||
name := commonservice.StoredName(ext)
|
||||
full := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
if err := os.WriteFile(full, data, 0644); err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
rec = model.File{
|
||||
Name: name,
|
||||
Original: commonservice.Clip(filepath.Base(original), 255),
|
||||
Mime: mime,
|
||||
Size: int64(len(data)),
|
||||
SHA256: sum,
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
Kind: commonservice.NormalizeKind(kind),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := s.DB.Create(&rec).Error; err != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
return s.toUploadResult(rec, requestHost), nil
|
||||
}
|
||||
|
||||
func (s *FileService) toUploadResult(f model.File, host string) *UploadResult {
|
||||
return &UploadResult{
|
||||
ID: f.ID, Name: f.Name, URL: commonservice.PublicURL(s.Cfg, host, f.Name),
|
||||
Size: f.Size, Mime: f.Mime, TeamID: f.TeamID,
|
||||
}
|
||||
}
|
||||
|
||||
// List 素材库分页;scope=mine|team|all。
|
||||
func (s *FileService) List(userID int64, scope string, teamID, page, size int64, requestHost string) (int64, []FileItem, error) {
|
||||
if userID <= 0 {
|
||||
return 0, nil, commonservice.BadRequest("USER_REQUIRED")
|
||||
}
|
||||
var where func(*gorm.DB) *gorm.DB
|
||||
switch scope {
|
||||
case "mine":
|
||||
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.user_id = ?", userID) }
|
||||
case "team":
|
||||
if !commonservice.IsTeamAdmin(s.DB, teamID, userID) {
|
||||
return 0, nil, commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.team_id = ?", teamID) }
|
||||
case "all":
|
||||
if userID != commonservice.AdminUserID {
|
||||
return 0, nil, commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
where = func(db *gorm.DB) *gorm.DB { return db }
|
||||
default:
|
||||
return 0, nil, commonservice.BadRequest("BAD_SCOPE")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 24
|
||||
}
|
||||
var total int64
|
||||
if err := s.DB.Table("pms_files").Scopes(where).Count(&total).Error; err != nil {
|
||||
return 0, nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
items := []FileItem{}
|
||||
err := s.DB.Table("pms_files").Scopes(where).
|
||||
Select("pms_files.id, pms_files.name, pms_files.original, pms_files.mime, pms_files.size, pms_files.user_id, pms_files.team_id, pms_files.kind, pms_files.created_at, COALESCE(u.username,'') AS username").
|
||||
Joins("LEFT JOIN users u ON u.id = pms_files.user_id").
|
||||
Order("pms_files.id DESC").Limit(int(size)).Offset(int((page - 1) * size)).
|
||||
Scan(&items).Error
|
||||
if err != nil {
|
||||
return 0, nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
for i := range items {
|
||||
items[i].URL = commonservice.PublicURL(s.Cfg, requestHost, items[i].Name)
|
||||
}
|
||||
return total, items, nil
|
||||
}
|
||||
|
||||
// Delete 删除记录与磁盘文件。
|
||||
func (s *FileService) Delete(userID, fileID int64) error {
|
||||
var rec model.File
|
||||
if s.DB.First(&rec, fileID).Error != nil {
|
||||
return commonservice.NotFound("NOT_FOUND")
|
||||
}
|
||||
allowed := userID == commonservice.AdminUserID ||
|
||||
(userID > 0 && rec.UserID == userID) ||
|
||||
(rec.TeamID > 0 && commonservice.IsTeamAdmin(s.DB, rec.TeamID, userID))
|
||||
if !allowed {
|
||||
return commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
if err := s.DB.Delete(&model.File{}, fileID).Error; err != nil {
|
||||
return commonservice.Internal("DELETE_FAILED")
|
||||
}
|
||||
_ = os.Remove(filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(rec.Name)))
|
||||
return nil
|
||||
}
|
||||
30
internal/service/notice.go
Normal file
30
internal/service/notice.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// NoticeService 团队通知增量拉取。
|
||||
type NoticeService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// ListNotices 拉取 to_user=本人且 id>afterID 的通知,按 id 升序,最多 200 条。
|
||||
func (s *NoticeService) ListNotices(userID, afterID int64) ([]model.TeamNotice, error) {
|
||||
if afterID < 0 {
|
||||
afterID = 0
|
||||
}
|
||||
var rows []model.TeamNotice
|
||||
err := s.DB.Where("to_user = ? AND id > ?", userID, afterID).
|
||||
Order("id ASC").Limit(200).Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []model.TeamNotice{}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
113
internal/service/profile.go
Normal file
113
internal/service/profile.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// ProfileService 用户公开资料。
|
||||
type ProfileService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// ProfileDTO 对外资料(techTags 为数组)。
|
||||
type ProfileDTO struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Title string `json:"title"`
|
||||
Email string `json:"email"`
|
||||
Bio string `json:"bio"`
|
||||
TechTags []string `json:"techTags"`
|
||||
AvatarThumb string `json:"avatarThumb"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func sanitizeProfile(p *ProfileDTO) error {
|
||||
p.Nickname = commonservice.ClipRunes(p.Nickname, 32)
|
||||
p.Title = commonservice.ClipRunes(p.Title, 48)
|
||||
p.Email = commonservice.ClipRunes(p.Email, 128)
|
||||
p.Bio = commonservice.ClipRunes(p.Bio, 300)
|
||||
if p.Email != "" && (!strings.Contains(p.Email, "@") || strings.ContainsAny(p.Email, " \t")) {
|
||||
return commonservice.BadRequest("PROFILE_EMAIL_INVALID")
|
||||
}
|
||||
tags, seen := []string{}, map[string]bool{}
|
||||
for _, t := range p.TechTags {
|
||||
t = commonservice.ClipRunes(t, 24)
|
||||
if t == "" || seen[strings.ToLower(t)] {
|
||||
continue
|
||||
}
|
||||
seen[strings.ToLower(t)] = true
|
||||
tags = append(tags, t)
|
||||
if len(tags) >= 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
p.TechTags = tags
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 读取本人资料;无记录返回空结构。
|
||||
func (s *ProfileService) Get(userID int64) (*ProfileDTO, error) {
|
||||
var row model.UserProfile
|
||||
err := s.DB.Where("user_id = ?", userID).First(&row).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return &ProfileDTO{TechTags: []string{}}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
return &ProfileDTO{
|
||||
Nickname: row.Nickname,
|
||||
Title: row.Title,
|
||||
Email: row.Email,
|
||||
Bio: row.Bio,
|
||||
TechTags: parseTechTags(row.TechTags),
|
||||
AvatarThumb: row.AvatarThumb,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Put LWW 写入资料:仅当传入 updatedAt 更新时覆盖。
|
||||
func (s *ProfileService) Put(userID int64, p ProfileDTO) (*ProfileDTO, error) {
|
||||
if err := sanitizeProfile(&p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.UpdatedAt == "" {
|
||||
p.UpdatedAt = commonservice.NowRFC()
|
||||
}
|
||||
tags, _ := json.Marshal(p.TechTags)
|
||||
var remote model.UserProfile
|
||||
err := s.DB.Where("user_id = ?", userID).First(&remote).Error
|
||||
if err == nil && remote.UpdatedAt >= p.UpdatedAt {
|
||||
// 远端相同或更新:返回远端,不覆盖。
|
||||
return &ProfileDTO{
|
||||
Nickname: remote.Nickname,
|
||||
Title: remote.Title,
|
||||
Email: remote.Email,
|
||||
Bio: remote.Bio,
|
||||
TechTags: parseTechTags(remote.TechTags),
|
||||
AvatarThumb: remote.AvatarThumb,
|
||||
UpdatedAt: remote.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
q := `INSERT INTO user_profiles(user_id,nickname,title,email,bio,tech_tags,avatar_thumb,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE nickname=VALUES(nickname),title=VALUES(title),email=VALUES(email),bio=VALUES(bio),
|
||||
tech_tags=VALUES(tech_tags),avatar_thumb=VALUES(avatar_thumb),updated_at=VALUES(updated_at)`
|
||||
if err := s.DB.Exec(q, userID, p.Nickname, p.Title, p.Email, p.Bio, string(tags), p.AvatarThumb, p.UpdatedAt).Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func parseTechTags(s string) []string {
|
||||
out := []string{}
|
||||
if json.Unmarshal([]byte(s), &out) != nil {
|
||||
return []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
178
internal/service/release.go
Normal file
178
internal/service/release.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/config"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
var versionRe = regexp.MustCompile(`^\d+\.\d+\.\d+([-.][A-Za-z0-9.]+)?$`)
|
||||
|
||||
// ReleaseService 客户端发版。
|
||||
type ReleaseService struct {
|
||||
DB *gorm.DB
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
type LatestRelease struct {
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Changelog string `json:"changelog"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// List 发版列表(新→旧)。
|
||||
func (s *ReleaseService) List(channel string) ([]model.AppRelease, error) {
|
||||
channel = normalizeChannel(channel)
|
||||
var rows []model.AppRelease
|
||||
q := s.DB.Order("id DESC").Limit(50)
|
||||
if channel != "" {
|
||||
q = q.Where("channel = ?", channel)
|
||||
}
|
||||
if err := q.Find(&rows).Error; err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []model.AppRelease{}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Latest 当前最新版元数据。
|
||||
func (s *ReleaseService) Latest(channel string) (*LatestRelease, error) {
|
||||
channel = normalizeChannel(channel)
|
||||
var row model.AppRelease
|
||||
err := s.DB.Where("channel = ? AND is_latest = 1", channel).Order("id DESC").First(&row).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, commonservice.NotFound("NO_RELEASE")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
return &LatestRelease{
|
||||
Version: row.Version,
|
||||
Channel: row.Channel,
|
||||
SHA256: row.SHA256,
|
||||
SizeBytes: row.SizeBytes,
|
||||
Changelog: row.Changelog,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upload 保存安装包并写入元数据(默认不标 latest,需 Publish)。
|
||||
func (s *ReleaseService) Upload(version, channel, changelog string, r io.Reader, size int64) (*model.AppRelease, error) {
|
||||
version = strings.TrimSpace(version)
|
||||
channel = normalizeChannel(channel)
|
||||
changelog = strings.TrimSpace(changelog)
|
||||
if !versionRe.MatchString(version) {
|
||||
return nil, commonservice.BadRequest("VERSION_INVALID")
|
||||
}
|
||||
if size <= 0 {
|
||||
return nil, commonservice.BadRequest("EMPTY_FILE")
|
||||
}
|
||||
if size > s.Cfg.MaxReleaseBytes() {
|
||||
return nil, commonservice.BadRequest("FILE_TOO_LARGE")
|
||||
}
|
||||
var n int64
|
||||
s.DB.Model(&model.AppRelease{}).Where("version = ? AND channel = ?", version, channel).Count(&n)
|
||||
if n > 0 {
|
||||
return nil, commonservice.Conflict("VERSION_EXISTS")
|
||||
}
|
||||
|
||||
dir := filepath.Join(s.Cfg.StorageDir, "releases", channel)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
relName := filepath.ToSlash(filepath.Join("releases", channel, version+"-installer.exe"))
|
||||
abs := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(relName))
|
||||
f, err := os.Create(abs)
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(r, s.Cfg.MaxReleaseBytes()+1))
|
||||
if err != nil {
|
||||
_ = os.Remove(abs)
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
if written > s.Cfg.MaxReleaseBytes() {
|
||||
_ = os.Remove(abs)
|
||||
return nil, commonservice.BadRequest("FILE_TOO_LARGE")
|
||||
}
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
row := model.AppRelease{
|
||||
Version: version,
|
||||
Channel: channel,
|
||||
Filename: relName,
|
||||
SHA256: sum,
|
||||
SizeBytes: written,
|
||||
Changelog: changelog,
|
||||
CreatedAt: commonservice.NowRFC(),
|
||||
IsLatest: 0,
|
||||
}
|
||||
if err := s.DB.Create(&row).Error; err != nil {
|
||||
_ = os.Remove(abs)
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// Publish 将指定发版标为该渠道最新。
|
||||
func (s *ReleaseService) Publish(id int64) (*model.AppRelease, error) {
|
||||
var row model.AppRelease
|
||||
if err := s.DB.First(&row, id).Error; err != nil {
|
||||
return nil, commonservice.NotFound("NOT_FOUND")
|
||||
}
|
||||
tx := s.DB.Begin()
|
||||
if err := tx.Model(&model.AppRelease{}).Where("channel = ?", row.Channel).Update("is_latest", 0).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
if err := tx.Model(&row).Update("is_latest", 1).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
row.IsLatest = 1
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// OpenFile 打开发版文件供下载。
|
||||
func (s *ReleaseService) OpenFile(version, channel string) (*model.AppRelease, *os.File, error) {
|
||||
version = strings.TrimSpace(version)
|
||||
channel = normalizeChannel(channel)
|
||||
var row model.AppRelease
|
||||
if err := s.DB.Where("version = ? AND channel = ?", version, channel).First(&row).Error; err != nil {
|
||||
return nil, nil, commonservice.NotFound("NOT_FOUND")
|
||||
}
|
||||
abs := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(row.Filename))
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return nil, nil, commonservice.NotFound("NOT_FOUND")
|
||||
}
|
||||
return &row, f, nil
|
||||
}
|
||||
|
||||
func normalizeChannel(ch string) string {
|
||||
ch = strings.TrimSpace(strings.ToLower(ch))
|
||||
if ch == "" {
|
||||
return "stable"
|
||||
}
|
||||
return ch
|
||||
}
|
||||
94
internal/service/settings.go
Normal file
94
internal/service/settings.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// SettingsService 用户设置与全局资源(fest_img:* / file_storage)。
|
||||
type SettingsService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func isGlobalSetting(name string) bool {
|
||||
return name == "file_storage" || strings.HasPrefix(name, "fest_img:")
|
||||
}
|
||||
|
||||
func settingOwner(userID int64, name string) int64 {
|
||||
if isGlobalSetting(name) {
|
||||
return commonservice.AdminUserID
|
||||
}
|
||||
return userID
|
||||
}
|
||||
|
||||
// GetSetting 读取单条设置;全局键从管理员账号读。
|
||||
func (s *SettingsService) GetSetting(userID int64, name string) (*model.SyncSetting, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, commonservice.BadRequest("SETTING_NAME_REQUIRED")
|
||||
}
|
||||
owner := settingOwner(userID, name)
|
||||
var row model.SyncSetting
|
||||
if err := s.DB.Where("user_id = ? AND name = ?", owner, name).First(&row).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, commonservice.NotFound("NOT_FOUND")
|
||||
}
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// ListByPrefix 按前缀批量拉取(如 fest_img:),全局前缀从管理员账号读。
|
||||
func (s *SettingsService) ListByPrefix(userID int64, prefix string) ([]model.SyncSetting, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
return nil, commonservice.BadRequest("SETTING_PREFIX_REQUIRED")
|
||||
}
|
||||
owner := userID
|
||||
if strings.HasPrefix(prefix, "fest_img:") || prefix == "fest_img:" {
|
||||
owner = commonservice.AdminUserID
|
||||
}
|
||||
var rows []model.SyncSetting
|
||||
if err := s.DB.Where("user_id = ? AND name LIKE ?", owner, prefix+"%").
|
||||
Order("name").Find(&rows).Error; err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []model.SyncSetting{}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// GetGlobal 读取挂在管理员名下的全局设置。
|
||||
func (s *SettingsService) GetGlobal(name string) (*model.SyncSetting, error) {
|
||||
return s.GetSetting(commonservice.AdminUserID, name)
|
||||
}
|
||||
|
||||
// PutSetting LWW 写入;全局键仅管理员可写,且落在 user_id=1。
|
||||
func (s *SettingsService) PutSetting(userID int64, name, value, updatedAt string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return commonservice.BadRequest("SETTING_NAME_REQUIRED")
|
||||
}
|
||||
if updatedAt == "" {
|
||||
updatedAt = commonservice.NowRFC()
|
||||
}
|
||||
owner := userID
|
||||
if isGlobalSetting(name) {
|
||||
if userID != commonservice.AdminUserID {
|
||||
return commonservice.Forbidden("FORBIDDEN")
|
||||
}
|
||||
owner = commonservice.AdminUserID
|
||||
}
|
||||
q := `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE value=IF(VALUES(updated_at)>updated_at,VALUES(value),value),
|
||||
updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)`
|
||||
if err := s.DB.Exec(q, owner, name, value, updatedAt).Error; err != nil {
|
||||
return commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
132
internal/service/sync.go
Normal file
132
internal/service/sync.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
)
|
||||
|
||||
// SyncService 个人待办/工单/记事本的推拉(LWW)。
|
||||
type SyncService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
type syncTableMeta struct {
|
||||
remote string
|
||||
cols []string // 不含 user_id;含 uuid … updated_at deleted
|
||||
hasProject bool
|
||||
hasTimes bool
|
||||
}
|
||||
|
||||
var syncTables = map[string]syncTableMeta{
|
||||
"todos": {
|
||||
remote: "sync_todos",
|
||||
hasProject: true,
|
||||
hasTimes: true,
|
||||
cols: []string{"uuid", "title", "content", "due_at", "priority", "status", "history", "team_id", "project_name", "created_at", "updated_at", "deleted"},
|
||||
},
|
||||
"tickets": {
|
||||
remote: "sync_tickets",
|
||||
hasProject: true,
|
||||
hasTimes: true,
|
||||
cols: []string{"uuid", "title", "description", "type", "start_at", "due_at", "status", "priority", "history", "team_id", "project_name", "created_at", "updated_at", "deleted"},
|
||||
},
|
||||
"notes": {
|
||||
remote: "sync_notes",
|
||||
cols: []string{"uuid", "content", "updated_at", "deleted"},
|
||||
},
|
||||
}
|
||||
|
||||
// Push 对指定表做 LWW upsert;row 中不含 user_id(取自 JWT)。
|
||||
func (s *SyncService) Push(table string, userID int64, rows []map[string]any) (int, error) {
|
||||
meta, ok := syncTables[table]
|
||||
if !ok {
|
||||
return 0, commonservice.BadRequest("SYNC_BAD_TABLE")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return 0, commonservice.Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
remoteCols := append([]string{"user_id"}, meta.cols...)
|
||||
set := make([]string, 0, len(meta.cols))
|
||||
for _, c := range meta.cols {
|
||||
if c == "uuid" || c == "updated_at" {
|
||||
continue
|
||||
}
|
||||
set = append(set, fmt.Sprintf("%s=IF(VALUES(updated_at)>updated_at,VALUES(%s),%s)", c, c, c))
|
||||
}
|
||||
set = append(set, "updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)")
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(remoteCols)), ",")
|
||||
q := fmt.Sprintf("INSERT INTO %s(%s) VALUES(%s) ON DUPLICATE KEY UPDATE %s",
|
||||
meta.remote, strings.Join(remoteCols, ","), placeholders, strings.Join(set, ","))
|
||||
|
||||
n := 0
|
||||
for _, row := range rows {
|
||||
uuid := commonservice.MapStr(row, "uuid")
|
||||
if uuid == "" {
|
||||
continue
|
||||
}
|
||||
args := make([]any, 0, len(remoteCols))
|
||||
args = append(args, userID)
|
||||
for _, c := range meta.cols {
|
||||
switch c {
|
||||
case "deleted", "team_id":
|
||||
args = append(args, commonservice.MapInt64(row, c))
|
||||
default:
|
||||
args = append(args, commonservice.MapStr(row, c))
|
||||
}
|
||||
}
|
||||
if err := s.DB.Exec(q, args...).Error; err != nil {
|
||||
return n, commonservice.Internal("SYNC_PUSH_FAILED")
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Pull 按 updated_at 游标增量拉取,最多 2000 条。
|
||||
func (s *SyncService) Pull(table string, userID int64, cursor string) ([]map[string]any, error) {
|
||||
meta, ok := syncTables[table]
|
||||
if !ok {
|
||||
return nil, commonservice.BadRequest("SYNC_BAD_TABLE")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, commonservice.Unauthorized("UNAUTHORIZED")
|
||||
}
|
||||
q := fmt.Sprintf("SELECT %s FROM %s WHERE user_id=? AND updated_at>? ORDER BY updated_at LIMIT 2000",
|
||||
strings.Join(meta.cols, ","), meta.remote)
|
||||
rawRows, err := s.DB.Raw(q, userID, cursor).Rows()
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("SYNC_PULL_FAILED")
|
||||
}
|
||||
defer rawRows.Close()
|
||||
|
||||
out := []map[string]any{}
|
||||
for rawRows.Next() {
|
||||
vals := make([]any, len(meta.cols))
|
||||
ptrs := make([]any, len(meta.cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rawRows.Scan(ptrs...); err != nil {
|
||||
return nil, commonservice.Internal("SYNC_PULL_FAILED")
|
||||
}
|
||||
m := map[string]any{}
|
||||
for i, c := range meta.cols {
|
||||
m[c] = coerceSQL(vals[i])
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func coerceSQL(v any) any {
|
||||
switch x := v.(type) {
|
||||
case []byte:
|
||||
return string(x)
|
||||
default:
|
||||
return x
|
||||
}
|
||||
}
|
||||
684
internal/service/team.go
Normal file
684
internal/service/team.go
Normal file
@@ -0,0 +1,684 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/model"
|
||||
)
|
||||
|
||||
// TeamService 团队协作:团队/成员/任务/共享/日报/摘要。
|
||||
type TeamService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
var teamDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
|
||||
const teamDisplayExpr = `COALESCE(NULLIF(p.nickname,''), u.username)`
|
||||
|
||||
// ---------- DTO ----------
|
||||
|
||||
type TeamInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerID int64 `json:"ownerId"`
|
||||
DigestTime string `json:"digestTime"`
|
||||
Role string `json:"role"`
|
||||
Members int `json:"members"`
|
||||
}
|
||||
|
||||
type TeamMemberDTO struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Title string `json:"title"`
|
||||
Bio string `json:"bio"`
|
||||
TechTags []string `json:"techTags"`
|
||||
Avatar string `json:"avatar"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
type TeamTaskDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
CreatorID int64 `json:"creatorId"`
|
||||
Creator string `json:"creator"`
|
||||
AssigneeID int64 `json:"assigneeId"`
|
||||
Assignee string `json:"assignee"`
|
||||
StartAt string `json:"startAt"`
|
||||
DueAt string `json:"dueAt"`
|
||||
UrgedAt string `json:"urgedAt"`
|
||||
History string `json:"history"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type TeamSharedItem struct {
|
||||
Kind string `json:"kind"`
|
||||
UUID string `json:"uuid"`
|
||||
UserID int64 `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
DueAt string `json:"dueAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type TeamReportDTO struct {
|
||||
UserID int64 `json:"userId"`
|
||||
User string `json:"user"`
|
||||
Date string `json:"date"`
|
||||
Content string `json:"content"`
|
||||
SubmittedAt string `json:"submittedAt"`
|
||||
}
|
||||
|
||||
type TeamDigestDTO struct {
|
||||
Date string `json:"date"`
|
||||
Content string `json:"content"`
|
||||
Provider string `json:"provider"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
}
|
||||
|
||||
type TeamReportBoard struct {
|
||||
Date string `json:"date"`
|
||||
Role string `json:"role"`
|
||||
Reports []TeamReportDTO `json:"reports"`
|
||||
Missing []TeamMemberDTO `json:"missing"`
|
||||
Digest *TeamDigestDTO `json:"digest"`
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func (s *TeamService) teamNotice(teamID, toUser, fromUser int64, kind, refID, content string) {
|
||||
if toUser <= 0 || toUser == fromUser {
|
||||
return
|
||||
}
|
||||
_ = s.DB.Exec(`INSERT INTO team_notices(team_id,to_user,from_user,kind,ref_id,content,created_at) VALUES(?,?,?,?,?,?,?)`,
|
||||
teamID, toUser, fromUser, kind, refID, content, commonservice.NowRFC())
|
||||
}
|
||||
|
||||
func (s *TeamService) teamUserName(userID int64) string {
|
||||
var name string
|
||||
_ = s.DB.Raw(`SELECT `+teamDisplayExpr+` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=?`, userID).Scan(&name)
|
||||
return name
|
||||
}
|
||||
|
||||
func parseClockOK(at string) bool {
|
||||
var h, m int
|
||||
if _, e := fmt.Sscanf(at, "%d:%d", &h, &m); e != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(at, ":", 2)
|
||||
return len(parts) == 2
|
||||
}
|
||||
|
||||
func teamTaskKindLabel(kind string) string {
|
||||
if kind == "ticket" {
|
||||
return "工单"
|
||||
}
|
||||
return "任务"
|
||||
}
|
||||
|
||||
func teamStatusLabel(st string) string {
|
||||
switch st {
|
||||
case "doing":
|
||||
return "进行中"
|
||||
case "done":
|
||||
return "已完成"
|
||||
case "closed":
|
||||
return "已关闭"
|
||||
}
|
||||
return "待处理"
|
||||
}
|
||||
|
||||
func appendHistoryNode(history, status, at string) string {
|
||||
node := fmt.Sprintf(`{"status":%q,"at":%q}`, status, at)
|
||||
h := strings.TrimSpace(history)
|
||||
if h == "" || h == "[]" {
|
||||
return "[" + node + "]"
|
||||
}
|
||||
if strings.HasSuffix(h, "]") {
|
||||
return h[:len(h)-1] + "," + node + "]"
|
||||
}
|
||||
return "[" + node + "]"
|
||||
}
|
||||
|
||||
func stripFence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "```") {
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// ---------- 团队 CRUD ----------
|
||||
|
||||
func (s *TeamService) Create(userID int64, name string) (*TeamInfo, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len([]rune(name)) > 64 {
|
||||
return nil, commonservice.BadRequest("TEAM_NAME_INVALID")
|
||||
}
|
||||
now := commonservice.NowRFC()
|
||||
team := model.Team{Name: name, OwnerID: userID, DigestTime: "21:00", CreatedAt: now}
|
||||
if err := s.DB.Create(&team).Error; err != nil {
|
||||
return nil, commonservice.Internal("TEAM_CREATE_FAILED")
|
||||
}
|
||||
if err := s.DB.Exec(`INSERT INTO team_members(team_id,user_id,role,joined_at) VALUES(?,?,?,?)`, team.ID, userID, "owner", now).Error; err != nil {
|
||||
return nil, commonservice.Internal("TEAM_CREATE_FAILED")
|
||||
}
|
||||
return &TeamInfo{ID: team.ID, Name: name, OwnerID: userID, DigestTime: "21:00", Role: "owner", Members: 1}, nil
|
||||
}
|
||||
|
||||
func (s *TeamService) List(userID int64) ([]TeamInfo, error) {
|
||||
rows, err := s.DB.Raw(`SELECT t.id,t.name,t.owner_id,t.digest_time,m.role,
|
||||
(SELECT COUNT(*) FROM team_members x WHERE x.team_id=t.id)
|
||||
FROM teams t JOIN team_members m ON m.team_id=t.id AND m.user_id=? ORDER BY t.id`, userID).Rows()
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TeamInfo{}
|
||||
for rows.Next() {
|
||||
var t TeamInfo
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.OwnerID, &t.DigestTime, &t.Role, &t.Members); err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TeamService) Rename(teamID, userID int64, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len([]rune(name)) > 64 {
|
||||
return commonservice.BadRequest("TEAM_NAME_INVALID")
|
||||
}
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "owner"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.DB.Exec(`UPDATE teams SET name=? WHERE id=?`, name, teamID).Error
|
||||
}
|
||||
|
||||
func (s *TeamService) SetDigestTime(teamID, userID int64, at string) error {
|
||||
if !parseClockOK(at) {
|
||||
return commonservice.BadRequest("TEAM_TIME_INVALID")
|
||||
}
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.DB.Exec(`UPDATE teams SET digest_time=? WHERE id=?`, at, teamID).Error
|
||||
}
|
||||
|
||||
func (s *TeamService) Dissolve(teamID, userID int64) error {
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "owner"); err != nil {
|
||||
return err
|
||||
}
|
||||
var name string
|
||||
_ = s.DB.Raw(`SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
|
||||
var members []int64
|
||||
_ = s.DB.Raw(`SELECT user_id FROM team_members WHERE team_id=?`, teamID).Scan(&members)
|
||||
for _, m := range members {
|
||||
s.teamNotice(teamID, m, userID, "member", "", fmt.Sprintf("团队「%s」已被解散", name))
|
||||
}
|
||||
for _, q := range []string{
|
||||
`DELETE FROM team_tasks WHERE team_id=?`,
|
||||
`DELETE FROM team_reports WHERE team_id=?`,
|
||||
`DELETE FROM team_digests WHERE team_id=?`,
|
||||
`DELETE FROM team_members WHERE team_id=?`,
|
||||
`DELETE FROM teams WHERE id=?`,
|
||||
} {
|
||||
if err := s.DB.Exec(q, teamID).Error; err != nil {
|
||||
return commonservice.Internal("TEAM_DISSOLVE_FAILED")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TeamService) Leave(teamID, userID int64) error {
|
||||
role, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role == "owner" {
|
||||
return commonservice.BadRequest("TEAM_OWNER_CANNOT_LEAVE")
|
||||
}
|
||||
if err := s.DB.Exec(`DELETE FROM team_members WHERE team_id=? AND user_id=?`, teamID, userID).Error; err != nil {
|
||||
return commonservice.Internal("TEAM_LEAVE_FAILED")
|
||||
}
|
||||
var ownerID int64
|
||||
_ = s.DB.Raw(`SELECT owner_id FROM teams WHERE id=?`, teamID).Scan(&ownerID)
|
||||
s.teamNotice(teamID, ownerID, userID, "member", "", fmt.Sprintf("%s 退出了团队", s.teamUserName(userID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 成员 ----------
|
||||
|
||||
func (s *TeamService) Members(teamID, userID int64) ([]TeamMemberDTO, error) {
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.memberRows(teamID)
|
||||
}
|
||||
|
||||
func (s *TeamService) memberRows(teamID int64) ([]TeamMemberDTO, error) {
|
||||
rows, err := s.DB.Raw(`SELECT m.user_id,u.username,COALESCE(p.nickname,''),COALESCE(p.title,''),COALESCE(p.bio,''),
|
||||
COALESCE(p.tech_tags,'[]'),COALESCE(p.avatar_thumb,''),m.role,m.joined_at
|
||||
FROM team_members m JOIN users u ON u.id=m.user_id LEFT JOIN user_profiles p ON p.user_id=m.user_id
|
||||
WHERE m.team_id=? ORDER BY CASE m.role WHEN 'owner' THEN 1 WHEN 'admin' THEN 2 ELSE 3 END, m.joined_at`, teamID).Rows()
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TeamMemberDTO{}
|
||||
for rows.Next() {
|
||||
var m TeamMemberDTO
|
||||
var tags string
|
||||
if err := rows.Scan(&m.UserID, &m.Username, &m.Nickname, &m.Title, &m.Bio, &tags, &m.Avatar, &m.Role, &m.JoinedAt); err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
m.TechTags = parseTechTags(tags)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TeamService) Invite(teamID, userID int64, username, role string) error {
|
||||
if role != "member" && role != "admin" {
|
||||
role = "member"
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
|
||||
return err
|
||||
}
|
||||
var target int64
|
||||
if err := s.DB.Raw(`SELECT id FROM users WHERE username=?`, username).Scan(&target).Error; err != nil || target == 0 {
|
||||
return commonservice.NotFound("TEAM_USER_NOT_FOUND")
|
||||
}
|
||||
res := s.DB.Exec(`INSERT IGNORE INTO team_members(team_id,user_id,role,joined_at) VALUES(?,?,?,?)`,
|
||||
teamID, target, role, commonservice.NowRFC())
|
||||
if res.Error != nil {
|
||||
return commonservice.Internal("TEAM_INVITE_FAILED")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return commonservice.Conflict("TEAM_ALREADY_MEMBER")
|
||||
}
|
||||
var name string
|
||||
_ = s.DB.Raw(`SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
|
||||
s.teamNotice(teamID, target, userID, "member", "", fmt.Sprintf("%s 把你加入了团队「%s」", s.teamUserName(userID), name))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TeamService) SetRole(teamID, actorID, targetUserID int64, role string) error {
|
||||
if role != "member" && role != "admin" {
|
||||
return commonservice.BadRequest("TEAM_ROLE_INVALID")
|
||||
}
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, actorID, "owner"); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := commonservice.TeamRole(s.DB, teamID, targetUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target == "" || target == "owner" {
|
||||
return commonservice.BadRequest("TEAM_ROLE_INVALID")
|
||||
}
|
||||
if err := s.DB.Exec(`UPDATE team_members SET role=? WHERE team_id=? AND user_id=?`, role, teamID, targetUserID).Error; err != nil {
|
||||
return commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
label := "成员"
|
||||
if role == "admin" {
|
||||
label = "管理员"
|
||||
}
|
||||
s.teamNotice(teamID, targetUserID, actorID, "role", "", fmt.Sprintf("你的团队角色已调整为「%s」", label))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TeamService) RemoveMember(teamID, actorID, targetUserID int64) error {
|
||||
actor, err := commonservice.RequireTeamRole(s.DB, teamID, actorID, "admin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := commonservice.TeamRole(s.DB, teamID, targetUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target == "" || target == "owner" || targetUserID == actorID || (actor == "admin" && target != "member") {
|
||||
return commonservice.Forbidden("TEAM_FORBIDDEN")
|
||||
}
|
||||
if err := s.DB.Exec(`DELETE FROM team_members WHERE team_id=? AND user_id=?`, teamID, targetUserID).Error; err != nil {
|
||||
return commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
var name string
|
||||
_ = s.DB.Raw(`SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
|
||||
s.teamNotice(teamID, targetUserID, actorID, "member", "", fmt.Sprintf("你已被移出团队「%s」", name))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 任务 ----------
|
||||
|
||||
func validTeamTask(t *TeamTaskDTO) error {
|
||||
t.Title = strings.TrimSpace(t.Title)
|
||||
if t.Title == "" {
|
||||
return commonservice.BadRequest("TEAM_TASK_TITLE_REQUIRED")
|
||||
}
|
||||
if t.Kind != "ticket" {
|
||||
t.Kind = "todo"
|
||||
}
|
||||
switch t.Priority {
|
||||
case "low", "medium", "high":
|
||||
default:
|
||||
t.Priority = "medium"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TeamService) TaskSave(userID int64, t TeamTaskDTO) (*TeamTaskDTO, error) {
|
||||
if err := validTeamTask(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, t.TeamID, userID, "admin"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.AssigneeID > 0 {
|
||||
r, err := commonservice.TeamRole(s.DB, t.TeamID, t.AssigneeID)
|
||||
if err != nil || r == "" {
|
||||
return nil, commonservice.BadRequest("TEAM_ASSIGNEE_INVALID")
|
||||
}
|
||||
}
|
||||
now := commonservice.NowRFC()
|
||||
if t.ID == 0 {
|
||||
history := fmt.Sprintf(`[{"status":"open","at":%q}]`, now)
|
||||
rec := model.TeamTask{
|
||||
TeamID: t.TeamID, Kind: t.Kind, Title: t.Title, Description: t.Description,
|
||||
Priority: t.Priority, Status: "open", CreatorID: userID, AssigneeID: t.AssigneeID,
|
||||
StartAt: t.StartAt, DueAt: t.DueAt, History: history, UpdatedAt: now,
|
||||
}
|
||||
if err := s.DB.Create(&rec).Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
t.ID, t.Status, t.CreatorID, t.History, t.UpdatedAt = rec.ID, "open", userID, history, now
|
||||
if t.AssigneeID > 0 {
|
||||
s.teamNotice(t.TeamID, t.AssigneeID, userID, "assign", fmt.Sprint(t.ID),
|
||||
fmt.Sprintf("%s 给你指派了%s:%s", s.teamUserName(userID), teamTaskKindLabel(t.Kind), t.Title))
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
var prevAssignee int64
|
||||
var n int64
|
||||
s.DB.Raw(`SELECT COUNT(*) FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, t.ID, t.TeamID).Scan(&n)
|
||||
if n == 0 {
|
||||
return nil, commonservice.NotFound("TEAM_TASK_NOT_FOUND")
|
||||
}
|
||||
_ = s.DB.Raw(`SELECT assignee_id FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, t.ID, t.TeamID).Scan(&prevAssignee)
|
||||
if err := s.DB.Exec(`UPDATE team_tasks SET kind=?,title=?,description=?,priority=?,assignee_id=?,start_at=?,due_at=?,updated_at=? WHERE id=? AND team_id=?`,
|
||||
t.Kind, t.Title, t.Description, t.Priority, t.AssigneeID, t.StartAt, t.DueAt, now, t.ID, t.TeamID).Error; err != nil {
|
||||
return nil, commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
if t.AssigneeID > 0 && t.AssigneeID != prevAssignee {
|
||||
s.teamNotice(t.TeamID, t.AssigneeID, userID, "assign", fmt.Sprint(t.ID),
|
||||
fmt.Sprintf("%s 给你指派了%s:%s", s.teamUserName(userID), teamTaskKindLabel(t.Kind), t.Title))
|
||||
}
|
||||
t.UpdatedAt = now
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *TeamService) TaskSetStatus(teamID, taskID, userID int64, status string) error {
|
||||
switch status {
|
||||
case "open", "doing", "done", "closed":
|
||||
default:
|
||||
return commonservice.BadRequest("TEAM_STATUS_INVALID")
|
||||
}
|
||||
role, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var assignee, creator int64
|
||||
var title, history, kind string
|
||||
row := s.DB.Raw(`SELECT assignee_id,creator_id,title,history,kind FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, taskID, teamID).Row()
|
||||
if err := row.Scan(&assignee, &creator, &title, &history, &kind); err != nil {
|
||||
return commonservice.NotFound("TEAM_TASK_NOT_FOUND")
|
||||
}
|
||||
if commonservice.TeamRoleRank(role) < commonservice.TeamRoleRank("admin") && userID != assignee {
|
||||
return commonservice.Forbidden("TEAM_FORBIDDEN")
|
||||
}
|
||||
now := commonservice.NowRFC()
|
||||
history = appendHistoryNode(history, status, now)
|
||||
if err := s.DB.Exec(`UPDATE team_tasks SET status=?,history=?,updated_at=? WHERE id=?`, status, history, now, taskID).Error; err != nil {
|
||||
return commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
if (status == "done" || status == "closed") && userID != creator {
|
||||
s.teamNotice(teamID, creator, userID, "status", fmt.Sprint(taskID),
|
||||
fmt.Sprintf("%s 将%s「%s」标记为%s", s.teamUserName(userID), teamTaskKindLabel(kind), title, teamStatusLabel(status)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TeamService) TaskUrge(teamID, taskID, userID int64) error {
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
|
||||
return err
|
||||
}
|
||||
var assignee int64
|
||||
var title, kind string
|
||||
if err := s.DB.Raw(`SELECT assignee_id,title,kind FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, taskID, teamID).
|
||||
Row().Scan(&assignee, &title, &kind); err != nil {
|
||||
return commonservice.NotFound("TEAM_TASK_NOT_FOUND")
|
||||
}
|
||||
if assignee <= 0 {
|
||||
return commonservice.BadRequest("TEAM_NO_ASSIGNEE")
|
||||
}
|
||||
now := commonservice.NowRFC()
|
||||
if err := s.DB.Exec(`UPDATE team_tasks SET urged_at=?,updated_at=? WHERE id=?`, now, now, taskID).Error; err != nil {
|
||||
return commonservice.Internal("SAVE_FAILED")
|
||||
}
|
||||
s.teamNotice(teamID, assignee, userID, "urge", fmt.Sprint(taskID),
|
||||
fmt.Sprintf("%s 催办了%s:%s", s.teamUserName(userID), teamTaskKindLabel(kind), title))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TeamService) TaskDelete(teamID, taskID, userID int64) error {
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.DB.Exec(`UPDATE team_tasks SET deleted=1,updated_at=? WHERE id=? AND team_id=?`, commonservice.NowRFC(), taskID, teamID).Error
|
||||
}
|
||||
|
||||
func (s *TeamService) TaskList(teamID, userID int64, filter string) ([]TeamTaskDTO, error) {
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := `SELECT t.id,t.team_id,t.kind,t.title,t.description,t.priority,t.status,t.creator_id,t.assignee_id,
|
||||
t.start_at,t.due_at,t.urged_at,t.history,t.updated_at,
|
||||
COALESCE((SELECT ` + teamDisplayExpr + ` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=t.creator_id),''),
|
||||
COALESCE((SELECT ` + teamDisplayExpr + ` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=t.assignee_id),'')
|
||||
FROM team_tasks t WHERE t.team_id=? AND t.deleted=0`
|
||||
args := []any{teamID}
|
||||
switch filter {
|
||||
case "mine":
|
||||
q += ` AND t.assignee_id=?`
|
||||
args = append(args, userID)
|
||||
case "created":
|
||||
q += ` AND t.creator_id=?`
|
||||
args = append(args, userID)
|
||||
case "open":
|
||||
q += ` AND t.status IN ('open','doing')`
|
||||
}
|
||||
q += ` ORDER BY t.updated_at DESC LIMIT 500`
|
||||
rows, err := s.DB.Raw(q, args...).Rows()
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TeamTaskDTO{}
|
||||
for rows.Next() {
|
||||
var t TeamTaskDTO
|
||||
if err := rows.Scan(&t.ID, &t.TeamID, &t.Kind, &t.Title, &t.Description, &t.Priority, &t.Status, &t.CreatorID, &t.AssigneeID,
|
||||
&t.StartAt, &t.DueAt, &t.UrgedAt, &t.History, &t.UpdatedAt, &t.Creator, &t.Assignee); err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- 共享 ----------
|
||||
|
||||
func (s *TeamService) SharedItems(teamID, userID int64) ([]TeamSharedItem, error) {
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := `SELECT 'todo',s.uuid,s.user_id,` + teamDisplayExpr + `,s.title,s.status,s.priority,s.due_at,s.updated_at
|
||||
FROM sync_todos s JOIN users u ON u.id=s.user_id LEFT JOIN user_profiles p ON p.user_id=s.user_id
|
||||
WHERE s.team_id=? AND s.deleted=0
|
||||
UNION ALL
|
||||
SELECT 'ticket',s.uuid,s.user_id,` + teamDisplayExpr + `,s.title,s.status,s.priority,s.due_at,s.updated_at
|
||||
FROM sync_tickets s JOIN users u ON u.id=s.user_id LEFT JOIN user_profiles p ON p.user_id=s.user_id
|
||||
WHERE s.team_id=? AND s.deleted=0
|
||||
ORDER BY 9 DESC LIMIT 500`
|
||||
rows, err := s.DB.Raw(q, teamID, teamID).Rows()
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TeamSharedItem{}
|
||||
for rows.Next() {
|
||||
var it TeamSharedItem
|
||||
if err := rows.Scan(&it.Kind, &it.UUID, &it.UserID, &it.Owner, &it.Title, &it.Status, &it.Priority, &it.DueAt, &it.UpdatedAt); err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *TeamService) UrgeShared(teamID, userID int64, kind, uuid string) error {
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
|
||||
return err
|
||||
}
|
||||
table := "sync_todos"
|
||||
if kind == "ticket" {
|
||||
table = "sync_tickets"
|
||||
}
|
||||
var owner int64
|
||||
var title string
|
||||
if err := s.DB.Raw(`SELECT user_id,title FROM `+table+` WHERE uuid=? AND team_id=? AND deleted=0`, uuid, teamID).
|
||||
Row().Scan(&owner, &title); err != nil {
|
||||
return commonservice.NotFound("TEAM_TASK_NOT_FOUND")
|
||||
}
|
||||
s.teamNotice(teamID, owner, userID, "urge", uuid,
|
||||
fmt.Sprintf("%s 催办了你共享的%s:%s", s.teamUserName(userID), teamTaskKindLabel(kind), title))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 日报 / 摘要 ----------
|
||||
|
||||
func (s *TeamService) ReportSubmit(teamID, userID int64, date, content string) error {
|
||||
if !teamDateRe.MatchString(date) {
|
||||
return commonservice.BadRequest("TEAM_DATE_INVALID")
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return commonservice.BadRequest("TEAM_REPORT_EMPTY")
|
||||
}
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.DB.Exec(`INSERT INTO team_reports(team_id,user_id,date,content,submitted_at) VALUES(?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE content=VALUES(content),submitted_at=VALUES(submitted_at)`,
|
||||
teamID, userID, date, content, commonservice.NowRFC()).Error
|
||||
}
|
||||
|
||||
func (s *TeamService) ReportBoardGet(teamID, userID int64, date string) (*TeamReportBoard, error) {
|
||||
if !teamDateRe.MatchString(date) {
|
||||
return nil, commonservice.BadRequest("TEAM_DATE_INVALID")
|
||||
}
|
||||
role, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
board := &TeamReportBoard{Date: date, Role: role, Reports: []TeamReportDTO{}, Missing: []TeamMemberDTO{}}
|
||||
rows, err := s.DB.Raw(`SELECT r.user_id,`+teamDisplayExpr+`,r.content,r.submitted_at
|
||||
FROM team_reports r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=r.user_id
|
||||
WHERE r.team_id=? AND r.date=? ORDER BY r.submitted_at`, teamID, date).Rows()
|
||||
if err != nil {
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
submitted := map[int64]bool{}
|
||||
for rows.Next() {
|
||||
var r TeamReportDTO
|
||||
if err := rows.Scan(&r.UserID, &r.User, &r.Content, &r.SubmittedAt); err != nil {
|
||||
rows.Close()
|
||||
return nil, commonservice.Internal("QUERY_FAILED")
|
||||
}
|
||||
r.Date = date
|
||||
if commonservice.TeamRoleRank(role) < commonservice.TeamRoleRank("admin") && r.UserID != userID {
|
||||
r.Content = ""
|
||||
}
|
||||
submitted[r.UserID] = true
|
||||
board.Reports = append(board.Reports, r)
|
||||
}
|
||||
rows.Close()
|
||||
members, err := s.memberRows(teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range members {
|
||||
if !submitted[m.UserID] {
|
||||
board.Missing = append(board.Missing, m)
|
||||
}
|
||||
}
|
||||
var d TeamDigestDTO
|
||||
err = s.DB.Raw(`SELECT date,content,provider,generated_at FROM team_digests WHERE team_id=? AND date=?`, teamID, date).
|
||||
Row().Scan(&d.Date, &d.Content, &d.Provider, &d.GeneratedAt)
|
||||
if err == nil {
|
||||
d.Content = stripFence(d.Content)
|
||||
board.Digest = &d
|
||||
}
|
||||
return board, nil
|
||||
}
|
||||
|
||||
func (s *TeamService) ReportUrge(teamID, actorID, targetUserID int64, date string) error {
|
||||
if !teamDateRe.MatchString(date) {
|
||||
return commonservice.BadRequest("TEAM_DATE_INVALID")
|
||||
}
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, actorID, "admin"); err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := commonservice.TeamRole(s.DB, teamID, targetUserID)
|
||||
if err != nil || r == "" {
|
||||
return commonservice.NotFound("TEAM_USER_NOT_FOUND")
|
||||
}
|
||||
s.teamNotice(teamID, targetUserID, actorID, "report_urge", date,
|
||||
fmt.Sprintf("%s 提醒你提交 %s 的团队日报", s.teamUserName(actorID), date))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DigestSave 存客户端本地 AI 生成后的摘要(admin+)。
|
||||
func (s *TeamService) DigestSave(teamID, userID int64, date, content, provider string) error {
|
||||
if !teamDateRe.MatchString(date) {
|
||||
return commonservice.BadRequest("TEAM_DATE_INVALID")
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return commonservice.BadRequest("TEAM_DIGEST_EMPTY")
|
||||
}
|
||||
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.DB.Exec(`INSERT INTO team_digests(team_id,date,content,provider,generated_at) VALUES(?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE content=VALUES(content),provider=VALUES(provider),generated_at=VALUES(generated_at)`,
|
||||
teamID, date, content, provider, commonservice.NowRFC()).Error
|
||||
}
|
||||
5
main.go
5
main.go
@@ -1,8 +1,7 @@
|
||||
package main
|
||||
|
||||
// nl-pms-api:code-count(view 桌面端)的文件存储服务。
|
||||
// 客户端凭配置密钥上传图片(POST /api/v1/files),换取可公开访问的
|
||||
// http URL(GET /files/...),Markdown 内容图 / 头像等远程场景直接引用该 URL。
|
||||
// nl-pms-api:code-count(view 桌面端)的同步 + 文件存储 HTTP API。
|
||||
// 提供 JWT 认证、待办/工单/记事本推拉、设置/资料、团队协作与图片上传。
|
||||
//
|
||||
// 表结构约定:仅 dev 环境(config.yaml 里 env: dev)启动时 AutoMigrate;
|
||||
// 生产环境启动绝不执行任何 DDL,缺表直接退出(需先手工执行 init.sql)。
|
||||
|
||||
84
migrations/20260815_admin_totp_update.sql
Normal file
84
migrations/20260815_admin_totp_update.sql
Normal file
@@ -0,0 +1,84 @@
|
||||
-- 管理员后台 / TOTP / 日活 / AI 用量 / 发版 / stepup
|
||||
-- 可重复执行(列/表不存在才添加)
|
||||
|
||||
USE code_count;
|
||||
|
||||
-- users 扩展列
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='totp_secret')=0,
|
||||
'ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''TOTP Base32 密钥(仅管理员)'' AFTER password_hash', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='totp_enabled')=0,
|
||||
'ALTER TABLE users ADD COLUMN totp_enabled TINYINT NOT NULL DEFAULT 0 COMMENT ''是否已启用 TOTP'' AFTER totp_secret', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='last_login_ip')=0,
|
||||
'ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''最近登录 IP'' AFTER totp_enabled', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='last_seen_at')=0,
|
||||
'ALTER TABLE users ADD COLUMN last_seen_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''最近活跃时间 RFC3339'' AFTER last_login_ip', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='ai_banned')=0,
|
||||
'ALTER TABLE users ADD COLUMN ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT ''禁止使用 AI'' AFTER last_seen_at', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='disabled')=0,
|
||||
'ALTER TABLE users ADD COLUMN disabled TINYINT NOT NULL DEFAULT 0 COMMENT ''禁用账号'' AFTER ai_banned', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- teams.ai_banned
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='teams' AND COLUMN_NAME='ai_banned')=0,
|
||||
'ALTER TABLE teams ADD COLUMN ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT ''禁止团队使用 AI'' AFTER digest_time', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_daily_active(
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
active_date CHAR(10) NOT NULL COMMENT '活跃日期 YYYY-MM-DD',
|
||||
last_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '当日最近 IP',
|
||||
last_seen_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '当日最近活跃时间',
|
||||
PRIMARY KEY(user_id, active_date),
|
||||
KEY idx_uda_date(active_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户日活';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_daily(
|
||||
user_id BIGINT NOT NULL COMMENT '用户ID',
|
||||
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '团队ID,0=个人',
|
||||
usage_date CHAR(10) NOT NULL COMMENT '用量日期 YYYY-MM-DD',
|
||||
provider VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'AI 提供商',
|
||||
prompt_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '提示 token',
|
||||
completion_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '补全 token',
|
||||
calls INT NOT NULL DEFAULT 0 COMMENT '调用次数',
|
||||
estimated TINYINT NOT NULL DEFAULT 0 COMMENT '是否含估算数据',
|
||||
PRIMARY KEY(user_id, team_id, usage_date, provider),
|
||||
KEY idx_aud_date(usage_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI Token 日汇总';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_releases(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '发版ID',
|
||||
version VARCHAR(32) NOT NULL COMMENT '版本号 semver',
|
||||
channel VARCHAR(16) NOT NULL DEFAULT 'stable' COMMENT '渠道',
|
||||
filename VARCHAR(255) NOT NULL COMMENT '存储相对路径',
|
||||
sha256 CHAR(64) NOT NULL DEFAULT '' COMMENT '文件 SHA256',
|
||||
size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '字节大小',
|
||||
changelog MEDIUMTEXT NOT NULL COMMENT '更新说明',
|
||||
created_at VARCHAR(32) NOT NULL COMMENT '上传时间',
|
||||
is_latest TINYINT NOT NULL DEFAULT 0 COMMENT '是否当前最新',
|
||||
UNIQUE KEY idx_app_releases_ver_ch(version, channel),
|
||||
KEY idx_app_releases_latest(channel, is_latest)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端发版';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_stepup(
|
||||
user_id BIGINT PRIMARY KEY COMMENT '管理员用户ID',
|
||||
token_hash CHAR(64) NOT NULL COMMENT 'stepup token SHA256',
|
||||
client_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '签发时 IP',
|
||||
expires_at VARCHAR(32) NOT NULL COMMENT '过期时间 RFC3339'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员敏感操作二次验证';
|
||||
503
migrations/20260815_restore_column_comments.sql
Normal file
503
migrations/20260815_restore_column_comments.sql
Normal file
@@ -0,0 +1,503 @@
|
||||
-- ============================================================
|
||||
-- 补全 / 恢复 code_count 全库表与字段 COMMENT
|
||||
-- 可重复执行:列存在才 MODIFY;已有相同注释再跑无害。
|
||||
-- 用法:
|
||||
-- mysql -u root -p code_count < migrations/20260815_restore_column_comments.sql
|
||||
-- 说明:
|
||||
-- - 不改业务数据;仅重写列类型元数据中的 COMMENT(类型/默认值与 init.sql 对齐)
|
||||
-- - 若某列尚未升级出来(旧库缺列),对应语句自动跳过
|
||||
-- ============================================================
|
||||
|
||||
USE code_count;
|
||||
|
||||
-- 通用:表存在且列存在时才执行 ALTER
|
||||
-- 用法示例见下方各 CALL 风格的动态 SQL
|
||||
|
||||
-- -------------------- users --------------------
|
||||
ALTER TABLE users COMMENT='应用账号';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='id')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='username')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN username VARCHAR(64) NOT NULL COMMENT ''登录名''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='password_hash')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(100) NOT NULL COMMENT ''bcrypt 密码哈希''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='totp_secret')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN totp_secret VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''TOTP Base32 密钥(仅管理员)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='totp_enabled')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN totp_enabled TINYINT NOT NULL DEFAULT 0 COMMENT ''是否已启用 TOTP''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='last_login_ip')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN last_login_ip VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''最近登录 IP''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='last_seen_at')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN last_seen_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''最近活跃时间 RFC3339''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='ai_banned')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT ''禁止使用 AI''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='disabled')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN disabled TINYINT NOT NULL DEFAULT 0 COMMENT ''禁用账号''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='created_at')>0,
|
||||
'ALTER TABLE users MODIFY COLUMN created_at VARCHAR(32) NOT NULL COMMENT ''注册时间(RFC3339)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- sync_todos --------------------
|
||||
ALTER TABLE sync_todos COMMENT='待办同步表';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''所属用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='uuid')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN uuid CHAR(36) NOT NULL COMMENT ''客户端生成的全局唯一ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='title')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN title TEXT NOT NULL COMMENT ''标题''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='content')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN content MEDIUMTEXT NOT NULL COMMENT ''正文(Markdown,可含内嵌图片)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='project_name')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN project_name VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''关联项目名(展示用)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='due_at')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN due_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''截止时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='priority')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN priority VARCHAR(16) NOT NULL DEFAULT ''medium'' COMMENT ''优先级:low/medium/high''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='status')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT ''open'' COMMENT ''状态:open/doing/done/cancelled''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='history')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN history MEDIUMTEXT NOT NULL COMMENT ''生命周期轨迹 JSON''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='team_id')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''共享团队ID,0=私密''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='created_at')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN created_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''创建时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='updated_at')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN updated_at VARCHAR(32) NOT NULL COMMENT ''最后更新时间(LWW)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='deleted')>0,
|
||||
'ALTER TABLE sync_todos MODIFY COLUMN deleted TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除标记:1=已删''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- sync_tickets --------------------
|
||||
ALTER TABLE sync_tickets COMMENT='工单同步表';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''所属用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='uuid')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN uuid CHAR(36) NOT NULL COMMENT ''客户端生成的全局唯一ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='title')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN title TEXT NOT NULL COMMENT ''标题''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='description')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN description MEDIUMTEXT NOT NULL COMMENT ''描述(Markdown)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='type')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN type VARCHAR(16) NOT NULL DEFAULT ''task'' COMMENT ''类型:task/bug/feature 等''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='project_name')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN project_name VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''关联项目名''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='start_at')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN start_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''开始时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='due_at')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN due_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''截止时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='status')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT ''open'' COMMENT ''状态''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='priority')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN priority VARCHAR(16) NOT NULL DEFAULT ''medium'' COMMENT ''优先级''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='history')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN history MEDIUMTEXT NOT NULL COMMENT ''生命周期轨迹 JSON''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='team_id')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''共享团队ID,0=私密''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='created_at')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN created_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''创建时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='updated_at')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN updated_at VARCHAR(32) NOT NULL COMMENT ''最后更新时间(LWW)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_tickets' AND COLUMN_NAME='deleted')>0,
|
||||
'ALTER TABLE sync_tickets MODIFY COLUMN deleted TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除标记''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- sync_notes --------------------
|
||||
ALTER TABLE sync_notes COMMENT='记事本同步表';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_notes' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE sync_notes MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''所属用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_notes' AND COLUMN_NAME='uuid')>0,
|
||||
'ALTER TABLE sync_notes MODIFY COLUMN uuid CHAR(36) NOT NULL COMMENT ''客户端生成的全局唯一ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_notes' AND COLUMN_NAME='content')>0,
|
||||
'ALTER TABLE sync_notes MODIFY COLUMN content MEDIUMTEXT NOT NULL COMMENT ''记事本正文''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_notes' AND COLUMN_NAME='updated_at')>0,
|
||||
'ALTER TABLE sync_notes MODIFY COLUMN updated_at VARCHAR(32) NOT NULL COMMENT ''最后更新时间(LWW)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_notes' AND COLUMN_NAME='deleted')>0,
|
||||
'ALTER TABLE sync_notes MODIFY COLUMN deleted TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除标记''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- sync_settings --------------------
|
||||
ALTER TABLE sync_settings COMMENT='用户同步设置键值表';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_settings' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE sync_settings MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''所属用户ID(全局资源挂在管理员 id=1)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_settings' AND COLUMN_NAME='name')>0,
|
||||
'ALTER TABLE sync_settings MODIFY COLUMN name VARCHAR(64) NOT NULL COMMENT ''设置键名''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_settings' AND COLUMN_NAME='value')>0,
|
||||
'ALTER TABLE sync_settings MODIFY COLUMN value MEDIUMTEXT NOT NULL COMMENT ''设置值(明文或密文)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_settings' AND COLUMN_NAME='updated_at')>0,
|
||||
'ALTER TABLE sync_settings MODIFY COLUMN updated_at VARCHAR(32) NOT NULL COMMENT ''最后更新时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- user_profiles --------------------
|
||||
ALTER TABLE user_profiles COMMENT='用户公开资料';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_profiles' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE user_profiles MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''对应用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_profiles' AND COLUMN_NAME='nickname')>0,
|
||||
'ALTER TABLE user_profiles MODIFY COLUMN nickname VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''昵称''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_profiles' AND COLUMN_NAME='title')>0,
|
||||
'ALTER TABLE user_profiles MODIFY COLUMN title VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''头衔/职位''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_profiles' AND COLUMN_NAME='email')>0,
|
||||
'ALTER TABLE user_profiles MODIFY COLUMN email VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''公开邮箱''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_profiles' AND COLUMN_NAME='bio')>0,
|
||||
'ALTER TABLE user_profiles MODIFY COLUMN bio VARCHAR(500) NOT NULL DEFAULT '''' COMMENT ''个人简介''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_profiles' AND COLUMN_NAME='tech_tags')>0,
|
||||
'ALTER TABLE user_profiles MODIFY COLUMN tech_tags VARCHAR(1000) NOT NULL DEFAULT ''[]'' COMMENT ''技术栈标签 JSON 数组''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_profiles' AND COLUMN_NAME='avatar_thumb')>0,
|
||||
'ALTER TABLE user_profiles MODIFY COLUMN avatar_thumb MEDIUMTEXT NOT NULL COMMENT ''头像缩略图(dataURL 或 URL)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_profiles' AND COLUMN_NAME='updated_at')>0,
|
||||
'ALTER TABLE user_profiles MODIFY COLUMN updated_at VARCHAR(32) NOT NULL COMMENT ''最后更新时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- user_avatar_history --------------------
|
||||
ALTER TABLE user_avatar_history COMMENT='用户头像历史';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_avatar_history' AND COLUMN_NAME='id')>0,
|
||||
'ALTER TABLE user_avatar_history MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''历史记录ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_avatar_history' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE user_avatar_history MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''所属用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_avatar_history' AND COLUMN_NAME='mode')>0,
|
||||
'ALTER TABLE user_avatar_history MODIFY COLUMN mode VARCHAR(16) NOT NULL DEFAULT ''url'' COMMENT ''存储模式:url | base64''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_avatar_history' AND COLUMN_NAME='value')>0,
|
||||
'ALTER TABLE user_avatar_history MODIFY COLUMN value MEDIUMTEXT NOT NULL COMMENT ''头像内容(URL 或 dataURL)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_avatar_history' AND COLUMN_NAME='created_at')>0,
|
||||
'ALTER TABLE user_avatar_history MODIFY COLUMN created_at VARCHAR(32) NOT NULL COMMENT ''写入时间(RFC3339)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- teams --------------------
|
||||
ALTER TABLE teams COMMENT='团队';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='teams' AND COLUMN_NAME='id')>0,
|
||||
'ALTER TABLE teams MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''团队ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='teams' AND COLUMN_NAME='name')>0,
|
||||
'ALTER TABLE teams MODIFY COLUMN name VARCHAR(64) NOT NULL COMMENT ''团队名称''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='teams' AND COLUMN_NAME='owner_id')>0,
|
||||
'ALTER TABLE teams MODIFY COLUMN owner_id BIGINT NOT NULL COMMENT ''创建者用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='teams' AND COLUMN_NAME='digest_time')>0,
|
||||
'ALTER TABLE teams MODIFY COLUMN digest_time VARCHAR(8) NOT NULL DEFAULT ''21:00'' COMMENT ''日报 AI 摘要自动生成时间 HH:MM''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='teams' AND COLUMN_NAME='ai_banned')>0,
|
||||
'ALTER TABLE teams MODIFY COLUMN ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT ''禁止团队使用 AI''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='teams' AND COLUMN_NAME='created_at')>0,
|
||||
'ALTER TABLE teams MODIFY COLUMN created_at VARCHAR(32) NOT NULL COMMENT ''创建时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- team_members --------------------
|
||||
ALTER TABLE team_members COMMENT='团队成员';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_members' AND COLUMN_NAME='team_id')>0,
|
||||
'ALTER TABLE team_members MODIFY COLUMN team_id BIGINT NOT NULL COMMENT ''团队ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_members' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE team_members MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''成员用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_members' AND COLUMN_NAME='role')>0,
|
||||
'ALTER TABLE team_members MODIFY COLUMN role VARCHAR(16) NOT NULL DEFAULT ''member'' COMMENT ''角色:owner/admin/member''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_members' AND COLUMN_NAME='joined_at')>0,
|
||||
'ALTER TABLE team_members MODIFY COLUMN joined_at VARCHAR(32) NOT NULL COMMENT ''加入时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- team_tasks --------------------
|
||||
ALTER TABLE team_tasks COMMENT='团队任务/工单';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='id')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''任务ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='team_id')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN team_id BIGINT NOT NULL COMMENT ''所属团队ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='kind')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN kind VARCHAR(16) NOT NULL DEFAULT ''todo'' COMMENT ''种类:todo/ticket''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='title')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN title TEXT NOT NULL COMMENT ''标题''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='description')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN description MEDIUMTEXT NOT NULL COMMENT ''描述''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='priority')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN priority VARCHAR(16) NOT NULL DEFAULT ''medium'' COMMENT ''优先级''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='status')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT ''open'' COMMENT ''状态''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='creator_id')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN creator_id BIGINT NOT NULL COMMENT ''创建者用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='assignee_id')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN assignee_id BIGINT NOT NULL DEFAULT 0 COMMENT ''指派人用户ID,0=未指派''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='start_at')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN start_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''开始时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='due_at')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN due_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''截止时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='urged_at')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN urged_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''最近催办时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='history')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN history MEDIUMTEXT NOT NULL COMMENT ''生命周期轨迹 JSON''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='updated_at')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN updated_at VARCHAR(32) NOT NULL COMMENT ''最后更新时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='deleted')>0,
|
||||
'ALTER TABLE team_tasks MODIFY COLUMN deleted TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除标记''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- team_reports --------------------
|
||||
ALTER TABLE team_reports COMMENT='团队日报';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='team_id')>0,
|
||||
'ALTER TABLE team_reports MODIFY COLUMN team_id BIGINT NOT NULL COMMENT ''团队ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE team_reports MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''提交人用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='date')>0,
|
||||
'ALTER TABLE team_reports MODIFY COLUMN date CHAR(10) NOT NULL COMMENT ''日报日期 YYYY-MM-DD''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='content')>0,
|
||||
'ALTER TABLE team_reports MODIFY COLUMN content MEDIUMTEXT NOT NULL COMMENT ''日报正文(Markdown)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='submitted_at')>0,
|
||||
'ALTER TABLE team_reports MODIFY COLUMN submitted_at VARCHAR(32) NOT NULL COMMENT ''提交/更新时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- team_digests --------------------
|
||||
ALTER TABLE team_digests COMMENT='团队日报 AI 摘要';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='team_id')>0,
|
||||
'ALTER TABLE team_digests MODIFY COLUMN team_id BIGINT NOT NULL COMMENT ''团队ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='date')>0,
|
||||
'ALTER TABLE team_digests MODIFY COLUMN date CHAR(10) NOT NULL COMMENT ''摘要对应日期 YYYY-MM-DD''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='content')>0,
|
||||
'ALTER TABLE team_digests MODIFY COLUMN content MEDIUMTEXT NOT NULL COMMENT ''AI 摘要正文(Markdown)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='provider')>0,
|
||||
'ALTER TABLE team_digests MODIFY COLUMN provider VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''生成所用 AI 提供商''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='generated_at')>0,
|
||||
'ALTER TABLE team_digests MODIFY COLUMN generated_at VARCHAR(32) NOT NULL COMMENT ''生成时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- team_notices --------------------
|
||||
ALTER TABLE team_notices COMMENT='团队通知';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='id')>0,
|
||||
'ALTER TABLE team_notices MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''通知ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='team_id')>0,
|
||||
'ALTER TABLE team_notices MODIFY COLUMN team_id BIGINT NOT NULL COMMENT ''团队ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='to_user')>0,
|
||||
'ALTER TABLE team_notices MODIFY COLUMN to_user BIGINT NOT NULL COMMENT ''接收用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='from_user')>0,
|
||||
'ALTER TABLE team_notices MODIFY COLUMN from_user BIGINT NOT NULL COMMENT ''发送用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='kind')>0,
|
||||
'ALTER TABLE team_notices MODIFY COLUMN kind VARCHAR(16) NOT NULL COMMENT ''通知类型''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='ref_id')>0,
|
||||
'ALTER TABLE team_notices MODIFY COLUMN ref_id VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''关联对象ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='content')>0,
|
||||
'ALTER TABLE team_notices MODIFY COLUMN content TEXT NOT NULL COMMENT ''通知正文''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='created_at')>0,
|
||||
'ALTER TABLE team_notices MODIFY COLUMN created_at VARCHAR(32) NOT NULL COMMENT ''创建时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- pms_files(原先 CREATE 多为行尾注释,列 COMMENT 常缺失) --------------------
|
||||
ALTER TABLE pms_files COMMENT='已上传文件元数据';
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='id')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''文件ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='name')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN name VARCHAR(191) NOT NULL COMMENT ''存储相对路径,如 2026/08/13/<hex>.jpg''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='original')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN original VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''原始文件名(仅备查)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='mime')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN mime VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''MIME 类型''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='size')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN size BIGINT NOT NULL DEFAULT 0 COMMENT ''文件字节大小''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='sha256')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN sha256 CHAR(64) NOT NULL DEFAULT '''' COMMENT ''内容摘要,同归属秒传去重''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN user_id BIGINT NOT NULL DEFAULT 0 COMMENT ''上传者用户ID,0=未知''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='team_id')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''上传时所在团队ID,0=个人''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='kind')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN kind VARCHAR(16) NOT NULL DEFAULT '''' COMMENT ''用途:avatar | content''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='created_at')>0,
|
||||
'ALTER TABLE pms_files MODIFY COLUMN created_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''上传时间(RFC3339)''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- user_daily_active --------------------
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_daily_active')>0,
|
||||
'ALTER TABLE user_daily_active COMMENT=''用户日活''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_daily_active' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE user_daily_active MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_daily_active' AND COLUMN_NAME='active_date')>0,
|
||||
'ALTER TABLE user_daily_active MODIFY COLUMN active_date CHAR(10) NOT NULL COMMENT ''活跃日期 YYYY-MM-DD''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_daily_active' AND COLUMN_NAME='last_ip')>0,
|
||||
'ALTER TABLE user_daily_active MODIFY COLUMN last_ip VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''当日最近 IP''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='user_daily_active' AND COLUMN_NAME='last_seen_at')>0,
|
||||
'ALTER TABLE user_daily_active MODIFY COLUMN last_seen_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''当日最近活跃时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- ai_usage_daily --------------------
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_usage_daily')>0,
|
||||
'ALTER TABLE ai_usage_daily COMMENT=''AI Token 日汇总''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_usage_daily' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE ai_usage_daily MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_usage_daily' AND COLUMN_NAME='team_id')>0,
|
||||
'ALTER TABLE ai_usage_daily MODIFY COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''团队ID,0=个人''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_usage_daily' AND COLUMN_NAME='usage_date')>0,
|
||||
'ALTER TABLE ai_usage_daily MODIFY COLUMN usage_date CHAR(10) NOT NULL COMMENT ''用量日期 YYYY-MM-DD''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_usage_daily' AND COLUMN_NAME='provider')>0,
|
||||
'ALTER TABLE ai_usage_daily MODIFY COLUMN provider VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''AI 提供商''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_usage_daily' AND COLUMN_NAME='prompt_tokens')>0,
|
||||
'ALTER TABLE ai_usage_daily MODIFY COLUMN prompt_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''提示 token''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_usage_daily' AND COLUMN_NAME='completion_tokens')>0,
|
||||
'ALTER TABLE ai_usage_daily MODIFY COLUMN completion_tokens BIGINT NOT NULL DEFAULT 0 COMMENT ''补全 token''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_usage_daily' AND COLUMN_NAME='calls')>0,
|
||||
'ALTER TABLE ai_usage_daily MODIFY COLUMN calls INT NOT NULL DEFAULT 0 COMMENT ''调用次数''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='ai_usage_daily' AND COLUMN_NAME='estimated')>0,
|
||||
'ALTER TABLE ai_usage_daily MODIFY COLUMN estimated TINYINT NOT NULL DEFAULT 0 COMMENT ''是否含估算数据''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- app_releases --------------------
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases')>0,
|
||||
'ALTER TABLE app_releases COMMENT=''客户端发版''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases' AND COLUMN_NAME='id')>0,
|
||||
'ALTER TABLE app_releases MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''发版ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases' AND COLUMN_NAME='version')>0,
|
||||
'ALTER TABLE app_releases MODIFY COLUMN version VARCHAR(32) NOT NULL COMMENT ''版本号 semver''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases' AND COLUMN_NAME='channel')>0,
|
||||
'ALTER TABLE app_releases MODIFY COLUMN channel VARCHAR(16) NOT NULL DEFAULT ''stable'' COMMENT ''渠道''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases' AND COLUMN_NAME='filename')>0,
|
||||
'ALTER TABLE app_releases MODIFY COLUMN filename VARCHAR(255) NOT NULL COMMENT ''存储相对路径''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases' AND COLUMN_NAME='sha256')>0,
|
||||
'ALTER TABLE app_releases MODIFY COLUMN sha256 CHAR(64) NOT NULL DEFAULT '''' COMMENT ''文件 SHA256''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases' AND COLUMN_NAME='size_bytes')>0,
|
||||
'ALTER TABLE app_releases MODIFY COLUMN size_bytes BIGINT NOT NULL DEFAULT 0 COMMENT ''字节大小''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases' AND COLUMN_NAME='changelog')>0,
|
||||
'ALTER TABLE app_releases MODIFY COLUMN changelog MEDIUMTEXT NOT NULL COMMENT ''更新说明''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases' AND COLUMN_NAME='created_at')>0,
|
||||
'ALTER TABLE app_releases MODIFY COLUMN created_at VARCHAR(32) NOT NULL COMMENT ''上传时间''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='app_releases' AND COLUMN_NAME='is_latest')>0,
|
||||
'ALTER TABLE app_releases MODIFY COLUMN is_latest TINYINT NOT NULL DEFAULT 0 COMMENT ''是否当前最新''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- -------------------- admin_stepup --------------------
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_stepup')>0,
|
||||
'ALTER TABLE admin_stepup COMMENT=''管理员敏感操作二次验证''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_stepup' AND COLUMN_NAME='user_id')>0,
|
||||
'ALTER TABLE admin_stepup MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''管理员用户ID''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_stepup' AND COLUMN_NAME='token_hash')>0,
|
||||
'ALTER TABLE admin_stepup MODIFY COLUMN token_hash CHAR(64) NOT NULL COMMENT ''stepup token SHA256''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_stepup' AND COLUMN_NAME='client_ip')>0,
|
||||
'ALTER TABLE admin_stepup MODIFY COLUMN client_ip VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''签发时 IP''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_stepup' AND COLUMN_NAME='expires_at')>0,
|
||||
'ALTER TABLE admin_stepup MODIFY COLUMN expires_at VARCHAR(32) NOT NULL COMMENT ''过期时间 RFC3339''', 'SELECT 1');
|
||||
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
|
||||
-- 抽查:仍为空注释的列(执行完应为 0 行,或仅含未建表跳过的情况)
|
||||
-- SELECT TABLE_NAME, COLUMN_NAME, COLUMN_COMMENT
|
||||
-- FROM information_schema.COLUMNS
|
||||
-- WHERE TABLE_SCHEMA = DATABASE()
|
||||
-- AND TABLE_NAME IN (
|
||||
-- 'users','sync_todos','sync_tickets','sync_notes','sync_settings',
|
||||
-- 'user_profiles','user_avatar_history','teams','team_members','team_tasks',
|
||||
-- 'team_reports','team_digests','team_notices','pms_files',
|
||||
-- 'user_daily_active','ai_usage_daily','app_releases','admin_stepup'
|
||||
-- )
|
||||
-- AND (COLUMN_COMMENT IS NULL OR COLUMN_COMMENT = '')
|
||||
-- ORDER BY TABLE_NAME, ORDINAL_POSITION;
|
||||
15
migrations/20260815_user_avatar_history.sql
Normal file
15
migrations/20260815_user_avatar_history.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- ============================================================
|
||||
-- 用户头像历史(与线上账号关联,可多端回选)
|
||||
-- 用法:mysql -u root -p code_count < migrations/20260815_user_avatar_history.sql
|
||||
-- 或:在已选中库后执行本文件内容。
|
||||
-- 可重复执行(CREATE IF NOT EXISTS)。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_avatar_history(
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '历史记录ID',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
mode VARCHAR(16) NOT NULL DEFAULT 'url' COMMENT '存储模式:url | base64',
|
||||
value MEDIUMTEXT NOT NULL COMMENT '头像内容(URL 或 dataURL)',
|
||||
created_at VARCHAR(32) NOT NULL COMMENT '写入时间(RFC3339)',
|
||||
KEY idx_uah_user_id(user_id, id DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户头像历史(每人保留最近若干条)';
|
||||
Reference in New Issue
Block a user