功能更新

This commit is contained in:
李琦
2026-08-15 17:04:47 +08:00
parent d9475ac9da
commit b71b99a60d
49 changed files with 4976 additions and 438 deletions

View File

@@ -1,70 +1,68 @@
# nl-pms-api
code-countview 桌面端)的文件存储服务:客户端凭密钥上传图片,换取可公开访问的
http URL。管理员云端账号 id=1在 view 的「文件存储」配置里把存储方式设为
「服务器」并填入本服务地址与密钥后,内容图片(`imageMode=server`)与头像会上传到
这里Markdown / 头像直接引用返回的 URL —— 跨设备、跨团队都能访问,不再依赖本地路径。
code-countview 桌面端)的 **云同步 + 文件存储** HTTP 服务。桌面端不再直连 MySQL
登录一次拿到 JWT 后,同步、团队、文件上传共用同一 Bearer无需二次登录。
技术栈Go + gin + gormMySQL 与 view 同步服务共用 `code_count` 库(新表 `pms_files`
技术栈Go + gin + gorm。分层:`router → controller → service → commonservice → model`
MySQL 库 `code_count` 的 DDL 以本仓库 [`init.sql`](./init.sql) 为唯一来源。
## 接口
## 鉴权
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | `/healthz` | 无 | 健康检查view「测试连接」调用 |
| POST | `/api/v1/files` | `Authorization: Bearer <api_key>` | multipart 上传:`file` 必填,`kind`avatar\|content`userId``teamId` 可选;返回 `{id, name, url, size, mime, teamId}` |
| GET | `/api/v1/files` | 同上 | 素材库列表:`scope=mine\|team\|all` + `userId` + `teamId` + `page` + `pageSize`mine 看自己team 需为该团队 owner/adminall 仅超管userId=1返回 `{total, items}`(含上传者 `username` |
| DELETE | `/api/v1/files/:id` | 同上 | 删除素材(记录+磁盘文件):本人、超管 id=1、或该文件归属团队的 owner/admin |
| GET | `/files/*path` | 无 | 文件公开访问(路径含 128 位随机 hex不可枚举 |
| 方 | 说明 |
|---|---|
| 公开 | `GET /healthz``POST /api/v1/auth/register\|login\|refresh` |
| JWT | 其余 `/api/v1/*``Authorization: Bearer <accessToken>` |
| 刷新 | `POST /api/v1/auth/refresh` `refreshToken` 换新双令牌 |
上传约束:默认单文件 ≤ 20MB`max_upload_mb`);按内容嗅探只接受 jpeg/png/gif/webp
同一归属(`userId`+`teamId`)重复上传相同内容直接复用已有记录(秒传),
不同归属各自落盘,保证删除自己的素材不影响他人引用。
配置项:`jwt_secret`(必填)、`access_ttl_hours`(默认 2`refresh_ttl_days`(默认 30
身份模型:沿用 code-count 的内网信任模型 —— 客户端自报 `userId`/`teamId`
服务端按 code_count 库的 `team_members`/`users` 判定管理范围(防误操作,不防伪造)。
身份从 JWT claims 解析(`user_id` / `username`**不再信任客户端自报 userId**。
```bash
curl http://127.0.0.1:8788/healthz
curl -H "Authorization: Bearer change-me" -F "file=@a.png" -F "kind=content" \
http://127.0.0.1:8788/api/v1/files
```
## 主要接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/healthz` | 健康检查 |
| POST | `/api/v1/auth/register` | 注册 `{username,password}` |
| POST | `/api/v1/auth/login` | 登录 → `{accessToken,refreshToken,userId,username}` |
| POST | `/api/v1/auth/refresh` | 刷新 `{refreshToken}` |
| POST | `/api/v1/auth/change-password` | 改密 `{oldPassword,newPassword}` |
| POST | `/api/v1/sync/push` | `{table,rows}` LWW 推送 |
| GET | `/api/v1/sync/pull` | `table` + `cursor` 增量拉取 |
| GET/PUT | `/api/v1/settings/:name` | 用户/全局设置 KV |
| GET | `/api/v1/settings?prefix=` | 按前缀批量(如 `fest_img:` |
| GET | `/api/v1/settings/global/:name` | 读管理员全局设置 |
| GET/PUT | `/api/v1/profile` | 公开资料 |
| GET | `/api/v1/notices` | 团队通知 `after=` |
| * | `/api/v1/teams...` | 团队/任务/日报/摘要 |
| POST/GET/DELETE | `/api/v1/files` | 图片上传/列表/删除JWT |
| GET | `/files/*path` | 文件公开访问(随机路径) |
上传约束:默认单文件 ≤ 20MB仅 jpeg/png/gif/webp同归属秒传去重。
## 配置
复制 `config.example.yaml``config.yaml` 后修改(启动可用 `-config` 指定路径)。
必填:`api_key`(客户端上传密钥)、`mysql.dsn``base_url` 建议填客户端可达地址
留空则按请求 Host 推断。
复制 `config.example.yaml``config.yaml` 后修改。必填:`jwt_secret``mysql.dsn`
`base_url` 建议填客户端可达地址(拼文件 URL
## 数据库迁移约定(重要)
## 数据库迁移约定
- **仅 dev 环境自动迁移**`config.yaml``env: dev` 时,启动执行 `AutoMigrate`
- **生产绝不迁移**`env: prod`(默认)启动只检查 `pms_files` 表是否存在,
缺表直接报错退出,不执行任何 DDL。部署 / 升级前先手工执行:
- **仅 dev**`env: dev`)启动 AutoMigrate。
- **生产绝不迁移**缺表直接退出。部署前:
```bash
mysql -u root -p < init.sql
```
`init.sql` 可重复执行(`CREATE TABLE IF NOT EXISTS`),索引名与 gorm 默认命名一致,
避免 dev / prod 两套 schema 漂移。
## 运行
```bash
# 开发(自动迁移)
go run . -config config.yaml # config.yaml 里 env: dev
# 生产(先执行 init.sql再构建部署
go build -o bin/nl-pms-api . # Windows 产出 bin/nl-pms-api.exe
go build -o bin/nl-pms-api .
./bin/nl-pms-api -config /etc/nl-pms-api/config.yaml
```
Windows 可用 nssm / 计划任务托管Linux 建议 systemd服务本身无状态
备份只需 `uploads/` 目录与 `pms_files` 表。
## 与桌面端的关系
## 安全权衡(内网定位)
- 上传密钥经 view 的 `sync_settings` 明文下发给所有登录客户端(与节日背景图同机制),
按内网工具定位设计;暴露公网需自行加 HTTPS 反代与更强的凭证体系。
- 图片 GET 公开:`<img>` 标签无法携带鉴权头,靠随机路径保证不可枚举。
view 打包配置 `build/sync.defaults.json``baseUrl` 指向本服务。
登录后本地存 `sync_access_token` / `sync_refresh_token`;文件存储「服务器」模式只需填本服务 `baseUrl`,鉴权用登录 JWT。

View File

@@ -3,13 +3,25 @@
# prod 绝不执行任何 DDL缺表直接退出 —— 部署前先手工执行 init.sql。
env: prod
listen: ":8788"
# 拼接文件访问 URL 的前缀,必须是客户端可达的地址(如 http://192.168.1.10:8788
# 留空则按每次请求的 Host 推断。
base_url: ""
# 客户端上传密钥view 管理员在「文件存储」配置里填同一个值Authorization: Bearer <api_key>
api_key: "change-me"
# 拼接文件访问 URL 的前缀,必须是客户端可达的地址(含反代前缀
# https://o-api.nailaoyun.cn/pms-api 留空则按每次请求的 Host 推断。
base_url: "https://o-api.nailaoyun.cn/pms-api"
# 反代若不剥路径前缀1Panel 常见),填 /pms-api若反代已把 /pms-api 剥掉再转发,则留空
base_path: "/pms-api"
# JWT 签发密钥access / refresh token 共用,务必使用足够长的随机串。
jwt_secret: "change-me-to-a-long-random-secret"
# access token 有效小时数(默认 2refresh token 有效天数(默认 30
access_ttl_hours: 2
refresh_ttl_days: 30
storage_dir: "./uploads"
# 普通图片上传上限MB
max_upload_mb: 20
# 客户端发版安装包上传上限MB
max_release_mb: 200
# 可信反代CIDR 或 IP填了才会信任 X-Forwarded-For 解析真实客户端 IP。
# trusted_proxies:
# - 127.0.0.1
# - 10.0.0.0/8
mysql:
# 与 view 同步服务共用 code_count 库;账号需要对 pms_files 表的 SELECT/INSERT 权限。
# 与 view 桌面端共用 code_count 库;账号需要对业务表的 SELECT/INSERT/UPDATE 权限。
dsn: "root:root@tcp(127.0.0.1:3306)/code_count?charset=utf8mb4&parseTime=false&loc=Local"

5
go.mod
View File

@@ -5,6 +5,8 @@ go 1.25.5
require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.55.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
@@ -12,6 +14,7 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -36,6 +39,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -43,7 +47,6 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect

6
go.sum
View File

@@ -1,5 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -37,6 +39,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -71,6 +75,8 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=

310
init.sql
View File

@@ -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 / qiqi991012bcrypt 哈希存储,可在应用内注册新账号)
-- ============================================================
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 '共享团队ID0=私密',
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 '共享团队ID0=私密',
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 为 TEXT64KB
-- 待办/工单内容支持 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 '指派人用户ID0=未指派',
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 ''共享团队ID0=私密'' 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 ''共享团队ID0=私密'' 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 表 id0 为未知)
user_id BIGINT NOT NULL DEFAULT 0, -- 上传者users 表 id0 为未知)
team_id BIGINT NOT NULL DEFAULT 0, -- 上传时所在团队teams 表 id0 为个人)
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 '团队ID0=个人',
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='管理员敏感操作二次验证';

View File

@@ -0,0 +1,23 @@
package commonservice
import "github.com/gin-gonic/gin"
// Gin context 中由 RequireJWT 写入的键名。
const (
CtxUserID = "user_id"
CtxUsername = "username"
)
// UserID 从 gin 上下文读取当前登录用户 ID。
func UserID(c *gin.Context) int64 {
v, _ := c.Get(CtxUserID)
id, _ := v.(int64)
return id
}
// Username 从 gin 上下文读取当前登录用户名。
func Username(c *gin.Context) string {
v, _ := c.Get(CtxUsername)
s, _ := v.(string)
return s
}

View File

@@ -0,0 +1,32 @@
package commonservice
// AppError 是带稳定错误码与 HTTP 状态的业务错误,控制器原样返回 {"error":"CODE"}。
type AppError struct {
Code string
Status int
}
func (e *AppError) Error() string {
if e == nil {
return ""
}
return e.Code
}
// AsAppError 从 error 中取出 AppError非业务错误返回 false。
func AsAppError(err error) (*AppError, bool) {
if err == nil {
return nil, false
}
if ae, ok := err.(*AppError); ok {
return ae, true
}
return nil, false
}
func BadRequest(code string) *AppError { return &AppError{Code: code, Status: 400} }
func Unauthorized(code string) *AppError { return &AppError{Code: code, Status: 401} }
func Forbidden(code string) *AppError { return &AppError{Code: code, Status: 403} }
func NotFound(code string) *AppError { return &AppError{Code: code, Status: 404} }
func Conflict(code string) *AppError { return &AppError{Code: code, Status: 409} }
func Internal(code string) *AppError { return &AppError{Code: code, Status: 500} }

View File

@@ -0,0 +1,67 @@
package commonservice
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims 是 access / refresh token 的载荷。
type Claims struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
Type string `json:"type"` // access | refresh
jwt.RegisteredClaims
}
// IssueAccess 签发 access token。
func IssueAccess(secret string, userID int64, username string, ttlHours int) (string, error) {
if ttlHours <= 0 {
ttlHours = 2
}
return issue(secret, userID, username, "access", time.Duration(ttlHours)*time.Hour)
}
// IssueRefresh 签发 refresh token。
func IssueRefresh(secret string, userID int64, username string, ttlDays int) (string, error) {
if ttlDays <= 0 {
ttlDays = 30
}
return issue(secret, userID, username, "refresh", time.Duration(ttlDays)*24*time.Hour)
}
func issue(secret string, userID int64, username, typ string, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Username: username,
Type: typ,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString([]byte(secret))
}
// Parse 校验并解析 JWTtyp 非空时要求 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
}

View File

@@ -0,0 +1,55 @@
package commonservice
import "gorm.io/gorm"
// TeamRole 查询用户在团队中的角色;非成员返回空串。
func TeamRole(db *gorm.DB, teamID, userID int64) (string, error) {
if teamID <= 0 || userID <= 0 {
return "", nil
}
var role string
err := db.Table("team_members").Select("role").
Where("team_id = ? AND user_id = ?", teamID, userID).
Scan(&role).Error
if err != nil {
return "", err
}
return role, nil
}
// TeamRoleRank 角色权重owner=3 admin=2 member=1。
func TeamRoleRank(role string) int {
switch role {
case "owner":
return 3
case "admin":
return 2
case "member":
return 1
}
return 0
}
// IsTeamAdmin 该用户是否为团队 owner/admin。
func IsTeamAdmin(db *gorm.DB, teamID, userID int64) bool {
if teamID <= 0 || userID <= 0 {
return false
}
var n int64
db.Table("team_members").
Where("team_id = ? AND user_id = ? AND role IN ('owner','admin')", teamID, userID).
Count(&n)
return n > 0
}
// RequireTeamRole 校验最低角色,返回实际角色。
func RequireTeamRole(db *gorm.DB, teamID, userID int64, min string) (string, error) {
role, err := TeamRole(db, teamID, userID)
if err != nil {
return "", err
}
if TeamRoleRank(role) < TeamRoleRank(min) {
return role, Forbidden("TEAM_FORBIDDEN")
}
return role, nil
}

View File

@@ -0,0 +1,125 @@
package commonservice
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
"nl-pms-api/internal/config"
)
// AdminUserID 是 code_count 体系的超级管理员账号users.id=1
const AdminUserID int64 = 1
// ParseID 把字符串解析为非负 int64非法或负数视为 0。
func ParseID(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
if n < 0 {
return 0
}
return n
}
// Clip 按字节截断字符串到最多 n 字节。
func Clip(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// ClipRunes 按 rune 截断到最多 n 个字符。
func ClipRunes(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) > n {
return string(r[:n])
}
return string(r)
}
// NowRFC 返回当前 UTC 的 RFC3339 时间串(与 view 库惯例一致)。
func NowRFC() string {
return time.Now().UTC().Format(time.RFC3339)
}
// PublicURL 拼接文件公开访问地址:优先配置的 base_url否则用 requestHost可含 scheme://host
func PublicURL(cfg *config.Config, requestHost, name string) string {
base := cfg.BaseURL
if base == "" {
base = strings.TrimRight(requestHost, "/")
if !strings.Contains(base, "://") {
base = "http://" + base
}
}
return base + "/files/" + name
}
// NormalizeKind 规范化文件用途avatar | content其余为空。
func NormalizeKind(k string) string {
if k == "avatar" || k == "content" {
return k
}
return ""
}
// StoredName 生成不可枚举的存储相对路径(日期目录 + 128 位随机 hex
func StoredName(ext string) string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return time.Now().UTC().Format("2006/01/02") + "/" + hex.EncodeToString(b) + ext
}
// MapStr 从通用 JSON map 取字符串字段。
func MapStr(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case []byte:
return string(x)
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case bool:
if x {
return "1"
}
return "0"
default:
return fmt.Sprint(x)
}
}
// MapInt64 从通用 JSON map 取 int64 字段。
func MapInt64(m map[string]any, key string) int64 {
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch x := v.(type) {
case float64:
return int64(x)
case int64:
return x
case int:
return int64(x)
case string:
n, _ := strconv.ParseInt(x, 10, 64)
return n
case bool:
if x {
return 1
}
return 0
default:
return 0
}
}

View File

