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,131 @@
package dao
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
const (
defaultCreditSnapshotTTL = 10 * time.Minute
defaultCreditBlockedTTL = 30 * time.Minute
)
// CreditBalanceSnapshot 是 TokenStore 在 Redis 中维护的余额快照。
type CreditBalanceSnapshot struct {
UserID uint64 `json:"user_id"`
Balance int64 `json:"balance"`
TotalRecharged int64 `json:"total_recharged"`
TotalRewarded int64 `json:"total_rewarded"`
TotalConsumed int64 `json:"total_consumed"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreditCacheDAO 只承载 Credit 余额快照相关的 Redis 能力。
type CreditCacheDAO struct {
client *redis.Client
}
func NewCreditCacheDAO(client *redis.Client) *CreditCacheDAO {
return &CreditCacheDAO{client: client}
}
func creditBalanceSnapshotKey(userID uint64) string {
return fmt.Sprintf("smartflow:credit_balance_snapshot:%d", userID)
}
func creditBlockedKey(userID uint64) string {
return fmt.Sprintf("smartflow:credit_blocked:%d", userID)
}
// SnapshotTTL 返回余额快照默认 TTL。
func (d *CreditCacheDAO) SnapshotTTL() time.Duration {
return defaultCreditSnapshotTTL
}
// BlockedTTL 返回阻断标记默认 TTL。
func (d *CreditCacheDAO) BlockedTTL() time.Duration {
return defaultCreditBlockedTTL
}
// GetCreditBalanceSnapshot 读取用户余额快照。
func (d *CreditCacheDAO) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*CreditBalanceSnapshot, bool, error) {
if d == nil || d.client == nil || userID == 0 {
return nil, false, nil
}
val, err := d.client.Get(ctx, creditBalanceSnapshotKey(userID)).Result()
if errors.Is(err, redis.Nil) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var snapshot CreditBalanceSnapshot
if err = json.Unmarshal([]byte(val), &snapshot); err != nil {
return nil, false, err
}
return &snapshot, true, nil
}
// SetCreditBalanceSnapshot 写入用户余额快照。
func (d *CreditCacheDAO) SetCreditBalanceSnapshot(ctx context.Context, userID uint64, snapshot CreditBalanceSnapshot, ttl time.Duration) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
if ttl <= 0 {
ttl = d.SnapshotTTL()
}
data, err := json.Marshal(snapshot)
if err != nil {
return err
}
return d.client.Set(ctx, creditBalanceSnapshotKey(userID), data, ttl).Err()
}
// DeleteCreditBalanceSnapshot 删除余额快照。
func (d *CreditCacheDAO) DeleteCreditBalanceSnapshot(ctx context.Context, userID uint64) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
return d.client.Del(ctx, creditBalanceSnapshotKey(userID)).Err()
}
// IsUserCreditBlocked 判断用户是否被阻断。
func (d *CreditCacheDAO) IsUserCreditBlocked(ctx context.Context, userID uint64) (bool, error) {
if d == nil || d.client == nil || userID == 0 {
return false, nil
}
result, err := d.client.Get(ctx, creditBlockedKey(userID)).Result()
if errors.Is(err, redis.Nil) {
return false, nil
}
if err != nil {
return false, err
}
return result == "1", nil
}
// SetUserCreditBlocked 写入用户阻断标记。
func (d *CreditCacheDAO) SetUserCreditBlocked(ctx context.Context, userID uint64, ttl time.Duration) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
if ttl <= 0 {
ttl = d.BlockedTTL()
}
return d.client.Set(ctx, creditBlockedKey(userID), "1", ttl).Err()
}
// DeleteUserCreditBlocked 删除用户阻断标记。
func (d *CreditCacheDAO) DeleteUserCreditBlocked(ctx context.Context, userID uint64) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
return d.client.Del(ctx, creditBlockedKey(userID)).Err()
}

View File

@@ -1,12 +1,14 @@
package dao
import (
"errors"
"fmt"
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
mysqlinfra "github.com/LoveLosita/smartflow/backend/shared/infra/mysql"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
redisinfra "github.com/LoveLosita/smartflow/backend/shared/infra/redis"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -14,22 +16,11 @@ import (
// OpenDBFromConfig 创建 token-store 服务自己的数据库句柄,并迁移本服务私有表。
//
// 职责边界:
// 1. 只迁移 token_* 表和 token-store outbox 表,不迁移 users避免和 user/auth 服务边界冲突
// 2. 自动迁移后执行 P0 seed确保前端商品页有可展示商品
// 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。
// 1. 只迁移 token_*、credit_* 以及 token-store outbox 表,不迁移其它服务表
// 2. 自动迁移后执行默认 seed保证旧 Token 链路和新 Credit 链路都能并行跑通
// 3. 返回 *gorm.DB 供 DAO 复用,调用方负责进程生命周期。
func OpenDBFromConfig() (*gorm.DB, error) {
host := viper.GetString("database.host")
port := viper.GetString("database.port")
user := viper.GetString("database.user")
password := viper.GetString("database.password")
dbname := viper.GetString("database.dbname")
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
user, password, host, port, dbname,
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
db, err := mysqlinfra.OpenDBFromConfig()
if err != nil {
return nil, err
}
@@ -42,22 +33,33 @@ func OpenDBFromConfig() (*gorm.DB, error) {
return db, nil
}
// OpenRedisFromConfig 创建 token-store 服务自己的 Redis 句柄。
//
// 职责边界:
// 1. 这里只负责初始化通用 Redis 连接,不决定是否必须启用;
// 2. 调用方可以把失败视为“可选能力不可用”,而不是必须退出进程;
// 3. Credit 缓存 key 语义统一放在 tokenstore 自己的 cache DAO 内维护。
func OpenRedisFromConfig() (*redis.Client, error) {
return redisinfra.OpenRedisFromConfig()
}
// AutoMigrate 只迁移 token-store 服务拥有的表。
//
// 步骤说明:
// 1. 先创建商品、订单、获取账本和奖励规则表;
// 2. 再按 service catalog 创建 token-store outbox 表,保证论坛奖励事件有稳定落表目录
// 3. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入;
// 4. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。
// 1. 只迁移 Credit 权威账本表;
// 2. 最后迁移 token-store outbox 表,保证论坛奖励与 Credit 扣费消费都能稳定落表;
// 3. 任一步失败都直接返回,避免服务在 schema 不完整时继续启动。
func AutoMigrate(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("tokenstore auto migrate failed: db is nil")
}
if err := db.AutoMigrate(
&tokenmodel.TokenProduct{},
&tokenmodel.TokenOrder{},
&tokenmodel.TokenGrant{},
&tokenmodel.TokenRewardRule{},
&storemodel.CreditAccount{},
&storemodel.CreditLedger{},
&storemodel.CreditProduct{},
&storemodel.CreditOrder{},
&storemodel.CreditPriceRule{},
&storemodel.CreditRewardRule{},
); err != nil {
return fmt.Errorf("auto migrate tokenstore tables failed: %w", err)
}
@@ -67,88 +69,62 @@ func AutoMigrate(db *gorm.DB) error {
return nil
}
// SeedDefaults 写入 P0 默认商品和奖励规则。
// SeedDefaults 写入 Token 与 Credit 默认商品/规则。
//
// 步骤说明:
// 1. 商品奖励规则都用稳定业务键做 upsert允许重复启动服务
// 2. seed 只提供 P0 默认数据,不代表有管理后台能力;
// 3. 后续若商品或规则由运营后台维护,可替换本函数或仅保留初始化兜底。
// 1. 只保留 Credit 商品奖励规则 seed
// 2. Credit 价格规则本轮只建表不写默认价格,避免误用错误计费参数。
func SeedDefaults(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("tokenstore seed failed: db is nil")
}
if err := seedDefaultProducts(db); err != nil {
if err := seedDefaultCreditProducts(db); err != nil {
return err
}
if err := seedDefaultRewardRules(db); err != nil {
if err := backfillCreditProductOriginalPrice(db); err != nil {
return err
}
if err := seedDefaultCreditPriceRules(db); err != nil {
return err
}
if err := seedDefaultCreditRewardRules(db); err != nil {
return err
}
return nil
}
func seedDefaultProducts(db *gorm.DB) error {
products := defaultTokenProducts()
func seedDefaultCreditProducts(db *gorm.DB) error {
products := defaultCreditProducts()
for _, product := range products {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sku"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"description",
"token_amount",
"price_cent",
"currency",
"badge",
"status",
"sort_order",
"updated_at",
}),
}).Create(&product).Error; err != nil {
return fmt.Errorf("seed token product %s failed: %w", product.SKU, err)
// 1. 这里只负责“缺失即补齐”的默认商品播种,不再把服务启动当成运营配置同步器。
// 2. 一旦线上已经存在同 SKU 商品,说明运营侧可能手动改过价格、文案或状态,此时必须保留现状。
// 3. 真正需要批量改默认套餐时,应该走显式 migration 或脚本,而不是依赖服务重启覆盖。
if err := db.Clauses(creditProductSeedOnConflict()).Create(&product).Error; err != nil {
return fmt.Errorf("seed credit product %s failed: %w", product.SKU, err)
}
}
return nil
}
func defaultTokenProducts() []tokenmodel.TokenProduct {
return []tokenmodel.TokenProduct{
{
SKU: "token_basic_100",
Name: "基础 Token 包",
Description: "适合轻量使用 Agent。",
TokenAmount: 100,
PriceCent: 990,
Currency: "CNY",
Badge: "入门",
Status: tokenmodel.TokenProductStatusActive,
SortOrder: 10,
},
{
SKU: "token_plus_300",
Name: "进阶 Token 包",
Description: "适合高频规划和复盘。",
TokenAmount: 300,
PriceCent: 1990,
Currency: "CNY",
Badge: "推荐",
Status: tokenmodel.TokenProductStatusActive,
SortOrder: 20,
},
{
SKU: "token_pro_800",
Name: "专业 Token 包",
Description: "适合长周期学习计划和高频 Agent 使用。",
TokenAmount: 800,
PriceCent: 3990,
Currency: "CNY",
Badge: "高频",
Status: tokenmodel.TokenProductStatusActive,
SortOrder: 30,
},
func creditProductSeedOnConflict() clause.OnConflict {
return clause.OnConflict{
Columns: []clause.Column{{Name: "sku"}},
DoNothing: true,
}
}
func seedDefaultRewardRules(db *gorm.DB) error {
rules := defaultTokenRewardRules()
func backfillCreditProductOriginalPrice(db *gorm.DB) error {
// 1. 只回填 original_price_cent 为空的旧数据,避免覆盖运营已手工维护的划线价。
// 2. 回填时直接复用当前售价 price_cent保证接口上线后这个字段立刻可用。
// 3. 这里不改商品文案、状态和现价,继续遵守“服务启动不是配置覆盖器”的边界。
return db.
Model(&storemodel.CreditProduct{}).
Where("original_price_cent = 0").
Update("original_price_cent", gorm.Expr("price_cent")).Error
}
func seedDefaultCreditRewardRules(db *gorm.DB) error {
rules := defaultCreditRewardRules()
for _, rule := range rules {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}},
@@ -156,29 +132,141 @@ func seedDefaultRewardRules(db *gorm.DB) error {
"name",
"amount",
"status",
"description",
"config_json",
"updated_at",
}),
}).Create(&rule).Error; err != nil {
return fmt.Errorf("seed token reward rule %s failed: %w", rule.Source, err)
return fmt.Errorf("seed credit reward rule %s failed: %w", rule.Source, err)
}
}
return nil
}
func defaultTokenRewardRules() []tokenmodel.TokenRewardRule {
return []tokenmodel.TokenRewardRule{
func seedDefaultCreditPriceRules(db *gorm.DB) error {
rules := defaultCreditPriceRules()
for _, rule := range rules {
var existing storemodel.CreditPriceRule
err := db.
Where(
"scene = ? AND provider_name = ? AND model_name = ? AND status = ?",
rule.Scene,
rule.ProviderName,
rule.ModelName,
storemodel.CreditPriceRuleStatusActive,
).
First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if createErr := db.Create(&rule).Error; createErr != nil {
return fmt.Errorf("seed credit price rule %s/%s/%s failed: %w", rule.Scene, rule.ProviderName, rule.ModelName, createErr)
}
continue
}
if err != nil {
return fmt.Errorf("load credit price rule %s/%s/%s failed: %w", rule.Scene, rule.ProviderName, rule.ModelName, err)
}
}
return nil
}
func defaultCreditProducts() []storemodel.CreditProduct {
return []storemodel.CreditProduct{
{
Source: tokenmodel.TokenGrantSourceForumLike,
Name: "计划被点赞奖励",
Amount: 1,
Status: tokenmodel.TokenRewardRuleStatusActive,
SKU: "credit_free_100",
Name: "Free",
Description: "每日免费发放,适合基础功能体验。",
CreditAmount: 100,
PriceCent: 0,
OriginalPriceCent: 0,
Currency: "CNY",
Badge: "每日",
Status: storemodel.CreditProductStatusActive,
SortOrder: 10,
},
{
Source: tokenmodel.TokenGrantSourceForumImport,
Name: "计划被导入奖励",
Amount: 5,
Status: tokenmodel.TokenRewardRuleStatusActive,
SKU: "credit_starter_1000",
Name: "Starter",
Description: "入门级额度,有效期 1 个月。续费时时间和额度均可累加。",
CreditAmount: 1000,
PriceCent: 990,
OriginalPriceCent: 990,
Currency: "CNY",
Badge: "入门",
Status: storemodel.CreditProductStatusActive,
SortOrder: 20,
},
{
SKU: "credit_lite_3000",
Name: "Lite",
Description: "经济型套餐,有效期 1 个月。适合日常轻度规划。",
CreditAmount: 3000,
PriceCent: 1990,
OriginalPriceCent: 1990,
Currency: "CNY",
Badge: "经济",
Status: storemodel.CreditProductStatusActive,
SortOrder: 30,
},
{
SKU: "credit_pro_10000",
Name: "Pro",
Description: "专业版套餐,有效期 1 个月。最受深度规划用户欢迎。",
CreditAmount: 10000,
PriceCent: 3990,
OriginalPriceCent: 3990,
Currency: "CNY",
Badge: "Most Popular",
Status: storemodel.CreditProductStatusActive,
SortOrder: 40,
},
{
SKU: "credit_max_40000",
Name: "Max",
Description: "旗舰级套餐,有效期 1 个月。极致体验,额度充沛。",
CreditAmount: 40000,
PriceCent: 9990,
OriginalPriceCent: 9990,
Currency: "CNY",
Badge: "旗舰",
Status: storemodel.CreditProductStatusActive,
SortOrder: 50,
},
}
}
func defaultCreditRewardRules() []storemodel.CreditRewardRule {
return []storemodel.CreditRewardRule{
{
Source: storemodel.CreditLedgerSourceForumLike,
Name: "计划被点赞奖励",
Amount: 1,
Status: storemodel.CreditRewardRuleStatusActive,
Description: "预留论坛点赞正向激励。",
},
{
Source: storemodel.CreditLedgerSourceForumImport,
Name: "计划被导入奖励",
Amount: 5,
Status: storemodel.CreditRewardRuleStatusActive,
Description: "预留论坛导入正向激励。",
},
}
}
func defaultCreditPriceRules() []storemodel.CreditPriceRule {
return []storemodel.CreditPriceRule{
{
Scene: "*",
ProviderName: "ark",
ModelName: "*",
InputPriceMicros: 3200,
OutputPriceMicros: 16000,
CachedPriceMicros: 800,
ReasoningPriceMicros: 16000,
CreditPerYuan: 100,
Status: storemodel.CreditPriceRuleStatusActive,
Priority: 100,
Description: "Default Ark rule, prices are expressed in micros CNY per 1K tokens.",
},
}
}

View File

@@ -0,0 +1,380 @@
package dao
import (
"context"
"errors"
"strings"
"time"
creditmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CreditStoreDAO 承载 Credit 权威账本相关表的持久化访问。
//
// 职责边界:
// 1. 只访问 credit_accounts、credit_ledger、credit_products、credit_orders、credit_price_rules、credit_reward_rules
// 2. 只提供查询、事务、行锁与原子状态更新,不承载 RPC/前端展示拼装;
// 3. 幂等语义、扣费校验和缓存同步策略由服务层负责。
type CreditStoreDAO struct {
db *gorm.DB
}
func NewCreditStoreDAO(db *gorm.DB) *CreditStoreDAO {
return &CreditStoreDAO{db: db}
}
func (dao *CreditStoreDAO) WithTx(tx *gorm.DB) *CreditStoreDAO {
return &CreditStoreDAO{db: tx}
}
// Transaction 在一个数据库事务内执行 Credit 账本写操作。
func (dao *CreditStoreDAO) Transaction(ctx context.Context, fn func(txDAO *CreditStoreDAO) error) error {
return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(dao.WithTx(tx))
})
}
type ListCreditOrdersQuery struct {
UserID uint64
Page int
PageSize int
Status string
}
type ListCreditTransactionsQuery struct {
UserID uint64
Page int
PageSize int
Source string
Direction string
}
type GetCreditConsumptionDashboardQuery struct {
UserID uint64
CreatedFrom *time.Time
}
type CreditConsumptionDashboardAggregate struct {
CreditConsumed int64
TokenConsumed int64
}
type ListCreditPriceRulesQuery struct {
Scene string
ProviderName string
ModelName string
Status string
}
type ListCreditRewardRulesQuery struct {
Source string
Status string
}
func (dao *CreditStoreDAO) ListActiveProducts(ctx context.Context) ([]creditmodel.CreditProduct, error) {
var products []creditmodel.CreditProduct
err := dao.db.WithContext(ctx).
Where("status = ?", creditmodel.CreditProductStatusActive).
Order("sort_order ASC, id ASC").
Find(&products).Error
return products, err
}
func (dao *CreditStoreDAO) FindActiveProductByID(ctx context.Context, productID uint64) (*creditmodel.CreditProduct, error) {
var product creditmodel.CreditProduct
err := dao.db.WithContext(ctx).
Where("id = ? AND status = ?", productID, creditmodel.CreditProductStatusActive).
First(&product).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &product, nil
}
func (dao *CreditStoreDAO) FindOrderByUserIdempotencyKey(ctx context.Context, userID uint64, key string) (*creditmodel.CreditOrder, error) {
var order creditmodel.CreditOrder
err := dao.db.WithContext(ctx).
Where("user_id = ? AND idempotency_key = ?", userID, key).
First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &order, nil
}
func (dao *CreditStoreDAO) CreateOrder(ctx context.Context, order *creditmodel.CreditOrder) error {
return dao.db.WithContext(ctx).Create(order).Error
}
func (dao *CreditStoreDAO) CountOrders(ctx context.Context, query ListCreditOrdersQuery) (int64, error) {
db := dao.db.WithContext(ctx).
Model(&creditmodel.CreditOrder{}).
Where("user_id = ?", query.UserID)
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var total int64
err := db.Count(&total).Error
return total, err
}
func (dao *CreditStoreDAO) ListOrders(ctx context.Context, query ListCreditOrdersQuery) ([]creditmodel.CreditOrder, error) {
db := dao.db.WithContext(ctx).
Where("user_id = ?", query.UserID)
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var orders []creditmodel.CreditOrder
err := db.Order("created_at DESC, id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&orders).Error
return orders, err
}
func (dao *CreditStoreDAO) FindOrderByID(ctx context.Context, orderID uint64) (*creditmodel.CreditOrder, error) {
var order creditmodel.CreditOrder
err := dao.db.WithContext(ctx).Where("id = ?", orderID).First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &order, nil
}
func (dao *CreditStoreDAO) LockOrderByID(ctx context.Context, orderID uint64) (*creditmodel.CreditOrder, error) {
var order creditmodel.CreditOrder
err := dao.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", orderID).
First(&order).Error
if err != nil {
return nil, err
}
return &order, nil
}
// UpdateOrderState 只负责把 Credit 订单持久化到最新状态。
func (dao *CreditStoreDAO) UpdateOrderState(ctx context.Context, orderID uint64, status string, paidAt *time.Time, creditedAt *time.Time, paymentMode string) error {
updates := map[string]any{
"status": status,
"paid_at": paidAt,
"credited_at": creditedAt,
"payment_mode": paymentMode,
"updated_at": time.Now(),
}
return dao.db.WithContext(ctx).
Model(&creditmodel.CreditOrder{}).
Where("id = ?", orderID).
Updates(updates).Error
}
func (dao *CreditStoreDAO) FindLedgerByEventID(ctx context.Context, eventID string) (*creditmodel.CreditLedger, error) {
var ledger creditmodel.CreditLedger
err := dao.db.WithContext(ctx).
Where("event_id = ?", eventID).
First(&ledger).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &ledger, nil
}
func (dao *CreditStoreDAO) FindLatestLedgerByOrderID(ctx context.Context, orderID uint64) (*creditmodel.CreditLedger, error) {
var ledger creditmodel.CreditLedger
err := dao.db.WithContext(ctx).
Where("order_id = ?", orderID).
Order("created_at DESC, id DESC").
First(&ledger).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &ledger, nil
}
func (dao *CreditStoreDAO) ListLedgerByOrderIDs(ctx context.Context, orderIDs []uint64) ([]creditmodel.CreditLedger, error) {
if len(orderIDs) == 0 {
return []creditmodel.CreditLedger{}, nil
}
var ledgers []creditmodel.CreditLedger
err := dao.db.WithContext(ctx).
Where("order_id IN ?", orderIDs).
Order("created_at DESC, id DESC").
Find(&ledgers).Error
return ledgers, err
}
func (dao *CreditStoreDAO) CreateLedger(ctx context.Context, ledger *creditmodel.CreditLedger) error {
return dao.db.WithContext(ctx).Create(ledger).Error
}
func (dao *CreditStoreDAO) CountTransactions(ctx context.Context, query ListCreditTransactionsQuery) (int64, error) {
db := dao.db.WithContext(ctx).
Model(&creditmodel.CreditLedger{}).
Where("user_id = ?", query.UserID)
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
if direction := strings.TrimSpace(query.Direction); direction != "" {
db = db.Where("direction = ?", direction)
}
var total int64
err := db.Count(&total).Error
return total, err
}
func (dao *CreditStoreDAO) ListTransactions(ctx context.Context, query ListCreditTransactionsQuery) ([]creditmodel.CreditLedger, error) {
db := dao.db.WithContext(ctx).
Where("user_id = ?", query.UserID)
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
if direction := strings.TrimSpace(query.Direction); direction != "" {
db = db.Where("direction = ?", direction)
}
var items []creditmodel.CreditLedger
err := db.Order("created_at DESC, id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&items).Error
return items, err
}
// GetCreditConsumptionDashboard 只聚合当前用户 AI 扣费流水对应的消耗看板数据。
//
// 职责边界:
// 1. 只统计 source=charge 且 direction=expense 的流水,保证商店页口径和真实扣费一致。
// 2. 默认排除 failed 流水skipped 会保留,这样可展示“有 Token 消耗但 Credit 未扣减”的真实情况。
// 3. 这里只做聚合查询,不负责周期归一化、权限校验和前端文案拼装。
func (dao *CreditStoreDAO) GetCreditConsumptionDashboard(ctx context.Context, query GetCreditConsumptionDashboardQuery) (CreditConsumptionDashboardAggregate, error) {
type aggregateRow struct {
CreditConsumed int64 `gorm:"column:credit_consumed"`
TokenConsumed int64 `gorm:"column:token_consumed"`
}
db := dao.db.WithContext(ctx).
Model(&creditmodel.CreditLedger{}).
Select(`
COALESCE(SUM(CASE WHEN amount < 0 THEN -amount ELSE 0 END), 0) AS credit_consumed,
COALESCE(SUM(
CASE
WHEN COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.total_tokens')) AS SIGNED), 0) > 0
THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.total_tokens')) AS SIGNED)
ELSE GREATEST(
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.input_tokens')) AS SIGNED), 0) +
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.output_tokens')) AS SIGNED), 0),
0
)
END
), 0) AS token_consumed
`).
Where("user_id = ?", query.UserID).
Where("source = ?", creditmodel.CreditLedgerSourceCharge).
Where("direction = ?", creditmodel.CreditLedgerDirectionExpense).
Where("status <> ?", creditmodel.CreditLedgerStatusFailed)
if query.CreatedFrom != nil {
db = db.Where("created_at >= ?", *query.CreatedFrom)
}
var row aggregateRow
if err := db.Scan(&row).Error; err != nil {
return CreditConsumptionDashboardAggregate{}, err
}
return CreditConsumptionDashboardAggregate{
CreditConsumed: row.CreditConsumed,
TokenConsumed: row.TokenConsumed,
}, nil
}
func (dao *CreditStoreDAO) FindAccountByUserID(ctx context.Context, userID uint64) (*creditmodel.CreditAccount, error) {
var account creditmodel.CreditAccount
err := dao.db.WithContext(ctx).
Where("user_id = ?", userID).
First(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &account, nil
}
func (dao *CreditStoreDAO) LockAccountByUserID(ctx context.Context, userID uint64) (*creditmodel.CreditAccount, error) {
var account creditmodel.CreditAccount
err := dao.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ?", userID).
First(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &account, nil
}
func (dao *CreditStoreDAO) CreateAccount(ctx context.Context, account *creditmodel.CreditAccount) error {
return dao.db.WithContext(ctx).Create(account).Error
}
func (dao *CreditStoreDAO) SaveAccount(ctx context.Context, account *creditmodel.CreditAccount) error {
return dao.db.WithContext(ctx).Save(account).Error
}
func (dao *CreditStoreDAO) ListPriceRules(ctx context.Context, query ListCreditPriceRulesQuery) ([]creditmodel.CreditPriceRule, error) {
db := dao.db.WithContext(ctx).Model(&creditmodel.CreditPriceRule{})
if scene := strings.TrimSpace(query.Scene); scene != "" {
db = db.Where("scene = ?", scene)
}
if providerName := strings.TrimSpace(query.ProviderName); providerName != "" {
db = db.Where("provider_name = ?", providerName)
}
if modelName := strings.TrimSpace(query.ModelName); modelName != "" {
db = db.Where("model_name = ?", modelName)
}
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var rules []creditmodel.CreditPriceRule
err := db.Order("priority DESC, id ASC").Find(&rules).Error
return rules, err
}
func (dao *CreditStoreDAO) ListRewardRules(ctx context.Context, query ListCreditRewardRulesQuery) ([]creditmodel.CreditRewardRule, error) {
db := dao.db.WithContext(ctx).Model(&creditmodel.CreditRewardRule{})
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var rules []creditmodel.CreditRewardRule
err := db.Order("id ASC").Find(&rules).Error
return rules, err
}

View File

@@ -1,285 +0,0 @@
package dao
import (
"context"
"errors"
"strings"
"time"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// TokenStoreDAO 承载 token-store 私有表的持久化访问。
//
// 职责边界:
// 1. 只访问 token_products、token_orders、token_grants、token_reward_rules。
// 2. 只提供查询、事务和原子状态更新,不组装 RPC/HTTP 视图。
// 3. 业务状态机、幂等回退和提示文案由 sv 层负责。
type TokenStoreDAO struct {
db *gorm.DB
}
func NewTokenStoreDAO(db *gorm.DB) *TokenStoreDAO {
return &TokenStoreDAO{db: db}
}
func (dao *TokenStoreDAO) WithTx(tx *gorm.DB) *TokenStoreDAO {
return &TokenStoreDAO{db: tx}
}
// Transaction 在一个数据库事务内执行 token-store 写操作。
func (dao *TokenStoreDAO) Transaction(ctx context.Context, fn func(txDAO *TokenStoreDAO) error) error {
return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(dao.WithTx(tx))
})
}
type ListTokenOrdersQuery struct {
UserID uint64
Page int
PageSize int
Status string
}
type ListTokenGrantsQuery struct {
UserID uint64
Page int
PageSize int
Source string
}
type TokenGrantSummary struct {
RecordedTokenTotal int64
AppliedTokenTotal int64
}
func (dao *TokenStoreDAO) ListActiveProducts(ctx context.Context) ([]tokenmodel.TokenProduct, error) {
var products []tokenmodel.TokenProduct
err := dao.db.WithContext(ctx).
Where("status = ?", tokenmodel.TokenProductStatusActive).
Order("sort_order ASC, id ASC").
Find(&products).Error
return products, err
}
// FindRewardRuleBySource 按来源读取社区奖励规则。
//
// 职责边界:
// 1. 只读取 token_reward_rules不计算最终发放金额也不判断停用语义
// 2. 未找到规则时返回 nil由服务层决定配置或默认值兜底
// 3. source 在 DAO 层做一次规范化,避免大小写和空格造成规则漏命中。
func (dao *TokenStoreDAO) FindRewardRuleBySource(ctx context.Context, source string) (*tokenmodel.TokenRewardRule, error) {
source = strings.ToLower(strings.TrimSpace(source))
if source == "" {
return nil, nil
}
var rule tokenmodel.TokenRewardRule
err := dao.db.WithContext(ctx).
Where("source = ?", source).
First(&rule).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &rule, nil
}
func (dao *TokenStoreDAO) FindActiveProductByID(ctx context.Context, productID uint64) (*tokenmodel.TokenProduct, error) {
var product tokenmodel.TokenProduct
err := dao.db.WithContext(ctx).
Where("id = ? AND status = ?", productID, tokenmodel.TokenProductStatusActive).
First(&product).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &product, nil
}
func (dao *TokenStoreDAO) FindOrderByUserIdempotencyKey(ctx context.Context, userID uint64, key string) (*tokenmodel.TokenOrder, error) {
var order tokenmodel.TokenOrder
err := dao.db.WithContext(ctx).
Where("user_id = ? AND idempotency_key = ?", userID, key).
First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &order, nil
}
func (dao *TokenStoreDAO) CreateOrder(ctx context.Context, order *tokenmodel.TokenOrder) error {
return dao.db.WithContext(ctx).Create(order).Error
}
func (dao *TokenStoreDAO) CountOrders(ctx context.Context, query ListTokenOrdersQuery) (int64, error) {
db := dao.db.WithContext(ctx).
Model(&tokenmodel.TokenOrder{}).
Where("user_id = ?", query.UserID)
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var total int64
err := db.Count(&total).Error
return total, err
}
func (dao *TokenStoreDAO) ListOrders(ctx context.Context, query ListTokenOrdersQuery) ([]tokenmodel.TokenOrder, error) {
db := dao.db.WithContext(ctx).
Where("user_id = ?", query.UserID)
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var orders []tokenmodel.TokenOrder
err := db.Order("created_at DESC, id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&orders).Error
return orders, err
}
func (dao *TokenStoreDAO) FindOrderByID(ctx context.Context, orderID uint64) (*tokenmodel.TokenOrder, error) {
var order tokenmodel.TokenOrder
err := dao.db.WithContext(ctx).Where("id = ?", orderID).First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &order, nil
}
func (dao *TokenStoreDAO) LockOrderByID(ctx context.Context, orderID uint64) (*tokenmodel.TokenOrder, error) {
var order tokenmodel.TokenOrder
err := dao.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", orderID).
First(&order).Error
if err != nil {
return nil, err
}
return &order, nil
}
// UpdateOrderState 只负责把订单持久化到最新状态。
//
// 职责边界:
// 1. 调用方必须先完成状态机判断,并决定最终 status/paid_at/granted_at。
// 2. 这里不做“是否允许从 A -> B”校验避免 DAO 层承载业务规则。
// 3. payment_mode 允许调用方显式回填,保证 mock paid 后订单快照完整。
func (dao *TokenStoreDAO) UpdateOrderState(ctx context.Context, orderID uint64, status string, paidAt *time.Time, grantedAt *time.Time, paymentMode string) error {
updates := map[string]any{
"status": status,
"paid_at": paidAt,
"granted_at": grantedAt,
"payment_mode": paymentMode,
"updated_at": time.Now(),
}
return dao.db.WithContext(ctx).
Model(&tokenmodel.TokenOrder{}).
Where("id = ?", orderID).
Updates(updates).Error
}
func (dao *TokenStoreDAO) FindGrantByEventID(ctx context.Context, eventID string) (*tokenmodel.TokenGrant, error) {
var grant tokenmodel.TokenGrant
err := dao.db.WithContext(ctx).
Where("event_id = ?", eventID).
First(&grant).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &grant, nil
}
func (dao *TokenStoreDAO) FindGrantByOrderID(ctx context.Context, orderID uint64) (*tokenmodel.TokenGrant, error) {
var grant tokenmodel.TokenGrant
err := dao.db.WithContext(ctx).
Where("order_id = ?", orderID).
Order("created_at DESC, id DESC").
First(&grant).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &grant, nil
}
func (dao *TokenStoreDAO) ListGrantsByOrderIDs(ctx context.Context, orderIDs []uint64) ([]tokenmodel.TokenGrant, error) {
if len(orderIDs) == 0 {
return []tokenmodel.TokenGrant{}, nil
}
var grants []tokenmodel.TokenGrant
err := dao.db.WithContext(ctx).
Where("order_id IN ?", orderIDs).
Order("created_at DESC, id DESC").
Find(&grants).Error
return grants, err
}
func (dao *TokenStoreDAO) CreateGrant(ctx context.Context, grant *tokenmodel.TokenGrant) error {
return dao.db.WithContext(ctx).Create(grant).Error
}
func (dao *TokenStoreDAO) CountGrants(ctx context.Context, query ListTokenGrantsQuery) (int64, error) {
db := dao.db.WithContext(ctx).
Model(&tokenmodel.TokenGrant{}).
Where("user_id = ?", query.UserID)
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
var total int64
err := db.Count(&total).Error
return total, err
}
func (dao *TokenStoreDAO) ListGrants(ctx context.Context, query ListTokenGrantsQuery) ([]tokenmodel.TokenGrant, error) {
db := dao.db.WithContext(ctx).
Where("user_id = ?", query.UserID)
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
var grants []tokenmodel.TokenGrant
err := db.Order("created_at DESC, id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&grants).Error
return grants, err
}
func (dao *TokenStoreDAO) SummarizePositiveGrants(ctx context.Context, userID uint64) (TokenGrantSummary, error) {
var summary TokenGrantSummary
err := dao.db.WithContext(ctx).
Model(&tokenmodel.TokenGrant{}).
Select(
`COALESCE(SUM(CASE WHEN amount > 0 AND status IN (?, ?) THEN amount ELSE 0 END), 0) AS recorded_token_total,
COALESCE(SUM(CASE WHEN amount > 0 AND (quota_applied = ? OR status = ?) THEN amount ELSE 0 END), 0) AS applied_token_total`,
tokenmodel.TokenGrantStatusRecorded,
tokenmodel.TokenGrantStatusApplied,
true,
tokenmodel.TokenGrantStatusApplied,
).
Where("user_id = ?", userID).
Scan(&summary).Error
return summary, err
}

View File

@@ -0,0 +1,206 @@
package model
import "time"
const (
// CreditProductStatusActive 表示商品可在 Credit 商店展示和购买。
CreditProductStatusActive = "active"
// CreditProductStatusInactive 表示商品已下架。
CreditProductStatusInactive = "inactive"
)
const (
// CreditOrderStatusPending 表示订单已创建,等待支付确认。
CreditOrderStatusPending = "pending"
// CreditOrderStatusPaid 表示订单已确认支付,等待入账。
CreditOrderStatusPaid = "paid"
// CreditOrderStatusCredited 表示订单对应的 Credit 已经写入账本。
CreditOrderStatusCredited = "credited"
// CreditOrderStatusClosed 表示订单已关闭。
CreditOrderStatusClosed = "closed"
)
const (
// CreditLedgerDirectionIncome 表示正向入账。
CreditLedgerDirectionIncome = "income"
// CreditLedgerDirectionExpense 表示扣费出账。
CreditLedgerDirectionExpense = "expense"
)
const (
// CreditLedgerStatusApplied 表示该笔流水已经成为权威账本事实。
CreditLedgerStatusApplied = "applied"
// CreditLedgerStatusSkipped 表示事件被消费但不影响余额。
CreditLedgerStatusSkipped = "skipped"
// CreditLedgerStatusFailed 预留给后续补偿或人工处理。
CreditLedgerStatusFailed = "failed"
)
const (
// CreditLedgerSourcePurchase 表示用户购买 Credit 商品。
CreditLedgerSourcePurchase = "purchase"
// CreditLedgerSourceCharge 表示 LLM 调用扣费。
CreditLedgerSourceCharge = "charge"
// CreditLedgerSourceForumLike 预留论坛点赞奖励。
CreditLedgerSourceForumLike = "forum_like"
// CreditLedgerSourceForumImport 预留论坛导入奖励。
CreditLedgerSourceForumImport = "forum_import"
// CreditLedgerSourceManual 预留人工补偿。
CreditLedgerSourceManual = "manual"
)
const (
// CreditPriceRuleStatusActive 表示价格规则启用。
CreditPriceRuleStatusActive = "active"
// CreditPriceRuleStatusInactive 表示价格规则停用。
CreditPriceRuleStatusInactive = "inactive"
)
const (
// CreditRewardRuleStatusActive 表示奖励规则启用。
CreditRewardRuleStatusActive = "active"
// CreditRewardRuleStatusInactive 表示奖励规则停用。
CreditRewardRuleStatusInactive = "inactive"
)
// CreditAccount 是 Credit 权威余额表。
//
// 职责边界:
// 1. 只保存用户在 TokenStore 账本口径下的当前余额与累计统计;
// 2. balance 允许被异步结算扣到 0 以下,后续由 Guard 和充值链路阻断新增调用;
// 3. 不保存逐笔明细,逐笔事实统一以 credit_ledger 为准。
type CreditAccount struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_credit_accounts_user;comment:用户ID"`
Balance int64 `gorm:"column:balance;not null;default:0;comment:当前Credit余额"`
TotalRecharged int64 `gorm:"column:total_recharged;not null;default:0;comment:累计购买入账"`
TotalRewarded int64 `gorm:"column:total_rewarded;not null;default:0;comment:累计奖励入账"`
TotalConsumed int64 `gorm:"column:total_consumed;not null;default:0;comment:累计扣费出账"`
LastLedgerEventID string `gorm:"column:last_ledger_event_id;type:varchar(128);comment:最近一次账本事件ID"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditAccount) TableName() string {
return "credit_accounts"
}
// CreditLedger 是 Credit 权威流水表。
//
// 职责边界:
// 1. event_id 是最终幂等键,所有异步扣费、充值、奖励都依赖它去重;
// 2. amount 使用带符号值正数表示入账负数表示扣费0 表示消费成功但不影响余额;
// 3. balance_before / balance_after 记录事件落账时的权威余额快照。
type CreditLedger struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_credit_ledger_event;comment:最终幂等事件ID"`
UserID uint64 `gorm:"column:user_id;not null;index:idx_credit_ledger_user_created,priority:1;comment:用户ID"`
Source string `gorm:"column:source;type:varchar(32);not null;index:idx_credit_ledger_user_created,priority:2;comment:purchase/charge/forum_like/forum_import/manual"`
SourceLabel string `gorm:"column:source_label;type:varchar(64);comment:来源展示文案"`
Direction string `gorm:"column:direction;type:varchar(16);not null;comment:income/expense"`
OrderID *uint64 `gorm:"column:order_id;index:idx_credit_ledger_order;comment:关联订单ID"`
SourceRefID *string `gorm:"column:source_ref_id;type:varchar(128);index:idx_credit_ledger_source_ref;comment:来源业务ID"`
Amount int64 `gorm:"column:amount;not null;comment:本次Credit变动正数入账负数扣费"`
BalanceBefore int64 `gorm:"column:balance_before;not null;default:0;comment:落账前余额"`
BalanceAfter int64 `gorm:"column:balance_after;not null;default:0;comment:落账后余额"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'applied';index:idx_credit_ledger_status;comment:applied/skipped/failed"`
Description string `gorm:"column:description;type:varchar(255);comment:展示描述"`
MetadataJSON string `gorm:"column:metadata_json;type:json;comment:扩展元数据"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_credit_ledger_user_created,priority:3;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditLedger) TableName() string {
return "credit_ledger"
}
// CreditProduct 是 Credit 商店商品表。
type CreditProduct struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
SKU string `gorm:"column:sku;type:varchar(64);not null;uniqueIndex:uk_credit_products_sku;comment:商品稳定编码"`
Name string `gorm:"column:name;type:varchar(80);not null;comment:商品名称"`
Description string `gorm:"column:description;type:varchar(255);comment:商品描述"`
CreditAmount int64 `gorm:"column:credit_amount;not null;comment:包含Credit数量"`
PriceCent int64 `gorm:"column:price_cent;not null;comment:价格,单位分"`
OriginalPriceCent int64 `gorm:"column:original_price_cent;not null;default:0;comment:优惠前价格,单位分"`
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
Badge string `gorm:"column:badge;type:varchar(32);comment:角标"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_credit_products_status_sort,priority:1;comment:active/inactive"`
SortOrder int `gorm:"column:sort_order;not null;default:0;index:idx_credit_products_status_sort,priority:2;comment:展示排序"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditProduct) TableName() string {
return "credit_products"
}
// CreditOrder 是 Credit 商品订单表。
type CreditOrder struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex:uk_credit_orders_order_no;comment:订单号"`
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_credit_orders_user_idem,priority:1;index:idx_credit_orders_user_status_created,priority:1;comment:下单用户ID"`
ProductID uint64 `gorm:"column:product_id;not null;index:idx_credit_orders_product;comment:商品ID"`
ProductSKU string `gorm:"column:product_sku;type:varchar(64);not null;comment:商品SKU快照"`
ProductName string `gorm:"column:product_name;type:varchar(80);not null;comment:商品名称快照"`
ProductSnapshotJSON string `gorm:"column:product_snapshot_json;type:json;not null;comment:商品完整快照JSON"`
Quantity int `gorm:"column:quantity;not null;default:1;comment:购买数量"`
CreditAmount int64 `gorm:"column:credit_amount;not null;comment:订单总Credit数量"`
AmountCent int64 `gorm:"column:amount_cent;not null;comment:订单总金额,单位分"`
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_credit_orders_user_status_created,priority:2;comment:pending/paid/credited/closed"`
PaymentMode string `gorm:"column:payment_mode;type:varchar(32);not null;default:'mock';comment:支付模式"`
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_credit_orders_user_idem,priority:2;comment:创建订单幂等键"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付确认时间"`
CreditedAt *time.Time `gorm:"column:credited_at;comment:入账时间"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_credit_orders_user_status_created,priority:3;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditOrder) TableName() string {
return "credit_orders"
}
// CreditPriceRule 是 LLM Credit 计价规则表。
//
// 职责边界:
// 1. 该表表达“某个 provider/model 在某场景下如何换算人民币与 Credit”的运营配置
// 2. 第二步先完成表结构与读取能力,具体由 LLM 服务如何引用放到后续切流阶段;
// 3. 当前结算事件已带出最终 rmb_cost_micros 与 credit_cost因此消费侧不在这里二次计算。
type CreditPriceRule struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Scene string `gorm:"column:scene;type:varchar(64);not null;index:idx_credit_price_rules_scene_status,priority:1;comment:计费场景"`
ProviderName string `gorm:"column:provider_name;type:varchar(64);not null;comment:模型提供方"`
ModelName string `gorm:"column:model_name;type:varchar(128);not null;comment:模型名称"`
InputPriceMicros int64 `gorm:"column:input_price_micros;not null;default:0;comment:输入Token单价单位微人民币"`
OutputPriceMicros int64 `gorm:"column:output_price_micros;not null;default:0;comment:输出Token单价单位微人民币"`
CachedPriceMicros int64 `gorm:"column:cached_price_micros;not null;default:0;comment:缓存Token单价单位微人民币"`
ReasoningPriceMicros int64 `gorm:"column:reasoning_price_micros;not null;default:0;comment:推理Token单价单位微人民币"`
CreditPerYuan int64 `gorm:"column:credit_per_yuan;not null;default:0;comment:1元人民币换算多少Credit"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'inactive';index:idx_credit_price_rules_scene_status,priority:2;comment:active/inactive"`
Priority int `gorm:"column:priority;not null;default:0;comment:匹配优先级,越大越优先"`
Description string `gorm:"column:description;type:varchar(255);comment:规则说明"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditPriceRule) TableName() string {
return "credit_price_rules"
}
// CreditRewardRule 是 Credit 奖励规则表。
type CreditRewardRule struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Source string `gorm:"column:source;type:varchar(32);not null;uniqueIndex:uk_credit_reward_rules_source;comment:奖励来源"`
Name string `gorm:"column:name;type:varchar(80);not null;comment:规则名称"`
Amount int64 `gorm:"column:amount;not null;comment:奖励Credit数量"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_credit_reward_rules_status;comment:active/inactive"`
Description string `gorm:"column:description;type:varchar(255);comment:规则描述"`
ConfigJSON *string `gorm:"column:config_json;type:json;comment:扩展配置"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditRewardRule) TableName() string {
return "credit_reward_rules"
}

View File

@@ -1,155 +0,0 @@
package model
import "time"
const (
// TokenProductStatusActive 表示商品可在 Token 商店展示和购买。
TokenProductStatusActive = "active"
// TokenProductStatusInactive 表示商品已下架,不再对前端展示。
TokenProductStatusInactive = "inactive"
)
const (
// TokenOrderStatusPending 表示订单已创建,等待支付确认。
TokenOrderStatusPending = "pending"
// TokenOrderStatusPaid 表示订单已确认支付,等待写入获取账本。
TokenOrderStatusPaid = "paid"
// TokenOrderStatusGranted 表示订单已经写入 token_grants 获取账本。
TokenOrderStatusGranted = "granted"
// TokenOrderStatusClosed 表示订单关闭P0 暂不实现复杂关闭流程。
TokenOrderStatusClosed = "closed"
)
const (
// TokenGrantStatusRecorded 表示 Token 获取事实已记录在 token-store 内。
TokenGrantStatusRecorded = "recorded"
// TokenGrantStatusApplied 表示后续已同步到 user/auth 权威额度。
TokenGrantStatusApplied = "applied"
// TokenGrantStatusSkipped 表示命中奖励规则或幂等条件后跳过发放。
TokenGrantStatusSkipped = "skipped"
// TokenGrantStatusFailed 表示记录或后续同步失败,可按 event_id 重试。
TokenGrantStatusFailed = "failed"
)
const (
// TokenGrantSourcePurchase 表示购买 Token 商品产生的获取记录。
TokenGrantSourcePurchase = "purchase"
// TokenGrantSourceForumLike 表示计划被点赞产生的作者奖励。
TokenGrantSourceForumLike = "forum_like"
// TokenGrantSourceForumImport 表示计划被导入产生的作者奖励。
TokenGrantSourceForumImport = "forum_import"
// TokenGrantSourceManual 预留人工补偿来源P0 不做管理后台。
TokenGrantSourceManual = "manual"
)
const (
// TokenRewardRuleStatusActive 表示奖励规则启用。
TokenRewardRuleStatusActive = "active"
// TokenRewardRuleStatusInactive 表示奖励规则停用。
TokenRewardRuleStatusInactive = "inactive"
)
// TokenProduct 是 Token 商店商品表。
//
// 职责边界:
// 1. P0 从表读取商品,由 seed 初始化 2-3 个固定商品;
// 2. 不承载真实支付渠道配置,也不做商品管理后台;
// 3. 下单时会复制商品快照到订单,避免后续改价影响历史订单。
type TokenProduct struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
SKU string `gorm:"column:sku;type:varchar(64);not null;uniqueIndex:uk_token_products_sku;comment:商品稳定编码"`
Name string `gorm:"column:name;type:varchar(80);not null;comment:商品名称"`
Description string `gorm:"column:description;type:varchar(255);comment:商品描述"`
TokenAmount int64 `gorm:"column:token_amount;not null;comment:商品包含Token数量"`
PriceCent int64 `gorm:"column:price_cent;not null;comment:价格,单位分"`
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
Badge string `gorm:"column:badge;type:varchar(32);comment:前端角标"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_token_products_status_sort,priority:1;comment:active/inactive"`
SortOrder int `gorm:"column:sort_order;not null;default:0;index:idx_token_products_status_sort,priority:2;comment:展示排序"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (TokenProduct) TableName() string {
return "token_products"
}
// TokenOrder 是 Token 商品订单表。
//
// 职责边界:
// 1. 记录用户购买商品的订单状态机;
// 2. P0 只支持 mock paid不接真实支付网关
// 3. granted 只表示已写入 token-store 获取账本,不代表已同步到 user/auth 权威额度。
type TokenOrder struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex:uk_token_orders_order_no;comment:订单号"`
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_token_orders_user_idem,priority:1;index:idx_token_orders_user_status_created,priority:1;comment:下单用户ID"`
ProductID uint64 `gorm:"column:product_id;not null;index:idx_token_orders_product;comment:商品ID"`
ProductSKU string `gorm:"column:product_sku;type:varchar(64);not null;comment:商品SKU快照"`
ProductName string `gorm:"column:product_name;type:varchar(80);not null;comment:商品名称快照"`
ProductSnapshotJSON string `gorm:"column:product_snapshot_json;type:json;not null;comment:商品完整快照JSON"`
Quantity int `gorm:"column:quantity;not null;default:1;comment:购买数量"`
TokenAmount int64 `gorm:"column:token_amount;not null;comment:订单总Token数量"`
AmountCent int64 `gorm:"column:amount_cent;not null;comment:订单总金额,单位分"`
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_token_orders_user_status_created,priority:2;comment:pending/paid/granted/closed"`
PaymentMode string `gorm:"column:payment_mode;type:varchar(32);not null;default:'mock';comment:支付模式P0为mock"`
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_token_orders_user_idem,priority:2;comment:创建订单幂等键"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付确认时间"`
GrantedAt *time.Time `gorm:"column:granted_at;comment:写入获取账本时间"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_token_orders_user_status_created,priority:3;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (TokenOrder) TableName() string {
return "token_orders"
}
// TokenGrant 是 Token 获取账本表。
//
// 职责边界:
// 1. 记录购买、论坛点赞奖励、论坛导入奖励等 Token 获取事实;
// 2. event_id 是最终幂等边界,避免订单或 outbox 重试重复发放;
// 3. P0 不直接修改 users 表quota_applied 默认为 false。
type TokenGrant struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_token_grants_event;comment:幂等事件ID"`
UserID uint64 `gorm:"column:user_id;not null;index:idx_token_grants_user_source_created,priority:1;comment:获得Token的用户ID"`
Source string `gorm:"column:source;type:varchar(32);not null;index:idx_token_grants_user_source_created,priority:2;comment:purchase/forum_like/forum_import/manual"`
SourceLabel string `gorm:"column:source_label;type:varchar(64);comment:前端展示来源"`
SourceRefID *uint64 `gorm:"column:source_ref_id;index:idx_token_grants_source_ref;comment:来源业务ID如order_id/post_id/import_id"`
OrderID *uint64 `gorm:"column:order_id;index:idx_token_grants_order;comment:购买订单ID非购买来源为空"`
Amount int64 `gorm:"column:amount;not null;comment:获取Token数量"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'recorded';index:idx_token_grants_status;comment:recorded/applied/skipped/failed"`
QuotaApplied bool `gorm:"column:quota_applied;not null;default:false;comment:是否已同步到user/auth权威额度"`
Description string `gorm:"column:description;type:varchar(255);comment:前端展示描述"`
AppliedAt *time.Time `gorm:"column:applied_at;comment:同步到权威额度时间"`
LastError *string `gorm:"column:last_error;type:text;comment:后续同步失败原因"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_token_grants_user_source_created,priority:3;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (TokenGrant) TableName() string {
return "token_grants"
}
// TokenRewardRule 是社区奖励规则表。
//
// 职责边界:
// 1. P0 可用 seed 初始化点赞、导入奖励额度;
// 2. 不提供管理后台,规则调整先通过配置或 seed 变更;
// 3. 规则命中后的最终发放仍以 token_grants.event_id 幂等为准。
type TokenRewardRule struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Source string `gorm:"column:source;type:varchar(32);not null;uniqueIndex:uk_token_reward_rules_source;comment:forum_like/forum_import"`
Name string `gorm:"column:name;type:varchar(80);not null;comment:规则名称"`
Amount int64 `gorm:"column:amount;not null;comment:奖励Token数量"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_token_reward_rules_status;comment:active/inactive"`
ConfigJSON *string `gorm:"column:config_json;type:json;comment:预留扩展配置"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (TokenRewardRule) TableName() string {
return "token_reward_rules"
}

View File

@@ -0,0 +1,392 @@
package rpc
import (
"context"
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
func (h *Handler) GetCreditBalanceSnapshot(ctx context.Context, req *pb.GetCreditBalanceSnapshotRequest) (*pb.GetCreditBalanceSnapshotResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
snapshot, err := svc.GetCreditBalanceSnapshot(ctx, req.UserId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetCreditBalanceSnapshotResponse{Snapshot: creditBalanceSnapshotToPB(snapshot)}, nil
}
func (h *Handler) GetCreditConsumptionDashboard(ctx context.Context, req *pb.GetCreditConsumptionDashboardRequest) (*pb.GetCreditConsumptionDashboardResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
dashboard, err := svc.GetCreditConsumptionDashboard(ctx, creditcontracts.GetCreditConsumptionDashboardRequest{
ActorUserID: req.ActorUserId,
Period: req.Period,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetCreditConsumptionDashboardResponse{Dashboard: creditConsumptionDashboardToPB(dashboard)}, nil
}
func (h *Handler) ListCreditProducts(ctx context.Context, req *pb.ListCreditProductsRequest) (*pb.ListCreditProductsResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, err := svc.ListCreditProducts(ctx, req.ActorUserId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditProductsResponse{Items: creditProductsToPB(items)}, nil
}
func (h *Handler) CreateCreditOrder(ctx context.Context, req *pb.CreateCreditOrderRequest) (*pb.CreateCreditOrderResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.CreateCreditOrder(ctx, creditcontracts.CreateCreditOrderRequest{
ActorUserID: req.ActorUserId,
ProductID: req.ProductId,
Quantity: int(req.Quantity),
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.CreateCreditOrderResponse{Order: creditOrderToPB(order)}, nil
}
func (h *Handler) ListCreditOrders(ctx context.Context, req *pb.ListCreditOrdersRequest) (*pb.ListCreditOrdersResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, page, err := svc.ListCreditOrders(ctx, creditcontracts.ListCreditOrdersRequest{
ActorUserID: req.ActorUserId,
Page: int(req.Page),
PageSize: int(req.PageSize),
Status: req.Status,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditOrdersResponse{
Items: creditOrdersToPB(items),
Page: creditPageToPB(page),
}, nil
}
func (h *Handler) GetCreditOrder(ctx context.Context, req *pb.GetCreditOrderRequest) (*pb.GetCreditOrderResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.GetCreditOrder(ctx, req.ActorUserId, req.OrderId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetCreditOrderResponse{Order: creditOrderToPB(order)}, nil
}
func (h *Handler) MockPaidCreditOrder(ctx context.Context, req *pb.MockPaidCreditOrderRequest) (*pb.MockPaidCreditOrderResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.MockPaidCreditOrder(ctx, creditcontracts.MockPaidCreditOrderRequest{
ActorUserID: req.ActorUserId,
OrderID: req.OrderId,
MockChannel: req.MockChannel,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.MockPaidCreditOrderResponse{Order: creditOrderToPB(order)}, nil
}
func (h *Handler) ListCreditTransactions(ctx context.Context, req *pb.ListCreditTransactionsRequest) (*pb.ListCreditTransactionsResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, page, err := svc.ListCreditTransactions(ctx, creditcontracts.ListCreditTransactionsRequest{
ActorUserID: req.ActorUserId,
Page: int(req.Page),
PageSize: int(req.PageSize),
Source: req.Source,
Direction: req.Direction,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditTransactionsResponse{
Items: creditTransactionsToPB(items),
Page: creditPageToPB(page),
}, nil
}
func (h *Handler) ListCreditPriceRules(ctx context.Context, req *pb.ListCreditPriceRulesRequest) (*pb.ListCreditPriceRulesResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, err := svc.ListCreditPriceRules(ctx, creditcontracts.ListCreditPriceRulesRequest{
Scene: req.Scene,
ProviderName: req.ProviderName,
ModelName: req.ModelName,
Status: req.Status,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditPriceRulesResponse{Items: creditPriceRulesToPB(items)}, nil
}
func (h *Handler) ListCreditRewardRules(ctx context.Context, req *pb.ListCreditRewardRulesRequest) (*pb.ListCreditRewardRulesResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, err := svc.ListCreditRewardRules(ctx, creditcontracts.ListCreditRewardRulesRequest{
Source: req.Source,
Status: req.Status,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditRewardRulesResponse{Items: creditRewardRulesToPB(items)}, nil
}
func creditPageToPB(page creditcontracts.PageResult) *pb.PageResponse {
return &pb.PageResponse{
Page: int32(page.Page),
PageSize: int32(page.PageSize),
Total: int32(page.Total),
HasMore: page.HasMore,
}
}
func creditBalanceSnapshotToPB(snapshot *creditcontracts.CreditBalanceSnapshot) *pb.CreditBalanceSnapshotView {
if snapshot == nil {
return nil
}
return &pb.CreditBalanceSnapshotView{
UserId: snapshot.UserID,
Balance: snapshot.Balance,
TotalRecharged: snapshot.TotalRecharged,
TotalRewarded: snapshot.TotalRewarded,
TotalConsumed: snapshot.TotalConsumed,
IsBlocked: snapshot.IsBlocked,
SnapshotSource: snapshot.SnapshotSource,
UpdatedAt: snapshot.UpdatedAt,
}
}
func creditConsumptionDashboardToPB(view *creditcontracts.CreditConsumptionDashboardView) *pb.CreditConsumptionDashboardView {
if view == nil {
return nil
}
return &pb.CreditConsumptionDashboardView{
Period: view.Period,
CreditConsumed: view.CreditConsumed,
TokenConsumed: view.TokenConsumed,
}
}
func creditProductToPB(product creditcontracts.CreditProductView) *pb.CreditProductView {
return &pb.CreditProductView{
ProductId: product.ProductID,
Name: product.Name,
Description: product.Description,
CreditAmount: product.CreditAmount,
PriceCent: product.PriceCent,
OriginalPriceCent: product.OriginalPriceCent,
PriceText: product.PriceText,
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: int32(product.SortOrder),
}
}
func creditProductsToPB(items []creditcontracts.CreditProductView) []*pb.CreditProductView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditProductView, 0, len(items))
for i := range items {
result = append(result, creditProductToPB(items[i]))
}
return result
}
func creditOrderToPB(order *creditcontracts.CreditOrderView) *pb.CreditOrderView {
if order == nil {
return nil
}
return &pb.CreditOrderView{
OrderId: order.OrderID,
OrderNo: order.OrderNo,
Status: order.Status,
CreditAmount: order.CreditAmount,
AmountCent: order.AmountCent,
PriceText: order.PriceText,
Currency: order.Currency,
PaymentMode: order.PaymentMode,
CreatedAt: order.CreatedAt,
PaidAt: tokenStringFromPtr(order.PaidAt),
CreditedAt: tokenStringFromPtr(order.CreditedAt),
ProductSnapshot: order.ProductSnapshot,
ProductName: order.ProductName,
Quantity: int32(order.Quantity),
}
}
func creditOrdersToPB(items []creditcontracts.CreditOrderView) []*pb.CreditOrderView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditOrderView, 0, len(items))
for i := range items {
item := items[i]
result = append(result, creditOrderToPB(&item))
}
return result
}
func creditTransactionToPB(item creditcontracts.CreditTransactionView) *pb.CreditTransactionView {
result := &pb.CreditTransactionView{
TransactionId: item.TransactionID,
EventId: item.EventID,
Source: item.Source,
SourceLabel: item.SourceLabel,
Direction: item.Direction,
Amount: item.Amount,
BalanceAfter: item.BalanceAfter,
Status: item.Status,
Description: item.Description,
MetadataJson: item.MetadataJSON,
CreatedAt: item.CreatedAt,
}
if item.OrderID != nil {
result.OrderId = *item.OrderID
}
return result
}
func creditTransactionsToPB(items []creditcontracts.CreditTransactionView) []*pb.CreditTransactionView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditTransactionView, 0, len(items))
for i := range items {
result = append(result, creditTransactionToPB(items[i]))
}
return result
}
func creditPriceRuleToPB(rule creditcontracts.CreditPriceRuleView) *pb.CreditPriceRuleView {
return &pb.CreditPriceRuleView{
RuleId: rule.RuleID,
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: int32(rule.Priority),
Description: rule.Description,
}
}
func creditPriceRulesToPB(items []creditcontracts.CreditPriceRuleView) []*pb.CreditPriceRuleView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditPriceRuleView, 0, len(items))
for i := range items {
result = append(result, creditPriceRuleToPB(items[i]))
}
return result
}
func creditRewardRuleToPB(rule creditcontracts.CreditRewardRuleView) *pb.CreditRewardRuleView {
return &pb.CreditRewardRuleView{
RuleId: rule.RuleID,
Source: rule.Source,
Name: rule.Name,
Amount: rule.Amount,
Status: rule.Status,
Description: rule.Description,
}
}
func creditRewardRulesToPB(items []creditcontracts.CreditRewardRuleView) []*pb.CreditRewardRuleView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditRewardRuleView, 0, len(items))
for i := range items {
result = append(result, creditRewardRuleToPB(items[i]))
}
return result
}
func tokenStringFromPtr(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -4,10 +4,10 @@ import (
"context"
"errors"
"github.com/LoveLosita/smartflow/backend/shared/respond"
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
tokenstoresv "github.com/LoveLosita/smartflow/backend/services/tokenstore/sv"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type Handler struct {
@@ -20,11 +20,6 @@ func NewHandler(svc *tokenstoresv.Service) *Handler {
}
// service 负责统一校验 RPC 层依赖是否已经注入。
//
// 职责边界:
// 1. 只判断 handler 自身和业务 service 是否可用;
// 2. 不负责支付状态流转、订单幂等和 grant 账本写入;
// 3. 失败时返回可直接转成 gRPC status 的业务错误。
func (h *Handler) service() (*tokenstoresv.Service, error) {
if h == nil || h.svc == nil {
return nil, errors.New("tokenstore service dependency not initialized")
@@ -32,282 +27,39 @@ func (h *Handler) service() (*tokenstoresv.Service, error) {
return h.svc, nil
}
// GetSummary 负责把 Token 概览请求从 gRPC 协议转成内部服务调用
func (h *Handler) GetSummary(ctx context.Context, req *pb.GetTokenSummaryRequest) (*pb.GetTokenSummaryResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
summary, err := svc.GetSummary(ctx, req.ActorUserId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetTokenSummaryResponse{Summary: tokenSummaryToPB(summary)}, nil
// GetSummary 保留旧 token RPC 方法壳,统一返回已下线
func (h *Handler) GetSummary(context.Context, *pb.GetTokenSummaryRequest) (*pb.GetTokenSummaryResponse, error) {
return nil, legacyTokenMethodRemoved()
}
func (h *Handler) ListProducts(ctx context.Context, req *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, err := svc.ListProducts(ctx, req.ActorUserId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListTokenProductsResponse{Items: tokenProductsToPB(items)}, nil
func (h *Handler) ListProducts(context.Context, *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, error) {
return nil, legacyTokenMethodRemoved()
}
func (h *Handler) CreateOrder(ctx context.Context, req *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.CreateOrder(ctx, tokencontracts.CreateTokenOrderRequest{
ActorUserID: req.ActorUserId,
ProductID: req.ProductId,
Quantity: int(req.Quantity),
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.CreateTokenOrderResponse{Order: tokenOrderToPB(order)}, nil
func (h *Handler) CreateOrder(context.Context, *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) {
return nil, legacyTokenMethodRemoved()
}
func (h *Handler) ListOrders(ctx context.Context, req *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, page, err := svc.ListOrders(ctx, tokencontracts.ListTokenOrdersRequest{
ActorUserID: req.ActorUserId,
Page: int(req.Page),
PageSize: int(req.PageSize),
Status: req.Status,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListTokenOrdersResponse{
Items: tokenOrdersToPB(items),
Page: tokenPageToPB(page),
}, nil
func (h *Handler) ListOrders(context.Context, *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, error) {
return nil, legacyTokenMethodRemoved()
}
func (h *Handler) GetOrder(ctx context.Context, req *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.GetOrder(ctx, req.ActorUserId, req.OrderId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetTokenOrderResponse{Order: tokenOrderToPB(order)}, nil
func (h *Handler) GetOrder(context.Context, *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) {
return nil, legacyTokenMethodRemoved()
}
func (h *Handler) MockPaidOrder(ctx context.Context, req *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.MockPaidOrder(ctx, tokencontracts.MockPaidOrderRequest{
ActorUserID: req.ActorUserId,
OrderID: req.OrderId,
MockChannel: req.MockChannel,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.MockPaidOrderResponse{Order: tokenOrderToPB(order)}, nil
func (h *Handler) MockPaidOrder(context.Context, *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, error) {
return nil, legacyTokenMethodRemoved()
}
func (h *Handler) ListGrants(ctx context.Context, req *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, page, err := svc.ListGrants(ctx, tokencontracts.ListTokenGrantsRequest{
ActorUserID: req.ActorUserId,
Page: int(req.Page),
PageSize: int(req.PageSize),
Source: req.Source,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListTokenGrantsResponse{
Items: tokenGrantsToPB(items),
Page: tokenPageToPB(page),
}, nil
func (h *Handler) ListGrants(context.Context, *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) {
return nil, legacyTokenMethodRemoved()
}
// RecordForumRewardGrant 负责把论坛 outbox 奖励事件转成 token-store 内部账本写入调用。
func (h *Handler) RecordForumRewardGrant(ctx context.Context, req *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{
EventID: req.EventId,
ReceiverUserID: req.ReceiverUserId,
Source: req.Source,
SourceRefID: req.SourceRefId,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.RecordForumRewardGrantResponse{Grant: tokenGrantToPB(grant)}, nil
func (h *Handler) RecordForumRewardGrant(context.Context, *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) {
return nil, legacyTokenMethodRemoved()
}
func tokenPageToPB(page tokencontracts.PageResult) *pb.PageResponse {
return &pb.PageResponse{
Page: int32(page.Page),
PageSize: int32(page.PageSize),
Total: int32(page.Total),
HasMore: page.HasMore,
}
}
func tokenSummaryToPB(summary *tokencontracts.TokenSummary) *pb.TokenSummary {
if summary == nil {
return nil
}
return &pb.TokenSummary{
RecordedTokenTotal: summary.RecordedTokenTotal,
AppliedTokenTotal: summary.AppliedTokenTotal,
PendingApplyTokenTotal: summary.PendingApplyTokenTotal,
QuotaSyncStatus: summary.QuotaSyncStatus,
Tip: summary.Tip,
}
}
func tokenProductToPB(product tokencontracts.TokenProductView) *pb.TokenProductView {
return &pb.TokenProductView{
ProductId: product.ProductID,
Name: product.Name,
Description: product.Description,
TokenAmount: product.TokenAmount,
PriceCent: product.PriceCent,
PriceText: product.PriceText,
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: int32(product.SortOrder),
}
}
func tokenProductsToPB(items []tokencontracts.TokenProductView) []*pb.TokenProductView {
if len(items) == 0 {
return nil
}
result := make([]*pb.TokenProductView, 0, len(items))
for i := range items {
result = append(result, tokenProductToPB(items[i]))
}
return result
}
func tokenGrantToPB(grant *tokencontracts.TokenGrantView) *pb.TokenGrantView {
if grant == nil {
return nil
}
return &pb.TokenGrantView{
GrantId: grant.GrantID,
EventId: grant.EventID,
Source: grant.Source,
SourceLabel: grant.SourceLabel,
Amount: grant.Amount,
Status: grant.Status,
QuotaApplied: grant.QuotaApplied,
Description: grant.Description,
CreatedAt: grant.CreatedAt,
}
}
func tokenGrantsToPB(items []tokencontracts.TokenGrantView) []*pb.TokenGrantView {
if len(items) == 0 {
return nil
}
result := make([]*pb.TokenGrantView, 0, len(items))
for i := range items {
item := items[i]
result = append(result, tokenGrantToPB(&item))
}
return result
}
func tokenOrderToPB(order *tokencontracts.TokenOrderView) *pb.TokenOrderView {
if order == nil {
return nil
}
return &pb.TokenOrderView{
OrderId: order.OrderID,
OrderNo: order.OrderNo,
Status: order.Status,
TokenAmount: order.TokenAmount,
AmountCent: order.AmountCent,
PriceText: order.PriceText,
Currency: order.Currency,
PaymentMode: order.PaymentMode,
Grant: tokenGrantToPB(order.Grant),
CreatedAt: order.CreatedAt,
PaidAt: tokenStringFromPtr(order.PaidAt),
GrantedAt: tokenStringFromPtr(order.GrantedAt),
ProductSnapshot: order.ProductSnapshot,
ProductName: order.ProductName,
Quantity: int32(order.Quantity),
}
}
func tokenOrdersToPB(items []tokencontracts.TokenOrderView) []*pb.TokenOrderView {
if len(items) == 0 {
return nil
}
result := make([]*pb.TokenOrderView, 0, len(items))
for i := range items {
item := items[i]
result = append(result, tokenOrderToPB(&item))
}
return result
}
func tokenStringFromPtr(value *string) string {
if value == nil {
return ""
}
return *value
func legacyTokenMethodRemoved() error {
return status.Error(codes.Unimplemented, "legacy token API has been removed; use credit APIs instead")
}

View File

@@ -229,3 +229,299 @@ type RecordForumRewardGrantResponse struct {
func (m *RecordForumRewardGrantResponse) Reset() { *m = RecordForumRewardGrantResponse{} }
func (m *RecordForumRewardGrantResponse) String() string { return proto.CompactTextString(m) }
func (*RecordForumRewardGrantResponse) ProtoMessage() {}
type CreditBalanceSnapshotView struct {
UserId uint64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Balance int64 `protobuf:"varint,2,opt,name=balance,proto3" json:"balance,omitempty"`
IsBlocked bool `protobuf:"varint,3,opt,name=is_blocked,json=isBlocked,proto3" json:"is_blocked,omitempty"`
SnapshotSource string `protobuf:"bytes,4,opt,name=snapshot_source,json=snapshotSource,proto3" json:"snapshot_source,omitempty"`
UpdatedAt string `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
TotalRecharged int64 `protobuf:"varint,6,opt,name=total_recharged,json=totalRecharged,proto3" json:"total_recharged,omitempty"`
TotalRewarded int64 `protobuf:"varint,7,opt,name=total_rewarded,json=totalRewarded,proto3" json:"total_rewarded,omitempty"`
TotalConsumed int64 `protobuf:"varint,8,opt,name=total_consumed,json=totalConsumed,proto3" json:"total_consumed,omitempty"`
}
func (m *CreditBalanceSnapshotView) Reset() { *m = CreditBalanceSnapshotView{} }
func (m *CreditBalanceSnapshotView) String() string { return proto.CompactTextString(m) }
func (*CreditBalanceSnapshotView) ProtoMessage() {}
type CreditConsumptionDashboardView struct {
Period string `protobuf:"bytes,1,opt,name=period,proto3" json:"period,omitempty"`
CreditConsumed int64 `protobuf:"varint,2,opt,name=credit_consumed,json=creditConsumed,proto3" json:"credit_consumed,omitempty"`
TokenConsumed int64 `protobuf:"varint,3,opt,name=token_consumed,json=tokenConsumed,proto3" json:"token_consumed,omitempty"`
}
func (m *CreditConsumptionDashboardView) Reset() { *m = CreditConsumptionDashboardView{} }
func (m *CreditConsumptionDashboardView) String() string { return proto.CompactTextString(m) }
func (*CreditConsumptionDashboardView) ProtoMessage() {}
type GetCreditConsumptionDashboardRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
Period string `protobuf:"bytes,2,opt,name=period,proto3" json:"period,omitempty"`
}
func (m *GetCreditConsumptionDashboardRequest) Reset() { *m = GetCreditConsumptionDashboardRequest{} }
func (m *GetCreditConsumptionDashboardRequest) String() string { return proto.CompactTextString(m) }
func (*GetCreditConsumptionDashboardRequest) ProtoMessage() {}
type GetCreditConsumptionDashboardResponse struct {
Dashboard *CreditConsumptionDashboardView `protobuf:"bytes,1,opt,name=dashboard,proto3" json:"dashboard,omitempty"`
}
func (m *GetCreditConsumptionDashboardResponse) Reset() { *m = GetCreditConsumptionDashboardResponse{} }
func (m *GetCreditConsumptionDashboardResponse) String() string { return proto.CompactTextString(m) }
func (*GetCreditConsumptionDashboardResponse) ProtoMessage() {}
type CreditProductView struct {
ProductId uint64 `protobuf:"varint,1,opt,name=product_id,json=productId,proto3" json:"product_id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
CreditAmount int64 `protobuf:"varint,4,opt,name=credit_amount,json=creditAmount,proto3" json:"credit_amount,omitempty"`
PriceCent int64 `protobuf:"varint,5,opt,name=price_cent,json=priceCent,proto3" json:"price_cent,omitempty"`
PriceText string `protobuf:"bytes,6,opt,name=price_text,json=priceText,proto3" json:"price_text,omitempty"`
Currency string `protobuf:"bytes,7,opt,name=currency,proto3" json:"currency,omitempty"`
Badge string `protobuf:"bytes,8,opt,name=badge,proto3" json:"badge,omitempty"`
Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"`
SortOrder int32 `protobuf:"varint,10,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
OriginalPriceCent int64 `protobuf:"varint,11,opt,name=original_price_cent,json=originalPriceCent,proto3" json:"original_price_cent,omitempty"`
}
func (m *CreditProductView) Reset() { *m = CreditProductView{} }
func (m *CreditProductView) String() string { return proto.CompactTextString(m) }
func (*CreditProductView) ProtoMessage() {}
type CreditOrderView struct {
OrderId uint64 `protobuf:"varint,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
OrderNo string `protobuf:"bytes,2,opt,name=order_no,json=orderNo,proto3" json:"order_no,omitempty"`
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
CreditAmount int64 `protobuf:"varint,4,opt,name=credit_amount,json=creditAmount,proto3" json:"credit_amount,omitempty"`
AmountCent int64 `protobuf:"varint,5,opt,name=amount_cent,json=amountCent,proto3" json:"amount_cent,omitempty"`
PriceText string `protobuf:"bytes,6,opt,name=price_text,json=priceText,proto3" json:"price_text,omitempty"`
Currency string `protobuf:"bytes,7,opt,name=currency,proto3" json:"currency,omitempty"`
PaymentMode string `protobuf:"bytes,8,opt,name=payment_mode,json=paymentMode,proto3" json:"payment_mode,omitempty"`
CreatedAt string `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
PaidAt string `protobuf:"bytes,10,opt,name=paid_at,json=paidAt,proto3" json:"paid_at,omitempty"`
CreditedAt string `protobuf:"bytes,11,opt,name=credited_at,json=creditedAt,proto3" json:"credited_at,omitempty"`
ProductSnapshot string `protobuf:"bytes,12,opt,name=product_snapshot,json=productSnapshot,proto3" json:"product_snapshot,omitempty"`
ProductName string `protobuf:"bytes,13,opt,name=product_name,json=productName,proto3" json:"product_name,omitempty"`
Quantity int32 `protobuf:"varint,14,opt,name=quantity,proto3" json:"quantity,omitempty"`
}
func (m *CreditOrderView) Reset() { *m = CreditOrderView{} }
func (m *CreditOrderView) String() string { return proto.CompactTextString(m) }
func (*CreditOrderView) ProtoMessage() {}
type CreditTransactionView struct {
TransactionId uint64 `protobuf:"varint,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"`
SourceLabel string `protobuf:"bytes,4,opt,name=source_label,json=sourceLabel,proto3" json:"source_label,omitempty"`
Direction string `protobuf:"bytes,5,opt,name=direction,proto3" json:"direction,omitempty"`
Amount int64 `protobuf:"varint,6,opt,name=amount,proto3" json:"amount,omitempty"`
BalanceAfter int64 `protobuf:"varint,7,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"`
Status string `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"`
Description string `protobuf:"bytes,9,opt,name=description,proto3" json:"description,omitempty"`
MetadataJson string `protobuf:"bytes,10,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"`
CreatedAt string `protobuf:"bytes,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
OrderId uint64 `protobuf:"varint,12,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
}
func (m *CreditTransactionView) Reset() { *m = CreditTransactionView{} }
func (m *CreditTransactionView) String() string { return proto.CompactTextString(m) }
func (*CreditTransactionView) ProtoMessage() {}
type CreditPriceRuleView struct {
RuleId uint64 `protobuf:"varint,1,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"`
Scene string `protobuf:"bytes,2,opt,name=scene,proto3" json:"scene,omitempty"`
ProviderName string `protobuf:"bytes,3,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"`
ModelName string `protobuf:"bytes,4,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"`
InputPriceMicros int64 `protobuf:"varint,5,opt,name=input_price_micros,json=inputPriceMicros,proto3" json:"input_price_micros,omitempty"`
OutputPriceMicros int64 `protobuf:"varint,6,opt,name=output_price_micros,json=outputPriceMicros,proto3" json:"output_price_micros,omitempty"`
CachedPriceMicros int64 `protobuf:"varint,7,opt,name=cached_price_micros,json=cachedPriceMicros,proto3" json:"cached_price_micros,omitempty"`
ReasoningPriceMicros int64 `protobuf:"varint,8,opt,name=reasoning_price_micros,json=reasoningPriceMicros,proto3" json:"reasoning_price_micros,omitempty"`
CreditPerYuan int64 `protobuf:"varint,9,opt,name=credit_per_yuan,json=creditPerYuan,proto3" json:"credit_per_yuan,omitempty"`
Status string `protobuf:"bytes,10,opt,name=status,proto3" json:"status,omitempty"`
Priority int32 `protobuf:"varint,11,opt,name=priority,proto3" json:"priority,omitempty"`
Description string `protobuf:"bytes,12,opt,name=description,proto3" json:"description,omitempty"`
}
func (m *CreditPriceRuleView) Reset() { *m = CreditPriceRuleView{} }
func (m *CreditPriceRuleView) String() string { return proto.CompactTextString(m) }
func (*CreditPriceRuleView) ProtoMessage() {}
type CreditRewardRuleView struct {
RuleId uint64 `protobuf:"varint,1,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"`
Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"`
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"`
Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
}
func (m *CreditRewardRuleView) Reset() { *m = CreditRewardRuleView{} }
func (m *CreditRewardRuleView) String() string { return proto.CompactTextString(m) }
func (*CreditRewardRuleView) ProtoMessage() {}
type GetCreditBalanceSnapshotRequest struct {
UserId uint64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
}
func (m *GetCreditBalanceSnapshotRequest) Reset() { *m = GetCreditBalanceSnapshotRequest{} }
func (m *GetCreditBalanceSnapshotRequest) String() string { return proto.CompactTextString(m) }
func (*GetCreditBalanceSnapshotRequest) ProtoMessage() {}
type GetCreditBalanceSnapshotResponse struct {
Snapshot *CreditBalanceSnapshotView `protobuf:"bytes,1,opt,name=snapshot,proto3" json:"snapshot,omitempty"`
}
func (m *GetCreditBalanceSnapshotResponse) Reset() { *m = GetCreditBalanceSnapshotResponse{} }
func (m *GetCreditBalanceSnapshotResponse) String() string { return proto.CompactTextString(m) }
func (*GetCreditBalanceSnapshotResponse) ProtoMessage() {}
type ListCreditProductsRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
}
func (m *ListCreditProductsRequest) Reset() { *m = ListCreditProductsRequest{} }
func (m *ListCreditProductsRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditProductsRequest) ProtoMessage() {}
type ListCreditProductsResponse struct {
Items []*CreditProductView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
}
func (m *ListCreditProductsResponse) Reset() { *m = ListCreditProductsResponse{} }
func (m *ListCreditProductsResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditProductsResponse) ProtoMessage() {}
type CreateCreditOrderRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
ProductId uint64 `protobuf:"varint,2,opt,name=product_id,json=productId,proto3" json:"product_id,omitempty"`
Quantity int32 `protobuf:"varint,3,opt,name=quantity,proto3" json:"quantity,omitempty"`
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
}
func (m *CreateCreditOrderRequest) Reset() { *m = CreateCreditOrderRequest{} }
func (m *CreateCreditOrderRequest) String() string { return proto.CompactTextString(m) }
func (*CreateCreditOrderRequest) ProtoMessage() {}
type CreateCreditOrderResponse struct {
Order *CreditOrderView `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
}
func (m *CreateCreditOrderResponse) Reset() { *m = CreateCreditOrderResponse{} }
func (m *CreateCreditOrderResponse) String() string { return proto.CompactTextString(m) }
func (*CreateCreditOrderResponse) ProtoMessage() {}
type ListCreditOrdersRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"`
PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
}
func (m *ListCreditOrdersRequest) Reset() { *m = ListCreditOrdersRequest{} }
func (m *ListCreditOrdersRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditOrdersRequest) ProtoMessage() {}
type ListCreditOrdersResponse struct {
Items []*CreditOrderView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
}
func (m *ListCreditOrdersResponse) Reset() { *m = ListCreditOrdersResponse{} }
func (m *ListCreditOrdersResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditOrdersResponse) ProtoMessage() {}
type GetCreditOrderRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
OrderId uint64 `protobuf:"varint,2,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
}
func (m *GetCreditOrderRequest) Reset() { *m = GetCreditOrderRequest{} }
func (m *GetCreditOrderRequest) String() string { return proto.CompactTextString(m) }
func (*GetCreditOrderRequest) ProtoMessage() {}
type GetCreditOrderResponse struct {
Order *CreditOrderView `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
}
func (m *GetCreditOrderResponse) Reset() { *m = GetCreditOrderResponse{} }
func (m *GetCreditOrderResponse) String() string { return proto.CompactTextString(m) }
func (*GetCreditOrderResponse) ProtoMessage() {}
type MockPaidCreditOrderRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
OrderId uint64 `protobuf:"varint,2,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
MockChannel string `protobuf:"bytes,3,opt,name=mock_channel,json=mockChannel,proto3" json:"mock_channel,omitempty"`
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
}
func (m *MockPaidCreditOrderRequest) Reset() { *m = MockPaidCreditOrderRequest{} }
func (m *MockPaidCreditOrderRequest) String() string { return proto.CompactTextString(m) }
func (*MockPaidCreditOrderRequest) ProtoMessage() {}
type MockPaidCreditOrderResponse struct {
Order *CreditOrderView `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
}
func (m *MockPaidCreditOrderResponse) Reset() { *m = MockPaidCreditOrderResponse{} }
func (m *MockPaidCreditOrderResponse) String() string { return proto.CompactTextString(m) }
func (*MockPaidCreditOrderResponse) ProtoMessage() {}
type ListCreditTransactionsRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"`
PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
Source string `protobuf:"bytes,4,opt,name=source,proto3" json:"source,omitempty"`
Direction string `protobuf:"bytes,5,opt,name=direction,proto3" json:"direction,omitempty"`
}
func (m *ListCreditTransactionsRequest) Reset() { *m = ListCreditTransactionsRequest{} }
func (m *ListCreditTransactionsRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditTransactionsRequest) ProtoMessage() {}
type ListCreditTransactionsResponse struct {
Items []*CreditTransactionView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
}
func (m *ListCreditTransactionsResponse) Reset() { *m = ListCreditTransactionsResponse{} }
func (m *ListCreditTransactionsResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditTransactionsResponse) ProtoMessage() {}
type ListCreditPriceRulesRequest struct {
Scene string `protobuf:"bytes,1,opt,name=scene,proto3" json:"scene,omitempty"`
ProviderName string `protobuf:"bytes,2,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"`
ModelName string `protobuf:"bytes,3,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"`
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
}
func (m *ListCreditPriceRulesRequest) Reset() { *m = ListCreditPriceRulesRequest{} }
func (m *ListCreditPriceRulesRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditPriceRulesRequest) ProtoMessage() {}
type ListCreditPriceRulesResponse struct {
Items []*CreditPriceRuleView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
}
func (m *ListCreditPriceRulesResponse) Reset() { *m = ListCreditPriceRulesResponse{} }
func (m *ListCreditPriceRulesResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditPriceRulesResponse) ProtoMessage() {}
type ListCreditRewardRulesRequest struct {
Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
}
func (m *ListCreditRewardRulesRequest) Reset() { *m = ListCreditRewardRulesRequest{} }
func (m *ListCreditRewardRulesRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditRewardRulesRequest) ProtoMessage() {}
type ListCreditRewardRulesResponse struct {
Items []*CreditRewardRuleView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
}
func (m *ListCreditRewardRulesResponse) Reset() { *m = ListCreditRewardRulesResponse{} }
func (m *ListCreditRewardRulesResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditRewardRulesResponse) ProtoMessage() {}

