Files
smartmate/backend/services/tokenstore/sv/credit_helpers.go
Losita 7b04b073ce Version: 0.9.81.dev.260506
后端:
1. Credit 价格规则补齐利润率与实际计费单价语义:新增 `profit_rate_bps` 与 `charge_*_price_micros` 展示字段,下沉共享价格推导 helper,tokenstore rpc/client/proto/model/default rule 全链路同步,LLM usage 扣费统一改按加价后的 charge 单价换算。
2. task-class 更新链路修正全量覆盖与归属校验:`runtime/conv` 保留 item id,DAO 更新前显式校验 task-class 与 item 归属,改用显式字段 map 落库 nil/空切片/零值,避免 `RowsAffected=0` 误判越权,同时补齐任务项可编辑字段更新。
3. GormCache task-class 失效补空 user_id 保护:更新语句缺少模型上下文时直接跳过失效,避免缓存插件因空指针影响主事务。

前端:
4. 课表中心补齐任务类编辑能力:新增 `updateTaskClass` API,创建弹窗支持编辑态回填与 item id 提交,日程页支持先拉详情再编辑并在保存后刷新任务类详情与列表。
5. 计划广场详情补点赞交互与奖励提示:详情页新增点赞/取消点赞按钮、奖励反馈文案与计数展示,论坛类型补 `reward_hint`,评论区与帖子作者头像统一接入兜底头像工具。
6. 品牌与展示细节收口:侧边栏与 favicon 切到项目 logo,首页标题改为 `SmartMate`,主面板缩放上限微调,论坛列表头像显示与整体品牌观感同步统一。
2026-05-06 21:53:17 +08:00

470 lines
15 KiB
Go

