-
Notifications
You must be signed in to change notification settings - Fork 11.3k
Feature/v1 governance #6236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature/v1 governance #6236
Changes from all commits
e51c726
dacfa8c
e34b9f7
1404c25
b29a776
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+49
to
+62
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Do not let ordinary applicants choose their department.
Proposed fix- dept := req.Dept
- if dept == "" {
- dept = c.GetString("department")
- }
+ dept := c.GetString("department")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+129
to
+171
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Map the relevant file first, then inspect the transaction block and related models.
ast-grep outline controller/quota.go --view expanded || true
echo
echo '--- controller/quota.go (around the cited lines) ---'
sed -n '100,220p' controller/quota.go
echo
echo '--- search for quota application / status transitions ---'
rg -n "QuotaApplication|pending|approved|rejected|RejectReason|BudgetPool|quota \+" controller -SRepository: QuantumNous/new-api Length of output: 10705 Lock the quota application row before checking 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+197
to
+200
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Return the persisted status values. The API currently returns Proposed fix+ status := "approved"
+ if req.Decision == "reject" {
+ status = "rejected"
+ }
c.JSON(http.StatusOK, gin.H{
"success": true,
- "data": gin.H{"application_id": app.Id, "status": req.Decision},
+ "data": gin.H{"application_id": app.Id, "status": status},
})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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#')}}" | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win Fix password extraction from the MySQL DSN. The regular expression Use 🐛 Proposed fix for the regex-MYSQL_PASS="${MYSQL_PASS:-${SQL_DSN:+$(echo "$SQL_DSN" | sed -E 's#.*://([^:]+):([^@]+)@.*#\1#')}}"
+MYSQL_PASS="${MYSQL_PASS:-${SQL_DSN:+$(echo "$SQL_DSN" | sed -nE 's#^([^:]+):([^@]+)@.*#\2#p')}}"📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| 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" | ||||||||||||||
|
Comment on lines
+36
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Avoid credential exposure in process lists. Passing the password via the 🛡️ Proposed fix to secure the password-docker exec "$MYSQL_CONTAINER" \
- mysqldump -u"$MYSQL_USER" -p"$MYSQL_PASS" --single-transaction --routines --triggers "$MYSQL_DB" \
+docker exec -e MYSQL_PWD="$MYSQL_PASS" "$MYSQL_CONTAINER" \
+ mysqldump -u"$MYSQL_USER" --single-transaction --routines --triggers "$MYSQL_DB" \
| gzip > "$OUT"📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| 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)" | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 取决于库体量与导入速度,需在演练中实测记录。 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Reject fractional amounts instead of silently rounding them.
An application for
0.49yuan is approved for zero, while1.50yuan credits and deducts two yuan. This makes the recorded amount and actual allocation disagree.Proposed fix for whole-yuan v1 accounting
Also applies to: 153-163
🤖 Prompt for AI Agents