View File

@@ -9,14 +9,24 @@ import (
)
const (
TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary"
TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts"
TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder"
TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders"
TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder"
TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder"
TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants"
TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant"
TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary"
TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts"
TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder"
TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders"
TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder"
TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder"
TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants"
TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant"
TokenStoreService_GetCreditBalanceSnapshot_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetCreditBalanceSnapshot"
TokenStoreService_GetCreditConsumptionDashboard_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetCreditConsumptionDashboard"
TokenStoreService_ListCreditProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditProducts"
TokenStoreService_CreateCreditOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateCreditOrder"
TokenStoreService_ListCreditOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditOrders"
TokenStoreService_GetCreditOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetCreditOrder"
TokenStoreService_MockPaidCreditOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidCreditOrder"
TokenStoreService_ListCreditTransactions_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditTransactions"
TokenStoreService_ListCreditPriceRules_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditPriceRules"
TokenStoreService_ListCreditRewardRules_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditRewardRules"
)
type TokenStoreServiceClient interface {
@@ -28,6 +38,16 @@ type TokenStoreServiceClient interface {
MockPaidOrder(ctx context.Context, in *MockPaidOrderRequest, opts ...grpc.CallOption) (*MockPaidOrderResponse, error)
ListGrants(ctx context.Context, in *ListTokenGrantsRequest, opts ...grpc.CallOption) (*ListTokenGrantsResponse, error)
RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error)
GetCreditBalanceSnapshot(ctx context.Context, in *GetCreditBalanceSnapshotRequest, opts ...grpc.CallOption) (*GetCreditBalanceSnapshotResponse, error)
GetCreditConsumptionDashboard(ctx context.Context, in *GetCreditConsumptionDashboardRequest, opts ...grpc.CallOption) (*GetCreditConsumptionDashboardResponse, error)
ListCreditProducts(ctx context.Context, in *ListCreditProductsRequest, opts ...grpc.CallOption) (*ListCreditProductsResponse, error)
CreateCreditOrder(ctx context.Context, in *CreateCreditOrderRequest, opts ...grpc.CallOption) (*CreateCreditOrderResponse, error)
ListCreditOrders(ctx context.Context, in *ListCreditOrdersRequest, opts ...grpc.CallOption) (*ListCreditOrdersResponse, error)
GetCreditOrder(ctx context.Context, in *GetCreditOrderRequest, opts ...grpc.CallOption) (*GetCreditOrderResponse, error)
MockPaidCreditOrder(ctx context.Context, in *MockPaidCreditOrderRequest, opts ...grpc.CallOption) (*MockPaidCreditOrderResponse, error)
ListCreditTransactions(ctx context.Context, in *ListCreditTransactionsRequest, opts ...grpc.CallOption) (*ListCreditTransactionsResponse, error)
ListCreditPriceRules(ctx context.Context, in *ListCreditPriceRulesRequest, opts ...grpc.CallOption) (*ListCreditPriceRulesResponse, error)
ListCreditRewardRules(ctx context.Context, in *ListCreditRewardRulesRequest, opts ...grpc.CallOption) (*ListCreditRewardRulesResponse, error)
}
type tokenStoreServiceClient struct {
@@ -70,6 +90,46 @@ func (c *tokenStoreServiceClient) RecordForumRewardGrant(ctx context.Context, in
return invokeTokenStore[RecordForumRewardGrantResponse](ctx, c.cc, TokenStoreService_RecordForumRewardGrant_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) GetCreditBalanceSnapshot(ctx context.Context, in *GetCreditBalanceSnapshotRequest, opts ...grpc.CallOption) (*GetCreditBalanceSnapshotResponse, error) {
return invokeTokenStore[GetCreditBalanceSnapshotResponse](ctx, c.cc, TokenStoreService_GetCreditBalanceSnapshot_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) GetCreditConsumptionDashboard(ctx context.Context, in *GetCreditConsumptionDashboardRequest, opts ...grpc.CallOption) (*GetCreditConsumptionDashboardResponse, error) {
return invokeTokenStore[GetCreditConsumptionDashboardResponse](ctx, c.cc, TokenStoreService_GetCreditConsumptionDashboard_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditProducts(ctx context.Context, in *ListCreditProductsRequest, opts ...grpc.CallOption) (*ListCreditProductsResponse, error) {
return invokeTokenStore[ListCreditProductsResponse](ctx, c.cc, TokenStoreService_ListCreditProducts_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) CreateCreditOrder(ctx context.Context, in *CreateCreditOrderRequest, opts ...grpc.CallOption) (*CreateCreditOrderResponse, error) {
return invokeTokenStore[CreateCreditOrderResponse](ctx, c.cc, TokenStoreService_CreateCreditOrder_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditOrders(ctx context.Context, in *ListCreditOrdersRequest, opts ...grpc.CallOption) (*ListCreditOrdersResponse, error) {
return invokeTokenStore[ListCreditOrdersResponse](ctx, c.cc, TokenStoreService_ListCreditOrders_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) GetCreditOrder(ctx context.Context, in *GetCreditOrderRequest, opts ...grpc.CallOption) (*GetCreditOrderResponse, error) {
return invokeTokenStore[GetCreditOrderResponse](ctx, c.cc, TokenStoreService_GetCreditOrder_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) MockPaidCreditOrder(ctx context.Context, in *MockPaidCreditOrderRequest, opts ...grpc.CallOption) (*MockPaidCreditOrderResponse, error) {
return invokeTokenStore[MockPaidCreditOrderResponse](ctx, c.cc, TokenStoreService_MockPaidCreditOrder_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditTransactions(ctx context.Context, in *ListCreditTransactionsRequest, opts ...grpc.CallOption) (*ListCreditTransactionsResponse, error) {
return invokeTokenStore[ListCreditTransactionsResponse](ctx, c.cc, TokenStoreService_ListCreditTransactions_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditPriceRules(ctx context.Context, in *ListCreditPriceRulesRequest, opts ...grpc.CallOption) (*ListCreditPriceRulesResponse, error) {
return invokeTokenStore[ListCreditPriceRulesResponse](ctx, c.cc, TokenStoreService_ListCreditPriceRules_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditRewardRules(ctx context.Context, in *ListCreditRewardRulesRequest, opts ...grpc.CallOption) (*ListCreditRewardRulesResponse, error) {
return invokeTokenStore[ListCreditRewardRulesResponse](ctx, c.cc, TokenStoreService_ListCreditRewardRules_FullMethodName, in, opts...)
}
func invokeTokenStore[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) {
out := new(Resp)
err := cc.Invoke(ctx, fullMethod, in, out, opts...)
@@ -88,6 +148,16 @@ type TokenStoreServiceServer interface {
MockPaidOrder(context.Context, *MockPaidOrderRequest) (*MockPaidOrderResponse, error)
ListGrants(context.Context, *ListTokenGrantsRequest) (*ListTokenGrantsResponse, error)
RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error)
GetCreditBalanceSnapshot(context.Context, *GetCreditBalanceSnapshotRequest) (*GetCreditBalanceSnapshotResponse, error)
GetCreditConsumptionDashboard(context.Context, *GetCreditConsumptionDashboardRequest) (*GetCreditConsumptionDashboardResponse, error)
ListCreditProducts(context.Context, *ListCreditProductsRequest) (*ListCreditProductsResponse, error)
CreateCreditOrder(context.Context, *CreateCreditOrderRequest) (*CreateCreditOrderResponse, error)
ListCreditOrders(context.Context, *ListCreditOrdersRequest) (*ListCreditOrdersResponse, error)
GetCreditOrder(context.Context, *GetCreditOrderRequest) (*GetCreditOrderResponse, error)
MockPaidCreditOrder(context.Context, *MockPaidCreditOrderRequest) (*MockPaidCreditOrderResponse, error)
ListCreditTransactions(context.Context, *ListCreditTransactionsRequest) (*ListCreditTransactionsResponse, error)
ListCreditPriceRules(context.Context, *ListCreditPriceRulesRequest) (*ListCreditPriceRulesResponse, error)
ListCreditRewardRules(context.Context, *ListCreditRewardRulesRequest) (*ListCreditRewardRulesResponse, error)
}
type UnimplementedTokenStoreServiceServer struct{}
@@ -124,6 +194,46 @@ func (UnimplementedTokenStoreServiceServer) RecordForumRewardGrant(context.Conte
return nil, status.Errorf(codes.Unimplemented, "method RecordForumRewardGrant not implemented")
}
func (UnimplementedTokenStoreServiceServer) GetCreditBalanceSnapshot(context.Context, *GetCreditBalanceSnapshotRequest) (*GetCreditBalanceSnapshotResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCreditBalanceSnapshot not implemented")
}
func (UnimplementedTokenStoreServiceServer) GetCreditConsumptionDashboard(context.Context, *GetCreditConsumptionDashboardRequest) (*GetCreditConsumptionDashboardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCreditConsumptionDashboard not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditProducts(context.Context, *ListCreditProductsRequest) (*ListCreditProductsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditProducts not implemented")
}
func (UnimplementedTokenStoreServiceServer) CreateCreditOrder(context.Context, *CreateCreditOrderRequest) (*CreateCreditOrderResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateCreditOrder not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditOrders(context.Context, *ListCreditOrdersRequest) (*ListCreditOrdersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditOrders not implemented")
}
func (UnimplementedTokenStoreServiceServer) GetCreditOrder(context.Context, *GetCreditOrderRequest) (*GetCreditOrderResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCreditOrder not implemented")
}
func (UnimplementedTokenStoreServiceServer) MockPaidCreditOrder(context.Context, *MockPaidCreditOrderRequest) (*MockPaidCreditOrderResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method MockPaidCreditOrder not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditTransactions(context.Context, *ListCreditTransactionsRequest) (*ListCreditTransactionsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditTransactions not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditPriceRules(context.Context, *ListCreditPriceRulesRequest) (*ListCreditPriceRulesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditPriceRules not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditRewardRules(context.Context, *ListCreditRewardRulesRequest) (*ListCreditRewardRulesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditRewardRules not implemented")
}
func RegisterTokenStoreServiceServer(s grpc.ServiceRegistrar, srv TokenStoreServiceServer) {
s.RegisterService(&TokenStoreService_ServiceDesc, srv)
}
@@ -179,6 +289,36 @@ var TokenStoreService_ServiceDesc = grpc.ServiceDesc{
tokenStoreUnaryHandler[RecordForumRewardGrantRequest]("RecordForumRewardGrant", TokenStoreService_RecordForumRewardGrant_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *RecordForumRewardGrantRequest) (interface{}, error) {
return s.RecordForumRewardGrant(ctx, req)
}),
tokenStoreUnaryHandler[GetCreditBalanceSnapshotRequest]("GetCreditBalanceSnapshot", TokenStoreService_GetCreditBalanceSnapshot_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetCreditBalanceSnapshotRequest) (interface{}, error) {
return s.GetCreditBalanceSnapshot(ctx, req)
}),
tokenStoreUnaryHandler[GetCreditConsumptionDashboardRequest]("GetCreditConsumptionDashboard", TokenStoreService_GetCreditConsumptionDashboard_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetCreditConsumptionDashboardRequest) (interface{}, error) {
return s.GetCreditConsumptionDashboard(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditProductsRequest]("ListCreditProducts", TokenStoreService_ListCreditProducts_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditProductsRequest) (interface{}, error) {
return s.ListCreditProducts(ctx, req)
}),
tokenStoreUnaryHandler[CreateCreditOrderRequest]("CreateCreditOrder", TokenStoreService_CreateCreditOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *CreateCreditOrderRequest) (interface{}, error) {
return s.CreateCreditOrder(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditOrdersRequest]("ListCreditOrders", TokenStoreService_ListCreditOrders_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditOrdersRequest) (interface{}, error) {
return s.ListCreditOrders(ctx, req)
}),
tokenStoreUnaryHandler[GetCreditOrderRequest]("GetCreditOrder", TokenStoreService_GetCreditOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetCreditOrderRequest) (interface{}, error) {
return s.GetCreditOrder(ctx, req)
}),
tokenStoreUnaryHandler[MockPaidCreditOrderRequest]("MockPaidCreditOrder", TokenStoreService_MockPaidCreditOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *MockPaidCreditOrderRequest) (interface{}, error) {
return s.MockPaidCreditOrder(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditTransactionsRequest]("ListCreditTransactions", TokenStoreService_ListCreditTransactions_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditTransactionsRequest) (interface{}, error) {
return s.ListCreditTransactions(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditPriceRulesRequest]("ListCreditPriceRules", TokenStoreService_ListCreditPriceRules_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditPriceRulesRequest) (interface{}, error) {
return s.ListCreditPriceRules(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditRewardRulesRequest]("ListCreditRewardRules", TokenStoreService_ListCreditRewardRules_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditRewardRulesRequest) (interface{}, error) {
return s.ListCreditRewardRules(ctx, req)
}),
},
Streams: []grpc.StreamDesc{},
Metadata: "tokenstore.proto",

View File

@@ -13,6 +13,16 @@ service TokenStoreService {
rpc MockPaidOrder(MockPaidOrderRequest) returns (MockPaidOrderResponse);
rpc ListGrants(ListTokenGrantsRequest) returns (ListTokenGrantsResponse);
rpc RecordForumRewardGrant(RecordForumRewardGrantRequest) returns (RecordForumRewardGrantResponse);
rpc GetCreditBalanceSnapshot(GetCreditBalanceSnapshotRequest) returns (GetCreditBalanceSnapshotResponse);
rpc GetCreditConsumptionDashboard(GetCreditConsumptionDashboardRequest) returns (GetCreditConsumptionDashboardResponse);
rpc ListCreditProducts(ListCreditProductsRequest) returns (ListCreditProductsResponse);
rpc CreateCreditOrder(CreateCreditOrderRequest) returns (CreateCreditOrderResponse);
rpc ListCreditOrders(ListCreditOrdersRequest) returns (ListCreditOrdersResponse);
rpc GetCreditOrder(GetCreditOrderRequest) returns (GetCreditOrderResponse);
rpc MockPaidCreditOrder(MockPaidCreditOrderRequest) returns (MockPaidCreditOrderResponse);
rpc ListCreditTransactions(ListCreditTransactionsRequest) returns (ListCreditTransactionsResponse);
rpc ListCreditPriceRules(ListCreditPriceRulesRequest) returns (ListCreditPriceRulesResponse);
rpc ListCreditRewardRules(ListCreditRewardRulesRequest) returns (ListCreditRewardRulesResponse);
}
message PageResponse {
@@ -154,3 +164,191 @@ message RecordForumRewardGrantRequest {
message RecordForumRewardGrantResponse {
TokenGrantView grant = 1;
}
message CreditBalanceSnapshotView {
uint64 user_id = 1;
int64 balance = 2;
bool is_blocked = 3;
string snapshot_source = 4;
string updated_at = 5;
int64 total_recharged = 6;
int64 total_rewarded = 7;
int64 total_consumed = 8;
}
message CreditProductView {
uint64 product_id = 1;
string name = 2;
string description = 3;
int64 credit_amount = 4;
int64 price_cent = 5;
string price_text = 6;
string currency = 7;
string badge = 8;
string status = 9;
int32 sort_order = 10;
int64 original_price_cent = 11;
}
message CreditOrderView {
uint64 order_id = 1;
string order_no = 2;
string status = 3;
int64 credit_amount = 4;
int64 amount_cent = 5;
string price_text = 6;
string currency = 7;
string payment_mode = 8;
string created_at = 9;
string paid_at = 10;
string credited_at = 11;
string product_snapshot = 12;
string product_name = 13;
int32 quantity = 14;
}
message CreditTransactionView {
uint64 transaction_id = 1;
string event_id = 2;
string source = 3;
string source_label = 4;
string direction = 5;
int64 amount = 6;
int64 balance_after = 7;
string status = 8;
string description = 9;
string metadata_json = 10;
string created_at = 11;
uint64 order_id = 12;
}
message CreditPriceRuleView {
uint64 rule_id = 1;
string scene = 2;
string provider_name = 3;
string model_name = 4;
int64 input_price_micros = 5;
int64 output_price_micros = 6;
int64 cached_price_micros = 7;
int64 reasoning_price_micros = 8;
int64 credit_per_yuan = 9;
string status = 10;
int32 priority = 11;
string description = 12;
}
message CreditRewardRuleView {
uint64 rule_id = 1;
string source = 2;
string name = 3;
int64 amount = 4;
string status = 5;
string description = 6;
}
message GetCreditBalanceSnapshotRequest {
uint64 user_id = 1;
}
message GetCreditBalanceSnapshotResponse {
CreditBalanceSnapshotView snapshot = 1;
}
message CreditConsumptionDashboardView {
string period = 1;
int64 credit_consumed = 2;
int64 token_consumed = 3;
}
message GetCreditConsumptionDashboardRequest {
uint64 actor_user_id = 1;
string period = 2;
}
message GetCreditConsumptionDashboardResponse {
CreditConsumptionDashboardView dashboard = 1;
}
message ListCreditProductsRequest {
uint64 actor_user_id = 1;
}
message ListCreditProductsResponse {
repeated CreditProductView items = 1;
}
message CreateCreditOrderRequest {
uint64 actor_user_id = 1;
uint64 product_id = 2;
int32 quantity = 3;
string idempotency_key = 4;
}
message CreateCreditOrderResponse {
CreditOrderView order = 1;
}
message ListCreditOrdersRequest {
uint64 actor_user_id = 1;
int32 page = 2;
int32 page_size = 3;
string status = 4;
}
message ListCreditOrdersResponse {
repeated CreditOrderView items = 1;
PageResponse page = 2;
}
message GetCreditOrderRequest {
uint64 actor_user_id = 1;
uint64 order_id = 2;
}
message GetCreditOrderResponse {
CreditOrderView order = 1;
}
message MockPaidCreditOrderRequest {
uint64 actor_user_id = 1;
uint64 order_id = 2;
string mock_channel = 3;
string idempotency_key = 4;
}
message MockPaidCreditOrderResponse {
CreditOrderView order = 1;
}
message ListCreditTransactionsRequest {
uint64 actor_user_id = 1;
int32 page = 2;
int32 page_size = 3;
string source = 4;
string direction = 5;
}
message ListCreditTransactionsResponse {
repeated CreditTransactionView items = 1;
PageResponse page = 2;
}
message ListCreditPriceRulesRequest {
string scene = 1;
string provider_name = 2;
string model_name = 3;
string status = 4;
}
message ListCreditPriceRulesResponse {
repeated CreditPriceRuleView items = 1;
}
message ListCreditRewardRulesRequest {
string source = 1;
string status = 2;
}
message ListCreditRewardRulesResponse {
repeated CreditRewardRuleView items = 1;
}

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")