Version: 0.9.80.dev.260506

后端:
1. LLM 独立服务与统一计费出口落地:新增 `cmd/llm`、`client/llm` 与 `services/llm/rpc`,补齐 BillingContext、CreditBalanceGuard、价格规则解析、stream usage 归集与 `credit.charge.requested` outbox 发布,active-scheduler / agent / course / memory / gateway fallback 全部改走 llm zrpc,不再各自本地初始化模型。
2. TokenStore 收口为 Credit 权威账本:新增 credit account / ledger / product / order / price-rule / reward-rule 能力与 Redis 快照缓存,扩展 tokenstore rpc/client 支撑余额快照、消耗看板、商品、订单、流水、价格规则和奖励规则,并接入 LLM charge 事件消费完成 Credit 扣费落账。
3. 计费旧链路下线与网关切口切换:`/token-store` 语义整体切到 `/credit-store`,agent chat 移除旧 TokenQuotaGuard,userauth 的 CheckTokenQuota / AdjustTokenUsage 改为废弃,聊天历史落库不再同步旧 token 额度账本,course 图片解析请求补 user_id 进入新计费口径。

前端:
4. 计划广场从 mock 数据切到真实接口:新增 forum api/types,首页支持真实列表、标签、搜索、防抖、点赞、导入和发布计划,详情页补齐帖子详情、评论树、回复和删除评论链路,同时补上“至少一个标签”的前后端约束与默认标签兜底。
5. 商店页切到 Credit 体系并重做展示:顶部改为余额 + Credit/Token 消耗看板,支持 24h/7d/30d/all 周期切换;套餐区展示原价与当前价;历史区改为当前用户 Credit 流水并支持查看更多,整体视觉和交互同步收口。

仓库:
6. 配置与本地启动体系补齐 llm / outbox 编排:`config.example.yaml` 增加 llm rpc 和统一 outbox service 配置,`dev-common.ps1` 把 llm 纳入多服务依赖并自动建 Kafka topic,`docker-compose.yml` 同步初始化 agent/task/memory/active-scheduler/notification/taskclass-forum/llm/token-store 全量 outbox topic。
This commit is contained in:
Losita
2026-05-06 20:16:53 +08:00
parent 7d324b77aa
commit 61db646805
104 changed files with 9527 additions and 3925 deletions

View File

@@ -0,0 +1,66 @@
package sv
import (
"context"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
// GetCreditBalanceSnapshot 返回用户 Credit 余额快照。
//
// 职责边界:
// 1. 优先读 tokenstore 自己维护的 Redis 快照,未命中再回源 DB
// 2. 只返回余额与阻断状态,不在这里计算价格或校验扣费规则;
// 3. DB 回源成功后会尽力回填缓存,但缓存失败不影响本次查询结果。
func (s *Service) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if userID == 0 {
return nil, respond.MissingParam
}
if s.creditCache != nil {
snapshot, ok, err := s.creditCache.GetCreditBalanceSnapshot(ctx, userID)
if err == nil && ok && snapshot != nil {
blocked, blockedErr := s.creditCache.IsUserCreditBlocked(ctx, userID)
if blockedErr == nil {
return &creditcontracts.CreditBalanceSnapshot{
UserID: userID,
Balance: snapshot.Balance,
TotalRecharged: snapshot.TotalRecharged,
TotalRewarded: snapshot.TotalRewarded,
TotalConsumed: snapshot.TotalConsumed,
IsBlocked: blocked || snapshot.Balance <= 0,
SnapshotSource: creditSnapshotSourceCache,
UpdatedAt: formatTime(snapshot.UpdatedAt),
}, nil
}
}
}
account, err := s.creditDAO.FindAccountByUserID(ctx, userID)
if err != nil {
return nil, err
}
result := &creditcontracts.CreditBalanceSnapshot{
UserID: userID,
SnapshotSource: creditSnapshotSourceDB,
}
if account != nil {
result.Balance = account.Balance
result.TotalRecharged = account.TotalRecharged
result.TotalRewarded = account.TotalRewarded
result.TotalConsumed = account.TotalConsumed
result.IsBlocked = account.Balance <= 0
result.UpdatedAt = formatTime(account.UpdatedAt)
} else {
result.Balance = 0
result.IsBlocked = true
}
s.syncCreditCacheBestEffort(ctx, userID, account, nil)
return result, nil
}

View File

@@ -0,0 +1,112 @@
package sv
import (
"context"
"fmt"
"strings"
"time"
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
)
type creditChargeMetadata struct {
Scene string `json:"scene"`
RequestID string `json:"request_id"`
ConversationID string `json:"conversation_id"`
ModelAlias string `json:"model_alias"`
ProviderName string `json:"provider_name"`
ModelName string `json:"model_name"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CachedTokens int64 `json:"cached_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
TotalTokens int64 `json:"total_tokens"`
RMBCostMicros int64 `json:"rmb_cost_micros"`
CreditCost int64 `json:"credit_cost"`
SkipCharge bool `json:"skip_charge"`
TriggeredAt time.Time `json:"triggered_at"`
}
// RecordCreditCharge 负责把 LLM 扣费事件写入 Credit 权威账本。
func (s *Service) RecordCreditCharge(ctx context.Context, payload sharedevents.CreditChargeRequestedPayload) (*creditcontracts.CreditTransactionView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if err := payload.Validate(); err != nil {
return nil, err
}
sourceRefID := strings.TrimSpace(payload.RequestID)
if sourceRefID == "" {
sourceRefID = strings.TrimSpace(payload.ConversationID)
}
var sourceRefIDPtr *string
if sourceRefID != "" {
sourceRefIDPtr = &sourceRefID
}
amount := -payload.CreditCost
status := storemodel.CreditLedgerStatusApplied
if payload.SkipCharge {
amount = 0
status = storemodel.CreditLedgerStatusSkipped
}
ledger, _, err := s.applyCreditLedger(ctx, applyCreditLedgerRequest{
EventID: strings.TrimSpace(payload.EventID),
UserID: payload.UserID,
Source: storemodel.CreditLedgerSourceCharge,
SourceLabel: creditSourceLabel(storemodel.CreditLedgerSourceCharge, ""),
Direction: storemodel.CreditLedgerDirectionExpense,
SourceRefID: sourceRefIDPtr,
Amount: amount,
Status: status,
Description: creditChargeDescription(payload),
MetadataJSON: creditMetadataJSON(creditChargeMetadataFromPayload(payload)),
CreatedAt: payload.TriggeredAt,
})
if err != nil {
return nil, err
}
view := creditTransactionViewFromModel(*ledger)
return &view, nil
}
func creditChargeMetadataFromPayload(payload sharedevents.CreditChargeRequestedPayload) creditChargeMetadata {
return creditChargeMetadata{
Scene: payload.Scene,
RequestID: payload.RequestID,
ConversationID: payload.ConversationID,
ModelAlias: payload.ModelAlias,
ProviderName: payload.ProviderName,
ModelName: payload.ModelName,
InputTokens: payload.InputTokens,
OutputTokens: payload.OutputTokens,
CachedTokens: payload.CachedTokens,
ReasoningTokens: payload.ReasoningTokens,
TotalTokens: payload.TotalTokens,
RMBCostMicros: payload.RMBCostMicros,
CreditCost: payload.CreditCost,
SkipCharge: payload.SkipCharge,
TriggeredAt: payload.TriggeredAt,
}
}
func creditChargeDescription(payload sharedevents.CreditChargeRequestedPayload) string {
modelText := strings.TrimSpace(payload.ModelAlias)
if modelText == "" {
modelText = strings.TrimSpace(payload.ModelName)
}
sceneText := strings.TrimSpace(payload.Scene)
switch {
case sceneText != "" && modelText != "":
return fmt.Sprintf("AI 调用扣费(%s / %s", sceneText, modelText)
case modelText != "":
return fmt.Sprintf("AI 调用扣费(%s", modelText)
default:
return "AI 调用扣费"
}
}