package sv
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
creditSnapshotSourceCache = "cache"
creditSnapshotSourceDB = "db"
)
type creditProductSnapshot struct {
ProductID uint64 `json:"product_id"`
SKU string `json:"sku"`
Name string `json:"name"`
Description string `json:"description"`
CreditAmount int64 `json:"credit_amount"`
PriceCent int64 `json:"price_cent"`
OriginalPriceCent int64 `json:"original_price_cent"`
PriceText string `json:"price_text"`
Currency string `json:"currency"`
Badge string `json:"badge"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
type applyCreditLedgerRequest struct {
EventID string
UserID uint64
Source string
SourceLabel string
Direction string
OrderID *uint64
SourceRefID *string
Amount int64
Status string
Description string
MetadataJSON string
CreatedAt time.Time
}
func creditPageResult(page int, pageSize int, total int64) creditcontracts.PageResult {
return creditcontracts.PageResult{
Page: page,
PageSize: pageSize,
Total: int(total),
HasMore: int64(page*pageSize) < total,
}
}
func creditProductViewFromModel(product storemodel.CreditProduct) creditcontracts.CreditProductView {
originalPriceCent := normalizeOriginalPriceCent(product.OriginalPriceCent, product.PriceCent)
return creditcontracts.CreditProductView{
ProductID: product.ID,
Name: product.Name,
Description: product.Description,
CreditAmount: product.CreditAmount,
PriceCent: product.PriceCent,
OriginalPriceCent: originalPriceCent,
PriceText: formatPriceText(product.Currency, product.PriceCent),
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: product.SortOrder,
}
}
func creditOrderViewFromModel(order storemodel.CreditOrder) creditcontracts.CreditOrderView {
return creditcontracts.CreditOrderView{
OrderID: order.ID,
OrderNo: order.OrderNo,
Status: order.Status,
ProductSnapshot: order.ProductSnapshotJSON,
ProductName: order.ProductName,
Quantity: order.Quantity,
CreditAmount: order.CreditAmount,
AmountCent: order.AmountCent,
PriceText: formatPriceText(order.Currency, order.AmountCent),
Currency: order.Currency,
PaymentMode: order.PaymentMode,
CreatedAt: formatTime(order.CreatedAt),
PaidAt: formatTimePtr(order.PaidAt),
CreditedAt: formatTimePtr(order.CreditedAt),
}
}
func creditTransactionViewFromModel(ledger storemodel.CreditLedger) creditcontracts.CreditTransactionView {
return creditcontracts.CreditTransactionView{
TransactionID: ledger.ID,
EventID: ledger.EventID,
Source: ledger.Source,
SourceLabel: creditSourceLabel(ledger.Source, ledger.SourceLabel),
Direction: ledger.Direction,
Amount: ledger.Amount,
BalanceAfter: ledger.BalanceAfter,
Status: ledger.Status,
Description: ledger.Description,
MetadataJSON: ledger.MetadataJSON,
CreatedAt: formatTime(ledger.CreatedAt),
OrderID: ledger.OrderID,
}
}
func creditPriceRuleViewFromModel(rule storemodel.CreditPriceRule) creditcontracts.CreditPriceRuleView {
chargePrices := creditcontracts.DeriveChargePriceMicrosSet(
rule.InputPriceMicros,
rule.OutputPriceMicros,
rule.CachedPriceMicros,
rule.ReasoningPriceMicros,
rule.ProfitRateBps,
)
return creditcontracts.CreditPriceRuleView{
RuleID: rule.ID,
Scene: rule.Scene,
ProviderName: rule.ProviderName,
ModelName: rule.ModelName,
InputPriceMicros: rule.InputPriceMicros,
OutputPriceMicros: rule.OutputPriceMicros,
CachedPriceMicros: rule.CachedPriceMicros,
ReasoningPriceMicros: rule.ReasoningPriceMicros,
CreditPerYuan: rule.CreditPerYuan,
ProfitRateBps: rule.ProfitRateBps,
ChargeInputPriceMicros: chargePrices.InputChargePriceMicros,
ChargeOutputPriceMicros: chargePrices.OutputChargePriceMicros,
ChargeCachedPriceMicros: chargePrices.CachedChargePriceMicros,
ChargeReasoningPriceMicros: chargePrices.ReasoningChargePriceMicros,
Status: rule.Status,
Priority: rule.Priority,
Description: rule.Description,
}
}
func creditRewardRuleViewFromModel(rule storemodel.CreditRewardRule) creditcontracts.CreditRewardRuleView {
return creditcontracts.CreditRewardRuleView{
RuleID: rule.ID,
Source: rule.Source,
Name: rule.Name,
Amount: rule.Amount,
Status: rule.Status,
Description: rule.Description,
}
}
func buildCreditProductSnapshot(product storemodel.CreditProduct) (string, error) {
originalPriceCent := normalizeOriginalPriceCent(product.OriginalPriceCent, product.PriceCent)
snapshot := creditProductSnapshot{
ProductID: product.ID,
SKU: product.SKU,
Name: product.Name,
Description: product.Description,
CreditAmount: product.CreditAmount,
PriceCent: product.PriceCent,
OriginalPriceCent: originalPriceCent,
PriceText: formatPriceText(product.Currency, product.PriceCent),
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: product.SortOrder,
}
raw, err := json.Marshal(snapshot)
if err != nil {
return "", err
}
return string(raw), nil
}
func normalizeOriginalPriceCent(originalPriceCent int64, priceCent int64) int64 {
if originalPriceCent > 0 {
return originalPriceCent
}
return priceCent
}
func newCreditOrderNo() string {
return fmt.Sprintf(
"CS%s%s",
time.Now().Format("20060102150405"),
strings.ReplaceAll(uuid.NewString(), "-", ""),
)
}
func creditOrderLedgerEventID(orderID uint64) string {
return fmt.Sprintf("credit-order:%d:paid", orderID)
}
func creditSourceLabel(source string, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return fallback
}
switch strings.TrimSpace(source) {
case storemodel.CreditLedgerSourcePurchase:
return "购买充值"
case storemodel.CreditLedgerSourceCharge:
return "AI 调用扣费"
case storemodel.CreditLedgerSourceForumLike:
return "计划被点赞"
case storemodel.CreditLedgerSourceForumImport:
return "计划被导入"
case storemodel.CreditLedgerSourceManual:
return "人工补发"
default:
return "Credit 流水"
}
}
func creditDirectionFromAmount(amount int64) string {
if amount < 0 {
return storemodel.CreditLedgerDirectionExpense
}
return storemodel.CreditLedgerDirectionIncome
}
func creditShouldAffectBalance(req applyCreditLedgerRequest) bool {
return strings.TrimSpace(req.Status) == storemodel.CreditLedgerStatusApplied && req.Amount != 0
}
func (s *Service) applyCreditLedger(ctx context.Context, req applyCreditLedgerRequest) (*storemodel.CreditLedger, *storemodel.CreditAccount, error) {
if err := s.Ready(); err != nil {
return nil, nil, err
}
normalized, err := normalizeApplyCreditLedgerRequest(req)
if err != nil {
return nil, nil, err
}
var resultLedger *storemodel.CreditLedger
var resultAccount *storemodel.CreditAccount
err = s.creditDAO.Transaction(ctx, func(txDAO *tokenstoredao.CreditStoreDAO) error {
ledger, account, err := s.applyCreditLedgerWithDAO(ctx, txDAO, normalized)
if err != nil {
return err
}
resultLedger = ledger
resultAccount = account
return nil
})
if err != nil {
return nil, nil, err
}
s.syncCreditCacheBestEffort(ctx, normalized.UserID, resultAccount, resultLedger)
return resultLedger, resultAccount, nil
}
func (s *Service) applyCreditLedgerWithDAO(ctx context.Context, txDAO *tokenstoredao.CreditStoreDAO, req applyCreditLedgerRequest) (*storemodel.CreditLedger, *storemodel.CreditAccount, error) {
if txDAO == nil {
return nil, nil, errors.New("credit dao is nil")
}
existing, findErr := txDAO.FindLedgerByEventID(ctx, req.EventID)
if findErr != nil {
return nil, nil, findErr
}
if existing != nil {
if err := validateExistingCreditLedger(*existing, req); err != nil {
return nil, nil, err
}
account, accountErr := txDAO.FindAccountByUserID(ctx, req.UserID)
if accountErr != nil {
return nil, nil, accountErr
}
return existing, account, nil
}
account, accountErr := txDAO.LockAccountByUserID(ctx, req.UserID)
if accountErr != nil {
return nil, nil, accountErr
}
if account == nil && creditShouldAffectBalance(req) {
account = &storemodel.CreditAccount{
UserID: req.UserID,
}
if err := txDAO.CreateAccount(ctx, account); err != nil {
if !isDuplicateKeyError(err) {
return nil, nil, err
}
account, err = txDAO.LockAccountByUserID(ctx, req.UserID)
if err != nil {
return nil, nil, err
}
if account == nil {
return nil, nil, errors.New("credit account duplicated but not found by user_id")
}
}
}
balanceBefore := int64(0)
if account != nil {
balanceBefore = account.Balance
}
balanceAfter := balanceBefore
if creditShouldAffectBalance(req) {
balanceAfter += req.Amount
}
ledger := &storemodel.CreditLedger{
EventID: req.EventID,
UserID: req.UserID,
Source: req.Source,
SourceLabel: creditSourceLabel(req.Source, req.SourceLabel),
Direction: req.Direction,
OrderID: req.OrderID,
SourceRefID: req.SourceRefID,
Amount: req.Amount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceAfter,
Status: req.Status,
Description: req.Description,
MetadataJSON: req.MetadataJSON,
CreatedAt: req.CreatedAt,
}
if err := txDAO.CreateLedger(ctx, ledger); err != nil {
if !isDuplicateKeyError(err) {
return nil, nil, err
}
existing, findErr := txDAO.FindLedgerByEventID(ctx, req.EventID)
if findErr != nil {
return nil, nil, findErr
}
if existing != nil {
if err := validateExistingCreditLedger(*existing, req); err != nil {
return nil, nil, err
}
account, accountErr := txDAO.FindAccountByUserID(ctx, req.UserID)
if accountErr != nil {
return nil, nil, accountErr
}
return existing, account, nil
}
return nil, nil, errors.New("credit ledger duplicated but not found by event_id")
}
if creditShouldAffectBalance(req) {
account.Balance = balanceAfter
account.LastLedgerEventID = req.EventID
if req.Amount > 0 {
if req.Source == storemodel.CreditLedgerSourcePurchase {
account.TotalRecharged += req.Amount
} else {
account.TotalRewarded += req.Amount
}
} else {
account.TotalConsumed += -req.Amount
}
if err := txDAO.SaveAccount(ctx, account); err != nil {
return nil, nil, err
}
}
return ledger, account, nil
}
func normalizeApplyCreditLedgerRequest(req applyCreditLedgerRequest) (applyCreditLedgerRequest, error) {
normalized := req
normalized.EventID = strings.TrimSpace(req.EventID)
normalized.Source = strings.ToLower(strings.TrimSpace(req.Source))
normalized.SourceLabel = strings.TrimSpace(req.SourceLabel)
normalized.Direction = strings.ToLower(strings.TrimSpace(req.Direction))
normalized.Status = strings.ToLower(strings.TrimSpace(req.Status))
normalized.Description = strings.TrimSpace(req.Description)
normalized.MetadataJSON = strings.TrimSpace(req.MetadataJSON)
if normalized.CreatedAt.IsZero() {
normalized.CreatedAt = time.Now()
}
switch {
case normalized.EventID == "":
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit event_id 不能为空")
case normalized.UserID == 0:
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit user_id 不能为空")
case normalized.Source == "":
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit source 不能为空")
case normalized.Direction != storemodel.CreditLedgerDirectionIncome && normalized.Direction != storemodel.CreditLedgerDirectionExpense:
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit direction 仅支持 income 或 expense")
case normalized.Status == "":
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit status 不能为空")
}
return normalized, nil
}
func validateExistingCreditLedger(existing storemodel.CreditLedger, req applyCreditLedgerRequest) error {
if existing.UserID != req.UserID ||
existing.Source != req.Source ||
existing.Direction != req.Direction ||
existing.Amount != req.Amount ||
existing.Status != req.Status {
return tokenStoreBadRequest("credit event_id 幂等冲突:已存在流水与本次请求不一致")
}
return nil
}
func (s *Service) syncCreditCacheBestEffort(ctx context.Context, userID uint64, account *storemodel.CreditAccount, ledger *storemodel.CreditLedger) {
if s == nil || s.creditCache == nil || userID == 0 {
return
}
if account == nil {
loaded, err := s.creditDAO.FindAccountByUserID(ctx, userID)
if err != nil {
log.Printf("tokenstore credit cache fallback load failed: user_id=%d err=%v", userID, err)
return
}
account = loaded
}
snapshot := tokenstoredao.CreditBalanceSnapshot{
UserID: userID,
UpdatedAt: time.Now(),
TotalRecharged: 0,
TotalRewarded: 0,
TotalConsumed: 0,
}
if account != nil {
snapshot.Balance = account.Balance
snapshot.TotalRecharged = account.TotalRecharged
snapshot.TotalRewarded = account.TotalRewarded
snapshot.TotalConsumed = account.TotalConsumed
if !account.UpdatedAt.IsZero() {
snapshot.UpdatedAt = account.UpdatedAt
}
} else if ledger != nil && !ledger.CreatedAt.IsZero() {
snapshot.Balance = ledger.BalanceAfter
snapshot.UpdatedAt = ledger.CreatedAt
}
if err := s.creditCache.SetCreditBalanceSnapshot(ctx, userID, snapshot, 0); err != nil {
log.Printf("tokenstore credit cache snapshot write failed: user_id=%d err=%v", userID, err)
}
if snapshot.Balance <= 0 {
if err := s.creditCache.SetUserCreditBlocked(ctx, userID, 0); err != nil {
log.Printf("tokenstore credit blocked flag write failed: user_id=%d err=%v", userID, err)
}
return
}
if err := s.creditCache.DeleteUserCreditBlocked(ctx, userID); err != nil {
log.Printf("tokenstore credit blocked flag delete failed: user_id=%d err=%v", userID, err)
}
}
func creditMetadataJSON(payload any) string {
if payload == nil {
return ""
}
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(raw)
}
func normalizeCreditRecordNotFound(err error, fallback error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fallback
}
return err
}