@@ -16,11 +16,21 @@ type Config struct {
Listen string `yaml:"listen"`
// BaseURL 拼接文件访问 URL 的前缀(客户端可达的地址);留空则按请求 Host 推断。
BaseURL string `yaml:"base_url"`
// APIKey 是客户端上传凭证:请求需带 Authorization: Bearer <api_key>
APIKey string `yaml:"api_key"`
StorageDir string `yaml:"storage_dir"`
MaxUploadMB int64 `yaml:"max_upload_mb"`
MySQL struct {
// BasePath 反代保留的 URL 前缀(如 /pms-api。1Panel 等不剥前缀时必填;留空挂在根路径
BasePath string `yaml:"base_path"`
// JWTSecret 签发/校验 access、refresh token 的密钥,不允许留空。
JWTSecret string `yaml:"jwt_secret"`
// AccessTTLHours access token 有效小时数,默认 2。
AccessTTLHours int `yaml:"access_ttl_hours"`
// RefreshTTLDays refresh token 有效天数,默认 30。
RefreshTTLDays int `yaml:"refresh_ttl_days"`
StorageDir string `yaml:"storage_dir"`
MaxUploadMB int64 `yaml:"max_upload_mb"`
// MaxReleaseMB 发版安装包上传上限MB默认 200。
MaxReleaseMB int64 `yaml:"max_release_mb"`
// TrustedProxies 可信反代 CIDR/IP用于正确解析 ClientIP空则不信任 X-Forwarded-For。
TrustedProxies []string `yaml:"trusted_proxies"`
MySQL struct {
DSN string `yaml:"dsn"`
} `yaml:"mysql"`
}
@@ -28,7 +38,10 @@ type Config struct {
// MaxUploadBytes 返回单文件字节上限。
func (c *Config) MaxUploadBytes() int64 { return c.MaxUploadMB << 20 }
// Load 读取并校验配置缺省值prod / :8788 / ./uploads / 20MB
// MaxReleaseBytes 返回发版包字节上限
func (c *Config) MaxReleaseBytes() int64 { return c.MaxReleaseMB << 20 }
// Load 读取并校验配置缺省值prod / :8788 / ./uploads / 20MB / access 2h / refresh 30d。
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
@@ -50,10 +63,29 @@ func Load(path string) (*Config, error) {
if c.MaxUploadMB <= 0 {
c.MaxUploadMB = 20
}
if c.MaxReleaseMB <= 0 {
c.MaxReleaseMB = 200
}
if c.AccessTTLHours <= 0 {
c.AccessTTLHours = 2
}
if c.RefreshTTLDays <= 0 {
c.RefreshTTLDays = 30
}
c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
c.APIKey = strings.TrimSpace(c.APIKey)
if c.APIKey == "" {
return nil, errors.New("配置缺少 api_key客户端上传凭证不允许留空开放上传")
c.BasePath = strings.TrimSpace(c.BasePath)
if c.BasePath != "" {
if !strings.HasPrefix(c.BasePath, "/") {
c.BasePath = "/" + c.BasePath
}
c.BasePath = strings.TrimRight(c.BasePath, "/")
if c.BasePath == "/" {
c.BasePath = ""
}
}
c.JWTSecret = strings.TrimSpace(c.JWTSecret)
if c.JWTSecret == "" {
return nil, errors.New("配置缺少 jwt_secretJWT 签发密钥,不允许留空)")
}
if strings.TrimSpace(c.MySQL.DSN) == "" {
return nil, errors.New("配置缺少 mysql.dsn")

View File

@@ -0,0 +1,244 @@
package controller
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/middleware"
"nl-pms-api/internal/service"
)
// AdminController 运营后台。
type AdminController struct {
Admin *service.AdminService
Sec *service.AdminSecurityService
Rel *service.ReleaseService
Act *service.ActivityService
}
func (h *AdminController) Overview(c *gin.Context) {
days, _ := strconv.Atoi(c.DefaultQuery("days", "14"))
out, err := h.Admin.Overview(days)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *AdminController) ListUsers(c *gin.Context) {
items, err := h.Admin.ListUsers()
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *AdminController) PatchUser(c *gin.Context) {
id := commonservice.ParseID(c.Param("id"))
var req struct {
AIBanned *int `json:"aiBanned"`
Disabled *int `json:"disabled"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Admin.PatchUser(commonservice.UserID(c), id, req.AIBanned, req.Disabled); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *AdminController) ListTeams(c *gin.Context) {
items, err := h.Admin.ListTeams()
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *AdminController) PatchTeam(c *gin.Context) {
id := commonservice.ParseID(c.Param("id"))
var req struct {
AIBanned *int `json:"aiBanned"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Admin.PatchTeam(commonservice.UserID(c), id, req.AIBanned); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *AdminController) TOTPStatus(c *gin.Context) {
out, err := h.Sec.Status(commonservice.UserID(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *AdminController) TOTPSetupBegin(c *gin.Context) {
out, err := h.Sec.SetupBegin(commonservice.UserID(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *AdminController) TOTPSetupConfirm(c *gin.Context) {
var req struct {
Code string `json:"code"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Sec.SetupConfirm(commonservice.UserID(c), req.Code); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *AdminController) StepUp(c *gin.Context) {
var req struct {
Code string `json:"code"`
}
if !bindJSON(c, &req) {
return
}
token, exp, err := h.Sec.StepUp(commonservice.UserID(c), req.Code, middleware.ClientIP(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"stepupToken": token,
"expiresAt": exp.UTC().Format(time.RFC3339),
})
}
func (h *AdminController) ListReleases(c *gin.Context) {
items, err := h.Rel.List(c.Query("channel"))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *AdminController) UploadRelease(c *gin.Context) {
version := c.PostForm("version")
channel := c.PostForm("channel")
changelog := c.PostForm("changelog")
fh, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
return
}
defer f.Close()
row, err := h.Rel.Upload(version, channel, changelog, f, fh.Size)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, row)
}
func (h *AdminController) PublishRelease(c *gin.Context) {
id := commonservice.ParseID(c.Param("id"))
row, err := h.Rel.Publish(id)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, row)
}
// ActivityController 日活心跳。
type ActivityController struct {
Svc *service.ActivityService
}
func (h *ActivityController) Ping(c *gin.Context) {
if err := h.Svc.Ping(commonservice.UserID(c), middleware.ClientIP(c)); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AIController AI 策略与用量上报。
type AIController struct {
Admin *service.AdminService
}
func (h *AIController) Policy(c *gin.Context) {
out, err := h.Admin.GetAIPolicy(commonservice.UserID(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *AIController) Usage(c *gin.Context) {
var req struct {
Provider string `json:"provider"`
TeamID int64 `json:"teamId"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
Estimated bool `json:"estimated"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Admin.ReportAIUsage(commonservice.UserID(c), req.TeamID, req.Provider, req.PromptTokens, req.CompletionTokens, req.Estimated); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AppController 客户端更新检查/下载。
type AppController struct {
Rel *service.ReleaseService
}
func (h *AppController) Latest(c *gin.Context) {
out, err := h.Rel.Latest(c.DefaultQuery("channel", "stable"))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *AppController) Download(c *gin.Context) {
row, f, err := h.Rel.OpenFile(c.Param("version"), c.DefaultQuery("channel", "stable"))
if err != nil {
writeErr(c, err)
return
}
defer f.Close()
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", `attachment; filename="`+row.Version+`-installer.exe"`)
c.Header("X-Content-SHA256", row.SHA256)
http.ServeContent(c.Writer, c.Request, row.Version+"-installer.exe", time.Time{}, f)
}

View File

@@ -0,0 +1,77 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/middleware"
"nl-pms-api/internal/service"
)
// AuthController 认证相关 HTTP 绑定。
type AuthController struct {
Svc *service.AuthService
}
func (h *AuthController) Register(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.Register(req.Username, req.Password); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *AuthController) Login(c *gin.Context) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if !bindJSON(c, &req) {
return
}
out, err := h.Svc.Login(req.Username, req.Password, middleware.ClientIP(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *AuthController) Refresh(c *gin.Context) {
var req struct {
RefreshToken string `json:"refreshToken"`
}
if !bindJSON(c, &req) {
return
}
out, err := h.Svc.Refresh(req.RefreshToken, middleware.ClientIP(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *AuthController) ChangePassword(c *gin.Context) {
var req struct {
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.ChangePassword(commonservice.UserID(c), req.OldPassword, req.NewPassword); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -0,0 +1,75 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/service"
)
// FileController 文件上传/列表/删除。
type FileController struct {
Svc *service.FileService
}
func (h *FileController) Upload(c *gin.Context) {
fh, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_READ_FAILED"})
return
}
defer f.Close()
out, err := h.Svc.Upload(
commonservice.UserID(c),
commonservice.ParseID(c.PostForm("teamId")),
c.PostForm("kind"),
fh.Filename,
f,
fh.Size,
requestHost(c),
)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *FileController) List(c *gin.Context) {
total, items, err := h.Svc.List(
commonservice.UserID(c),
c.Query("scope"),
commonservice.ParseID(c.Query("teamId")),
commonservice.ParseID(c.Query("page")),
commonservice.ParseID(c.Query("pageSize")),
requestHost(c),
)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"total": total, "items": items})
}
func (h *FileController) Delete(c *gin.Context) {
if err := h.Svc.Delete(commonservice.UserID(c), commonservice.ParseID(c.Param("id"))); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func requestHost(c *gin.Context) string {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
return scheme + "://" + c.Request.Host
}

View File

@@ -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 生成一张纯色 PNGtone 不同则内容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)
}
}

View File

@@ -0,0 +1,25 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/service"
)
// NoticeController 团队通知。
type NoticeController struct {
Svc *service.NoticeService
}
func (h *NoticeController) List(c *gin.Context) {
after := commonservice.ParseID(c.Query("after"))
rows, err := h.Svc.ListNotices(commonservice.UserID(c), after)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": rows})
}

View File

@@ -0,0 +1,74 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/service"
)
// ProfileController 用户资料。
type ProfileController struct {
Svc *service.ProfileService
AvatarHist *service.AvatarHistoryService
}
func (h *ProfileController) Get(c *gin.Context) {
out, err := h.Svc.Get(commonservice.UserID(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *ProfileController) Put(c *gin.Context) {
var req service.ProfileDTO
if !bindJSON(c, &req) {
return
}
out, err := h.Svc.Put(commonservice.UserID(c), req)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
// ListAvatars GET /profile/avatars
func (h *ProfileController) ListAvatars(c *gin.Context) {
out, err := h.AvatarHist.List(commonservice.UserID(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
// PushAvatar POST /profile/avatars body: {mode,value}
func (h *ProfileController) PushAvatar(c *gin.Context) {
var req struct {
Mode string `json:"mode"`
Value string `json:"value"`
}
if !bindJSON(c, &req) {
return
}
out, err := h.AvatarHist.Push(commonservice.UserID(c), req.Mode, req.Value)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
// DeleteAvatar DELETE /profile/avatars/:id
func (h *ProfileController) DeleteAvatar(c *gin.Context) {
if err := h.AvatarHist.Delete(commonservice.UserID(c), commonservice.ParseID(c.Param("id"))); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -0,0 +1,29 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
)
// writeErr 统一把业务错误写成 {"error":"CODE"}。
func writeErr(c *gin.Context, err error) {
if err == nil {
return
}
if ae, ok := commonservice.AsAppError(err); ok {
c.JSON(ae.Status, gin.H{"error": ae.Code})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
}
func bindJSON(c *gin.Context, dst any) bool {
if err := c.ShouldBindJSON(dst); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST"})
return false
}
return true
}

View File

@@ -0,0 +1,62 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/service"
)
// SettingsController 用户/全局设置。
type SettingsController struct {
Svc *service.SettingsService
}
func (h *SettingsController) Get(c *gin.Context) {
if prefix := c.Query("prefix"); prefix != "" {
rows, err := h.Svc.ListByPrefix(commonservice.UserID(c), prefix)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": rows})
return
}
name := c.Param("name")
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "SETTING_NAME_REQUIRED"})
return
}
row, err := h.Svc.GetSetting(commonservice.UserID(c), name)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, row)
}
func (h *SettingsController) Put(c *gin.Context) {
var req struct {
Value string `json:"value"`
UpdatedAt string `json:"updatedAt"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.PutSetting(commonservice.UserID(c), c.Param("name"), req.Value, req.UpdatedAt); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *SettingsController) GetGlobal(c *gin.Context) {
row, err := h.Svc.GetGlobal(c.Param("name"))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, row)
}

View File

@@ -0,0 +1,40 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/service"
)
// SyncController 同步推拉。
type SyncController struct {
Svc *service.SyncService
}
func (h *SyncController) Push(c *gin.Context) {
var req struct {
Table string `json:"table"`
Rows []map[string]any `json:"rows"`
}
if !bindJSON(c, &req) {
return
}
n, err := h.Svc.Push(req.Table, commonservice.UserID(c), req.Rows)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"pushed": n})
}
func (h *SyncController) Pull(c *gin.Context) {
rows, err := h.Svc.Pull(c.Query("table"), commonservice.UserID(c), c.Query("cursor"))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"rows": rows})
}

263
internal/controller/team.go Normal file
View File

@@ -0,0 +1,263 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/service"
)
// TeamController 团队协作 HTTP 绑定。
type TeamController struct {
Svc *service.TeamService
}
func (h *TeamController) Create(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if !bindJSON(c, &req) {
return
}
out, err := h.Svc.Create(commonservice.UserID(c), req.Name)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *TeamController) List(c *gin.Context) {
out, err := h.Svc.List(commonservice.UserID(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
func (h *TeamController) Rename(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.Rename(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Name); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) SetDigestTime(c *gin.Context) {
var req struct {
DigestTime string `json:"digestTime"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.SetDigestTime(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.DigestTime); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) Dissolve(c *gin.Context) {
if err := h.Svc.Dissolve(commonservice.ParseID(c.Param("id")), commonservice.UserID(c)); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) Leave(c *gin.Context) {
if err := h.Svc.Leave(commonservice.ParseID(c.Param("id")), commonservice.UserID(c)); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) Members(c *gin.Context) {
out, err := h.Svc.Members(commonservice.ParseID(c.Param("id")), commonservice.UserID(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
func (h *TeamController) Invite(c *gin.Context) {
var req struct {
Username string `json:"username"`
Role string `json:"role"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.Invite(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Username, req.Role); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) SetRole(c *gin.Context) {
var req struct {
Role string `json:"role"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.SetRole(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), commonservice.ParseID(c.Param("userId")), req.Role); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) RemoveMember(c *gin.Context) {
if err := h.Svc.RemoveMember(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), commonservice.ParseID(c.Param("userId"))); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) TaskSave(c *gin.Context) {
var req service.TeamTaskDTO
if !bindJSON(c, &req) {
return
}
req.TeamID = commonservice.ParseID(c.Param("id"))
if tid := commonservice.ParseID(c.Param("taskId")); tid > 0 {
req.ID = tid
}
out, err := h.Svc.TaskSave(commonservice.UserID(c), req)
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *TeamController) TaskSetStatus(c *gin.Context) {
var req struct {
Status string `json:"status"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.TaskSetStatus(commonservice.ParseID(c.Param("id")), commonservice.ParseID(c.Param("taskId")), commonservice.UserID(c), req.Status); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) TaskUrge(c *gin.Context) {
if err := h.Svc.TaskUrge(commonservice.ParseID(c.Param("id")), commonservice.ParseID(c.Param("taskId")), commonservice.UserID(c)); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) TaskDelete(c *gin.Context) {
if err := h.Svc.TaskDelete(commonservice.ParseID(c.Param("id")), commonservice.ParseID(c.Param("taskId")), commonservice.UserID(c)); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) TaskList(c *gin.Context) {
out, err := h.Svc.TaskList(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), c.Query("filter"))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
func (h *TeamController) SharedItems(c *gin.Context) {
out, err := h.Svc.SharedItems(commonservice.ParseID(c.Param("id")), commonservice.UserID(c))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
func (h *TeamController) UrgeShared(c *gin.Context) {
var req struct {
Kind string `json:"kind"`
UUID string `json:"uuid"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.UrgeShared(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Kind, req.UUID); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) ReportSubmit(c *gin.Context) {
var req struct {
Date string `json:"date"`
Content string `json:"content"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.ReportSubmit(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Date, req.Content); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) ReportBoardGet(c *gin.Context) {
out, err := h.Svc.ReportBoardGet(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), c.Param("date"))
if err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, out)
}
func (h *TeamController) ReportUrge(c *gin.Context) {
var req struct {
UserID int64 `json:"userId"`
Date string `json:"date"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.ReportUrge(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.UserID, req.Date); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *TeamController) DigestSave(c *gin.Context) {
var req struct {
Content string `json:"content"`
Provider string `json:"provider"`
}
if !bindJSON(c, &req) {
return
}
if err := h.Svc.DigestSave(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), c.Param("date"), req.Content, req.Provider); err != nil {
writeErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -13,7 +13,7 @@ import (
)
// Open 连接 MySQL 并按环境处理表结构:
// dev 环境自动迁移 pms_filesprod 严禁任何 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")
}

View File

@@ -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/filesmultipart 字段 file 必填kindavatar|content
// userId、teamId 可选。同一归属userId+teamId重复上传同内容直接复用已有记录秒传
// 不同归属各自落盘,保证"删除自己的素材"不影响他人。
// 成功返回 {id, name, url, size, mime}url 可直接放进 <img src> / Markdown。
func (h *FileHandler) Upload(c *gin.Context) {
fh, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
return
}
if fh.Size > h.Cfg.MaxUploadBytes() {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_READ_FAILED"})
return
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, h.Cfg.MaxUploadBytes()+1))
if err != nil || int64(len(data)) > h.Cfg.MaxUploadBytes() {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"})
return
}
mime := http.DetectContentType(data)
ext, ok := extByMime[mime]
if !ok {
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "UNSUPPORTED_TYPE"})
return
}
uid := parseID(c.PostForm("userId"))
tid := parseID(c.PostForm("teamId"))
sum := hex.EncodeToString(func() []byte { s := sha256.Sum256(data); return s[:] }())
var rec model.File
if h.DB.Where("sha256 = ? AND user_id = ? AND team_id = ?", sum, uid, tid).First(&rec).Error == nil {
c.JSON(http.StatusOK, h.fileResponse(c, rec))
return
}
name := storedName(ext)
full := filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
return
}
if err := os.WriteFile(full, data, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
return
}
rec = model.File{
Name: name,
Original: clip(filepath.Base(fh.Filename), 255),
Mime: mime,
Size: int64(len(data)),
SHA256: sum,
UserID: uid,
TeamID: tid,
Kind: normalizeKind(c.PostForm("kind")),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := h.DB.Create(&rec).Error; err != nil {
os.Remove(full)
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
return
}
c.JSON(http.StatusOK, h.fileResponse(c, rec))
}
// fileItem 是素材库列表项pms_files 联 users 取上传者名)。
type fileItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Original string `json:"original"`
Mime string `json:"mime"`
Size int64 `json:"size"`
UserID int64 `json:"userId"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"`
CreatedAt string `json:"createdAt"`
Username string `json:"username"`
URL string `json:"url" gorm:"-"`
}
// List 处理 GET /api/v1/files素材库分页列表。
// scope=mine 看自己任何登录用户scope=team 看指定团队(需为该团队 owner/admin
// scope=all 看全部(仅 userId=1。按 id 倒序分页。
func (h *FileHandler) List(c *gin.Context) {
uid := parseID(c.Query("userId"))
if uid <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "USER_REQUIRED"})
return
}
var where func(*gorm.DB) *gorm.DB
switch c.Query("scope") {
case "mine":
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.user_id = ?", uid) }
case "team":
tid := parseID(c.Query("teamId"))
if !h.isTeamAdmin(tid, uid) {
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.team_id = ?", tid) }
case "all":
if uid != adminUserID {
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
where = func(db *gorm.DB) *gorm.DB { return db }
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_SCOPE"})
return
}
page := parseID(c.Query("page"))
if page < 1 {
page = 1
}
size := parseID(c.Query("pageSize"))
if size < 1 || size > 100 {
size = 24
}
var total int64
if err := h.DB.Table("pms_files").Scopes(where).Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"})
return
}
items := []fileItem{}
err := h.DB.Table("pms_files").Scopes(where).
Select("pms_files.id, pms_files.name, pms_files.original, pms_files.mime, pms_files.size, pms_files.user_id, pms_files.team_id, pms_files.kind, pms_files.created_at, COALESCE(u.username,'') AS username").
Joins("LEFT JOIN users u ON u.id = pms_files.user_id").
Order("pms_files.id DESC").Limit(int(size)).Offset(int((page - 1) * size)).
Scan(&items).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"})
return
}
for i := range items {
items[i].URL = h.publicURL(c, items[i].Name)
}
c.JSON(http.StatusOK, gin.H{"total": total, "items": items})
}
// Delete 处理 DELETE /api/v1/files/:id删除记录与磁盘文件。
// 允许本人、超管userId=1、该文件归属团队的 owner/admin。
func (h *FileHandler) Delete(c *gin.Context) {
uid := parseID(c.Query("userId"))
id := parseID(c.Param("id"))
var rec model.File
if h.DB.First(&rec, id).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "NOT_FOUND"})
return
}
allowed := uid == adminUserID ||
(uid > 0 && rec.UserID == uid) ||
(rec.TeamID > 0 && h.isTeamAdmin(rec.TeamID, uid))
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
if err := h.DB.Delete(&model.File{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "DELETE_FAILED"})
return
}
// 每条记录独占一个文件name 唯一),删记录即可删盘;失败不影响结果(孤儿文件可人工清理)。
_ = os.Remove(filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(rec.Name)))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// isTeamAdmin 查 code_count 库的 team_members该用户是否为团队 owner/admin。
func (h *FileHandler) isTeamAdmin(teamID, userID int64) bool {
if teamID <= 0 || userID <= 0 {
return false
}
var n int64
h.DB.Table("team_members").
Where("team_id = ? AND user_id = ? AND role IN ('owner','admin')", teamID, userID).
Count(&n)
return n > 0
}
func (h *FileHandler) fileResponse(c *gin.Context, f model.File) gin.H {
return gin.H{"id": f.ID, "name": f.Name, "url": h.publicURL(c, f.Name), "size": f.Size, "mime": f.Mime, "teamId": f.TeamID}
}
// publicURL 拼接文件公开访问地址:优先配置的 base_url否则按本次请求推断。
func (h *FileHandler) publicURL(c *gin.Context, name string) string {
base := h.Cfg.BaseURL
if base == "" {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
base = scheme + "://" + c.Request.Host
}
return base + "/files/" + name
}
// storedName 生成不可枚举的存储相对路径(日期目录 + 128 位随机 hex
// 文件公开可读但路径不可猜测,等效 capability URL。
func storedName(ext string) string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return time.Now().UTC().Format("2006/01/02") + "/" + hex.EncodeToString(b) + ext
}
func normalizeKind(k string) string {
if k == "avatar" || k == "content" {
return k
}
return ""
}
func parseID(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
if n < 0 {
return 0
}
return n
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}

View File

@@ -0,0 +1,125 @@
package middleware
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
const (
HeaderAdminStepUp = "X-Admin-StepUp"
CtxClientIP = "client_ip"
)
// AttachClientIP 把解析后的客户端 IP 写入上下文。
func AttachClientIP() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set(CtxClientIP, strings.TrimSpace(c.ClientIP()))
c.Next()
}
}
// ClientIP 读取中间件写入的客户端 IP。
func ClientIP(c *gin.Context) string {
v, _ := c.Get(CtxClientIP)
s, _ := v.(string)
if s == "" {
return strings.TrimSpace(c.ClientIP())
}
return s
}
// RejectDisabled 拒绝已禁用账号(在 JWT 之后)。
func RejectDisabled(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := commonservice.UserID(c)
if uid <= 0 {
c.Next()
return
}
var u model.User
if err := db.Select("id", "disabled").First(&u, uid).Error; err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
return
}
if u.Disabled != 0 {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ACCOUNT_DISABLED"})
return
}
c.Next()
}
}
// RequireAdmin 仅允许 users.id=1。
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
if commonservice.UserID(c) != commonservice.AdminUserID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
c.Next()
}
}
// RequireAdminStepUp 校验 2 小时内有效的动态码二次验证,且 IP 与签发时一致。
func RequireAdminStepUp(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if commonservice.UserID(c) != commonservice.AdminUserID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
token := strings.TrimSpace(c.GetHeader(HeaderAdminStepUp))
if token == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
return
}
ip := ClientIP(c)
var row model.AdminStepup
if err := db.First(&row, commonservice.AdminUserID).Error; err != nil {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
return
}
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])
if row.TokenHash != hash {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
return
}
exp, err := time.Parse(time.RFC3339, row.ExpiresAt)
if err != nil || time.Now().UTC().After(exp) {
_ = db.Delete(&model.AdminStepup{}, commonservice.AdminUserID).Error
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
return
}
if row.ClientIP != "" && ip != "" && row.ClientIP != ip {
_ = db.Delete(&model.AdminStepup{}, commonservice.AdminUserID).Error
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "ADMIN_IP_CHANGED",
"message": "检测到 IP 变化,存在账号被盗风险,请重新输入动态码",
})
return
}
c.Next()
}
}
// RequireStepUpForGlobalSetting 仅当写入全局设置键时要求 stepup。
func RequireStepUpForGlobalSetting(db *gorm.DB) gin.HandlerFunc {
inner := RequireAdminStepUp(db)
return func(c *gin.Context) {
name := strings.TrimSpace(c.Param("name"))
if name == "file_storage" || strings.HasPrefix(name, "fest_img:") {
inner(c)
return
}
c.Next()
}
}