View File

@@ -0,0 +1,86 @@
package sv
import (
"context"
"strings"
"time"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
// GetCreditConsumptionDashboard 返回当前用户的 Credit 消耗看板。
//
// 职责边界:
// 1. 负责把前端周期参数归一化为 tokenstore 的统一时间窗口。
// 2. 只校验当前用户语义和周期合法性,真正的聚合查询下沉到 DAO。
// 3. 返回值只包含前端顶部看板需要的两个指标,不夹带商品、流水等其它信息。
func (s *Service) GetCreditConsumptionDashboard(ctx context.Context, req creditcontracts.GetCreditConsumptionDashboardRequest) (*creditcontracts.CreditConsumptionDashboardView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 {
return nil, respond.MissingParam
}
period, err := normalizeCreditConsumptionPeriod(req.Period)
if err != nil {
return nil, err
}
query := tokenstoredao.GetCreditConsumptionDashboardQuery{
UserID: req.ActorUserID,
CreatedFrom: resolveCreditConsumptionWindowStart(period, time.Now()),
}
aggregate, err := s.creditDAO.GetCreditConsumptionDashboard(ctx, query)
if err != nil {
return nil, err
}
return &creditcontracts.CreditConsumptionDashboardView{
Period: period,
CreditConsumed: aggregate.CreditConsumed,
TokenConsumed: aggregate.TokenConsumed,
}, nil
}
// normalizeCreditConsumptionPeriod 只负责把前端周期值收敛到固定枚举。
//
// 1. 空值默认回落到 24h保证首页初次进入时可直接展示。
// 2. 非法值直接返回业务坏参,避免网关和前端各自维护一份不一致的枚举。
// 3. 这里不做时间计算,方便后续单独复用和测试。
func normalizeCreditConsumptionPeriod(raw string) (string, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", creditcontracts.CreditConsumptionPeriod24h:
return creditcontracts.CreditConsumptionPeriod24h, nil
case creditcontracts.CreditConsumptionPeriod7d:
return creditcontracts.CreditConsumptionPeriod7d, nil
case creditcontracts.CreditConsumptionPeriod30d:
return creditcontracts.CreditConsumptionPeriod30d, nil
case creditcontracts.CreditConsumptionPeriodAll:
return creditcontracts.CreditConsumptionPeriodAll, nil
default:
return "", tokenStoreBadRequest("period 仅支持 24h、7d、30d 或 all")
}
}
// resolveCreditConsumptionWindowStart 负责把固定周期映射为统计起点。
//
// 1. only "all" 返回 nil表示不加 created_at 过滤。
// 2. 其它周期统一按当前时间回退固定时长,保证前后端口径一致。
// 3. 这里不处理时区格式化,因为最终查询直接使用 time.Time 传给 DAO。
func resolveCreditConsumptionWindowStart(period string, now time.Time) *time.Time {
var startAt time.Time
switch period {
case creditcontracts.CreditConsumptionPeriod24h:
startAt = now.Add(-24 * time.Hour)
case creditcontracts.CreditConsumptionPeriod7d:
startAt = now.Add(-7 * 24 * time.Hour)
case creditcontracts.CreditConsumptionPeriod30d:
startAt = now.Add(-30 * 24 * time.Hour)
default:
return nil
}
return &startAt
}

View File

@@ -0,0 +1,457 @@
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 {
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,
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
}

View File

