492 lines
17 KiB
Go
492 lines
17 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"infinite-canvas/backend/internal/model"
|
|
"infinite-canvas/backend/internal/repository"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const CreditScale int64 = 1_000_000
|
|
|
|
type WalletSummary struct {
|
|
Account model.CreditAccount `json:"account"`
|
|
Entries []model.CreditLedgerEntry `json:"entries"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
Limit int `json:"limit"`
|
|
Policy PublicCreditPolicy `json:"policy"`
|
|
}
|
|
|
|
type RedeemBatchPage struct {
|
|
Batches []model.RedeemBatch `json:"batches"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
type AdminRedeemCodeDetail struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code,omitempty"`
|
|
CodeSuffix string `json:"codeSuffix"`
|
|
Status string `json:"status"`
|
|
RedeemedBy string `json:"redeemedBy,omitempty"`
|
|
RedeemedUsername string `json:"redeemedUsername,omitempty"`
|
|
RedeemedDisplayName string `json:"redeemedDisplayName,omitempty"`
|
|
RedeemedAt *time.Time `json:"redeemedAt"`
|
|
RedeemedIP string `json:"redeemedIp,omitempty"`
|
|
ExpiresAt *time.Time `json:"expiresAt"`
|
|
AmountMicrocredits int64 `json:"amountMicrocredits"`
|
|
}
|
|
|
|
type AdminRedeemCodePage struct {
|
|
Batch model.RedeemBatch `json:"batch"`
|
|
Codes []AdminRedeemCodeDetail `json:"codes"`
|
|
PlaintextAvailable bool `json:"plaintextAvailable"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
type BillingOrderPage struct {
|
|
Orders []model.BillingOrder `json:"orders"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
type CreateRedeemBatchRequest struct {
|
|
AmountMicrocredits int64 `json:"amountMicrocredits"`
|
|
Count int `json:"count"`
|
|
Note string `json:"note"`
|
|
ExpiresAt *time.Time `json:"expiresAt"`
|
|
}
|
|
|
|
type CreateRedeemBatchResult struct {
|
|
Batch model.RedeemBatch `json:"batch"`
|
|
Codes []string `json:"codes"`
|
|
}
|
|
|
|
type AdminCreditAdjustmentRequest struct {
|
|
AmountMicrocredits int64 `json:"amountMicrocredits"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
type ResolveBillingRequest struct {
|
|
Action string `json:"action"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
func (s *Service) Wallet(user *model.User, entryType string, page int, limit int) (*WalletSummary, error) {
|
|
if user == nil {
|
|
return nil, Unauthorized("请先登录")
|
|
}
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 30
|
|
}
|
|
account, err := s.repo.CreditAccount(user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, total, err := s.repo.CreditLedger(user.ID, strings.TrimSpace(entryType), limit, (page-1)*limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
policy, err := s.publicCreditPolicy(user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &WalletSummary{Account: *account, Entries: entries, Total: total, Page: page, Limit: limit, Policy: policy}, nil
|
|
}
|
|
|
|
func (s *Service) RedeemCredits(user *model.User, code string, redeemedIP string) (*model.CreditAccount, error) {
|
|
if user == nil {
|
|
return nil, Unauthorized("请先登录")
|
|
}
|
|
code = strings.ToLower(strings.TrimSpace(code))
|
|
if len(code) != 32 {
|
|
return nil, BadAuthRequest("兑换码无效或已使用")
|
|
}
|
|
account, err := s.repo.RedeemCode(user.ID, hashRedeemCode(code), truncateRunes(strings.TrimSpace(redeemedIP), 64))
|
|
if errors.Is(err, repository.ErrRedeemCodeInvalid) {
|
|
return nil, BadAuthRequest("兑换码无效或已使用")
|
|
}
|
|
return account, err
|
|
}
|
|
|
|
func (s *Service) AdminCreateRedeemBatch(actor *model.User, req CreateRedeemBatchRequest) (*CreateRedeemBatchResult, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.AmountMicrocredits <= 0 {
|
|
return nil, BadAuthRequest("兑换码积分必须大于 0")
|
|
}
|
|
if req.Count <= 0 || req.Count > 5000 {
|
|
return nil, BadAuthRequest("单批兑换码数量需为 1-5000")
|
|
}
|
|
if req.ExpiresAt != nil && !req.ExpiresAt.After(time.Now()) {
|
|
return nil, BadAuthRequest("兑换码过期时间必须晚于当前时间")
|
|
}
|
|
batch := model.RedeemBatch{ID: newID(), AmountMicrocredits: req.AmountMicrocredits, Count: req.Count, Note: truncateRunes(strings.TrimSpace(req.Note), 500), CreatedBy: actor.ID, ExpiresAt: req.ExpiresAt}
|
|
codes := make([]string, 0, req.Count)
|
|
items := make([]model.RedeemCode, 0, req.Count)
|
|
for range req.Count {
|
|
plain, err := newRedeemCode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
codes = append(codes, plain)
|
|
items = append(items, model.RedeemCode{
|
|
ID: newID(), BatchID: batch.ID, CodeHash: hashRedeemCode(plain), CodeSuffix: plain[len(plain)-4:],
|
|
AmountMicrocredits: req.AmountMicrocredits, Status: model.RedeemCodeUnused, ExpiresAt: req.ExpiresAt,
|
|
})
|
|
}
|
|
encodedCodes, err := json.Marshal(codes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
batch.CodesCipher, err = s.encryptSettingSecret(string(encodedCodes))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// SQLite 只有一个写入器;批次生成串行进入短事务,避免并发生成占满连接池拖住全站读取。
|
|
s.redeemBatchMu.Lock()
|
|
defer s.redeemBatchMu.Unlock()
|
|
if err := s.repo.CreateRedeemBatch(&batch, items); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.appendAdminAudit(actor, "redeem_batch.create", "redeem_batch", batch.ID, "创建兑换码批次", map[string]any{"count": batch.Count, "amountMicrocredits": batch.AmountMicrocredits}); err != nil {
|
|
return nil, err
|
|
}
|
|
return &CreateRedeemBatchResult{Batch: batch, Codes: codes}, nil
|
|
}
|
|
|
|
func (s *Service) AdminRedeemCodePage(actor *model.User, batchID string, status string, page int, limit int) (*AdminRedeemCodePage, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
batch, err := s.repo.RedeemBatch(strings.TrimSpace(batchID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
page, limit = normalizeAdminPage(page, limit)
|
|
rows, total, err := s.repo.AdminRedeemCodes(batch.ID, strings.TrimSpace(status), limit, (page-1)*limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plainCodes, err := s.redeemBatchPlainCodes(batch.CodesCipher)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plainByHash := make(map[string]string, len(plainCodes))
|
|
for _, code := range plainCodes {
|
|
plainByHash[hashRedeemCode(code)] = code
|
|
}
|
|
now := time.Now()
|
|
details := make([]AdminRedeemCodeDetail, 0, len(rows))
|
|
for _, row := range rows {
|
|
status := string(row.Status)
|
|
if row.Status == model.RedeemCodeUnused && row.ExpiresAt != nil && !row.ExpiresAt.After(now) {
|
|
status = "expired"
|
|
}
|
|
details = append(details, AdminRedeemCodeDetail{
|
|
ID: row.ID, Code: plainByHash[row.CodeHash], CodeSuffix: row.CodeSuffix, Status: status,
|
|
RedeemedBy: row.RedeemedBy, RedeemedUsername: row.RedeemedUsername, RedeemedDisplayName: row.RedeemedDisplayName,
|
|
RedeemedAt: row.RedeemedAt, RedeemedIP: row.RedeemedIP, ExpiresAt: row.ExpiresAt, AmountMicrocredits: row.AmountMicrocredits,
|
|
})
|
|
}
|
|
batch.CodesCipher = ""
|
|
return &AdminRedeemCodePage{Batch: *batch, Codes: details, PlaintextAvailable: len(plainCodes) > 0, Total: total, Page: page, Limit: limit}, nil
|
|
}
|
|
|
|
func (s *Service) redeemBatchPlainCodes(ciphertext string) ([]string, error) {
|
|
if strings.TrimSpace(ciphertext) == "" {
|
|
return nil, nil
|
|
}
|
|
encoded, err := s.decryptSettingSecret(ciphertext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("兑换码批次密文无法解密:%w", err)
|
|
}
|
|
var codes []string
|
|
if err := json.Unmarshal([]byte(encoded), &codes); err != nil {
|
|
return nil, errors.New("兑换码批次密文内容无效")
|
|
}
|
|
return codes, nil
|
|
}
|
|
|
|
func (s *Service) AdminRedeemBatchPage(actor *model.User, query AdminListQuery) (*RedeemBatchPage, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
page, limit := normalizeAdminPage(query.Page, query.Limit)
|
|
items, total, err := s.repo.AdminRedeemBatches(query.Keyword, query.Status, limit, (page-1)*limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &RedeemBatchPage{Batches: items, Total: total, Page: page, Limit: limit}, nil
|
|
}
|
|
|
|
func (s *Service) AdminAdjustCredits(actor *model.User, userID string, req AdminCreditAdjustmentRequest) (*model.CreditAccount, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.AmountMicrocredits == 0 {
|
|
return nil, BadAuthRequest("调账积分不能为 0")
|
|
}
|
|
note := strings.TrimSpace(req.Note)
|
|
if note == "" {
|
|
return nil, BadAuthRequest("请填写调账原因")
|
|
}
|
|
if _, err := s.repo.User(userID); err != nil {
|
|
return nil, err
|
|
}
|
|
account, err := s.repo.AdjustCredits(userID, actor.ID, req.AmountMicrocredits, truncateRunes(note, 500))
|
|
if errors.Is(err, repository.ErrInsufficientCredits) {
|
|
return nil, BadAuthRequest("用户可用积分不足,不能执行本次扣减")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.appendAdminAudit(actor, "credits.adjust", "user", userID, "管理员调整用户积分", map[string]any{"amountMicrocredits": req.AmountMicrocredits, "note": truncateRunes(note, 500)}); err != nil {
|
|
return nil, err
|
|
}
|
|
return account, nil
|
|
}
|
|
|
|
func (s *Service) AdminBillingOrderPage(actor *model.User, query AdminListQuery) (*BillingOrderPage, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
page, limit := normalizeAdminPage(query.Page, query.Limit)
|
|
items, total, err := s.repo.AdminBillingOrders(query.Status, query.Keyword, limit, (page-1)*limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &BillingOrderPage{Orders: items, Total: total, Page: page, Limit: limit}, nil
|
|
}
|
|
|
|
func (s *Service) ResolveBillingOrder(actor *model.User, id string, req ResolveBillingRequest) (*model.BillingOrder, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return nil, err
|
|
}
|
|
note := strings.TrimSpace(req.Note)
|
|
if note == "" {
|
|
return nil, BadAuthRequest("请填写核对依据")
|
|
}
|
|
order, err := s.repo.BillingOrder(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if order.Status != model.BillingStatusUncertain && order.Status != model.BillingStatusRunning && order.Status != model.BillingStatusReserved {
|
|
return nil, BadAuthRequest("当前订单不需要人工核对")
|
|
}
|
|
switch strings.TrimSpace(req.Action) {
|
|
case "settle":
|
|
err = s.SettleBilling(id, order.ProviderRequestID)
|
|
case "refund":
|
|
err = s.RefundBilling(id, note)
|
|
default:
|
|
return nil, BadAuthRequest("请选择结算或退款")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.repo.RecordBillingResolution(id, actor.ID, truncateRunes(note, 500)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.appendAdminAudit(actor, "billing.resolve", "user", order.UserID, "人工核对用户计费订单", map[string]any{"billingOrderId": id, "action": req.Action, "note": truncateRunes(note, 500)}); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.BillingOrder(id)
|
|
}
|
|
|
|
func (s *Service) AdminDisableRedeemBatch(actor *model.User, batchID string) (int64, error) {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := s.repo.RedeemBatch(strings.TrimSpace(batchID)); err != nil {
|
|
return 0, err
|
|
}
|
|
count, err := s.repo.DisableRedeemBatch(batchID, time.Now())
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if count == 0 {
|
|
return 0, BadAuthRequest("该批次没有可禁用的兑换码")
|
|
}
|
|
if err := s.appendAdminAudit(actor, "redeem_batch.disable", "redeem_batch", batchID, "禁用批次内全部未使用兑换码", map[string]any{"disabledCount": count}); err != nil {
|
|
return 0, err
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func (s *Service) AdminDisableRedeemCode(actor *model.User, batchID string, codeID string) error {
|
|
if err := s.RequireAdmin(actor); err != nil {
|
|
return err
|
|
}
|
|
disabled, err := s.repo.DisableRedeemCode(batchID, codeID, time.Now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !disabled {
|
|
return BadAuthRequest("兑换码不存在、已使用、已禁用或已过期")
|
|
}
|
|
return s.appendAdminAudit(actor, "redeem_code.disable", "redeem_code", codeID, "禁用单个兑换码", map[string]any{"batchId": batchID})
|
|
}
|
|
|
|
func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[string]any) (*model.BillingOrder, error) {
|
|
config, _ := input["config"].(map[string]any)
|
|
if config == nil {
|
|
return nil, nil
|
|
}
|
|
channelID := strings.TrimSpace(fmt.Sprint(config["channelId"]))
|
|
if channelID == "" {
|
|
channelID = systemChannelIDFromBaseURL(fmt.Sprint(config["baseUrl"]))
|
|
}
|
|
if channelID == "" {
|
|
return nil, nil
|
|
}
|
|
modelKey := strings.TrimPrefix(strings.TrimSpace(fmt.Sprint(config["model"])), "models/")
|
|
capability := normalizeCapability(fmt.Sprint(input["mode"]))
|
|
if capability == "" {
|
|
capability = capabilityFromTaskType(task.Type)
|
|
}
|
|
scene := firstNonEmpty(strings.TrimSpace(task.Operation), task.Type)
|
|
return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene)
|
|
}
|
|
|
|
func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string) (*model.BillingOrder, error) {
|
|
if strings.TrimSpace(idempotencyKey) == "" {
|
|
idempotencyKey = newID()
|
|
}
|
|
order, err := s.newBillingOrder(userID, "", "proxy:"+idempotencyKey, channelID, modelKey, capability, firstNonEmpty(strings.TrimSpace(scene), "system_proxy"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.repo.ReserveBillingOrder(order); err != nil {
|
|
if errors.Is(err, repository.ErrInsufficientCredits) {
|
|
return nil, BadAuthRequest("积分不足,请先使用兑换码充值")
|
|
}
|
|
return nil, err
|
|
}
|
|
return order, nil
|
|
}
|
|
|
|
func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string) (*model.BillingOrder, error) {
|
|
item, err := s.repo.ChannelModelByKey(channelID, modelKey)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, BadAuthRequest("当前系统渠道模型未配置或已停用")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !item.PriceConfigured {
|
|
return nil, BadAuthRequest("当前模型尚未配置用户积分价格")
|
|
}
|
|
if item.BillingMode != "fixed_request" {
|
|
return nil, BadAuthRequest("当前模型计费方式暂不支持")
|
|
}
|
|
policy, err := s.creditPolicy()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
multiplierBPS := policy.DefaultMultiplierBPS
|
|
if configured := policy.ModelMultiplierBPS[modelKey]; configured > 0 {
|
|
multiplierBPS = configured
|
|
}
|
|
amount, err := creditAmount(item.UnitPriceMicrocredits, multiplierBPS)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &model.BillingOrder{
|
|
ID: newID(), UserID: userID, IdempotencyKey: idempotencyKey, TaskID: taskID,
|
|
ChannelID: channelID, ChannelModelID: item.ID, Model: modelKey, Capability: capability,
|
|
Scene: truncateRunes(scene, 80), BillingMode: item.BillingMode, PriceVersion: item.PriceVersion,
|
|
UnitPriceMicrocredits: item.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: 1, AmountMicrocredits: amount,
|
|
Status: model.BillingStatusReserved,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) MarkBillingRunning(orderID string) error {
|
|
if orderID == "" {
|
|
return nil
|
|
}
|
|
return s.repo.MarkBillingRunning(orderID)
|
|
}
|
|
|
|
func (s *Service) SettleBilling(orderID string, providerRequestID string) error {
|
|
if orderID == "" {
|
|
return nil
|
|
}
|
|
return s.repo.SettleBillingOrder(orderID, providerRequestID)
|
|
}
|
|
|
|
func (s *Service) RefundBilling(orderID string, errorText string) error {
|
|
if orderID == "" {
|
|
return nil
|
|
}
|
|
return s.repo.RefundBillingOrder(orderID, truncateRunes(errorText, 1000))
|
|
}
|
|
|
|
func (s *Service) MarkBillingUncertain(orderID string, errorText string) error {
|
|
if orderID == "" {
|
|
return nil
|
|
}
|
|
return s.repo.MarkBillingUncertain(orderID, truncateRunes(errorText, 1000))
|
|
}
|
|
|
|
func (s *Service) BillingFailureRequiresReview(orderID string, taskID string, err error) bool {
|
|
if orderID == "" {
|
|
return false
|
|
}
|
|
if billingFailureUncertain(err) {
|
|
return true
|
|
}
|
|
order, orderErr := s.repo.BillingOrder(orderID)
|
|
if orderErr != nil || order.Status == model.BillingStatusUncertain {
|
|
return true
|
|
}
|
|
hasSuccessfulCall, logErr := s.repo.TaskHasSuccessfulBillableCall(taskID)
|
|
return logErr != nil || hasSuccessfulCall
|
|
}
|
|
|
|
func billingFailureUncertain(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
for _, marker := range []string{"524", "timeout", "超时", "deadline exceeded", "context canceled", "connection reset", "unexpected eof", "broken pipe"} {
|
|
if strings.Contains(message, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func newRedeemCode() (string, error) {
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", err
|
|
}
|
|
raw[6] = (raw[6] & 0x0f) | 0x40
|
|
raw[8] = (raw[8] & 0x3f) | 0x80
|
|
return hex.EncodeToString(raw[:]), nil
|
|
}
|
|
|
|
func hashRedeemCode(code string) string {
|
|
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(code))))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|