fix: 记账可靠性——关停排空队列、失败重试、扣费不再静默;余额改 SQL 原子递减

- Recorder.Close 改为停止接收+排空残留,channel 不再 close(杜绝 send-on-closed panic),Close 后的 Record 走同步落库
- flush 瞬时 DB 错误重试 3 次,不再整批丢弃;事务内扣费/流水/日聚合失败一律打日志,明细与扣费不再静默脱节
- 扣费与管理员调余额改 SQL 侧原子加减(SQLite 驱动不支持 FOR UPDATE,读-改-写并发会丢扣费);调余额 ref_id 改纳秒不再撞唯一索引
- 最后一个启用的管理员不可被降级/禁用(管理面锁死后 reset-admin-password 救不回)
This commit is contained in:
Sakurasan committed 2026-10-29 02:56:31 +08:00
1 parent 7c597a6aa0
commit 984a31cbc5
3 files changed
+292 -65

No files matched your search

+33 -4
View File
@@ -115,6 +115,34 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
}
updates["status"] = *req.Status
}
// 最后一个启用的管理员不可被降级/禁用:否则管理面被锁死,
// 且 reset-admin-password CLI 只在 admin 角色里找目标,救不回来。
if req.Role != nil || req.Status != nil {
var target store.User
if err := h.a.DB.First(&target, id).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "user not found")
return
}
if target.Role == store.RoleAdmin && target.Status == store.UserStatusActive {
newRole, newStatus := target.Role, target.Status
if req.Role != nil {
newRole = *req.Role
}
if req.Status != nil {
newStatus = *req.Status
}
if newRole != store.RoleAdmin || newStatus != store.UserStatusActive {
var admins int64
h.a.DB.Model(&store.User{}).
Where("role = ? AND status = ? AND id != ?", store.RoleAdmin, store.UserStatusActive, id).
Count(&admins)
if admins == 0 {
resp.Fail(c, http.StatusBadRequest, "cannot demote or disable the last active admin")
return
}
}
}
}
// 模型限制(jsonb):手动序列化
if req.AllowedModels != nil {
raw, _ := json.Marshal(*req.AllowedModels)
@@ -166,16 +194,17 @@ func (h *Handler) AdminAdjustBalance(c *gin.Context) {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, id).Error; err != nil {
return err
}
newBalance := u.Balance + req.Amount
if err := tx.Model(&store.User{}).Where("id = ?", id).Update("balance", newBalance).Error; err != nil {
// SQL 侧原子加减:SQLite 驱动不支持行锁,Go 侧读-改-写会与记账扣费互相覆盖
if err := tx.Model(&store.User{}).Where("id = ?", id).Update("balance", gorm.Expr("balance + ?", req.Amount)).Error; err != nil {
return err
}
ref := "admin:" + strconv.FormatUint(u.ID, 10) + ":" + time.Now().Format("20060102150405")
// ref_id 有单列唯一索引,秒级时间戳同一秒第二次调整必撞;纳秒粒度避免
ref := "admin:" + strconv.FormatUint(u.ID, 10) + ":" + strconv.FormatInt(time.Now().UnixNano(), 10)
_ = admin.ID // 流水里不冗余管理员 ID;需要时再加
return tx.Create(&store.BalanceLog{
UserID: u.ID,
Change: req.Amount,
BalanceAfter: newBalance,
BalanceAfter: u.Balance + req.Amount,
Type: store.BalanceTypeAdminAdjust,
RefID: ref,
Remark: req.Remark,