Files
image2api/backend/internal/repo/user_repo.go
T
chiyiandClaude Opus 4.8 1137d0bd1d feat: 画图台并发重构 + 品牌定制 + 用户备注 + provider/UI 多项修复
画图台(并发出图):
- 不再锁定 UI:点「生成」开独立任务,可连续多次并发
- 结果网格一行5个、最多10张,进行中/成功/失败状态回显,刷新保留进行中
- 生图张数 1/2/3/4,各自独立计费出卡
- 点图=参考图(单张替换/多张替换末位);首尾帧模型点视频=抓末帧设为首帧,否则放大
- /logs 新增 statuses=pending,success 服务端过滤(status IN 专用 SQL)

品牌定制(设置→网站):
- 自定义 Logo 图片 + 子标题(公开页头部 + 管理侧栏)
- 邮件验证码标题改用站点名:{title} 邮箱验证码

提示词复制:
- 去掉复制按钮,点提示词文字即复制(预览/后台日志/图片管理/画图记录),统一弹「指令已复制」
- 新增 utils/clipboard.js:execCommand 回退,非安全上下文(http/IP)也能复制

用户管理:列表加「备注」列,新建/编辑可填改备注(默认空)

provider 修复:
- grok 401 正确判死封号(markTokenFailure 漏了 grok 池)
- grok 视频支持 15s
- custom 上游报错去敏感(抹掉上游 URL/IP,改英文短描述)
- custom 去掉额度耗尽锁定:429/欠费当临时错误,账号保持 active

UI/其它:
- 展示位弹窗浅色主题适配(tab 选中高亮、输入框边框)— 主题变量 + 中心补丁
- 自定义模型:时长可填任意秒数 + 15s 预设
- 首页设置/卡密弹窗去固定高度与滚动条
- 顶部菜单「记录」→「图片」
- 下线 Flow provider(代码移除)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:11:22 +08:00

638 lines
18 KiB
Go