@@ -0,0 +1,240 @@
package sv
import (
"context"
"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/LoveLosita/smartflow/backend/shared/respond"
)
// CreateCreditOrder 创建 Credit 商品订单。
func (s *Service) CreateCreditOrder(ctx context.Context, req creditcontracts.CreateCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 || req.ProductID == 0 {
return nil, respond.MissingParam
}
if req.Quantity < 1 || req.Quantity > 99 {
return nil, tokenStoreBadRequest("quantity 仅支持 1 到 99")
}
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
if idempotencyKey != "" {
existing, err := s.creditDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
if err != nil {
return nil, err
}
if existing != nil {
return s.creditOrderViewByID(ctx, req.ActorUserID, existing.ID)
}
}
product, err := s.creditDAO.FindActiveProductByID(ctx, req.ProductID)
if err != nil {
return nil, err
}
if product == nil {
return nil, tokenStoreBadRequest("Credit 商品不存在或已下架")
}
snapshot, err := buildCreditProductSnapshot(*product)
if err != nil {
return nil, err
}
order := storemodel.CreditOrder{
OrderNo: newCreditOrderNo(),
UserID: req.ActorUserID,
ProductID: product.ID,
ProductSKU: product.SKU,
ProductName: product.Name,
ProductSnapshotJSON: snapshot,
Quantity: req.Quantity,
CreditAmount: product.CreditAmount * int64(req.Quantity),
AmountCent: product.PriceCent * int64(req.Quantity),
Currency: product.Currency,
Status: storemodel.CreditOrderStatusPending,
PaymentMode: "mock",
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
}
if err := s.creditDAO.CreateOrder(ctx, &order); err != nil {
if idempotencyKey != "" && isDuplicateKeyError(err) {
existing, findErr := s.creditDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
if findErr != nil {
return nil, findErr
}
if existing != nil {
return s.creditOrderViewByID(ctx, req.ActorUserID, existing.ID)
}
}
return nil, err
}
return s.creditOrderViewByID(ctx, req.ActorUserID, order.ID)
}
// ListCreditOrders 按用户分页查询 Credit 订单。
func (s *Service) ListCreditOrders(ctx context.Context, req creditcontracts.ListCreditOrdersRequest) ([]creditcontracts.CreditOrderView, creditcontracts.PageResult, error) {
if err := s.Ready(); err != nil {
return nil, creditcontracts.PageResult{}, err
}
if req.ActorUserID == 0 {
return nil, creditcontracts.PageResult{}, respond.MissingParam
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := tokenstoredao.ListCreditOrdersQuery{
UserID: req.ActorUserID,
Page: page,
PageSize: pageSize,
Status: strings.TrimSpace(req.Status),
}
total, err := s.creditDAO.CountOrders(ctx, query)
if err != nil {
return nil, creditcontracts.PageResult{}, err
}
orders, err := s.creditDAO.ListOrders(ctx, query)
if err != nil {
return nil, creditcontracts.PageResult{}, err
}
if len(orders) == 0 {
return []creditcontracts.CreditOrderView{}, creditPageResult(page, pageSize, total), nil
}
result := make([]creditcontracts.CreditOrderView, 0, len(orders))
for _, order := range orders {
result = append(result, creditOrderViewFromModel(order))
}
return result, creditPageResult(page, pageSize, total), nil
}
// GetCreditOrder 查询单个 Credit 订单详情。
func (s *Service) GetCreditOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if actorUserID == 0 || orderID == 0 {
return nil, respond.MissingParam
}
return s.creditOrderViewByID(ctx, actorUserID, orderID)
}
// MockPaidCreditOrder 在同步事务里完成 mock paid 和 Credit 入账。
func (s *Service) MockPaidCreditOrder(ctx context.Context, req creditcontracts.MockPaidCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 || req.OrderID == 0 {
return nil, respond.MissingParam
}
var resultOrder storemodel.CreditOrder
err := s.creditDAO.Transaction(ctx, func(txDAO *tokenstoredao.CreditStoreDAO) error {
now := time.Now()
order, err := txDAO.LockOrderByID(ctx, req.OrderID)
if err != nil {
return normalizeCreditRecordNotFound(err, tokenStoreBadRequest("Credit 订单不存在"))
}
if order.UserID != req.ActorUserID {
return tokenStoreBadRequest("Credit 订单不属于当前用户")
}
switch order.Status {
case storemodel.CreditOrderStatusPending, storemodel.CreditOrderStatusPaid, storemodel.CreditOrderStatusCredited:
case storemodel.CreditOrderStatusClosed:
return tokenStoreBadRequest("Credit 订单已关闭,不能执行 mock paid")
default:
return tokenStoreBadRequest("Credit 订单状态不支持执行 mock paid")
}
eventID := creditOrderLedgerEventID(order.ID)
paymentMode := paymentModeOrDefault(order.PaymentMode, req.MockChannel)
ledger, _, err := s.applyCreditLedgerWithDAO(ctx, txDAO, applyCreditLedgerRequest{
EventID: eventID,
UserID: order.UserID,
Source: storemodel.CreditLedgerSourcePurchase,
SourceLabel: creditSourceLabel(storemodel.CreditLedgerSourcePurchase, ""),
Direction: storemodel.CreditLedgerDirectionIncome,
OrderID: &order.ID,
Amount: order.CreditAmount,
Status: storemodel.CreditLedgerStatusApplied,
Description: creditPurchaseDescription(order.ProductName),
MetadataJSON: creditMetadataJSON(map[string]any{"order_no": order.OrderNo, "payment_mode": paymentMode}),
CreatedAt: now,
})
if err != nil {
return err
}
paidAt := order.PaidAt
if paidAt == nil || paidAt.IsZero() {
paidAt = &now
}
creditedAt := order.CreditedAt
if creditedAt == nil || creditedAt.IsZero() {
ledgerCreatedAt := ledger.CreatedAt
if ledgerCreatedAt.IsZero() {
ledgerCreatedAt = now
}
creditedAt = &ledgerCreatedAt
}
if err := txDAO.UpdateOrderState(ctx, order.ID, storemodel.CreditOrderStatusCredited, paidAt, creditedAt, paymentMode); err != nil {
return err
}
order.Status = storemodel.CreditOrderStatusCredited
order.PaidAt = paidAt
order.CreditedAt = creditedAt
order.PaymentMode = paymentMode
resultOrder = *order
return nil
})
if err != nil {
return nil, err
}
s.syncCreditCacheBestEffort(ctx, req.ActorUserID, nil, nil)
view := creditOrderViewFromModel(resultOrder)
return &view, nil
}
func (s *Service) creditOrderViewByID(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, error) {
order, err := s.creditDAO.FindOrderByID(ctx, orderID)
if err != nil {
return nil, err
}
if order == nil {
return nil, tokenStoreBadRequest("Credit 订单不存在")
}
if order.UserID != actorUserID {
return nil, tokenStoreBadRequest("Credit 订单不属于当前用户")
}
view := creditOrderViewFromModel(*order)
return &view, nil
}
func creditPurchaseDescription(productName string) string {
trimmed := strings.TrimSpace(productName)
if trimmed == "" {
return "购买 Credit 商品"
}
return "购买" + trimmed
}
func paymentModeOrDefault(current string, fallback string) string {
if trimmed := strings.TrimSpace(current); trimmed != "" {
return trimmed
}
if trimmed := strings.TrimSpace(fallback); trimmed != "" {
return trimmed
}
return "mock"
}

View File

@@ -0,0 +1,104 @@
package sv
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
)
// RegisterCreditChargeRoutes 只登记 token-store 负责消费的 Credit 扣费事件归属。
func RegisterCreditChargeRoutes() error {
return outboxinfra.RegisterEventService(sharedevents.CreditChargeRequestedEventType, outboxinfra.ServiceTokenStore)
}
// RegisterCreditChargeHandlers 注册 token-store 对 Credit 扣费事件的消费处理器。
func RegisterCreditChargeHandlers(bus OutboxBus, outboxRepo *outboxinfra.Repository, svc *Service) error {
if bus == nil {
return errors.New("event bus is nil")
}
if outboxRepo == nil {
return errors.New("outbox repository is nil")
}
if svc == nil {
return errors.New("tokenstore service is nil")
}
if err := RegisterCreditChargeRoutes(); err != nil {
return err
}
route, ok := outboxinfra.ResolveEventRoute(sharedevents.CreditChargeRequestedEventType)
if !ok {
return fmt.Errorf("credit charge outbox route is missing: eventType=%s", sharedevents.CreditChargeRequestedEventType)
}
eventOutboxRepo := outboxRepo.WithRoute(route)
handler := func(ctx context.Context, envelope kafkabus.Envelope) error {
if !isAllowedCreditChargeEventVersion(envelope.EventVersion) {
if err := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("Credit 扣费事件版本不受支持: %s", envelope.EventVersion)); err != nil {
return err
}
return nil
}
var payload sharedevents.CreditChargeRequestedPayload
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "解析 Credit 扣费载荷失败: "+err.Error()); markErr != nil {
return markErr
}
return nil
}
if strings.TrimSpace(payload.EventID) == "" {
payload.EventID = strings.TrimSpace(envelope.EventID)
}
if err := payload.Validate(); err != nil {
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "Credit 扣费载荷非法: "+err.Error()); markErr != nil {
return markErr
}
return nil
}
if payload.EventType() != sharedevents.CreditChargeRequestedEventType {
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("Credit 扣费事件类型不匹配: envelope=%s payload=%s", sharedevents.CreditChargeRequestedEventType, payload.EventType())); markErr != nil {
return markErr
}
return nil
}
if !payload.SkipCharge && payload.CreditCost <= 0 && payload.RMBCostMicros <= 0 {
if err := eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID); err != nil {
return err
}
log.Printf("credit charge event skipped with zero cost: event_id=%s outbox_id=%d", payload.EventID, envelope.OutboxID)
return nil
}
tx, err := svc.RecordCreditCharge(ctx, payload)
if err != nil {
return err
}
if err := eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID); err != nil {
return err
}
log.Printf(
"credit charge event consumed by tokenstore: event_id=%s transaction_id=%d outbox_id=%d",
payload.EventID,
tx.TransactionID,
envelope.OutboxID,
)
return nil
}
return bus.RegisterEventHandler(sharedevents.CreditChargeRequestedEventType, handler)
}
func isAllowedCreditChargeEventVersion(version string) bool {
version = strings.TrimSpace(version)
return version == "" || version == sharedevents.CreditChargeEventVersion
}

View File

