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

@@ -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.",
},
}
}