View File

@@ -1,21 +0,0 @@
package middleware
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// RequireAPIKey 校验 Authorization: Bearer <api_key>(常数时间比较),不匹配返回 401。
func RequireAPIKey(key string) gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
if key == "" || subtle.ConstantTimeCompare([]byte(token), []byte(key)) != 1 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,29 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
)
// RequireJWT 校验 Authorization: Bearer <access token>,并把 user_id/username 写入上下文。
func RequireJWT(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
return
}
claims, err := commonservice.Parse(secret, token, "access")
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
return
}
c.Set(commonservice.CtxUserID, claims.UserID)
c.Set(commonservice.CtxUsername, claims.Username)
c.Next()
}
}

50
internal/model/admin.go Normal file
View File

@@ -0,0 +1,50 @@
package model
// UserDailyActive 对应 user_daily_active。
type UserDailyActive struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
ActiveDate string `gorm:"primaryKey;size:10" json:"activeDate"`
LastIP string `gorm:"column:last_ip;size:64;not null;default:''" json:"lastIp"`
LastSeenAt string `gorm:"column:last_seen_at;size:32;not null;default:''" json:"lastSeenAt"`
}
func (UserDailyActive) TableName() string { return "user_daily_active" }
// AIUsageDaily 对应 ai_usage_daily。
type AIUsageDaily struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
TeamID int64 `gorm:"primaryKey;default:0" json:"teamId"`
UsageDate string `gorm:"primaryKey;size:10" json:"usageDate"`
Provider string `gorm:"primaryKey;size:32;not null;default:''" json:"provider"`
PromptTokens int64 `gorm:"not null;default:0" json:"promptTokens"`
CompletionTokens int64 `gorm:"not null;default:0" json:"completionTokens"`
Calls int `gorm:"not null;default:0" json:"calls"`
Estimated int `gorm:"not null;default:0" json:"estimated"`
}
func (AIUsageDaily) TableName() string { return "ai_usage_daily" }
// AppRelease 对应 app_releases。
type AppRelease struct {
ID int64 `gorm:"primaryKey" json:"id"`
Version string `gorm:"size:32;not null" json:"version"`
Channel string `gorm:"size:16;not null;default:stable" json:"channel"`
Filename string `gorm:"size:255;not null" json:"filename"`
SHA256 string `gorm:"column:sha256;size:64;not null;default:''" json:"sha256"`
SizeBytes int64 `gorm:"column:size_bytes;not null;default:0" json:"sizeBytes"`
Changelog string `gorm:"type:mediumtext;not null" json:"changelog"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
IsLatest int `gorm:"column:is_latest;not null;default:0" json:"isLatest"`
}
func (AppRelease) TableName() string { return "app_releases" }
// AdminStepup 对应 admin_stepup。
type AdminStepup struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
TokenHash string `gorm:"column:token_hash;size:64;not null" json:"-"`
ClientIP string `gorm:"column:client_ip;size:64;not null;default:''" json:"clientIp"`
ExpiresAt string `gorm:"column:expires_at;size:32;not null" json:"expiresAt"`
}
func (AdminStepup) TableName() string { return "admin_stepup" }

View File

@@ -0,0 +1,12 @@
package model
// UserAvatarHistory 用户头像历史,对应 user_avatar_history。
type UserAvatarHistory struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"not null;index:idx_uah_user_id" json:"userId"`
Mode string `gorm:"size:16;not null;default:url" json:"mode"` // url | base64
Value string `gorm:"type:mediumtext;not null" json:"value"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
}
func (UserAvatarHistory) TableName() string { return "user_avatar_history" }

15
internal/model/profile.go Normal file
View File

@@ -0,0 +1,15 @@
package model
// UserProfile 对应 user_profiles。
type UserProfile struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
Title string `gorm:"size:64;not null;default:''" json:"title"`
Email string `gorm:"size:128;not null;default:''" json:"email"`
Bio string `gorm:"size:500;not null;default:''" json:"bio"`
TechTags string `gorm:"size:1000;not null;default:'[]'" json:"techTags"`
AvatarThumb string `gorm:"type:mediumtext;not null" json:"avatarThumb"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
}
func (UserProfile) TableName() string { return "user_profiles" }

62
internal/model/sync.go Normal file
View File

@@ -0,0 +1,62 @@
package model
// SyncTodo 对应 sync_todos。
type SyncTodo struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
UUID string `gorm:"primaryKey;size:36" json:"uuid"`
Title string `gorm:"type:text;not null" json:"title"`
Content string `gorm:"type:mediumtext;not null" json:"content"`
ProjectName string `gorm:"size:255;not null;default:''" json:"projectName"`
DueAt string `gorm:"size:32;not null;default:''" json:"dueAt"`
Priority string `gorm:"size:16;not null;default:medium" json:"priority"`
Status string `gorm:"size:16;not null;default:open" json:"status"`
History string `gorm:"type:mediumtext;not null" json:"history"`
TeamID int64 `gorm:"not null;default:0;index" json:"teamId"`
CreatedAt string `gorm:"size:32;not null;default:''" json:"createdAt"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
Deleted int `gorm:"not null;default:0" json:"deleted"`
}
func (SyncTodo) TableName() string { return "sync_todos" }
// SyncTicket 对应 sync_tickets。
type SyncTicket struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
UUID string `gorm:"primaryKey;size:36" json:"uuid"`
Title string `gorm:"type:text;not null" json:"title"`
Description string `gorm:"type:mediumtext;not null" json:"description"`
Type string `gorm:"size:16;not null;default:task" json:"type"`
ProjectName string `gorm:"size:255;not null;default:''" json:"projectName"`
StartAt string `gorm:"size:32;not null;default:''" json:"startAt"`
DueAt string `gorm:"size:32;not null;default:''" json:"dueAt"`
Status string `gorm:"size:16;not null;default:open" json:"status"`
Priority string `gorm:"size:16;not null;default:medium" json:"priority"`
History string `gorm:"type:mediumtext;not null" json:"history"`
TeamID int64 `gorm:"not null;default:0;index" json:"teamId"`
CreatedAt string `gorm:"size:32;not null;default:''" json:"createdAt"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
Deleted int `gorm:"not null;default:0" json:"deleted"`
}
func (SyncTicket) TableName() string { return "sync_tickets" }
// SyncNote 对应 sync_notes。
type SyncNote struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
UUID string `gorm:"primaryKey;size:36" json:"uuid"`
Content string `gorm:"type:mediumtext;not null" json:"content"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
Deleted int `gorm:"not null;default:0" json:"deleted"`
}
func (SyncNote) TableName() string { return "sync_notes" }
// SyncSetting 对应 sync_settings。
type SyncSetting struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
Name string `gorm:"primaryKey;size:64" json:"name"`
Value string `gorm:"type:mediumtext;not null" json:"value"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
}
func (SyncSetting) TableName() string { return "sync_settings" }

