Files
Losita 7b04b073ce Version: 0.9.81.dev.260506
后端:
1. Credit 价格规则补齐利润率与实际计费单价语义:新增 `profit_rate_bps` 与 `charge_*_price_micros` 展示字段,下沉共享价格推导 helper,tokenstore rpc/client/proto/model/default rule 全链路同步,LLM usage 扣费统一改按加价后的 charge 单价换算。
2. task-class 更新链路修正全量覆盖与归属校验:`runtime/conv` 保留 item id,DAO 更新前显式校验 task-class 与 item 归属,改用显式字段 map 落库 nil/空切片/零值,避免 `RowsAffected=0` 误判越权,同时补齐任务项可编辑字段更新。
3. GormCache task-class 失效补空 user_id 保护:更新语句缺少模型上下文时直接跳过失效,避免缓存插件因空指针影响主事务。

前端:
4. 课表中心补齐任务类编辑能力:新增 `updateTaskClass` API,创建弹窗支持编辑态回填与 item id 提交,日程页支持先拉详情再编辑并在保存后刷新任务类详情与列表。
5. 计划广场详情补点赞交互与奖励提示:详情页新增点赞/取消点赞按钮、奖励反馈文案与计数展示,论坛类型补 `reward_hint`,评论区与帖子作者头像统一接入兜底头像工具。
6. 品牌与展示细节收口:侧边栏与 favicon 切到项目 logo,首页标题改为 `SmartMate`,主面板缩放上限微调,论坛列表头像显示与整体品牌观感同步统一。
2026-05-06 21:53:17 +08:00

274 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
redisinfra "github.com/LoveLosita/smartflow/backend/shared/infra/redis"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// OpenDBFromConfig 创建 token-store 服务自己的数据库句柄,并迁移本服务私有表。
//
// 职责边界:
// 1. 只迁移 token_*、credit_* 以及 token-store outbox 表,不迁移其它服务表;
// 2. 自动迁移后执行默认 seed保证旧 Token 链路和新 Credit 链路都能并行跑通;
// 3. 返回 *gorm.DB 供 DAO 复用,调用方负责进程生命周期。
func OpenDBFromConfig() (*gorm.DB, error) {
db, err := mysqlinfra.OpenDBFromConfig()
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
}
// 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. 只迁移 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(
&storemodel.CreditAccount{},
&storemodel.CreditLedger{},
&storemodel.CreditProduct{},
&storemodel.CreditOrder{},
&storemodel.CreditPriceRule{},
&storemodel.CreditRewardRule{},
); 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 写入 Token 与 Credit 默认商品/规则。
//
// 步骤说明:
// 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 := seedDefaultCreditProducts(db); err != nil {
return err
}
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 seedDefaultCreditProducts(db *gorm.DB) error {
products := defaultCreditProducts()
for _, product := range products {
// 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 creditProductSeedOnConflict() clause.OnConflict {
return clause.OnConflict{
Columns: []clause.Column{{Name: "sku"}},
DoNothing: true,
}
}
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"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"amount",
"status",
"description",
"config_json",
"updated_at",
}),
}).Create(&rule).Error; err != nil {
return fmt.Errorf("seed credit reward rule %s failed: %w", rule.Source, err)
}
}
return nil
}
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{
{
SKU: "credit_free_100",
Name: "Free",
Description: "每日免费发放,适合基础功能体验。",
CreditAmount: 100,
PriceCent: 0,
OriginalPriceCent: 0,
Currency: "CNY",
Badge: "每日",
Status: storemodel.CreditProductStatusActive,
SortOrder: 10,
},
{
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,
ProfitRateBps: 0,
Status: storemodel.CreditPriceRuleStatusActive,
Priority: 100,
Description: "Default Ark rule, prices are expressed in micros CNY per 1K tokens.",
},
}
}