Version: 0.9.78.dev.260506
This commit is contained in:
184
backend/services/tokenstore/dao/connect.go
Normal file
184
backend/services/tokenstore/dao/connect.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
285
backend/services/tokenstore/dao/tokenstore.go
Normal file
285
backend/services/tokenstore/dao/tokenstore.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user