@@ -0,0 +1,29 @@
package sv
import (
"context"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
)
// ListCreditProducts 返回当前可售 Credit 商品列表。
func (s *Service) ListCreditProducts(ctx context.Context, actorUserID uint64) ([]creditcontracts.CreditProductView, error) {
_ = actorUserID
if err := s.Ready(); err != nil {
return nil, err
}
products, err := s.creditDAO.ListActiveProducts(ctx)
if err != nil {
return nil, err
}
if len(products) == 0 {
return []creditcontracts.CreditProductView{}, nil
}
result := make([]creditcontracts.CreditProductView, 0, len(products))
for _, product := range products {
result = append(result, creditProductViewFromModel(product))
}
return result, nil
}

View File

@@ -0,0 +1,90 @@
package sv
import (
"context"
"strconv"
"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"
)
// RecordForumRewardCredit 把论坛点赞/导入奖励直接写入 Credit 权威账本。
//
// 职责边界:
// 1. 只处理 forum_like / forum_import 两类论坛正向奖励;
// 2. 复用 event_id 做最终幂等键,重复消费时直接返回既有账本结果;
// 3. 奖励金额优先读取 credit_reward_rules规则缺失时再走默认兜底。
func (s *Service) RecordForumRewardCredit(ctx context.Context, req forumRewardGrantRequest) (*creditcontracts.CreditTransactionView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
decision, err := s.forumRewardCreditDecision(ctx, req.Source)
if err != nil {
return nil, err
}
sourceRefID := strconv.FormatUint(req.SourceRefID, 10)
ledger, _, err := s.applyCreditLedger(ctx, applyCreditLedgerRequest{
EventID: req.EventID,
UserID: req.ReceiverUserID,
Source: req.Source,
SourceLabel: creditSourceLabel(req.Source, ""),
Direction: creditDirectionFromAmount(decision.Amount),
SourceRefID: &sourceRefID,
Amount: decision.Amount,
Status: decision.Status,
Description: decision.Description,
MetadataJSON: creditMetadataJSON(map[string]any{"reward_source": req.Source, "source_ref_id": req.SourceRefID}),
CreatedAt: time.Now(),
})
if err != nil {
return nil, err
}
view := creditTransactionViewFromModel(*ledger)
return &view, nil
}
func (s *Service) forumRewardCreditDecision(ctx context.Context, source string) (forumRewardDecision, error) {
rules, err := s.creditDAO.ListRewardRules(ctx, tokenstoredao.ListCreditRewardRulesQuery{
Source: strings.TrimSpace(source),
})
if err != nil {
return forumRewardDecision{}, err
}
if len(rules) > 0 {
rule := rules[0]
if strings.TrimSpace(rule.Status) != storemodel.CreditRewardRuleStatusActive {
return skippedForumRewardDecision(source, "奖励规则已停用,未发放 Credit"), nil
}
if rule.Amount <= 0 {
return skippedForumRewardDecision(source, "奖励规则金额非正,未发放 Credit"), nil
}
return forumRewardDecision{
Amount: rule.Amount,
Status: storemodel.CreditLedgerStatusApplied,
Description: forumRewardDescription(source),
}, nil
}
switch strings.TrimSpace(source) {
case storemodel.CreditLedgerSourceForumLike:
return forumRewardDecision{
Amount: defaultForumLikeRewardAmount,
Status: storemodel.CreditLedgerStatusApplied,
Description: forumRewardDescription(source),
}, nil
case storemodel.CreditLedgerSourceForumImport:
return forumRewardDecision{
Amount: defaultForumImportRewardAmount,
Status: storemodel.CreditLedgerStatusApplied,
Description: forumRewardDescription(source),
}, nil
default:
return skippedForumRewardDecision(source, "未知论坛奖励来源,未发放 Credit"), nil
}
}

View File

@@ -0,0 +1,59 @@
package sv
import (
"context"
"strings"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
)
// ListCreditPriceRules 查询 Credit 价格规则。
func (s *Service) ListCreditPriceRules(ctx context.Context, req creditcontracts.ListCreditPriceRulesRequest) ([]creditcontracts.CreditPriceRuleView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
rules, err := s.creditDAO.ListPriceRules(ctx, tokenstoredao.ListCreditPriceRulesQuery{
Scene: strings.TrimSpace(req.Scene),
ProviderName: strings.TrimSpace(req.ProviderName),
ModelName: strings.TrimSpace(req.ModelName),
Status: strings.TrimSpace(req.Status),
})
if err != nil {
return nil, err
}
if len(rules) == 0 {
return []creditcontracts.CreditPriceRuleView{}, nil
}
result := make([]creditcontracts.CreditPriceRuleView, 0, len(rules))
for _, rule := range rules {
result = append(result, creditPriceRuleViewFromModel(rule))
}
return result, nil
}
// ListCreditRewardRules 查询 Credit 奖励规则。
func (s *Service) ListCreditRewardRules(ctx context.Context, req creditcontracts.ListCreditRewardRulesRequest) ([]creditcontracts.CreditRewardRuleView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
rules, err := s.creditDAO.ListRewardRules(ctx, tokenstoredao.ListCreditRewardRulesQuery{
Source: strings.TrimSpace(req.Source),
Status: strings.TrimSpace(req.Status),
})
if err != nil {
return nil, err
}
if len(rules) == 0 {
return []creditcontracts.CreditRewardRuleView{}, nil
}
result := make([]creditcontracts.CreditRewardRuleView, 0, len(rules))
for _, rule := range rules {
result = append(result, creditRewardRuleViewFromModel(rule))
}
return result, nil
}

View File

