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:
131
backend/services/tokenstore/dao/cache.go
Normal file
131
backend/services/tokenstore/dao/cache.go
Normal 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()
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
380
backend/services/tokenstore/dao/creditstore.go
Normal file
380
backend/services/tokenstore/dao/creditstore.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user