diff --git a/README.md b/README.md index a1fc62d..49b698b 100644 --- a/README.md +++ b/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 ` | 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 ` | +| 刷新 | `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 公开:`` 标签无法携带鉴权头,靠随机路径保证不可枚举。 +view 打包配置 `build/sync.defaults.json` 的 `baseUrl` 指向本服务。 +登录后本地存 `sync_access_token` / `sync_refresh_token`;文件存储「服务器」模式只需填本服务 `baseUrl`,鉴权用登录 JWT。 diff --git a/config.example.yaml b/config.example.yaml index f345a59..d43829a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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: "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" diff --git a/go.mod b/go.mod index 9d8a841..65d818b 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index d7b8abe..b1696f4 100644 --- a/go.sum +++ b/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= diff --git a/init.sql b/init.sql index 3e0c8b5..8f0b61b 100644 --- a/init.sql +++ b/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='管理员敏感操作二次验证'; diff --git a/internal/commonservice/ctx.go b/internal/commonservice/ctx.go new file mode 100644 index 0000000..f0ed514 --- /dev/null +++ b/internal/commonservice/ctx.go @@ -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 +} diff --git a/internal/commonservice/errors.go b/internal/commonservice/errors.go new file mode 100644 index 0000000..0ae45b2 --- /dev/null +++ b/internal/commonservice/errors.go @@ -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} } diff --git a/internal/commonservice/jwt.go b/internal/commonservice/jwt.go new file mode 100644 index 0000000..aa7ef9a --- /dev/null +++ b/internal/commonservice/jwt.go @@ -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 +} diff --git a/internal/commonservice/team.go b/internal/commonservice/team.go new file mode 100644 index 0000000..9833b8b --- /dev/null +++ b/internal/commonservice/team.go @@ -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 +} diff --git a/internal/commonservice/util.go b/internal/commonservice/util.go new file mode 100644 index 0000000..2f5d983 --- /dev/null +++ b/internal/commonservice/util.go @@ -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 + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 588079f..2aa684c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,11 +16,21 @@ type Config struct { Listen string `yaml:"listen"` // BaseURL 拼接文件访问 URL 的前缀(客户端可达的地址);留空则按请求 Host 推断。 BaseURL string `yaml:"base_url"` - // APIKey 是客户端上传凭证:请求需带 Authorization: Bearer 。 - 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") diff --git a/internal/controller/admin.go b/internal/controller/admin.go new file mode 100644 index 0000000..b1c8d84 --- /dev/null +++ b/internal/controller/admin.go @@ -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) +} diff --git a/internal/controller/auth.go b/internal/controller/auth.go new file mode 100644 index 0000000..44ac84f --- /dev/null +++ b/internal/controller/auth.go @@ -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}) +} diff --git a/internal/controller/file.go b/internal/controller/file.go new file mode 100644 index 0000000..6534e6a --- /dev/null +++ b/internal/controller/file.go @@ -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 +} diff --git a/internal/handler/file_test.go b/internal/controller/file_test.go similarity index 57% rename from internal/handler/file_test.go rename to internal/controller/file_test.go index 62b0486..6799f97 100644 --- a/internal/handler/file_test.go +++ b/internal/controller/file_test.go @@ -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) } } diff --git a/internal/controller/notice.go b/internal/controller/notice.go new file mode 100644 index 0000000..75a92f1 --- /dev/null +++ b/internal/controller/notice.go @@ -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}) +} diff --git a/internal/controller/profile.go b/internal/controller/profile.go new file mode 100644 index 0000000..db0e50b --- /dev/null +++ b/internal/controller/profile.go @@ -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}) +} diff --git a/internal/controller/respond.go b/internal/controller/respond.go new file mode 100644 index 0000000..f65db10 --- /dev/null +++ b/internal/controller/respond.go @@ -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 +} diff --git a/internal/controller/settings.go b/internal/controller/settings.go new file mode 100644 index 0000000..dc1dd5d --- /dev/null +++ b/internal/controller/settings.go @@ -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) +} diff --git a/internal/controller/sync.go b/internal/controller/sync.go new file mode 100644 index 0000000..031f1f9 --- /dev/null +++ b/internal/controller/sync.go @@ -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}) +} diff --git a/internal/controller/team.go b/internal/controller/team.go new file mode 100644 index 0000000..60b7282 --- /dev/null +++ b/internal/controller/team.go @@ -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}) +} diff --git a/internal/database/db.go b/internal/database/db.go index 2a97094..f0ddbf7 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -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") } diff --git a/internal/handler/file.go b/internal/handler/file.go deleted file mode 100644 index 444a4e4..0000000 --- a/internal/handler/file.go +++ /dev/null @@ -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 可直接放进 / 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 -} diff --git a/internal/middleware/admin.go b/internal/middleware/admin.go new file mode 100644 index 0000000..f903f9e --- /dev/null +++ b/internal/middleware/admin.go @@ -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() + } +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go deleted file mode 100644 index 3beb0d4..0000000 --- a/internal/middleware/auth.go +++ /dev/null @@ -1,21 +0,0 @@ -package middleware - -import ( - "crypto/subtle" - "net/http" - "strings" - - "github.com/gin-gonic/gin" -) - -// RequireAPIKey 校验 Authorization: Bearer (常数时间比较),不匹配返回 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() - } -} diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go new file mode 100644 index 0000000..31c4ad1 --- /dev/null +++ b/internal/middleware/jwt.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "nl-pms-api/internal/commonservice" +) + +// RequireJWT 校验 Authorization: Bearer ,并把 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() + } +} diff --git a/internal/model/admin.go b/internal/model/admin.go new file mode 100644 index 0000000..da3ee8d --- /dev/null +++ b/internal/model/admin.go @@ -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" } diff --git a/internal/model/avatar_history.go b/internal/model/avatar_history.go new file mode 100644 index 0000000..f4c8ada --- /dev/null +++ b/internal/model/avatar_history.go @@ -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" } diff --git a/internal/model/profile.go b/internal/model/profile.go new file mode 100644 index 0000000..bed208b --- /dev/null +++ b/internal/model/profile.go @@ -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" } diff --git a/internal/model/sync.go b/internal/model/sync.go new file mode 100644 index 0000000..24e07ee --- /dev/null +++ b/internal/model/sync.go @@ -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" } diff --git a/internal/model/team.go b/internal/model/team.go new file mode 100644 index 0000000..c58aa60 --- /dev/null +++ b/internal/model/team.go @@ -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" } diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..d80d5fa --- /dev/null +++ b/internal/model/user.go @@ -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" } diff --git a/internal/router/router.go b/internal/router/router.go index c385183..3753331 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) diff --git a/internal/service/activity.go b/internal/service/activity.go new file mode 100644 index 0000000..d9b717a --- /dev/null +++ b/internal/service/activity.go @@ -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 +} diff --git a/internal/service/admin.go b/internal/service/admin.go new file mode 100644 index 0000000..b225e6c --- /dev/null +++ b/internal/service/admin.go @@ -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 +} diff --git a/internal/service/admin_security.go b/internal/service/admin_security.go new file mode 100644 index 0000000..0697449 --- /dev/null +++ b/internal/service/admin_security.go @@ -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() +} diff --git a/internal/service/auth.go b/internal/service/auth.go new file mode 100644 index 0000000..3f876b8 --- /dev/null +++ b/internal/service/auth.go @@ -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 +} diff --git a/internal/service/avatar_history.go b/internal/service/avatar_history.go new file mode 100644 index 0000000..0ff5af8 --- /dev/null +++ b/internal/service/avatar_history.go @@ -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 +} diff --git a/internal/service/file.go b/internal/service/file.go new file mode 100644 index 0000000..b9f1867 --- /dev/null +++ b/internal/service/file.go @@ -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 +} diff --git a/internal/service/notice.go b/internal/service/notice.go new file mode 100644 index 0000000..b80bdff --- /dev/null +++ b/internal/service/notice.go @@ -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 +} diff --git a/internal/service/profile.go b/internal/service/profile.go new file mode 100644 index 0000000..a04482e --- /dev/null +++ b/internal/service/profile.go @@ -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 +} diff --git a/internal/service/release.go b/internal/service/release.go new file mode 100644 index 0000000..e3685d6 --- /dev/null +++ b/internal/service/release.go @@ -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 +} diff --git a/internal/service/settings.go b/internal/service/settings.go new file mode 100644 index 0000000..27ba380 --- /dev/null +++ b/internal/service/settings.go @@ -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 +} diff --git a/internal/service/sync.go b/internal/service/sync.go new file mode 100644 index 0000000..232bc0c --- /dev/null +++ b/internal/service/sync.go @@ -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 + } +} diff --git a/internal/service/team.go b/internal/service/team.go new file mode 100644 index 0000000..b7bafa2 --- /dev/null +++ b/internal/service/team.go @@ -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 +} diff --git a/main.go b/main.go index 2a3187c..cafedd4 100644 --- a/main.go +++ b/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)。 diff --git a/migrations/20260815_admin_totp_update.sql b/migrations/20260815_admin_totp_update.sql new file mode 100644 index 0000000..3145474 --- /dev/null +++ b/migrations/20260815_admin_totp_update.sql @@ -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='管理员敏感操作二次验证'; diff --git a/migrations/20260815_restore_column_comments.sql b/migrations/20260815_restore_column_comments.sql new file mode 100644 index 0000000..62d6264 --- /dev/null +++ b/migrations/20260815_restore_column_comments.sql @@ -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/.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; diff --git a/migrations/20260815_user_avatar_history.sql b/migrations/20260815_user_avatar_history.sql new file mode 100644 index 0000000..58ddd97 --- /dev/null +++ b/migrations/20260815_user_avatar_history.sql @@ -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='用户头像历史(每人保留最近若干条)';