package service import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "math" "math/big" "net/http" "regexp" "sort" "strconv" "strings" "sync/atomic" "time" "juhe-factory/api/internal/security" "github.com/google/uuid" "gorm.io/gorm" ) type AdminData struct { DB *gorm.DB Passwords security.PasswordHasher Encryptor *security.Encryptor HTTPClient *http.Client EncryptionKeyVersion string balanceSyncing atomic.Bool } type Page struct { Items []map[string]any `json:"items"` Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` } type ResourceSpec struct { Table string Select string SearchColumns []string Fields map[string]string Filters map[string]string Order string SoftDelete bool } var resourceSpecs = map[string]ResourceSpec{ "styles": {Table: "project_styles", Select: "id,name,image_key,image_url,image_mime,image_size,image_width,image_height,sort_order", Fields: map[string]string{"name": "name", "image_key": "image_key", "image_url": "image_url", "image_mime": "image_mime", "image_size": "image_size", "image_width": "image_width", "image_height": "image_height"}, Order: "sort_order,name", SoftDelete: true}, "channels": {Table: "channels", Select: "c.id,c.name,c.channel_type,c.base_url,c.api_key_ciphertext,c.api_key_last4,c.warning_threshold,c.channel_points_per_cny,c.max_concurrency,c.max_user_concurrency,c.enabled,c.created_at,c.updated_at,b.balance,b.currency,b.synced_at", SearchColumns: []string{"c.name", "c.base_url"}, Fields: map[string]string{"name": "name", "channel_type": "channel_type", "base_url": "base_url", "warning_threshold": "warning_threshold", "channel_points_per_cny": "channel_points_per_cny", "max_concurrency": "max_concurrency", "max_user_concurrency": "max_user_concurrency", "enabled": "enabled"}, Filters: map[string]string{"channel_type": "channel_type", "enabled": "enabled"}, Order: "c.created_at DESC", SoftDelete: true}, "models": {Table: "models", Select: "id", Fields: map[string]string{}, Order: "created_at DESC", SoftDelete: true}, "prompts": {Table: "prompts", Select: "id", Fields: map[string]string{}, Order: "updated_at DESC", SoftDelete: true}, } func pageArgs(pageRaw, sizeRaw string) (int, int) { page, _ := strconv.Atoi(pageRaw) if page < 1 { page = 1 } size, _ := strconv.Atoi(sizeRaw) if size < 1 { size = 20 } if size > 100 { size = 100 } return page, size } func (s *AdminData) ListResource(ctx context.Context, resource, keyword string, filters map[string]string, pageRaw, sizeRaw string) (Page, error) { spec, ok := resourceSpecs[resource] if !ok { return Page{}, errors.New("不支持的资源类型") } page, size := pageArgs(pageRaw, sizeRaw) if resource == "channels" { s.triggerChannelBalanceSync() } query := s.DB.Table(spec.Table) if resource == "channels" { query = s.DB.Table("channels c").Joins("LEFT JOIN LATERAL (SELECT balance,currency,synced_at FROM channel_balance_snapshots WHERE channel_id=c.id ORDER BY synced_at DESC LIMIT 1) b ON true") } if spec.SoftDelete { prefix := strings.Split(spec.Table, " ")[0] if resource == "channels" { prefix = "c" } query = query.Where(prefix + ".deleted_at IS NULL") } if keyword = strings.TrimSpace(keyword); keyword != "" && len(spec.SearchColumns) > 0 { parts := make([]string, len(spec.SearchColumns)) args := make([]any, len(spec.SearchColumns)) for i, column := range spec.SearchColumns { parts[i] = column + "::text ILIKE ?" args[i] = "%" + keyword + "%" } query = query.Where("("+strings.Join(parts, " OR ")+")", args...) } for key, value := range filters { if column, exists := spec.Filters[key]; exists && value != "" { query = query.Where(column+" = ?", value) } } var total int64 if err := query.Count(&total).Error; err != nil { return Page{}, err } items := make([]map[string]any, 0) if err := query.Select(spec.Select).Order(spec.Order).Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil { return Page{}, err } if resource == "channels" { s.maskChannelAPIKeys(items) } return Page{Items: items, Total: total, Page: page, PageSize: size}, nil } func (s *AdminData) maskChannelAPIKeys(items []map[string]any) { for _, item := range items { ciphertext := strings.TrimSpace(fmt.Sprint(item["api_key_ciphertext"])) last4 := strings.TrimSpace(fmt.Sprint(item["api_key_last4"])) delete(item, "api_key_ciphertext") delete(item, "api_key_last4") item["api_key_masked"] = "" if ciphertext != "" && ciphertext != "" && s.Encryptor != nil { if plain, err := s.Encryptor.Decrypt(ciphertext); err == nil { item["api_key_masked"] = maskAPIKey(plain) continue } } if last4 != "" && last4 != "" { item["api_key_masked"] = "********" + last4 } } } func maskAPIKey(value string) string { characters := []rune(value) if len(characters) <= 10 { return strings.Repeat("*", max(len(characters), 8)) } return string(characters[:5]) + "********" + string(characters[len(characters)-5:]) } func (s *AdminData) SaveResource(resource, id string, values map[string]any) (string, error) { spec, ok := resourceSpecs[resource] if !ok { return "", errors.New("不支持的资源类型") } data := map[string]any{} for input, column := range spec.Fields { if value, exists := values[input]; exists { data[column] = value } } if resource == "channels" { if err := s.prepareChannel(values, data); err != nil { return "", err } } if resource == "channels" && id == "" { apiKey, _ := values["api_key"].(string) if strings.TrimSpace(apiKey) == "" { return "", errors.New("API Key 不能为空") } data["channel_type"] = "relay" } if id == "" { newID := uuid.NewString() data["id"] = newID if resource == "styles" { var next int if err := s.DB.Table("project_styles").Select("coalesce(max(sort_order),-1)+1").Scan(&next).Error; err != nil { return "", err } data["sort_order"] = next } if err := s.DB.Table(spec.Table).Create(data).Error; err != nil { return "", err } return newID, nil } if len(data) == 0 { return id, nil } result := s.DB.Table(spec.Table).Where("id = ? AND deleted_at IS NULL", id).Updates(data) if result.Error != nil { return "", result.Error } if result.RowsAffected == 0 { return "", gorm.ErrRecordNotFound } return id, nil } func (s *AdminData) ReorderStyles(ids []string) error { if len(ids) == 0 { return errors.New("风格排序不能为空") } seen := make(map[string]struct{}, len(ids)) for _, id := range ids { if _, err := uuid.Parse(id); err != nil { return errors.New("风格 ID 无效") } if _, exists := seen[id]; exists { return errors.New("风格排序包含重复项") } seen[id] = struct{}{} } return s.DB.Transaction(func(tx *gorm.DB) error { var count int64 if err := tx.Table("project_styles").Where("deleted_at IS NULL").Count(&count).Error; err != nil { return err } if int64(len(ids)) != count { return errors.New("风格列表已变化,请刷新后重试") } for position, id := range ids { result := tx.Table("project_styles").Where("id=? AND deleted_at IS NULL", id).Update("sort_order", position) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return errors.New("风格列表已变化,请刷新后重试") } } return nil }) } func (s *AdminData) prepareChannel(values, data map[string]any) error { rateText := strings.TrimSpace(fmt.Sprint(values["channel_points_per_cny"])) rate, err := strconv.ParseFloat(rateText, 64) if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) || rate <= 0 || rate > 1000000000000 { return errors.New("渠道汇率必须大于 0") } data["channel_points_per_cny"] = rateText maxConcurrency, err := positiveInt(values["max_concurrency"], 500) if err != nil || maxConcurrency > 5000 { return errors.New("最大并发数必须为 1 至 5000") } maxUserConcurrency, err := positiveInt(values["max_user_concurrency"], 10) if err != nil || maxUserConcurrency > 500 || maxUserConcurrency > maxConcurrency { return errors.New("单用户最高并发数必须为 1 至 500,且不能超过最大并发数") } data["max_concurrency"] = maxConcurrency data["max_user_concurrency"] = maxUserConcurrency plain, _ := values["api_key"].(string) plain = strings.TrimSpace(plain) if plain == "" { return nil } if s.Encryptor == nil { return errors.New("敏感配置加密未配置") } ciphertext, err := s.Encryptor.Encrypt(plain) if err != nil { return err } last4 := plain if len(last4) > 4 { last4 = last4[len(last4)-4:] } data["api_key_ciphertext"] = ciphertext data["api_key_last4"] = last4 data["encryption_key_version"] = s.EncryptionKeyVersion return nil } func positiveInt(value any, fallback int) (int, error) { if value == nil || fmt.Sprint(value) == "" { return fallback, nil } parsed, err := strconv.Atoi(fmt.Sprint(value)) if err != nil || parsed < 1 { return 0, errors.New("必须为正整数") } return parsed, nil } func (s *AdminData) ToggleResource(resource, id string, enabled bool) error { spec, ok := resourceSpecs[resource] if !ok { return errors.New("不支持的资源类型") } if resource == "models" && enabled { var channelEnabled bool if err := s.DB.Raw(`SELECT c.enabled FROM models m JOIN channels c ON c.id=m.channel_id WHERE m.id=? AND m.deleted_at IS NULL AND c.deleted_at IS NULL`, id).Scan(&channelEnabled).Error; err != nil { return err } if !channelEnabled { return errors.New("所属渠道已禁用,无法启用该模型") } } if resource == "channels" { return s.DB.Transaction(func(tx *gorm.DB) error { result := tx.Table("channels").Where("id = ? AND deleted_at IS NULL", id).Update("enabled", enabled) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } return tx.Table("models").Where("channel_id = ? AND deleted_at IS NULL", id).Update("enabled", enabled).Error }) } result := s.DB.Table(spec.Table).Where("id = ? AND deleted_at IS NULL", id).Update("enabled", enabled) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (s *AdminData) DeleteResource(resource, id string) error { spec, ok := resourceSpecs[resource] if !ok || !spec.SoftDelete { return errors.New("不支持删除该资源") } result := s.DB.Table(spec.Table).Where("id = ? AND deleted_at IS NULL", id).Update("deleted_at", time.Now()) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } func (s *AdminData) ListUsers(keyword, enabled, pageRaw, sizeRaw string) (Page, error) { page, size := pageArgs(pageRaw, sizeRaw) query := s.DB.Table("web_users u").Where("u.deleted_at IS NULL") if keyword = strings.TrimSpace(keyword); keyword != "" { query = query.Where("u.account::text ILIKE ? OR u.username::text ILIKE ? OR u.uid = ?", "%"+keyword+"%", "%"+keyword+"%", keyword) } if enabled != "" { query = query.Where("u.enabled = ?", enabled) } var total int64 if err := query.Count(&total).Error; err != nil { return Page{}, err } items := make([]map[string]any, 0) err := query.Select(`u.id,u.account,u.username,u.uid,u.point_balance,u.daily_limit,u.enabled,u.last_online_at,u.created_at, coalesce((SELECT sum(-l.change_amount) FROM point_ledger l WHERE l.user_id=u.id AND l.change_amount<0 AND l.created_at>=CURRENT_DATE AND l.created_at end { return nil, errors.New("起始序号不能大于终止序号") } count := end - start + 1 if count > 50 { return nil, errors.New("每次最多批量创建 50 个用户") } width := max(len(startSequence), len(endSequence)) accounts := make([]string, 0, count) for sequence := start; sequence <= end; sequence++ { accounts = append(accounts, fmt.Sprintf("%s-%0*d", prefix, width, sequence)) } return accounts, nil } func createUserWithAccount(tx *gorm.DB, account, passwordHash string, dailyLimit any) (map[string]any, error) { for range 20 { username, err := randomUsername() if err != nil { return nil, err } uid, err := newUID(tx) if err != nil { return nil, err } id := uuid.NewString() result := tx.Exec(`INSERT INTO web_users(id,uid,username,account,password_hash,daily_limit,enabled) VALUES(?,?,?,?,?,?,true) ON CONFLICT DO NOTHING`, id, uid, username, account, passwordHash, dailyLimit) if result.Error != nil { return nil, result.Error } if result.RowsAffected == 1 { return map[string]any{"id": id, "uid": uid, "account": account, "username": username}, nil } var accountExists int64 if err := tx.Table("web_users").Where("account = ?", account).Count(&accountExists).Error; err != nil { return nil, err } if accountExists > 0 { return nil, fmt.Errorf("账号 %s 已存在", account) } } return nil, errors.New("用户名生成失败,请重试") } func randomUsername() (string, error) { lengthOffset, err := rand.Int(rand.Reader, big.NewInt(8)) if err != nil { return "", err } username := make([]byte, 5+lengthOffset.Int64()) for i := range username { index, err := rand.Int(rand.Reader, big.NewInt(int64(len(usernameAlphabet)))) if err != nil { return "", err } username[i] = usernameAlphabet[index.Int64()] } return string(username), nil } func newUID(tx *gorm.DB) (string, error) { for i := 0; i < 20; i++ { n, err := rand.Int(rand.Reader, big.NewInt(90000000)) if err != nil { return "", err } uid := fmt.Sprintf("%08d", n.Int64()+10000000) var count int64 if err := tx.Table("web_users").Where("uid=?", uid).Count(&count).Error; err != nil { return "", err } if count == 0 { return uid, nil } } return "", errors.New("UID 生成失败,请重试") } func (s *AdminData) UpdateUsers(ids []string, updates map[string]any) error { if len(ids) == 0 { return errors.New("请选择用户") } allowed := map[string]any{} if value, ok := updates["daily_limit"]; ok { allowed["daily_limit"] = value } if value, ok := updates["enabled"]; ok { allowed["enabled"] = value allowed["session_version"] = gorm.Expr("session_version + 1") } if len(allowed) == 0 { return errors.New("没有可更新的字段") } return s.DB.Table("web_users").Where("id IN ? AND deleted_at IS NULL", ids).Updates(allowed).Error } func (s *AdminData) DeleteUsers(ids []string) error { if len(ids) == 0 { return errors.New("请选择用户") } return s.DB.Table("web_users").Where("id IN ? AND deleted_at IS NULL", ids).Updates(map[string]any{"deleted_at": time.Now(), "enabled": false, "session_version": gorm.Expr("session_version + 1")}).Error } var placeholderPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`) func ValidatePromptVariables(content string, variables []string) error { allowed := map[string]bool{} for _, v := range variables { allowed[v] = true } missing := []string{} for _, m := range placeholderPattern.FindAllStringSubmatch(content, -1) { if !allowed[m[1]] { missing = append(missing, m[1]) } } sort.Strings(missing) if len(missing) > 0 { return fmt.Errorf("正文使用了未声明变量:%s", strings.Join(missing, "、")) } return nil } func GenerateRedemptionCode() (plain, hash, mask string, err error) { const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" const firstAlphabet = "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" raw := make([]byte, 30) for i := range raw { chars := alphabet if i == 0 { chars = firstAlphabet } index, randomErr := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) if randomErr != nil { err = randomErr return } raw[i] = chars[index.Int64()] } normalized := string(raw) groups := make([]string, 0, 5) for start := 0; start < len(raw); start += 6 { groups = append(groups, string(raw[start:start+6])) } plain = strings.Join(groups, "-") sum := sha256.Sum256([]byte(normalized)) hash = hex.EncodeToString(sum[:]) mask = normalized[:3] + "***-******-******-******-" + normalized[len(normalized)-3:] return }