Files
Rou 054dab28cc fix(workbench): 模型目录与助手形象 - 修正 Live2D 首帧闪现和模型默认分组 (#773)
* fix(workbench): 模型目录与助手形象 - 修正 Live2D 首帧闪现和模型默认分组

进入画布时形象配置与 Live2D 首帧未就绪不再显示默认球,改为加载提示,失败才回退。

新导入模型一级目录默认未分类、二级渠道展示名默认跟随上游 ID;历史非空分组(含与模型 ID 同名)保留。

拉取模型弹窗默认只勾选已存在模型并移除逐项已存在字样;一级分组下拉点击即展开全部选项。

* style(web): 画布与模型导入 - 按 Prettier 规范格式化改动文件

修复 PR 中 Web checks 的格式化检查失败:Live2D 启动器 JSX、模型导入选择工具函数与 Live2D 加载测试按项目 Prettier 配置重新排版,不改变行为。

* test(web): Live2D 生命周期 - 修复 bun 1.3.9 下 text 导入与模块导入冲突

agent-live2d-lifecycle 曾用 with { type: text } 导入 live2d-avatar.tsx,bun 1.3.9 会按路径复用该缓存,导致新增的模块导入拿不到 Live2DAvatar 具名导出并报 SyntaxError。改为 readFileSync 读取源码文本,本地以 bun 1.3.9 复现并验证通过。
2026-10-14 09:36:01 +08:00

340 lines
11 KiB
Go

// 管理端用户管理:列表、创建、更新、删除与批量停用。
package app
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
)
func (s *Service) RequireAdmin(user *model.User) error {
if user == nil {
return Unauthorized("请先登录")
}
if user.Role != model.UserRoleAdmin {
return Forbidden("需要管理员权限")
}
return nil
}
func (s *Service) AdminUsers(actor *model.User, query AdminListQuery) (*AdminUserPage, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
page, limit := normalizeAdminPage(query.Page, query.Limit)
users, total, err := s.repo.AdminUsers(query.Keyword, model.UserRole(query.Type), model.UserStatus(query.Status), limit, (page-1)*limit)
if err != nil {
return nil, err
}
userIDs := make([]string, 0, len(users))
for _, user := range users {
userIDs = append(userIDs, user.ID)
}
accounts, err := s.repo.CreditAccounts(userIDs)
if err != nil {
return nil, err
}
accountByUserID := make(map[string]model.CreditAccount, len(accounts))
for _, account := range accounts {
accountByUserID[account.UserID] = account
}
result := make([]AdminUser, 0, len(users))
for _, user := range users {
account := accountByUserID[user.ID]
result = append(result, AdminUser{User: user, AvailableMicrocredits: account.AvailableMicrocredits, ReservedMicrocredits: account.ReservedMicrocredits})
}
return &AdminUserPage{Users: result, Total: total, Page: page, Limit: limit}, nil
}
func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
users, err := s.repo.AdminUserReferences()
if err != nil {
return nil, err
}
channels, err := s.repo.AdminSystemChannelReferences()
if err != nil {
return nil, err
}
channelIDs := make([]string, 0, len(channels))
for _, channel := range channels {
channelIDs = append(channelIDs, channel.ID)
}
channelModels, err := s.repo.ChannelModelReferences(channelIDs)
if err != nil {
return nil, err
}
modelsByChannel := make(map[string][]model.ChannelModel, len(channels))
for _, item := range channelModels {
modelsByChannel[item.ChannelID] = append(modelsByChannel[item.ChannelID], item)
}
result := &AdminReferenceData{
Users: make([]AdminUserReference, 0, len(users)),
Channels: make([]AdminChannelReference, 0, len(channels)),
}
for _, user := range users {
result.Users = append(result.Users, AdminUserReference{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName})
}
for _, channel := range channels {
items := modelsByChannel[channel.ID]
models := make([]string, 0, len(items))
displayNames := make([]string, 0, len(items))
for _, item := range items {
if item.Enabled {
models = append(models, item.ModelKey)
}
displayNames = append(displayNames, strings.TrimSpace(item.DisplayName))
}
result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models), ModelDisplayNames: uniqueNonEmpty(displayNames)})
}
return result, nil
}
func (s *Service) CreateAdminUser(actor *model.User, req CreateAdminUserRequest) (*AdminUser, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
username := normalizeUsername(req.Username)
email := normalizeEmail(req.Email)
displayName := normalizeDisplayName(req.DisplayName, username)
if err := validateUsername(username); err != nil {
return nil, err
}
if err := validatePassword(req.Password); err != nil {
return nil, err
}
if email != "" {
if err := validateEmail(email); err != nil {
return nil, err
}
}
if req.Role != model.UserRoleAdmin && req.Role != model.UserRoleUser {
return nil, BadAuthRequest("\u7528\u6237\u89d2\u8272\u65e0\u6548")
}
if req.Status != model.UserStatusActive && req.Status != model.UserStatusDisabled {
return nil, BadAuthRequest("\u7528\u6237\u72b6\u6001\u65e0\u6548")
}
if _, err := s.repo.UserByUsername(username); err == nil {
return nil, BadAuthRequest("\u7528\u6237\u540d\u5df2\u5b58\u5728")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if email != "" {
if _, err := s.repo.UserByEmail(email); err == nil {
return nil, BadAuthRequest("\u90ae\u7bb1\u5df2\u88ab\u6ce8\u518c")
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
passwordHash, err := hashPassword(req.Password)
if err != nil {
return nil, err
}
now := time.Now()
user := &model.User{
ID: newID(),
Username: username,
Email: email,
DisplayName: displayName,
Role: req.Role,
Status: req.Status,
PasswordHash: passwordHash,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.Create(user); err != nil {
return nil, err
}
if err := s.ensureSignupBonus(user.ID); err != nil {
return nil, err
}
if err := s.appendAdminAudit(actor, "user.create", "user", user.ID, "\u521b\u5efa\u7528\u6237\u8d26\u53f7", map[string]any{"role": user.Role, "status": user.Status}); err != nil {
return nil, err
}
account, err := s.repo.CreditAccount(user.ID)
if err != nil {
return nil, err
}
return &AdminUser{
User: *user,
AvailableMicrocredits: account.AvailableMicrocredits,
ReservedMicrocredits: account.ReservedMicrocredits,
}, nil
}
func (s *Service) UpdateUser(actor *model.User, userID string, req UpdateUserRequest) (*model.User, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
user, err := s.repo.User(userID)
if err != nil {
return nil, err
}
if actor.ID == user.ID && req.Status == model.UserStatusDisabled {
return nil, BadAuthRequest("不能禁用当前管理员账号")
}
nextRole := user.Role
if req.Role == model.UserRoleAdmin || req.Role == model.UserRoleUser {
nextRole = req.Role
}
nextStatus := user.Status
if req.Status == model.UserStatusActive || req.Status == model.UserStatusDisabled {
nextStatus = req.Status
}
if user.Role == model.UserRoleAdmin && nextRole != model.UserRoleAdmin {
count, err := s.repo.ActiveAdminCountExcluding(user.ID)
if err != nil {
return nil, err
}
if count == 0 {
return nil, BadAuthRequest("至少需要保留一个管理员")
}
}
if user.Role == model.UserRoleAdmin && nextStatus != model.UserStatusActive {
count, err := s.repo.ActiveAdminCountExcluding(user.ID)
if err != nil {
return nil, err
}
if count == 0 {
return nil, BadAuthRequest("至少需要保留一个可用管理员")
}
}
if strings.TrimSpace(req.DisplayName) != "" {
user.DisplayName = normalizeDisplayName(req.DisplayName, user.Username)
}
if req.Email != "" {
email := normalizeEmail(req.Email)
if err := validateEmail(email); err != nil {
return nil, err
}
existing, err := s.repo.UserByEmail(email)
if err == nil && existing.ID != user.ID {
return nil, BadAuthRequest("邮箱已被注册")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
user.Email = email
}
if req.Password != "" {
if err := validatePassword(req.Password); err != nil {
return nil, err
}
hash, err := hashPassword(req.Password)
if err != nil {
return nil, err
}
user.PasswordHash = hash
if err := s.repo.DeleteUserAuthSessions(user.ID); err != nil {
return nil, fmt.Errorf("清理旧登录会话失败,密码未更新:%w", err)
}
}
if nextStatus == model.UserStatusDisabled {
if err := s.repo.DeleteUserAuthSessions(user.ID); err != nil {
return nil, err
}
}
user.Role = nextRole
user.Status = nextStatus
user.UpdatedAt = time.Now()
if err := s.repo.Save(user); err != nil {
return nil, err
}
if err := s.appendAdminAudit(actor, "user.update", "user", user.ID, "更新用户账号状态或资料", map[string]any{"role": user.Role, "status": user.Status}); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) DeleteUser(actor *model.User, userID string) error {
if err := s.RequireAdmin(actor); err != nil {
return err
}
if actor.ID == userID {
return BadAuthRequest("不能删除当前登录的管理员账号")
}
user, err := s.repo.User(userID)
if err != nil {
return err
}
if user.Role == model.UserRoleAdmin {
count, err := s.repo.ActiveAdminCountExcluding(user.ID)
if err != nil {
return err
}
if count == 0 {
return BadAuthRequest("至少需要保留一个管理员")
}
}
if err := s.repo.DeleteUserAuthSessions(user.ID); err != nil {
return err
}
if err := s.repo.DeleteUserTaskTextDeltas(user.ID); err != nil {
return err
}
// 有资金流水后必须保留用户主体,删除入口改为停用并清除全部登录态。
user.Status = model.UserStatusDisabled
user.UpdatedAt = time.Now()
if err := s.repo.Save(user); err != nil {
return err
}
return s.appendAdminAudit(actor, "user.disable", "user", user.ID, "停用用户并清除登录态", nil)
}
func (s *Service) BulkDisableUsers(actor *model.User, req BulkDisableUsersRequest) (*BulkDisableUsersResult, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(req.UserIDs))
userIDs := make([]string, 0, len(req.UserIDs))
for _, rawID := range req.UserIDs {
id := strings.TrimSpace(rawID)
if id == "" {
return nil, BadAuthRequest("用户 ID 无效")
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
userIDs = append(userIDs, id)
}
if len(userIDs) == 0 {
return nil, BadAuthRequest("请选择要停用的用户")
}
if len(userIDs) > 100 {
return nil, BadAuthRequest("单次最多停用 100 个用户")
}
metadata, err := json.Marshal(map[string]any{"userIds": userIDs, "count": len(userIDs)})
if err != nil {
return nil, err
}
now := time.Now()
events := make([]model.AdminAuditEvent, 0, len(userIDs))
for _, userID := range userIDs {
events = append(events, model.AdminAuditEvent{ID: newID(), ActorUserID: actor.ID, Action: "user.bulk_disable", TargetType: "user", TargetID: userID, Summary: "批量停用用户并清除登录态", MetadataJSON: string(metadata), CreatedAt: now})
}
users, err := s.repo.BulkDisableUsers(actor.ID, userIDs, events, now)
if errors.Is(err, repository.ErrBulkUserNotFound) {
return nil, BadAuthRequest("部分用户不存在,请刷新列表后重试")
}
if errors.Is(err, repository.ErrBulkCurrentAdmin) {
return nil, BadAuthRequest("不能停用当前登录的管理员账号")
}
if errors.Is(err, repository.ErrBulkLastActiveAdmin) {
return nil, BadAuthRequest("批量操作后至少需要保留一个可用管理员")
}
if err != nil {
return nil, err
}
return &BulkDisableUsersResult{Users: users, DisabledCount: len(users)}, nil
}