初始化

This commit is contained in:
Ran
2026-08-25 17:59:42 +08:00
commit 4b7380dd9b
408 changed files with 327400 additions and 0 deletions
+575
View File
@@ -0,0 +1,575 @@
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 != "<nil>" && s.Encryptor != nil {
if plain, err := s.Encryptor.Decrypt(ciphertext); err == nil {
item["api_key_masked"] = maskAPIKey(plain)
continue
}
}
if last4 != "" && last4 != "<nil>" {
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<CURRENT_DATE+INTERVAL '1 day'),0) AS today_consumption`).
Order("u.created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
return Page{Items: items, Total: total, Page: page, PageSize: size}, err
}
func (s *AdminData) CreateUser(account, password string, dailyLimit any) (map[string]any, error) {
account = strings.TrimSpace(account)
if account == "" {
return nil, errors.New("账号不能为空")
}
items, err := s.createUsers([]string{account}, password, dailyLimit)
if err != nil {
return nil, err
}
return items[0], nil
}
func (s *AdminData) BatchCreateUsers(prefix, startSequence, endSequence, password string, dailyLimit any) ([]map[string]any, error) {
accounts, err := buildBatchAccounts(prefix, startSequence, endSequence)
if err != nil {
return nil, err
}
return s.createUsers(accounts, password, dailyLimit)
}
func (s *AdminData) createUsers(accounts []string, password string, dailyLimit any) ([]map[string]any, error) {
hash, err := s.Passwords.Hash(password)
if err != nil {
return nil, err
}
created := make([]map[string]any, 0, len(accounts))
err = s.DB.Transaction(func(tx *gorm.DB) error {
var existing string
if err := tx.Table("web_users").Select("account").Where("account IN ?", accounts).Limit(1).Scan(&existing).Error; err != nil {
return err
}
if existing != "" {
return fmt.Errorf("账号 %s 已存在", existing)
}
for _, account := range accounts {
user, err := createUserWithAccount(tx, account, hash, dailyLimit)
if err != nil {
return err
}
created = append(created, user)
}
return nil
})
return created, err
}
const usernameAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
var sequencePattern = regexp.MustCompile(`^[0-9]+$`)
func buildBatchAccounts(prefix, startSequence, endSequence string) ([]string, error) {
prefix = strings.TrimSpace(prefix)
startSequence = strings.TrimSpace(startSequence)
endSequence = strings.TrimSpace(endSequence)
if prefix == "" {
return nil, errors.New("账号前缀不能为空")
}
if !sequencePattern.MatchString(startSequence) || !sequencePattern.MatchString(endSequence) {
return nil, errors.New("起始序号和终止序号必须为数字")
}
start, err := strconv.Atoi(startSequence)
if err != nil {
return nil, errors.New("起始序号无效")
}
end, err := strconv.Atoi(endSequence)
if err != nil {
return nil, errors.New("终止序号无效")
}
if start > 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
}