后端: 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。
190 lines
5.4 KiB
Go
190 lines
5.4 KiB
Go
package outbox
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/spf13/viper"
|
||
)
|
||
|
||
const (
|
||
ServiceAgent = "agent"
|
||
ServiceTask = "task"
|
||
ServiceMemory = "memory"
|
||
ServiceActiveScheduler = "active-scheduler"
|
||
ServiceNotification = "notification"
|
||
ServiceTaskClassForum = "taskclass-forum"
|
||
ServiceLLM = "llm"
|
||
ServiceTokenStore = "token-store"
|
||
)
|
||
|
||
// ServiceConfig 描述一个服务级 outbox 的固定归属。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只描述“事件属于哪个服务、写哪张表、发哪个 topic、用哪个 group”。
|
||
// 2. 不承载具体业务 handler,也不承载 Kafka 消息体格式。
|
||
// 3. 服务级写入、扫描和消费都应从这里读取同一份映射,避免配置漂移。
|
||
type ServiceConfig struct {
|
||
Name string
|
||
Topic string
|
||
GroupID string
|
||
TableName string
|
||
}
|
||
|
||
var serviceCatalogCache = struct {
|
||
sync.RWMutex
|
||
loaded bool
|
||
entries map[string]ServiceConfig
|
||
}{
|
||
entries: make(map[string]ServiceConfig),
|
||
}
|
||
|
||
// LoadServiceConfigs 读取服务级 outbox 目录。
|
||
//
|
||
// 说明:
|
||
// 1. 先给出默认终态映射,再允许通过配置中心覆盖 topic/groupID/table;
|
||
// 2. 该目录只负责服务级 outbox 基础设施,不混入业务逻辑;
|
||
// 3. 若某个服务配置缺失,直接使用默认值,避免启动期因为非关键配置崩掉。
|
||
func LoadServiceConfigs() map[string]ServiceConfig {
|
||
serviceCatalogCache.Lock()
|
||
defer serviceCatalogCache.Unlock()
|
||
|
||
if serviceCatalogCache.loaded {
|
||
return cloneServiceConfigs(serviceCatalogCache.entries)
|
||
}
|
||
|
||
entries := map[string]ServiceConfig{
|
||
ServiceAgent: {
|
||
Name: ServiceAgent,
|
||
Topic: "smartflow.agent.outbox",
|
||
GroupID: "smartflow-agent-outbox-consumer",
|
||
TableName: "agent_outbox_messages",
|
||
},
|
||
ServiceTask: {
|
||
Name: ServiceTask,
|
||
Topic: "smartflow.task.outbox",
|
||
GroupID: "smartflow-task-outbox-consumer",
|
||
TableName: "task_outbox_messages",
|
||
},
|
||
ServiceMemory: {
|
||
Name: ServiceMemory,
|
||
Topic: "smartflow.memory.outbox",
|
||
GroupID: "smartflow-memory-outbox-consumer",
|
||
TableName: "memory_outbox_messages",
|
||
},
|
||
ServiceActiveScheduler: {
|
||
Name: ServiceActiveScheduler,
|
||
Topic: "smartflow.active-scheduler.outbox",
|
||
GroupID: "smartflow-active-scheduler-outbox-consumer",
|
||
TableName: "active_scheduler_outbox_messages",
|
||
},
|
||
ServiceNotification: {
|
||
Name: ServiceNotification,
|
||
Topic: "smartflow.notification.outbox",
|
||
GroupID: "smartflow-notification-outbox-consumer",
|
||
TableName: "notification_outbox_messages",
|
||
},
|
||
ServiceTaskClassForum: {
|
||
Name: ServiceTaskClassForum,
|
||
Topic: "smartflow.taskclass-forum.outbox",
|
||
GroupID: "smartflow-taskclass-forum-outbox-consumer",
|
||
TableName: "taskclass_forum_outbox_messages",
|
||
},
|
||
ServiceLLM: {
|
||
Name: ServiceLLM,
|
||
Topic: "smartflow.llm.outbox",
|
||
GroupID: "smartflow-llm-outbox-consumer",
|
||
TableName: "llm_outbox_messages",
|
||
},
|
||
ServiceTokenStore: {
|
||
Name: ServiceTokenStore,
|
||
Topic: "smartflow.token-store.outbox",
|
||
GroupID: "smartflow-token-store-outbox-consumer",
|
||
TableName: "token_store_outbox_messages",
|
||
},
|
||
}
|
||
|
||
for name, entry := range entries {
|
||
entries[name] = overrideServiceConfig(entry)
|
||
}
|
||
|
||
serviceCatalogCache.entries = entries
|
||
serviceCatalogCache.loaded = true
|
||
return cloneServiceConfigs(entries)
|
||
}
|
||
|
||
// ResolveServiceConfig 查询某个服务的 outbox 目录。
|
||
func ResolveServiceConfig(serviceName string) (ServiceConfig, bool) {
|
||
serviceName = strings.TrimSpace(serviceName)
|
||
if serviceName == "" {
|
||
return ServiceConfig{}, false
|
||
}
|
||
|
||
entries := LoadServiceConfigs()
|
||
cfg, ok := entries[serviceName]
|
||
return cfg, ok
|
||
}
|
||
|
||
// ResolveEventServiceConfig 先解析事件归属服务,再返回该服务的 outbox 目录。
|
||
func ResolveEventServiceConfig(eventType string) (ServiceConfig, bool) {
|
||
serviceName, ok := ResolveEventService(eventType)
|
||
if !ok {
|
||
return ServiceConfig{}, false
|
||
}
|
||
return ResolveServiceConfig(serviceName)
|
||
}
|
||
|
||
// ServiceTables 返回当前目录中的所有 outbox 表名。
|
||
func ServiceTables() []string {
|
||
entries := LoadServiceConfigs()
|
||
tables := make([]string, 0, len(entries))
|
||
for _, entry := range entries {
|
||
tables = append(tables, entry.TableName)
|
||
}
|
||
sort.Strings(tables)
|
||
return tables
|
||
}
|
||
|
||
// ServiceNames 返回当前目录中的所有服务名。
|
||
func ServiceNames() []string {
|
||
entries := LoadServiceConfigs()
|
||
names := make([]string, 0, len(entries))
|
||
for name := range entries {
|
||
names = append(names, name)
|
||
}
|
||
sort.Strings(names)
|
||
return names
|
||
}
|
||
|
||
func overrideServiceConfig(entry ServiceConfig) ServiceConfig {
|
||
upperName := strings.TrimSpace(entry.Name)
|
||
if upperName == "" {
|
||
return entry
|
||
}
|
||
|
||
topicKey := fmt.Sprintf("outbox.services.%s.topic", upperName)
|
||
groupKey := fmt.Sprintf("outbox.services.%s.groupID", upperName)
|
||
tableKey := fmt.Sprintf("outbox.services.%s.table", upperName)
|
||
|
||
if topic := strings.TrimSpace(viper.GetString(topicKey)); topic != "" {
|
||
entry.Topic = topic
|
||
}
|
||
if groupID := strings.TrimSpace(viper.GetString(groupKey)); groupID != "" {
|
||
entry.GroupID = groupID
|
||
}
|
||
if tableName := strings.TrimSpace(viper.GetString(tableKey)); tableName != "" {
|
||
entry.TableName = tableName
|
||
}
|
||
return entry
|
||
}
|
||
|
||
func cloneServiceConfigs(entries map[string]ServiceConfig) map[string]ServiceConfig {
|
||
cloned := make(map[string]ServiceConfig, len(entries))
|
||
for name, entry := range entries {
|
||
cloned[name] = entry
|
||
}
|
||
return cloned
|
||
}
|