80
internal/model/team.go Normal file
View File

@@ -0,0 +1,80 @@
package model
// Team 对应 teams。
type Team struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
OwnerID int64 `gorm:"not null" json:"ownerId"`
DigestTime string `gorm:"size:8;not null;default:21:00" json:"digestTime"`
AIBanned int `gorm:"column:ai_banned;not null;default:0" json:"aiBanned"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
}
func (Team) TableName() string { return "teams" }
// TeamMember 对应 team_members。
type TeamMember struct {
TeamID int64 `gorm:"primaryKey" json:"teamId"`
UserID int64 `gorm:"primaryKey" json:"userId"`
Role string `gorm:"size:16;not null;default:member" json:"role"`
JoinedAt string `gorm:"size:32;not null" json:"joinedAt"`
}
func (TeamMember) TableName() string { return "team_members" }
// TeamTask 对应 team_tasks。
type TeamTask struct {
ID int64 `gorm:"primaryKey" json:"id"`
TeamID int64 `gorm:"not null;index" json:"teamId"`
Kind string `gorm:"size:16;not null;default:todo" json:"kind"`
Title string `gorm:"type:text;not null" json:"title"`
Description string `gorm:"type:mediumtext;not null" json:"description"`
Priority string `gorm:"size:16;not null;default:medium" json:"priority"`
Status string `gorm:"size:16;not null;default:open" json:"status"`
CreatorID int64 `gorm:"not null" json:"creatorId"`
AssigneeID int64 `gorm:"not null;default:0;index" json:"assigneeId"`
StartAt string `gorm:"size:32;not null;default:''" json:"startAt"`
DueAt string `gorm:"size:32;not null;default:''" json:"dueAt"`
UrgedAt string `gorm:"size:32;not null;default:''" json:"urgedAt"`
History string `gorm:"type:mediumtext;not null" json:"history"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
Deleted int `gorm:"not null;default:0" json:"deleted"`
}
func (TeamTask) TableName() string { return "team_tasks" }
// TeamReport 对应 team_reports。
type TeamReport struct {
TeamID int64 `gorm:"primaryKey" json:"teamId"`
UserID int64 `gorm:"primaryKey" json:"userId"`
Date string `gorm:"primaryKey;size:10" json:"date"`
Content string `gorm:"type:mediumtext;not null" json:"content"`
SubmittedAt string `gorm:"size:32;not null" json:"submittedAt"`
}
func (TeamReport) TableName() string { return "team_reports" }
// TeamDigest 对应 team_digests。
type TeamDigest struct {
TeamID int64 `gorm:"primaryKey" json:"teamId"`
Date string `gorm:"primaryKey;size:10" json:"date"`
Content string `gorm:"type:mediumtext;not null" json:"content"`
Provider string `gorm:"size:32;not null;default:''" json:"provider"`
GeneratedAt string `gorm:"size:32;not null" json:"generatedAt"`
}
func (TeamDigest) TableName() string { return "team_digests" }
// TeamNotice 对应 team_notices。
type TeamNotice struct {
ID int64 `gorm:"primaryKey" json:"id"`
TeamID int64 `gorm:"not null" json:"teamId"`
ToUser int64 `gorm:"not null;index" json:"toUser"`
FromUser int64 `gorm:"not null" json:"fromUser"`
Kind string `gorm:"size:16;not null" json:"kind"`
RefID string `gorm:"size:64;not null;default:''" json:"refId"`
Content string `gorm:"type:text;not null" json:"content"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
}
func (TeamNotice) TableName() string { return "team_notices" }

17
internal/model/user.go Normal file
View File

@@ -0,0 +1,17 @@
package model
// User 对应 users 表。
type User struct {
ID int64 `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"size:100;not null" json:"-"`
TOTPSecret string `gorm:"column:totp_secret;size:64;not null;default:''" json:"-"`
TOTPEnabled int `gorm:"column:totp_enabled;not null;default:0" json:"totpEnabled"`
LastLoginIP string `gorm:"column:last_login_ip;size:64;not null;default:''" json:"lastLoginIp"`
LastSeenAt string `gorm:"column:last_seen_at;size:32;not null;default:''" json:"lastSeenAt"`
AIBanned int `gorm:"column:ai_banned;not null;default:0" json:"aiBanned"`
Disabled int `gorm:"not null;default:0" json:"disabled"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
}
func (User) TableName() string { return "users" }

View File

@@ -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/authJWT 保护同步/设置/资料/团队/文件/后台。
// 若配置了 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)

View File

@@ -0,0 +1,44 @@
package service
import (
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// ActivityService 日活埋点。
type ActivityService struct {
DB *gorm.DB
}
// Ping 记录今日活跃并刷新 users.last_seen_at / last_login_ip。
func (s *ActivityService) Ping(userID int64, clientIP string) error {
if userID <= 0 {
return commonservice.Unauthorized("UNAUTHORIZED")
}
now := commonservice.NowRFC()
today := time.Now().In(time.Local).Format("2006-01-02")
ip := strings.TrimSpace(clientIP)
row := model.UserDailyActive{
UserID: userID,
ActiveDate: today,
LastIP: ip,
LastSeenAt: now,
}
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "active_date"}},
DoUpdates: clause.AssignmentColumns([]string{"last_ip", "last_seen_at"}),
}).Create(&row).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
_ = s.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
"last_seen_at": now,
"last_login_ip": ip,
}).Error
return nil
}

306
internal/service/admin.go Normal file
View File

@@ -0,0 +1,306 @@
package service
import (
"strings"
"time"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// AdminService 运营后台:统计、用户、团队。
type AdminService struct {
DB *gorm.DB
}
type StatsPoint struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
type TokenPoint struct {
Date string `json:"date"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
Calls int64 `json:"calls"`
}
type OverviewStats struct {
UserCount int64 `json:"userCount"`
TeamCount int64 `json:"teamCount"`
DAUToday int64 `json:"dauToday"`
DAUSeries []StatsPoint `json:"dauSeries"`
TokenToday TokenPoint `json:"tokenToday"`
TokenSeries []TokenPoint `json:"tokenSeries"`
}
// Overview 概览统计。
func (s *AdminService) Overview(days int) (*OverviewStats, error) {
if days <= 0 || days > 90 {
days = 14
}
out := &OverviewStats{
DAUSeries: []StatsPoint{},
TokenSeries: []TokenPoint{},
}
s.DB.Model(&model.User{}).Count(&out.UserCount)
s.DB.Model(&model.Team{}).Count(&out.TeamCount)
today := time.Now().In(time.Local).Format("2006-01-02")
s.DB.Model(&model.UserDailyActive{}).Where("active_date = ?", today).Count(&out.DAUToday)
start := time.Now().In(time.Local).AddDate(0, 0, -(days - 1)).Format("2006-01-02")
type dauRow struct {
ActiveDate string
Cnt int64
}
var dauRows []dauRow
s.DB.Model(&model.UserDailyActive{}).
Select("active_date, COUNT(*) AS cnt").
Where("active_date >= ?", start).
Group("active_date").
Order("active_date").
Scan(&dauRows)
dauMap := map[string]int64{}
for _, r := range dauRows {
dauMap[r.ActiveDate] = r.Cnt
}
type tokRow struct {
UsageDate string
PromptTokens int64
CompletionTokens int64
Calls int64
}
var tokRows []tokRow
s.DB.Model(&model.AIUsageDaily{}).
Select("usage_date, SUM(prompt_tokens) AS prompt_tokens, SUM(completion_tokens) AS completion_tokens, SUM(calls) AS calls").
Where("usage_date >= ?", start).
Group("usage_date").
Order("usage_date").
Scan(&tokRows)
tokMap := map[string]tokRow{}
for _, r := range tokRows {
tokMap[r.UsageDate] = r
}
for i := 0; i < days; i++ {
d := time.Now().In(time.Local).AddDate(0, 0, -(days - 1 - i)).Format("2006-01-02")
out.DAUSeries = append(out.DAUSeries, StatsPoint{Date: d, Count: dauMap[d]})
t := tokMap[d]
pt := TokenPoint{
Date: d,
PromptTokens: t.PromptTokens,
CompletionTokens: t.CompletionTokens,
Calls: t.Calls,
}
out.TokenSeries = append(out.TokenSeries, pt)
if d == today {
out.TokenToday = pt
}
}
return out, nil
}
type AdminUserItem struct {
ID int64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AIBanned int `json:"aiBanned"`
Disabled int `json:"disabled"`
LastLoginIP string `json:"lastLoginIp"`
LastSeenAt string `json:"lastSeenAt"`
CreatedAt string `json:"createdAt"`
}
// ListUsers 用户列表。
func (s *AdminService) ListUsers() ([]AdminUserItem, error) {
var users []model.User
if err := s.DB.Order("id ASC").Find(&users).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
ids := make([]int64, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
nick := map[int64]string{}
if len(ids) > 0 {
var profiles []model.UserProfile
s.DB.Where("user_id IN ?", ids).Find(&profiles)
for _, p := range profiles {
nick[p.UserID] = p.Nickname
}
}
out := make([]AdminUserItem, 0, len(users))
for _, u := range users {
out = append(out, AdminUserItem{
ID: u.ID,
Username: u.Username,
Nickname: nick[u.ID],
AIBanned: u.AIBanned,
Disabled: u.Disabled,
LastLoginIP: u.LastLoginIP,
LastSeenAt: u.LastSeenAt,
CreatedAt: u.CreatedAt,
})
}
return out, nil
}
// PatchUser 更新用户 ai_banned / disabled不可禁用自己
func (s *AdminService) PatchUser(actorID, targetID int64, aiBanned, disabled *int) error {
if actorID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
if targetID <= 0 {
return commonservice.BadRequest("BAD_REQUEST")
}
if targetID == commonservice.AdminUserID && disabled != nil && *disabled != 0 {
return commonservice.BadRequest("CANNOT_DISABLE_ADMIN")
}
updates := map[string]any{}
if aiBanned != nil {
if *aiBanned != 0 {
updates["ai_banned"] = 1
} else {
updates["ai_banned"] = 0
}
}
if disabled != nil {
if *disabled != 0 {
updates["disabled"] = 1
} else {
updates["disabled"] = 0
}
}
if len(updates) == 0 {
return commonservice.BadRequest("BAD_REQUEST")
}
res := s.DB.Model(&model.User{}).Where("id = ?", targetID).Updates(updates)
if res.Error != nil {
return commonservice.Internal("SAVE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.NotFound("NOT_FOUND")
}
return nil
}
type AdminTeamItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"ownerId"`
OwnerName string `json:"ownerName"`
AIBanned int `json:"aiBanned"`
Members int64 `json:"members"`
CreatedAt string `json:"createdAt"`
}
// ListTeams 团队列表。
func (s *AdminService) ListTeams() ([]AdminTeamItem, error) {
var teams []model.Team
if err := s.DB.Order("id ASC").Find(&teams).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out := make([]AdminTeamItem, 0, len(teams))
for _, t := range teams {
item := AdminTeamItem{
ID: t.ID, Name: t.Name, OwnerID: t.OwnerID,
AIBanned: t.AIBanned, CreatedAt: t.CreatedAt,
}
var u model.User
if s.DB.Select("username").First(&u, t.OwnerID).Error == nil {
item.OwnerName = u.Username
}
s.DB.Model(&model.TeamMember{}).Where("team_id = ?", t.ID).Count(&item.Members)
out = append(out, item)
}
return out, nil
}
// PatchTeam 更新团队 ai_banned。
func (s *AdminService) PatchTeam(actorID, teamID int64, aiBanned *int) error {
if actorID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
if teamID <= 0 || aiBanned == nil {
return commonservice.BadRequest("BAD_REQUEST")
}
v := 0
if *aiBanned != 0 {
v = 1
}
res := s.DB.Model(&model.Team{}).Where("id = ?", teamID).Update("ai_banned", v)
if res.Error != nil {
return commonservice.Internal("SAVE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.NotFound("NOT_FOUND")
}
return nil
}
// AIPolicy 当前用户是否允许使用 AI。
type AIPolicy struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason,omitempty"`
}
// GetAIPolicy 综合用户禁用/禁 AI 与所属团队禁 AI。
func (s *AdminService) GetAIPolicy(userID int64) (*AIPolicy, error) {
var u model.User
if err := s.DB.Select("id", "disabled", "ai_banned").First(&u, userID).Error; err != nil {
return nil, commonservice.Unauthorized("UNAUTHORIZED")
}
if u.Disabled != 0 {
return &AIPolicy{Allowed: false, Reason: "ACCOUNT_DISABLED"}, nil
}
if u.AIBanned != 0 {
return &AIPolicy{Allowed: false, Reason: "USER_AI_BANNED"}, nil
}
var n int64
s.DB.Table("team_members").
Joins("JOIN teams ON teams.id = team_members.team_id").
Where("team_members.user_id = ? AND teams.ai_banned = 1", userID).
Count(&n)
if n > 0 {
return &AIPolicy{Allowed: false, Reason: "TEAM_AI_BANNED"}, nil
}
return &AIPolicy{Allowed: true}, nil
}
// ReportAIUsage 累加当日 token 用量。
func (s *AdminService) ReportAIUsage(userID, teamID int64, provider string, prompt, completion int64, estimated bool) error {
if userID <= 0 {
return commonservice.Unauthorized("UNAUTHORIZED")
}
provider = strings.TrimSpace(provider)
if provider == "" {
provider = "unknown"
}
if prompt < 0 {
prompt = 0
}
if completion < 0 {
completion = 0
}
today := time.Now().In(time.Local).Format("2006-01-02")
est := 0
if estimated {
est = 1
}
q := `INSERT INTO ai_usage_daily(user_id,team_id,usage_date,provider,prompt_tokens,completion_tokens,calls,estimated)
VALUES(?,?,?,?,?,?,1,?)
ON DUPLICATE KEY UPDATE
prompt_tokens=prompt_tokens+VALUES(prompt_tokens),
completion_tokens=completion_tokens+VALUES(completion_tokens),
calls=calls+1,
estimated=IF(VALUES(estimated)=1,1,estimated)`
if err := s.DB.Exec(q, userID, teamID, today, provider, prompt, completion, est).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
return nil
}

View File

@@ -0,0 +1,174 @@
package service
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/url"
"strings"
"time"
"github.com/pquerna/otp/totp"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
const (
adminStepupTTL = 2 * time.Hour
totpIssuer = "年糕崽崽PMS"
totpPendingPrefix = "pending:"
)
// AdminSecurityService 管理员 TOTP 与敏感操作 stepup。
type AdminSecurityService struct {
DB *gorm.DB
}
type TOTPStatus struct {
Enabled bool `json:"enabled"`
Pending bool `json:"pending"`
OTPAuth string `json:"otpauth,omitempty"`
Secret string `json:"secret,omitempty"`
}
// Status 返回 TOTP 绑定状态;若未启用且无 pending不自动生成。
func (s *AdminSecurityService) Status(userID int64) (*TOTPStatus, error) {
if userID != commonservice.AdminUserID {
return nil, commonservice.Forbidden("FORBIDDEN")
}
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
st := &TOTPStatus{Enabled: u.TOTPEnabled != 0}
if !st.Enabled && strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
secret := strings.TrimPrefix(u.TOTPSecret, totpPendingPrefix)
st.Pending = true
st.Secret = secret
st.OTPAuth = buildOTPAuth(u.Username, secret)
}
return st, nil
}
// SetupBegin 生成待确认的 TOTP 密钥(覆盖未确认的 pending
func (s *AdminSecurityService) SetupBegin(userID int64) (*TOTPStatus, error) {
if userID != commonservice.AdminUserID {
return nil, commonservice.Forbidden("FORBIDDEN")
}
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if u.TOTPEnabled != 0 {
return nil, commonservice.Conflict("TOTP_ALREADY_ENABLED")
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: totpIssuer,
AccountName: u.Username,
})
if err != nil {
return nil, commonservice.Internal("INTERNAL")
}
secret := key.Secret()
if err := s.DB.Model(&u).Update("totp_secret", totpPendingPrefix+secret).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
return &TOTPStatus{
Enabled: false,
Pending: true,
Secret: secret,
OTPAuth: key.URL(),
}, nil
}
// SetupConfirm 用动态码确认绑定。
func (s *AdminSecurityService) SetupConfirm(userID int64, code string) error {
if userID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
code = strings.TrimSpace(code)
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return commonservice.Internal("QUERY_FAILED")
}
if u.TOTPEnabled != 0 {
return commonservice.Conflict("TOTP_ALREADY_ENABLED")
}
if !strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
return commonservice.BadRequest("TOTP_SETUP_REQUIRED")
}
secret := strings.TrimPrefix(u.TOTPSecret, totpPendingPrefix)
if !totp.Validate(code, secret) {
return commonservice.BadRequest("TOTP_INVALID")
}
return s.DB.Model(&u).Updates(map[string]any{
"totp_secret": secret,
"totp_enabled": 1,
}).Error
}
// StepUp 校验动态码并签发 2h stepup token绑定 IP
func (s *AdminSecurityService) StepUp(userID int64, code, clientIP string) (string, time.Time, error) {
if userID != commonservice.AdminUserID {
return "", time.Time{}, commonservice.Forbidden("FORBIDDEN")
}
code = strings.TrimSpace(code)
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return "", time.Time{}, commonservice.Internal("QUERY_FAILED")
}
if u.TOTPEnabled == 0 || u.TOTPSecret == "" || strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
return "", time.Time{}, commonservice.BadRequest("TOTP_NOT_ENABLED")
}
if !totp.Validate(code, u.TOTPSecret) {
return "", time.Time{}, commonservice.BadRequest("TOTP_INVALID")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", time.Time{}, commonservice.Internal("INTERNAL")
}
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
expires := time.Now().UTC().Add(adminStepupTTL)
row := model.AdminStepup{
UserID: userID,
TokenHash: hex.EncodeToString(sum[:]),
ClientIP: strings.TrimSpace(clientIP),
ExpiresAt: expires.Format(time.RFC3339),
}
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"token_hash", "client_ip", "expires_at"}),
}).Create(&row).Error; err != nil {
return "", time.Time{}, commonservice.Internal("SAVE_FAILED")
}
// 更新管理员最近登录 IP便于审计
_ = s.DB.Model(&u).Updates(map[string]any{
"last_login_ip": row.ClientIP,
"last_seen_at": commonservice.NowRFC(),
}).Error
return token, expires, nil
}
// ClearStepUp 作废二次验证。
func (s *AdminSecurityService) ClearStepUp(userID int64) error {
if userID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
return s.DB.Delete(&model.AdminStepup{}, userID).Error
}
func buildOTPAuth(account, secret string) string {
// otpauth URLissuer/account 需编码
label := url.PathEscape(totpIssuer) + ":" + url.PathEscape(account)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", totpIssuer)
q.Set("algorithm", "SHA1")
q.Set("digits", "6")
q.Set("period", "30")
return "otpauth://totp/" + label + "?" + q.Encode()
}

