package app import ( "encoding/json" "errors" "fmt" "infinite-canvas/backend/internal/kernel" stdlog "log" "strings" "time" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" "gorm.io/gorm" ) type CreateAdminUserRequest struct { Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` Password string `json:"password"` Role model.UserRole `json:"role"` Status model.UserStatus `json:"status"` } type UpdateUserRequest struct { DisplayName string `json:"displayName"` Email string `json:"email"` Password string `json:"password"` Role model.UserRole `json:"role"` Status model.UserStatus `json:"status"` } type BulkDisableUsersRequest struct { UserIDs []string `json:"userIds"` } type BulkDisableUsersResult struct { Users []model.User `json:"users"` DisabledCount int `json:"disabledCount"` } type AdminListQuery struct { Keyword string Status string Type string Page int Limit int } type AdminUserPage struct { Users []AdminUser `json:"users"` Total int64 `json:"total"` Page int `json:"page"` Limit int `json:"pageSize"` } type AdminUser struct { model.User AvailableMicrocredits int64 `json:"availableMicrocredits"` ReservedMicrocredits int64 `json:"reservedMicrocredits"` } type AdminChannelPage struct { Channels []PublicModelChannel `json:"channels"` Total int64 `json:"total"` Page int `json:"page"` Limit int `json:"pageSize"` } type AdminUserReference struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"displayName"` } type AdminChannelReference struct { ID string `json:"id"` Name string `json:"name"` Enabled bool `json:"enabled"` Models []string `json:"models"` } type AdminReferenceData struct { Users []AdminUserReference `json:"users"` Channels []AdminChannelReference `json:"channels"` } type ChannelRequest struct { Name string `json:"name"` SortOrder *int `json:"sortOrder"` BaseURL string `json:"baseUrl"` APIKey string `json:"apiKey"` SecretKey string `json:"secretKey"` ConcurrencyLimit *int `json:"concurrencyLimit"` UseGlobalConcurrency *bool `json:"useGlobalConcurrency"` Models []string `json:"models"` Headers []OutboundHeader `json:"headers"` Enabled *bool `json:"enabled"` } type PublicModelChannel struct { ID string `json:"id"` UserID string `json:"userId"` Scope model.ChannelScope `json:"scope"` Enabled bool `json:"enabled"` Name string `json:"name"` SortOrder int `json:"sortOrder"` BaseURL string `json:"baseUrl"` APIKey string `json:"apiKey"` APIFormat string `json:"apiFormat"` ConcurrencyLimit int `json:"concurrencyLimit"` Models []string `json:"models"` ModelCosts []PublicChannelModelPrice `json:"modelCosts"` Headers []OutboundHeader `json:"headers,omitempty"` HasAPIKey bool `json:"hasApiKey"` HasSecretKey bool `json:"hasSecretKey"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type PublicChannelModelPrice struct { Model string `json:"model"` DisplayName string `json:"displayName"` ChannelLabel string `json:"channelLabel"` Description string `json:"description"` Icon string `json:"icon"` Capability string `json:"capability"` Protocol model.ChannelInterfaceType `json:"protocol"` BillingMode string `json:"billingMode"` UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig,omitempty"` } 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 } 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, itemErr := s.repo.ChannelModels(channel.ID, false) if itemErr != nil { return nil, itemErr } models := make([]string, 0, len(items)) for _, item := range items { models = append(models, item.ModelKey) } result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models)}) } 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) } } 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 } func (s *Service) PublicSystemChannels() ([]PublicModelChannel, error) { channels, err := s.repo.SystemChannels(false) if err != nil { return nil, err } result := make([]PublicModelChannel, 0, len(channels)) for _, channel := range channels { items, itemErr := s.repo.ChannelModels(channel.ID, false) if itemErr != nil { return nil, itemErr } result = append(result, publicChannel(channel, false, items)) } return result, nil } func (s *Service) SystemChannel(id string) (*model.ModelChannel, error) { channel, err := s.repo.SystemChannel(id) if err != nil { return nil, err } if err := s.decryptSystemChannelSecrets(channel); err != nil { return nil, err } return channel, nil } func (s *Service) adminSystemChannel(id string) (*model.ModelChannel, error) { channel, err := s.repo.AdminSystemChannel(id) if err != nil { return nil, err } if err := s.decryptSystemChannelSecrets(channel); err != nil { return nil, err } return channel, nil } func (s *Service) AdminSystemChannelPage(actor *model.User, query AdminListQuery) (*AdminChannelPage, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } page, limit := normalizeAdminPage(query.Page, query.Limit) channels, total, err := s.repo.AdminSystemChannels(query.Keyword, query.Status, limit, (page-1)*limit) if err != nil { return nil, err } result := make([]PublicModelChannel, 0, len(channels)) for _, channel := range channels { items, itemErr := s.repo.ChannelModels(channel.ID, true) if itemErr != nil { return nil, itemErr } result = append(result, publicChannel(channel, true, items)) } return &AdminChannelPage{Channels: result, Total: total, Page: page, Limit: limit}, nil } func normalizeAdminPage(page int, limit int) (int, int) { if page <= 0 { page = 1 } if limit <= 0 || limit > 100 { limit = 20 } return page, limit } func (s *Service) CreateSystemChannel(actor *model.User, req ChannelRequest) (*PublicModelChannel, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } channelID, err := s.repo.NextPrefixedID("CHANNEL") if err != nil { return nil, err } channel, err := s.channelFromRequest(req, model.ModelChannel{ID: channelID, UserID: actor.ID, Scope: model.ChannelScopeSystem, Enabled: true}) if err != nil { return nil, err } if err := s.encryptSystemChannelSecrets(&channel); err != nil { return nil, err } if err := s.repo.Create(&channel); err != nil { return nil, err } if err := s.syncInitialChannelModels(&channel, req.Models); err != nil { return nil, err } s.invalidateRouteCatalog() items, err := s.repo.ChannelModels(channel.ID, true) if err != nil { return nil, err } public := publicChannel(channel, true, items) return &public, nil } func (s *Service) DuplicateSystemChannel(actor *model.User, id string) (*PublicModelChannel, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } source, err := s.adminSystemChannel(id) if err != nil { return nil, err } sourceModels, err := s.repo.ChannelModels(source.ID, true) if err != nil { return nil, err } if len(sourceModels) == 0 { for _, name := range channelModelNames(*source) { sourceModels = append(sourceModels, model.ChannelModel{ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) } } channelID, err := s.repo.NextPrefixedID("CHANNEL") if err != nil { return nil, err } channel := *source channel.ID = channelID channel.UserID = actor.ID channel.Scope = model.ChannelScopeSystem channel.Name = duplicateChannelName(source.Name) channel.CreatedAt = time.Time{} channel.UpdatedAt = time.Time{} channel.DeletedAt = gorm.DeletedAt{} if err := s.encryptSystemChannelSecrets(&channel); err != nil { return nil, err } channelModels := make([]model.ChannelModel, 0, len(sourceModels)) priceTiers := make([]model.ChannelModelPriceTier, 0) for _, sourceModel := range sourceModels { modelID, idErr := s.repo.NextPrefixedID("MODEL") if idErr != nil { return nil, idErr } channelModel := sourceModel channelModel.ID = modelID channelModel.ChannelID = channel.ID channelModel.CreatedAt = time.Time{} channelModel.UpdatedAt = time.Time{} channelModel.DeletedAt = gorm.DeletedAt{} channelModel.PriceTiers = nil channelModels = append(channelModels, channelModel) for _, sourceTier := range sourceModel.PriceTiers { tierID, tierErr := s.repo.NextPrefixedID("PTIER") if tierErr != nil { return nil, tierErr } priceTier := sourceTier priceTier.ID = tierID priceTier.ChannelModelID = channelModel.ID priceTier.Selector = nil priceTier.CreatedAt = time.Time{} priceTier.UpdatedAt = time.Time{} priceTier.DeletedAt = gorm.DeletedAt{} priceTiers = append(priceTiers, priceTier) } } if err := s.repo.CreateDuplicatedSystemChannel(&channel, channelModels, priceTiers); err != nil { return nil, err } s.invalidateRouteCatalog() items, err := s.repo.ChannelModels(channel.ID, true) if err != nil { return nil, err } public := publicChannel(channel, true, items) return &public, nil } func duplicateChannelName(name string) string { const suffix = " - 副本" base := []rune(strings.TrimSpace(name)) if len(base) == 0 { base = []rune("系统渠道") } if len(base)+len([]rune(suffix)) > 80 { base = base[:80-len([]rune(suffix))] } return string(base) + suffix } func (s *Service) UpdateSystemChannel(actor *model.User, id string, req ChannelRequest) (*PublicModelChannel, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err } if req.presentationOnly() { return s.updateChannelPresentation(id, req) } updateModels := req.Models != nil channel, err := s.repo.AdminSystemChannel(id) if err != nil { return nil, err } if err := s.decryptSystemChannelSecrets(channel); err != nil { return nil, err } req = mergeChannelRequest(req, *channel) next, err := s.channelFromRequest(req, *channel) if err != nil { return nil, err } next.ID = channel.ID next.UserID = channel.UserID next.Scope = model.ChannelScopeSystem next.CreatedAt = channel.CreatedAt if req.APIKey == "" { next.APIKey = channel.APIKey } if req.SecretKey == "" { next.SecretKey = channel.SecretKey } if err := s.encryptSystemChannelSecrets(&next); err != nil { return nil, err } if err := s.repo.Save(&next); err != nil { return nil, err } if updateModels { if err := s.syncInitialChannelModels(&next, req.Models); err != nil { return nil, err } } s.invalidateRouteCatalog() items, err := s.repo.ChannelModels(next.ID, true) if err != nil { return nil, err } public := publicChannel(next, true, items) return &public, nil } func (s *Service) encryptSystemChannelSecrets(channel *model.ModelChannel) error { apiKey, err := s.encryptSettingSecret(channel.APIKey) if err != nil { return err } secretKey, err := s.encryptSettingSecret(channel.SecretKey) if err != nil { return err } channel.APIKey = apiKey channel.SecretKey = secretKey return nil } func (s *Service) decryptSystemChannelSecrets(channel *model.ModelChannel) error { apiKey, err := s.decryptSettingSecret(channel.APIKey) if err != nil { return err } secretKey, err := s.decryptSettingSecret(channel.SecretKey) if err != nil { return err } channel.APIKey = apiKey channel.SecretKey = secretKey return nil } func (s *Service) DeleteSystemChannel(actor *model.User, id string) error { if err := s.RequireAdmin(actor); err != nil { return err } channel, err := s.repo.AdminSystemChannel(id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return BadAuthRequest("系统渠道不存在或已删除") } return err } // 保留主体供历史账单和调用日志关联,但从所有业务查询中隐藏并清除密钥。 err = s.repo.DeleteSystemChannel(channel.ID) if errors.Is(err, gorm.ErrRecordNotFound) { return BadAuthRequest("系统渠道不存在或已删除") } if err == nil { s.invalidateRouteCatalog() } return err } func (s *Service) LogAPICall(log model.ApiCallLog) error { if log.ID == "" { log.ID = newID() } if log.CreatedAt.IsZero() { log.CreatedAt = time.Now() } if log.StartedAt.IsZero() { log.StartedAt = log.CreatedAt.Add(-time.Duration(log.DurationMs) * time.Millisecond) } s.estimateCallCost(&log) if log.BillingOrderID != "" && log.ProviderRequestID != "" { if err := s.repo.UpdateBillingProviderRequestID(log.BillingOrderID, log.ProviderRequestID); err != nil { // 账单关联是请求日志的辅助状态,不能因为关联更新失败而丢失 // 已经发生的上游调用记录。后续由任务/账单对账流程补偿关联。 stdlog.Printf("provider billing request id update failed: billing_order_id=%s provider_request_id=%s error=%v", log.BillingOrderID, log.ProviderRequestID, err) } } if log.TaskID != "" { stage := log.RequestKind var nextPollAt *time.Time if stage == "create" && log.Status == model.ApiCallStatusSucceeded && log.ProviderRequestID != "" { stage = "accepted" delay := 2 * time.Second if log.Capability == "video" { delay = defaultVideoPollInterval } next := time.Now().Add(delay) nextPollAt = &next } else if stage == "poll" { delay := 5 * time.Second if log.Capability == "video" { delay = defaultVideoPollInterval } next := time.Now().Add(delay) nextPollAt = &next } if err := s.repo.UpdateTaskProviderState(log.TaskID, log.ProviderRequestID, stage, nextPollAt); err != nil { // 请求日志本身仍需保留;任务状态可由后续任务收尾或恢复流程 // 重建,不能让一次状态写失败掩盖真实的上游调用。 stdlog.Printf("provider task state update failed: task_id=%s provider_request_id=%s error=%v", log.TaskID, log.ProviderRequestID, err) } } if merged, err := s.mergeVideoAPICallLog(log); err != nil { return err } else if merged { return nil } policy, err := s.RuntimePolicy() if err != nil { return err } s.storageMu.Lock() defer s.storageMu.Unlock() usage, err := s.repo.UserStorageUsage(log.UserID) if err != nil { return err } incomingBytes := int64(len(log.Path) + len(log.Model) + len(log.ProviderRequestID) + len(log.ErrorCode) + len(log.Error) + len(log.UpstreamURL) + len(log.RequestContentType) + len(log.RequestBody) + len(log.ResponseBody)) if err := validateAPICallLogQuotaWithPolicy(usage, incomingBytes, policy.Resource); err != nil { return err } return s.repo.Create(&log) } func (s *Service) mergeVideoAPICallLog(log model.ApiCallLog) (bool, error) { if log.Capability != "video" || (log.RequestKind != "poll" && log.RequestKind != "download") { return false, nil } if log.TaskID == "" && log.ProviderRequestID == "" { return false, nil } root, err := s.repo.VideoAPICallRoot(log) if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } if err != nil { return false, err } if log.RequestKind == "poll" { root.PollCount++ if log.ResponseBody != "" { root.ResponseBody = log.ResponseBody } } if log.ProviderRequestID != "" { root.ProviderRequestID = log.ProviderRequestID } if log.ProviderStatus != "" { root.ProviderStatus = log.ProviderStatus } startedAt := root.StartedAt if startedAt.IsZero() { startedAt = root.CreatedAt.Add(-time.Duration(root.DurationMs) * time.Millisecond) root.StartedAt = startedAt } root.DurationMs = max(root.DurationMs, log.CreatedAt.Sub(startedAt).Milliseconds()) root.StatusCode = log.StatusCode root.ConcurrencyLimit = log.ConcurrencyLimit if log.Status == model.ApiCallStatusFailed { root.Status = log.Status root.ErrorCode = log.ErrorCode root.Error = log.Error } else { root.Status = model.ApiCallStatusSucceeded root.ErrorCode = "" root.Error = "" } if log.UsageAvailable { root.UsageAvailable = true root.InputTokens = log.InputTokens root.OutputTokens = log.OutputTokens root.CachedTokens = log.CachedTokens } return true, s.repo.Save(root) } func (s *Service) APICallLogs(actor *model.User, limit int) ([]model.ApiCallLog, error) { if actor == nil { return nil, Unauthorized("请先登录") } return s.repo.ApiCallLogs(actor.ID, actor.Role == model.UserRoleAdmin, limit) } func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.ModelChannel, error) { return (&Service{}).channelFromRequest(req, channel) } func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.ModelChannel, error) { name := strings.TrimSpace(req.Name) baseURL := strings.TrimSpace(req.BaseURL) if name == "" { return channel, BadAuthRequest("请填写渠道名称") } if baseURL == "" { return channel, BadAuthRequest("请填写 Base URL") } // 启用/停用或只修改价格、模型等配置时,不应要求上游域名当前可解析。 // 只有 Base URL 实际变化时才做出站地址校验。 connectionChanged := strings.TrimRight(baseURL, "/") != strings.TrimRight(channel.BaseURL, "/") if connectionChanged { if _, err := ValidateOutboundURL(baseURL); err != nil { return channel, err } } models := uniqueNonEmpty(req.Models) modelsJSON, _ := json.Marshal(models) headersJSON, err := EncodeOutboundHeadersJSON(req.Headers) if err != nil { return channel, err } channel.Name = name if req.SortOrder != nil { if err := validateChannelSortOrder(*req.SortOrder); err != nil { return channel, err } channel.SortOrder = *req.SortOrder } channel.BaseURL = strings.TrimRight(baseURL, "/") if req.APIKey != "" { channel.APIKey = req.APIKey } if req.SecretKey != "" { channel.SecretKey = req.SecretKey } // 系统渠道只保存地址与凭证;实际协议和鉴权方式由所选模型决定。 channel.APIFormat = "openai" if req.UseGlobalConcurrency != nil && *req.UseGlobalConcurrency { channel.ConcurrencyLimit = 0 } else if req.ConcurrencyLimit != nil { if *req.ConcurrencyLimit < minChannelConcurrencyLimit || *req.ConcurrencyLimit > maxChannelConcurrencyLimit { return channel, BadAuthRequest("最大并发数必须是 1-999 的整数") } channel.ConcurrencyLimit = *req.ConcurrencyLimit } else if req.UseGlobalConcurrency != nil { return channel, BadAuthRequest("请填写渠道最大并发数") } channel.ModelsJSON = string(modelsJSON) channel.HeadersJSON = headersJSON if req.Enabled != nil { channel.Enabled = *req.Enabled } return channel, nil } func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) ChannelRequest { if strings.TrimSpace(req.Name) == "" { req.Name = channel.Name } if strings.TrimSpace(req.BaseURL) == "" { req.BaseURL = channel.BaseURL } if req.Models == nil { req.Models = channelModelNames(channel) } if req.Headers == nil { req.Headers, _ = ParseOutboundHeadersJSON(channel.HeadersJSON) } return req } func publicChannel(channel model.ModelChannel, admin bool, channelModels []model.ChannelModel) PublicModelChannel { models := make([]string, 0, len(channelModels)) modelCosts := make([]PublicChannelModelPrice, 0, len(channelModels)) for _, item := range channelModels { if !item.Enabled { continue } models = append(models, item.ModelKey) if item.Enabled && item.PriceConfigured { capabilityConfig, decodeErr := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) if decodeErr == nil && capabilityConfig != nil { if normalized, normalizeErr := NormalizeModelCapabilityConfigForModel(item.Capability, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), capabilityConfig); normalizeErr == nil { capabilityConfig = normalized } } modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, ChannelLabel: item.ChannelLabel, Description: item.Description, Icon: item.Icon, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig}) } } if len(models) == 0 { _ = json.Unmarshal([]byte(channel.ModelsJSON), &models) } apiKey := "" baseURL := channel.BaseURL var headers []OutboundHeader if channel.Scope == model.ChannelScopeSystem { if !admin { apiKey = "system" baseURL = "/api/ai/system/" + channel.ID } if admin { headers, _ = ParseOutboundHeadersJSON(channel.HeadersJSON) } } else if admin { apiKey = channel.APIKey } return PublicModelChannel{ ID: channel.ID, UserID: channel.UserID, Scope: channel.Scope, Enabled: channel.Enabled, Name: channel.Name, SortOrder: channel.SortOrder, BaseURL: baseURL, APIKey: apiKey, APIFormat: channel.APIFormat, ConcurrencyLimit: channel.ConcurrencyLimit, Models: models, ModelCosts: modelCosts, Headers: headers, HasAPIKey: strings.TrimSpace(channel.APIKey) != "", HasSecretKey: strings.TrimSpace(channel.SecretKey) != "", CreatedAt: channel.CreatedAt, UpdatedAt: channel.UpdatedAt, } } func uniqueNonEmpty(values []string) []string { return kernel.UniqueNonEmpty(values) }