package repo
import (
"context"
"errors"
"strings"
"time"
"backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UserRepository struct {
db *gorm.DB
}
var ErrAlreadyCheckedInToday = errors.New("already checked in today")
type InviteStats struct {
InviteCount int64 `json:"invite_count"`
InviteEarned int `json:"invite_earned"`
}
type InviteRecord struct {
Name string `json:"name,omitempty"`
Inviter string `json:"inviter,omitempty"`
Invitee string `json:"invitee,omitempty"`
Reward int `json:"reward"`
RegisteredAt time.Time `json:"registered_at"`
CompletedAt *time.Time `json:"completed_at"`
Status string `json:"status"`
}
type InviteLogStats struct {
Total int64 `json:"total"`
Completed int64 `json:"completed"`
Pending int64 `json:"pending"`
RewardPaid int64 `json:"reward_paid"`
}
type CheckinResult struct {
Already bool `json:"already"`
Awarded int `json:"awarded"`
Streak int `json:"streak"`
Credits float64 `json:"credits"`
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) GetByID(ctx context.Context, userID string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "id = ?", userID).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByIdentifier(ctx context.Context, identifier string) (*model.User, error) {
ident := strings.TrimSpace(identifier)
if ident == "" {
return nil, gorm.ErrRecordNotFound
}
var user model.User
q := r.db.WithContext(ctx).Preload("APIKeys")
if strings.Contains(ident, "@") {
if err := q.First(&user, "email = ?", strings.ToLower(ident)).Error; err != nil {
return nil, err
}
return &user, nil
}
if err := q.First(&user, "LOWER(name) = ?", strings.ToLower(ident)).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByInviteCode(ctx context.Context, code string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).First(&user, "invite_code = ?", strings.ToUpper(strings.TrimSpace(code))).Error; err != nil {
return nil, err
}
return &user, nil
}
// IncrementGenerationCount bumps a user's persistent success counter by 1.
func (r *UserRepository) IncrementGenerationCount(ctx context.Context, userID string) error {
if userID == "" {
return nil
}
return r.db.WithContext(ctx).Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("generation_count", gorm.Expr("generation_count + 1")).Error
}
func (r *UserRepository) List(ctx context.Context) ([]model.User, error) {
var users []model.User
if err := r.db.WithContext(ctx).Preload("APIKeys").Order("created_at desc").Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) ExistsEmail(ctx context.Context, email, excludeUserID string) (bool, error) {
var count int64
q := r.db.WithContext(ctx).Model(&model.User{}).Where("email = ?", strings.ToLower(strings.TrimSpace(email)))
if strings.TrimSpace(excludeUserID) != "" {
q = q.Where("id <> ?", strings.TrimSpace(excludeUserID))
}
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *UserRepository) ExistsName(ctx context.Context, name, excludeUserID string) (bool, error) {
var count int64
q := r.db.WithContext(ctx).Model(&model.User{}).Where("LOWER(name) = ?", strings.ToLower(strings.TrimSpace(name)))
if strings.TrimSpace(excludeUserID) != "" {
q = q.Where("id <> ?", strings.TrimSpace(excludeUserID))
}
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *UserRepository) GetByAPIKeyHash(ctx context.Context, keyHash string) (*model.User, error) {
var apiKey model.APIKey
if err := r.db.WithContext(ctx).First(&apiKey, "key_hash = ?", keyHash).Error; err != nil {
return nil, err
}
var user model.User
if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "id = ?", apiKey.UserID).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) TouchLogin(ctx context.Context, userID, ip string) error {
now := time.Now()
return r.db.WithContext(ctx).
Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"last_login_at": now,
"last_login_ip": ip,
}).Error
}
func (r *UserRepository) HasAdmin(ctx context.Context) (bool, error) {
var count int64
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Where("role = ?", "admin").
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *UserRepository) Stats(ctx context.Context) (map[string]any, error) {
var total, active, disabled, admins int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&total).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", "active").Count(&active).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("status = ?", "disabled").Count(&disabled).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("role = ?", "admin").Count(&admins).Error; err != nil {
return nil, err
}
type sumRow struct {
Total *float64 `gorm:"column:total"`
}
var credits sumRow
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Select("SUM(credits) AS total").
Scan(&credits).Error; err != nil {
return nil, err
}
now := time.Now()
dayCut := now.Add(-24 * time.Hour)
weekCut := now.Add(-7 * 24 * time.Hour)
var new24h, new7d, active24h int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", dayCut).Count(&new24h).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", weekCut).Count(&new7d).Error; err != nil {
return nil, err
}
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("last_login_at >= ?", dayCut).Count(&active24h).Error; err != nil {
return nil, err
}
creditsTotal := 0.0
if credits.Total != nil {
creditsTotal = *credits.Total
}
return map[string]any{
"total": total,
"active": active,
"disabled": disabled,
"admins": admins,
"credits_total": creditsTotal,
"new_24h": new24h,
"new_7d": new7d,
"active_24h": active24h,
}, nil
}
type CheckinStats struct {
TodayCount int64 `json:"today_count"`
MaxStreak int64 `json:"max_streak"`
}
// CheckinStats counts users who checked in today and the longest active streak —
// a single-query summary for the admin dashboard's 签到 card.
func (r *UserRepository) CheckinStats(ctx context.Context) (*CheckinStats, error) {
today := time.Now().Format("2006-01-02")
type row struct {
TodayCount int64 `gorm:"column:today_count"`
MaxStreak int64 `gorm:"column:max_streak"`
}
var out row
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Select("COUNT(*) FILTER (WHERE checkin_last = ?) AS today_count, COALESCE(MAX(checkin_streak), 0) AS max_streak", today).
Scan(&out).Error; err != nil {
return nil, err
}
return &CheckinStats{TodayCount: out.TodayCount, MaxStreak: out.MaxStreak}, nil
}
type InviteSummary struct {
Total int64 `json:"total"`
Completed int64 `json:"completed"`
}
// InviteSummary is a lightweight count of invited users (and how many have had
// their reward granted). Cheaper than AllInvites — no JOIN, no record list —
// for the dashboard which polls frequently.
func (r *UserRepository) InviteSummary(ctx context.Context) (*InviteSummary, error) {
type row struct {
Total int64 `gorm:"column:total"`
Completed int64 `gorm:"column:completed"`
}
var out row
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Select("COUNT(*) AS total, COUNT(*) FILTER (WHERE invite_reward_done) AS completed").
Where("invited_by IS NOT NULL AND invited_by <> ''").
Scan(&out).Error; err != nil {
return nil, err
}
return &InviteSummary{Total: out.Total, Completed: out.Completed}, nil
}
func (r *UserRepository) Create(ctx context.Context, user *model.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *UserRepository) Update(ctx context.Context, userID string, patch map[string]any) (*model.User, error) {
patch["updated_at"] = time.Now()
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(patch).Error; err != nil {
return nil, err
}
return r.GetByID(ctx, userID)
}
func (r *UserRepository) Delete(ctx context.Context, userID string) (int64, error) {
res := r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", userID)
return res.RowsAffected, res.Error
}
func (r *UserRepository) DeleteByIDs(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
res := r.db.WithContext(ctx).Delete(&model.User{}, "id IN ?", ids)
return res.RowsAffected, res.Error
}
func (r *UserRepository) SetPasswordByEmail(ctx context.Context, email, passwordHash string) (*model.User, error) {
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Where("email = ?", strings.ToLower(strings.TrimSpace(email))).
Updates(map[string]any{
"password_hash": passwordHash,
"updated_at": time.Now(),
}).Error; err != nil {
return nil, err
}
var user model.User
if err := r.db.WithContext(ctx).Preload("APIKeys").First(&user, "email = ?", strings.ToLower(strings.TrimSpace(email))).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) TouchAPIKeyUsage(ctx context.Context, keyHash string) error {
now := time.Now()
return r.db.WithContext(ctx).
Model(&model.APIKey{}).
Where("key_hash = ?", keyHash).
Update("last_used_at", now).Error
}
func (r *UserRepository) InviteStats(ctx context.Context, userID string, reward int) (*InviteStats, error) {
var inviteCount int64
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Where("invited_by = ?", userID).
Count(&inviteCount).Error; err != nil {
return nil, err
}
var rewardedCount int64
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Where("invited_by = ? AND invite_reward_done = ?", userID, true).
Count(&rewardedCount).Error; err != nil {
return nil, err
}
return &InviteStats{
InviteCount: inviteCount,
InviteEarned: int(rewardedCount) * reward,
}, nil
}
func (r *UserRepository) InviteList(ctx context.Context, userID string, reward int) ([]InviteRecord, error) {
type row struct {
Name string
CreatedAt time.Time
InviteRewardDone bool
InviteRewardAt *time.Time
}
var rows []row
if err := r.db.WithContext(ctx).
Model(&model.User{}).
Select("name, created_at, invite_reward_done, invite_reward_at").
Where("invited_by = ?", userID).
Order("created_at desc").
Find(&rows).Error; err != nil {
return nil, err
}
out := make([]InviteRecord, 0, len(rows))
for _, item := range rows {
status := "pending"
rewardValue := 0
if item.InviteRewardDone {
status = "completed"
rewardValue = reward
}
name := strings.TrimSpace(item.Name)
if name == "" {
name = "—"
}
out = append(out, InviteRecord{
Name: name,
Reward: rewardValue,
RegisteredAt: item.CreatedAt,
CompletedAt: item.InviteRewardAt,
Status: status,
})
}
return out, nil
}
func (r *UserRepository) AllInvites(ctx context.Context, reward int) ([]InviteRecord, *InviteLogStats, error) {
type row struct {
InviterName string `gorm:"column:inviter_name"`
InviterEmail string `gorm:"column:inviter_email"`
InviteeName string `gorm:"column:invitee_name"`
InviteeEmail string `gorm:"column:invitee_email"`
CreatedAt time.Time `gorm:"column:created_at"`
InviteRewardDone bool `gorm:"column:invite_reward_done"`
InviteRewardAt *time.Time `gorm:"column:invite_reward_at"`
}
var rows []row
if err := r.db.WithContext(ctx).
Table("users AS invitee").
Select(`
inviter.name AS inviter_name,
inviter.email AS inviter_email,
invitee.name AS invitee_name,
invitee.email AS invitee_email,
invitee.created_at,
invitee.invite_reward_done,
invitee.invite_reward_at
`).
Joins("JOIN users AS inviter ON inviter.id = invitee.invited_by").
Order("invitee.created_at desc").
Scan(&rows).Error; err != nil {
return nil, nil, err
}
out := make([]InviteRecord, 0, len(rows))
stats := &InviteLogStats{}
for _, item := range rows {
stats.Total++
status := "pending"
rewardValue := 0
if item.InviteRewardDone {
status = "completed"
rewardValue = reward
stats.Completed++
stats.RewardPaid += int64(reward)
} else {
stats.Pending++
}
inviter := strings.TrimSpace(item.InviterName)
if inviter == "" {
inviter = strings.TrimSpace(item.InviterEmail)
}
invitee := strings.TrimSpace(item.InviteeName)
if invitee == "" {
invitee = strings.TrimSpace(item.InviteeEmail)
}
out = append(out, InviteRecord{
Inviter: inviter,
Invitee: invitee,
Reward: rewardValue,
RegisteredAt: item.CreatedAt,
CompletedAt: item.InviteRewardAt,
Status: status,
})
}
return out, stats, nil
}
func (r *UserRepository) DailyCheckin(ctx context.Context, userID string, reward int) (*CheckinResult, error) {
today := time.Now().Format("2006-01-02")
yesterday := time.Now().Add(-24 * time.Hour).Format("2006-01-02")
var result *CheckinResult
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
return err
}
if user.CheckinLast == today {
result = &CheckinResult{
Already: true,
Awarded: 0,
Streak: user.CheckinStreak,
Credits: user.Credits,
}
return ErrAlreadyCheckedInToday
}
streak := 1
if user.CheckinLast == yesterday {
streak = user.CheckinStreak + 1
}
credits := user.Credits + float64(reward)
if err := tx.Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"credits": credits,
"checkin_last": today,
"checkin_streak": streak,
"updated_at": time.Now(),
}).Error; err != nil {
return err
}
result = &CheckinResult{
Already: false,
Awarded: reward,
Streak: streak,
Credits: credits,
}
return nil
})
if err != nil {
if errors.Is(err, ErrAlreadyCheckedInToday) {
return result, nil
}
return nil, err
}
return result, nil
}
func (r *UserRepository) AdjustCredits(ctx context.Context, userID string, delta float64) (*model.User, error) {
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
return err
}
nextCredits := user.Credits + delta
if nextCredits < 0 {
nextCredits = 0
}
return tx.Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"credits": nextCredits,
"updated_at": time.Now(),
}).Error
})
if err != nil {
return nil, err
}
return r.GetByID(ctx, userID)
}
// SetCredits sets a user's credit balance to an absolute (non-negative) value.
// The row is locked for the duration of the transaction so it stays consistent
// with concurrent AdjustCredits/TryDebitCredits operations.
func (r *UserRepository) SetCredits(ctx context.Context, userID string, value float64) (*model.User, error) {
if value < 0 {
value = 0
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
return err
}
return tx.Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"credits": value,
"updated_at": time.Now(),
}).Error
})
if err != nil {
return nil, err
}
return r.GetByID(ctx, userID)
}
func (r *UserRepository) TryDebitCredits(ctx context.Context, userID string, amount float64) (*model.User, bool, error) {
if amount <= 0 {
user, err := r.GetByID(ctx, userID)
return user, user != nil, err
}
var result *model.User
debited := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Preload("APIKeys").First(&user, "id = ?", userID).Error; err != nil {
return err
}
if user.Credits < amount {
result = &user
return nil
}
nextCredits := user.Credits - amount
if err := tx.Model(&model.User{}).
Where("id = ?", userID).
Updates(map[string]any{
"credits": nextCredits,
"updated_at": time.Now(),
}).Error; err != nil {
return err
}
user.Credits = nextCredits
user.UpdatedAt = time.Now()
result = &user
debited = true
return nil
})
if err != nil {
return nil, false, err
}
return result, debited, nil
}
func (r *UserRepository) GrantInviteReward(ctx context.Context, inviteeUserID string, reward int) (bool, error) {
if reward <= 0 {
return false, nil
}
granted := false
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var invitee model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&invitee, "id = ?", inviteeUserID).Error; err != nil {
return err
}
if invitee.InvitedBy == nil || *invitee.InvitedBy == "" || invitee.InviteRewardDone {
return nil
}
var inviter model.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&inviter, "id = ?", *invitee.InvitedBy).Error; err != nil {
return err
}
now := time.Now()
if err := tx.Model(&model.User{}).
Where("id = ?", invitee.ID).
Updates(map[string]any{
"invite_reward_done": true,
"invite_reward_at": now,
"updated_at": now,
}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", inviter.ID).
Updates(map[string]any{
"credits": inviter.Credits + float64(reward),
"updated_at": now,
}).Error; err != nil {
return err
}
granted = true
return nil
})
if err != nil {
return false, err
}
return granted, nil
}