142
internal/service/auth.go Normal file
View File

@@ -0,0 +1,142 @@
package service
import (
"strings"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
)
// AuthService 注册 / 登录 / 刷新 / 改密。
type AuthService struct {
DB *gorm.DB
Cfg *config.Config
}
// AuthTokens 登录/刷新返回的令牌与用户信息。
type AuthTokens struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
UserID int64 `json:"userId"`
Username string `json:"username"`
}
func validAccount(username, password string) error {
username = strings.TrimSpace(username)
if len(username) < 3 || len(username) > 64 {
return commonservice.BadRequest("SYNC_USERNAME_INVALID")
}
if len(password) < 6 {
return commonservice.BadRequest("SYNC_PASSWORD_TOO_SHORT")
}
return nil
}
// Register 注册新账号。
func (s *AuthService) Register(username, password string) error {
if err := validAccount(username, password); err != nil {
return err
}
username = strings.TrimSpace(username)
var n int64
s.DB.Model(&model.User{}).Where("username = ?", username).Count(&n)
if n > 0 {
return commonservice.Conflict("SYNC_USER_EXISTS")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return commonservice.Internal("INTERNAL")
}
u := model.User{
Username: username,
PasswordHash: string(hash),
CreatedAt: commonservice.NowRFC(),
}
if err := s.DB.Create(&u).Error; err != nil {
// 并发注册由唯一索引兜底。
return commonservice.Conflict("SYNC_USER_EXISTS")
}
return nil
}
// Login 校验账号并签发双令牌。
func (s *AuthService) Login(username, password, clientIP string) (*AuthTokens, error) {
username = strings.TrimSpace(username)
var u model.User
if err := s.DB.Where("username = ?", username).First(&u).Error; err != nil {
return nil, commonservice.Unauthorized("SYNC_BAD_CREDENTIALS")
}
if u.Disabled != 0 {
return nil, commonservice.Forbidden("ACCOUNT_DISABLED")
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
return nil, commonservice.Unauthorized("SYNC_BAD_CREDENTIALS")
}
tok, err := s.issueTokens(u.ID, u.Username)
if err != nil {
return nil, err
}
_ = (&ActivityService{DB: s.DB}).Ping(u.ID, clientIP)
return tok, nil
}
// Refresh 用 refresh token 换新的双令牌。
func (s *AuthService) Refresh(refreshToken, clientIP string) (*AuthTokens, error) {
claims, err := commonservice.Parse(s.Cfg.JWTSecret, strings.TrimSpace(refreshToken), "refresh")
if err != nil {
return nil, err
}
var u model.User
if err := s.DB.First(&u, claims.UserID).Error; err != nil {
return nil, commonservice.Unauthorized("UNAUTHORIZED")
}
if u.Disabled != 0 {
return nil, commonservice.Forbidden("ACCOUNT_DISABLED")
}
tok, err := s.issueTokens(u.ID, u.Username)
if err != nil {
return nil, err
}
_ = (&ActivityService{DB: s.DB}).Ping(u.ID, clientIP)
return tok, nil
}
// ChangePassword 在线修改密码。
func (s *AuthService) ChangePassword(userID int64, oldPassword, newPassword string) error {
if len(newPassword) < 6 {
return commonservice.BadRequest("SYNC_PASSWORD_TOO_SHORT")
}
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return commonservice.Unauthorized("UNAUTHORIZED")
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(oldPassword)) != nil {
return commonservice.BadRequest("SYNC_OLD_PASSWORD_WRONG")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return commonservice.Internal("INTERNAL")
}
return s.DB.Model(&u).Update("password_hash", string(hash)).Error
}
func (s *AuthService) issueTokens(userID int64, username string) (*AuthTokens, error) {
access, err := commonservice.IssueAccess(s.Cfg.JWTSecret, userID, username, s.Cfg.AccessTTLHours)
if err != nil {
return nil, commonservice.Internal("INTERNAL")
}
refresh, err := commonservice.IssueRefresh(s.Cfg.JWTSecret, userID, username, s.Cfg.RefreshTTLDays)
if err != nil {
return nil, commonservice.Internal("INTERNAL")
}
return &AuthTokens{
AccessToken: access,
RefreshToken: refresh,
UserID: userID,
Username: username,
}, nil
}

View File

@@ -0,0 +1,107 @@
package service
import (
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
const avatarHistoryLimit = 12
// AvatarHistoryService 用户头像历史(与账号关联,多端可回选)。
type AvatarHistoryService struct {
DB *gorm.DB
}
// AvatarHistoryItem 对外一条历史。
type AvatarHistoryItem struct {
ID int64 `json:"id"`
Mode string `json:"mode"`
Value string `json:"value"`
CreatedAt string `json:"createdAt"`
}
// List 按时间倒序返回本人历史(最多 avatarHistoryLimit 条)。
func (s *AvatarHistoryService) List(userID int64) ([]AvatarHistoryItem, error) {
var rows []model.UserAvatarHistory
if err := s.DB.Where("user_id = ?", userID).Order("id DESC").Limit(avatarHistoryLimit).Find(&rows).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out := make([]AvatarHistoryItem, 0, len(rows))
for _, r := range rows {
out = append(out, AvatarHistoryItem{ID: r.ID, Mode: r.Mode, Value: r.Value, CreatedAt: r.CreatedAt})
}
return out, nil
}
// Push 追加一条;同 value 去重后置顶;超出上限删最旧。
func (s *AvatarHistoryService) Push(userID int64, mode, value string) ([]AvatarHistoryItem, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, commonservice.BadRequest("AVATAR_VALUE_REQUIRED")
}
mode = strings.TrimSpace(strings.ToLower(mode))
if mode != "url" && mode != "base64" && mode != "path" {
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
mode = "url"
} else {
mode = "base64"
}
}
// path 仅本机有效,不入库线上历史
if mode == "path" {
return s.List(userID)
}
// 防爆:单条过大拒绝(约 1.5MB 文本)
if len(value) > 1_500_000 {
return nil, commonservice.BadRequest("AVATAR_VALUE_TOO_LARGE")
}
now := commonservice.NowRFC()
tx := s.DB.Begin()
if tx.Error != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
defer tx.Rollback()
if err := tx.Where("user_id = ? AND value = ?", userID, value).Delete(&model.UserAvatarHistory{}).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
row := model.UserAvatarHistory{UserID: userID, Mode: mode, Value: value, CreatedAt: now}
if err := tx.Create(&row).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
// 超出上限:删掉最旧的多余行
var ids []int64
if err := tx.Model(&model.UserAvatarHistory{}).Where("user_id = ?", userID).
Order("id DESC").Limit(500).Pluck("id", &ids).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
if len(ids) > avatarHistoryLimit {
drop := ids[avatarHistoryLimit:]
if err := tx.Where("user_id = ? AND id IN ?", userID, drop).Delete(&model.UserAvatarHistory{}).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
}
if err := tx.Commit().Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
return s.List(userID)
}
// Delete 删除本人一条历史。
func (s *AvatarHistoryService) Delete(userID, id int64) error {
if id <= 0 {
return commonservice.BadRequest("ID_REQUIRED")
}
res := s.DB.Where("user_id = ? AND id = ?", userID, id).Delete(&model.UserAvatarHistory{})
if res.Error != nil {
return commonservice.Internal("DELETE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.NotFound("NOT_FOUND")
}
return nil
}

173
internal/service/file.go Normal file
View File

@@ -0,0 +1,173 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"time"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
)
// FileService 图片上传与素材库管理。
type FileService struct {
DB *gorm.DB
Cfg *config.Config
}
var extByMime = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
}
// UploadResult 上传成功响应。
type UploadResult struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
Mime string `json:"mime"`
TeamID int64 `json:"teamId"`
}
// FileItem 素材库列表项。
type FileItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Original string `json:"original"`
Mime string `json:"mime"`
Size int64 `json:"size"`
UserID int64 `json:"userId"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"`
CreatedAt string `json:"createdAt"`
Username string `json:"username"`
URL string `json:"url" gorm:"-"`
}
// Upload 保存图片userID 来自 JWTteamID/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
}

View File

@@ -0,0 +1,30 @@
package service
import (
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// NoticeService 团队通知增量拉取。
type NoticeService struct {
DB *gorm.DB
}
// ListNotices 拉取 to_user=本人且 id>afterID 的通知,按 id 升序,最多 200 条。
func (s *NoticeService) ListNotices(userID, afterID int64) ([]model.TeamNotice, error) {
if afterID < 0 {
afterID = 0
}
var rows []model.TeamNotice
err := s.DB.Where("to_user = ? AND id > ?", userID, afterID).
Order("id ASC").Limit(200).Find(&rows).Error
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if rows == nil {
rows = []model.TeamNotice{}
}
return rows, nil
}

113
internal/service/profile.go Normal file
View File

@@ -0,0 +1,113 @@
package service
import (
"encoding/json"
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// ProfileService 用户公开资料。
type ProfileService struct {
DB *gorm.DB
}
// ProfileDTO 对外资料techTags 为数组)。
type ProfileDTO struct {
Nickname string `json:"nickname"`
Title string `json:"title"`
Email string `json:"email"`
Bio string `json:"bio"`
TechTags []string `json:"techTags"`
AvatarThumb string `json:"avatarThumb"`
UpdatedAt string `json:"updatedAt"`
}
func sanitizeProfile(p *ProfileDTO) error {
p.Nickname = commonservice.ClipRunes(p.Nickname, 32)
p.Title = commonservice.ClipRunes(p.Title, 48)
p.Email = commonservice.ClipRunes(p.Email, 128)
p.Bio = commonservice.ClipRunes(p.Bio, 300)
if p.Email != "" && (!strings.Contains(p.Email, "@") || strings.ContainsAny(p.Email, " \t")) {
return commonservice.BadRequest("PROFILE_EMAIL_INVALID")
}
tags, seen := []string{}, map[string]bool{}
for _, t := range p.TechTags {
t = commonservice.ClipRunes(t, 24)
if t == "" || seen[strings.ToLower(t)] {
continue
}
seen[strings.ToLower(t)] = true
tags = append(tags, t)
if len(tags) >= 20 {
break
}
}
p.TechTags = tags
return nil
}
// Get 读取本人资料;无记录返回空结构。
func (s *ProfileService) Get(userID int64) (*ProfileDTO, error) {
var row model.UserProfile
err := s.DB.Where("user_id = ?", userID).First(&row).Error
if err == gorm.ErrRecordNotFound {
return &ProfileDTO{TechTags: []string{}}, nil
}
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
return &ProfileDTO{
Nickname: row.Nickname,
Title: row.Title,
Email: row.Email,
Bio: row.Bio,
TechTags: parseTechTags(row.TechTags),
AvatarThumb: row.AvatarThumb,
UpdatedAt: row.UpdatedAt,
}, nil
}
// Put LWW 写入资料:仅当传入 updatedAt 更新时覆盖。
func (s *ProfileService) Put(userID int64, p ProfileDTO) (*ProfileDTO, error) {
if err := sanitizeProfile(&p); err != nil {
return nil, err
}
if p.UpdatedAt == "" {
p.UpdatedAt = commonservice.NowRFC()
}
tags, _ := json.Marshal(p.TechTags)
var remote model.UserProfile
err := s.DB.Where("user_id = ?", userID).First(&remote).Error
if err == nil && remote.UpdatedAt >= p.UpdatedAt {
// 远端相同或更新:返回远端,不覆盖。
return &ProfileDTO{
Nickname: remote.Nickname,
Title: remote.Title,
Email: remote.Email,
Bio: remote.Bio,
TechTags: parseTechTags(remote.TechTags),
AvatarThumb: remote.AvatarThumb,
UpdatedAt: remote.UpdatedAt,
}, nil
}
q := `INSERT INTO user_profiles(user_id,nickname,title,email,bio,tech_tags,avatar_thumb,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE nickname=VALUES(nickname),title=VALUES(title),email=VALUES(email),bio=VALUES(bio),
tech_tags=VALUES(tech_tags),avatar_thumb=VALUES(avatar_thumb),updated_at=VALUES(updated_at)`
if err := s.DB.Exec(q, userID, p.Nickname, p.Title, p.Email, p.Bio, string(tags), p.AvatarThumb, p.UpdatedAt).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
return &p, nil
}
func parseTechTags(s string) []string {
out := []string{}
if json.Unmarshal([]byte(s), &out) != nil {
return []string{}
}
return out
}

178
internal/service/release.go Normal file
View File

@@ -0,0 +1,178 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
)
var versionRe = regexp.MustCompile(`^\d+\.\d+\.\d+([-.][A-Za-z0-9.]+)?$`)
// ReleaseService 客户端发版。
type ReleaseService struct {
DB *gorm.DB
Cfg *config.Config
}
type LatestRelease struct {
Version string `json:"version"`
Channel string `json:"channel"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"sizeBytes"`
Changelog string `json:"changelog"`
CreatedAt string `json:"createdAt"`
}
// List 发版列表(新→旧)。
func (s *ReleaseService) List(channel string) ([]model.AppRelease, error) {
channel = normalizeChannel(channel)
var rows []model.AppRelease
q := s.DB.Order("id DESC").Limit(50)
if channel != "" {
q = q.Where("channel = ?", channel)
}
if err := q.Find(&rows).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if rows == nil {
rows = []model.AppRelease{}
}
return rows, nil
}
// Latest 当前最新版元数据。
func (s *ReleaseService) Latest(channel string) (*LatestRelease, error) {
channel = normalizeChannel(channel)
var row model.AppRelease
err := s.DB.Where("channel = ? AND is_latest = 1", channel).Order("id DESC").First(&row).Error
if err == gorm.ErrRecordNotFound {
return nil, commonservice.NotFound("NO_RELEASE")
}
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
return &LatestRelease{
Version: row.Version,
Channel: row.Channel,
SHA256: row.SHA256,
SizeBytes: row.SizeBytes,
Changelog: row.Changelog,
CreatedAt: row.CreatedAt,
}, nil
}
// Upload 保存安装包并写入元数据(默认不标 latest需 Publish
func (s *ReleaseService) Upload(version, channel, changelog string, r io.Reader, size int64) (*model.AppRelease, error) {
version = strings.TrimSpace(version)
channel = normalizeChannel(channel)
changelog = strings.TrimSpace(changelog)
if !versionRe.MatchString(version) {
return nil, commonservice.BadRequest("VERSION_INVALID")
}
if size <= 0 {
return nil, commonservice.BadRequest("EMPTY_FILE")
}
if size > s.Cfg.MaxReleaseBytes() {
return nil, commonservice.BadRequest("FILE_TOO_LARGE")
}
var n int64
s.DB.Model(&model.AppRelease{}).Where("version = ? AND channel = ?", version, channel).Count(&n)
if n > 0 {
return nil, commonservice.Conflict("VERSION_EXISTS")
}
dir := filepath.Join(s.Cfg.StorageDir, "releases", channel)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
relName := filepath.ToSlash(filepath.Join("releases", channel, version+"-installer.exe"))
abs := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(relName))
f, err := os.Create(abs)
if err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
defer f.Close()
h := sha256.New()
written, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(r, s.Cfg.MaxReleaseBytes()+1))
if err != nil {
_ = os.Remove(abs)
return nil, commonservice.Internal("SAVE_FAILED")
}
if written > s.Cfg.MaxReleaseBytes() {
_ = os.Remove(abs)
return nil, commonservice.BadRequest("FILE_TOO_LARGE")
}
sum := hex.EncodeToString(h.Sum(nil))
row := model.AppRelease{
Version: version,
Channel: channel,
Filename: relName,
SHA256: sum,
SizeBytes: written,
Changelog: changelog,
CreatedAt: commonservice.NowRFC(),
IsLatest: 0,
}
if err := s.DB.Create(&row).Error; err != nil {
_ = os.Remove(abs)
return nil, commonservice.Internal("SAVE_FAILED")
}
return &row, nil
}
// Publish 将指定发版标为该渠道最新。
func (s *ReleaseService) Publish(id int64) (*model.AppRelease, error) {
var row model.AppRelease
if err := s.DB.First(&row, id).Error; err != nil {
return nil, commonservice.NotFound("NOT_FOUND")
}
tx := s.DB.Begin()
if err := tx.Model(&model.AppRelease{}).Where("channel = ?", row.Channel).Update("is_latest", 0).Error; err != nil {
tx.Rollback()
return nil, commonservice.Internal("SAVE_FAILED")
}
if err := tx.Model(&row).Update("is_latest", 1).Error; err != nil {
tx.Rollback()
return nil, commonservice.Internal("SAVE_FAILED")
}
if err := tx.Commit().Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
row.IsLatest = 1
return &row, nil
}
// OpenFile 打开发版文件供下载。
func (s *ReleaseService) OpenFile(version, channel string) (*model.AppRelease, *os.File, error) {
version = strings.TrimSpace(version)
channel = normalizeChannel(channel)
var row model.AppRelease
if err := s.DB.Where("version = ? AND channel = ?", version, channel).First(&row).Error; err != nil {
return nil, nil, commonservice.NotFound("NOT_FOUND")
}
abs := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(row.Filename))
f, err := os.Open(abs)
if err != nil {
return nil, nil, commonservice.NotFound("NOT_FOUND")
}
return &row, f, nil
}
func normalizeChannel(ch string) string {
ch = strings.TrimSpace(strings.ToLower(ch))
if ch == "" {
return "stable"
}
return ch
}

View File

@@ -0,0 +1,94 @@
package service
import (
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// SettingsService 用户设置与全局资源fest_img:* / file_storage
type SettingsService struct {
DB *gorm.DB
}
func isGlobalSetting(name string) bool {
return name == "file_storage" || strings.HasPrefix(name, "fest_img:")
}
func settingOwner(userID int64, name string) int64 {
if isGlobalSetting(name) {
return commonservice.AdminUserID
}
return userID
}
// GetSetting 读取单条设置;全局键从管理员账号读。
func (s *SettingsService) GetSetting(userID int64, name string) (*model.SyncSetting, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, commonservice.BadRequest("SETTING_NAME_REQUIRED")
}
owner := settingOwner(userID, name)
var row model.SyncSetting
if err := s.DB.Where("user_id = ? AND name = ?", owner, name).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, commonservice.NotFound("NOT_FOUND")
}
return nil, commonservice.Internal("QUERY_FAILED")
}
return &row, nil
}
// ListByPrefix 按前缀批量拉取(如 fest_img:),全局前缀从管理员账号读。
func (s *SettingsService) ListByPrefix(userID int64, prefix string) ([]model.SyncSetting, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return nil, commonservice.BadRequest("SETTING_PREFIX_REQUIRED")
}
owner := userID
if strings.HasPrefix(prefix, "fest_img:") || prefix == "fest_img:" {
owner = commonservice.AdminUserID
}
var rows []model.SyncSetting
if err := s.DB.Where("user_id = ? AND name LIKE ?", owner, prefix+"%").
Order("name").Find(&rows).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if rows == nil {
rows = []model.SyncSetting{}
}
return rows, nil
}
// GetGlobal 读取挂在管理员名下的全局设置。
func (s *SettingsService) GetGlobal(name string) (*model.SyncSetting, error) {
return s.GetSetting(commonservice.AdminUserID, name)
}
// PutSetting LWW 写入;全局键仅管理员可写,且落在 user_id=1。
func (s *SettingsService) PutSetting(userID int64, name, value, updatedAt string) error {
name = strings.TrimSpace(name)
if name == "" {
return commonservice.BadRequest("SETTING_NAME_REQUIRED")
}
if updatedAt == "" {
updatedAt = commonservice.NowRFC()
}
owner := userID
if isGlobalSetting(name) {
if userID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
owner = commonservice.AdminUserID
}
q := `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
ON DUPLICATE KEY UPDATE value=IF(VALUES(updated_at)>updated_at,VALUES(value),value),
updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)`
if err := s.DB.Exec(q, owner, name, value, updatedAt).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
return nil
}

132
internal/service/sync.go Normal file
View File

