diff --git a/controller/audit_governance.go b/controller/audit_governance.go new file mode 100644 index 000000000000..c9490b1cc920 --- /dev/null +++ b/controller/audit_governance.go @@ -0,0 +1,56 @@ +package controller + +import ( + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// GetAuditLogs 审计检索(仅超管可见,详见研发任务卡 T8 检索页)。 +// GET /api/audit?actor_id=&actor_name=&action=&target_type=&from=&to=&keyword=&p=&size= +// from/to 为 unix 秒;actor_id/actor_name/action/target_type/keyword 为可选过滤维度。 +func GetAuditLogs(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + q := model.AuditLogQuery{ + ActorName: c.Query("actor_name"), + Action: c.Query("action"), + TargetType: c.Query("target_type"), + Keyword: c.Query("keyword"), + From: parseInt64(c.Query("from")), + To: parseInt64(c.Query("to")), + StartIdx: pageInfo.GetStartIdx(), + PageSize: pageInfo.GetPageSize(), + } + if v := c.Query("actor_id"); v != "" { + if id, err := strconv.Atoi(v); err == nil { + q.ActorId = id + } + } + + logs, total, err := model.SearchAuditLogs(q) + if err != nil { + common.SysLog("GetAuditLogs search failed: " + err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "检索失败"}) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) +} + +// parseInt64 解析 unix 秒时间戳;非法或空值返回 0(表示不限制该边界)。 +func parseInt64(s string) int64 { + if s == "" { + return 0 + } + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 + } + return v +} diff --git a/controller/quota.go b/controller/quota.go new file mode 100644 index 000000000000..78df8cae7493 --- /dev/null +++ b/controller/quota.go @@ -0,0 +1,201 @@ +package controller + +import ( + "errors" + "math" + "net/http" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// 治理域错误(用于审批事务内返回,并由 ApproveQuota 映射为 HTTP 响应)。 +var ( + ErrAppNotFoundOrHandled = errors.New("申请单不存在或已处理") + ErrSelfApprove = errors.New("禁止自审批") + ErrBudgetInsufficient = errors.New("预算池余额不足") +) + +// quotaApplyRequest 提交额度申请请求体。 +type quotaApplyRequest struct { + Amount float64 `json:"amount"` + Reason string `json:"reason"` + Dept string `json:"dept"` +} + +// ApplyQuota 提交额度申请:插入 quota_application(status=pending),返回申请单号。 +// 仅要求登录(普通用户/部门管理员/超管均可提交)。校验 amount > 0。 +// +// POST /api/quota/apply +func ApplyQuota(c *gin.Context) { + var req quotaApplyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"}) + return + } + if req.Amount <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "金额必须大于 0"}) + return + } + + actorId := c.GetInt("id") + actorName := c.GetString("username") + dept := req.Dept + if dept == "" { + dept = c.GetString("department") // 未显式传 dept 时取当前用户部门 + } + + app := model.QuotaApplication{ + ApplicantId: actorId, + ApplicantName: actorName, + Dept: dept, + Amount: req.Amount, + Reason: req.Reason, + Status: "pending", + CreatedAt: time.Now().Unix(), + } + if err := model.DB.Create(&app).Error; err != nil { + common.SysLog("ApplyQuota create failed: " + err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "提交失败"}) + return + } + + // 审计:提交申请(事务外,不阻塞) + model.WriteAuditLog(actorId, actorName, "quota_apply", + "quota_application", strconv.FormatInt(app.Id, 10), + "提交额度申请 "+strconv.FormatFloat(req.Amount, 'f', 2, 64)+" 元", c.ClientIP()) + + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "data": gin.H{"application_id": app.Id, "status": "pending"}, + }) +} + +// quotaApproveRequest 审批请求体。 +type quotaApproveRequest struct { + ApplicationId int64 `json:"application_id"` + Decision string `json:"decision"` // approve | reject + RejectReason string `json:"reject_reason"` +} + +// ApproveQuota 审批额度申请:批准时从事务内行锁预算池拨至申请人个人余额; +// 拒绝时仅落状态。禁止自审批(handler + 事务内双校验),部门管理员仅可审批本部门申请。 +// 并发安全由 GORM 事务 + SELECT ... FOR UPDATE 行锁保证(详见研发任务卡 T5)。 +// +// POST /api/quota/approve +func ApproveQuota(c *gin.Context) { + var req quotaApproveRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "参数错误"}) + return + } + if req.Decision != "approve" && req.Decision != "reject" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "decision 必须为 approve 或 reject"}) + return + } + + actorId := c.GetInt("id") + actorName := c.GetString("username") + roleLevel := c.GetInt("role_level") + actorDept := c.GetString("department") + + // 预校验(事务外):存在性 / 是否已处理 / 自审批 / 部门范围。 + // 事务内会再次校验自审批与存在性,防止并发窗口绕过。 + app, err := model.GetQuotaApplicationById(req.ApplicationId) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": ErrAppNotFoundOrHandled.Error()}) + return + } + if app.Status != "pending" { + c.JSON(http.StatusConflict, gin.H{"success": false, "message": "申请单已处理"}) + return + } + if app.ApplicantId == actorId { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "禁止自审批", "code": "self_approve_forbidden"}) + return + } + // 部门管理员仅可审批本部门;超级管理员不限部门。 + if roleLevel == model.RoleLevelDeptAdmin && app.Dept != actorDept { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "只能审批本部门申请"}) + return + } + + txErr := model.DB.Transaction(func(tx *gorm.DB) error { + var appTx model.QuotaApplication + if e := tx.Where("id = ? AND status = ?", req.ApplicationId, "pending").First(&appTx).Error; e != nil { + return ErrAppNotFoundOrHandled + } + // 事务内二次自审批校验(并发安全) + if appTx.ApplicantId == actorId { + return ErrSelfApprove + } + now := time.Now().Unix() + if req.Decision == "reject" { + appTx.Status = "rejected" + appTx.ApproverId = actorId + appTx.ApproverName = actorName + appTx.DecidedAt = now + appTx.RejectReason = req.RejectReason + return tx.Save(&appTx).Error + } + // approve:行锁预算池(id=1)防止并发超拨 + var pool model.BudgetPool + if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", 1).First(&pool).Error; e != nil { + return e + } + // 货币单位=元:预算池(decimal)与个人余额(Quota int)统一以「元」记账。 + // 为与 User.Quota(int) 对齐,拨付按整元四舍五入(角分在 v1 暂不保留,待生产决策)。 + deltaYuan := int64(math.Round(appTx.Amount)) + if pool.TotalBalance < float64(deltaYuan) { + return ErrBudgetInsufficient + } + if e := tx.Model(&model.User{}).Where("id = ?", appTx.ApplicantId). + UpdateColumn("quota", gorm.Expr("quota + ?", deltaYuan)).Error; e != nil { + return e + } + pool.TotalBalance -= float64(deltaYuan) + appTx.Status = "approved" + appTx.ApproverId = actorId + appTx.ApproverName = actorName + appTx.DecidedAt = now + if e := tx.Save(&pool).Error; e != nil { + return e + } + return tx.Save(&appTx).Error + }) + + if txErr != nil { + switch txErr { + case ErrBudgetInsufficient: + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "预算池余额不足", "code": "budget_insufficient"}) + case ErrSelfApprove: + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "禁止自审批", "code": "self_approve_forbidden"}) + case ErrAppNotFoundOrHandled: + c.JSON(http.StatusConflict, gin.H{"success": false, "message": "申请单不存在或已处理"}) + default: + common.SysLog("ApproveQuota tx failed: " + txErr.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "审批失败"}) + } + return + } + + // 审计(事务外,不阻塞) + detail := "批准额度申请 " + strconv.FormatFloat(app.Amount, 'f', 2, 64) + " 元" + if req.Decision == "reject" { + detail = "拒绝额度申请" + } + model.WriteAuditLog(actorId, actorName, "quota_approve", + "quota_application", strconv.FormatInt(app.Id, 10), detail, c.ClientIP()) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{"application_id": app.Id, "status": req.Decision}, + }) +} diff --git a/controller/user.go b/controller/user.go index 6316fd13121a..de90a9aef708 100644 --- a/controller/user.go +++ b/controller/user.go @@ -149,6 +149,9 @@ func setupLogin(user *model.User, c *gin.Context) { session.Set("role", user.Role) session.Set("status", user.Status) session.Set("group", user.Group) + // v1 治理:写入治理角色层级与部门,使 authHelper 无需每请求回源 DB 即可注入 context。 + session.Set("role_level", user.RoleLevel) + session.Set("department", user.Department) err := session.Save() if err != nil { common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed) diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100755 index 000000000000..b2335a37ff75 --- /dev/null +++ b/deploy/backup.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# New API(企业内部 AI 网关)MySQL 每日备份脚本 +# 配合 crontab 使用,例如每日 03:30: +# 30 3 * * * /path/to/deploy/backup.sh >> /var/log/newapi-backup.log 2>&1 +# +# 依赖:docker(通过容器执行 mysqldump,避免宿主机安装客户端)。 +# 凭据与库名取自同目录 .env(与 docker-compose.yml 一致)。 + +set -euo pipefail + +# ---- 配置(可被环境变量覆盖)---- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/.env}" +BACKUP_DIR="${BACKUP_DIR:-/var/backups/newapi}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" +MYSQL_CONTAINER="${MYSQL_CONTAINER:-newapi-mysql}" +MYSQL_DB="${MYSQL_DB:-new-api}" + +# ---- 读取凭据 ---- +if [[ -f "$ENV_FILE" ]]; then + # 仅提取 SQL_DSN 中的 user:pass@host:port/db 解析为独立变量 + SQL_DSN="$(grep -E '^SQL_DSN=' "$ENV_FILE" | tail -n1 | cut -d= -f2-)" +fi +# 兜底默认值(与 docker-compose.yml 默认一致) +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_PASS="${MYSQL_PASS:-${SQL_DSN:+$(echo "$SQL_DSN" | sed -E 's#.*://([^:]+):([^@]+)@.*#\1#')}}" +MYSQL_PASS="${MYSQL_PASS:-123456}" + +mkdir -p "$BACKUP_DIR" + +TS="$(date +%Y%m%d-%H%M%S)" +OUT="$BACKUP_DIR/newapi-$TS.sql.gz" + +echo "[$(date '+%F %T')] 开始备份 -> $OUT" +docker exec "$MYSQL_CONTAINER" \ + mysqldump -u"$MYSQL_USER" -p"$MYSQL_PASS" --single-transaction --routines --triggers "$MYSQL_DB" \ + | gzip > "$OUT" + +if [[ -s "$OUT" ]]; then + echo "[$(date '+%F %T')] 备份成功,体积 $(du -h "$OUT" | cut -f1)" +else + echo "[$(date '+%F %T')] 错误:备份文件为空" >&2 + rm -f "$OUT" + exit 1 +fi + +# ---- 保留期清理 ---- +echo "[$(date '+%F %T')] 清理 $RETENTION_DAYS 天前的备份" +find "$BACKUP_DIR" -name 'newapi-*.sql.gz' -type f -mtime "+$RETENTION_DAYS" -delete + +echo "[$(date '+%F %T')] 完成。当前备份文件数:$(ls -1 "$BACKUP_DIR"/newapi-*.sql.gz 2>/dev/null | wc -l)" diff --git a/deploy/restore-drill.md b/deploy/restore-drill.md new file mode 100644 index 000000000000..a6c844d73819 --- /dev/null +++ b/deploy/restore-drill.md @@ -0,0 +1,58 @@ +# 备份恢复演练 SOP(New API 企业内部 AI 网关 v1) + +> 对应研发任务卡 **T7**。目标:确保每日备份**可恢复**、数据**完整**,上线前至少演练一次。 +> 恢复演练**必须**在隔离库(非生产)进行,避免污染/覆盖生产数据。 + +## 1. 前置 + +- 备份文件:`/var/backups/newapi/newapi-YYYYMMDD-HHMMSS.sql.gz`(由 `backup.sh` 产出)。 +- 一个**隔离**的 MySQL 实例(可用临时容器 `newapi-mysql-drill`,或独立测试库)。 +- 与生产相同的字符集/版本(MySQL 8)。 + +## 2. 演练步骤 + +```bash +# 2.1 准备隔离库(临时容器,用独立卷,演练完即删) +docker run -d --name newapi-mysql-drill \ + -e MYSQL_ROOT_PASSWORD=drillpass \ + -e MYSQL_DATABASE=new-api \ + -p 13306:3306 mysql:8 + +# 2.2 等就绪 +docker exec newapi-mysql-drill sh -c 'until mysqladmin ping -pdrillpass --silent; do sleep 2; done' + +# 2.3 选一个备份还原(解压后导入隔离库) +BACKUP=/var/backups/newapi/newapi-20250715-030000.sql.gz +gunzip -c "$BACKUP" | docker exec -i newapi-mysql-drill \ + mysql -pdrillpass new-api + +# 2.4 校验关键表与行数(与生产侧记录对比) +docker exec newapi-mysql-drill mysql -pdrillpass new-api -e \ + "SELECT 'user', COUNT(*) FROM user + UNION ALL SELECT 'token', COUNT(*) FROM token + UNION ALL SELECT 'channel', COUNT(*) FROM channel + UNION ALL SELECT 'budget_pool', COUNT(*) FROM budget_pool + UNION ALL SELECT 'quota_application', COUNT(*) FROM quota_application + UNION ALL SELECT 'audit_log', COUNT(*) FROM audit_log;" +``` + +## 3. 校验清单 + +- [ ] 导入无报错(无 `ERROR` / `errno`)。 +- [ ] 上述各表行数与备份时生产侧记录**一致**(偏差需在误差范围,并解释)。 +- [ ] `budget_pool` 总额(`total_balance`)与备份时点一致(元)。 +- [ ] 抽样用户 `quota` 字段值与备份时点一致。 +- [ ] 任意抽样一条 `audit_log` / `logs` 可正常 `SELECT` 且字段完整。 + +## 4. 收尾 + +```bash +# 清理隔离容器与卷,避免遗留 +docker rm -f newapi-mysql-drill +``` + +## 5. 排期与待决 + +- **频率**:建议每季度至少一次正式演练;CI/变更大版本前加做一次(待运维/SRE 与安全确认)。 +- **保留期**:备份保留天数默认 30 天(见 `backup.sh` 的 `RETENTION_DAYS`),最终值待安全/合规负责人拍板。 +- **恢复时间目标(RTO)/ 恢复点目标(RPO)**:基于每日 03:30 备份,RPO≈1 天;RTO 取决于库体量与导入速度,需在演练中实测记录。 diff --git a/docker-compose.yml b/docker-compose.yml index f5881f4a24cc..6dd565674ecd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,13 +26,15 @@ services: - ./data:/data - ./logs:/app/logs environment: - - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production! + - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # v1 治理使用 MySQL(对应 PRD 4.1) # - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL # - LOG_SQL_DSN=postgresql://root:123456@postgres:5432/new-api-log # OPTIONAL: If you want a separate database for logging, uncomment and set this # - LOG_SQL_DSN=clickhouse://default:123456@clickhouse:9000/new_api_logs # OPTIONAL: Use ClickHouse for logs only; also uncomment clickhouse in depends_on and the clickhouse service below # - LOG_SQL_CLICKHOUSE_TTL_DAYS=0 # OPTIONAL: ClickHouse log retention days. Unset or 0 disables automatic deletion; set to e.g. 30 to keep 30 days - REDIS_CONN_STRING=redis://:123456@redis:6379 # ⚠️ IMPORTANT: Change the password in production! - TZ=Asia/Shanghai + - LOG_CONTENT_ENABLED=false # v1 治理:不存储请求/响应正文(审计仅记元数据) + - INITIAL_POOL_BALANCE=1000 # 初始预算池(元),来源待定,可用 env 覆盖 - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording) - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update) - NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers) @@ -48,8 +50,8 @@ services: depends_on: - redis - - postgres -# - mysql # Uncomment if using MySQL + - mysql +# - postgres # v1 使用 MySQL,已弃用 postgres # - clickhouse # Uncomment if using ClickHouse for LOG_SQL_DSN networks: - new-api-network @@ -82,17 +84,22 @@ services: # ports: # - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker -# mysql: -# image: mysql:8.2 -# container_name: mysql -# restart: always -# environment: -# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! -# MYSQL_DATABASE: new-api -# volumes: -# - mysql_data:/var/lib/mysql -# networks: -# - new-api-network + mysql: + image: mysql:8 + container_name: mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! + MYSQL_DATABASE: new-api + volumes: + - mysql_data:/var/lib/mysql + networks: + - new-api-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p123456"] + interval: 10s + timeout: 5s + retries: 10 # ports: # - "3306:3306" # Uncomment if you need to access MySQL from outside Docker @@ -114,8 +121,8 @@ services: # - "9000:9000" # Native interface used by the LOG_SQL_DSN example above volumes: - pg_data: -# mysql_data: +# pg_data: + mysql_data: # clickhouse_data: networks: diff --git a/middleware/auth.go b/middleware/auth.go index 86abddc79945..91f9a81fbd23 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -41,6 +41,8 @@ func authHelper(c *gin.Context, minRole int) { id := session.Get("id") status := session.Get("status") useAccessToken := false + var user *model.User // token 路径下回退读取 RoleLevel/Department 用 + var authErr error if username == nil { // Check access token accessToken := c.Request.Header.Get("Authorization") @@ -52,7 +54,7 @@ func authHelper(c *gin.Context, minRole int) { c.Abort() return } - user, authErr := model.ValidateAccessToken(accessToken) + user, authErr = model.ValidateAccessToken(accessToken) if authErr != nil { if errors.Is(authErr, model.ErrDatabase) { common.SysLog("ValidateAccessToken database error: " + authErr.Error()) @@ -154,6 +156,24 @@ func authHelper(c *gin.Context, minRole int) { c.Set("user_group", session.Get("group")) c.Set("use_access_token", useAccessToken) + // 治理角色层级与部门(T3):注入 context 供 RequireRole / 部门范围限制使用。 + // 优先从 session 读取(登录时已写入,零额外 DB 开销); + // 走 access token 的路径无 session,则回退到令牌对应用户(治理端点低频,可接受)。 + if rl := session.Get("role_level"); rl != nil { + c.Set("role_level", rl) + } else if useAccessToken && user != nil { + c.Set("role_level", user.RoleLevel) + } else { + c.Set("role_level", 0) + } + if dept := session.Get("department"); dept != nil { + c.Set("department", dept) + } else if useAccessToken && user != nil { + c.Set("department", user.Department) + } else { + c.Set("department", "") + } + // 管理/root 写操作审计兜底:内聚在鉴权链路里,保证任何经过 AdminAuth/RootAuth // 的写接口都会自动留痕(无需在路由上单独挂审计中间件,避免漏挂)。 // handler 内手动埋点者会设置 ContextKeyAuditLogged,finishAdminAudit 据此跳过。 diff --git a/middleware/governance.go b/middleware/governance.go new file mode 100644 index 000000000000..4522cb539f8d --- /dev/null +++ b/middleware/governance.go @@ -0,0 +1,73 @@ +package middleware + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +// RequireRole 校验当前登录用户的 RoleLevel(见 model.RoleLevel*)是否属于给定集合, +// 否则返回 403。用于治理端点(区别于原生 AdminAuth/RootAuth 的 admin/common 角色)。 +// +// 前置条件:调用方必须先经过 authHelper(UserAuth/AdminAuth/GovernanceAuth 等)填充 +// context 中的 role_level 与 department,否则读到默认值 0(普通用户)而被拒。 +func RequireRole(levels ...int) gin.HandlerFunc { + return func(c *gin.Context) { + level := c.GetInt("role_level") + ok := false + for _, l := range levels { + if l == level { + ok = true + break + } + } + if !ok { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege), + }) + c.Abort() + return + } + c.Next() + } +} + +// GovernanceAuth 治理域鉴权:先完成会话/令牌鉴权(填充 id/role_level/department), +// 再要求 RoleLevel 为部门管理员或超级管理员(普通用户被拒 403)。 +// 适用于预算池、额度审批等治理端点。 +func GovernanceAuth() gin.HandlerFunc { + return func(c *gin.Context) { + authHelper(c, common.RoleCommonUser) // 至少需登录用户 + if c.IsAborted() { + return + } + RequireRole(model.RoleLevelDeptAdmin, model.RoleLevelSuperAdmin)(c) + } +} + +// IsDeptAdmin 当前用户是否为部门管理员(RoleLevel=10)。 +func IsDeptAdmin(c *gin.Context) bool { + return c.GetInt("role_level") == model.RoleLevelDeptAdmin +} + +// IsSuperAdmin 当前用户是否为超级管理员(RoleLevel=100)。 +func IsSuperAdmin(c *gin.Context) bool { + return c.GetInt("role_level") == model.RoleLevelSuperAdmin +} + +// SuperAdminAuth 超管鉴权:先完成会话/令牌鉴权,再要求 RoleLevel=超级管理员。 +// 用于审计检索等仅超管可见的治理端点。 +func SuperAdminAuth() gin.HandlerFunc { + return func(c *gin.Context) { + authHelper(c, common.RoleCommonUser) + if c.IsAborted() { + return + } + RequireRole(model.RoleLevelSuperAdmin)(c) + } +} diff --git a/model/audit_log.go b/model/audit_log.go new file mode 100644 index 000000000000..5bf8847c307b --- /dev/null +++ b/model/audit_log.go @@ -0,0 +1,108 @@ +package model + +import ( + "fmt" + + "github.com/QuantumNous/new-api/common" + + "github.com/bytedance/gopkg/util/gopool" +) + +// AuditLog 管理操作审计(登录/建删令牌/改配置/提交审批/审批等)。 +// 异步 goroutine 写入,失败重试+告警,不阻塞业务(RT3,详见研发任务卡 T6)。 +// 注意:仅记元数据,不存请求/响应正文(LOG_CONTENT_ENABLED=false)。 +// +// 与 New API 原生 model.RecordOperationAuditLog(中间件兜底审计)并行存在: +// 本表聚焦治理域审计(quota_apply/quota_approve 等),由业务 handler 在关键节点手动埋点, +// 避免依赖中间件兜底导致治理动作漏记。 +type AuditLog struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + ActorId int `json:"actor_id" gorm:"index"` + ActorName string `json:"actor_name" gorm:"type:varchar(64)"` + Action string `json:"action" gorm:"type:varchar(64);index"` // login/token_create/quota_apply/quota_approve/... + TargetType string `json:"target_type" gorm:"type:varchar(32)"` + TargetId string `json:"target_id" gorm:"type:varchar(64);index"` + Detail string `json:"detail" gorm:"type:text"` + Ip string `json:"ip" gorm:"type:varchar(64)"` + Ts int64 `json:"ts" gorm:"autoCreateTime;column:ts;index"` +} + +func (AuditLog) TableName() string { return "audit_log" } + +// auditWriteMaxRetry 审计异步写入最大重试次数(RT3:失败重试 + 告警)。 +const auditWriteMaxRetry = 3 + +// WriteAuditLog 异步写入一条审计记录(治理域手动埋点用)。 +// 通过 gopool 异步落库并对瞬时故障重试,保证审计写入永不阻塞业务主流程(RT3,详见研发任务卡 T6)。 +// 最终失败仅记告警日志、不返回错误,不影响调用方/操作的结果返回。 +func WriteAuditLog(actorId int, actorName, action, targetType, targetId, detail, ip string) { + rec := AuditLog{ + ActorId: actorId, + ActorName: actorName, + Action: action, + TargetType: targetType, + TargetId: targetId, + Detail: detail, + Ip: ip, + } + gopool.Go(func() { + var lastErr error + for attempt := 0; attempt < auditWriteMaxRetry; attempt++ { + if err := DB.Create(&rec).Error; err != nil { + lastErr = err + common.SysLog(fmt.Sprintf("WriteAuditLog retry %d/%d failed: %v", attempt+1, auditWriteMaxRetry, err)) + continue + } + return + } + // 重试耗尽:触发告警,但绝不回抛影响业务。 + common.SysLog("WriteAuditLog ALERT: final failure after retry, audit lost: " + lastErr.Error()) + }) +} + +// AuditLogQuery 审计检索条件(对应 T8 检索页 GET /api/audit)。 +type AuditLogQuery struct { + ActorId int + ActorName string + Action string + TargetType string + From int64 // 起始时间戳(含) + To int64 // 结束时间戳(含) + Keyword string // 模糊匹配 detail + StartIdx int + PageSize int +} + +// SearchAuditLogs 按条件分页检索审计日志(audit_log 表)。 +// from/to 为 unix 秒;keyword 模糊匹配 detail。返回本页数据与总数。 +func SearchAuditLogs(q AuditLogQuery) (logs []*AuditLog, total int64, err error) { + tx := DB.Model(&AuditLog{}) + if q.ActorId > 0 { + tx = tx.Where("actor_id = ?", q.ActorId) + } + if q.ActorName != "" { + tx = tx.Where("actor_name LIKE ?", "%"+q.ActorName+"%") + } + if q.Action != "" { + tx = tx.Where("action = ?", q.Action) + } + if q.TargetType != "" { + tx = tx.Where("target_type = ?", q.TargetType) + } + if q.From > 0 { + tx = tx.Where("ts >= ?", q.From) + } + if q.To > 0 { + tx = tx.Where("ts <= ?", q.To) + } + if q.Keyword != "" { + tx = tx.Where("detail LIKE ?", "%"+q.Keyword+"%") + } + if err = tx.Count(&total).Error; err != nil { + return nil, 0, err + } + if err = tx.Order("ts DESC").Offset(q.StartIdx).Limit(q.PageSize).Find(&logs).Error; err != nil { + return nil, 0, err + } + return logs, total, nil +} diff --git a/model/budget_pool.go b/model/budget_pool.go new file mode 100644 index 000000000000..c0affa3c2974 --- /dev/null +++ b/model/budget_pool.go @@ -0,0 +1,37 @@ +package model + +import ( + "os" + "strconv" +) + +// BudgetPool 总预算池(单行,id 固定=1),所有个人余额拨付均来源于此池。 +// 金额单位:元(v1 货币单位已定元,toQuotaUnit 退化为 1:1)。 +type BudgetPool struct { + Id int `json:"id" gorm:"primaryKey"` + TotalBalance float64 `json:"total_balance" gorm:"type:decimal(18,2);not null;default:0"` // 单位:元 + Currency string `json:"currency" gorm:"type:varchar(8);not null;default:'CNY'"` + UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime;column:updated_at"` +} + +func (BudgetPool) TableName() string { return "budget_pool" } + +// SeedBudgetPoolIfEmpty 首启按环境变量 INITIAL_POOL_BALANCE 注入 id=1 的预算池行。 +// 初始金额来源待定(env 注入 / 种子 / 手动充值),默认 0。 +func SeedBudgetPoolIfEmpty() error { + var cnt int64 + if err := DB.Model(&BudgetPool{}).Where("id = 1").Count(&cnt).Error; err != nil { + return err + } + if cnt > 0 { + return nil + } + balance := 0.0 + if v := os.Getenv("INITIAL_POOL_BALANCE"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil { + balance = f + } + } + pool := BudgetPool{Id: 1, TotalBalance: balance, Currency: "CNY"} + return DB.Create(&pool).Error +} diff --git a/model/log.go b/model/log.go index 506bd504b686..611dc34e210d 100644 --- a/model/log.go +++ b/model/log.go @@ -78,6 +78,10 @@ type Log struct { RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"` UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` Other string `json:"other"` + // v1 治理审计扩展(T6):调用元数据补充字段,relay 埋点为后续工作(详见研发任务卡 T6)。 + // 仅新增列、不改既有写入路径,存量 Log 记录对应列为默认值(NULL / 空串)。 + HitWhitelist *bool `json:"hit_whitelist,omitempty" gorm:"type:tinyint(1)"` // 是否命中模型白名单(白名单拒绝路径=false) + Department string `json:"department,omitempty" gorm:"type:varchar(64);default:''"` // 调用者部门标签,P1 报表聚合预留 } // don't use iota, avoid change log type value diff --git a/model/main.go b/model/main.go index 76f98a59c307..7a50384f91d1 100644 --- a/model/main.go +++ b/model/main.go @@ -212,7 +212,12 @@ func InitDB() (err error) { } common.SysLog("database migration started") err = migrateDB() - return err + if err != nil { + return err + } + if err = SeedBudgetPoolIfEmpty(); err != nil { + return err + } } else { common.FatalLog(err) } @@ -299,6 +304,9 @@ func migrateDB() error { &SystemTaskLock{}, &CasbinRule{}, &AuthzRole{}, + &BudgetPool{}, + &QuotaApplication{}, + &AuditLog{}, ) if err != nil { return err @@ -351,6 +359,9 @@ func migrateDBFast() error { {&SystemInstance{}, "SystemInstance"}, {&SystemTask{}, "SystemTask"}, {&SystemTaskLock{}, "SystemTaskLock"}, + {&BudgetPool{}, "BudgetPool"}, + {&QuotaApplication{}, "QuotaApplication"}, + {&AuditLog{}, "AuditLog"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/quota_application.go b/model/quota_application.go new file mode 100644 index 000000000000..a9cb851b6bbd --- /dev/null +++ b/model/quota_application.go @@ -0,0 +1,30 @@ +package model + +// QuotaApplication 额度申请单:用户/部门管理员提交,审批人(超管/财务/本部门部门管理员)批准后 +// 从总预算池扣减并拨付至申请人个人余额(详见研发任务卡 T5 事务路径)。 +type QuotaApplication struct { + Id int64 `json:"id" gorm:"primaryKey;autoIncrement"` + ApplicantId int `json:"applicant_id" gorm:"index"` + ApplicantName string `json:"applicant_name" gorm:"type:varchar(64)"` + Dept string `json:"dept" gorm:"type:varchar(64)"` + Amount float64 `json:"amount" gorm:"type:decimal(18,2);not null"` // 单位:元 + Reason string `json:"reason" gorm:"type:varchar(512)"` + Status string `json:"status" gorm:"type:varchar(16);not null;default:'pending'"` // pending/approved/rejected + ApproverId int `json:"approver_id"` + ApproverName string `json:"approver_name" gorm:"type:varchar(64)"` + CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"` + DecidedAt int64 `json:"decided_at" gorm:"column:decided_at"` + RejectReason string `json:"reject_reason" gorm:"type:varchar(512)"` +} + +func (QuotaApplication) TableName() string { return "quota_application" } + +// GetQuotaApplicationById 按主键查询申请单;未找到返回 gorm.ErrRecordNotFound。 +func GetQuotaApplicationById(id int64) (*QuotaApplication, error) { + var app QuotaApplication + err := DB.Where("id = ?", id).First(&app).Error + if err != nil { + return nil, err + } + return &app, nil +} diff --git a/model/role_level.go b/model/role_level.go new file mode 100644 index 000000000000..ae92d300a723 --- /dev/null +++ b/model/role_level.go @@ -0,0 +1,12 @@ +package model + +// v1 治理三角色层级(对应 User.RoleLevel 字段)。 +// 与 New API 原生 common.Role(admin/common 的 1/10/100)语义独立、共存: +// 原生 Role 控制「是否管理员」,本 RoleLevel 控制「治理域角色(超管/部门管理员/普通用户)」。 +// 部门管理员在原生 Role 上通常仍是普通用户(common),仅通过 RoleLevel 获得治理权限, +// 并由 Department 字段限制其仅可操作本部门数据(详见研发任务卡 T3)。 +const ( + RoleLevelUser = 0 // 普通用户:可提交额度申请 + RoleLevelDeptAdmin = 10 // 部门管理员:可审批本部门申请、查看本部门数据 + RoleLevelSuperAdmin = 100 // 超级管理员:全部治理权限(含角色/部门变更) +) diff --git a/model/user.go b/model/user.go index 03eb589ede80..715c875aa35a 100644 --- a/model/user.go +++ b/model/user.go @@ -27,6 +27,8 @@ type User struct { OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database! DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` Role int `json:"role" gorm:"type:int;default:1"` // admin, common + RoleLevel int `json:"role_level" gorm:"type:int;default:0"` // 0=普通用户 10=部门管理员 100=超级管理员(v1 治理扩展) + Department string `json:"department" gorm:"type:varchar(64);default:''"` // 部门标签(v1 仅用于报表聚合,无硬隔离) Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled Email string `json:"email" gorm:"index" validate:"max=50"` GitHubId string `json:"github_id" gorm:"column:github_id;index"` diff --git a/model/whitelist.go b/model/whitelist.go new file mode 100644 index 000000000000..ef78123ec3b1 --- /dev/null +++ b/model/whitelist.go @@ -0,0 +1,20 @@ +package model + +// GroupAllowsModel 判断某分组(User.Group)是否可使用指定模型。 +// +// 原生白名单机制:New API 按「分组」通过 abilities 表控制可用模型—— +// 为某 group 启用 ability 的模型即该 group 可用(见 GetGroupEnabledModels)。 +// 因此 v1 的「模型白名单」可直接复用分组能力配置,无需新增独立白名单表。 +// +// 本函数仅做判定,不触发 DB 写;调用方可据此拒绝未授权模型且不扣费(详见研发任务卡 T4)。 +func GroupAllowsModel(group, modelName string) bool { + if group == "" || modelName == "" { + return false + } + for _, m := range GetGroupEnabledModels(group) { + if m == modelName { + return true + } + } + return false +} diff --git a/router/api-router.go b/router/api-router.go index 83f9259b2132..9ea08565c148 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -299,6 +299,16 @@ func SetApiRouter(router *gin.Engine) { dataRoute.GET("/flow", middleware.AdminAuth(), controller.GetAllFlowQuotaDates) dataRoute.GET("/flow/self", middleware.UserAuth(), controller.GetUserFlowQuotaDates) + // v1 治理:预算池与额度审批 + apiRouter.POST("/quota/apply", middleware.UserAuth(), controller.ApplyQuota) + quotaRoute := apiRouter.Group("/quota") + quotaRoute.Use(middleware.GovernanceAuth()) + { + quotaRoute.POST("/approve", controller.ApproveQuota) + } + // v1 治理:审计检索(仅超管可见,详见研发任务卡 T8) + apiRouter.GET("/audit", middleware.SuperAdminAuth(), controller.GetAuditLogs) + logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit()) { logRoute.GET("/token", middleware.TokenAuthReadOnly(), controller.GetLogByKey) diff --git a/web/classic/src/App.jsx b/web/classic/src/App.jsx index 0dccb50539c7..65edb70238be 100644 --- a/web/classic/src/App.jsx +++ b/web/classic/src/App.jsx @@ -36,6 +36,7 @@ import Token from './pages/Token'; import Redemption from './pages/Redemption'; import TopUp from './pages/TopUp'; import Log from './pages/Log'; +import AuditLog from './pages/AuditLog'; import Chat from './pages/Chat'; import Chat2Link from './pages/Chat2Link'; import MjProxy from './pages/Midjourney'; @@ -285,6 +286,14 @@ function App() { } /> + + + + } + /> {} }) => { itemKey: 'log', to: '/log', }, + { + text: t('审计日志'), + itemKey: 'audit', + to: '/audit', + }, { text: t('绘图日志'), itemKey: 'midjourney', diff --git a/web/classic/src/pages/AuditLog/index.jsx b/web/classic/src/pages/AuditLog/index.jsx new file mode 100644 index 000000000000..6f474afe7217 --- /dev/null +++ b/web/classic/src/pages/AuditLog/index.jsx @@ -0,0 +1,186 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. +*/ + +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { API, showError, timestamp2string } from '../../helpers'; +import { Input, Button, Space } from '@douyinfe/semi-ui'; + +// 审计日志检索页(T8:仅超管可见,后端 GET /api/audit 已落地)。 +// 过滤维度:操作人 / 动作(action) / 关键词(详情) / 时间区间(unix 秒)。 +const AuditLogPage = () => { + const { t } = useTranslation(); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const [actorName, setActorName] = useState(''); + const [action, setAction] = useState(''); + const [keyword, setKeyword] = useState(''); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + + const load = useCallback(async () => { + try { + const params = new URLSearchParams(); + // 关键:p 传「页码」(1-based),由后端 common.GetPageQuery 内部换算 offset。 + // 与既有页面(/api/log、/api/user)保持一致,切勿传 (page-1)*pageSize 的 offset, + // 否则第 2 页起会二次偏移导致取错数据。 + params.set('p', String(page)); + params.set('page_size', String(pageSize)); + if (actorName) params.set('actor_name', actorName); + if (action) params.set('action', action); + if (keyword) params.set('keyword', keyword); + if (from) params.set('from', from); + if (to) params.set('to', to); + const res = await API.get(`/api/audit?${params.toString()}`); + // 后端 common.ApiSuccess 包裹为 { success, message, data:{ page, page_size, total, items } } + const { success, message, data } = res.data; + if (!success) { + showError(message || t('加载审计日志失败')); + return; + } + setItems(data.items || []); + setTotal(data.total || 0); + } catch (e) { + showError(e.message || t('加载审计日志失败')); + } + }, [page, pageSize, actorName, action, keyword, from, to, t]); + + useEffect(() => { + load(); + }, [load]); + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + return ( +
+

{t('审计日志')}

+ + + setActorName(v)} + showClear + /> + setAction(v)} + showClear + /> + setKeyword(v)} + showClear + /> + setFrom(v)} + /> + setTo(v)} + /> + + + + + + + + + + + + + + + + + {items.map((log) => ( + + + + + + + + + + ))} + {items.length === 0 && ( + + + + )} + +
{t('时间')}{t('操作人')}{t('动作')}{t('目标类型')}{t('目标ID')}{t('详情')}{t('IP')}
+ {timestamp2string(log.ts)} + {log.actor_name}{log.action}{log.target_type}{log.target_id}{log.detail}{log.ip}
+ {t('暂无数据')} +
+ +
+ + {t('共')} {total} {t('条')} + + + + + {page} / {totalPages} + + + +
+
+ ); +}; + +export default AuditLogPage;