Version: 0.9.66.dev.260504
后端: 1. 阶段 2 user/auth 服务边界落地,新增 `cmd/userauth` go-zero zrpc 服务、`services/userauth` 核心实现、gateway user API/zrpc client 与 shared contracts/ports,迁移注册、登录、刷新 token、登出、JWT、黑名单和 token 额度治理 2. gateway 与启动装配切流,`cmd/all` 只保留边缘路由、鉴权和轻量组合,通过 userauth zrpc 访问核心用户能力;拆分 MySQL/Redis 初始化与 AutoMigrate 边界,`userauth` 自迁 `users` 和 token 记账幂等表,`all` 不再迁用户表 3. 清退 Gin 单体旧 user/auth DAO、model、service、router、middleware 和 JWT handler,并同步调整 agent/schedule/cache/outbox 相关调用依赖 4. 补齐 refresh token 防并发重放、MySQL 幂等 token 记账、额度 `>=` 拦截和 RPC 错误映射,避免重复记账与内部错误透出 文档: 1. 新增《学习计划论坛与Token商店PRD》
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type AgentDAO struct {
|
||||
@@ -39,7 +40,7 @@ func (r *AgentDAO) WithTx(tx *gorm.DB) *AgentDAO {
|
||||
// 1. retry 机制已整体下线,本函数不再写入 retry_group_id / retry_index / retry_from_* 四列;
|
||||
// 2. 这些列在 GORM ChatHistory 模型上暂时保留,列本身可空,历史数据不受影响;
|
||||
// 3. Step B 会做 DROP COLUMN 的 migration。
|
||||
func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int) error {
|
||||
func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int, sourceEventID string) error {
|
||||
// 0. token 入库前兜底:负数统一归零,避免异常值污染累计统计。
|
||||
if tokensConsumed < 0 {
|
||||
tokensConsumed = 0
|
||||
@@ -48,6 +49,23 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa
|
||||
if reasoningDurationSeconds < 0 {
|
||||
reasoningDurationSeconds = 0
|
||||
}
|
||||
normalizedEventID := strings.TrimSpace(sourceEventID)
|
||||
var normalizedEventIDPtr *string
|
||||
if normalizedEventID != "" {
|
||||
normalizedEventIDPtr = &normalizedEventID
|
||||
var chat model.AgentChat
|
||||
err := a.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("last_history_event_id").
|
||||
Where("user_id = ? AND chat_id = ?", userID, conversationID).
|
||||
First(&chat).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if chat.LastHistoryEventID != nil && strings.TrimSpace(*chat.LastHistoryEventID) == normalizedEventID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 先写 chat_histories 原始消息。
|
||||
var reasoningContentPtr *string
|
||||
@@ -55,6 +73,7 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa
|
||||
reasoningContentPtr = &reasoningContent
|
||||
}
|
||||
userChat := model.ChatHistory{
|
||||
SourceEventID: normalizedEventIDPtr,
|
||||
UserID: userID,
|
||||
MessageContent: &message,
|
||||
ReasoningContent: reasoningContentPtr,
|
||||
@@ -67,16 +86,16 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 再更新会话统计:
|
||||
// 2.1 message_count +1,保持和 chat_histories 行数口径一致;
|
||||
// 2.2 tokens_total 累加本条消息 token;
|
||||
// 2.3 last_message_at 刷新为当前时间,供会话排序使用。
|
||||
// 2. 再更新会话统计,保证 message_count / tokens_total / last_message_at 同步推进。
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"message_count": gorm.Expr("message_count + ?", 1),
|
||||
"tokens_total": gorm.Expr("tokens_total + ?", tokensConsumed),
|
||||
"last_message_at": &now,
|
||||
}
|
||||
if normalizedEventIDPtr != nil {
|
||||
updates["last_history_event_id"] = normalizedEventIDPtr
|
||||
}
|
||||
result := a.db.WithContext(ctx).Model(&model.AgentChat{}).
|
||||
Where("user_id = ? AND chat_id = ?", userID, conversationID).
|
||||
Updates(updates)
|
||||
@@ -84,26 +103,9 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
// 会话不存在时直接失败,避免出现"孤儿历史消息"。
|
||||
return fmt.Errorf("conversation not found when updating stats: user_id=%d chat_id=%s", userID, conversationID)
|
||||
}
|
||||
|
||||
// 3. 最后更新 users.token_usage(同一事务内):
|
||||
// 3.1 只在 tokensConsumed>0 时执行,避免无意义写入;
|
||||
// 3.2 和 chat_histories/agent_chats 放在同一事务里,保证统计口径原子一致;
|
||||
// 3.3 若用户行不存在则返回错误,触发事务回滚,防止出现"会话统计成功但用户统计丢失"。
|
||||
if tokensConsumed > 0 {
|
||||
userUpdate := a.db.WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Update("token_usage", gorm.Expr("token_usage + ?", tokensConsumed))
|
||||
if userUpdate.Error != nil {
|
||||
return userUpdate.Error
|
||||
}
|
||||
if userUpdate.RowsAffected == 0 {
|
||||
return fmt.Errorf("user not found when updating token usage: user_id=%d", userID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -112,8 +114,8 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa
|
||||
// 设计目的:
|
||||
// 1. 给服务层组合多个 DAO 操作时复用,避免嵌套事务;
|
||||
// 2. 让 outbox 消费处理器可以和业务写入共享同一个 tx。
|
||||
func (a *AgentDAO) SaveChatHistoryInTx(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int) error {
|
||||
return a.saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, tokensConsumed)
|
||||
func (a *AgentDAO) SaveChatHistoryInTx(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int, sourceEventID string) error {
|
||||
return a.saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, tokensConsumed, sourceEventID)
|
||||
}
|
||||
|
||||
// SaveChatHistory 在同步直写路径下写入聊天历史。
|
||||
@@ -121,28 +123,47 @@ func (a *AgentDAO) SaveChatHistoryInTx(ctx context.Context, userID int, conversa
|
||||
// 说明:
|
||||
// 1. 该方法会自行开启事务;
|
||||
// 2. 内部复用 saveChatHistoryCore,确保和 SaveChatHistoryInTx 的业务口径完全一致。
|
||||
func (a *AgentDAO) SaveChatHistory(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int) error {
|
||||
func (a *AgentDAO) SaveChatHistory(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int, sourceEventID string) error {
|
||||
return a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return a.WithTx(tx).saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, tokensConsumed)
|
||||
return a.WithTx(tx).saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, tokensConsumed, sourceEventID)
|
||||
})
|
||||
}
|
||||
|
||||
// adjustTokenUsageCore 在同一事务语义下做"会话/用户"token 账本增量调整。
|
||||
// adjustTokenUsageCore 在同一事务语义下做"会话"token 账本增量调整。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只更新 agent_chats.tokens_total 与 users.token_usage;
|
||||
// 1. 只更新 agent_chats.tokens_total;
|
||||
// 2. 不写 chat_histories(消息落库由 SaveChatHistory* 路径负责);
|
||||
// 3. deltaTokens<=0 时视为无操作,直接返回。
|
||||
func (a *AgentDAO) adjustTokenUsageCore(ctx context.Context, userID int, conversationID string, deltaTokens int) error {
|
||||
func (a *AgentDAO) adjustTokenUsageCore(ctx context.Context, userID int, conversationID string, deltaTokens int, eventID string) error {
|
||||
if deltaTokens <= 0 {
|
||||
return nil
|
||||
}
|
||||
normalizedEventID := strings.TrimSpace(eventID)
|
||||
var normalizedEventIDPtr *string
|
||||
if normalizedEventID != "" {
|
||||
normalizedEventIDPtr = &normalizedEventID
|
||||
var chat model.AgentChat
|
||||
err := a.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("last_token_adjust_event_id").
|
||||
Where("user_id = ? AND chat_id = ?", userID, conversationID).
|
||||
First(&chat).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if chat.LastTokenAdjustEventID != nil && strings.TrimSpace(*chat.LastTokenAdjustEventID) == normalizedEventID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 先更新会话累计 token。
|
||||
chatUpdate := a.db.WithContext(ctx).
|
||||
Model(&model.AgentChat{}).
|
||||
Where("user_id = ? AND chat_id = ?", userID, conversationID).
|
||||
Update("tokens_total", gorm.Expr("tokens_total + ?", deltaTokens))
|
||||
Updates(map[string]interface{}{
|
||||
"tokens_total": gorm.Expr("tokens_total + ?", deltaTokens),
|
||||
"last_token_adjust_event_id": normalizedEventIDPtr,
|
||||
})
|
||||
if chatUpdate.Error != nil {
|
||||
return chatUpdate.Error
|
||||
}
|
||||
@@ -150,32 +171,20 @@ func (a *AgentDAO) adjustTokenUsageCore(ctx context.Context, userID int, convers
|
||||
return fmt.Errorf("conversation not found when adjusting tokens: user_id=%d chat_id=%s", userID, conversationID)
|
||||
}
|
||||
|
||||
// 2. 再更新用户累计 token。
|
||||
userUpdate := a.db.WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Update("token_usage", gorm.Expr("token_usage + ?", deltaTokens))
|
||||
if userUpdate.Error != nil {
|
||||
return userUpdate.Error
|
||||
}
|
||||
if userUpdate.RowsAffected == 0 {
|
||||
return fmt.Errorf("user not found when adjusting token usage: user_id=%d", userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdjustTokenUsageInTx 在调用方已开启事务时执行 token 账本增量调整。
|
||||
func (a *AgentDAO) AdjustTokenUsageInTx(ctx context.Context, userID int, conversationID string, deltaTokens int) error {
|
||||
return a.adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens)
|
||||
func (a *AgentDAO) AdjustTokenUsageInTx(ctx context.Context, userID int, conversationID string, deltaTokens int, eventID string) error {
|
||||
return a.adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens, eventID)
|
||||
}
|
||||
|
||||
// AdjustTokenUsage 在同步路径下执行 token 账本增量调整(内部自带事务)。
|
||||
func (a *AgentDAO) AdjustTokenUsage(ctx context.Context, userID int, conversationID string, deltaTokens int) error {
|
||||
func (a *AgentDAO) AdjustTokenUsage(ctx context.Context, userID int, conversationID string, deltaTokens int, eventID string) error {
|
||||
return a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return a.WithTx(tx).adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens)
|
||||
return a.WithTx(tx).adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens, eventID)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *AgentDAO) CreateNewChat(userID int, chatID string) (int64, error) {
|
||||
chat := model.AgentChat{
|
||||
ChatID: chatID,
|
||||
|
||||
@@ -13,7 +13,6 @@ type RepoManager struct {
|
||||
Task *TaskDAO
|
||||
Course *CourseDAO
|
||||
TaskClass *TaskClassDAO
|
||||
User *UserDAO
|
||||
Agent *AgentDAO
|
||||
ActiveSchedule *ActiveScheduleDAO
|
||||
ActiveScheduleSession *ActiveScheduleSessionDAO
|
||||
@@ -27,7 +26,6 @@ func NewManager(db *gorm.DB) *RepoManager {
|
||||
Task: NewTaskDAO(db),
|
||||
Course: NewCourseDAO(db),
|
||||
TaskClass: NewTaskClassDAO(db),
|
||||
User: NewUserDAO(db),
|
||||
Agent: NewAgentDAO(db),
|
||||
ActiveSchedule: NewActiveScheduleDAO(db),
|
||||
ActiveScheduleSession: NewActiveScheduleSessionDAO(db),
|
||||
@@ -48,7 +46,6 @@ func (m *RepoManager) WithTx(tx *gorm.DB) *RepoManager {
|
||||
Task: m.Task.WithTx(tx),
|
||||
TaskClass: m.TaskClass.WithTx(tx),
|
||||
Course: m.Course.WithTx(tx),
|
||||
User: m.User.WithTx(tx),
|
||||
Agent: m.Agent.WithTx(tx),
|
||||
ActiveSchedule: m.ActiveSchedule.WithTx(tx),
|
||||
ActiveScheduleSession: m.ActiveScheduleSession.WithTx(tx),
|
||||
|
||||
@@ -18,17 +18,6 @@ type CacheDAO struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// UserTokenQuotaSnapshot 是“用户额度判断”的 Redis 快照结构。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 只保留额度判断必要字段,避免把 users 全字段塞进缓存;
|
||||
// 2. 该结构仅用于“快速门禁判断”,权威账本仍以 MySQL 为准。
|
||||
type UserTokenQuotaSnapshot struct {
|
||||
TokenLimit int `json:"token_limit"`
|
||||
TokenUsage int `json:"token_usage"`
|
||||
LastResetAt time.Time `json:"last_reset_at"`
|
||||
}
|
||||
|
||||
func NewCacheDAO(client *redis.Client) *CacheDAO {
|
||||
return &CacheDAO{client: client}
|
||||
}
|
||||
@@ -45,22 +34,6 @@ func (d *CacheDAO) conversationTimelineSeqKey(userID int, conversationID string)
|
||||
return fmt.Sprintf("smartflow:conversation_timeline_seq:u:%d:c:%s", userID, conversationID)
|
||||
}
|
||||
|
||||
// SetBlacklist 把 Token 写入黑名单。
|
||||
func (d *CacheDAO) SetBlacklist(jti string, expiration time.Duration) error {
|
||||
return d.client.Set(context.Background(), "blacklist:"+jti, "1", expiration).Err()
|
||||
}
|
||||
|
||||
// IsBlacklisted 检查 Token 是否在黑名单中。
|
||||
func (d *CacheDAO) IsBlacklisted(jti string) (bool, error) {
|
||||
result, err := d.client.Get(context.Background(), "blacklist:"+jti).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return false, nil // 不在黑名单中
|
||||
} else if err != nil {
|
||||
return false, err // 其他错误
|
||||
}
|
||||
return result == "1", nil // 在黑名单中
|
||||
}
|
||||
|
||||
func (d *CacheDAO) AddTaskClassList(ctx context.Context, userID int, list *model.UserGetTaskClassesResponse) error {
|
||||
// 1. 定义 Key,使用 userID 隔离不同用户的数据。
|
||||
key := fmt.Sprintf("smartflow:task_classes:%d", userID)
|
||||
@@ -293,82 +266,6 @@ func (d *CacheDAO) DeleteUserOngoingScheduleFromCache(ctx context.Context, userI
|
||||
return d.client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func userTokenQuotaSnapshotKey(userID int) string {
|
||||
return fmt.Sprintf("smartflow:user_token_quota_snapshot:%d", userID)
|
||||
}
|
||||
|
||||
func userTokenBlockedKey(userID int) string {
|
||||
return fmt.Sprintf("smartflow:user_token_blocked:%d", userID)
|
||||
}
|
||||
|
||||
// GetUserTokenQuotaSnapshot 读取用户 token 配额快照。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. 命中返回 (*UserTokenQuotaSnapshot, true, nil);
|
||||
// 2. 未命中返回 (nil, false, nil);
|
||||
// 3. Redis/反序列化错误返回 (nil, false, err)。
|
||||
func (d *CacheDAO) GetUserTokenQuotaSnapshot(ctx context.Context, userID int) (*UserTokenQuotaSnapshot, bool, error) {
|
||||
key := userTokenQuotaSnapshotKey(userID)
|
||||
val, err := d.client.Get(ctx, key).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var snapshot UserTokenQuotaSnapshot
|
||||
if err = json.Unmarshal([]byte(val), &snapshot); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &snapshot, true, nil
|
||||
}
|
||||
|
||||
// SetUserTokenQuotaSnapshot 写入用户 token 配额快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做缓存写入,不做额度判断;
|
||||
// 2. ttl 由上层策略控制,便于按场景调优“性能 vs 一致性”。
|
||||
func (d *CacheDAO) SetUserTokenQuotaSnapshot(ctx context.Context, userID int, snapshot UserTokenQuotaSnapshot, ttl time.Duration) error {
|
||||
key := userTokenQuotaSnapshotKey(userID)
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.client.Set(ctx, key, data, ttl).Err()
|
||||
}
|
||||
|
||||
// DeleteUserTokenQuotaSnapshot 删除用户 token 快照缓存。
|
||||
func (d *CacheDAO) DeleteUserTokenQuotaSnapshot(ctx context.Context, userID int) error {
|
||||
return d.client.Del(ctx, userTokenQuotaSnapshotKey(userID)).Err()
|
||||
}
|
||||
|
||||
// IsUserTokenBlocked 检查用户是否被“额度封禁键”命中。
|
||||
func (d *CacheDAO) IsUserTokenBlocked(ctx context.Context, userID int) (bool, error) {
|
||||
result, err := d.client.Get(ctx, userTokenBlockedKey(userID)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result == "1", nil
|
||||
}
|
||||
|
||||
// SetUserTokenBlocked 设置用户“额度封禁键”。
|
||||
//
|
||||
// 说明:
|
||||
// 1. 该键是快速拦截层,不是权威账本;
|
||||
// 2. ttl 建议设置到“下一次重置时间”,到期自动解封。
|
||||
func (d *CacheDAO) SetUserTokenBlocked(ctx context.Context, userID int, ttl time.Duration) error {
|
||||
return d.client.Set(ctx, userTokenBlockedKey(userID), "1", ttl).Err()
|
||||
}
|
||||
|
||||
// DeleteUserTokenBlocked 清理用户“额度封禁键”。
|
||||
func (d *CacheDAO) DeleteUserTokenBlocked(ctx context.Context, userID int) error {
|
||||
return d.client.Del(ctx, userTokenBlockedKey(userID)).Err()
|
||||
}
|
||||
|
||||
// SetSchedulePlanPreviewToCache 写入“排程预览”缓存。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserDAO 用户数据访问对象
|
||||
// 负责用户相关的数据库操作
|
||||
type UserDAO struct {
|
||||
// 这是一个口袋,用来装数据库连接实例
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewUserDAO 创建UserDAO实例
|
||||
// NewUserDAO 接收一个 *gorm.DB,并把它塞进结构体的口袋里
|
||||
func NewUserDAO(db *gorm.DB) *UserDAO {
|
||||
return &UserDAO{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserDAO) WithTx(tx *gorm.DB) *UserDAO {
|
||||
return &UserDAO{db: tx}
|
||||
}
|
||||
|
||||
// Create 创建新用户
|
||||
// 插入新用户信息到数据库
|
||||
func (r *UserDAO) Create(username, phoneNumber, password string) (*model.User, error) {
|
||||
// 创建User实例
|
||||
user := &model.User{
|
||||
Username: username,
|
||||
PhoneNumber: phoneNumber,
|
||||
Password: password, // 注意:实际项目中应该对密码进行加密处理
|
||||
TokenLimit: 100000, // 默认值
|
||||
TokenUsage: 0, // 初始使用量为0
|
||||
LastResetAt: time.Now(), // 设置为当前时间
|
||||
}
|
||||
|
||||
// 插入数据
|
||||
if err := r.db.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserDAO) IfUsernameExists(name string) (bool, error) {
|
||||
err := r.db.Where("username = ?", name).First(&model.User{}).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *UserDAO) GetUserHashedPasswordByName(name string) (string, error) {
|
||||
var user model.User
|
||||
err := r.db.Where("username = ?", name).First(&user).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return user.Password, nil
|
||||
}
|
||||
|
||||
func (r *UserDAO) GetUserIDByName(name string) (int, error) {
|
||||
var user model.User
|
||||
err := r.db.Where("username = ?", name).First(&user).Error
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
return int(user.ID), nil
|
||||
}
|
||||
|
||||
func (r *UserDAO) GetUserByID(id int) (*model.User, error) {
|
||||
var user model.User
|
||||
err := r.db.Where("id = ?", id).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserTokenQuotaByID 查询用户 token 配额快照(仅查询配额相关字段)。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只返回 token_limit / token_usage / last_reset_at 等“额度判断必需字段”;
|
||||
// 2. 不负责做超额判断与重置判断(由中间件统一决策);
|
||||
// 3. 不返回密码等敏感字段,避免把无关信息带入鉴权链路。
|
||||
func (r *UserDAO) GetUserTokenQuotaByID(ctx context.Context, id int) (*model.User, error) {
|
||||
var user model.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Select("id", "token_limit", "token_usage", "last_reset_at").
|
||||
Where("id = ?", id).
|
||||
First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// ResetUserTokenUsageIfDue 在“已到重置窗口”时执行懒重置。
|
||||
//
|
||||
// 输入输出语义:
|
||||
// 1. dueBefore:判定“到期可重置”的截止时间(通常是 now-7d);
|
||||
// 2. resetAt:本次重置写入的时间戳;
|
||||
// 3. 返回值 bool:
|
||||
// - true 表示本次调用实际执行了重置;
|
||||
// - false 表示条件未命中(尚未到期或记录不存在)。
|
||||
//
|
||||
// 并发与幂等说明:
|
||||
// 1. 使用条件更新(WHERE last_reset_at <= dueBefore)保证并发下最多一次成功重置;
|
||||
// 2. 重复调用是安全的,未命中条件时不会破坏现有统计。
|
||||
func (r *UserDAO) ResetUserTokenUsageIfDue(ctx context.Context, id int, dueBefore time.Time, resetAt time.Time) (bool, error) {
|
||||
result := r.db.WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("id = ? AND (last_reset_at IS NULL OR last_reset_at <= ?)", id, dueBefore).
|
||||
Updates(map[string]interface{}{
|
||||
"token_usage": 0,
|
||||
"last_reset_at": resetAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
Reference in New Issue
Block a user