@@ -0,0 +1,132 @@
package service
import (
"fmt"
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
)
// SyncService 个人待办/工单/记事本的推拉LWW
type SyncService struct {
DB *gorm.DB
}
type syncTableMeta struct {
remote string
cols []string // 不含 user_id含 uuid … updated_at deleted
hasProject bool
hasTimes bool
}
var syncTables = map[string]syncTableMeta{
"todos": {
remote: "sync_todos",
hasProject: true,
hasTimes: true,
cols: []string{"uuid", "title", "content", "due_at", "priority", "status", "history", "team_id", "project_name", "created_at", "updated_at", "deleted"},
},
"tickets": {
remote: "sync_tickets",
hasProject: true,
hasTimes: true,
cols: []string{"uuid", "title", "description", "type", "start_at", "due_at", "status", "priority", "history", "team_id", "project_name", "created_at", "updated_at", "deleted"},
},
"notes": {
remote: "sync_notes",
cols: []string{"uuid", "content", "updated_at", "deleted"},
},
}
// Push 对指定表做 LWW upsertrow 中不含 user_id取自 JWT
func (s *SyncService) Push(table string, userID int64, rows []map[string]any) (int, error) {
meta, ok := syncTables[table]
if !ok {
return 0, commonservice.BadRequest("SYNC_BAD_TABLE")
}
if userID <= 0 {
return 0, commonservice.Unauthorized("UNAUTHORIZED")
}
remoteCols := append([]string{"user_id"}, meta.cols...)
set := make([]string, 0, len(meta.cols))
for _, c := range meta.cols {
if c == "uuid" || c == "updated_at" {
continue
}
set = append(set, fmt.Sprintf("%s=IF(VALUES(updated_at)>updated_at,VALUES(%s),%s)", c, c, c))
}
set = append(set, "updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)")
placeholders := strings.TrimRight(strings.Repeat("?,", len(remoteCols)), ",")
q := fmt.Sprintf("INSERT INTO %s(%s) VALUES(%s) ON DUPLICATE KEY UPDATE %s",
meta.remote, strings.Join(remoteCols, ","), placeholders, strings.Join(set, ","))
n := 0
for _, row := range rows {
uuid := commonservice.MapStr(row, "uuid")
if uuid == "" {
continue
}
args := make([]any, 0, len(remoteCols))
args = append(args, userID)
for _, c := range meta.cols {
switch c {
case "deleted", "team_id":
args = append(args, commonservice.MapInt64(row, c))
default:
args = append(args, commonservice.MapStr(row, c))
}
}
if err := s.DB.Exec(q, args...).Error; err != nil {
return n, commonservice.Internal("SYNC_PUSH_FAILED")
}
n++
}
return n, nil
}
// Pull 按 updated_at 游标增量拉取,最多 2000 条。
func (s *SyncService) Pull(table string, userID int64, cursor string) ([]map[string]any, error) {
meta, ok := syncTables[table]
if !ok {
return nil, commonservice.BadRequest("SYNC_BAD_TABLE")
}
if userID <= 0 {
return nil, commonservice.Unauthorized("UNAUTHORIZED")
}
q := fmt.Sprintf("SELECT %s FROM %s WHERE user_id=? AND updated_at>? ORDER BY updated_at LIMIT 2000",
strings.Join(meta.cols, ","), meta.remote)
rawRows, err := s.DB.Raw(q, userID, cursor).Rows()
if err != nil {
return nil, commonservice.Internal("SYNC_PULL_FAILED")
}
defer rawRows.Close()
out := []map[string]any{}
for rawRows.Next() {
vals := make([]any, len(meta.cols))
ptrs := make([]any, len(meta.cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rawRows.Scan(ptrs...); err != nil {
return nil, commonservice.Internal("SYNC_PULL_FAILED")
}
m := map[string]any{}
for i, c := range meta.cols {
m[c] = coerceSQL(vals[i])
}
out = append(out, m)
}
return out, nil
}
func coerceSQL(v any) any {
switch x := v.(type) {
case []byte:
return string(x)
default:
return x
}
}

684
internal/service/team.go Normal file
View File

@@ -0,0 +1,684 @@
package service
import (
"fmt"
"regexp"
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// TeamService 团队协作:团队/成员/任务/共享/日报/摘要。
type TeamService struct {
DB *gorm.DB
}
var teamDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
const teamDisplayExpr = `COALESCE(NULLIF(p.nickname,''), u.username)`
// ---------- DTO ----------
type TeamInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"ownerId"`
DigestTime string `json:"digestTime"`
Role string `json:"role"`
Members int `json:"members"`
}
type TeamMemberDTO struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Title string `json:"title"`
Bio string `json:"bio"`
TechTags []string `json:"techTags"`
Avatar string `json:"avatar"`
Role string `json:"role"`
JoinedAt string `json:"joinedAt"`
}
type TeamTaskDTO struct {
ID int64 `json:"id"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"`
Title string `json:"title"`
Description string `json:"description"`
Priority string `json:"priority"`
Status string `json:"status"`
CreatorID int64 `json:"creatorId"`
Creator string `json:"creator"`
AssigneeID int64 `json:"assigneeId"`
Assignee string `json:"assignee"`
StartAt string `json:"startAt"`
DueAt string `json:"dueAt"`
UrgedAt string `json:"urgedAt"`
History string `json:"history"`
UpdatedAt string `json:"updatedAt"`
}
type TeamSharedItem struct {
Kind string `json:"kind"`
UUID string `json:"uuid"`
UserID int64 `json:"userId"`
Owner string `json:"owner"`
Title string `json:"title"`
Status string `json:"status"`
Priority string `json:"priority"`
DueAt string `json:"dueAt"`
UpdatedAt string `json:"updatedAt"`
}
type TeamReportDTO struct {
UserID int64 `json:"userId"`
User string `json:"user"`
Date string `json:"date"`
Content string `json:"content"`
SubmittedAt string `json:"submittedAt"`
}
type TeamDigestDTO struct {
Date string `json:"date"`
Content string `json:"content"`
Provider string `json:"provider"`
GeneratedAt string `json:"generatedAt"`
}
type TeamReportBoard struct {
Date string `json:"date"`
Role string `json:"role"`
Reports []TeamReportDTO `json:"reports"`
Missing []TeamMemberDTO `json:"missing"`
Digest *TeamDigestDTO `json:"digest"`
}
// ---------- helpers ----------
func (s *TeamService) teamNotice(teamID, toUser, fromUser int64, kind, refID, content string) {
if toUser <= 0 || toUser == fromUser {
return
}
_ = s.DB.Exec(`INSERT INTO team_notices(team_id,to_user,from_user,kind,ref_id,content,created_at) VALUES(?,?,?,?,?,?,?)`,
teamID, toUser, fromUser, kind, refID, content, commonservice.NowRFC())
}
func (s *TeamService) teamUserName(userID int64) string {
var name string
_ = s.DB.Raw(`SELECT `+teamDisplayExpr+` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=?`, userID).Scan(&name)
return name
}
func parseClockOK(at string) bool {
var h, m int
if _, e := fmt.Sscanf(at, "%d:%d", &h, &m); e != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return false
}
parts := strings.SplitN(at, ":", 2)
return len(parts) == 2
}
func teamTaskKindLabel(kind string) string {
if kind == "ticket" {
return "工单"
}
return "任务"
}
func teamStatusLabel(st string) string {
switch st {
case "doing":
return "进行中"
case "done":
return "已完成"
case "closed":
return "已关闭"
}
return "待处理"
}
func appendHistoryNode(history, status, at string) string {
node := fmt.Sprintf(`{"status":%q,"at":%q}`, status, at)
h := strings.TrimSpace(history)
if h == "" || h == "[]" {
return "[" + node + "]"
}
if strings.HasSuffix(h, "]") {
return h[:len(h)-1] + "," + node + "]"
}
return "[" + node + "]"
}
func stripFence(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```")
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
}
return strings.TrimSpace(s)
}
// ---------- 团队 CRUD ----------
func (s *TeamService) Create(userID int64, name string) (*TeamInfo, error) {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 64 {
return nil, commonservice.BadRequest("TEAM_NAME_INVALID")
}
now := commonservice.NowRFC()
team := model.Team{Name: name, OwnerID: userID, DigestTime: "21:00", CreatedAt: now}
if err := s.DB.Create(&team).Error; err != nil {
return nil, commonservice.Internal("TEAM_CREATE_FAILED")
}
if err := s.DB.Exec(`INSERT INTO team_members(team_id,user_id,role,joined_at) VALUES(?,?,?,?)`, team.ID, userID, "owner", now).Error; err != nil {
return nil, commonservice.Internal("TEAM_CREATE_FAILED")
}
return &TeamInfo{ID: team.ID, Name: name, OwnerID: userID, DigestTime: "21:00", Role: "owner", Members: 1}, nil
}
func (s *TeamService) List(userID int64) ([]TeamInfo, error) {
rows, err := s.DB.Raw(`SELECT t.id,t.name,t.owner_id,t.digest_time,m.role,
(SELECT COUNT(*) FROM team_members x WHERE x.team_id=t.id)
FROM teams t JOIN team_members m ON m.team_id=t.id AND m.user_id=? ORDER BY t.id`, userID).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
defer rows.Close()
out := []TeamInfo{}
for rows.Next() {
var t TeamInfo
if err := rows.Scan(&t.ID, &t.Name, &t.OwnerID, &t.DigestTime, &t.Role, &t.Members); err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out = append(out, t)
}
return out, nil
}
func (s *TeamService) Rename(teamID, userID int64, name string) error {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 64 {
return commonservice.BadRequest("TEAM_NAME_INVALID")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "owner"); err != nil {
return err
}
return s.DB.Exec(`UPDATE teams SET name=? WHERE id=?`, name, teamID).Error
}
func (s *TeamService) SetDigestTime(teamID, userID int64, at string) error {
if !parseClockOK(at) {
return commonservice.BadRequest("TEAM_TIME_INVALID")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
return s.DB.Exec(`UPDATE teams SET digest_time=? WHERE id=?`, at, teamID).Error
}
func (s *TeamService) Dissolve(teamID, userID int64) error {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "owner"); err != nil {
return err
}
var name string
_ = s.DB.Raw(`SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
var members []int64
_ = s.DB.Raw(`SELECT user_id FROM team_members WHERE team_id=?`, teamID).Scan(&members)
for _, m := range members {
s.teamNotice(teamID, m, userID, "member", "", fmt.Sprintf("团队「%s」已被解散", name))
}
for _, q := range []string{
`DELETE FROM team_tasks WHERE team_id=?`,
`DELETE FROM team_reports WHERE team_id=?`,
`DELETE FROM team_digests WHERE team_id=?`,
`DELETE FROM team_members WHERE team_id=?`,
`DELETE FROM teams WHERE id=?`,
} {
if err := s.DB.Exec(q, teamID).Error; err != nil {
return commonservice.Internal("TEAM_DISSOLVE_FAILED")
}
}
return nil
}
func (s *TeamService) Leave(teamID, userID int64) error {
role, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member")
if err != nil {
return err
}
if role == "owner" {
return commonservice.BadRequest("TEAM_OWNER_CANNOT_LEAVE")
}
if err := s.DB.Exec(`DELETE FROM team_members WHERE team_id=? AND user_id=?`, teamID, userID).Error; err != nil {
return commonservice.Internal("TEAM_LEAVE_FAILED")
}
var ownerID int64
_ = s.DB.Raw(`SELECT owner_id FROM teams WHERE id=?`, teamID).Scan(&ownerID)
s.teamNotice(teamID, ownerID, userID, "member", "", fmt.Sprintf("%s 退出了团队", s.teamUserName(userID)))
return nil
}
// ---------- 成员 ----------
func (s *TeamService) Members(teamID, userID int64) ([]TeamMemberDTO, error) {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
return nil, err
}
return s.memberRows(teamID)
}
func (s *TeamService) memberRows(teamID int64) ([]TeamMemberDTO, error) {
rows, err := s.DB.Raw(`SELECT m.user_id,u.username,COALESCE(p.nickname,''),COALESCE(p.title,''),COALESCE(p.bio,''),
COALESCE(p.tech_tags,'[]'),COALESCE(p.avatar_thumb,''),m.role,m.joined_at
FROM team_members m JOIN users u ON u.id=m.user_id LEFT JOIN user_profiles p ON p.user_id=m.user_id
WHERE m.team_id=? ORDER BY CASE m.role WHEN 'owner' THEN 1 WHEN 'admin' THEN 2 ELSE 3 END, m.joined_at`, teamID).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
defer rows.Close()
out := []TeamMemberDTO{}
for rows.Next() {
var m TeamMemberDTO
var tags string
if err := rows.Scan(&m.UserID, &m.Username, &m.Nickname, &m.Title, &m.Bio, &tags, &m.Avatar, &m.Role, &m.JoinedAt); err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
m.TechTags = parseTechTags(tags)
out = append(out, m)
}
return out, nil
}
func (s *TeamService) Invite(teamID, userID int64, username, role string) error {
if role != "member" && role != "admin" {
role = "member"
}
username = strings.TrimSpace(username)
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
var target int64
if err := s.DB.Raw(`SELECT id FROM users WHERE username=?`, username).Scan(&target).Error; err != nil || target == 0 {
return commonservice.NotFound("TEAM_USER_NOT_FOUND")
}
res := s.DB.Exec(`INSERT IGNORE INTO team_members(team_id,user_id,role,joined_at) VALUES(?,?,?,?)`,
teamID, target, role, commonservice.NowRFC())
if res.Error != nil {
return commonservice.Internal("TEAM_INVITE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.Conflict("TEAM_ALREADY_MEMBER")
}
var name string
_ = s.DB.Raw(`SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
s.teamNotice(teamID, target, userID, "member", "", fmt.Sprintf("%s 把你加入了团队「%s」", s.teamUserName(userID), name))
return nil
}
func (s *TeamService) SetRole(teamID, actorID, targetUserID int64, role string) error {
if role != "member" && role != "admin" {
return commonservice.BadRequest("TEAM_ROLE_INVALID")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, actorID, "owner"); err != nil {
return err
}
target, err := commonservice.TeamRole(s.DB, teamID, targetUserID)
if err != nil {
return err
}
if target == "" || target == "owner" {
return commonservice.BadRequest("TEAM_ROLE_INVALID")
}
if err := s.DB.Exec(`UPDATE team_members SET role=? WHERE team_id=? AND user_id=?`, role, teamID, targetUserID).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
label := "成员"
if role == "admin" {
label = "管理员"
}
s.teamNotice(teamID, targetUserID, actorID, "role", "", fmt.Sprintf("你的团队角色已调整为「%s」", label))
return nil
}
func (s *TeamService) RemoveMember(teamID, actorID, targetUserID int64) error {
actor, err := commonservice.RequireTeamRole(s.DB, teamID, actorID, "admin")
if err != nil {
return err
}
target, err := commonservice.TeamRole(s.DB, teamID, targetUserID)
if err != nil {
return err
}
if target == "" || target == "owner" || targetUserID == actorID || (actor == "admin" && target != "member") {
return commonservice.Forbidden("TEAM_FORBIDDEN")
}
if err := s.DB.Exec(`DELETE FROM team_members WHERE team_id=? AND user_id=?`, teamID, targetUserID).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
var name string
_ = s.DB.Raw(`SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
s.teamNotice(teamID, targetUserID, actorID, "member", "", fmt.Sprintf("你已被移出团队「%s」", name))
return nil
}
// ---------- 任务 ----------
func validTeamTask(t *TeamTaskDTO) error {
t.Title = strings.TrimSpace(t.Title)
if t.Title == "" {
return commonservice.BadRequest("TEAM_TASK_TITLE_REQUIRED")
}
if t.Kind != "ticket" {
t.Kind = "todo"
}
switch t.Priority {
case "low", "medium", "high":
default:
t.Priority = "medium"
}
return nil
}
func (s *TeamService) TaskSave(userID int64, t TeamTaskDTO) (*TeamTaskDTO, error) {
if err := validTeamTask(&t); err != nil {
return nil, err
}
if _, err := commonservice.RequireTeamRole(s.DB, t.TeamID, userID, "admin"); err != nil {
return nil, err
}
if t.AssigneeID > 0 {
r, err := commonservice.TeamRole(s.DB, t.TeamID, t.AssigneeID)
if err != nil || r == "" {
return nil, commonservice.BadRequest("TEAM_ASSIGNEE_INVALID")
}
}
now := commonservice.NowRFC()
if t.ID == 0 {
history := fmt.Sprintf(`[{"status":"open","at":%q}]`, now)
rec := model.TeamTask{
TeamID: t.TeamID, Kind: t.Kind, Title: t.Title, Description: t.Description,
Priority: t.Priority, Status: "open", CreatorID: userID, AssigneeID: t.AssigneeID,
StartAt: t.StartAt, DueAt: t.DueAt, History: history, UpdatedAt: now,
}
if err := s.DB.Create(&rec).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
t.ID, t.Status, t.CreatorID, t.History, t.UpdatedAt = rec.ID, "open", userID, history, now
if t.AssigneeID > 0 {
s.teamNotice(t.TeamID, t.AssigneeID, userID, "assign", fmt.Sprint(t.ID),
fmt.Sprintf("%s 给你指派了%s%s", s.teamUserName(userID), teamTaskKindLabel(t.Kind), t.Title))
}
return &t, nil
}
var prevAssignee int64
var n int64
s.DB.Raw(`SELECT COUNT(*) FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, t.ID, t.TeamID).Scan(&n)
if n == 0 {
return nil, commonservice.NotFound("TEAM_TASK_NOT_FOUND")
}
_ = s.DB.Raw(`SELECT assignee_id FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, t.ID, t.TeamID).Scan(&prevAssignee)
if err := s.DB.Exec(`UPDATE team_tasks SET kind=?,title=?,description=?,priority=?,assignee_id=?,start_at=?,due_at=?,updated_at=? WHERE id=? AND team_id=?`,
t.Kind, t.Title, t.Description, t.Priority, t.AssigneeID, t.StartAt, t.DueAt, now, t.ID, t.TeamID).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
if t.AssigneeID > 0 && t.AssigneeID != prevAssignee {
s.teamNotice(t.TeamID, t.AssigneeID, userID, "assign", fmt.Sprint(t.ID),
fmt.Sprintf("%s 给你指派了%s%s", s.teamUserName(userID), teamTaskKindLabel(t.Kind), t.Title))
}
t.UpdatedAt = now
return &t, nil
}
func (s *TeamService) TaskSetStatus(teamID, taskID, userID int64, status string) error {
switch status {
case "open", "doing", "done", "closed":
default:
return commonservice.BadRequest("TEAM_STATUS_INVALID")
}
role, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member")
if err != nil {
return err
}
var assignee, creator int64
var title, history, kind string
row := s.DB.Raw(`SELECT assignee_id,creator_id,title,history,kind FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, taskID, teamID).Row()
if err := row.Scan(&assignee, &creator, &title, &history, &kind); err != nil {
return commonservice.NotFound("TEAM_TASK_NOT_FOUND")
}
if commonservice.TeamRoleRank(role) < commonservice.TeamRoleRank("admin") && userID != assignee {
return commonservice.Forbidden("TEAM_FORBIDDEN")
}
now := commonservice.NowRFC()
history = appendHistoryNode(history, status, now)
if err := s.DB.Exec(`UPDATE team_tasks SET status=?,history=?,updated_at=? WHERE id=?`, status, history, now, taskID).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
if (status == "done" || status == "closed") && userID != creator {
s.teamNotice(teamID, creator, userID, "status", fmt.Sprint(taskID),
fmt.Sprintf("%s 将%s「%s」标记为%s", s.teamUserName(userID), teamTaskKindLabel(kind), title, teamStatusLabel(status)))
}
return nil
}
func (s *TeamService) TaskUrge(teamID, taskID, userID int64) error {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
var assignee int64
var title, kind string
if err := s.DB.Raw(`SELECT assignee_id,title,kind FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, taskID, teamID).
Row().Scan(&assignee, &title, &kind); err != nil {
return commonservice.NotFound("TEAM_TASK_NOT_FOUND")
}
if assignee <= 0 {
return commonservice.BadRequest("TEAM_NO_ASSIGNEE")
}
now := commonservice.NowRFC()
if err := s.DB.Exec(`UPDATE team_tasks SET urged_at=?,updated_at=? WHERE id=?`, now, now, taskID).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
s.teamNotice(teamID, assignee, userID, "urge", fmt.Sprint(taskID),
fmt.Sprintf("%s 催办了%s%s", s.teamUserName(userID), teamTaskKindLabel(kind), title))
return nil
}
func (s *TeamService) TaskDelete(teamID, taskID, userID int64) error {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
return s.DB.Exec(`UPDATE team_tasks SET deleted=1,updated_at=? WHERE id=? AND team_id=?`, commonservice.NowRFC(), taskID, teamID).Error
}
func (s *TeamService) TaskList(teamID, userID int64, filter string) ([]TeamTaskDTO, error) {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
return nil, err
}
q := `SELECT t.id,t.team_id,t.kind,t.title,t.description,t.priority,t.status,t.creator_id,t.assignee_id,
t.start_at,t.due_at,t.urged_at,t.history,t.updated_at,
COALESCE((SELECT ` + teamDisplayExpr + ` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=t.creator_id),''),
COALESCE((SELECT ` + teamDisplayExpr + ` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=t.assignee_id),'')
FROM team_tasks t WHERE t.team_id=? AND t.deleted=0`
args := []any{teamID}
switch filter {
case "mine":
q += ` AND t.assignee_id=?`
args = append(args, userID)
case "created":
q += ` AND t.creator_id=?`
args = append(args, userID)
case "open":
q += ` AND t.status IN ('open','doing')`
}
q += ` ORDER BY t.updated_at DESC LIMIT 500`
rows, err := s.DB.Raw(q, args...).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
defer rows.Close()
out := []TeamTaskDTO{}
for rows.Next() {
var t TeamTaskDTO
if err := rows.Scan(&t.ID, &t.TeamID, &t.Kind, &t.Title, &t.Description, &t.Priority, &t.Status, &t.CreatorID, &t.AssigneeID,
&t.StartAt, &t.DueAt, &t.UrgedAt, &t.History, &t.UpdatedAt, &t.Creator, &t.Assignee); err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out = append(out, t)
}
return out, nil
}
// ---------- 共享 ----------
func (s *TeamService) SharedItems(teamID, userID int64) ([]TeamSharedItem, error) {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
return nil, err
}
q := `SELECT 'todo',s.uuid,s.user_id,` + teamDisplayExpr + `,s.title,s.status,s.priority,s.due_at,s.updated_at
FROM sync_todos s JOIN users u ON u.id=s.user_id LEFT JOIN user_profiles p ON p.user_id=s.user_id
WHERE s.team_id=? AND s.deleted=0
UNION ALL
SELECT 'ticket',s.uuid,s.user_id,` + teamDisplayExpr + `,s.title,s.status,s.priority,s.due_at,s.updated_at
FROM sync_tickets s JOIN users u ON u.id=s.user_id LEFT JOIN user_profiles p ON p.user_id=s.user_id
WHERE s.team_id=? AND s.deleted=0
ORDER BY 9 DESC LIMIT 500`
rows, err := s.DB.Raw(q, teamID, teamID).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
defer rows.Close()
out := []TeamSharedItem{}
for rows.Next() {
var it TeamSharedItem
if err := rows.Scan(&it.Kind, &it.UUID, &it.UserID, &it.Owner, &it.Title, &it.Status, &it.Priority, &it.DueAt, &it.UpdatedAt); err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out = append(out, it)
}
return out, nil
}
func (s *TeamService) UrgeShared(teamID, userID int64, kind, uuid string) error {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
table := "sync_todos"
if kind == "ticket" {
table = "sync_tickets"
}
var owner int64
var title string
if err := s.DB.Raw(`SELECT user_id,title FROM `+table+` WHERE uuid=? AND team_id=? AND deleted=0`, uuid, teamID).
Row().Scan(&owner, &title); err != nil {
return commonservice.NotFound("TEAM_TASK_NOT_FOUND")
}
s.teamNotice(teamID, owner, userID, "urge", uuid,
fmt.Sprintf("%s 催办了你共享的%s%s", s.teamUserName(userID), teamTaskKindLabel(kind), title))
return nil
}
// ---------- 日报 / 摘要 ----------
func (s *TeamService) ReportSubmit(teamID, userID int64, date, content string) error {
if !teamDateRe.MatchString(date) {
return commonservice.BadRequest("TEAM_DATE_INVALID")
}
content = strings.TrimSpace(content)
if content == "" {
return commonservice.BadRequest("TEAM_REPORT_EMPTY")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
return err
}
return s.DB.Exec(`INSERT INTO team_reports(team_id,user_id,date,content,submitted_at) VALUES(?,?,?,?,?)
ON DUPLICATE KEY UPDATE content=VALUES(content),submitted_at=VALUES(submitted_at)`,
teamID, userID, date, content, commonservice.NowRFC()).Error
}
func (s *TeamService) ReportBoardGet(teamID, userID int64, date string) (*TeamReportBoard, error) {
if !teamDateRe.MatchString(date) {
return nil, commonservice.BadRequest("TEAM_DATE_INVALID")
}
role, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member")
if err != nil {
return nil, err
}
board := &TeamReportBoard{Date: date, Role: role, Reports: []TeamReportDTO{}, Missing: []TeamMemberDTO{}}
rows, err := s.DB.Raw(`SELECT r.user_id,`+teamDisplayExpr+`,r.content,r.submitted_at
FROM team_reports r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=r.user_id
WHERE r.team_id=? AND r.date=? ORDER BY r.submitted_at`, teamID, date).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
submitted := map[int64]bool{}
for rows.Next() {
var r TeamReportDTO
if err := rows.Scan(&r.UserID, &r.User, &r.Content, &r.SubmittedAt); err != nil {
rows.Close()
return nil, commonservice.Internal("QUERY_FAILED")
}
r.Date = date
if commonservice.TeamRoleRank(role) < commonservice.TeamRoleRank("admin") && r.UserID != userID {
r.Content = ""
}
submitted[r.UserID] = true
board.Reports = append(board.Reports, r)
}
rows.Close()
members, err := s.memberRows(teamID)
if err != nil {
return nil, err
}
for _, m := range members {
if !submitted[m.UserID] {
board.Missing = append(board.Missing, m)
}
}
var d TeamDigestDTO
err = s.DB.Raw(`SELECT date,content,provider,generated_at FROM team_digests WHERE team_id=? AND date=?`, teamID, date).
Row().Scan(&d.Date, &d.Content, &d.Provider, &d.GeneratedAt)
if err == nil {
d.Content = stripFence(d.Content)
board.Digest = &d
}
return board, nil
}
func (s *TeamService) ReportUrge(teamID, actorID, targetUserID int64, date string) error {
if !teamDateRe.MatchString(date) {
return commonservice.BadRequest("TEAM_DATE_INVALID")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, actorID, "admin"); err != nil {
return err
}
r, err := commonservice.TeamRole(s.DB, teamID, targetUserID)
if err != nil || r == "" {
return commonservice.NotFound("TEAM_USER_NOT_FOUND")
}
s.teamNotice(teamID, targetUserID, actorID, "report_urge", date,
fmt.Sprintf("%s 提醒你提交 %s 的团队日报", s.teamUserName(actorID), date))
return nil
}
// DigestSave 存客户端本地 AI 生成后的摘要admin+)。
func (s *TeamService) DigestSave(teamID, userID int64, date, content, provider string) error {
if !teamDateRe.MatchString(date) {
return commonservice.BadRequest("TEAM_DATE_INVALID")
}
content = strings.TrimSpace(content)
if content == "" {
return commonservice.BadRequest("TEAM_DIGEST_EMPTY")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
return s.DB.Exec(`INSERT INTO team_digests(team_id,date,content,provider,generated_at) VALUES(?,?,?,?,?)
ON DUPLICATE KEY UPDATE content=VALUES(content),provider=VALUES(provider),generated_at=VALUES(generated_at)`,
teamID, date, content, provider, commonservice.NowRFC()).Error
}

View File

@@ -1,8 +1,7 @@
package main
// nl-pms-apicode-countview 桌面端)的文件存储服务
// 客户端凭配置密钥上传图片POST /api/v1/files换取可公开访问的
// http URLGET /files/...Markdown 内容图 / 头像等远程场景直接引用该 URL。
// nl-pms-apicode-countview 桌面端)的同步 + 文件存储 HTTP API
// 提供 JWT 认证、待办/工单/记事本推拉、设置/资料、团队协作与图片上传。
//
// 表结构约定:仅 dev 环境config.yaml 里 env: dev启动时 AutoMigrate
// 生产环境启动绝不执行任何 DDL缺表直接退出需先手工执行 init.sql

View File

@@ -0,0 +1,84 @@
-- 管理员后台 / TOTP / 日活 / AI 用量 / 发版 / stepup
-- 可重复执行(列/表不存在才添加)
USE code_count;
-- users 扩展列
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='totp_secret')=0,
'ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''TOTP Base32 密钥(仅管理员)'' AFTER password_hash', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='totp_enabled')=0,
'ALTER TABLE users ADD COLUMN totp_enabled TINYINT NOT NULL DEFAULT 0 COMMENT ''是否已启用 TOTP'' AFTER totp_secret', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='last_login_ip')=0,
'ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''最近登录 IP'' AFTER totp_enabled', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='last_seen_at')=0,
'ALTER TABLE users ADD COLUMN last_seen_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''最近活跃时间 RFC3339'' AFTER last_login_ip', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='ai_banned')=0,
'ALTER TABLE users ADD COLUMN ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT ''禁止使用 AI'' AFTER last_seen_at', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='users' AND COLUMN_NAME='disabled')=0,
'ALTER TABLE users ADD COLUMN disabled TINYINT NOT NULL DEFAULT 0 COMMENT ''禁用账号'' AFTER ai_banned', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- teams.ai_banned
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA='code_count' AND TABLE_NAME='teams' AND COLUMN_NAME='ai_banned')=0,
'ALTER TABLE teams ADD COLUMN ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT ''禁止团队使用 AI'' AFTER digest_time', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
CREATE TABLE IF NOT EXISTS user_daily_active(
user_id BIGINT NOT NULL COMMENT '用户ID',
active_date CHAR(10) NOT NULL COMMENT '活跃日期 YYYY-MM-DD',
last_ip VARCHAR(64) NOT NULL DEFAULT '' COMMENT '当日最近 IP',
last_seen_at VARCHAR(32) NOT NULL DEFAULT '' COMMENT '当日最近活跃时间',
PRIMARY KEY(user_id, active_date),
KEY idx_uda_date(active_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户日活';
CREATE TABLE IF NOT EXISTS ai_usage_daily(
user_id BIGINT NOT NULL COMMENT '用户ID',
team_id BIGINT NOT NULL DEFAULT 0 COMMENT '团队ID0=个人',
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='管理员敏感操作二次验证';

View File

@@ -0,0 +1,503 @@
-- ============================================================
-- 补全 / 恢复 code_count 全库表与字段 COMMENT
-- 可重复执行:列存在才 MODIFY已有相同注释再跑无害。
-- 用法:
-- mysql -u root -p code_count < migrations/20260815_restore_column_comments.sql
-- 说明:
-- - 不改业务数据;仅重写列类型元数据中的 COMMENT类型/默认值与 init.sql 对齐)
-- - 若某列尚未升级出来(旧库缺列),对应语句自动跳过
-- ============================================================
USE code_count;
-- 通用:表存在且列存在时才执行 ALTER
-- 用法示例见下方各 CALL 风格的动态 SQL
-- -------------------- users --------------------
ALTER TABLE users COMMENT='应用账号';
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='id')>0,
'ALTER TABLE users MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''用户ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='username')>0,
'ALTER TABLE users MODIFY COLUMN username VARCHAR(64) NOT NULL COMMENT ''登录名''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='password_hash')>0,
'ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(100) NOT NULL COMMENT ''bcrypt 密码哈希''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='totp_secret')>0,
'ALTER TABLE users MODIFY COLUMN totp_secret VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''TOTP Base32 密钥(仅管理员)''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='totp_enabled')>0,
'ALTER TABLE users MODIFY COLUMN totp_enabled TINYINT NOT NULL DEFAULT 0 COMMENT ''是否已启用 TOTP''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='last_login_ip')>0,
'ALTER TABLE users MODIFY COLUMN last_login_ip VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''最近登录 IP''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='last_seen_at')>0,
'ALTER TABLE users MODIFY COLUMN last_seen_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''最近活跃时间 RFC3339''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='ai_banned')>0,
'ALTER TABLE users MODIFY COLUMN ai_banned TINYINT NOT NULL DEFAULT 0 COMMENT ''禁止使用 AI''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='disabled')>0,
'ALTER TABLE users MODIFY COLUMN disabled TINYINT NOT NULL DEFAULT 0 COMMENT ''禁用账号''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='users' AND COLUMN_NAME='created_at')>0,
'ALTER TABLE users MODIFY COLUMN created_at VARCHAR(32) NOT NULL COMMENT ''注册时间RFC3339''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- -------------------- sync_todos --------------------
ALTER TABLE sync_todos COMMENT='待办同步表';
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='user_id')>0,
'ALTER TABLE sync_todos MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''所属用户ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='uuid')>0,
'ALTER TABLE sync_todos MODIFY COLUMN uuid CHAR(36) NOT NULL COMMENT ''客户端生成的全局唯一ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='title')>0,
'ALTER TABLE sync_todos MODIFY COLUMN title TEXT NOT NULL COMMENT ''标题''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='content')>0,
'ALTER TABLE sync_todos MODIFY COLUMN content MEDIUMTEXT NOT NULL COMMENT ''正文Markdown可含内嵌图片''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='project_name')>0,
'ALTER TABLE sync_todos MODIFY COLUMN project_name VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''关联项目名(展示用)''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='due_at')>0,
'ALTER TABLE sync_todos MODIFY COLUMN due_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''截止时间''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='priority')>0,
'ALTER TABLE sync_todos MODIFY COLUMN priority VARCHAR(16) NOT NULL DEFAULT ''medium'' COMMENT ''优先级low/medium/high''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='status')>0,
'ALTER TABLE sync_todos MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT ''open'' COMMENT ''状态open/doing/done/cancelled''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='history')>0,
'ALTER TABLE sync_todos MODIFY COLUMN history MEDIUMTEXT NOT NULL COMMENT ''生命周期轨迹 JSON''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='sync_todos' AND COLUMN_NAME='team_id')>0,
'ALTER TABLE sync_todos MODIFY COLUMN team_id BIGINT NOT NULL DEFAULT 0 COMMENT ''共享团队ID0=私密''', '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 ''共享团队ID0=私密''', '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 ''指派人用户ID0=未指派''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='start_at')>0,
'ALTER TABLE team_tasks MODIFY COLUMN start_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''开始时间''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='due_at')>0,
'ALTER TABLE team_tasks MODIFY COLUMN due_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''截止时间''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='urged_at')>0,
'ALTER TABLE team_tasks MODIFY COLUMN urged_at VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''最近催办时间''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='history')>0,
'ALTER TABLE team_tasks MODIFY COLUMN history MEDIUMTEXT NOT NULL COMMENT ''生命周期轨迹 JSON''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='updated_at')>0,
'ALTER TABLE team_tasks MODIFY COLUMN updated_at VARCHAR(32) NOT NULL COMMENT ''最后更新时间''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_tasks' AND COLUMN_NAME='deleted')>0,
'ALTER TABLE team_tasks MODIFY COLUMN deleted TINYINT NOT NULL DEFAULT 0 COMMENT ''软删除标记''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- -------------------- team_reports --------------------
ALTER TABLE team_reports COMMENT='团队日报';
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='team_id')>0,
'ALTER TABLE team_reports MODIFY COLUMN team_id BIGINT NOT NULL COMMENT ''团队ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='user_id')>0,
'ALTER TABLE team_reports MODIFY COLUMN user_id BIGINT NOT NULL COMMENT ''提交人用户ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='date')>0,
'ALTER TABLE team_reports MODIFY COLUMN date CHAR(10) NOT NULL COMMENT ''日报日期 YYYY-MM-DD''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='content')>0,
'ALTER TABLE team_reports MODIFY COLUMN content MEDIUMTEXT NOT NULL COMMENT ''日报正文Markdown''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_reports' AND COLUMN_NAME='submitted_at')>0,
'ALTER TABLE team_reports MODIFY COLUMN submitted_at VARCHAR(32) NOT NULL COMMENT ''提交/更新时间''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- -------------------- team_digests --------------------
ALTER TABLE team_digests COMMENT='团队日报 AI 摘要';
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='team_id')>0,
'ALTER TABLE team_digests MODIFY COLUMN team_id BIGINT NOT NULL COMMENT ''团队ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='date')>0,
'ALTER TABLE team_digests MODIFY COLUMN date CHAR(10) NOT NULL COMMENT ''摘要对应日期 YYYY-MM-DD''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='content')>0,
'ALTER TABLE team_digests MODIFY COLUMN content MEDIUMTEXT NOT NULL COMMENT ''AI 摘要正文Markdown''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='provider')>0,
'ALTER TABLE team_digests MODIFY COLUMN provider VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''生成所用 AI 提供商''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_digests' AND COLUMN_NAME='generated_at')>0,
'ALTER TABLE team_digests MODIFY COLUMN generated_at VARCHAR(32) NOT NULL COMMENT ''生成时间''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- -------------------- team_notices --------------------
ALTER TABLE team_notices COMMENT='团队通知';
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='id')>0,
'ALTER TABLE team_notices MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''通知ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='team_id')>0,
'ALTER TABLE team_notices MODIFY COLUMN team_id BIGINT NOT NULL COMMENT ''团队ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='to_user')>0,
'ALTER TABLE team_notices MODIFY COLUMN to_user BIGINT NOT NULL COMMENT ''接收用户ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='from_user')>0,
'ALTER TABLE team_notices MODIFY COLUMN from_user BIGINT NOT NULL COMMENT ''发送用户ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='kind')>0,
'ALTER TABLE team_notices MODIFY COLUMN kind VARCHAR(16) NOT NULL COMMENT ''通知类型''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='ref_id')>0,
'ALTER TABLE team_notices MODIFY COLUMN ref_id VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''关联对象ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='content')>0,
'ALTER TABLE team_notices MODIFY COLUMN content TEXT NOT NULL COMMENT ''通知正文''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='team_notices' AND COLUMN_NAME='created_at')>0,
'ALTER TABLE team_notices MODIFY COLUMN created_at VARCHAR(32) NOT NULL COMMENT ''创建时间''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
-- -------------------- pms_files原先 CREATE 多为行尾注释,列 COMMENT 常缺失) --------------------
ALTER TABLE pms_files COMMENT='已上传文件元数据';
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='id')>0,
'ALTER TABLE pms_files MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT COMMENT ''文件ID''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='name')>0,
'ALTER TABLE pms_files MODIFY COLUMN name VARCHAR(191) NOT NULL COMMENT ''存储相对路径,如 2026/08/13/<hex>.jpg''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='original')>0,
'ALTER TABLE pms_files MODIFY COLUMN original VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''原始文件名(仅备查)''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='mime')>0,
'ALTER TABLE pms_files MODIFY COLUMN mime VARCHAR(64) NOT NULL DEFAULT '''' COMMENT ''MIME 类型''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='size')>0,
'ALTER TABLE pms_files MODIFY COLUMN size BIGINT NOT NULL DEFAULT 0 COMMENT ''文件字节大小''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='sha256')>0,
'ALTER TABLE pms_files MODIFY COLUMN sha256 CHAR(64) NOT NULL DEFAULT '''' COMMENT ''内容摘要,同归属秒传去重''', 'SELECT 1');
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='pms_files' AND COLUMN_NAME='user_id')>0,
'ALTER TABLE pms_files MODIFY COLUMN user_id BIGINT NOT NULL DEFAULT 0 COMMENT ''上传者用户ID0=未知''', '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 ''上传时所在团队ID0=个人''', '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 ''团队ID0=个人''', '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;

View File

@@ -0,0 +1,15 @@
-- ============================================================
-- 用户头像历史(与线上账号关联,可多端回选)
-- 用法mysql -u root -p code_count < migrations/20260815_user_avatar_history.sql
-- 或:在已选中库后执行本文件内容。
-- 可重复执行CREATE IF NOT EXISTS
-- ============================================================
CREATE TABLE IF NOT EXISTS user_avatar_history(
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '历史记录ID',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
mode VARCHAR(16) NOT NULL DEFAULT 'url' COMMENT '存储模式url | base64',
value MEDIUMTEXT NOT NULL COMMENT '头像内容URL 或 dataURL',
created_at VARCHAR(32) NOT NULL COMMENT '写入时间RFC3339',
KEY idx_uah_user_id(user_id, id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户头像历史(每人保留最近若干条)';