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,14 +1,11 @@
package tokenstore
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"github.com/zeromicro/go-zero/zrpc"
)
@@ -23,48 +20,12 @@ type ClientConfig struct {
Timeout time.Duration
}
// ProductSnapshot 是订单详情里内嵌的商品快照
// Client 是 gateway 访问 tokenstore zrpc 的统一 Credit 语义适配层
//
// 职责边界:
// 1. 只承载 HTTP gateway 当前需要透出的商品摘要
// 2. 不补充 description、price 等商品列表字段,避免把详情快照扩成第二份商品实体
// 3. 若下游 proto/contract 还未合入对应字段,这里允许保持 nil/零值兜底
type ProductSnapshot struct {
ProductID uint64 `json:"product_id"`
Name string `json:"name"`
TokenAmount int64 `json:"token_amount"`
}
// OrderView 是 gateway 侧订单展示结构。
//
// 职责边界:
// 1. 复用 token-store contract 里已稳定的订单字段;
// 2. 为前端 P0 额外承载 product_snapshot / product_name / quantity 三个 HTTP 所需字段;
// 3. 不反向影响 shared/contracts等并行 worker 合入正式字段后可再收敛。
type OrderView struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
Status string `json:"status"`
ProductSnapshot *ProductSnapshot `json:"product_snapshot,omitempty"`
ProductName string `json:"product_name,omitempty"`
Quantity int `json:"quantity"`
TokenAmount int64 `json:"token_amount"`
AmountCent int64 `json:"amount_cent"`
PriceText string `json:"price_text"`
Currency string `json:"currency"`
PaymentMode string `json:"payment_mode"`
Grant *tokencontracts.TokenGrantView `json:"grant"`
CreatedAt string `json:"created_at"`
PaidAt *string `json:"paid_at"`
GrantedAt *string `json:"granted_at"`
}
// Client 是 gateway 侧访问 token-store zrpc 的适配层。
//
// 职责边界:
// 1. 只负责 HTTP gateway 与 token-store zrpc 之间的协议转译;
// 2. 不直连 token_* 表,也不承载订单/支付业务规则;
// 3. gRPC 业务错误会在这里反解回 respond.Response便于 HTTP 层统一返回。
// 1. 只负责 HTTP gateway 与 tokenstore zrpc 之间的协议转译
// 2. 不直连底层 credit_* 表,也不承载订单/充值/扣费业务规则
// 3. gRPC 业务错误会在这里反解成普通 error / respond.Response交给 HTTP 层统一处理
type Client struct {
rpc pb.TokenStoreServiceClient
}
@@ -92,150 +53,6 @@ func NewClient(cfg ClientConfig) (*Client, error) {
return &Client{rpc: pb.NewTokenStoreServiceClient(zclient.Conn())}, nil
}
func (c *Client) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GetSummary(ctx, &pb.GetTokenSummaryRequest{ActorUserId: actorUserID})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty get summary response")
}
summary := tokenSummaryFromPB(resp.Summary)
return &summary, nil
}
func (c *Client) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.ListProducts(ctx, &pb.ListTokenProductsRequest{ActorUserId: actorUserID})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty list products response")
}
return tokenProductsFromPB(resp.Items), nil
}
func (c *Client) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*OrderView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.CreateOrder(ctx, &pb.CreateTokenOrderRequest{
ActorUserId: req.ActorUserID,
ProductId: req.ProductID,
Quantity: int32(req.Quantity),
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty create order response")
}
order := tokenOrderFromPB(resp.Order)
return &order, nil
}
func (c *Client) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]OrderView, tokencontracts.PageResult, error) {
if err := c.ensureReady(); err != nil {
return nil, tokencontracts.PageResult{}, err
}
resp, err := c.rpc.ListOrders(ctx, &pb.ListTokenOrdersRequest{
ActorUserId: req.ActorUserID,
Page: int32(req.Page),
PageSize: int32(req.PageSize),
Status: req.Status,
})
if err != nil {
return nil, tokencontracts.PageResult{}, responseFromRPCError(err)
}
if resp == nil {
return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list orders response")
}
return tokenOrdersFromPB(resp.Items), pageFromPB(resp.Page), nil
}
func (c *Client) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*OrderView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GetOrder(ctx, &pb.GetTokenOrderRequest{
ActorUserId: actorUserID,
OrderId: orderID,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty get order response")
}
order := tokenOrderFromPB(resp.Order)
return &order, nil
}
func (c *Client) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*OrderView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.MockPaidOrder(ctx, &pb.MockPaidOrderRequest{
ActorUserId: req.ActorUserID,
OrderId: req.OrderID,
MockChannel: req.MockChannel,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty mock paid response")
}
order := tokenOrderFromPB(resp.Order)
return &order, nil
}
func (c *Client) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
if err := c.ensureReady(); err != nil {
return nil, tokencontracts.PageResult{}, err
}
resp, err := c.rpc.ListGrants(ctx, &pb.ListTokenGrantsRequest{
ActorUserId: req.ActorUserID,
Page: int32(req.Page),
PageSize: int32(req.PageSize),
Source: req.Source,
})
if err != nil {
return nil, tokencontracts.PageResult{}, responseFromRPCError(err)
}
if resp == nil {
return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list grants response")
}
return tokenGrantsFromPB(resp.Items), pageFromPB(resp.Page), nil
}
func (c *Client) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.RecordForumRewardGrant(ctx, &pb.RecordForumRewardGrantRequest{
EventId: req.EventID,
ReceiverUserId: req.ReceiverUserID,
Source: req.Source,
SourceRefId: req.SourceRefID,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty record forum reward grant response")
}
return tokenGrantFromPB(resp.Grant), nil
}
func (c *Client) ensureReady() error {
if c == nil || c.rpc == nil {
return errors.New("tokenstore zrpc client is not initialized")
@@ -254,150 +71,6 @@ func normalizeEndpoints(values []string) []string {
return endpoints
}
func pageFromPB(page *pb.PageResponse) tokencontracts.PageResult {
if page == nil {
return tokencontracts.PageResult{}
}
return tokencontracts.PageResult{
Page: int(page.Page),
PageSize: int(page.PageSize),
Total: int(page.Total),
HasMore: page.HasMore,
}
}
func tokenSummaryFromPB(summary *pb.TokenSummary) tokencontracts.TokenSummary {
if summary == nil {
return tokencontracts.TokenSummary{}
}
return tokencontracts.TokenSummary{
RecordedTokenTotal: summary.RecordedTokenTotal,
AppliedTokenTotal: summary.AppliedTokenTotal,
PendingApplyTokenTotal: summary.PendingApplyTokenTotal,
QuotaSyncStatus: summary.QuotaSyncStatus,
Tip: summary.Tip,
}
}
func tokenProductFromPB(product *pb.TokenProductView) tokencontracts.TokenProductView {
if product == nil {
return tokencontracts.TokenProductView{}
}
return tokencontracts.TokenProductView{
ProductID: product.ProductId,
Name: product.Name,
Description: product.Description,
TokenAmount: product.TokenAmount,
PriceCent: product.PriceCent,
PriceText: product.PriceText,
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: int(product.SortOrder),
}
}
func tokenProductsFromPB(items []*pb.TokenProductView) []tokencontracts.TokenProductView {
if len(items) == 0 {
return []tokencontracts.TokenProductView{}
}
result := make([]tokencontracts.TokenProductView, 0, len(items))
for _, item := range items {
result = append(result, tokenProductFromPB(item))
}
return result
}
func tokenGrantFromPB(grant *pb.TokenGrantView) *tokencontracts.TokenGrantView {
if grant == nil {
return nil
}
return &tokencontracts.TokenGrantView{
GrantID: grant.GrantId,
EventID: grant.EventId,
Source: grant.Source,
SourceLabel: grant.SourceLabel,
Amount: grant.Amount,
Status: grant.Status,
QuotaApplied: grant.QuotaApplied,
Description: grant.Description,
CreatedAt: grant.CreatedAt,
}
}
func tokenGrantsFromPB(items []*pb.TokenGrantView) []tokencontracts.TokenGrantView {
if len(items) == 0 {
return []tokencontracts.TokenGrantView{}
}
result := make([]tokencontracts.TokenGrantView, 0, len(items))
for _, item := range items {
if grant := tokenGrantFromPB(item); grant != nil {
result = append(result, *grant)
}
}
return result
}
func tokenOrderFromPB(order *pb.TokenOrderView) OrderView {
if order == nil {
return OrderView{}
}
productSnapshot := tokenProductSnapshotFromJSON(order.ProductSnapshot)
productName := strings.TrimSpace(order.ProductName)
if productName == "" && productSnapshot != nil {
productName = productSnapshot.Name
}
return OrderView{
OrderID: order.OrderId,
OrderNo: order.OrderNo,
Status: order.Status,
ProductSnapshot: productSnapshot,
ProductName: productName,
Quantity: int(order.Quantity),
TokenAmount: order.TokenAmount,
AmountCent: order.AmountCent,
PriceText: order.PriceText,
Currency: order.Currency,
PaymentMode: order.PaymentMode,
Grant: tokenGrantFromPB(order.Grant),
CreatedAt: order.CreatedAt,
PaidAt: stringPtrFromNonEmpty(order.PaidAt),
GrantedAt: stringPtrFromNonEmpty(order.GrantedAt),
}
}
func tokenOrdersFromPB(items []*pb.TokenOrderView) []OrderView {
if len(items) == 0 {
return []OrderView{}
}
result := make([]OrderView, 0, len(items))
for _, item := range items {
result = append(result, tokenOrderFromPB(item))
}
return result
}
// tokenProductSnapshotFromJSON 负责把 RPC 内部快照字符串转成 HTTP 展示对象。
//
// 职责边界:
// 1. 只解析 product_id / name / token_amount 三个前端需要的字段;
// 2. 不把解析失败暴露成接口错误,避免历史脏快照影响订单主流程展示;
// 3. 不反查商品表,订单详情必须以当时下单快照为准。
func tokenProductSnapshotFromJSON(raw string) *ProductSnapshot {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
var snapshot ProductSnapshot
if err := json.Unmarshal([]byte(trimmed), &snapshot); err != nil {
return nil
}
if snapshot.ProductID == 0 && snapshot.Name == "" && snapshot.TokenAmount == 0 {
return nil
}
return &snapshot
}
func stringPtrFromNonEmpty(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {