Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions controller/audit_governance.go
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
}
201 changes: 201 additions & 0 deletions controller/quota.go
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
}
Comment on lines +42 to +45

Copy link
Copy Markdown
Contributor

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.49 yuan is approved for zero, while 1.50 yuan credits and deducts two yuan. This makes the recorded amount and actual allocation disagree.

Proposed fix for whole-yuan v1 accounting
-	if req.Amount <= 0 {
-		c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "金额必须大于 0"})
+	if req.Amount <= 0 || math.Trunc(req.Amount) != req.Amount {
+		c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "金额必须为正整数元"})
 		return
 	}

Also applies to: 153-163

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/quota.go` around lines 42 - 45, Update the amount validation in
the quota request handler around the req.Amount check to reject fractional yuan
values before any allocation or rounding occurs. Accept only positive whole-yuan
amounts, preserving the existing bad-request response for invalid values, and
apply the same validation to the additional amount-processing path referenced by
the comment.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

req.Dept overrides the authenticated department, allowing users to place requests in another department’s approval scope. Derive it from the session; use a separately authorized on-behalf flow if overrides are required.

Proposed fix
-	dept := req.Dept
-	if dept == "" {
-		dept = c.GetString("department")
-	}
+	dept := c.GetString("department")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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(),
}
dept := c.GetString("department")
app := model.QuotaApplication{
ApplicantId: actorId,
ApplicantName: actorName,
Dept: dept,
Amount: req.Amount,
Reason: req.Reason,
Status: "pending",
CreatedAt: time.Now().Unix(),
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/quota.go` around lines 49 - 62, Update the department assignment
in the quota application creation flow to always derive the department from the
authenticated session via c.GetString("department"), ignoring req.Dept for
ordinary applicants. Preserve the existing ApplicantId, ApplicantName, and other
application fields, and do not add an override path here.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 -S

Repository: QuantumNous/new-api

Length of output: 10705


Lock the quota application row before checking pending. Two concurrent approvals can both pass the initial read, then serialize only on the budget-pool lock. The second transaction resumes with a stale appTx and can apply the same request twice. Use FOR UPDATE on the application row here and in the reject path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/quota.go` around lines 129 - 171, The transaction’s initial
quota-application lookup must lock the application row before validating its
pending status. Update the appTx query in the transaction callback to use a FOR
UPDATE row lock, ensuring both approval and rejection paths serialize on the
application row and prevent duplicate processing.

})

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 approve or reject, although the application is stored as approved or rejected. This breaks clients expecting the model’s status contract.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{"application_id": app.Id, "status": req.Decision},
})
status := "approved"
if req.Decision == "reject" {
status = "rejected"
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{"application_id": app.Id, "status": status},
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/quota.go` around lines 197 - 200, Update the response in the quota
decision handler to return the application’s persisted status value (`approved`
or `rejected`) instead of `req.Decision` (`approve` or `reject`). Reuse the
status assigned when saving the application, while preserving the existing
success response structure and application_id.

}
3 changes: 3 additions & 0 deletions controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions deploy/backup.sh
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#')}}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 .*://([^:]+):([^@]+)@.* expects a :// scheme, which Go's MySQL DSN format (e.g., root:123456@tcp(...)) does not contain. Because sed -E fails to match, it outputs the entire unchanged DSN string, setting $MYSQL_PASS to the full DSN and breaking authentication. Furthermore, even if it did match, \1 extracts the username, not the password.

Use -nE alongside \2 to accurately extract the password and safely return an empty string (triggering your fallback) if no match occurs.

🐛 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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')}}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/backup.sh` at line 27, Update the MYSQL_PASS extraction fallback to
match Go MySQL DSNs such as root:password@tcp(...), use sed -nE so unmatched
input produces an empty result, and return the password capture group rather
than the username. Preserve the existing MYSQL_PASS precedence and SQL_DSN
fallback behavior.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 -p command-line argument will trigger a warning (mysqldump: [Warning] Using a password on the command line interface can be insecure.) in the backup logs and expose the credentials to process lists (ps) inside the container. Supplying it securely via the MYSQL_PWD environment variable prevents both issues.

🛡️ 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
docker exec "$MYSQL_CONTAINER" \
mysqldump -u"$MYSQL_USER" -p"$MYSQL_PASS" --single-transaction --routines --triggers "$MYSQL_DB" \
| gzip > "$OUT"
docker exec -e MYSQL_PWD="$MYSQL_PASS" "$MYSQL_CONTAINER" \
mysqldump -u"$MYSQL_USER" --single-transaction --routines --triggers "$MYSQL_DB" \
| gzip > "$OUT"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/backup.sh` around lines 36 - 38, Update the mysqldump invocation in
the backup command to remove the password from the -p command-line argument and
provide it through the MYSQL_PWD environment variable for the docker exec
process. Preserve the existing MYSQL_USER, MYSQL_DB, dump options, gzip
pipeline, and output behavior.


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)"
58 changes: 58 additions & 0 deletions deploy/restore-drill.md
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 取决于库体量与导入速度,需在演练中实测记录。
Loading