Version: 0.9.78.dev.260506

This commit is contained in:
Losita
2026-05-06 00:30:08 +08:00
parent 3b6fca44a6
commit 33227e48a7
71 changed files with 13137 additions and 62 deletions

View File

@@ -0,0 +1,184 @@
package dao
import (
"fmt"
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"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// OpenDBFromConfig 创建 token-store 服务自己的数据库句柄,并迁移本服务私有表。
//
// 职责边界:
// 1. 只迁移 token_* 表和 token-store outbox 表,不迁移 users避免和 user/auth 服务边界冲突;
// 2. 自动迁移后执行 P0 seed确保前端商品页有可展示商品
// 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{})
if err != nil {
return nil, err
}
if err = AutoMigrate(db); err != nil {
return nil, err
}
if err = SeedDefaults(db); err != nil {
return nil, err
}
return db, nil
}
// AutoMigrate 只迁移 token-store 服务拥有的表。
//
// 步骤说明:
// 1. 先创建商品、订单、获取账本和奖励规则表;
// 2. 再按 service catalog 创建 token-store outbox 表,保证论坛奖励事件有稳定落表目录;
// 3. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入;
// 4. 失败时直接返回错误,避免服务在 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{},
); err != nil {
return fmt.Errorf("auto migrate tokenstore tables failed: %w", err)
}
if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTokenStore); err != nil {
return err
}
return nil
}
// SeedDefaults 写入 P0 默认商品和奖励规则。
//
// 步骤说明:
// 1. 商品和奖励规则都用稳定业务键做 upsert允许重复启动服务
// 2. seed 只提供 P0 默认数据,不代表有管理后台能力;
// 3. 后续若商品或规则由运营后台维护,可替换本函数或仅保留初始化兜底。
func SeedDefaults(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("tokenstore seed failed: db is nil")
}
if err := seedDefaultProducts(db); err != nil {
return err
}
if err := seedDefaultRewardRules(db); err != nil {
return err
}
return nil
}
func seedDefaultProducts(db *gorm.DB) error {
products := defaultTokenProducts()
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)
}
}
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 seedDefaultRewardRules(db *gorm.DB) error {
rules := defaultTokenRewardRules()
for _, rule := range rules {
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"amount",
"status",
"config_json",
"updated_at",
}),
}).Create(&rule).Error; err != nil {
return fmt.Errorf("seed token reward rule %s failed: %w", rule.Source, err)
}
}
return nil
}
func defaultTokenRewardRules() []tokenmodel.TokenRewardRule {
return []tokenmodel.TokenRewardRule{
{
Source: tokenmodel.TokenGrantSourceForumLike,
Name: "计划被点赞奖励",
Amount: 1,
Status: tokenmodel.TokenRewardRuleStatusActive,
},
{
Source: tokenmodel.TokenGrantSourceForumImport,
Name: "计划被导入奖励",
Amount: 5,
Status: tokenmodel.TokenRewardRuleStatusActive,
},
}
}

View File

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