@@ -0,0 +1,47 @@
package sv
import (
"context"
"strings"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
// ListCreditTransactions 查询当前用户自己的 Credit 流水。
func (s *Service) ListCreditTransactions(ctx context.Context, req creditcontracts.ListCreditTransactionsRequest) ([]creditcontracts.CreditTransactionView, creditcontracts.PageResult, error) {
if err := s.Ready(); err != nil {
return nil, creditcontracts.PageResult{}, err
}
if req.ActorUserID == 0 {
return nil, creditcontracts.PageResult{}, respond.MissingParam
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := tokenstoredao.ListCreditTransactionsQuery{
UserID: req.ActorUserID,
Page: page,
PageSize: pageSize,
Source: strings.TrimSpace(req.Source),
Direction: strings.TrimSpace(req.Direction),
}
total, err := s.creditDAO.CountTransactions(ctx, query)
if err != nil {
return nil, creditcontracts.PageResult{}, err
}
items, err := s.creditDAO.ListTransactions(ctx, query)
if err != nil {
return nil, creditcontracts.PageResult{}, err
}
if len(items) == 0 {
return []creditcontracts.CreditTransactionView{}, creditPageResult(page, pageSize, total), nil
}
result := make([]creditcontracts.CreditTransactionView, 0, len(items))
for _, item := range items {
result = append(result, creditTransactionViewFromModel(item))
}
return result, creditPageResult(page, pageSize, total), nil
}

View File

@@ -1,84 +0,0 @@
package sv
import (
"context"
"strings"
"github.com/LoveLosita/smartflow/backend/shared/respond"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
)
// GetSummary 聚合当前用户在 token-store 账本中的获得记录。
//
// 职责边界:
// 1. 只统计 token_grants不读取 user/auth 的权威额度。
// 2. 只汇总正向获取额度,避免把未来的冲正或补偿误算进 P0 展示口径。
// 3. quota_sync_status 在 P0 固定为 not_connected明确告知尚未打通权威额度。
func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if actorUserID == 0 {
return nil, respond.MissingParam
}
summary, err := s.tokenDAO.SummarizePositiveGrants(ctx, actorUserID)
if err != nil {
return nil, err
}
pending := summary.RecordedTokenTotal - summary.AppliedTokenTotal
if pending < 0 {
pending = 0
}
return &tokencontracts.TokenSummary{
RecordedTokenTotal: summary.RecordedTokenTotal,
AppliedTokenTotal: summary.AppliedTokenTotal,
PendingApplyTokenTotal: pending,
QuotaSyncStatus: tokenSummaryQuotaStatusNotConnected,
Tip: tokenSummaryTipP0,
}, nil
}
// ListGrants 按用户分页查询 Token 获得记录。
//
// 职责边界:
// 1. 只支持 user_id 维度分页和 source 过滤,不做跨用户检索。
// 2. 负责把空 source 归一化为“不筛选”,避免 DAO 层重复处理入口噪音。
// 3. 结果只来自账本事实,不推导 user/auth 可用额度。
func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
if err := s.Ready(); err != nil {
return nil, tokencontracts.PageResult{}, err
}
if req.ActorUserID == 0 {
return nil, tokencontracts.PageResult{}, respond.MissingParam
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := tokenstoredao.ListTokenGrantsQuery{
UserID: req.ActorUserID,
Page: page,
PageSize: pageSize,
Source: strings.TrimSpace(req.Source),
}
total, err := s.tokenDAO.CountGrants(ctx, query)
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
grants, err := s.tokenDAO.ListGrants(ctx, query)
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
if len(grants) == 0 {
return []tokencontracts.TokenGrantView{}, pageResult(page, pageSize, total), nil
}
result := make([]tokencontracts.TokenGrantView, 0, len(grants))
for _, grant := range grants {
result = append(result, grantViewFromModel(grant))
}
return result, pageResult(page, pageSize, total), nil
}

View File

@@ -1,16 +1,12 @@
package sv
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/shared/respond"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -18,25 +14,8 @@ const (
defaultPage = 1
defaultPageSize = 20
maxPageSize = 50
tokenSummaryQuotaStatusNotConnected = "not_connected"
tokenSummaryTipP0 = "当前仅统计 Token 商店已记录的获得记录,尚未同步到 user/auth 可用额度。"
)
type productSnapshot struct {
ProductID uint64 `json:"product_id"`
SKU string `json:"sku"`
Name string `json:"name"`
Description string `json:"description"`
TokenAmount int64 `json:"token_amount"`
PriceCent int64 `json:"price_cent"`
PriceText string `json:"price_text"`
Currency string `json:"currency"`
Badge string `json:"badge"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
func normalizePage(page int, pageSize int) (int, int) {
if page <= 0 {
page = defaultPage
@@ -50,15 +29,6 @@ func normalizePage(page int, pageSize int) (int, int) {
return page, pageSize
}
func pageResult(page int, pageSize int, total int64) tokencontracts.PageResult {
return tokencontracts.PageResult{
Page: page,
PageSize: pageSize,
Total: int(total),
HasMore: int64(page*pageSize) < total,
}
}
func formatTime(value time.Time) string {
if value.IsZero() {
return ""
@@ -75,6 +45,9 @@ func formatTimePtr(value *time.Time) *string {
}
func formatPriceText(currency string, amountCent int64) string {
if amountCent == 0 {
return "免费"
}
if strings.EqualFold(strings.TrimSpace(currency), "CNY") {
return fmt.Sprintf("¥%.2f", float64(amountCent)/100)
}
@@ -89,120 +62,6 @@ func stringPtrFromNonEmpty(value string) *string {
return &trimmed
}
func productViewFromModel(product tokenmodel.TokenProduct) tokencontracts.TokenProductView {
return tokencontracts.TokenProductView{
ProductID: product.ID,
Name: product.Name,
Description: product.Description,
TokenAmount: product.TokenAmount,
PriceCent: product.PriceCent,
PriceText: formatPriceText(product.Currency, product.PriceCent),
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: product.SortOrder,
}
}
func grantViewFromModel(grant tokenmodel.TokenGrant) tokencontracts.TokenGrantView {
return tokencontracts.TokenGrantView{
GrantID: grant.ID,
EventID: grant.EventID,
Source: grant.Source,
SourceLabel: grantSourceLabel(grant.Source, grant.SourceLabel),
Amount: grant.Amount,
Status: grant.Status,
QuotaApplied: grant.QuotaApplied,
Description: grant.Description,
CreatedAt: formatTime(grant.CreatedAt),
}
}
func orderViewFromModel(order tokenmodel.TokenOrder, grant *tokenmodel.TokenGrant) tokencontracts.TokenOrderView {
var grantView *tokencontracts.TokenGrantView
if grant != nil {
view := grantViewFromModel(*grant)
grantView = &view
}
return tokencontracts.TokenOrderView{
OrderID: order.ID,
OrderNo: order.OrderNo,
Status: order.Status,
ProductSnapshot: order.ProductSnapshotJSON,
ProductName: order.ProductName,
Quantity: order.Quantity,
TokenAmount: order.TokenAmount,
AmountCent: order.AmountCent,
PriceText: formatPriceText(order.Currency, order.AmountCent),
Currency: order.Currency,
PaymentMode: order.PaymentMode,
Grant: grantView,
CreatedAt: formatTime(order.CreatedAt),
PaidAt: formatTimePtr(order.PaidAt),
GrantedAt: formatTimePtr(order.GrantedAt),
}
}
func grantSourceLabel(source string, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return fallback
}
switch strings.TrimSpace(source) {
case tokenmodel.TokenGrantSourcePurchase:
return "购买充值"
case tokenmodel.TokenGrantSourceForumLike:
return "计划被点赞"
case tokenmodel.TokenGrantSourceForumImport:
return "计划被导入"
case tokenmodel.TokenGrantSourceManual:
return "人工补发"
default:
return "Token 获得记录"
}
}
func buildProductSnapshot(product tokenmodel.TokenProduct) (string, error) {
snapshot := productSnapshot{
ProductID: product.ID,
SKU: product.SKU,
Name: product.Name,
Description: product.Description,
TokenAmount: product.TokenAmount,
PriceCent: product.PriceCent,
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 newOrderNo() string {
return fmt.Sprintf(
"TS%s%s",
time.Now().Format("20060102150405"),
strings.ReplaceAll(uuid.NewString(), "-", ""),
)
}
func purchaseGrantEventID(orderID uint64) string {
return fmt.Sprintf("order:%d:paid", orderID)
}
func purchaseGrantDescription(productName string) string {
trimmed := strings.TrimSpace(productName)
if trimmed == "" {
return "购买 Token 商品"
}
return fmt.Sprintf("购买%s", trimmed)
}
func isDuplicateKeyError(err error) bool {
if err == nil {
return false
@@ -226,8 +85,6 @@ func errorsIsRecordNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// tokenStoreBadRequestStatus 是 token-store P0 统一业务校验错误码。
// 具体错误原因仍放在 Info避免为每个商品/订单校验分支提前扩散大量细分码。
const tokenStoreBadRequestStatus = "40067"
func tokenStoreBadRequest(message string) respond.Response {

View File

@@ -1,312 +0,0 @@
package sv
import (
"context"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/shared/respond"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
)
// CreateOrder 创建 Token 商品订单。
//
// 职责边界:
// 1. 校验 actor_user_id、product_id、quantity 与幂等键。
// 2. 只生成 pending 订单和商品快照,不触发真实支付或 user/auth 同步。
// 3. 并发冲突时优先按 user_id + idempotency_key 回查旧单,保证 P0 幂等语义。
func (s *Service) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*tokencontracts.TokenOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 || req.ProductID == 0 {
return nil, respond.MissingParam
}
if req.Quantity < 1 || req.Quantity > 99 {
return nil, tokenStoreBadRequest("quantity 仅支持 1 到 99")
}
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
if idempotencyKey != "" {
existing, err := s.tokenDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
if err != nil {
return nil, err
}
if existing != nil {
return s.orderViewByID(ctx, req.ActorUserID, existing.ID)
}
}
product, err := s.tokenDAO.FindActiveProductByID(ctx, req.ProductID)
if err != nil {
return nil, err
}
if product == nil {
return nil, tokenStoreBadRequest("商品不存在或已下架")
}
productSnapshot, err := buildProductSnapshot(*product)
if err != nil {
return nil, err
}
order := tokenmodel.TokenOrder{
OrderNo: newOrderNo(),
UserID: req.ActorUserID,
ProductID: product.ID,
ProductSKU: product.SKU,
ProductName: product.Name,
ProductSnapshotJSON: productSnapshot,
Quantity: req.Quantity,
TokenAmount: product.TokenAmount * int64(req.Quantity),
AmountCent: product.PriceCent * int64(req.Quantity),
Currency: product.Currency,
Status: tokenmodel.TokenOrderStatusPending,
PaymentMode: "mock",
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
}
if err := s.tokenDAO.CreateOrder(ctx, &order); err != nil {
if idempotencyKey != "" && isDuplicateKeyError(err) {
existing, findErr := s.tokenDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
if findErr != nil {
return nil, findErr
}
if existing != nil {
return s.orderViewByID(ctx, req.ActorUserID, existing.ID)
}
}
return nil, err
}
return s.orderViewByID(ctx, req.ActorUserID, order.ID)
}
// ListOrders 按用户分页查询订单列表。
//
// 职责边界:
// 1. 只支持当前用户维度分页,不做跨用户检索。
// 2. status 为空时不过滤,非空时按精确值过滤。
// 3. 负责把订单与 grant 账本拼装成统一视图。
func (s *Service) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]tokencontracts.TokenOrderView, tokencontracts.PageResult, error) {
if err := s.Ready(); err != nil {
return nil, tokencontracts.PageResult{}, err
}
if req.ActorUserID == 0 {
return nil, tokencontracts.PageResult{}, respond.MissingParam
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := tokenstoredao.ListTokenOrdersQuery{
UserID: req.ActorUserID,
Page: page,
PageSize: pageSize,
Status: strings.TrimSpace(req.Status),
}
total, err := s.tokenDAO.CountOrders(ctx, query)
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
orders, err := s.tokenDAO.ListOrders(ctx, query)
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
if len(orders) == 0 {
return []tokencontracts.TokenOrderView{}, pageResult(page, pageSize, total), nil
}
grantMap, err := s.orderGrantMap(ctx, collectOrderIDs(orders))
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
result := make([]tokencontracts.TokenOrderView, 0, len(orders))
for _, order := range orders {
result = append(result, orderViewFromModel(order, grantMap[order.ID]))
}
return result, pageResult(page, pageSize, total), nil
}
// GetOrder 查询单个订单详情,并校验归属用户。
func (s *Service) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if actorUserID == 0 || orderID == 0 {
return nil, respond.MissingParam
}
return s.orderViewByID(ctx, actorUserID, orderID)
}
// MockPaidOrder 在同步事务里完成 mock paid 和 grant 入账。
//
// 职责边界:
// 1. 只处理订单状态流转与 token_grants 幂等写入,不调用 user/auth。
// 2. event_id 固定为 order:{order_id}:paid作为最终 grant 幂等边界。
// 3. 重复调用优先复用既有 grant再把订单补齐到 granted避免重复写账本。
func (s *Service) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*tokencontracts.TokenOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 || req.OrderID == 0 {
return nil, respond.MissingParam
}
var resultOrder tokenmodel.TokenOrder
var resultGrant *tokenmodel.TokenGrant
err := s.tokenDAO.Transaction(ctx, func(txDAO *tokenstoredao.TokenStoreDAO) error {
now := time.Now()
// 1. 先锁订单并校验归属,避免并发 mock paid 重复写 grant。
// 2. 订单不存在直接返回;订单不属于当前用户时明确拒绝。
// 3. closed 状态不允许继续支付,避免把关闭单重新拉回可用态。
order, err := txDAO.LockOrderByID(ctx, req.OrderID)
if err != nil {
return normalizeRecordNotFound(err, tokenStoreBadRequest("订单不存在"))
}
if order.UserID != req.ActorUserID {
return tokenStoreBadRequest("订单不属于当前用户")
}
switch order.Status {
case tokenmodel.TokenOrderStatusPending, tokenmodel.TokenOrderStatusPaid, tokenmodel.TokenOrderStatusGranted:
case tokenmodel.TokenOrderStatusClosed:
return tokenStoreBadRequest("订单已关闭,不能执行 mock paid")
default:
return tokenStoreBadRequest("订单状态不支持执行 mock paid")
}
eventID := purchaseGrantEventID(order.ID)
grant, err := txDAO.FindGrantByEventID(ctx, eventID)
if err != nil {
return err
}
// 1. grant 不存在时才尝试创建,保证账本幂等写入边界只在 event_id。
// 2. 即使因为历史脏数据或极端并发触发唯一冲突,也要立刻按 event_id 反查旧 grant。
// 3. 这里不写 user/auth只把 token-store 自己的账本事实补齐。
if grant == nil {
sourceRefID := order.ID
orderID := order.ID
newGrant := &tokenmodel.TokenGrant{
EventID: eventID,
UserID: order.UserID,
Source: tokenmodel.TokenGrantSourcePurchase,
SourceLabel: grantSourceLabel(tokenmodel.TokenGrantSourcePurchase, ""),
SourceRefID: &sourceRefID,
OrderID: &orderID,
Amount: order.TokenAmount,
Status: tokenmodel.TokenGrantStatusRecorded,
QuotaApplied: false,
Description: purchaseGrantDescription(order.ProductName),
}
if err := txDAO.CreateGrant(ctx, newGrant); err != nil {
if !isDuplicateKeyError(err) {
return err
}
newGrant, err = txDAO.FindGrantByEventID(ctx, eventID)
if err != nil {
return err
}
if newGrant == nil {
return tokenStoreBadRequest("Token 发放记录创建后未找到")
}
}
grant = newGrant
}
// 1. 无论订单原来是 pending、paid 还是 granted只要 grant 已确定,就把订单补齐到 granted。
// 2. paid_at 缺失时使用本次确认时间granted_at 缺失时优先复用 grant.created_at保证链路时间可追溯。
// 3. 这样即便出现“grant 已有、订单未完成切流”的历史半状态,也能在重复调用时自愈。
paidAt := order.PaidAt
if paidAt == nil || paidAt.IsZero() {
paidAt = &now
}
grantedAt := order.GrantedAt
if grantedAt == nil || grantedAt.IsZero() {
if grant != nil && !grant.CreatedAt.IsZero() {
grantCreatedAt := grant.CreatedAt
grantedAt = &grantCreatedAt
} else {
grantedAt = &now
}
}
paymentMode := strings.TrimSpace(order.PaymentMode)
if paymentMode == "" {
paymentMode = strings.TrimSpace(req.MockChannel)
}
if paymentMode == "" {
paymentMode = "mock"
}
if err := txDAO.UpdateOrderState(ctx, order.ID, tokenmodel.TokenOrderStatusGranted, paidAt, grantedAt, paymentMode); err != nil {
return err
}
order.Status = tokenmodel.TokenOrderStatusGranted
order.PaidAt = paidAt
order.GrantedAt = grantedAt
order.PaymentMode = paymentMode
resultOrder = *order
resultGrant = grant
return nil
})
if err != nil {
return nil, err
}
view := orderViewFromModel(resultOrder, resultGrant)
return &view, nil
}
func (s *Service) orderViewByID(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) {
order, err := s.tokenDAO.FindOrderByID(ctx, orderID)
if err != nil {
return nil, err
}
if order == nil {
return nil, tokenStoreBadRequest("订单不存在")
}
if order.UserID != actorUserID {
return nil, tokenStoreBadRequest("订单不属于当前用户")
}
grant, err := s.tokenDAO.FindGrantByOrderID(ctx, order.ID)
if err != nil {
return nil, err
}
view := orderViewFromModel(*order, grant)
return &view, nil
}
func (s *Service) orderGrantMap(ctx context.Context, orderIDs []uint64) (map[uint64]*tokenmodel.TokenGrant, error) {
result := make(map[uint64]*tokenmodel.TokenGrant, len(orderIDs))
if len(orderIDs) == 0 {
return result, nil
}
grants, err := s.tokenDAO.ListGrantsByOrderIDs(ctx, orderIDs)
if err != nil {
return nil, err
}
for i := range grants {
grant := grants[i]
if grant.OrderID == nil {
continue
}
if _, exists := result[*grant.OrderID]; exists {
continue
}
grantCopy := grant
result[*grant.OrderID] = &grantCopy
}
return result, nil
}
func collectOrderIDs(orders []tokenmodel.TokenOrder) []uint64 {
result := make([]uint64, 0, len(orders))
for _, order := range orders {
result = append(result, order.ID)
}
return result
}

View File

@@ -9,7 +9,6 @@ import (
"strconv"
"strings"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
@@ -110,11 +109,19 @@ func registerForumRewardHandler(
return nil
}
grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{
sourceRefID, parseErr := parseForumRewardSourceRefID(forumRewardSourceRefID(payload, source))
if parseErr != nil {
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "论坛奖励 source_ref_id 非法: "+parseErr.Error()); markErr != nil {
return markErr
}
return nil
}
transaction, err := svc.RecordForumRewardCredit(ctx, forumRewardGrantRequest{
EventID: eventID,
ReceiverUserID: payload.RewardReceiverUserID,
Source: forumRewardSource(payload, source),
SourceRefID: forumRewardSourceRefID(payload, source),
SourceRefID: sourceRefID,
})
if err != nil {
return err
@@ -124,10 +131,10 @@ func registerForumRewardHandler(
}
log.Printf(
"forum reward event consumed by tokenstore: event_type=%s event_id=%s grant_id=%d outbox_id=%d",
"forum reward event consumed by tokenstore: event_type=%s event_id=%s transaction_id=%d outbox_id=%d",
eventType,
eventID,
grant.GrantID,
transaction.TransactionID,
envelope.OutboxID,
)
return nil

View File

@@ -1,34 +0,0 @@
package sv
import (
"context"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
)
// ListProducts 返回当前可售商品列表。
//
// 职责边界:
// 1. 只返回 active 商品,不负责后台商品管理。
// 2. 负责补齐 price_text保持前端不必重复格式化价格。
// 3. actorUserID 当前仅保留为统一接口形状P0 不参与筛选。
func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
_ = actorUserID
if err := s.Ready(); err != nil {
return nil, err
}
products, err := s.tokenDAO.ListActiveProducts(ctx)
if err != nil {
return nil, err
}
if len(products) == 0 {
return []tokencontracts.TokenProductView{}, nil
}
result := make([]tokencontracts.TokenProductView, 0, len(products))
for _, product := range products {
result = append(result, productViewFromModel(product))
}
return result, nil
}

View File

@@ -1,13 +1,10 @@
package sv
import (
"context"
"errors"
"strconv"
"strings"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
"github.com/spf13/viper"
)
@@ -32,85 +29,12 @@ type forumRewardDecision struct {
Description string
}
// RecordForumRewardGrant 负责把论坛点赞/导入奖励写入 token_grants。
//
// 职责边界:
// 1. 只处理 forum_like / forum_import 两类奖励账本写入,不修改 users也不调用 user/auth
// 2. 以 event_id 作为最终幂等边界,重复请求校验一致后返回既有 grant
// 3. 奖励金额优先读取 token_reward_rules配置和代码默认值只作为兜底。
func (s *Service) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
normalized, err := normalizeForumRewardGrantRequest(req)
if err != nil {
return nil, err
}
// 1. 先按 event_id 回查,命中时直接视为成功,避免 outbox 重试重复写账本。
// 2. 命中后必须校验用户、来源和来源业务 ID避免错误复用 event_id 时静默吞掉错账。
// 3. 校验通过才返回既有 grant兼容“首次已成功、调用方超时后重试”的常见场景。
existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID)
if err != nil {
return nil, err
}
if existing != nil {
if err := validateExistingForumRewardGrant(*existing, normalized); err != nil {
return nil, err
}
view := grantViewFromModel(*existing)
return &view, nil
}
sourceRefID := normalized.SourceRefID
decision, err := s.forumRewardDecision(ctx, normalized.Source)
if err != nil {
return nil, err
}
grant := tokenmodel.TokenGrant{
EventID: normalized.EventID,
UserID: normalized.ReceiverUserID,
Source: normalized.Source,
SourceLabel: grantSourceLabel(normalized.Source, ""),
SourceRefID: &sourceRefID,
Amount: decision.Amount,
Status: decision.Status,
QuotaApplied: false,
Description: decision.Description,
}
// 1. 账本写入只依赖 token_grants.event_id 唯一约束兜底并发幂等。
// 2. 若并发下插入触发唯一键冲突,立刻回查 event_id把已有 grant 当作成功结果返回。
// 3. 只有“冲突后仍查不到旧记录”这种异常态才上抛内部错误,避免吞掉真实一致性问题。
if err := s.tokenDAO.CreateGrant(ctx, &grant); err != nil {
if !isDuplicateKeyError(err) {
return nil, err
}
existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID)
if err != nil {
return nil, err
}
if existing == nil {
return nil, errors.New("forum reward grant duplicated but not found by event_id")
}
if err := validateExistingForumRewardGrant(*existing, normalized); err != nil {
return nil, err
}
view := grantViewFromModel(*existing)
return &view, nil
}
view := grantViewFromModel(grant)
return &view, nil
}
func normalizeForumRewardGrantRequest(req tokencontracts.RecordForumRewardGrantRequest) (forumRewardGrantRequest, error) {
func normalizeForumRewardGrantRequest(req forumRewardGrantRequest) (forumRewardGrantRequest, error) {
normalized := forumRewardGrantRequest{
EventID: strings.TrimSpace(req.EventID),
ReceiverUserID: req.ReceiverUserID,
Source: strings.ToLower(strings.TrimSpace(req.Source)),
SourceRefID: req.SourceRefID,
}
switch {
@@ -118,16 +42,12 @@ func normalizeForumRewardGrantRequest(req tokencontracts.RecordForumRewardGrantR
return forumRewardGrantRequest{}, tokenStoreBadRequest("event_id 不能为空")
case normalized.ReceiverUserID == 0:
return forumRewardGrantRequest{}, tokenStoreBadRequest("receiver_user_id 不能为空")
case normalized.SourceRefID == 0:
return forumRewardGrantRequest{}, tokenStoreBadRequest("source_ref_id 不能为空")
}
sourceRefID, err := parseForumRewardSourceRefID(req.SourceRefID)
if err != nil {
return forumRewardGrantRequest{}, err
}
normalized.SourceRefID = sourceRefID
switch normalized.Source {
case tokenmodel.TokenGrantSourceForumLike, tokenmodel.TokenGrantSourceForumImport:
case storemodel.CreditLedgerSourceForumLike, storemodel.CreditLedgerSourceForumImport:
return normalized, nil
default:
return forumRewardGrantRequest{}, tokenStoreBadRequest("source 仅支持 forum_like 或 forum_import")
@@ -147,69 +67,10 @@ func parseForumRewardSourceRefID(raw string) (uint64, error) {
return parsed, nil
}
// validateExistingForumRewardGrant 校验重复 event_id 是否真的是同一条论坛奖励。
//
// 职责边界:
// 1. 只比较幂等所需的最小字段:接收人、来源和来源业务 ID
// 2. 不比较金额和状态,避免规则调整后重放旧事件被误判;
// 3. 不一致时返回业务校验错误,让上游暴露这类错账风险。
func validateExistingForumRewardGrant(existing tokenmodel.TokenGrant, req forumRewardGrantRequest) error {
sourceRefID := uint64(0)
if existing.SourceRefID != nil {
sourceRefID = *existing.SourceRefID
}
if existing.UserID != req.ReceiverUserID || existing.Source != req.Source || sourceRefID != req.SourceRefID {
return tokenStoreBadRequest("event_id 幂等冲突:已有奖励记录与本次论坛奖励请求不一致")
}
return nil
}
// forumRewardDecision 解析论坛奖励发放决策。
//
// 职责边界:
// 1. 优先读取 token_reward_rules保持“从表里读”的 P0 口径;
// 2. 规则停用或金额非正时写 skipped 账本,消费 outbox 但不增加 Token
// 3. 表规则缺失时再读取配置和代码默认值,兼容旧环境尚未 seed 的情况。
func (s *Service) forumRewardDecision(ctx context.Context, source string) (forumRewardDecision, error) {
rule, err := s.tokenDAO.FindRewardRuleBySource(ctx, source)
if err != nil {
return forumRewardDecision{}, err
}
if rule != nil {
if strings.TrimSpace(rule.Status) != tokenmodel.TokenRewardRuleStatusActive {
return skippedForumRewardDecision(source, "奖励规则已停用,未发放 Token"), nil
}
if rule.Amount <= 0 {
return skippedForumRewardDecision(source, "奖励规则金额非正,未发放 Token"), nil
}
return recordedForumRewardDecision(source, rule.Amount), nil
}
switch strings.TrimSpace(source) {
case tokenmodel.TokenGrantSourceForumLike:
return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumLikeRewardConfigKey, defaultForumLikeRewardAmount)), nil
case tokenmodel.TokenGrantSourceForumImport:
return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumImportRewardConfigKey, defaultForumImportRewardAmount)), nil
default:
return skippedForumRewardDecision(source, "未知论坛奖励来源,未发放 Token"), nil
}
}
func recordedForumRewardDecision(source string, amount int64) forumRewardDecision {
if amount <= 0 {
return skippedForumRewardDecision(source, "奖励金额非正,未发放 Token")
}
return forumRewardDecision{
Amount: amount,
Status: tokenmodel.TokenGrantStatusRecorded,
Description: forumRewardDescription(source),
}
}
func skippedForumRewardDecision(source string, description string) forumRewardDecision {
return forumRewardDecision{
Amount: 0,
Status: tokenmodel.TokenGrantStatusSkipped,
Status: storemodel.CreditLedgerStatusSkipped,
Description: strings.TrimSpace(description),
}
}
@@ -224,9 +85,9 @@ func positiveConfigAmountOrDefault(configKey string, fallback int64) int64 {
func forumRewardDescription(source string) string {
switch strings.TrimSpace(source) {
case tokenmodel.TokenGrantSourceForumLike:
case storemodel.CreditLedgerSourceForumLike:
return "计划被点赞奖励"
case tokenmodel.TokenGrantSourceForumImport:
case storemodel.CreditLedgerSourceForumImport:
return "计划被导入奖励"
default:
return "论坛奖励入账"

View File

@@ -1,54 +1,41 @@
package sv
import (
"context"
"errors"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"gorm.io/gorm"
)
// ErrNotImplemented 表示 RPC 骨架已接线,但对应业务用例还在后续步骤实现。
var ErrNotImplemented = errors.New("tokenstore service method not implemented")
// TokenGrantOutlet 是 token-store 后续切到 user/auth 权威额度的内部发放出口。
//
// 职责边界:
// 1. P0 只记录 token-store 自己的获取事实和账本;
// 2. 禁止直接修改 users 表;
// 3. 后续切 user/auth 时新增 adapter服务编排层不重写。
type TokenGrantOutlet interface {
RecordAcquisition(ctx context.Context, grant tokencontracts.TokenGrantRecord) error
}
// Options 是 token-store 服务的依赖注入参数。
type Options struct {
DB *gorm.DB
GrantOutlet TokenGrantOutlet
CreditCache *tokenstoredao.CreditCacheDAO
}
// Service 承载 Token 商店服务内部业务编排。
// Service 承载 token-store 内部业务编排。
//
// 职责边界:
// 1. 负责商品、订单、mock paid、grant 账本和奖励规则
// 2. 不负责登录鉴权,也不直接修改 user/auth 权威额度
// 3. 不负责真实第三方支付回调P0 只处理 mock paid
// 1. 同时承载旧 Token 商店与新 Credit 权威账本两套能力,服务进程先并行存在
// 2. Token 与 Credit 分别走各自 DAO不在服务层混写数据表访问
// 3. 真正的跨服务 HTTP/gateway 接线留给后续第三步,本层只暴露 RPC 可用能力
type Service struct {
db *gorm.DB
tokenDAO *tokenstoredao.TokenStoreDAO
grantOutlet TokenGrantOutlet
creditDAO *tokenstoredao.CreditStoreDAO
creditCache *tokenstoredao.CreditCacheDAO
}
func New(opts Options) *Service {
return &Service{
db: opts.DB,
tokenDAO: tokenstoredao.NewTokenStoreDAO(opts.DB),
grantOutlet: opts.GrantOutlet,
creditDAO: tokenstoredao.NewCreditStoreDAO(opts.DB),
creditCache: opts.CreditCache,
}
}
// Ready 用于第二步骨架阶段的依赖检查。
// Ready 用于服务依赖检查。
func (s *Service) Ready() error {
if s == nil {
return errors.New("tokenstore service is nil")