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

@@ -92,6 +92,7 @@ func (c *Client) ImportCourses(ctx context.Context, req coursecontracts.UserImpo
func (c *Client) ParseCourseTableImage(ctx context.Context, req coursecontracts.CourseImageParseRequest) (json.RawMessage, error) { func (c *Client) ParseCourseTableImage(ctx context.Context, req coursecontracts.CourseImageParseRequest) (json.RawMessage, error) {
resp, err := c.rpc.ParseCourseImage(ctx, &coursepb.CourseImageRequest{ resp, err := c.rpc.ParseCourseImage(ctx, &coursepb.CourseImageRequest{
UserId: uint64(req.UserID),
Filename: req.Filename, Filename: req.Filename,
MimeType: req.MIMEType, MimeType: req.MIMEType,
ImageBytes: req.ImageBytes, ImageBytes: req.ImageBytes,

View File

@@ -0,0 +1,301 @@
package llm
import (
"context"
"errors"
"io"
"strings"
"time"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
llmrpc "github.com/LoveLosita/smartflow/backend/services/llm/rpc"
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
"github.com/cloudwego/eino/schema"
"github.com/zeromicro/go-zero/zrpc"
)
const (
defaultEndpoint = "127.0.0.1:9096"
defaultTimeout = 0
defaultPingTimeout = 2 * time.Second
)
type ClientConfig struct {
Endpoints []string
Target string
Timeout time.Duration
}
type ServiceConfig struct {
ClientConfig
CourseVisionModel string
}
// Client 是业务进程访问独立 LLM 服务的最小 RPC 适配层。
type Client struct {
rpc llmrpc.LLMClient
}
func NewClient(cfg ClientConfig) (*Client, error) {
timeout := cfg.Timeout
if timeout < 0 {
timeout = defaultTimeout
}
endpoints := normalizeEndpoints(cfg.Endpoints)
target := strings.TrimSpace(cfg.Target)
if len(endpoints) == 0 && target == "" {
endpoints = []string{defaultEndpoint}
}
zclient, err := zrpc.NewClient(zrpc.RpcClientConf{
Endpoints: endpoints,
Target: target,
NonBlock: true,
Timeout: int64(timeout / time.Millisecond),
}, zrpc.WithDialOption(llmrpc.JSONCodecDialOption()))
if err != nil {
return nil, err
}
client := &Client{rpc: llmrpc.NewLLMClient(zclient.Conn())}
if err = client.ping(resolvePingTimeout(timeout)); err != nil {
return nil, err
}
return client, nil
}
// NewService 一次性把远端 LLM RPC 包装回旧的 *llmservice.Service 门面。
func NewService(cfg ServiceConfig) (*llmservice.Service, error) {
client, err := NewClient(cfg.ClientConfig)
if err != nil {
return nil, err
}
return client.BuildService(cfg.CourseVisionModel), nil
}
func (c *Client) BuildService(courseVisionModel string) *llmservice.Service {
if c == nil {
return nil
}
return llmservice.NewWithClients(llmservice.StaticClients{
Lite: buildTextClient(c, llmcontracts.ModelAliasLite),
Pro: buildTextClient(c, llmcontracts.ModelAliasPro),
Max: buildTextClient(c, llmcontracts.ModelAliasMax),
CourseImageResponses: llmservice.NewArkResponsesClientWithFunc(courseVisionModel, func(ctx context.Context, messages []llmservice.ArkResponsesMessage, options llmservice.ArkResponsesOptions) (*llmservice.ArkResponsesResult, error) {
return c.GenerateResponsesText(ctx, llmcontracts.ModelAliasCourseImageResponses, messages, options)
}),
})
}
func (c *Client) Ping(ctx context.Context) error {
if err := c.ensureReady(); err != nil {
return err
}
_, err := c.rpc.Ping(ctx, &llmcontracts.PingRequest{})
return responseFromRPCError(err)
}
func (c *Client) GenerateText(ctx context.Context, modelAlias string, messages []*schema.Message, options llmservice.GenerateOptions) (*llmservice.TextResult, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GenerateText(ctx, &llmcontracts.TextRequest{
ModelAlias: modelAlias,
Messages: messages,
Options: toContractGenerateOptions(options),
Billing: billingFromContext(ctx, modelAlias),
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil || resp.Result == nil {
return nil, errors.New("llm zrpc service returned empty text response")
}
return &llmservice.TextResult{
Text: resp.Result.Text,
Usage: llmservice.CloneUsage(resp.Result.Usage),
FinishReason: resp.Result.FinishReason,
}, nil
}
func (c *Client) StreamText(ctx context.Context, modelAlias string, messages []*schema.Message, options llmservice.GenerateOptions) (llmservice.StreamReader, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
stream, err := c.rpc.StreamText(ctx, &llmcontracts.StreamTextRequest{
ModelAlias: modelAlias,
Messages: messages,
Options: toContractGenerateOptions(options),
Billing: billingFromContext(ctx, modelAlias),
})
if err != nil {
return nil, responseFromRPCError(err)
}
return &streamReader{stream: stream}, nil
}
func (c *Client) GenerateResponsesText(ctx context.Context, modelAlias string, messages []llmservice.ArkResponsesMessage, options llmservice.ArkResponsesOptions) (*llmservice.ArkResponsesResult, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GenerateResponsesText(ctx, &llmcontracts.ResponsesRequest{
ModelAlias: modelAlias,
Messages: toContractResponsesMessages(messages),
Options: toContractResponsesOptions(options),
Billing: billingFromContext(ctx, modelAlias),
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil || resp.Result == nil {
return nil, errors.New("llm zrpc service returned empty responses response")
}
return toServiceResponsesResult(resp.Result), nil
}
func (c *Client) ensureReady() error {
if c == nil || c.rpc == nil {
return errors.New("llm zrpc client is not initialized")
}
return nil
}
func (c *Client) ping(timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return c.Ping(ctx)
}
type streamReader struct {
stream llmrpc.LLM_StreamTextClient
}
func (r *streamReader) Recv() (*schema.Message, error) {
if r == nil || r.stream == nil {
return nil, errors.New("llm zrpc stream is not initialized")
}
chunk, err := r.stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
return nil, io.EOF
}
return nil, responseFromRPCError(err)
}
if chunk == nil {
return nil, errors.New("llm zrpc service returned empty stream chunk")
}
return chunk.Message, nil
}
func (r *streamReader) Close() error {
return nil
}
func buildTextClient(remote *Client, modelAlias string) *llmservice.Client {
return llmservice.NewClient(
func(ctx context.Context, messages []*schema.Message, options llmservice.GenerateOptions) (*llmservice.TextResult, error) {
return remote.GenerateText(ctx, modelAlias, messages, options)
},
func(ctx context.Context, messages []*schema.Message, options llmservice.GenerateOptions) (llmservice.StreamReader, error) {
return remote.StreamText(ctx, modelAlias, messages, options)
},
)
}
func billingFromContext(ctx context.Context, modelAlias string) *llmcontracts.BillingContext {
billing, ok := llmservice.BillingContextFromContext(ctx)
if !ok {
return nil
}
if strings.TrimSpace(billing.ModelAlias) == "" {
billing.ModelAlias = strings.TrimSpace(modelAlias)
}
return &llmcontracts.BillingContext{
UserID: billing.UserID,
EventID: billing.EventID,
Scene: billing.Scene,
RequestID: billing.RequestID,
ConversationID: billing.ConversationID,
ModelAlias: billing.ModelAlias,
SkipCharge: billing.SkipCharge,
}
}
func toContractGenerateOptions(input llmservice.GenerateOptions) llmcontracts.GenerateOptions {
return llmcontracts.GenerateOptions{
Temperature: input.Temperature,
MaxTokens: input.MaxTokens,
Thinking: string(input.Thinking),
Metadata: input.Metadata,
}
}
func toContractResponsesMessages(input []llmservice.ArkResponsesMessage) []llmcontracts.ResponsesMessage {
if len(input) == 0 {
return nil
}
output := make([]llmcontracts.ResponsesMessage, 0, len(input))
for _, item := range input {
output = append(output, llmcontracts.ResponsesMessage{
Role: item.Role,
Text: item.Text,
ImageURL: item.ImageURL,
ImageDetail: item.ImageDetail,
})
}
return output
}
func toContractResponsesOptions(input llmservice.ArkResponsesOptions) llmcontracts.ResponsesOptions {
return llmcontracts.ResponsesOptions{
Model: input.Model,
Temperature: input.Temperature,
MaxOutputTokens: input.MaxOutputTokens,
Thinking: string(input.Thinking),
TextFormat: input.TextFormat,
}
}
func toServiceResponsesResult(result *llmcontracts.ResponsesResult) *llmservice.ArkResponsesResult {
if result == nil {
return nil
}
output := &llmservice.ArkResponsesResult{
Text: result.Text,
Status: result.Status,
IncompleteReason: result.IncompleteReason,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
if result.Usage != nil {
output.Usage = &llmservice.ArkResponsesUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
TotalTokens: result.Usage.TotalTokens,
}
}
return output
}
func normalizeEndpoints(values []string) []string {
endpoints := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
endpoints = append(endpoints, trimmed)
}
}
return endpoints
}
func resolvePingTimeout(timeout time.Duration) time.Duration {
if timeout > 0 && timeout < defaultPingTimeout {
return timeout
}
return defaultPingTimeout
}

View File

@@ -0,0 +1,73 @@
package llm
import (
"errors"
"fmt"
"strings"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func responseFromRPCError(err error) error {
if err == nil {
return nil
}
st, ok := status.FromError(err)
if !ok {
return wrapRPCError(err)
}
if message, ok := messageFromStatus(st); ok {
switch st.Code() {
case codes.InvalidArgument, codes.ResourceExhausted, codes.FailedPrecondition:
return errors.New(message)
}
}
switch st.Code() {
case codes.InvalidArgument, codes.ResourceExhausted, codes.FailedPrecondition:
return errors.New(strings.TrimSpace(st.Message()))
case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented:
msg := strings.TrimSpace(st.Message())
if msg == "" {
msg = "llm zrpc service internal error"
}
return wrapRPCError(errors.New(msg))
default:
msg := strings.TrimSpace(st.Message())
if msg == "" {
msg = "llm zrpc service rejected request"
}
return errors.New(msg)
}
}
func messageFromStatus(st *status.Status) (string, bool) {
if st == nil {
return "", false
}
for _, detail := range st.Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if !ok {
continue
}
message := strings.TrimSpace(st.Message())
if message == "" && info.Metadata != nil {
message = strings.TrimSpace(info.Metadata["info"])
}
if message == "" {
message = strings.TrimSpace(info.Reason)
}
return message, message != ""
}
return "", false
}
func wrapRPCError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("调用 llm zrpc 服务失败: %w", err)
}

View File

@@ -1,14 +1,11 @@
package tokenstore package tokenstore
import ( import (
"context"
"encoding/json"
"errors" "errors"
"strings" "strings"
"time" "time"
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb" "github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"github.com/zeromicro/go-zero/zrpc" "github.com/zeromicro/go-zero/zrpc"
) )
@@ -23,48 +20,12 @@ type ClientConfig struct {
Timeout time.Duration Timeout time.Duration
} }
// ProductSnapshot 是订单详情里内嵌的商品快照 // Client 是 gateway 访问 tokenstore zrpc 的统一 Credit 语义适配层
// //
// 职责边界: // 职责边界:
// 1. 只承载 HTTP gateway 当前需要透出的商品摘要 // 1. 只负责 HTTP gateway 与 tokenstore zrpc 之间的协议转译
// 2. 不补充 description、price 等商品列表字段,避免把详情快照扩成第二份商品实体 // 2. 不直连底层 credit_* 表,也不承载订单/充值/扣费业务规则
// 3. 若下游 proto/contract 还未合入对应字段,这里允许保持 nil/零值兜底 // 3. gRPC 业务错误会在这里反解成普通 error / respond.Response交给 HTTP 层统一处理
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 层统一返回。
type Client struct { type Client struct {
rpc pb.TokenStoreServiceClient rpc pb.TokenStoreServiceClient
} }
@@ -92,150 +53,6 @@ func NewClient(cfg ClientConfig) (*Client, error) {
return &Client{rpc: pb.NewTokenStoreServiceClient(zclient.Conn())}, nil 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 { func (c *Client) ensureReady() error {
if c == nil || c.rpc == nil { if c == nil || c.rpc == nil {
return errors.New("tokenstore zrpc client is not initialized") return errors.New("tokenstore zrpc client is not initialized")
@@ -254,150 +71,6 @@ func normalizeEndpoints(values []string) []string {
return endpoints 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 { func stringPtrFromNonEmpty(value string) *string {
trimmed := strings.TrimSpace(value) trimmed := strings.TrimSpace(value)
if trimmed == "" { if trimmed == "" {

View File

@@ -0,0 +1,383 @@
package tokenstore
import (
"context"
"errors"
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
)
func (c *Client) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GetCreditBalanceSnapshot(ctx, &pb.GetCreditBalanceSnapshotRequest{UserId: userID})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty get credit balance snapshot response")
}
snapshot := creditBalanceSnapshotFromPB(resp.Snapshot)
return &snapshot, nil
}
func (c *Client) GetCreditConsumptionDashboard(ctx context.Context, req creditcontracts.GetCreditConsumptionDashboardRequest) (*creditcontracts.CreditConsumptionDashboardView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GetCreditConsumptionDashboard(ctx, &pb.GetCreditConsumptionDashboardRequest{
ActorUserId: req.ActorUserID,
Period: req.Period,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty get credit consumption dashboard response")
}
dashboard := creditConsumptionDashboardFromPB(resp.Dashboard)
return &dashboard, nil
}
func (c *Client) ListCreditProducts(ctx context.Context, actorUserID uint64) ([]creditcontracts.CreditProductView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.ListCreditProducts(ctx, &pb.ListCreditProductsRequest{ActorUserId: actorUserID})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty list credit products response")
}
return creditProductsFromPB(resp.Items), nil
}
func (c *Client) CreateCreditOrder(ctx context.Context, req creditcontracts.CreateCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.CreateCreditOrder(ctx, &pb.CreateCreditOrderRequest{
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 credit order response")
}
order := creditOrderFromPB(resp.Order)
return &order, nil
}
func (c *Client) ListCreditOrders(ctx context.Context, req creditcontracts.ListCreditOrdersRequest) ([]creditcontracts.CreditOrderView, creditcontracts.PageResult, error) {
if err := c.ensureReady(); err != nil {
return nil, creditcontracts.PageResult{}, err
}
resp, err := c.rpc.ListCreditOrders(ctx, &pb.ListCreditOrdersRequest{
ActorUserId: req.ActorUserID,
Page: int32(req.Page),
PageSize: int32(req.PageSize),
Status: req.Status,
})
if err != nil {
return nil, creditcontracts.PageResult{}, responseFromRPCError(err)
}
if resp == nil {
return nil, creditcontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list credit orders response")
}
return creditOrdersFromPB(resp.Items), creditPageFromPB(resp.Page), nil
}
func (c *Client) GetCreditOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.GetCreditOrder(ctx, &pb.GetCreditOrderRequest{
ActorUserId: actorUserID,
OrderId: orderID,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty get credit order response")
}
order := creditOrderFromPB(resp.Order)
return &order, nil
}
func (c *Client) MockPaidCreditOrder(ctx context.Context, req creditcontracts.MockPaidCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.MockPaidCreditOrder(ctx, &pb.MockPaidCreditOrderRequest{
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 credit order response")
}
order := creditOrderFromPB(resp.Order)
return &order, nil
}
func (c *Client) ListCreditTransactions(ctx context.Context, req creditcontracts.ListCreditTransactionsRequest) ([]creditcontracts.CreditTransactionView, creditcontracts.PageResult, error) {
if err := c.ensureReady(); err != nil {
return nil, creditcontracts.PageResult{}, err
}
resp, err := c.rpc.ListCreditTransactions(ctx, &pb.ListCreditTransactionsRequest{
ActorUserId: req.ActorUserID,
Page: int32(req.Page),
PageSize: int32(req.PageSize),
Source: req.Source,
Direction: req.Direction,
})
if err != nil {
return nil, creditcontracts.PageResult{}, responseFromRPCError(err)
}
if resp == nil {
return nil, creditcontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list credit transactions response")
}
return creditTransactionsFromPB(resp.Items), creditPageFromPB(resp.Page), nil
}
func (c *Client) ListCreditPriceRules(ctx context.Context, req creditcontracts.ListCreditPriceRulesRequest) ([]creditcontracts.CreditPriceRuleView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.ListCreditPriceRules(ctx, &pb.ListCreditPriceRulesRequest{
Scene: req.Scene,
ProviderName: req.ProviderName,
ModelName: req.ModelName,
Status: req.Status,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty list credit price rules response")
}
return creditPriceRulesFromPB(resp.Items), nil
}
func (c *Client) ListCreditRewardRules(ctx context.Context, req creditcontracts.ListCreditRewardRulesRequest) ([]creditcontracts.CreditRewardRuleView, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.ListCreditRewardRules(ctx, &pb.ListCreditRewardRulesRequest{
Source: req.Source,
Status: req.Status,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("tokenstore zrpc service returned empty list credit reward rules response")
}
return creditRewardRulesFromPB(resp.Items), nil
}
func creditBalanceSnapshotFromPB(snapshot *pb.CreditBalanceSnapshotView) creditcontracts.CreditBalanceSnapshot {
if snapshot == nil {
return creditcontracts.CreditBalanceSnapshot{}
}
return creditcontracts.CreditBalanceSnapshot{
UserID: snapshot.UserId,
Balance: snapshot.Balance,
TotalRecharged: snapshot.TotalRecharged,
TotalRewarded: snapshot.TotalRewarded,
TotalConsumed: snapshot.TotalConsumed,
IsBlocked: snapshot.IsBlocked,
SnapshotSource: snapshot.SnapshotSource,
UpdatedAt: snapshot.UpdatedAt,
}
}
func creditConsumptionDashboardFromPB(view *pb.CreditConsumptionDashboardView) creditcontracts.CreditConsumptionDashboardView {
if view == nil {
return creditcontracts.CreditConsumptionDashboardView{}
}
return creditcontracts.CreditConsumptionDashboardView{
Period: view.Period,
CreditConsumed: view.CreditConsumed,
TokenConsumed: view.TokenConsumed,
}
}
func creditProductFromPB(product *pb.CreditProductView) creditcontracts.CreditProductView {
if product == nil {
return creditcontracts.CreditProductView{}
}
return creditcontracts.CreditProductView{
ProductID: product.ProductId,
Name: product.Name,
Description: product.Description,
CreditAmount: product.CreditAmount,
PriceCent: product.PriceCent,
OriginalPriceCent: product.OriginalPriceCent,
PriceText: product.PriceText,
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: int(product.SortOrder),
}
}
func creditProductsFromPB(items []*pb.CreditProductView) []creditcontracts.CreditProductView {
if len(items) == 0 {
return []creditcontracts.CreditProductView{}
}
result := make([]creditcontracts.CreditProductView, 0, len(items))
for _, item := range items {
result = append(result, creditProductFromPB(item))
}
return result
}
func creditOrderFromPB(order *pb.CreditOrderView) creditcontracts.CreditOrderView {
if order == nil {
return creditcontracts.CreditOrderView{}
}
return creditcontracts.CreditOrderView{
OrderID: order.OrderId,
OrderNo: order.OrderNo,
Status: order.Status,
ProductSnapshot: order.ProductSnapshot,
ProductName: order.ProductName,
Quantity: int(order.Quantity),
CreditAmount: order.CreditAmount,
AmountCent: order.AmountCent,
PriceText: order.PriceText,
Currency: order.Currency,
PaymentMode: order.PaymentMode,
CreatedAt: order.CreatedAt,
PaidAt: stringPtrFromNonEmpty(order.PaidAt),
CreditedAt: stringPtrFromNonEmpty(order.CreditedAt),
}
}
func creditOrdersFromPB(items []*pb.CreditOrderView) []creditcontracts.CreditOrderView {
if len(items) == 0 {
return []creditcontracts.CreditOrderView{}
}
result := make([]creditcontracts.CreditOrderView, 0, len(items))
for _, item := range items {
result = append(result, creditOrderFromPB(item))
}
return result
}
func creditTransactionFromPB(item *pb.CreditTransactionView) creditcontracts.CreditTransactionView {
if item == nil {
return creditcontracts.CreditTransactionView{}
}
var orderID *uint64
if item.OrderId > 0 {
value := item.OrderId
orderID = &value
}
return creditcontracts.CreditTransactionView{
TransactionID: item.TransactionId,
EventID: item.EventId,
Source: item.Source,
SourceLabel: item.SourceLabel,
Direction: item.Direction,
Amount: item.Amount,
BalanceAfter: item.BalanceAfter,
Status: item.Status,
Description: item.Description,
MetadataJSON: item.MetadataJson,
CreatedAt: item.CreatedAt,
OrderID: orderID,
}
}
func creditTransactionsFromPB(items []*pb.CreditTransactionView) []creditcontracts.CreditTransactionView {
if len(items) == 0 {
return []creditcontracts.CreditTransactionView{}
}
result := make([]creditcontracts.CreditTransactionView, 0, len(items))
for _, item := range items {
result = append(result, creditTransactionFromPB(item))
}
return result
}
func creditPriceRuleFromPB(item *pb.CreditPriceRuleView) creditcontracts.CreditPriceRuleView {
if item == nil {
return creditcontracts.CreditPriceRuleView{}
}
return creditcontracts.CreditPriceRuleView{
RuleID: item.RuleId,
Scene: item.Scene,
ProviderName: item.ProviderName,
ModelName: item.ModelName,
InputPriceMicros: item.InputPriceMicros,
OutputPriceMicros: item.OutputPriceMicros,
CachedPriceMicros: item.CachedPriceMicros,
ReasoningPriceMicros: item.ReasoningPriceMicros,
CreditPerYuan: item.CreditPerYuan,
Status: item.Status,
Priority: int(item.Priority),
Description: item.Description,
}
}
func creditPriceRulesFromPB(items []*pb.CreditPriceRuleView) []creditcontracts.CreditPriceRuleView {
if len(items) == 0 {
return []creditcontracts.CreditPriceRuleView{}
}
result := make([]creditcontracts.CreditPriceRuleView, 0, len(items))
for _, item := range items {
result = append(result, creditPriceRuleFromPB(item))
}
return result
}
func creditRewardRuleFromPB(item *pb.CreditRewardRuleView) creditcontracts.CreditRewardRuleView {
if item == nil {
return creditcontracts.CreditRewardRuleView{}
}
return creditcontracts.CreditRewardRuleView{
RuleID: item.RuleId,
Source: item.Source,
Name: item.Name,
Amount: item.Amount,
Status: item.Status,
Description: item.Description,
}
}
func creditRewardRulesFromPB(items []*pb.CreditRewardRuleView) []creditcontracts.CreditRewardRuleView {
if len(items) == 0 {
return []creditcontracts.CreditRewardRuleView{}
}
result := make([]creditcontracts.CreditRewardRuleView, 0, len(items))
for _, item := range items {
result = append(result, creditRewardRuleFromPB(item))
}
return result
}
func creditPageFromPB(page *pb.PageResponse) creditcontracts.PageResult {
if page == nil {
return creditcontracts.PageResult{}
}
return creditcontracts.PageResult{
Page: int(page.Page),
PageSize: int(page.PageSize),
Total: int(page.Total),
HasMore: page.HasMore,
}
}

View File

@@ -138,50 +138,6 @@ func (c *Client) ValidateAccessToken(ctx context.Context, accessToken string) (*
}, nil }, nil
} }
func (c *Client) CheckTokenQuota(ctx context.Context, userID int) (*contracts.CheckTokenQuotaResponse, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.CheckTokenQuota(ctx, &pb.CheckTokenQuotaRequest{
UserId: int64(userID),
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("userauth zrpc service returned empty quota response")
}
return &contracts.CheckTokenQuotaResponse{
Allowed: resp.Allowed,
TokenLimit: int(resp.TokenLimit),
TokenUsage: int(resp.TokenUsage),
LastResetAt: timeFromUnixNano(resp.LastResetAtUnixNano),
}, nil
}
func (c *Client) AdjustTokenUsage(ctx context.Context, req contracts.AdjustTokenUsageRequest) (*contracts.CheckTokenQuotaResponse, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.AdjustTokenUsage(ctx, &pb.AdjustTokenUsageRequest{
EventId: req.EventID,
UserId: int64(req.UserID),
TokenDelta: int64(req.TokenDelta),
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("userauth zrpc service returned empty adjust response")
}
return &contracts.CheckTokenQuotaResponse{
Allowed: resp.Allowed,
TokenLimit: int(resp.TokenLimit),
TokenUsage: int(resp.TokenUsage),
LastResetAt: timeFromUnixNano(resp.LastResetAtUnixNano),
}, nil
}
func (c *Client) ensureReady() error { func (c *Client) ensureReady() error {
if c == nil || c.rpc == nil { if c == nil || c.rpc == nil {
return errors.New("userauth zrpc client is not initialized") return errors.New("userauth zrpc client is not initialized")

View File

@@ -7,13 +7,12 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
llmclient "github.com/LoveLosita/smartflow/backend/client/llm"
activeadapters "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/adapters" activeadapters "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/adapters"
activeschedulerdao "github.com/LoveLosita/smartflow/backend/services/active_scheduler/dao" activeschedulerdao "github.com/LoveLosita/smartflow/backend/services/active_scheduler/dao"
activeschedulerrpc "github.com/LoveLosita/smartflow/backend/services/active_scheduler/rpc" activeschedulerrpc "github.com/LoveLosita/smartflow/backend/services/active_scheduler/rpc"
activeschedulersv "github.com/LoveLosita/smartflow/backend/services/active_scheduler/sv" activeschedulersv "github.com/LoveLosita/smartflow/backend/services/active_scheduler/sv"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
"github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap" "github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap"
einoinfra "github.com/LoveLosita/smartflow/backend/shared/infra/eino"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka" kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -31,16 +30,17 @@ func main() {
log.Fatalf("failed to connect active-scheduler database: %v", err) log.Fatalf("failed to connect active-scheduler database: %v", err)
} }
aiHub, err := einoinfra.InitEino() llmService, err := llmclient.NewService(llmclient.ServiceConfig{
if err != nil { ClientConfig: llmclient.ClientConfig{
log.Fatalf("failed to initialize active-scheduler Eino runtime: %v", err) Endpoints: viper.GetStringSlice("llm.rpc.endpoints"),
} Target: viper.GetString("llm.rpc.target"),
llmService := llmservice.New(llmservice.Options{ Timeout: viper.GetDuration("llm.rpc.timeout"),
AIHub: aiHub, },
APIKey: os.Getenv("ARK_API_KEY"),
BaseURL: viper.GetString("agent.baseURL"),
CourseVisionModel: viper.GetString("courseImport.visionModel"), CourseVisionModel: viper.GetString("courseImport.visionModel"),
}) })
if err != nil {
log.Fatalf("failed to initialize active-scheduler llm client: %v", err)
}
svc, err := activeschedulersv.New(db, llmService, activeschedulersv.Options{ svc, err := activeschedulersv.New(db, llmService, activeschedulersv.Options{
JobScanEvery: viper.GetDuration("activeScheduler.jobScanEvery"), JobScanEvery: viper.GetDuration("activeScheduler.jobScanEvery"),

View File

@@ -5,9 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
"os"
"strings" "strings"
llmclient "github.com/LoveLosita/smartflow/backend/client/llm"
memoryclient "github.com/LoveLosita/smartflow/backend/client/memory" memoryclient "github.com/LoveLosita/smartflow/backend/client/memory"
scheduleclient "github.com/LoveLosita/smartflow/backend/client/schedule" scheduleclient "github.com/LoveLosita/smartflow/backend/client/schedule"
taskclient "github.com/LoveLosita/smartflow/backend/client/task" taskclient "github.com/LoveLosita/smartflow/backend/client/task"
@@ -34,7 +34,6 @@ import (
schedulesv "github.com/LoveLosita/smartflow/backend/services/schedule/sv" schedulesv "github.com/LoveLosita/smartflow/backend/services/schedule/sv"
taskdao "github.com/LoveLosita/smartflow/backend/services/task/dao" taskdao "github.com/LoveLosita/smartflow/backend/services/task/dao"
tasksv "github.com/LoveLosita/smartflow/backend/services/task/sv" tasksv "github.com/LoveLosita/smartflow/backend/services/task/sv"
einoinfra "github.com/LoveLosita/smartflow/backend/shared/infra/eino"
gormcache "github.com/LoveLosita/smartflow/backend/shared/infra/gormcache" gormcache "github.com/LoveLosita/smartflow/backend/shared/infra/gormcache"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka" kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
mysqlinfra "github.com/LoveLosita/smartflow/backend/shared/infra/mysql" mysqlinfra "github.com/LoveLosita/smartflow/backend/shared/infra/mysql"
@@ -254,10 +253,6 @@ func (r *agentRuntime) startWorkers(ctx context.Context) error {
log.Println("Agent outbox consumer is disabled") log.Println("Agent outbox consumer is disabled")
return nil return nil
} }
if r.userAuthClient == nil {
return fmt.Errorf("agent outbox consumer requires userauth zrpc client")
}
// 1. 先登记 agent 自己消费的 handler同时补齐 memory.extract.requested 的服务路由。 // 1. 先登记 agent 自己消费的 handler同时补齐 memory.extract.requested 的服务路由。
// 2. 这里明确只接 agent 边界memory 消费仍归 cmd/memorytask 事件仍是 publish-only 写入 task outbox。 // 2. 这里明确只接 agent 边界memory 消费仍归 cmd/memorytask 事件仍是 publish-only 写入 task outbox。
// 3. 注册完成后再启动总线,避免服务一起来就抢先消费到尚未挂 handler 的消息。 // 3. 注册完成后再启动总线,避免服务一起来就抢先消费到尚未挂 handler 的消息。
@@ -268,7 +263,6 @@ func (r *agentRuntime) startWorkers(ctx context.Context) error {
r.agentRepo, r.agentRepo,
r.cacheRepo, r.cacheRepo,
nil, nil,
r.userAuthClient,
); err != nil { ); err != nil {
return fmt.Errorf("register agent outbox handlers failed: %w", err) return fmt.Errorf("register agent outbox handlers failed: %w", err)
} }
@@ -370,16 +364,14 @@ func ensureAgentRuntimeDependencyTables(db *gorm.DB) error {
} }
func buildAgentLLMService() (*llmservice.Service, error) { func buildAgentLLMService() (*llmservice.Service, error) {
aiHub, err := einoinfra.InitEino() return llmclient.NewService(llmclient.ServiceConfig{
if err != nil { ClientConfig: llmclient.ClientConfig{
return nil, err Endpoints: viper.GetStringSlice("llm.rpc.endpoints"),
} Target: viper.GetString("llm.rpc.target"),
return llmservice.New(llmservice.Options{ Timeout: viper.GetDuration("llm.rpc.timeout"),
AIHub: aiHub, },
APIKey: os.Getenv("ARK_API_KEY"),
BaseURL: viper.GetString("agent.baseURL"),
CourseVisionModel: viper.GetString("courseImport.visionModel"), CourseVisionModel: viper.GetString("courseImport.visionModel"),
}), nil })
} }
func buildAgentRAGService(ctx context.Context) (*ragservice.Service, error) { func buildAgentRAGService(ctx context.Context) (*ragservice.Service, error) {

View File

@@ -7,10 +7,10 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
llmclient "github.com/LoveLosita/smartflow/backend/client/llm"
coursedao "github.com/LoveLosita/smartflow/backend/services/course/dao" coursedao "github.com/LoveLosita/smartflow/backend/services/course/dao"
courserpc "github.com/LoveLosita/smartflow/backend/services/course/rpc" courserpc "github.com/LoveLosita/smartflow/backend/services/course/rpc"
coursesv "github.com/LoveLosita/smartflow/backend/services/course/sv" coursesv "github.com/LoveLosita/smartflow/backend/services/course/sv"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
rootdao "github.com/LoveLosita/smartflow/backend/services/runtime/dao" rootdao "github.com/LoveLosita/smartflow/backend/services/runtime/dao"
"github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap" "github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -33,15 +33,21 @@ func main() {
// 2. scheduleRepo 用于复用既有冲突检查,后续若切 schedule RPC bridge 再替换这里。 // 2. scheduleRepo 用于复用既有冲突检查,后续若切 schedule RPC bridge 再替换这里。
courseRepo := coursedao.NewCourseDAO(db) courseRepo := coursedao.NewCourseDAO(db)
scheduleRepo := rootdao.NewScheduleDAO(db) scheduleRepo := rootdao.NewScheduleDAO(db)
courseImageClient := llmservice.NewArkResponsesClient( llmService, err := llmclient.NewService(llmclient.ServiceConfig{
os.Getenv("ARK_API_KEY"), ClientConfig: llmclient.ClientConfig{
viper.GetString("agent.baseURL"), Endpoints: viper.GetStringSlice("llm.rpc.endpoints"),
viper.GetString("courseImport.visionModel"), Target: viper.GetString("llm.rpc.target"),
) Timeout: viper.GetDuration("llm.rpc.timeout"),
},
CourseVisionModel: viper.GetString("courseImport.visionModel"),
})
if err != nil {
log.Fatalf("failed to initialize course llm client: %v", err)
}
svc := coursesv.NewCourseService( svc := coursesv.NewCourseService(
courseRepo, courseRepo,
scheduleRepo, scheduleRepo,
courseImageClient, llmService.CourseImageResponsesClient(),
coursesv.NewCourseImageParseConfig( coursesv.NewCourseImageParseConfig(
viper.GetInt64("courseImport.maxImageBytes"), viper.GetInt64("courseImport.maxImageBytes"),
viper.GetInt("courseImport.maxTokens"), viper.GetInt("courseImport.maxTokens"),

157
backend/cmd/llm/main.go Normal file
View File

@@ -0,0 +1,157 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"sync"
"syscall"
tokenstoreclient "github.com/LoveLosita/smartflow/backend/client/tokenstore"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao"
llmrpc "github.com/LoveLosita/smartflow/backend/services/llm/rpc"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap"
einoinfra "github.com/LoveLosita/smartflow/backend/shared/infra/eino"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
redisinfra "github.com/LoveLosita/smartflow/backend/shared/infra/redis"
"github.com/spf13/viper"
)
func main() {
if err := bootstrap.LoadConfig(); err != nil {
log.Fatalf("failed to load config: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := llmdao.OpenDBFromConfig()
if err != nil {
log.Fatalf("failed to connect llm database: %v", err)
}
redisClient, err := redisinfra.OpenRedisFromConfig()
if err != nil {
log.Fatalf("failed to connect llm redis: %v", err)
}
defer redisClient.Close()
aiHub, err := einoinfra.InitEino()
if err != nil {
log.Fatalf("failed to initialize llm Eino runtime: %v", err)
}
legacyService := llmservice.New(llmservice.Options{
AIHub: aiHub,
APIKey: os.Getenv("ARK_API_KEY"),
BaseURL: viper.GetString("agent.baseURL"),
CourseVisionModel: viper.GetString("courseImport.visionModel"),
})
balanceSnapshotProvider := &tokenStoreSnapshotProvider{
cfg: tokenstoreclient.ClientConfig{
Endpoints: viper.GetStringSlice("tokenstore.rpc.endpoints"),
Target: viper.GetString("tokenstore.rpc.target"),
Timeout: viper.GetDuration("tokenstore.rpc.timeout"),
},
}
outboxRepo := outboxinfra.NewRepository(db)
priceRuleDAO := llmdao.NewPriceRuleDAO(db)
dispatchEngine, err := buildLLMOutboxDispatchEngine(outboxRepo)
if err != nil {
log.Fatalf("failed to initialize llm outbox dispatch engine: %v", err)
}
if dispatchEngine != nil {
dispatchEngine.StartDispatch(ctx)
defer dispatchEngine.Close()
log.Println("llm outbox dispatch started")
} else {
log.Println("llm outbox dispatch is disabled")
}
runtimeService, err := llmservice.NewRuntimeService(llmservice.RuntimeServiceOptions{
LegacyService: legacyService,
CacheDAO: llmdao.NewCacheDAO(redisClient),
PriceRuleDAO: priceRuleDAO,
SnapshotProvider: balanceSnapshotProvider,
OutboxRepo: outboxRepo,
OutboxMaxRetry: kafkabus.LoadConfig().MaxRetry,
ProviderName: viper.GetString("llm.providerName"),
LiteModelName: viper.GetString("agent.liteModel"),
ProModelName: viper.GetString("agent.proModel"),
MaxModelName: viper.GetString("agent.maxModel"),
CourseVisionModel: viper.GetString("courseImport.visionModel"),
})
if err != nil {
log.Fatalf("failed to initialize llm runtime service: %v", err)
}
server, listenOn, err := llmrpc.NewServer(llmrpc.ServerOptions{
ListenOn: viper.GetString("llm.rpc.listenOn"),
Timeout: viper.GetDuration("llm.rpc.timeout"),
Service: runtimeService,
})
if err != nil {
log.Fatalf("failed to build llm zrpc server: %v", err)
}
defer server.Stop()
go func() {
log.Printf("llm zrpc service starting on %s", listenOn)
server.Start()
}()
<-ctx.Done()
log.Println("llm service stopping")
}
func buildLLMOutboxDispatchEngine(outboxRepo *outboxinfra.Repository) (*outboxinfra.Engine, error) {
kafkaCfg := kafkabus.LoadConfig()
if !kafkaCfg.Enabled || outboxRepo == nil {
return nil, nil
}
route, _ := outboxinfra.ResolveServiceRoute(outboxinfra.ServiceLLM)
kafkaCfg.ServiceName = outboxinfra.ServiceLLM
return outboxinfra.NewEngine(outboxRepo.WithRoute(route), kafkaCfg)
}
type tokenStoreSnapshotProvider struct {
cfg tokenstoreclient.ClientConfig
mu sync.Mutex
client *tokenstoreclient.Client
}
func (p *tokenStoreSnapshotProvider) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error) {
client, err := p.ensureClient()
if err != nil {
return nil, err
}
return client.GetCreditBalanceSnapshot(ctx, userID)
}
func (p *tokenStoreSnapshotProvider) ensureClient() (*tokenstoreclient.Client, error) {
if p == nil {
return nil, nil
}
p.mu.Lock()
defer p.mu.Unlock()
if p.client != nil {
return p.client, nil
}
client, err := tokenstoreclient.NewClient(p.cfg)
if err != nil {
return nil, err
}
p.client = client
return p.client, nil
}

View File

@@ -8,6 +8,7 @@ import (
"os/signal" "os/signal"
"syscall" "syscall"
llmclient "github.com/LoveLosita/smartflow/backend/client/llm"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm" llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
memorymodule "github.com/LoveLosita/smartflow/backend/services/memory" memorymodule "github.com/LoveLosita/smartflow/backend/services/memory"
memorydao "github.com/LoveLosita/smartflow/backend/services/memory/dao" memorydao "github.com/LoveLosita/smartflow/backend/services/memory/dao"
@@ -17,7 +18,6 @@ import (
ragservice "github.com/LoveLosita/smartflow/backend/services/rag" ragservice "github.com/LoveLosita/smartflow/backend/services/rag"
ragconfig "github.com/LoveLosita/smartflow/backend/services/rag/config" ragconfig "github.com/LoveLosita/smartflow/backend/services/rag/config"
"github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap" "github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap"
einoinfra "github.com/LoveLosita/smartflow/backend/shared/infra/eino"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka" kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox" outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -96,20 +96,21 @@ func main() {
// //
// 说明: // 说明:
// 1. CP1 先复用既有 llm-service canonical 入口,不在 memory 服务里重建模型调用封装; // 1. CP1 先复用既有 llm-service canonical 入口,不在 memory 服务里重建模型调用封装;
// 2. 当前启动入口与 cmd/start.go / cmd/active-scheduler 都需要 Eino 初始化,后续若出现第三处重复装配,应抽公共 bootstrap // 2. 现在统一改走独立 llm zrpc clientmemory 进程不再本地初始化 AIHub
// 3. 返回 ProClient 是因为现有 memory.Module 只需要 llmservice.Client不需要完整 Service。 // 3. 返回 ProClient 是因为现有 memory.Module 只需要 llmservice.Client不需要完整 Service。
func buildMemoryLLMClient() (*llmservice.Client, error) { func buildMemoryLLMClient() (*llmservice.Client, error) {
aiHub, err := einoinfra.InitEino() remoteService, err := llmclient.NewService(llmclient.ServiceConfig{
ClientConfig: llmclient.ClientConfig{
Endpoints: viper.GetStringSlice("llm.rpc.endpoints"),
Target: viper.GetString("llm.rpc.target"),
Timeout: viper.GetDuration("llm.rpc.timeout"),
},
CourseVisionModel: viper.GetString("courseImport.visionModel"),
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
llmService := llmservice.New(llmservice.Options{ return remoteService.ProClient(), nil
AIHub: aiHub,
APIKey: os.Getenv("ARK_API_KEY"),
BaseURL: viper.GetString("agent.baseURL"),
CourseVisionModel: viper.GetString("courseImport.visionModel"),
})
return llmService.ProClient(), nil
} }
// buildMemoryRAGRuntime 初始化 memory 检索与向量同步使用的 RAG Runtime。 // buildMemoryRAGRuntime 初始化 memory 检索与向量同步使用的 RAG Runtime。

View File

@@ -14,6 +14,7 @@ import (
activeschedulerclient "github.com/LoveLosita/smartflow/backend/client/activescheduler" activeschedulerclient "github.com/LoveLosita/smartflow/backend/client/activescheduler"
agentclient "github.com/LoveLosita/smartflow/backend/client/agent" agentclient "github.com/LoveLosita/smartflow/backend/client/agent"
courseclient "github.com/LoveLosita/smartflow/backend/client/course" courseclient "github.com/LoveLosita/smartflow/backend/client/course"
llmclient "github.com/LoveLosita/smartflow/backend/client/llm"
memoryclient "github.com/LoveLosita/smartflow/backend/client/memory" memoryclient "github.com/LoveLosita/smartflow/backend/client/memory"
notificationclient "github.com/LoveLosita/smartflow/backend/client/notification" notificationclient "github.com/LoveLosita/smartflow/backend/client/notification"
scheduleclient "github.com/LoveLosita/smartflow/backend/client/schedule" scheduleclient "github.com/LoveLosita/smartflow/backend/client/schedule"
@@ -53,7 +54,6 @@ import (
taskdao "github.com/LoveLosita/smartflow/backend/services/task/dao" taskdao "github.com/LoveLosita/smartflow/backend/services/task/dao"
tasksv "github.com/LoveLosita/smartflow/backend/services/task/sv" tasksv "github.com/LoveLosita/smartflow/backend/services/task/sv"
"github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap" "github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap"
einoinfra "github.com/LoveLosita/smartflow/backend/shared/infra/eino"
gormcache "github.com/LoveLosita/smartflow/backend/shared/infra/gormcache" gormcache "github.com/LoveLosita/smartflow/backend/shared/infra/gormcache"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka" kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox" outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
@@ -273,16 +273,17 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
if shouldBuildGatewayAgentFallback() { if shouldBuildGatewayAgentFallback() {
log.Println("Gateway agent RPC fallback is enabled; building local AgentService compatibility path") log.Println("Gateway agent RPC fallback is enabled; building local AgentService compatibility path")
aiHub, err := einoinfra.InitEino() llmService, err := llmclient.NewService(llmclient.ServiceConfig{
if err != nil { ClientConfig: llmclient.ClientConfig{
return nil, fmt.Errorf("failed to initialize Eino: %w", err) Endpoints: viper.GetStringSlice("llm.rpc.endpoints"),
} Target: viper.GetString("llm.rpc.target"),
llmService := llmservice.New(llmservice.Options{ Timeout: viper.GetDuration("llm.rpc.timeout"),
AIHub: aiHub, },
APIKey: os.Getenv("ARK_API_KEY"),
BaseURL: viper.GetString("agent.baseURL"),
CourseVisionModel: viper.GetString("courseImport.visionModel"), CourseVisionModel: viper.GetString("courseImport.visionModel"),
}) })
if err != nil {
return nil, fmt.Errorf("failed to initialize llm zrpc client: %w", err)
}
ragService, err := buildRAGService(ctx) ragService, err := buildRAGService(ctx)
if err != nil { if err != nil {

View File

@@ -29,7 +29,19 @@ func main() {
log.Fatalf("failed to connect tokenstore database: %v", err) log.Fatalf("failed to connect tokenstore database: %v", err)
} }
svc := tokenstoresv.New(tokenstoresv.Options{DB: db}) var creditCache *tokenstoredao.CreditCacheDAO
rdb, err := tokenstoredao.OpenRedisFromConfig()
if err != nil {
log.Printf("tokenstore redis is unavailable, credit cache disabled: %v", err)
} else {
creditCache = tokenstoredao.NewCreditCacheDAO(rdb)
log.Println("Tokenstore credit cache enabled")
}
svc := tokenstoresv.New(tokenstoresv.Options{
DB: db,
CreditCache: creditCache,
})
outboxRepo := outboxinfra.NewRepository(db) outboxRepo := outboxinfra.NewRepository(db)
eventBus, err := outboxinfra.NewEventBus(outboxRepo, kafkabus.LoadConfig()) eventBus, err := outboxinfra.NewEventBus(outboxRepo, kafkabus.LoadConfig())
@@ -40,6 +52,9 @@ func main() {
if err := tokenstoresv.RegisterForumRewardHandlers(eventBus, outboxRepo, svc); err != nil { if err := tokenstoresv.RegisterForumRewardHandlers(eventBus, outboxRepo, svc); err != nil {
log.Fatalf("failed to register tokenstore outbox handlers: %v", err) log.Fatalf("failed to register tokenstore outbox handlers: %v", err)
} }
if err := tokenstoresv.RegisterCreditChargeHandlers(eventBus, outboxRepo, svc); err != nil {
log.Fatalf("failed to register credit charge handlers: %v", err)
}
eventBus.Start(ctx) eventBus.Start(ctx)
defer eventBus.Close() defer eventBus.Close()
log.Println("Tokenstore outbox consumer started") log.Println("Tokenstore outbox consumer started")

View File

@@ -56,6 +56,14 @@ tokenstore:
- "127.0.0.1:9095" - "127.0.0.1:9095"
timeout: 2s timeout: 2s
# LLM zrpc 独立服务与各业务服务客户端配置。
llm:
rpc:
listenOn: "0.0.0.0:9096"
endpoints:
- "127.0.0.1:9096"
timeout: 0s
# Kafka outbox 事件总线配置。 # Kafka outbox 事件总线配置。
kafka: kafka:
enabled: true enabled: true
@@ -67,6 +75,41 @@ kafka:
retryBatchSize: 100 retryBatchSize: 100
maxRetry: 20 maxRetry: 20
outbox:
services:
agent:
topic: "smartflow.agent.outbox"
groupID: "smartflow-agent-outbox-consumer"
table: "agent_outbox_messages"
task:
topic: "smartflow.task.outbox"
groupID: "smartflow-task-outbox-consumer"
table: "task_outbox_messages"
memory:
topic: "smartflow.memory.outbox"
groupID: "smartflow-memory-outbox-consumer"
table: "memory_outbox_messages"
active-scheduler:
topic: "smartflow.active-scheduler.outbox"
groupID: "smartflow-active-scheduler-outbox-consumer"
table: "active_scheduler_outbox_messages"
notification:
topic: "smartflow.notification.outbox"
groupID: "smartflow-notification-outbox-consumer"
table: "notification_outbox_messages"
taskclass-forum:
topic: "smartflow.taskclass-forum.outbox"
groupID: "smartflow-taskclass-forum-outbox-consumer"
table: "taskclass_forum_outbox_messages"
llm:
topic: "smartflow.llm.outbox"
groupID: "smartflow-llm-outbox-consumer"
table: "llm_outbox_messages"
token-store:
topic: "smartflow.token-store.outbox"
groupID: "smartflow-token-store-outbox-consumer"
table: "token_store_outbox_messages"
# 通知投递配置。 # 通知投递配置。
notification: notification:
rpc: rpc:

View File

@@ -117,6 +117,7 @@ func (sa *CourseHandler) ParseCourseTableImage(c *gin.Context) {
defer cancel() defer cancel()
rawDraft, err := sa.client.ParseCourseTableImage(ctx, coursecontracts.CourseImageParseRequest{ rawDraft, err := sa.client.ParseCourseTableImage(ctx, coursecontracts.CourseImageParseRequest{
UserID: userID,
Filename: fileHeader.Filename, Filename: fileHeader.Filename,
MIMEType: fileHeader.Header.Get("Content-Type"), MIMEType: fileHeader.Header.Get("Content-Type"),
ImageBytes: imageBytes, ImageBytes: imageBytes,

View File

@@ -2,28 +2,43 @@ package tokenstoreapi
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
gatewaytokenstore "github.com/LoveLosita/smartflow/backend/client/tokenstore"
"github.com/LoveLosita/smartflow/backend/gateway/shared/respond" "github.com/LoveLosita/smartflow/backend/gateway/shared/respond"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const requestTimeout = 2 * time.Second const requestTimeout = 5 * time.Second
const (
creditConsumptionPeriod24h = "24h"
creditConsumptionPeriod7d = "7d"
creditConsumptionPeriod30d = "30d"
creditConsumptionPeriodAll = "all"
creditDashboardPageSize = 50
)
// TokenStoreClient 是商店页 credit-store 语义所需的最小依赖面。
//
// 职责边界:
// 1. 只暴露 Credit 商店和流水所需能力,不再承接旧 token 商店接口。
// 2. 所有方法都以“当前登录用户”口径访问,不开放跨用户查询。
// 3. 具体 DB、RPC、Redis 细节统一封装在 tokenstore client 内。
type TokenStoreClient interface { type TokenStoreClient interface {
GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error)
ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) GetCreditConsumptionDashboard(ctx context.Context, req creditcontracts.GetCreditConsumptionDashboardRequest) (*creditcontracts.CreditConsumptionDashboardView, error)
CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*gatewaytokenstore.OrderView, error) ListCreditProducts(ctx context.Context, actorUserID uint64) ([]creditcontracts.CreditProductView, error)
ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]gatewaytokenstore.OrderView, tokencontracts.PageResult, error) CreateCreditOrder(ctx context.Context, req creditcontracts.CreateCreditOrderRequest) (*creditcontracts.CreditOrderView, error)
GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*gatewaytokenstore.OrderView, error) ListCreditOrders(ctx context.Context, req creditcontracts.ListCreditOrdersRequest) ([]creditcontracts.CreditOrderView, creditcontracts.PageResult, error)
MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*gatewaytokenstore.OrderView, error) GetCreditOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, error)
ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) MockPaidCreditOrder(ctx context.Context, req creditcontracts.MockPaidCreditOrderRequest) (*creditcontracts.CreditOrderView, error)
ListCreditTransactions(ctx context.Context, req creditcontracts.ListCreditTransactionsRequest) ([]creditcontracts.CreditTransactionView, creditcontracts.PageResult, error)
} }
type Handler struct { type Handler struct {
@@ -42,53 +57,50 @@ type pageEnvelope[T any] struct {
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
} }
type creditSummaryEnvelope struct {
CurrentCreditTotal int64 `json:"current_credit_total"`
RecordedCreditTotal int64 `json:"recorded_credit_total"`
AppliedCreditTotal int64 `json:"applied_credit_total"`
PendingApplyCreditTotal int64 `json:"pending_apply_credit_total"`
ValidUntil *string `json:"valid_until"`
QuotaSyncStatus string `json:"quota_sync_status"`
Tip string `json:"tip"`
}
type paymentAction struct { type paymentAction struct {
Type string `json:"type"` Type string `json:"type"`
Label string `json:"label"` Label string `json:"label"`
} }
type orderCreateEnvelope struct { type creditOrderEnvelope struct {
OrderID uint64 `json:"order_id"` OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"` OrderNo string `json:"order_no"`
Status string `json:"status"` Status string `json:"status"`
ProductSnapshot *gatewaytokenstore.ProductSnapshot `json:"product_snapshot"` Quantity int `json:"quantity"`
Quantity int `json:"quantity"` CreditAmount int64 `json:"credit_amount"`
TokenAmount int64 `json:"token_amount"` AmountCent int64 `json:"amount_cent"`
AmountCent int64 `json:"amount_cent"` PriceText string `json:"price_text"`
PriceText string `json:"price_text"` Currency string `json:"currency"`
Currency string `json:"currency"` PaymentMode string `json:"payment_mode"`
PaymentMode string `json:"payment_mode"` ProductName string `json:"product_name"`
PaymentAction paymentAction `json:"payment_action"` ProductDetail map[string]any `json:"product_snapshot,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
PaidAt *string `json:"paid_at"`
CreditedAt *string `json:"credited_at"`
PaymentAction paymentAction `json:"payment_action"`
} }
type orderListItemEnvelope struct { type creditTransactionEnvelope struct {
OrderID uint64 `json:"order_id"` GrantID uint64 `json:"grant_id"`
OrderNo string `json:"order_no"` SourceLabel string `json:"source_label"`
Status string `json:"status"` Amount int64 `json:"amount"`
ProductName string `json:"product_name"` Status string `json:"status"`
TokenAmount int64 `json:"token_amount"` Description string `json:"description"`
PriceText string `json:"price_text"` CreatedAt string `json:"created_at"`
CreatedAt string `json:"created_at"` Direction string `json:"direction"`
PaidAt *string `json:"paid_at"` BalanceAfter int64 `json:"balance_after"`
GrantedAt *string `json:"granted_at"` EventID string `json:"event_id"`
} OrderID *uint64 `json:"order_id"`
type orderDetailEnvelope struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
Status string `json:"status"`
ProductSnapshot *gatewaytokenstore.ProductSnapshot `json:"product_snapshot"`
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"`
} }
type createOrderBody struct { type createOrderBody struct {
@@ -105,15 +117,36 @@ func (h *Handler) GetSummary(c *gin.Context) {
if !ok { if !ok {
return return
} }
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel() defer cancel()
summary, err := client.GetSummary(ctx, currentUserID(c)) snapshot, err := client.GetCreditBalanceSnapshot(ctx, currentUserID(c))
if err != nil { if err != nil {
respond.DealWithError(c, err) respond.DealWithError(c, err)
return return
} }
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, summary)) c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, buildCreditSummaryEnvelope(snapshot)))
}
func (h *Handler) GetConsumptionDashboard(c *gin.Context) {
client, ok := h.ready(c)
if !ok {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel()
dashboard, err := client.GetCreditConsumptionDashboard(ctx, creditcontracts.GetCreditConsumptionDashboardRequest{
ActorUserID: currentUserID(c),
Period: c.Query("period"),
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, dashboard))
} }
func (h *Handler) ListProducts(c *gin.Context) { func (h *Handler) ListProducts(c *gin.Context) {
@@ -121,10 +154,11 @@ func (h *Handler) ListProducts(c *gin.Context) {
if !ok { if !ok {
return return
} }
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel() defer cancel()
items, err := client.ListProducts(ctx, currentUserID(c)) items, err := client.ListCreditProducts(ctx, currentUserID(c))
if err != nil { if err != nil {
respond.DealWithError(c, err) respond.DealWithError(c, err)
return return
@@ -137,6 +171,7 @@ func (h *Handler) CreateOrder(c *gin.Context) {
if !ok { if !ok {
return return
} }
var body createOrderBody var body createOrderBody
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType) c.JSON(http.StatusBadRequest, respond.WrongParamType)
@@ -145,7 +180,8 @@ func (h *Handler) CreateOrder(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel() defer cancel()
order, err := client.CreateOrder(ctx, tokencontracts.CreateTokenOrderRequest{
order, err := client.CreateCreditOrder(ctx, creditcontracts.CreateCreditOrderRequest{
ActorUserID: currentUserID(c), ActorUserID: currentUserID(c),
ProductID: body.ProductID, ProductID: body.ProductID,
Quantity: body.Quantity, Quantity: body.Quantity,
@@ -155,7 +191,7 @@ func (h *Handler) CreateOrder(c *gin.Context) {
respond.DealWithError(c, err) respond.DealWithError(c, err)
return return
} }
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderCreateEnvelope(order))) c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, buildCreditOrderEnvelope(order)))
} }
func (h *Handler) ListOrders(c *gin.Context) { func (h *Handler) ListOrders(c *gin.Context) {
@@ -163,6 +199,7 @@ func (h *Handler) ListOrders(c *gin.Context) {
if !ok { if !ok {
return return
} }
pageValue, ok := intQuery(c, "page") pageValue, ok := intQuery(c, "page")
if !ok { if !ok {
return return
@@ -174,7 +211,8 @@ func (h *Handler) ListOrders(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel() defer cancel()
items, page, err := client.ListOrders(ctx, tokencontracts.ListTokenOrdersRequest{
items, page, err := client.ListCreditOrders(ctx, creditcontracts.ListCreditOrdersRequest{
ActorUserID: currentUserID(c), ActorUserID: currentUserID(c),
Page: pageValue, Page: pageValue,
PageSize: pageSize, PageSize: pageSize,
@@ -184,7 +222,13 @@ func (h *Handler) ListOrders(c *gin.Context) {
respond.DealWithError(c, err) respond.DealWithError(c, err)
return return
} }
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(newOrderListItemEnvelopes(items), page)))
result := make([]creditOrderEnvelope, 0, len(items))
for i := range items {
item := items[i]
result = append(result, buildCreditOrderEnvelope(&item))
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(result, page)))
} }
func (h *Handler) GetOrder(c *gin.Context) { func (h *Handler) GetOrder(c *gin.Context) {
@@ -192,6 +236,7 @@ func (h *Handler) GetOrder(c *gin.Context) {
if !ok { if !ok {
return return
} }
orderID, ok := uint64Param(c, "order_id") orderID, ok := uint64Param(c, "order_id")
if !ok { if !ok {
return return
@@ -199,12 +244,13 @@ func (h *Handler) GetOrder(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel() defer cancel()
order, err := client.GetOrder(ctx, currentUserID(c), orderID)
order, err := client.GetCreditOrder(ctx, currentUserID(c), orderID)
if err != nil { if err != nil {
respond.DealWithError(c, err) respond.DealWithError(c, err)
return return
} }
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderDetailEnvelope(order))) c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, buildCreditOrderEnvelope(order)))
} }
func (h *Handler) MockPaidOrder(c *gin.Context) { func (h *Handler) MockPaidOrder(c *gin.Context) {
@@ -212,10 +258,12 @@ func (h *Handler) MockPaidOrder(c *gin.Context) {
if !ok { if !ok {
return return
} }
orderID, ok := uint64Param(c, "order_id") orderID, ok := uint64Param(c, "order_id")
if !ok { if !ok {
return return
} }
var body mockPaidBody var body mockPaidBody
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType) c.JSON(http.StatusBadRequest, respond.WrongParamType)
@@ -224,24 +272,26 @@ func (h *Handler) MockPaidOrder(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel() defer cancel()
order, err := client.MockPaidOrder(ctx, tokencontracts.MockPaidOrderRequest{
order, err := client.MockPaidCreditOrder(ctx, creditcontracts.MockPaidCreditOrderRequest{
ActorUserID: currentUserID(c), ActorUserID: currentUserID(c),
OrderID: orderID, OrderID: orderID,
MockChannel: body.MockChannel, MockChannel: firstNonEmptyString(strings.TrimSpace(body.MockChannel), "mock"),
IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")), IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")),
}) })
if err != nil { if err != nil {
respond.DealWithError(c, err) respond.DealWithError(c, err)
return return
} }
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newOrderDetailEnvelope(order))) c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, buildCreditOrderEnvelope(order)))
} }
func (h *Handler) ListGrants(c *gin.Context) { func (h *Handler) ListTransactions(c *gin.Context) {
client, ok := h.ready(c) client, ok := h.ready(c)
if !ok { if !ok {
return return
} }
pageValue, ok := intQuery(c, "page") pageValue, ok := intQuery(c, "page")
if !ok { if !ok {
return return
@@ -253,22 +303,29 @@ func (h *Handler) ListGrants(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel() defer cancel()
items, page, err := client.ListGrants(ctx, tokencontracts.ListTokenGrantsRequest{
items, page, err := client.ListCreditTransactions(ctx, creditcontracts.ListCreditTransactionsRequest{
ActorUserID: currentUserID(c), ActorUserID: currentUserID(c),
Page: pageValue, Page: pageValue,
PageSize: pageSize, PageSize: pageSize,
Source: c.Query("source"), Source: c.Query("source"),
Direction: c.Query("direction"),
}) })
if err != nil { if err != nil {
respond.DealWithError(c, err) respond.DealWithError(c, err)
return return
} }
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(items, page)))
result := make([]creditTransactionEnvelope, 0, len(items))
for i := range items {
result = append(result, buildCreditTransactionEnvelope(items[i]))
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, newPageEnvelope(result, page)))
} }
func (h *Handler) ready(c *gin.Context) (TokenStoreClient, bool) { func (h *Handler) ready(c *gin.Context) (TokenStoreClient, bool) {
if h == nil || h.client == nil { if h == nil || h.client == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token-store gateway client 未初始化"))) c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("credit-store gateway client 未初始化")))
return nil, false return nil, false
} }
return h.client, true return h.client, true
@@ -282,82 +339,107 @@ func currentUserID(c *gin.Context) uint64 {
return uint64(userID) return uint64(userID)
} }
func newOrderCreateEnvelope(order *gatewaytokenstore.OrderView) orderCreateEnvelope { // buildCreditSummaryEnvelope 负责把权威余额快照转换成钱包摘要返回值。
//
// 职责边界:
// 1. current_credit_total 明确表示“当前可用 Credit 总数”,口径直接复用权威余额。
// 2. 老字段继续保留,避免影响现有前端和兼容逻辑。
// 3. 这里只做展示层拼装,不在网关重复做扣费或账本计算。
func buildCreditSummaryEnvelope(snapshot *creditcontracts.CreditBalanceSnapshot) creditSummaryEnvelope {
if snapshot == nil {
return creditSummaryEnvelope{
CurrentCreditTotal: 0,
RecordedCreditTotal: 0,
AppliedCreditTotal: 0,
PendingApplyCreditTotal: 0,
ValidUntil: nil,
QuotaSyncStatus: "synced",
Tip: "当前为 Credit 权威账本,购买、奖励和 AI 消费都会实时入账。",
}
}
recordedTotal := snapshot.TotalRecharged + snapshot.TotalRewarded
if recordedTotal <= 0 && snapshot.Balance > 0 {
recordedTotal = snapshot.Balance + snapshot.TotalConsumed
}
tip := "当前为 Credit 权威账本,购买、奖励和 AI 消费都会实时入账。"
if snapshot.IsBlocked {
tip = "当前 Credit 余额不足AI 调用会被阻断;充值后会自动恢复。"
}
return creditSummaryEnvelope{
CurrentCreditTotal: snapshot.Balance,
RecordedCreditTotal: maxInt64(recordedTotal, 0),
AppliedCreditTotal: snapshot.Balance,
PendingApplyCreditTotal: 0,
ValidUntil: nil,
QuotaSyncStatus: "synced",
Tip: tip,
}
}
func buildCreditOrderEnvelope(order *creditcontracts.CreditOrderView) creditOrderEnvelope {
if order == nil { if order == nil {
return orderCreateEnvelope{ return creditOrderEnvelope{
PaymentAction: paymentAction{ PaymentAction: paymentAction{
Type: "mock_paid", Type: "mock_paid",
Label: "确认支付", Label: "确认支付",
}, },
} }
} }
return orderCreateEnvelope{
OrderID: order.OrderID, return creditOrderEnvelope{
OrderNo: order.OrderNo, OrderID: order.OrderID,
Status: order.Status, OrderNo: order.OrderNo,
ProductSnapshot: order.ProductSnapshot, Status: order.Status,
Quantity: order.Quantity, Quantity: order.Quantity,
TokenAmount: order.TokenAmount, CreditAmount: order.CreditAmount,
AmountCent: order.AmountCent, AmountCent: order.AmountCent,
PriceText: order.PriceText, PriceText: order.PriceText,
Currency: order.Currency, Currency: order.Currency,
PaymentMode: order.PaymentMode, PaymentMode: order.PaymentMode,
ProductName: order.ProductName,
ProductDetail: parseJSONMap(order.ProductSnapshot),
CreatedAt: order.CreatedAt,
PaidAt: order.PaidAt,
CreditedAt: order.CreditedAt,
PaymentAction: paymentAction{ PaymentAction: paymentAction{
Type: "mock_paid", Type: "mock_paid",
Label: "确认支付", Label: "确认支付",
}, },
CreatedAt: order.CreatedAt,
} }
} }
func newOrderListItemEnvelopes(items []gatewaytokenstore.OrderView) []orderListItemEnvelope { func buildCreditTransactionEnvelope(item creditcontracts.CreditTransactionView) creditTransactionEnvelope {
if len(items) == 0 { return creditTransactionEnvelope{
return []orderListItemEnvelope{} GrantID: item.TransactionID,
SourceLabel: item.SourceLabel,
Amount: item.Amount,
Status: item.Status,
Description: firstNonEmptyString(item.Description, item.SourceLabel),
CreatedAt: item.CreatedAt,
Direction: item.Direction,
BalanceAfter: item.BalanceAfter,
EventID: item.EventID,
OrderID: item.OrderID,
} }
result := make([]orderListItemEnvelope, 0, len(items)) }
for _, item := range items {
productName := item.ProductName func parseJSONMap(raw string) map[string]any {
if productName == "" && item.ProductSnapshot != nil { trimmed := strings.TrimSpace(raw)
productName = item.ProductSnapshot.Name if trimmed == "" {
} return nil
result = append(result, orderListItemEnvelope{ }
OrderID: item.OrderID,
OrderNo: item.OrderNo, result := make(map[string]any)
Status: item.Status, if err := json.Unmarshal([]byte(trimmed), &result); err != nil {
ProductName: productName, return nil
TokenAmount: item.TokenAmount,
PriceText: item.PriceText,
CreatedAt: item.CreatedAt,
PaidAt: item.PaidAt,
GrantedAt: item.GrantedAt,
})
} }
return result return result
} }
func newOrderDetailEnvelope(order *gatewaytokenstore.OrderView) orderDetailEnvelope { func newPageEnvelope[T any](items []T, page creditcontracts.PageResult) pageEnvelope[T] {
if order == nil {
return orderDetailEnvelope{}
}
return orderDetailEnvelope{
OrderID: order.OrderID,
OrderNo: order.OrderNo,
Status: order.Status,
ProductSnapshot: order.ProductSnapshot,
Quantity: order.Quantity,
TokenAmount: order.TokenAmount,
AmountCent: order.AmountCent,
PriceText: order.PriceText,
Currency: order.Currency,
PaymentMode: order.PaymentMode,
Grant: order.Grant,
CreatedAt: order.CreatedAt,
PaidAt: order.PaidAt,
GrantedAt: order.GrantedAt,
}
}
func newPageEnvelope[T any](items []T, page tokencontracts.PageResult) pageEnvelope[T] {
return pageEnvelope[T]{ return pageEnvelope[T]{
Items: items, Items: items,
Page: page.Page, Page: page.Page,
@@ -372,6 +454,7 @@ func intQuery(c *gin.Context, key string) (int, bool) {
if raw == "" { if raw == "" {
return 0, true return 0, true
} }
value, err := strconv.Atoi(raw) value, err := strconv.Atoi(raw)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType) c.JSON(http.StatusBadRequest, respond.WrongParamType)
@@ -388,3 +471,145 @@ func uint64Param(c *gin.Context, key string) (uint64, bool) {
} }
return value, true return value, true
} }
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func maxInt64(left int64, right int64) int64 {
if left > right {
return left
}
return right
}
func normalizeCreditConsumptionPeriod(raw string) (string, error) {
switch strings.TrimSpace(raw) {
case "", creditConsumptionPeriod24h:
return creditConsumptionPeriod24h, nil
case creditConsumptionPeriod7d:
return creditConsumptionPeriod7d, nil
case creditConsumptionPeriod30d:
return creditConsumptionPeriod30d, nil
case creditConsumptionPeriodAll:
return creditConsumptionPeriodAll, nil
default:
return "", errors.New("invalid consumption period")
}
}
func buildCreditConsumptionDashboard(ctx context.Context, client TokenStoreClient, actorUserID uint64, period string) (creditcontracts.CreditConsumptionDashboardView, error) {
startAt, hasWindow := resolveCreditConsumptionWindow(period, time.Now())
dashboard := creditcontracts.CreditConsumptionDashboardView{
Period: period,
}
for page := 1; ; page++ {
items, pageResult, err := client.ListCreditTransactions(ctx, creditcontracts.ListCreditTransactionsRequest{
ActorUserID: actorUserID,
Page: page,
PageSize: creditDashboardPageSize,
Source: "charge",
Direction: "expense",
})
if err != nil {
return creditcontracts.CreditConsumptionDashboardView{}, err
}
if len(items) == 0 {
return dashboard, nil
}
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(item.Status), "failed") {
continue
}
createdAt, ok := parseCreditTransactionCreatedAt(item.CreatedAt)
if hasWindow && ok && createdAt.Before(startAt) {
continue
}
if hasWindow && !ok {
continue
}
dashboard.CreditConsumed += normalizeCreditConsumedAmount(item.Amount)
dashboard.TokenConsumed += extractChargeTokenConsumed(item.MetadataJSON)
}
if !pageResult.HasMore {
return dashboard, nil
}
if hasWindow && isCreditTransactionPageBeforeWindow(items, startAt) {
return dashboard, nil
}
}
}
func resolveCreditConsumptionWindow(period string, now time.Time) (time.Time, bool) {
switch strings.TrimSpace(period) {
case creditConsumptionPeriod24h:
return now.Add(-24 * time.Hour), true
case creditConsumptionPeriod7d:
return now.Add(-7 * 24 * time.Hour), true
case creditConsumptionPeriod30d:
return now.Add(-30 * 24 * time.Hour), true
default:
return time.Time{}, false
}
}
func parseCreditTransactionCreatedAt(raw string) (time.Time, bool) {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
if err != nil {
return time.Time{}, false
}
return parsed, true
}
func isCreditTransactionPageBeforeWindow(items []creditcontracts.CreditTransactionView, startAt time.Time) bool {
if len(items) == 0 {
return false
}
oldest, ok := parseCreditTransactionCreatedAt(items[len(items)-1].CreatedAt)
if !ok {
return false
}
return oldest.Before(startAt)
}
func normalizeCreditConsumedAmount(amount int64) int64 {
if amount >= 0 {
return 0
}
return -amount
}
func extractChargeTokenConsumed(metadataJSON string) int64 {
if strings.TrimSpace(metadataJSON) == "" {
return 0
}
var metadata struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
}
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
return 0
}
if metadata.TotalTokens > 0 {
return metadata.TotalTokens
}
total := metadata.InputTokens + metadata.OutputTokens
if total < 0 {
return 0
}
return total
}

View File

@@ -1,18 +1,18 @@
package tokenstoreapi package tokenstoreapi
import ( import (
"github.com/LoveLosita/smartflow/backend/services/runtime/dao"
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware" gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
rootmiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware" rootmiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
"github.com/LoveLosita/smartflow/backend/services/runtime/dao"
ratelimit "github.com/LoveLosita/smartflow/backend/shared/infra/ratelimit" ratelimit "github.com/LoveLosita/smartflow/backend/shared/infra/ratelimit"
"github.com/LoveLosita/smartflow/backend/shared/ports" "github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// RegisterRoutes 把 Token 商店 HTTP 入口挂到 gateway 路由组。 // RegisterRoutes 把 Credit 商店 HTTP 入口挂到 gateway 路由组。
// //
// 职责边界: // 职责边界:
// 1. 只注册 /token-store 下的边缘路由,不承载订单和 grant 业务规则 // 1. 只注册 /credit-store 下的边缘路由,不承载底层订单和账本实现细节
// 2. P0 全部接口都要求登录,并统一走限流保护; // 2. P0 全部接口都要求登录,并统一走限流保护;
// 3. 只有创建订单与 mock paid 需要幂等键,避免重复下单或重复确认支付。 // 3. 只有创建订单与 mock paid 需要幂等键,避免重复下单或重复确认支付。
func RegisterRoutes(apiGroup *gin.RouterGroup, handler *Handler, authClient ports.AccessTokenValidator, cache *dao.CacheDAO, limiter *ratelimit.RateLimiter) { func RegisterRoutes(apiGroup *gin.RouterGroup, handler *Handler, authClient ports.AccessTokenValidator, cache *dao.CacheDAO, limiter *ratelimit.RateLimiter) {
@@ -20,15 +20,16 @@ func RegisterRoutes(apiGroup *gin.RouterGroup, handler *Handler, authClient port
return return
} }
tokenStoreGroup := apiGroup.Group("/token-store") tokenStoreGroup := apiGroup.Group("/credit-store")
tokenStoreGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1)) tokenStoreGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
{ {
tokenStoreGroup.GET("/summary", handler.GetSummary) tokenStoreGroup.GET("/summary", handler.GetSummary)
tokenStoreGroup.GET("/consumption-dashboard", handler.GetConsumptionDashboard)
tokenStoreGroup.GET("/products", handler.ListProducts) tokenStoreGroup.GET("/products", handler.ListProducts)
tokenStoreGroup.POST("/orders", rootmiddleware.IdempotencyMiddleware(cache), handler.CreateOrder) tokenStoreGroup.POST("/orders", rootmiddleware.IdempotencyMiddleware(cache), handler.CreateOrder)
tokenStoreGroup.GET("/orders", handler.ListOrders) tokenStoreGroup.GET("/orders", handler.ListOrders)
tokenStoreGroup.GET("/orders/:order_id", handler.GetOrder) tokenStoreGroup.GET("/orders/:order_id", handler.GetOrder)
tokenStoreGroup.POST("/orders/:order_id/mock-paid", rootmiddleware.IdempotencyMiddleware(cache), handler.MockPaidOrder) tokenStoreGroup.POST("/orders/:order_id/mock-paid", rootmiddleware.IdempotencyMiddleware(cache), handler.MockPaidOrder)
tokenStoreGroup.GET("/grants", handler.ListGrants) tokenStoreGroup.GET("/transactions", handler.ListTransactions)
} }
} }

View File

@@ -1,51 +0,0 @@
package middleware
import (
"context"
"errors"
"net/http"
"time"
"github.com/LoveLosita/smartflow/backend/gateway/shared/respond"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin"
)
// TokenQuotaGuard 在请求入口做 token 额度门禁。
// 职责边界:
// 1. 只负责调用 user/auth 服务判断当前用户是否还能继续消耗 token
// 2. 不再直连 users 表或 Redis 额度细节;
// 3. 额度超限时直接拒绝,不进入业务 handler。
func TokenQuotaGuard(checker ports.TokenQuotaChecker) gin.HandlerFunc {
return func(c *gin.Context) {
if checker == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token quota checker dependency not initialized")))
c.Abort()
return
}
userID := c.GetInt("user_id")
if userID <= 0 {
c.JSON(http.StatusUnauthorized, respond.ErrUnauthorized)
c.Abort()
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
resp, err := checker.CheckTokenQuota(ctx, userID)
if err != nil {
writeRespondError(c, err)
c.Abort()
return
}
if resp == nil || !resp.Allowed {
c.JSON(http.StatusBadRequest, respond.TokenUsageExceedsLimit)
c.Abort()
return
}
c.Next()
}
}

View File

@@ -130,7 +130,7 @@ func RegisterRouters(
agentGroup := apiGroup.Group("/agent") agentGroup := apiGroup.Group("/agent")
{ {
agentGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1)) agentGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
agentGroup.POST("/chat", gatewaymiddleware.TokenQuotaGuard(authClient), handlers.AgentHandler.ChatAgent) agentGroup.POST("/chat", handlers.AgentHandler.ChatAgent)
agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta) agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta)
agentGroup.GET("/conversation-list", handlers.AgentHandler.GetConversationList) agentGroup.GET("/conversation-list", handlers.AgentHandler.GetConversationList)
agentGroup.GET("/conversation-timeline", handlers.AgentHandler.GetConversationTimeline) agentGroup.GET("/conversation-timeline", handlers.AgentHandler.GetConversationTimeline)

View File

@@ -1,10 +1,20 @@
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/IBM/sarama v1.43.1/go.mod h1:GG5q1RURtDNPz8xxJs3mgX6Ytak8Z9eLhAkJPObe2xE=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
@@ -45,6 +55,7 @@ github.com/cloudwego/eino-ext/components/model/ark v0.1.64 h1:ecsP4xWhOGi6NYxl2N
github.com/cloudwego/eino-ext/components/model/ark v0.1.64/go.mod h1:aabMR15RTXBSi9Eu13CWavzE+no5BQO4FJUEEdqImbg= github.com/cloudwego/eino-ext/components/model/ark v0.1.64/go.mod h1:aabMR15RTXBSi9Eu13CWavzE+no5BQO4FJUEEdqImbg=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 h1:z0bI5TH3nE+uDQiRhxBQMvk2HswlDUM3xP38+VSgpSQ= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 h1:z0bI5TH3nE+uDQiRhxBQMvk2HswlDUM3xP38+VSgpSQ=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
@@ -57,21 +68,30 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eapache/go-resiliency v1.6.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho=
github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
@@ -83,6 +103,7 @@ github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -109,6 +130,7 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -124,6 +146,7 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -137,6 +160,8 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -148,6 +173,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -157,16 +183,34 @@ github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/pyroscope-go v1.2.8 h1:UvCwIhlx9DeV7F6TW/z8q1Mi4PIm3vuUJ2ZlCEvmA4M= github.com/grafana/pyroscope-go v1.2.8 h1:UvCwIhlx9DeV7F6TW/z8q1Mi4PIm3vuUJ2ZlCEvmA4M=
github.com/grafana/pyroscope-go v1.2.8/go.mod h1:SSi59eQ1/zmKoY/BKwa5rSFsJaq+242Bcrr4wPix1g8= github.com/grafana/pyroscope-go v1.2.8/go.mod h1:SSi59eQ1/zmKoY/BKwa5rSFsJaq+242Bcrr4wPix1g8=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jhump/protoreflect v1.18.0/go.mod h1:ezWcltJIVF4zYdIFM+D/sHV4Oh5LNU08ORzCGfwvTz8=
github.com/jhump/protoreflect/v2 v2.0.0-beta.1/go.mod h1:D9LBEowZyv8/iSu97FU2zmXG3JxVTmNw21mu63niFzU=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -177,10 +221,12 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -210,10 +256,15 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs= github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs=
github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -222,6 +273,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
@@ -239,12 +292,15 @@ github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7s
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
@@ -262,15 +318,21 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc=
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0=
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0= github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0=
github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@@ -294,6 +356,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -336,8 +399,11 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -353,8 +419,10 @@ go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoB
go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs= go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs=
go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY= go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY=
go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU= go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
@@ -461,6 +529,7 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -502,6 +571,7 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo=
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
@@ -528,6 +598,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
@@ -549,6 +620,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -559,12 +631,14 @@ k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE=
k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A=
k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM=
k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=

View File

@@ -118,6 +118,19 @@ function Get-InfrastructureComposeServices {
) )
} }
function Get-KafkaTopicDefinitions {
return @(
"smartflow.agent.outbox",
"smartflow.task.outbox",
"smartflow.memory.outbox",
"smartflow.active-scheduler.outbox",
"smartflow.notification.outbox",
"smartflow.taskclass-forum.outbox",
"smartflow.llm.outbox",
"smartflow.token-store.outbox"
)
}
function Get-BackendServiceDefinitions { function Get-BackendServiceDefinitions {
return @( return @(
[pscustomobject]@{ [pscustomobject]@{
@@ -160,6 +173,16 @@ function Get-BackendServiceDefinitions {
StartTimeoutSec = 90 StartTimeoutSec = 90
Dependencies = @() Dependencies = @()
}, },
[pscustomobject]@{
Name = "llm"
Package = "./cmd/llm"
BinaryPath = (Join-Path $BinRoot "llm.exe")
Port = 9096
ProbeType = "tcp"
ProbeTarget = $null
StartTimeoutSec = 120
Dependencies = @()
},
[pscustomobject]@{ [pscustomobject]@{
Name = "course" Name = "course"
Package = "./cmd/course" Package = "./cmd/course"
@@ -168,7 +191,7 @@ function Get-BackendServiceDefinitions {
ProbeType = "tcp" ProbeType = "tcp"
ProbeTarget = $null ProbeTarget = $null
StartTimeoutSec = 120 StartTimeoutSec = 120
Dependencies = @() Dependencies = @("llm")
}, },
[pscustomobject]@{ [pscustomobject]@{
Name = "tokenstore" Name = "tokenstore"
@@ -198,10 +221,11 @@ function Get-BackendServiceDefinitions {
ProbeType = "tcp" ProbeType = "tcp"
ProbeTarget = $null ProbeTarget = $null
StartTimeoutSec = 150 StartTimeoutSec = 150
Dependencies = @() Dependencies = @("llm")
}, },
[pscustomobject]@{ [pscustomobject]@{
Name = "taskclassforum" Name = "taskclassforum"
Aliases = @("forum")
Package = "./cmd/taskclassforum" Package = "./cmd/taskclassforum"
BinaryPath = (Join-Path $BinRoot "taskclassforum.exe") BinaryPath = (Join-Path $BinRoot "taskclassforum.exe")
Port = 9090 Port = 9090
@@ -218,7 +242,7 @@ function Get-BackendServiceDefinitions {
ProbeType = "tcp" ProbeType = "tcp"
ProbeTarget = $null ProbeTarget = $null
StartTimeoutSec = 120 StartTimeoutSec = 120
Dependencies = @("task", "schedule") Dependencies = @("task", "schedule", "llm")
}, },
[pscustomobject]@{ [pscustomobject]@{
Name = "agent" Name = "agent"
@@ -228,7 +252,7 @@ function Get-BackendServiceDefinitions {
ProbeType = "tcp" ProbeType = "tcp"
ProbeTarget = $null ProbeTarget = $null
StartTimeoutSec = 180 StartTimeoutSec = 180
Dependencies = @("task", "schedule", "task-class", "memory") Dependencies = @("task", "schedule", "task-class", "memory", "llm")
}, },
[pscustomobject]@{ [pscustomobject]@{
Name = "api" Name = "api"
@@ -244,6 +268,7 @@ function Get-BackendServiceDefinitions {
"schedule", "schedule",
"task-class", "task-class",
"course", "course",
"llm",
"tokenstore", "tokenstore",
"notification", "notification",
"memory", "memory",
@@ -261,13 +286,33 @@ function Get-BackendServiceDefinition {
[string]$Name [string]$Name
) )
foreach ($service in (Get-BackendServiceDefinitions)) { $serviceDefinitions = @(Get-BackendServiceDefinitions)
if ($service.Name -eq $Name) { foreach ($service in $serviceDefinitions) {
$aliases = @()
if ($service.PSObject.Properties.Name -contains "Aliases" -and $null -ne $service.Aliases) {
$aliases = @($service.Aliases)
}
if ($service.Name -eq $Name -or $aliases -contains $Name) {
return $service return $service
} }
} }
throw "Service definition not found: $Name" $availableNames = foreach ($service in $serviceDefinitions) {
$aliases = @()
if ($service.PSObject.Properties.Name -contains "Aliases" -and $null -ne $service.Aliases) {
$aliases = @($service.Aliases)
}
if ($aliases.Count -gt 0) {
"{0} ({1})" -f $service.Name, ($aliases -join ", ")
}
else {
$service.Name
}
}
throw "Service definition not found: $Name. Available names: $($availableNames -join '; ')"
} }
function Get-ServicePidFilePath { function Get-ServicePidFilePath {
@@ -859,6 +904,31 @@ function Start-BackendInfrastructure {
foreach ($definition in (Get-InfrastructureDefinitions)) { foreach ($definition in (Get-InfrastructureDefinitions)) {
Wait-ContainerStatus -ContainerDefinition $definition Wait-ContainerStatus -ContainerDefinition $definition
} }
Ensure-KafkaTopics
}
function Ensure-KafkaTopics {
Assert-ToolExists -Name "docker"
foreach ($topic in (Get-KafkaTopicDefinitions)) {
$arguments = @(
"exec",
"smartflow-kafka",
"/opt/kafka/bin/kafka-topics.sh",
"--bootstrap-server",
"localhost:9092",
"--create",
"--if-not-exists",
"--topic",
$topic,
"--partitions",
"3",
"--replication-factor",
"1"
)
Invoke-ExternalCommand -FilePath "docker" -Arguments $arguments -WorkingDirectory $RepoRoot
}
} }
function Stop-BackendInfrastructure { function Stop-BackendInfrastructure {

View File

@@ -2,6 +2,8 @@ package feedbacklocate
import ( import (
"context" "context"
"crypto/sha1"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"log" "log"
@@ -102,8 +104,9 @@ func (s *Service) Resolve(ctx context.Context, req Request) (Result, error) {
} }
messages := llmservice.BuildSystemUserMessages(strings.TrimSpace(locateSystemPrompt), nil, userPrompt) messages := llmservice.BuildSystemUserMessages(strings.TrimSpace(locateSystemPrompt), nil, userPrompt)
invokeCtx := llmservice.WithBillingContext(ctx, buildFeedbackLocateBillingContext(req))
resp, rawResult, err := llmservice.GenerateJSON[llmResponse]( resp, rawResult, err := llmservice.GenerateJSON[llmResponse](
ctx, invokeCtx,
s.client, s.client,
messages, messages,
llmservice.GenerateOptions{ llmservice.GenerateOptions{
@@ -365,3 +368,21 @@ func minInt(left, right int) int {
} }
return right return right
} }
func buildFeedbackLocateBillingContext(req Request) llmservice.BillingContext {
if req.UserID <= 0 {
return llmservice.BillingContext{
Scene: "active_scheduler_feedback_locate",
ModelAlias: "active_scheduler_feedback_locate",
}
}
sum := sha1.Sum([]byte(strings.TrimSpace(req.UserMessage) + "|" + strings.TrimSpace(req.PendingQuestion)))
requestID := fmt.Sprintf("active_scheduler_feedback_locate:%d:%s", req.UserID, hex.EncodeToString(sum[:]))
return llmservice.BillingContext{
UserID: uint64(req.UserID),
EventID: requestID,
Scene: "active_scheduler_feedback_locate",
RequestID: requestID,
ModelAlias: "active_scheduler_feedback_locate",
}
}

View File

@@ -75,8 +75,9 @@ func (s *Service) Select(ctx context.Context, req SelectRequest) (Result, error)
nil, nil,
userPrompt, userPrompt,
) )
invokeCtx := llmservice.WithBillingContext(ctx, buildSelectionBillingContext(req))
resp, rawResult, err := llmservice.GenerateJSON[llmSelectionResponse]( resp, rawResult, err := llmservice.GenerateJSON[llmSelectionResponse](
ctx, invokeCtx,
s.client, s.client,
messages, messages,
llmservice.GenerateOptions{ llmservice.GenerateOptions{
@@ -294,6 +295,26 @@ func (s *Service) now() time.Time {
return s.clock() return s.clock()
} }
func buildSelectionBillingContext(req SelectRequest) llmservice.BillingContext {
if req.ActiveContext == nil {
return llmservice.BillingContext{
Scene: "active_scheduler_select",
ModelAlias: "active_scheduler_select",
}
}
traceID := strings.TrimSpace(req.ActiveContext.Trace.TraceID)
if traceID == "" {
traceID = fmt.Sprintf("active_scheduler_select:%d:%s", req.ActiveContext.User.UserID, strings.TrimSpace(req.ActiveContext.Trigger.TriggerID))
}
return llmservice.BillingContext{
UserID: uint64(req.ActiveContext.User.UserID),
EventID: traceID,
Scene: "active_scheduler_select",
RequestID: traceID,
ModelAlias: "active_scheduler_select",
}
}
func (r Result) String() string { func (r Result) String() string {
return fmt.Sprintf("active_scheduler_selection(action=%s, selected=%s, fallback=%t)", return fmt.Sprintf("active_scheduler_selection(action=%s, selected=%s, fallback=%t)",
r.Action, r.Action,

View File

@@ -14,6 +14,7 @@ import (
agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream" agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream"
agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools" agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools"
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule" schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -65,6 +66,13 @@ func (s *AgentService) runAgentGraph(
// 1. 规范会话 ID 和模型选择。 // 1. 规范会话 ID 和模型选择。
chatID = normalizeConversationID(chatID) chatID = normalizeConversationID(chatID)
_, resolvedModelName := s.pickChatModel(modelName) _, resolvedModelName := s.pickChatModel(modelName)
requestCtx = llmservice.WithBillingContext(requestCtx, llmservice.BillingContext{
UserID: uint64(userID),
Scene: "agent_chat",
RequestID: strings.TrimSpace(traceID),
ConversationID: chatID,
ModelAlias: strings.TrimSpace(resolvedModelName),
})
// 2. 确保会话存在(优先缓存,必要时回源 DB // 2. 确保会话存在(优先缓存,必要时回源 DB
result, err := s.agentCache.GetConversationStatus(requestCtx, chatID) result, err := s.agentCache.GetConversationStatus(requestCtx, chatID)
@@ -543,36 +551,17 @@ func (s *AgentService) persistNewAgentConversationMessage(
// placement普通时段放置的任务全部被丢弃。 // placement普通时段放置的任务全部被丢弃。
// 正确做法:使用第一个返回值 []HybridScheduleEntry过滤 Status="suggested" 且 TaskItemID>0 的条目, // 正确做法:使用第一个返回值 []HybridScheduleEntry过滤 Status="suggested" 且 TaskItemID>0 的条目,
// 这样嵌入和非嵌入的粗排结果都能正确写入 ScheduleState。 // 这样嵌入和非嵌入的粗排结果都能正确写入 ScheduleState。
// adjustAgentRequestTokenUsage 负责把本轮 graph 的请求级 token 一次性回写到账本 // adjustAgentRequestTokenUsage 保留为迁移期兼容空实现
// //
// 说明: // 说明:
// 1. agent 逐条可见消息都按 0 token 落库,最终统一在这里补记整轮消耗 // 1. Credit 计费已切到独立 LLM 服务出口,这里不再回写旧 token 账本
// 2. 如果启用了 outbox就沿用异步 token 调整事件,保持写账口径一致 // 2. 会话级 tokens_total 仍由聊天历史持久化自己记录,不需要在这里二次补写
// 3. 该步骤属于请求收尾,不应反过来打断用户已看到的回复 // 3. 先保留方法壳,避免同轮大面积改调用点
func (s *AgentService) adjustAgentRequestTokenUsage(ctx context.Context, userID int, chatID string, deltaTokens int) { func (s *AgentService) adjustAgentRequestTokenUsage(ctx context.Context, userID int, chatID string, deltaTokens int) {
if s == nil || userID <= 0 || strings.TrimSpace(chatID) == "" || deltaTokens <= 0 { _ = ctx
return _ = userID
} _ = chatID
if ctx == nil { _ = deltaTokens
ctx = context.Background()
}
if s.eventPublisher != nil {
if err := eventsvc.PublishChatTokenUsageAdjustRequested(ctx, s.eventPublisher, model.ChatTokenUsageAdjustPayload{
UserID: userID,
ConversationID: chatID,
TokensDelta: deltaTokens,
Reason: "new_agent_request",
TriggeredAt: time.Now(),
}); err != nil {
log.Printf("写入 agent 请求级 token 调整事件失败 chat=%s tokens=%d err=%v", chatID, deltaTokens, err)
}
return
}
if err := s.repo.AdjustTokenUsage(ctx, userID, chatID, deltaTokens, ""); err != nil {
log.Printf("同步写入 agent 请求级 token 调整失败 chat=%s tokens=%d err=%v", chatID, deltaTokens, err)
}
} }
func (s *AgentService) makeRoughBuildFunc() agentmodel.RoughBuildFunc { func (s *AgentService) makeRoughBuildFunc() agentmodel.RoughBuildFunc {

View File

@@ -9,7 +9,6 @@ import (
"unicode/utf8" "unicode/utf8"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm" llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
eventsvc "github.com/LoveLosita/smartflow/backend/services/runtime/eventsvc"
"github.com/LoveLosita/smartflow/backend/services/runtime/model" "github.com/LoveLosita/smartflow/backend/services/runtime/model"
"github.com/LoveLosita/smartflow/backend/shared/respond" "github.com/LoveLosita/smartflow/backend/shared/respond"
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
@@ -202,27 +201,10 @@ func (s *AgentService) ensureConversationTitleAsync(userID int, chatID string) {
return return
} }
// 4.1 标题生成成功后,把本次异步模型 token 记账: // 4.1 标题生成的模型消耗不再走旧 token 账本。
// 4.1.1 启用 outbox 时走 adjust 事件,异步可靠入账 // 4.1.1 当前 Credit 计费统一由独立 LLM 服务出口处理
// 4.1.2 未启用 outbox 时走同步兜底,直接更新账本 // 4.1.2 这里只保留 titleTokens 变量,避免同轮继续改动模型返回签名
if titleTokens > 0 { _ = titleTokens
if s.eventPublisher != nil {
publishErr := eventsvc.PublishChatTokenUsageAdjustRequested(ctx, s.eventPublisher, model.ChatTokenUsageAdjustPayload{
UserID: userID,
ConversationID: chatID,
TokensDelta: titleTokens,
Reason: conversationTitleTokenAdjustReason,
TriggeredAt: time.Now(),
})
if publishErr != nil {
log.Printf("异步标题 token 记账事件发布失败 chat=%s tokens=%d err=%v", chatID, titleTokens, publishErr)
}
} else {
if adjustErr := s.repo.AdjustTokenUsage(ctx, userID, chatID, titleTokens, ""); adjustErr != nil {
log.Printf("异步标题 token 同步记账失败 chat=%s tokens=%d err=%v", chatID, titleTokens, adjustErr)
}
}
}
// 5. 只在标题仍为空时写入,保证并发幂等。 // 5. 只在标题仍为空时写入,保证并发幂等。
if err = s.repo.UpdateConversationTitleIfEmpty(ctx, userID, chatID, generated); err != nil { if err = s.repo.UpdateConversationTitleIfEmpty(ctx, userID, chatID, generated); err != nil {

View File

@@ -20,6 +20,7 @@ message JSONResponse {
} }
message CourseImageRequest { message CourseImageRequest {
uint64 user_id = 4;
string filename = 1; string filename = 1;
string mime_type = 2; string mime_type = 2;
bytes image_bytes = 3; bytes image_bytes = 3;

View File

@@ -70,6 +70,7 @@ func (h *Handler) ParseCourseImage(ctx context.Context, req *pb.CourseImageReque
return nil, err return nil, err
} }
draft, err := h.svc.ParseCourseTableImage(ctx, model.CourseImageParseRequest{ draft, err := h.svc.ParseCourseTableImage(ctx, model.CourseImageParseRequest{
UserID: int(req.UserId),
Filename: req.Filename, Filename: req.Filename,
MIMEType: req.MimeType, MIMEType: req.MimeType,
ImageBytes: req.ImageBytes, ImageBytes: req.ImageBytes,

View File

@@ -29,6 +29,7 @@ func (m *JSONResponse) String() string { return proto.CompactTextString(m) }
func (*JSONResponse) ProtoMessage() {} func (*JSONResponse) ProtoMessage() {}
type CourseImageRequest struct { type CourseImageRequest struct {
UserId uint64 `protobuf:"varint,4,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"` Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"`
MimeType string `protobuf:"bytes,2,opt,name=mime_type,json=mimeType,proto3" json:"mime_type,omitempty"` MimeType string `protobuf:"bytes,2,opt,name=mime_type,json=mimeType,proto3" json:"mime_type,omitempty"`
ImageBytes []byte `protobuf:"bytes,3,opt,name=image_bytes,json=imageBytes,proto3" json:"image_bytes,omitempty"` ImageBytes []byte `protobuf:"bytes,3,opt,name=image_bytes,json=imageBytes,proto3" json:"image_bytes,omitempty"`

View File

@@ -2,9 +2,12 @@ package sv
import ( import (
"context" "context"
"crypto/sha1"
"encoding/base64" "encoding/base64"
"encoding/hex"
"fmt" "fmt"
"log" "log"
"strconv"
"strings" "strings"
"time" "time"
@@ -70,7 +73,8 @@ func (ss *CourseService) ParseCourseTableImage(ctx context.Context, req model.Co
// 1. 课程表图片识别输出体量大,显式透传 max_output_tokens避免被默认值截断。 // 1. 课程表图片识别输出体量大,显式透传 max_output_tokens避免被默认值截断。
// 2. text_format 固定为 json_object降低输出混入解释文本导致解析失败的概率。 // 2. text_format 固定为 json_object降低输出混入解释文本导致解析失败的概率。
// 3. thinking 显式关闭,优先保证课程导入链路稳定性。 // 3. thinking 显式关闭,优先保证课程导入链路稳定性。
draft, rawResult, err := llmservice.GenerateArkResponsesJSON[model.CourseImageParseResponse](ctx, ss.courseImageResponsesClient, messages, llmservice.ArkResponsesOptions{ invokeCtx := llmservice.WithBillingContext(ctx, buildCourseImageBillingContext(normalizedReq, ss.courseImageModel))
draft, rawResult, err := llmservice.GenerateArkResponsesJSON[model.CourseImageParseResponse](invokeCtx, ss.courseImageResponsesClient, messages, llmservice.ArkResponsesOptions{
Temperature: courseImageParseTemperature, Temperature: courseImageParseTemperature,
MaxOutputTokens: ss.courseImageConfig.MaxTokens, MaxOutputTokens: ss.courseImageConfig.MaxTokens,
Thinking: llmservice.ThinkingModeDisabled, Thinking: llmservice.ThinkingModeDisabled,
@@ -226,3 +230,25 @@ func isCourseImageOutputTruncated(rawResult *llmservice.ArkResponsesResult) bool
return strings.EqualFold(strings.TrimSpace(rawResult.Status), "incomplete") && reason == "" return strings.EqualFold(strings.TrimSpace(rawResult.Status), "incomplete") && reason == ""
} }
func buildCourseImageBillingContext(req *model.CourseImageParseRequest, modelName string) llmservice.BillingContext {
if req == nil || req.UserID <= 0 {
return llmservice.BillingContext{
Scene: "course_image_parse",
ModelAlias: strings.TrimSpace(modelName),
}
}
// 1. 当前 course 导入链路尚未单独透传外层 request_id这里先用“用户 + 文件内容摘要”构造稳定请求键。
// 2. 这样同一张图片在同一请求链路内重试时event_id 保持稳定,便于后续扣费幂等。
// 3. 后续若网关统一注入 request_id可直接替换这里的兜底策略不影响业务语义。
sum := sha1.Sum(req.ImageBytes)
requestID := "course_image_parse:" + strconv.Itoa(req.UserID) + ":" + hex.EncodeToString(sum[:])
return llmservice.BillingContext{
UserID: uint64(req.UserID),
EventID: requestID,
Scene: "course_image_parse",
RequestID: requestID,
ModelAlias: strings.TrimSpace(modelName),
}
}

View File

@@ -46,8 +46,9 @@ type ArkResponsesResult struct {
// ArkResponsesClient 是 Ark SDK Responses 的统一模型出口。 // ArkResponsesClient 是 Ark SDK Responses 的统一模型出口。
type ArkResponsesClient struct { type ArkResponsesClient struct {
model string model string
client *arkruntime.Client client *arkruntime.Client
generateText func(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error)
} }
// NewArkResponsesClient 创建 Ark SDK Responses 客户端。 // NewArkResponsesClient 创建 Ark SDK Responses 客户端。
@@ -71,8 +72,28 @@ func NewArkResponsesClient(apiKey string, baseURL string, model string) *ArkResp
} }
} }
// NewArkResponsesClientWithFunc 使用外部注入的 GenerateText 能力构造客户端。
//
// 职责边界:
// 1. 供 llm zrpc remote client 和测试替身复用;
// 2. 这里只负责挂接统一函数签名,不负责远端连接初始化;
// 3. model 仅作为兼容字段保留,真正调用行为以 generateText 为准。
func NewArkResponsesClientWithFunc(model string, generateText func(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error)) *ArkResponsesClient {
if generateText == nil {
return nil
}
return &ArkResponsesClient{
model: strings.TrimSpace(model),
generateText: generateText,
}
}
// GenerateText 执行一次非流式 Responses 调用并提取文本。 // GenerateText 执行一次非流式 Responses 调用并提取文本。
func (c *ArkResponsesClient) GenerateText(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error) { func (c *ArkResponsesClient) GenerateText(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error) {
if c != nil && c.generateText != nil {
return c.generateText(ctx, messages, options)
}
req, err := c.buildRequest(messages, options) req, err := c.buildRequest(messages, options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -0,0 +1,76 @@
package llm
import (
"context"
"strings"
)
type billingContextKey struct{}
// BillingContext 描述一次 LLM 调用必需的计费上下文。
//
// 职责边界:
// 1. 只承载计费、审计、幂等所需的调用上下文;
// 2. 不承载 Temperature / MaxTokens 这类模型行为参数;
// 3. 不混入 prompt 文本,避免把业务输入复制成第二份协议。
type BillingContext struct {
UserID uint64 `json:"user_id"`
EventID string `json:"event_id"`
Scene string `json:"scene"`
RequestID string `json:"request_id"`
ConversationID string `json:"conversation_id"`
ModelAlias string `json:"model_alias"`
SkipCharge bool `json:"skip_charge"`
}
// Normalize 返回去空格后的 BillingContext 副本。
func (c BillingContext) Normalize() BillingContext {
c.EventID = strings.TrimSpace(c.EventID)
c.Scene = strings.TrimSpace(c.Scene)
c.RequestID = strings.TrimSpace(c.RequestID)
c.ConversationID = strings.TrimSpace(c.ConversationID)
c.ModelAlias = strings.TrimSpace(c.ModelAlias)
return c
}
// IsZero 判断是否完全没有注入计费上下文。
func (c BillingContext) IsZero() bool {
return c.UserID == 0 &&
strings.TrimSpace(c.EventID) == "" &&
strings.TrimSpace(c.Scene) == "" &&
strings.TrimSpace(c.RequestID) == "" &&
strings.TrimSpace(c.ConversationID) == "" &&
strings.TrimSpace(c.ModelAlias) == "" &&
!c.SkipCharge
}
// WithBillingContext 把计费上下文挂进调用 ctx。
//
// 设计说明:
// 1. 这次优先保持 GenerateText / GenerateJSON / Stream 原有签名基本不变;
// 2. 计费必填信息不再塞进 GenerateOptions.Metadata而是走强语义 ctx
// 3. 后续若统一切为显式 request struct可继续复用本结构体不改业务语义。
func WithBillingContext(ctx context.Context, billing BillingContext) context.Context {
if ctx == nil {
ctx = context.Background()
}
billing = billing.Normalize()
return context.WithValue(ctx, billingContextKey{}, billing)
}
// BillingContextFromContext 读取调用上下文中的计费信息。
func BillingContextFromContext(ctx context.Context) (BillingContext, bool) {
if ctx == nil {
return BillingContext{}, false
}
value := ctx.Value(billingContextKey{})
billing, ok := value.(BillingContext)
if !ok {
return BillingContext{}, false
}
billing = billing.Normalize()
if billing.IsZero() {
return BillingContext{}, false
}
return billing, true
}

View File

@@ -0,0 +1,68 @@
package llm
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
"github.com/cloudwego/eino/schema"
)
// EnsureTextBillingIdentity 负责在老调用点未显式提供 event_id 时兜底补稳定事件号。
//
// 兼容策略:
// 1. 只在 user_id、request_id 已具备且 event_id 为空时触发,避免覆盖显式幂等键;
// 2. stage 优先从 GenerateOptions.Metadata["stage"] 读取,兼容 agent 现有大量调用点;
// 3. 输入摘要使用 messages 的稳定哈希,确保同一 request_id 下不同阶段/不同输入不会串账。
func EnsureTextBillingIdentity(billing BillingContext, options llmcontracts.GenerateOptions, messages []*schema.Message) BillingContext {
return ensureBillingIdentity(billing, readStageFromMetadata(options.Metadata), messages)
}
// EnsureResponsesBillingIdentity 负责给 Responses 调用补稳定事件号。
func EnsureResponsesBillingIdentity(billing BillingContext, messages []llmcontracts.ResponsesMessage) BillingContext {
return ensureBillingIdentity(billing, "", messages)
}
func ensureBillingIdentity(billing BillingContext, stage string, payload any) BillingContext {
billing = billing.Normalize()
if billing.UserID == 0 || strings.TrimSpace(billing.RequestID) == "" || strings.TrimSpace(billing.EventID) != "" {
return billing
}
stage = strings.TrimSpace(stage)
billing.EventID = buildStableBillingEventID(billing.RequestID, stage, hashPayload(payload))
return billing
}
func readStageFromMetadata(metadata map[string]any) string {
if len(metadata) == 0 {
return ""
}
raw, ok := metadata["stage"]
if !ok || raw == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(raw))
}
func hashPayload(payload any) string {
raw, err := json.Marshal(payload)
if err != nil || len(raw) == 0 {
return ""
}
sum := sha1.Sum(raw)
return hex.EncodeToString(sum[:8])
}
func buildStableBillingEventID(requestID, stage, payloadDigest string) string {
requestID = strings.TrimSpace(requestID)
stage = strings.TrimSpace(stage)
payloadDigest = strings.TrimSpace(payloadDigest)
base := requestID + "|" + stage + "|" + payloadDigest
sum := sha1.Sum([]byte(base))
return requestID + ":" + hex.EncodeToString(sum[:8])
}

View File

@@ -0,0 +1,107 @@
package dao
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
const defaultCreditSnapshotTTL = 10 * time.Minute
// CreditBalanceSnapshot 是 LLM 准入守卫读取的余额快照。
type CreditBalanceSnapshot struct {
AvailableCredit int64 `json:"balance"`
UpdatedAt time.Time `json:"updated_at"`
}
// CacheDAO 只承载 LLM 服务私有的 Redis Key 读写。
type CacheDAO struct {
client *redis.Client
}
func NewCacheDAO(client *redis.Client) *CacheDAO {
return &CacheDAO{client: client}
}
func userCreditBalanceSnapshotKey(userID uint64) string {
return fmt.Sprintf("smartflow:credit_balance_snapshot:%d", userID)
}
func userCreditBlockedKey(userID uint64) string {
return fmt.Sprintf("smartflow:credit_blocked:%d", userID)
}
func (d *CacheDAO) GetUserCreditBalanceSnapshot(ctx context.Context, userID uint64) (*CreditBalanceSnapshot, bool, error) {
if d == nil || d.client == nil || userID == 0 {
return nil, false, nil
}
value, err := d.client.Get(ctx, userCreditBalanceSnapshotKey(userID)).Result()
if errors.Is(err, redis.Nil) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var snapshot CreditBalanceSnapshot
if err = json.Unmarshal([]byte(value), &snapshot); err != nil {
return nil, false, err
}
return &snapshot, true, nil
}
func (d *CacheDAO) SetUserCreditBalanceSnapshot(ctx context.Context, userID uint64, snapshot CreditBalanceSnapshot, ttl time.Duration) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
if ttl <= 0 {
ttl = defaultCreditSnapshotTTL
}
raw, err := json.Marshal(snapshot)
if err != nil {
return err
}
return d.client.Set(ctx, userCreditBalanceSnapshotKey(userID), raw, ttl).Err()
}
func (d *CacheDAO) DeleteUserCreditBalanceSnapshot(ctx context.Context, userID uint64) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
return d.client.Del(ctx, userCreditBalanceSnapshotKey(userID)).Err()
}
func (d *CacheDAO) IsUserCreditBlocked(ctx context.Context, userID uint64) (bool, error) {
if d == nil || d.client == nil || userID == 0 {
return false, nil
}
value, err := d.client.Get(ctx, userCreditBlockedKey(userID)).Result()
if errors.Is(err, redis.Nil) {
return false, nil
}
if err != nil {
return false, err
}
return value == "1", nil
}
func (d *CacheDAO) SetUserCreditBlocked(ctx context.Context, userID uint64, ttl time.Duration) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
return d.client.Set(ctx, userCreditBlockedKey(userID), "1", ttl).Err()
}
func (d *CacheDAO) DeleteUserCreditBlocked(ctx context.Context, userID uint64) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
return d.client.Del(ctx, userCreditBlockedKey(userID)).Err()
}

View File

@@ -0,0 +1,42 @@
package dao
import (
"fmt"
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
mysqlinfra "github.com/LoveLosita/smartflow/backend/shared/infra/mysql"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
"gorm.io/gorm"
)
// OpenDBFromConfig 负责打开 LLM 独立服务需要的数据库连接。
//
// 职责边界:
// 1. 只初始化通用 MySQL 连接并补齐 LLM 自己的 outbox 表;
// 2. 不负责启动 Kafka relay也不负责装配 Redis/模型客户端;
// 3. 当前阶段不额外声明业务私表,避免和主代理后续 Credit 表迁移交叉。
func OpenDBFromConfig() (*gorm.DB, error) {
db, err := mysqlinfra.OpenDBFromConfig()
if err != nil {
return nil, err
}
if err = autoMigrateLLMOutboxTable(db); err != nil {
return nil, err
}
return db, nil
}
func autoMigrateLLMOutboxTable(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("llm database is not initialized")
}
cfg, ok := outboxinfra.ResolveServiceConfig(outboxinfra.ServiceLLM)
if !ok {
return fmt.Errorf("resolve llm outbox config failed")
}
if err := db.Table(cfg.TableName).AutoMigrate(&model.AgentOutboxMessage{}); err != nil {
return fmt.Errorf("auto migrate llm outbox table failed for %s (%s): %w", cfg.Name, cfg.TableName, err)
}
return nil
}

View File

@@ -0,0 +1,53 @@
package dao
import (
"context"
"gorm.io/gorm"
)
const creditPriceRuleStatusActive = "active"
type CreditPriceRule struct {
ID uint64 `gorm:"column:id"`
Scene string `gorm:"column:scene"`
ProviderName string `gorm:"column:provider_name"`
ModelName string `gorm:"column:model_name"`
InputPriceMicros int64 `gorm:"column:input_price_micros"`
OutputPriceMicros int64 `gorm:"column:output_price_micros"`
CachedPriceMicros int64 `gorm:"column:cached_price_micros"`
ReasoningPriceMicros int64 `gorm:"column:reasoning_price_micros"`
CreditPerYuan int64 `gorm:"column:credit_per_yuan"`
Status string `gorm:"column:status"`
Priority int `gorm:"column:priority"`
Description string `gorm:"column:description"`
}
func (CreditPriceRule) TableName() string {
return "credit_price_rules"
}
type PriceRuleDAO struct {
db *gorm.DB
}
func NewPriceRuleDAO(db *gorm.DB) *PriceRuleDAO {
return &PriceRuleDAO{db: db}
}
func (d *PriceRuleDAO) ListActiveRules(ctx context.Context) ([]CreditPriceRule, error) {
if d == nil || d.db == nil {
return nil, nil
}
var rules []CreditPriceRule
err := d.db.WithContext(ctx).
Model(&CreditPriceRule{}).
Where("status = ?", creditPriceRuleStatusActive).
Order("priority DESC, id ASC").
Find(&rules).Error
if err != nil {
return nil, err
}
return rules, nil
}

View File

@@ -0,0 +1,152 @@
package llm
import (
"context"
"errors"
"log"
"time"
llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
)
var (
ErrRuntimeServiceNotReady = errors.New("llm runtime service dependency not initialized")
ErrUnsupportedModelAlias = errors.New("llm model alias is unsupported")
ErrCreditBalanceBlocked = errors.New("credit balance is insufficient")
)
const (
defaultCreditBlockedTTL = 5 * time.Minute
defaultCreditSnapshotTimeout = time.Second
)
type CreditBalanceSnapshotProvider interface {
GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error)
}
// CreditBalanceGuard 负责在真正发起 LLM 调用前做一次轻量余额准入。
type CreditBalanceGuard struct {
cacheDAO *llmdao.CacheDAO
snapshotProvider CreditBalanceSnapshotProvider
blockTTL time.Duration
snapshotTimeout time.Duration
}
type CreditBalanceGuardOptions struct {
CacheDAO *llmdao.CacheDAO
SnapshotProvider CreditBalanceSnapshotProvider
BlockTTL time.Duration
SnapshotTimeout time.Duration
}
func NewCreditBalanceGuard(opts CreditBalanceGuardOptions) *CreditBalanceGuard {
blockTTL := opts.BlockTTL
if blockTTL <= 0 {
blockTTL = defaultCreditBlockedTTL
}
snapshotTimeout := opts.SnapshotTimeout
if snapshotTimeout <= 0 {
snapshotTimeout = defaultCreditSnapshotTimeout
}
return &CreditBalanceGuard{
cacheDAO: opts.CacheDAO,
snapshotProvider: opts.SnapshotProvider,
blockTTL: blockTTL,
snapshotTimeout: snapshotTimeout,
}
}
// Guard 只做 Redis 快照级别的 fail-open 准入检查。
//
// 设计说明:
// 1. 先查 blocked key命中则直接拒绝避免每次都回源余额快照
// 2. 再查余额快照;若快照明确余额 <= 0则写 blocked key 并拒绝;
// 3. Redis 读失败或快照缺失时保持放行,避免基础设施抖动直接阻断全部 LLM 调用。
func (g *CreditBalanceGuard) Guard(ctx context.Context, billing BillingContext) error {
if g == nil || g.cacheDAO == nil {
return nil
}
billing = billing.Normalize()
if billing.UserID == 0 || billing.SkipCharge {
return nil
}
blocked, err := g.cacheDAO.IsUserCreditBlocked(ctx, billing.UserID)
if err != nil {
log.Printf("llm credit guard read blocked key failed: user_id=%d err=%v", billing.UserID, err)
return nil
}
if blocked {
return ErrCreditBalanceBlocked
}
snapshot, found, err := g.cacheDAO.GetUserCreditBalanceSnapshot(ctx, billing.UserID)
if err != nil {
log.Printf("llm credit guard read balance snapshot failed: user_id=%d err=%v", billing.UserID, err)
return nil
}
if !found || snapshot == nil {
snapshot, err = g.fetchSnapshot(ctx, billing.UserID)
if err != nil {
log.Printf("llm credit guard fetch balance snapshot failed: user_id=%d err=%v", billing.UserID, err)
return nil
}
}
if snapshot == nil {
return nil
}
if snapshot.AvailableCredit > 0 {
return nil
}
if err = g.cacheDAO.SetUserCreditBlocked(ctx, billing.UserID, g.blockTTL); err != nil {
log.Printf("llm credit guard set blocked key failed: user_id=%d err=%v", billing.UserID, err)
}
return ErrCreditBalanceBlocked
}
func (g *CreditBalanceGuard) fetchSnapshot(ctx context.Context, userID uint64) (*llmdao.CreditBalanceSnapshot, error) {
if g == nil || g.snapshotProvider == nil || userID == 0 {
return nil, nil
}
fetchCtx := ctx
if fetchCtx == nil {
fetchCtx = context.Background()
}
if g.snapshotTimeout > 0 {
var cancel context.CancelFunc
fetchCtx, cancel = context.WithTimeout(context.WithoutCancel(fetchCtx), g.snapshotTimeout)
defer cancel()
}
snapshotView, err := g.snapshotProvider.GetCreditBalanceSnapshot(fetchCtx, userID)
if err != nil {
return nil, err
}
if snapshotView == nil {
return nil, nil
}
snapshot := &llmdao.CreditBalanceSnapshot{
AvailableCredit: snapshotView.Balance,
UpdatedAt: time.Now(),
}
if err = g.cacheDAO.SetUserCreditBalanceSnapshot(fetchCtx, userID, *snapshot, 0); err != nil {
log.Printf("llm credit guard backfill balance snapshot failed: user_id=%d err=%v", userID, err)
}
if snapshotView.IsBlocked || snapshotView.Balance <= 0 {
if err = g.cacheDAO.SetUserCreditBlocked(fetchCtx, userID, g.blockTTL); err != nil {
log.Printf("llm credit guard backfill blocked key failed: user_id=%d err=%v", userID, err)
}
return snapshot, nil
}
if err = g.cacheDAO.DeleteUserCreditBlocked(fetchCtx, userID); err != nil {
log.Printf("llm credit guard clear blocked key failed: user_id=%d err=%v", userID, err)
}
return snapshot, nil
}

View File

@@ -0,0 +1,211 @@
package llm
import (
"context"
"log"
"strings"
"time"
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
)
const (
defaultOutboxMaxRetry = 20
defaultBillingPersistWindow = 2 * time.Second
)
// ChargeRecorder 负责把一次已完成的 LLM usage 写入 LLM 自己的 outbox。
type ChargeRecorder struct {
publisher *outboxinfra.RepositoryPublisher
providerName string
pricing UsagePricingResolver
}
type ChargeRecorderOptions struct {
Repo *outboxinfra.Repository
MaxRetry int
ProviderName string
Pricing UsagePricingResolver
}
func NewChargeRecorder(opts ChargeRecorderOptions) (*ChargeRecorder, error) {
if err := RegisterCreditChargeRoute(); err != nil {
return nil, err
}
providerName := strings.TrimSpace(opts.ProviderName)
if providerName == "" {
providerName = llmcontracts.ProviderNameArk
}
if opts.Repo == nil {
return &ChargeRecorder{providerName: providerName}, nil
}
maxRetry := opts.MaxRetry
if maxRetry <= 0 {
maxRetry = defaultOutboxMaxRetry
}
return &ChargeRecorder{
// 1. 当前 outbox infra 仍是“由归属服务自己 dispatch + consume 自己的 outbox”模型。
// 2. 因此这里必须让 Repository 按事件归属把 credit 事件写进 token-store 的 outbox
// 不能再强绑到 llm 自己的 route否则消息只会停在 published 而无人消费。
publisher: outboxinfra.NewRepositoryPublisher(opts.Repo, maxRetry),
providerName: providerName,
pricing: opts.Pricing,
}, nil
}
func RegisterCreditChargeRoute() error {
return outboxinfra.RegisterEventService(sharedevents.CreditChargeRequestedEventType, outboxinfra.ServiceTokenStore)
}
func (r *ChargeRecorder) RecordTextUsage(ctx context.Context, billing BillingContext, modelAlias, modelName, defaultScene string, usage *schema.TokenUsage) error {
if usage == nil {
return nil
}
return r.publish(ctx, billing, publishUsageInput{
ModelAlias: modelAlias,
ModelName: modelName,
DefaultScene: defaultScene,
InputTokens: int64(usage.PromptTokens),
OutputTokens: int64(usage.CompletionTokens),
CachedTokens: int64(usage.PromptTokenDetails.CachedTokens),
ReasoningTokens: int64(usage.CompletionTokensDetails.ReasoningTokens),
TotalTokens: int64(usage.TotalTokens),
})
}
func (r *ChargeRecorder) RecordResponsesUsage(ctx context.Context, billing BillingContext, modelAlias, modelName, defaultScene string, usage *ArkResponsesUsage) error {
if usage == nil {
return nil
}
return r.publish(ctx, billing, publishUsageInput{
ModelAlias: modelAlias,
ModelName: modelName,
DefaultScene: defaultScene,
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
TotalTokens: usage.TotalTokens,
})
}
type publishUsageInput struct {
ModelAlias string
ModelName string
DefaultScene string
InputTokens int64
OutputTokens int64
CachedTokens int64
ReasoningTokens int64
TotalTokens int64
}
func (r *ChargeRecorder) publish(ctx context.Context, billing BillingContext, input publishUsageInput) error {
if r == nil || r.publisher == nil {
return nil
}
billing = billing.Normalize()
if billing.UserID == 0 {
return nil
}
eventID := firstNonEmptyString(strings.TrimSpace(billing.EventID), uuid.NewString())
requestID := firstNonEmptyString(strings.TrimSpace(billing.RequestID), eventID)
scene := firstNonEmptyString(strings.TrimSpace(billing.Scene), strings.TrimSpace(input.DefaultScene))
modelAlias := firstNonEmptyString(strings.TrimSpace(billing.ModelAlias), strings.TrimSpace(input.ModelAlias))
modelName := firstNonEmptyString(strings.TrimSpace(input.ModelName), modelAlias)
totalTokens := input.TotalTokens
if totalTokens <= 0 {
totalTokens = input.InputTokens + input.OutputTokens
}
payload := sharedevents.CreditChargeRequestedPayload{
EventID: eventID,
UserID: billing.UserID,
Scene: scene,
RequestID: requestID,
ConversationID: strings.TrimSpace(billing.ConversationID),
ModelAlias: modelAlias,
ProviderName: r.providerName,
ModelName: modelName,
InputTokens: input.InputTokens,
OutputTokens: input.OutputTokens,
CachedTokens: input.CachedTokens,
ReasoningTokens: input.ReasoningTokens,
TotalTokens: totalTokens,
RMBCostMicros: 0,
CreditCost: 0,
TriggeredAt: time.Now(),
SkipCharge: billing.SkipCharge,
}
if !billing.SkipCharge {
quote, err := r.resolvePriceQuote(ctx, payload)
if err != nil {
log.Printf("llm price quote resolve failed: event_id=%s user_id=%d err=%v", payload.EventID, payload.UserID, err)
} else {
payload.RMBCostMicros = quote.RMBCostMicros
payload.CreditCost = quote.CreditCost
}
}
if err := payload.Validate(); err != nil {
return err
}
recordCtx, cancel := detachedBillingContext(ctx)
defer cancel()
return r.publisher.Publish(recordCtx, outboxinfra.PublishRequest{
EventID: payload.EventID,
EventType: sharedevents.CreditChargeRequestedEventType,
EventVersion: sharedevents.CreditChargeEventVersion,
MessageKey: payload.MessageKey(),
AggregateID: payload.AggregateID(),
Payload: payload,
})
}
func (r *ChargeRecorder) resolvePriceQuote(ctx context.Context, payload sharedevents.CreditChargeRequestedPayload) (UsagePriceQuote, error) {
if r == nil || r.pricing == nil {
return UsagePriceQuote{}, nil
}
return r.pricing.Resolve(ctx, UsagePricingInput{
Scene: payload.Scene,
ProviderName: payload.ProviderName,
ModelName: payload.ModelName,
InputTokens: payload.InputTokens,
OutputTokens: payload.OutputTokens,
CachedTokens: payload.CachedTokens,
ReasoningTokens: payload.ReasoningTokens,
})
}
func detachedBillingContext(ctx context.Context) (context.Context, context.CancelFunc) {
base := context.Background()
if ctx != nil {
base = context.WithoutCancel(ctx)
}
return context.WithTimeout(base, defaultBillingPersistWindow)
}
func logChargeRecordError(scene string, err error) {
if err == nil {
return
}
log.Printf("llm charge record failed: scene=%s err=%v", strings.TrimSpace(scene), err)
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}

View File

@@ -0,0 +1,207 @@
package llm
import (
"context"
"strings"
"sync"
"time"
llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao"
)
const (
defaultPriceRuleCacheTTL = time.Minute
tokenPriceScalePer1K = int64(1000)
rmbMicrosPerYuan = int64(1_000_000)
)
type UsagePricingInput struct {
Scene string
ProviderName string
ModelName string
InputTokens int64
OutputTokens int64
CachedTokens int64
ReasoningTokens int64
}
type UsagePriceQuote struct {
RuleID uint64
RMBCostMicros int64
CreditCost int64
MatchedScene string
MatchedProvider string
MatchedModel string
}
type UsagePricingResolver interface {
Resolve(ctx context.Context, input UsagePricingInput) (UsagePriceQuote, error)
}
type CreditPriceResolverOptions struct {
DAO *llmdao.PriceRuleDAO
CacheTTL time.Duration
}
type CreditPriceResolver struct {
dao *llmdao.PriceRuleDAO
cacheTTL time.Duration
mu sync.RWMutex
cachedAt time.Time
cachedSet []llmdao.CreditPriceRule
}
func NewCreditPriceResolver(opts CreditPriceResolverOptions) *CreditPriceResolver {
cacheTTL := opts.CacheTTL
if cacheTTL <= 0 {
cacheTTL = defaultPriceRuleCacheTTL
}
return &CreditPriceResolver{
dao: opts.DAO,
cacheTTL: cacheTTL,
}
}
func (r *CreditPriceResolver) Resolve(ctx context.Context, input UsagePricingInput) (UsagePriceQuote, error) {
if r == nil || r.dao == nil {
return UsagePriceQuote{}, nil
}
rules, err := r.loadRules(ctx)
if err != nil {
return UsagePriceQuote{}, err
}
if len(rules) == 0 {
return UsagePriceQuote{}, nil
}
scene := strings.TrimSpace(input.Scene)
providerName := strings.TrimSpace(input.ProviderName)
modelName := strings.TrimSpace(input.ModelName)
for _, rule := range rules {
if !matchesPriceRuleField(rule.Scene, scene) {
continue
}
if !matchesPriceRuleField(rule.ProviderName, providerName) {
continue
}
if !matchesPriceRuleField(rule.ModelName, modelName) {
continue
}
return quoteUsagePrice(rule, input), nil
}
return UsagePriceQuote{}, nil
}
func (r *CreditPriceResolver) loadRules(ctx context.Context) ([]llmdao.CreditPriceRule, error) {
now := time.Now()
r.mu.RLock()
if len(r.cachedSet) > 0 && now.Sub(r.cachedAt) < r.cacheTTL {
rules := clonePriceRules(r.cachedSet)
r.mu.RUnlock()
return rules, nil
}
r.mu.RUnlock()
r.mu.Lock()
defer r.mu.Unlock()
if len(r.cachedSet) > 0 && now.Sub(r.cachedAt) < r.cacheTTL {
return clonePriceRules(r.cachedSet), nil
}
rules, err := r.dao.ListActiveRules(ctx)
if err != nil {
return nil, err
}
r.cachedSet = clonePriceRules(rules)
r.cachedAt = now
return clonePriceRules(r.cachedSet), nil
}
func clonePriceRules(input []llmdao.CreditPriceRule) []llmdao.CreditPriceRule {
if len(input) == 0 {
return nil
}
output := make([]llmdao.CreditPriceRule, len(input))
copy(output, input)
return output
}
func matchesPriceRuleField(ruleValue string, actual string) bool {
ruleValue = strings.TrimSpace(ruleValue)
actual = strings.TrimSpace(actual)
if ruleValue == "" || ruleValue == "*" {
return true
}
return strings.EqualFold(ruleValue, actual)
}
func quoteUsagePrice(rule llmdao.CreditPriceRule, input UsagePricingInput) UsagePriceQuote {
inputTokens := maxInt64(input.InputTokens, 0)
outputTokens := maxInt64(input.OutputTokens, 0)
cachedTokens := clampInt64(input.CachedTokens, 0, inputTokens)
reasoningTokens := clampInt64(input.ReasoningTokens, 0, outputTokens)
nonCachedInputTokens := inputTokens - cachedTokens
nonReasoningOutputTokens := outputTokens - reasoningTokens
cachedPriceMicros := rule.CachedPriceMicros
if cachedPriceMicros <= 0 {
cachedPriceMicros = rule.InputPriceMicros
}
reasoningPriceMicros := rule.ReasoningPriceMicros
if reasoningPriceMicros <= 0 {
reasoningPriceMicros = rule.OutputPriceMicros
}
totalMicrosScaled := nonCachedInputTokens*maxInt64(rule.InputPriceMicros, 0) +
cachedTokens*maxInt64(cachedPriceMicros, 0) +
nonReasoningOutputTokens*maxInt64(rule.OutputPriceMicros, 0) +
reasoningTokens*maxInt64(reasoningPriceMicros, 0)
rmbCostMicros := ceilDivInt64(totalMicrosScaled, tokenPriceScalePer1K)
creditCost := int64(0)
if rmbCostMicros > 0 && rule.CreditPerYuan > 0 {
creditCost = ceilDivInt64(rmbCostMicros*rule.CreditPerYuan, rmbMicrosPerYuan)
}
return UsagePriceQuote{
RuleID: rule.ID,
RMBCostMicros: rmbCostMicros,
CreditCost: creditCost,
MatchedScene: strings.TrimSpace(rule.Scene),
MatchedProvider: strings.TrimSpace(rule.ProviderName),
MatchedModel: strings.TrimSpace(rule.ModelName),
}
}
func ceilDivInt64(numerator int64, denominator int64) int64 {
if numerator <= 0 || denominator <= 0 {
return 0
}
return (numerator + denominator - 1) / denominator
}
func clampInt64(value int64, minValue int64, maxValue int64) int64 {
if value < minValue {
return minValue
}
if value > maxValue {
return maxValue
}
return value
}
func maxInt64(value int64, minValue int64) int64 {
if value < minValue {
return minValue
}
return value
}

View File

@@ -0,0 +1,71 @@
package rpc
import (
"errors"
"log"
"strings"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
"github.com/LoveLosita/smartflow/backend/shared/respond"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const llmErrorDomain = "smartflow.llm"
func grpcErrorFromServiceError(err error) error {
if err == nil {
return nil
}
var resp respond.Response
if errors.As(err, &resp) {
return grpcErrorFromResponse(resp)
}
switch {
case errors.Is(err, llmservice.ErrUnsupportedModelAlias):
return status.Error(codes.InvalidArgument, err.Error())
case errors.Is(err, llmservice.ErrCreditBalanceBlocked):
return status.Error(codes.ResourceExhausted, err.Error())
case errors.Is(err, llmservice.ErrRuntimeServiceNotReady):
return status.Error(codes.FailedPrecondition, err.Error())
}
log.Printf("llm rpc internal error: %v", err)
return status.Error(codes.Internal, "llm service internal error")
}
func grpcErrorFromResponse(resp respond.Response) error {
code := grpcCodeFromRespondStatus(resp.Status)
message := strings.TrimSpace(resp.Info)
if message == "" {
message = strings.TrimSpace(resp.Status)
}
st := status.New(code, message)
detail := &errdetails.ErrorInfo{
Domain: llmErrorDomain,
Reason: resp.Status,
Metadata: map[string]string{
"info": resp.Info,
},
}
withDetails, err := st.WithDetails(detail)
if err != nil {
return st.Err()
}
return withDetails.Err()
}
func grpcCodeFromRespondStatus(statusValue string) codes.Code {
switch strings.TrimSpace(statusValue) {
case respond.MissingParam.Status, respond.WrongParamType.Status:
return codes.InvalidArgument
}
if strings.HasPrefix(strings.TrimSpace(statusValue), "5") {
return codes.Internal
}
return codes.InvalidArgument
}

View File

@@ -0,0 +1,122 @@
package rpc
import (
"context"
"errors"
"io"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
type Handler struct {
UnimplementedLLMServer
svc *llmservice.RuntimeService
}
func NewHandler(svc *llmservice.RuntimeService) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Ping(ctx context.Context, req *llmcontracts.PingRequest) (*llmcontracts.PingResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
return &llmcontracts.PingResponse{}, nil
}
func (h *Handler) GenerateText(ctx context.Context, req *llmcontracts.TextRequest) (*llmcontracts.TextResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
result, err := h.svc.GenerateText(ctx, *req)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &llmcontracts.TextResponse{Result: llmserviceToContractTextResult(result)}, nil
}
func (h *Handler) StreamText(req *llmcontracts.StreamTextRequest, stream LLM_StreamTextServer) error {
if err := h.ensureReady(req); err != nil {
return err
}
reader, err := h.svc.StreamText(stream.Context(), *req)
if err != nil {
return grpcErrorFromServiceError(err)
}
if reader == nil {
return grpcErrorFromServiceError(llmservice.ErrRuntimeServiceNotReady)
}
defer reader.Close()
for {
message, recvErr := reader.Recv()
if recvErr != nil {
if errors.Is(recvErr, io.EOF) {
return nil
}
return grpcErrorFromServiceError(recvErr)
}
if message == nil {
continue
}
if err = stream.Send(&llmcontracts.StreamChunk{Message: message}); err != nil {
return err
}
}
}
func (h *Handler) GenerateResponsesText(ctx context.Context, req *llmcontracts.ResponsesRequest) (*llmcontracts.ResponsesResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
result, err := h.svc.GenerateResponsesText(ctx, *req)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &llmcontracts.ResponsesResponse{Result: llmserviceToContractResponsesResult(result)}, nil
}
func (h *Handler) ensureReady(req any) error {
if h == nil || h.svc == nil {
return grpcErrorFromServiceError(llmservice.ErrRuntimeServiceNotReady)
}
if req == nil {
return grpcErrorFromServiceError(respond.MissingParam)
}
return nil
}
func llmserviceToContractTextResult(result *llmservice.TextResult) *llmcontracts.TextResult {
if result == nil {
return nil
}
return &llmcontracts.TextResult{
Text: result.Text,
Usage: llmservice.CloneUsage(result.Usage),
FinishReason: result.FinishReason,
}
}
func llmserviceToContractResponsesResult(result *llmservice.ArkResponsesResult) *llmcontracts.ResponsesResult {
if result == nil {
return nil
}
output := &llmcontracts.ResponsesResult{
Text: result.Text,
Status: result.Status,
IncompleteReason: result.IncompleteReason,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
if result.Usage != nil {
output.Usage = &llmcontracts.ResponsesUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
TotalTokens: result.Usage.TotalTokens,
}
}
return output
}

View File

@@ -0,0 +1,38 @@
package rpc
import (
"encoding/json"
"google.golang.org/grpc"
"google.golang.org/grpc/encoding"
)
const jsonCodecName = "smartflow-json"
type jsonCodec struct{}
func init() {
encoding.RegisterCodec(jsonCodec{})
}
func (jsonCodec) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (jsonCodec) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func (jsonCodec) Name() string {
return jsonCodecName
}
// JSONCodecDialOption 负责让 zrpc client 按 JSON 编解码本服务请求体。
func JSONCodecDialOption() grpc.DialOption {
return grpc.WithDefaultCallOptions(grpc.ForceCodec(jsonCodec{}))
}
// JSONCodecServerOption 负责让 zrpc server 按 JSON 编解码本服务请求体。
func JSONCodecServerOption() grpc.ServerOption {
return grpc.ForceServerCodec(jsonCodec{})
}

View File

@@ -0,0 +1,19 @@
syntax = "proto3";
package smartflow.llm;
service LLM {
rpc Ping (PingRequest) returns (PingResponse);
rpc GenerateText (TextRequest) returns (TextResponse);
rpc StreamText (StreamTextRequest) returns (stream StreamChunk);
rpc GenerateResponsesText (ResponsesRequest) returns (ResponsesResponse);
}
message PingRequest {}
message PingResponse {}
message TextRequest {}
message TextResponse {}
message StreamTextRequest {}
message StreamChunk {}
message ResponsesRequest {}
message ResponsesResponse {}

View File

@@ -0,0 +1,55 @@
package rpc
import (
"errors"
"strings"
"time"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
"github.com/zeromicro/go-zero/core/service"
"github.com/zeromicro/go-zero/zrpc"
"google.golang.org/grpc"
)
const (
defaultListenOn = "0.0.0.0:9096"
defaultTimeout = 0
)
type ServerOptions struct {
ListenOn string
Timeout time.Duration
Service *llmservice.RuntimeService
}
// NewServer 负责创建 LLM 独立进程的最小 zrpc server。
func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) {
if opts.Service == nil {
return nil, "", errors.New("llm runtime service dependency not initialized")
}
listenOn := strings.TrimSpace(opts.ListenOn)
if listenOn == "" {
listenOn = defaultListenOn
}
timeout := opts.Timeout
if timeout < 0 {
timeout = defaultTimeout
}
server, err := zrpc.NewServer(zrpc.RpcServerConf{
ServiceConf: service.ServiceConf{
Name: "llm.rpc",
Mode: service.DevMode,
},
ListenOn: listenOn,
Timeout: int64(timeout / time.Millisecond),
}, func(grpcServer *grpc.Server) {
RegisterLLMServer(grpcServer, NewHandler(opts.Service))
})
if err != nil {
return nil, "", err
}
server.AddOptions(JSONCodecServerOption())
return server, listenOn, nil
}

View File

@@ -0,0 +1,195 @@
package rpc
import (
"context"
"io"
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
LLM_Ping_FullMethodName = "/smartflow.llm.LLM/Ping"
LLM_GenerateText_FullMethodName = "/smartflow.llm.LLM/GenerateText"
LLM_StreamText_FullMethodName = "/smartflow.llm.LLM/StreamText"
LLM_GenerateResponsesText_FullMethodName = "/smartflow.llm.LLM/GenerateResponsesText"
)
type LLMClient interface {
Ping(ctx context.Context, in *llmcontracts.PingRequest, opts ...grpc.CallOption) (*llmcontracts.PingResponse, error)
GenerateText(ctx context.Context, in *llmcontracts.TextRequest, opts ...grpc.CallOption) (*llmcontracts.TextResponse, error)
StreamText(ctx context.Context, in *llmcontracts.StreamTextRequest, opts ...grpc.CallOption) (LLM_StreamTextClient, error)
GenerateResponsesText(ctx context.Context, in *llmcontracts.ResponsesRequest, opts ...grpc.CallOption) (*llmcontracts.ResponsesResponse, error)
}
type llmClient struct {
cc grpc.ClientConnInterface
}
func NewLLMClient(cc grpc.ClientConnInterface) LLMClient {
return &llmClient{cc: cc}
}
func (c *llmClient) Ping(ctx context.Context, in *llmcontracts.PingRequest, opts ...grpc.CallOption) (*llmcontracts.PingResponse, error) {
out := new(llmcontracts.PingResponse)
err := c.cc.Invoke(ctx, LLM_Ping_FullMethodName, in, out, opts...)
return out, err
}
func (c *llmClient) GenerateText(ctx context.Context, in *llmcontracts.TextRequest, opts ...grpc.CallOption) (*llmcontracts.TextResponse, error) {
out := new(llmcontracts.TextResponse)
err := c.cc.Invoke(ctx, LLM_GenerateText_FullMethodName, in, out, opts...)
return out, err
}
func (c *llmClient) StreamText(ctx context.Context, in *llmcontracts.StreamTextRequest, opts ...grpc.CallOption) (LLM_StreamTextClient, error) {
stream, err := c.cc.NewStream(ctx, &LLM_ServiceDesc.Streams[0], LLM_StreamText_FullMethodName, opts...)
if err != nil {
return nil, err
}
client := &llmStreamTextClient{ClientStream: stream}
if err = client.SendMsg(in); err != nil {
return nil, err
}
if err = client.CloseSend(); err != nil {
return nil, err
}
return client, nil
}
func (c *llmClient) GenerateResponsesText(ctx context.Context, in *llmcontracts.ResponsesRequest, opts ...grpc.CallOption) (*llmcontracts.ResponsesResponse, error) {
out := new(llmcontracts.ResponsesResponse)
err := c.cc.Invoke(ctx, LLM_GenerateResponsesText_FullMethodName, in, out, opts...)
return out, err
}
type LLM_StreamTextClient interface {
Recv() (*llmcontracts.StreamChunk, error)
grpc.ClientStream
}
type llmStreamTextClient struct {
grpc.ClientStream
}
func (x *llmStreamTextClient) Recv() (*llmcontracts.StreamChunk, error) {
m := new(llmcontracts.StreamChunk)
if err := x.ClientStream.RecvMsg(m); err != nil {
if err == io.EOF {
return nil, err
}
return nil, err
}
return m, nil
}
type LLMServer interface {
Ping(context.Context, *llmcontracts.PingRequest) (*llmcontracts.PingResponse, error)
GenerateText(context.Context, *llmcontracts.TextRequest) (*llmcontracts.TextResponse, error)
StreamText(*llmcontracts.StreamTextRequest, LLM_StreamTextServer) error
GenerateResponsesText(context.Context, *llmcontracts.ResponsesRequest) (*llmcontracts.ResponsesResponse, error)
}
type UnimplementedLLMServer struct{}
func (UnimplementedLLMServer) Ping(context.Context, *llmcontracts.PingRequest) (*llmcontracts.PingResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
}
func (UnimplementedLLMServer) GenerateText(context.Context, *llmcontracts.TextRequest) (*llmcontracts.TextResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GenerateText not implemented")
}
func (UnimplementedLLMServer) StreamText(*llmcontracts.StreamTextRequest, LLM_StreamTextServer) error {
return status.Errorf(codes.Unimplemented, "method StreamText not implemented")
}
func (UnimplementedLLMServer) GenerateResponsesText(context.Context, *llmcontracts.ResponsesRequest) (*llmcontracts.ResponsesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GenerateResponsesText not implemented")
}
func RegisterLLMServer(s grpc.ServiceRegistrar, srv LLMServer) {
s.RegisterService(&LLM_ServiceDesc, srv)
}
type LLM_StreamTextServer interface {
Send(*llmcontracts.StreamChunk) error
grpc.ServerStream
}
type llmStreamTextServer struct {
grpc.ServerStream
}
func (x *llmStreamTextServer) Send(m *llmcontracts.StreamChunk) error {
return x.ServerStream.SendMsg(m)
}
func _LLM_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(llmcontracts.PingRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LLMServer).Ping(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: LLM_Ping_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LLMServer).Ping(ctx, req.(*llmcontracts.PingRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LLM_GenerateText_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(llmcontracts.TextRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LLMServer).GenerateText(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: LLM_GenerateText_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LLMServer).GenerateText(ctx, req.(*llmcontracts.TextRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LLM_GenerateResponsesText_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(llmcontracts.ResponsesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(LLMServer).GenerateResponsesText(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: LLM_GenerateResponsesText_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(LLMServer).GenerateResponsesText(ctx, req.(*llmcontracts.ResponsesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _LLM_StreamText_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(llmcontracts.StreamTextRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(LLMServer).StreamText(m, &llmStreamTextServer{ServerStream: stream})
}
var LLM_ServiceDesc = grpc.ServiceDesc{
ServiceName: "smartflow.llm.LLM",
HandlerType: (*LLMServer)(nil),
Methods: []grpc.MethodDesc{
{MethodName: "Ping", Handler: _LLM_Ping_Handler},
{MethodName: "GenerateText", Handler: _LLM_GenerateText_Handler},
{MethodName: "GenerateResponsesText", Handler: _LLM_GenerateResponsesText_Handler},
},
Streams: []grpc.StreamDesc{
{StreamName: "StreamText", Handler: _LLM_StreamText_Handler, ServerStreams: true},
},
Metadata: "services/llm/rpc/llm.proto",
}

View File

@@ -0,0 +1,315 @@
package llm
import (
"context"
"strings"
llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao"
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
"github.com/cloudwego/eino/schema"
)
// RuntimeService 是独立 LLM 进程对外暴露的业务门面。
//
// 职责边界:
// 1. 负责模型别名选择、BillingContext 注入、准入守卫与 outbox 写入;
// 2. 不负责 prompt 编排,调用方仍然直接传入 messages
// 3. 不负责价格换算细则,本轮先把 usage 事件稳定写入 outbox价格字段留给后续主代理接线。
type RuntimeService struct {
legacy *Service
textClients map[string]*Client
textModelNames map[string]string
responsesClient *ArkResponsesClient
responsesModel string
balanceGuard *CreditBalanceGuard
chargeRecorder *ChargeRecorder
defaultProvider string
}
type RuntimeServiceOptions struct {
LegacyService *Service
CacheDAO *llmdao.CacheDAO
PriceRuleDAO *llmdao.PriceRuleDAO
SnapshotProvider CreditBalanceSnapshotProvider
OutboxRepo *outboxinfra.Repository
OutboxMaxRetry int
ProviderName string
LiteModelName string
ProModelName string
MaxModelName string
CourseVisionModel string
}
func NewRuntimeService(opts RuntimeServiceOptions) (*RuntimeService, error) {
if opts.LegacyService == nil {
return nil, ErrRuntimeServiceNotReady
}
chargeRecorder, err := NewChargeRecorder(ChargeRecorderOptions{
Repo: opts.OutboxRepo,
MaxRetry: opts.OutboxMaxRetry,
ProviderName: opts.ProviderName,
Pricing: NewCreditPriceResolver(CreditPriceResolverOptions{DAO: opts.PriceRuleDAO}),
})
if err != nil {
return nil, err
}
return &RuntimeService{
legacy: opts.LegacyService,
textClients: map[string]*Client{
llmcontracts.ModelAliasLite: opts.LegacyService.LiteClient(),
llmcontracts.ModelAliasPro: opts.LegacyService.ProClient(),
llmcontracts.ModelAliasMax: opts.LegacyService.MaxClient(),
},
textModelNames: map[string]string{
llmcontracts.ModelAliasLite: strings.TrimSpace(opts.LiteModelName),
llmcontracts.ModelAliasPro: strings.TrimSpace(opts.ProModelName),
llmcontracts.ModelAliasMax: strings.TrimSpace(opts.MaxModelName),
},
responsesClient: opts.LegacyService.CourseImageResponsesClient(),
responsesModel: strings.TrimSpace(opts.CourseVisionModel),
balanceGuard: NewCreditBalanceGuard(CreditBalanceGuardOptions{
CacheDAO: opts.CacheDAO,
SnapshotProvider: opts.SnapshotProvider,
}),
chargeRecorder: chargeRecorder,
defaultProvider: firstNonEmptyString(strings.TrimSpace(opts.ProviderName), llmcontracts.ProviderNameArk),
}, nil
}
func (s *RuntimeService) LegacyService() *Service {
if s == nil {
return nil
}
return s.legacy
}
// GenerateText 负责处理一次非流式文本调用。
func (s *RuntimeService) GenerateText(ctx context.Context, req llmcontracts.TextRequest) (*TextResult, error) {
client, alias, modelName, err := s.resolveTextClient(req.ModelAlias)
if err != nil {
return nil, err
}
// 1. 先把跨进程 billing 副本还原回 ctx保持业务侧调用面不改签名。
// 2. 再做一次 Redis 快照级准入守卫;守卫失败直接短路,不继续发起模型调用。
// 3. 模型成功后同步写 LLM outbox写失败只打日志避免因为记账侧抖动反向打挂主链路。
ctx, billing := applyRequestBillingContext(ctx, req.Billing, alias)
billing = EnsureTextBillingIdentity(billing, req.Options, req.Messages)
if !billing.IsZero() {
ctx = WithBillingContext(ctx, billing)
}
if err = s.balanceGuard.Guard(ctx, billing); err != nil {
return nil, err
}
result, err := client.GenerateText(ctx, req.Messages, toServiceGenerateOptions(req.Options))
if err != nil {
return nil, err
}
logChargeRecordError("llm.text.generate", s.chargeRecorder.RecordTextUsage(ctx, billing, alias, modelName, "llm.text.generate", result.Usage))
return result, nil
}
// StreamText 负责处理一次流式文本调用。
func (s *RuntimeService) StreamText(ctx context.Context, req llmcontracts.StreamTextRequest) (StreamReader, error) {
client, alias, modelName, err := s.resolveTextClient(req.ModelAlias)
if err != nil {
return nil, err
}
ctx, billing := applyRequestBillingContext(ctx, req.Billing, alias)
billing = EnsureTextBillingIdentity(billing, req.Options, req.Messages)
if !billing.IsZero() {
ctx = WithBillingContext(ctx, billing)
}
if err = s.balanceGuard.Guard(ctx, billing); err != nil {
return nil, err
}
reader, err := client.Stream(ctx, req.Messages, toServiceGenerateOptions(req.Options))
if err != nil {
return nil, err
}
return NewUsageAccountingStreamReader(reader, func(usage *schema.TokenUsage) {
logChargeRecordError("llm.text.stream", s.chargeRecorder.RecordTextUsage(ctx, billing, alias, modelName, "llm.text.stream", usage))
}), nil
}
// GenerateResponsesText 负责处理课程图片解析使用的 Responses 文本调用。
func (s *RuntimeService) GenerateResponsesText(ctx context.Context, req llmcontracts.ResponsesRequest) (*ArkResponsesResult, error) {
client, alias, modelName, err := s.resolveResponsesClient(req.ModelAlias)
if err != nil {
return nil, err
}
ctx, billing := applyRequestBillingContext(ctx, req.Billing, alias)
billing = EnsureResponsesBillingIdentity(billing, req.Messages)
if !billing.IsZero() {
ctx = WithBillingContext(ctx, billing)
}
if err = s.balanceGuard.Guard(ctx, billing); err != nil {
return nil, err
}
result, err := client.GenerateText(ctx, toServiceResponsesMessages(req.Messages), toServiceResponsesOptions(req.Options))
if err != nil {
return nil, err
}
logChargeRecordError("llm.responses.generate", s.chargeRecorder.RecordResponsesUsage(ctx, billing, alias, modelName, "llm.responses.generate", result.Usage))
return result, nil
}
func (s *RuntimeService) resolveTextClient(modelAlias string) (*Client, string, string, error) {
if s == nil {
return nil, "", "", ErrRuntimeServiceNotReady
}
alias := llmcontracts.NormalizeModelAlias(modelAlias)
client, ok := s.textClients[alias]
if !ok {
return nil, alias, "", ErrUnsupportedModelAlias
}
if client == nil {
return nil, alias, "", ErrRuntimeServiceNotReady
}
return client, alias, firstNonEmptyString(s.textModelNames[alias], alias), nil
}
func (s *RuntimeService) resolveResponsesClient(modelAlias string) (*ArkResponsesClient, string, string, error) {
if s == nil || s.responsesClient == nil {
return nil, "", "", ErrRuntimeServiceNotReady
}
alias := strings.TrimSpace(modelAlias)
if alias == "" {
alias = llmcontracts.ModelAliasCourseImageResponses
}
if alias != llmcontracts.ModelAliasCourseImageResponses {
return nil, alias, "", ErrUnsupportedModelAlias
}
return s.responsesClient, alias, firstNonEmptyString(s.responsesModel, alias), nil
}
func applyRequestBillingContext(ctx context.Context, input *llmcontracts.BillingContext, modelAlias string) (context.Context, BillingContext) {
billing := BillingContext{}
if input != nil {
billing = BillingContext{
UserID: input.UserID,
EventID: input.EventID,
Scene: input.Scene,
RequestID: input.RequestID,
ConversationID: input.ConversationID,
ModelAlias: input.ModelAlias,
SkipCharge: input.SkipCharge,
}
}
if strings.TrimSpace(billing.ModelAlias) == "" {
billing.ModelAlias = strings.TrimSpace(modelAlias)
}
if billing.IsZero() {
return ctx, billing
}
return WithBillingContext(ctx, billing), billing
}
func toServiceGenerateOptions(input llmcontracts.GenerateOptions) GenerateOptions {
return GenerateOptions{
Temperature: input.Temperature,
MaxTokens: input.MaxTokens,
Thinking: ThinkingMode(strings.TrimSpace(input.Thinking)),
Metadata: input.Metadata,
}
}
func toServiceResponsesMessages(input []llmcontracts.ResponsesMessage) []ArkResponsesMessage {
if len(input) == 0 {
return nil
}
output := make([]ArkResponsesMessage, 0, len(input))
for _, item := range input {
output = append(output, ArkResponsesMessage{
Role: item.Role,
Text: item.Text,
ImageURL: item.ImageURL,
ImageDetail: item.ImageDetail,
})
}
return output
}
func toServiceResponsesOptions(input llmcontracts.ResponsesOptions) ArkResponsesOptions {
return ArkResponsesOptions{
Model: input.Model,
Temperature: input.Temperature,
MaxOutputTokens: input.MaxOutputTokens,
Thinking: ThinkingMode(strings.TrimSpace(input.Thinking)),
TextFormat: input.TextFormat,
}
}
func toContractTextResult(result *TextResult) *llmcontracts.TextResult {
if result == nil {
return nil
}
return &llmcontracts.TextResult{
Text: result.Text,
Usage: CloneUsage(result.Usage),
FinishReason: result.FinishReason,
}
}
func toContractResponsesResult(result *ArkResponsesResult) *llmcontracts.ResponsesResult {
if result == nil {
return nil
}
output := &llmcontracts.ResponsesResult{
Text: result.Text,
Status: result.Status,
IncompleteReason: result.IncompleteReason,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
if result.Usage != nil {
output.Usage = &llmcontracts.ResponsesUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
TotalTokens: result.Usage.TotalTokens,
}
}
return output
}
func toServiceTextResult(result *llmcontracts.TextResult) *TextResult {
if result == nil {
return nil
}
return &TextResult{
Text: result.Text,
Usage: CloneUsage(result.Usage),
FinishReason: result.FinishReason,
}
}
func toServiceResponsesResult(result *llmcontracts.ResponsesResult) *ArkResponsesResult {
if result == nil {
return nil
}
output := &ArkResponsesResult{
Text: result.Text,
Status: result.Status,
IncompleteReason: result.IncompleteReason,
ErrorCode: result.ErrorCode,
ErrorMessage: result.ErrorMessage,
}
if result.Usage != nil {
output.Usage = &ArkResponsesUsage{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
TotalTokens: result.Usage.TotalTokens,
}
}
return output
}

View File

@@ -35,6 +35,19 @@ type AgentModelClients struct {
Summary *Client Summary *Client
} }
// StaticClients 用于在不依赖 AIHub 的情况下直接注入已构造好的客户端。
//
// 职责边界:
// 1. 只负责把已经准备好的 client 聚合成 Service
// 2. 不负责选择 provider也不负责初始化远端 RPC 连接;
// 3. 供独立 llm zrpc client、测试替身和迁移期桥接入口复用。
type StaticClients struct {
Lite *Client
Pro *Client
Max *Client
CourseImageResponses *ArkResponsesClient
}
// New 构造 llm-service。 // New 构造 llm-service。
// 1. 不返回 error是为了让上层继续按 nil 客户端做逐步降级。 // 1. 不返回 error是为了让上层继续按 nil 客户端做逐步降级。
// 2. 只要 AIHub 已初始化,就把其中的 ChatModel 收敛成统一 Client。 // 2. 只要 AIHub 已初始化,就把其中的 ChatModel 收敛成统一 Client。
@@ -62,6 +75,16 @@ func New(opts Options) *Service {
return svc return svc
} }
// NewWithClients 使用外部注入的现成客户端构造 Service。
func NewWithClients(clients StaticClients) *Service {
return &Service{
liteClient: clients.Lite,
proClient: clients.Pro,
maxClient: clients.Max,
courseImageResponsesClient: clients.CourseImageResponses,
}
}
// LiteClient 返回低成本短输出模型客户端。 // LiteClient 返回低成本短输出模型客户端。
func (s *Service) LiteClient() *Client { func (s *Service) LiteClient() *Client {
if s == nil { if s == nil {

View File

@@ -0,0 +1,61 @@
package llm
import (
"sync"
"github.com/cloudwego/eino/schema"
)
// usageAccountingStreamReader 负责在流式读取结束时统一回收 usage。
type usageAccountingStreamReader struct {
source StreamReader
onDone func(usage *schema.TokenUsage)
once sync.Once
usage *schema.TokenUsage
}
func NewUsageAccountingStreamReader(source StreamReader, onDone func(usage *schema.TokenUsage)) StreamReader {
if source == nil {
return nil
}
return &usageAccountingStreamReader{
source: source,
onDone: onDone,
}
}
func (r *usageAccountingStreamReader) Recv() (*schema.Message, error) {
if r == nil || r.source == nil {
return nil, nil
}
msg, err := r.source.Recv()
if msg != nil && msg.ResponseMeta != nil {
r.usage = MergeUsage(r.usage, msg.ResponseMeta.Usage)
}
if err != nil {
r.finish()
}
return msg, err
}
func (r *usageAccountingStreamReader) Close() error {
if r == nil || r.source == nil {
return nil
}
err := r.source.Close()
r.finish()
return err
}
func (r *usageAccountingStreamReader) finish() {
if r == nil {
return
}
r.once.Do(func() {
if r.onDone != nil {
r.onDone(CloneUsage(r.usage))
}
})
}

View File

@@ -41,6 +41,7 @@ func NewLLMDecisionOrchestrator(client *llmservice.Client, cfg memorymodel.Confi
// 3. 不做最终决策,最终动作由确定性汇总逻辑产出。 // 3. 不做最终决策,最终动作由确定性汇总逻辑产出。
func (o *LLMDecisionOrchestrator) Compare( func (o *LLMDecisionOrchestrator) Compare(
ctx context.Context, ctx context.Context,
billing llmservice.BillingContext,
fact memorymodel.NormalizedFact, fact memorymodel.NormalizedFact,
candidate memorymodel.CandidateSnapshot, candidate memorymodel.CandidateSnapshot,
) (*memorymodel.ComparisonResult, error) { ) (*memorymodel.ComparisonResult, error) {
@@ -53,10 +54,11 @@ func (o *LLMDecisionOrchestrator) Compare(
userPrompt := buildDecisionCompareUserPrompt(fact, candidate) userPrompt := buildDecisionCompareUserPrompt(fact, candidate)
messages := llmservice.BuildSystemUserMessages(systemPrompt, nil, userPrompt) messages := llmservice.BuildSystemUserMessages(systemPrompt, nil, userPrompt)
invokeCtx := llmservice.WithBillingContext(ctx, billing)
// 2. 调用 LLM 做结构化输出,温度用低值保证判断稳定。 // 2. 调用 LLM 做结构化输出,温度用低值保证判断稳定。
resp, _, err := llmservice.GenerateJSON[decisionCompareResponse]( resp, _, err := llmservice.GenerateJSON[decisionCompareResponse](
ctx, invokeCtx,
o.client, o.client,
messages, messages,
llmservice.GenerateOptions{ llmservice.GenerateOptions{

View File

@@ -59,9 +59,10 @@ func (o *LLMWriteOrchestrator) ExtractFacts(ctx context.Context, payload memorym
nil, nil,
buildMemoryExtractUserPrompt(payload), buildMemoryExtractUserPrompt(payload),
) )
invokeCtx := llmservice.WithBillingContext(ctx, buildMemoryExtractBillingContext(payload))
resp, rawResult, err := llmservice.GenerateJSON[memoryExtractResponse]( resp, rawResult, err := llmservice.GenerateJSON[memoryExtractResponse](
ctx, invokeCtx,
o.client, o.client,
messages, messages,
llmservice.GenerateOptions{ llmservice.GenerateOptions{
@@ -329,3 +330,18 @@ func truncateForLog(raw *llmservice.TextResult) string {
} }
return text[:200] + "..." return text[:200] + "..."
} }
func buildMemoryExtractBillingContext(payload memorymodel.ExtractJobPayload) llmservice.BillingContext {
requestID := strings.TrimSpace(payload.TraceID)
if requestID == "" {
requestID = fmt.Sprintf("memory_extract:%d:%s:%d", payload.UserID, strings.TrimSpace(payload.ConversationID), payload.SourceMessageID)
}
return llmservice.BillingContext{
UserID: uint64(payload.UserID),
EventID: requestID,
Scene: "memory_extract",
RequestID: requestID,
ConversationID: strings.TrimSpace(payload.ConversationID),
ModelAlias: "memory_extract",
}
}

View File

@@ -3,7 +3,9 @@ package worker
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo" memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo"
memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils" memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils"
memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model" memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model"
@@ -144,7 +146,7 @@ func (r *Runner) executeDecisionForFact(
} }
// Step 3: 逐对 LLM 比对。 // Step 3: 逐对 LLM 比对。
comparisons := r.compareWithCandidates(ctx, fact, candidates) comparisons := r.compareWithCandidates(ctx, payload, fact, candidates)
// Step 4: 确定性汇总。 // Step 4: 确定性汇总。
decision := memoryutils.AggregateComparisons(fact, comparisons, candidates) decision := memoryutils.AggregateComparisons(fact, comparisons, candidates)
@@ -298,6 +300,7 @@ func (r *Runner) recallCandidatesFromMySQL(
// 3. 无候选或决策编排器为空时返回空切片,上层直接走 ADD 路径。 // 3. 无候选或决策编排器为空时返回空切片,上层直接走 ADD 路径。
func (r *Runner) compareWithCandidates( func (r *Runner) compareWithCandidates(
ctx context.Context, ctx context.Context,
payload memorymodel.ExtractJobPayload,
fact memorymodel.NormalizedFact, fact memorymodel.NormalizedFact,
candidates []memorymodel.CandidateSnapshot, candidates []memorymodel.CandidateSnapshot,
) []memorymodel.ComparisonResult { ) []memorymodel.ComparisonResult {
@@ -307,7 +310,7 @@ func (r *Runner) compareWithCandidates(
comparisons := make([]memorymodel.ComparisonResult, 0, len(candidates)) comparisons := make([]memorymodel.ComparisonResult, 0, len(candidates))
for _, candidate := range candidates { for _, candidate := range candidates {
compResult, err := r.decisionOrchestrator.Compare(ctx, fact, candidate) compResult, err := r.decisionOrchestrator.Compare(ctx, buildMemoryDecisionBillingContext(payload, fact, candidate), fact, candidate)
if err != nil { if err != nil {
// LLM 调用失败 → 视为 unrelated不影响其他候选。 // LLM 调用失败 → 视为 unrelated不影响其他候选。
if r.logger != nil { if r.logger != nil {
@@ -335,6 +338,26 @@ func (r *Runner) compareWithCandidates(
return comparisons return comparisons
} }
func buildMemoryDecisionBillingContext(
payload memorymodel.ExtractJobPayload,
fact memorymodel.NormalizedFact,
candidate memorymodel.CandidateSnapshot,
) llmservice.BillingContext {
requestID := strings.TrimSpace(payload.TraceID)
if requestID == "" {
requestID = fmt.Sprintf("memory_decision:%d:%s:%d", payload.UserID, strings.TrimSpace(payload.ConversationID), payload.SourceMessageID)
}
eventID := fmt.Sprintf("%s:%d:%s", requestID, candidate.MemoryID, fact.ContentHash)
return llmservice.BillingContext{
UserID: uint64(payload.UserID),
EventID: eventID,
Scene: "memory_decision_compare",
RequestID: requestID,
ConversationID: strings.TrimSpace(payload.ConversationID),
ModelAlias: "memory_decision_compare",
}
}
// collectActionOutcome 汇总单个动作结果到全局 outcome。 // collectActionOutcome 汇总单个动作结果到全局 outcome。
func (r *Runner) collectActionOutcome(outcome *DecisionFlowOutcome, actionOutcome *ApplyActionOutcome) { func (r *Runner) collectActionOutcome(outcome *DecisionFlowOutcome, actionOutcome *ApplyActionOutcome) {
if actionOutcome == nil { if actionOutcome == nil {

View File

@@ -9,10 +9,8 @@ import (
"github.com/LoveLosita/smartflow/backend/services/runtime/dao" "github.com/LoveLosita/smartflow/backend/services/runtime/dao"
"github.com/LoveLosita/smartflow/backend/services/runtime/model" "github.com/LoveLosita/smartflow/backend/services/runtime/model"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka" kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox" outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -25,13 +23,12 @@ const (
// 职责边界: // 职责边界:
// 1. 只处理聊天历史事件,不处理其它业务事件; // 1. 只处理聊天历史事件,不处理其它业务事件;
// 2. 只负责注册,不负责总线启动; // 2. 只负责注册,不负责总线启动;
// 3. 先写本地 chat 相关表,再调用 userauth 调整 token 额度 // 3. 先写本地 chat 相关表,不再把聊天 token 消耗同步到旧 userauth 额度账本
// 4. 当前版本仅注册新路由键,不再注册旧兼容键。 // 4. 当前版本仅注册新路由键,不再注册旧兼容键。
func RegisterChatHistoryPersistHandler( func RegisterChatHistoryPersistHandler(
bus OutboxBus, bus OutboxBus,
outboxRepo *outboxinfra.Repository, outboxRepo *outboxinfra.Repository,
repoManager *dao.RepoManager, repoManager *dao.RepoManager,
adjuster ports.TokenUsageAdjuster,
) error { ) error {
if bus == nil { if bus == nil {
return errors.New("event bus is nil") return errors.New("event bus is nil")
@@ -77,19 +74,6 @@ func RegisterChatHistoryPersistHandler(
return err return err
} }
if payload.TokensConsumed > 0 {
if adjuster == nil {
return errors.New("userauth token adjuster is nil")
}
if _, err := adjuster.AdjustTokenUsage(ctx, contracts.AdjustTokenUsageRequest{
EventID: eventID,
UserID: payload.UserID,
TokenDelta: payload.TokensConsumed,
}); err != nil {
return err
}
}
return eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID) return eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID)
} }

View File

@@ -1,126 +0,0 @@
package eventsvc
import (
"context"
"encoding/json"
"errors"
"strconv"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/services/runtime/dao"
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"gorm.io/gorm"
)
const (
// EventTypeChatTokenUsageAdjustRequested 是“会话 token 额度调整”事件类型。
// 命名约束:
// 1. 只表达业务语义,不泄露 outbox/kafka 实现细节;
// 2. 作为稳定路由键长期保留,后续演进优先通过 event_version。
EventTypeChatTokenUsageAdjustRequested = "chat.token.usage.adjust.requested"
)
// RegisterChatTokenUsageAdjustHandler 注册“会话 token 额度调整”消费者。
// 职责边界:
// 1. 只处理 token 调整事件,不处理聊天正文落库;
// 2. 先写本地账本,再调用 userauth 侧做额度同步;
// 3. 非法载荷直接标记 dead避免无意义重试。
func RegisterChatTokenUsageAdjustHandler(
bus OutboxBus,
outboxRepo *outboxinfra.Repository,
repoManager *dao.RepoManager,
adjuster ports.TokenUsageAdjuster,
) error {
if bus == nil {
return errors.New("event bus is nil")
}
if outboxRepo == nil {
return errors.New("outbox repository is nil")
}
if repoManager == nil {
return errors.New("repo manager is nil")
}
eventOutboxRepo, err := scopedOutboxRepoForEvent(outboxRepo, EventTypeChatTokenUsageAdjustRequested)
if err != nil {
return err
}
handler := func(ctx context.Context, envelope kafkabus.Envelope) error {
var payload model.ChatTokenUsageAdjustPayload
if unmarshalErr := json.Unmarshal(envelope.Payload, &payload); unmarshalErr != nil {
_ = eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "解析会话 token 调整载荷失败: "+unmarshalErr.Error())
return nil
}
if payload.UserID <= 0 || payload.TokensDelta <= 0 || payload.ConversationID == "" {
_ = eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "会话 token 调整载荷无效: user_id/conversation_id/tokens_delta 非法")
return nil
}
eventID := strings.TrimSpace(envelope.EventID)
if eventID == "" {
eventID = strconv.FormatInt(envelope.OutboxID, 10)
}
if err := eventOutboxRepo.ConsumeInTx(ctx, envelope.OutboxID, func(tx *gorm.DB) error {
txM := repoManager.WithTx(tx)
return txM.Agent.AdjustTokenUsageInTx(ctx, payload.UserID, payload.ConversationID, payload.TokensDelta, eventID)
}); err != nil {
return err
}
if adjuster == nil {
return errors.New("userauth token adjuster is nil")
}
if _, err := adjuster.AdjustTokenUsage(ctx, contracts.AdjustTokenUsageRequest{
EventID: eventID,
UserID: payload.UserID,
TokenDelta: payload.TokensDelta,
}); err != nil {
return err
}
return eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID)
}
return bus.RegisterEventHandler(EventTypeChatTokenUsageAdjustRequested, handler)
}
// PublishChatTokenUsageAdjustRequested 发布“会话 token 额度调整”事件。
// 1. 这里只保证 outbox 写入成功,不等待消费结果;
// 2. 业务层只关心 DTO不关心 outbox/Kafka 细节。
func PublishChatTokenUsageAdjustRequested(
ctx context.Context,
publisher outboxinfra.EventPublisher,
payload model.ChatTokenUsageAdjustPayload,
) error {
if publisher == nil {
return errors.New("event publisher is nil")
}
if payload.UserID <= 0 {
return errors.New("invalid user_id")
}
if payload.TokensDelta <= 0 {
return errors.New("invalid tokens_delta")
}
if payload.ConversationID == "" {
return errors.New("invalid conversation_id")
}
if payload.TriggeredAt.IsZero() {
payload.TriggeredAt = time.Now()
}
return publisher.Publish(ctx, outboxinfra.PublishRequest{
EventType: EventTypeChatTokenUsageAdjustRequested,
EventVersion: outboxinfra.DefaultEventVersion,
MessageKey: payload.ConversationID,
AggregateID: strconv.Itoa(payload.UserID) + ":" + payload.ConversationID,
Payload: payload,
})
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/LoveLosita/smartflow/backend/services/runtime/dao" "github.com/LoveLosita/smartflow/backend/services/runtime/dao"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox" outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
"github.com/LoveLosita/smartflow/backend/shared/ports"
) )
// RegisterCoreOutboxHandlers 注册单体残留内仍由 agent 边界消费的 outbox handler。 // RegisterCoreOutboxHandlers 注册单体残留内仍由 agent 边界消费的 outbox handler。
@@ -24,7 +23,6 @@ func RegisterCoreOutboxHandlers(
agentRepo *dao.AgentDAO, agentRepo *dao.AgentDAO,
cacheRepo *dao.CacheDAO, cacheRepo *dao.CacheDAO,
memoryModule *memory.Module, memoryModule *memory.Module,
adjuster ports.TokenUsageAdjuster,
) error { ) error {
if err := validateCoreOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo); err != nil { if err := validateCoreOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo); err != nil {
return err return err
@@ -33,7 +31,7 @@ func RegisterCoreOutboxHandlers(
return err return err
} }
return registerOutboxHandlerRoutes(coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, adjuster)) return registerOutboxHandlerRoutes(coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule))
} }
// RegisterAllOutboxHandlers 注册当前阶段所有 outbox handler。 // RegisterAllOutboxHandlers 注册当前阶段所有 outbox handler。
@@ -51,7 +49,6 @@ func RegisterAllOutboxHandlers(
cacheRepo *dao.CacheDAO, cacheRepo *dao.CacheDAO,
memoryModule *memory.Module, memoryModule *memory.Module,
activeTriggerWorkflow ActiveScheduleTriggeredProcessor, activeTriggerWorkflow ActiveScheduleTriggeredProcessor,
adjuster ports.TokenUsageAdjuster,
) error { ) error {
if err := validateAllOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, activeTriggerWorkflow); err != nil { if err := validateAllOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, activeTriggerWorkflow); err != nil {
return err return err
@@ -65,7 +62,6 @@ func RegisterAllOutboxHandlers(
cacheRepo, cacheRepo,
memoryModule, memoryModule,
activeTriggerWorkflow, activeTriggerWorkflow,
adjuster,
)) ))
} }
@@ -126,21 +122,13 @@ func coreOutboxHandlerRoutes(
agentRepo *dao.AgentDAO, agentRepo *dao.AgentDAO,
cacheRepo *dao.CacheDAO, cacheRepo *dao.CacheDAO,
memoryModule *memory.Module, memoryModule *memory.Module,
adjuster ports.TokenUsageAdjuster,
) []outboxHandlerRoute { ) []outboxHandlerRoute {
return []outboxHandlerRoute{ return []outboxHandlerRoute{
{ {
EventType: EventTypeChatHistoryPersistRequested, EventType: EventTypeChatHistoryPersistRequested,
Service: outboxHandlerServiceAgent, Service: outboxHandlerServiceAgent,
Register: func() error { Register: func() error {
return RegisterChatHistoryPersistHandler(eventBus, outboxRepo, repoManager, adjuster) return RegisterChatHistoryPersistHandler(eventBus, outboxRepo, repoManager)
},
},
{
EventType: EventTypeChatTokenUsageAdjustRequested,
Service: outboxHandlerServiceAgent,
Register: func() error {
return RegisterChatTokenUsageAdjustHandler(eventBus, outboxRepo, repoManager, adjuster)
}, },
}, },
{ {
@@ -169,9 +157,8 @@ func allOutboxHandlerRoutes(
cacheRepo *dao.CacheDAO, cacheRepo *dao.CacheDAO,
memoryModule *memory.Module, memoryModule *memory.Module,
activeTriggerWorkflow ActiveScheduleTriggeredProcessor, activeTriggerWorkflow ActiveScheduleTriggeredProcessor,
adjuster ports.TokenUsageAdjuster,
) []outboxHandlerRoute { ) []outboxHandlerRoute {
routes := coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, adjuster) routes := coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule)
routes = append(routes, routes = append(routes,
outboxHandlerRoute{ outboxHandlerRoute{
EventType: sharedevents.ActiveScheduleTriggeredEventType, EventType: sharedevents.ActiveScheduleTriggeredEventType,

View File

@@ -32,6 +32,7 @@ type CourseImageParseResponse struct {
} }
type CourseImageParseRequest struct { type CourseImageParseRequest struct {
UserID int
Filename string Filename string
MIMEType string MIMEType string
ImageBytes []byte ImageBytes []byte

View File

@@ -1,8 +1,23 @@
package sv package sv
import "errors" import (
"errors"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
var ( var (
// ErrTaskClassPortMissing 表示计划广场需要访问旧 TaskClass但 adapter 尚未注入。 // ErrTaskClassPortMissing 表示计划广场需要访问旧 TaskClass但 adapter 尚未注入。
ErrTaskClassPortMissing = errors.New("taskclassforum taskclass adapter is nil") ErrTaskClassPortMissing = errors.New("taskclassforum taskclass adapter is nil")
// ErrForumTagsRequired 表示发布帖子时至少要选择一个标签。
//
// 说明:
// 1. 复用 MissingParam 的状态码,保持 RPC/HTTP 错误映射链路不变;
// 2. 单独覆写 info保证前端能直接展示更明确的中文提示
// 3. 仅用于“标签必填”这条业务规则,不替代其他参数校验。
ErrForumTagsRequired = respond.Response{
Status: respond.MissingParam.Status,
Info: "至少选择一个标签",
}
) )

View File

@@ -7,9 +7,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/LoveLosita/smartflow/backend/shared/respond"
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
"github.com/LoveLosita/smartflow/backend/shared/respond"
) )
const ( const (
@@ -24,6 +24,66 @@ const (
maxImportTitle = 80 maxImportTitle = 80
) )
var defaultForumTags = []string{
"期末复习",
"考研备考",
"四六级",
"编程学习",
"实习求职",
"习惯养成",
"竞赛项目",
"证书考试",
}
func buildForumTagItems(rawTags []string, limit int) []forumcontracts.ForumTagItem {
if limit <= 0 || limit > 50 {
limit = 20
}
// 1. 先聚合真实帖子里的标签热度,保证已有社区语义优先展示。
// 2. 空白标签直接忽略,避免把脏数据继续透给前端。
// 3. 默认标签只在缺失时兜底补齐,保证清库后分类区仍可用。
counter := make(map[string]int)
for _, raw := range rawTags {
for _, tag := range tagsFromJSON(raw) {
trimmedTag := strings.TrimSpace(tag)
if trimmedTag == "" {
continue
}
counter[trimmedTag]++
}
}
items := make([]forumcontracts.ForumTagItem, 0, len(counter)+len(defaultForumTags))
for tag, count := range counter {
items = append(items, forumcontracts.ForumTagItem{Tag: tag, PostCount: count})
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].PostCount == items[j].PostCount {
return items[i].Tag < items[j].Tag
}
return items[i].PostCount > items[j].PostCount
})
seen := make(map[string]struct{}, len(items))
for _, item := range items {
seen[item.Tag] = struct{}{}
}
for _, tag := range defaultForumTags {
if _, exists := seen[tag]; exists {
continue
}
items = append(items, forumcontracts.ForumTagItem{
Tag: tag,
PostCount: 0,
})
}
if len(items) > limit {
return items[:limit]
}
return items
}
func normalizePage(page int, pageSize int) (int, int) { func normalizePage(page int, pageSize int) (int, int) {
if page <= 0 { if page <= 0 {
page = defaultPage page = defaultPage
@@ -69,6 +129,23 @@ func normalizeTags(tags []string) ([]string, error) {
return result, nil return result, nil
} }
// normalizeRequiredTags 负责统一“帖子标签去重 + 必填”规则。
//
// 职责边界:
// 1. 负责清洗空白、去重、数量和长度限制;
// 2. 额外补上“发布帖子至少一个标签”的业务校验;
// 3. 不负责前端提示文案,调用方只消费 error。
func normalizeRequiredTags(tags []string) ([]string, error) {
normalizedTags, err := normalizeTags(tags)
if err != nil {
return nil, err
}
if len(normalizedTags) == 0 {
return nil, ErrForumTagsRequired
}
return normalizedTags, nil
}
func validateRuneMax(value string, maxLen int) error { func validateRuneMax(value string, maxLen int) error {
if len([]rune(strings.TrimSpace(value))) > maxLen { if len([]rune(strings.TrimSpace(value))) > maxLen {
return respond.ParamTooLong return respond.ParamTooLong

View File

@@ -7,10 +7,10 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/LoveLosita/smartflow/backend/shared/respond"
forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao"
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
"github.com/LoveLosita/smartflow/backend/shared/respond"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -73,38 +73,12 @@ func (s *Service) ListTags(ctx context.Context, actorUserID uint64, limit int) (
if err := s.Ready(); err != nil { if err := s.Ready(); err != nil {
return nil, err return nil, err
} }
if limit <= 0 || limit > 50 {
limit = 20
}
rawTags, err := s.forumDAO.ListPublishedTagJSONs(ctx) rawTags, err := s.forumDAO.ListPublishedTagJSONs(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
counter := make(map[string]int) return buildForumTagItems(rawTags, limit), nil
for _, raw := range rawTags {
for _, tag := range tagsFromJSON(raw) {
if strings.TrimSpace(tag) == "" {
continue
}
counter[tag]++
}
}
items := make([]forumcontracts.ForumTagItem, 0, len(counter))
for tag, count := range counter {
items = append(items, forumcontracts.ForumTagItem{Tag: tag, PostCount: count})
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].PostCount == items[j].PostCount {
return items[i].Tag < items[j].Tag
}
return items[i].PostCount > items[j].PostCount
})
if len(items) > limit {
items = items[:limit]
}
return items, nil
} }
// CreatePost 发布计划,并把旧 TaskClass 复制为论坛快照。 // CreatePost 发布计划,并把旧 TaskClass 复制为论坛快照。
@@ -141,7 +115,7 @@ func (s *Service) CreatePost(ctx context.Context, req forumcontracts.CreateForum
} }
} }
tags, err := normalizeTags(req.Tags) tags, err := normalizeRequiredTags(req.Tags)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,131 @@
package dao
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
const (
defaultCreditSnapshotTTL = 10 * time.Minute
defaultCreditBlockedTTL = 30 * time.Minute
)
// CreditBalanceSnapshot 是 TokenStore 在 Redis 中维护的余额快照。
type CreditBalanceSnapshot struct {
UserID uint64 `json:"user_id"`
Balance int64 `json:"balance"`
TotalRecharged int64 `json:"total_recharged"`
TotalRewarded int64 `json:"total_rewarded"`
TotalConsumed int64 `json:"total_consumed"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreditCacheDAO 只承载 Credit 余额快照相关的 Redis 能力。
type CreditCacheDAO struct {
client *redis.Client
}
func NewCreditCacheDAO(client *redis.Client) *CreditCacheDAO {
return &CreditCacheDAO{client: client}
}
func creditBalanceSnapshotKey(userID uint64) string {
return fmt.Sprintf("smartflow:credit_balance_snapshot:%d", userID)
}
func creditBlockedKey(userID uint64) string {
return fmt.Sprintf("smartflow:credit_blocked:%d", userID)
}
// SnapshotTTL 返回余额快照默认 TTL。
func (d *CreditCacheDAO) SnapshotTTL() time.Duration {
return defaultCreditSnapshotTTL
}
// BlockedTTL 返回阻断标记默认 TTL。
func (d *CreditCacheDAO) BlockedTTL() time.Duration {
return defaultCreditBlockedTTL
}
// GetCreditBalanceSnapshot 读取用户余额快照。
func (d *CreditCacheDAO) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*CreditBalanceSnapshot, bool, error) {
if d == nil || d.client == nil || userID == 0 {
return nil, false, nil
}
val, err := d.client.Get(ctx, creditBalanceSnapshotKey(userID)).Result()
if errors.Is(err, redis.Nil) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var snapshot CreditBalanceSnapshot
if err = json.Unmarshal([]byte(val), &snapshot); err != nil {
return nil, false, err
}
return &snapshot, true, nil
}
// SetCreditBalanceSnapshot 写入用户余额快照。
func (d *CreditCacheDAO) SetCreditBalanceSnapshot(ctx context.Context, userID uint64, snapshot CreditBalanceSnapshot, ttl time.Duration) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
if ttl <= 0 {
ttl = d.SnapshotTTL()
}
data, err := json.Marshal(snapshot)
if err != nil {
return err
}
return d.client.Set(ctx, creditBalanceSnapshotKey(userID), data, ttl).Err()
}
// DeleteCreditBalanceSnapshot 删除余额快照。
func (d *CreditCacheDAO) DeleteCreditBalanceSnapshot(ctx context.Context, userID uint64) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
return d.client.Del(ctx, creditBalanceSnapshotKey(userID)).Err()
}
// IsUserCreditBlocked 判断用户是否被阻断。
func (d *CreditCacheDAO) IsUserCreditBlocked(ctx context.Context, userID uint64) (bool, error) {
if d == nil || d.client == nil || userID == 0 {
return false, nil
}
result, err := d.client.Get(ctx, creditBlockedKey(userID)).Result()
if errors.Is(err, redis.Nil) {
return false, nil
}
if err != nil {
return false, err
}
return result == "1", nil
}
// SetUserCreditBlocked 写入用户阻断标记。
func (d *CreditCacheDAO) SetUserCreditBlocked(ctx context.Context, userID uint64, ttl time.Duration) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
if ttl <= 0 {
ttl = d.BlockedTTL()
}
return d.client.Set(ctx, creditBlockedKey(userID), "1", ttl).Err()
}
// DeleteUserCreditBlocked 删除用户阻断标记。
func (d *CreditCacheDAO) DeleteUserCreditBlocked(ctx context.Context, userID uint64) error {
if d == nil || d.client == nil || userID == 0 {
return nil
}
return d.client.Del(ctx, creditBlockedKey(userID)).Err()
}

View File

@@ -1,12 +1,14 @@
package dao package dao
import ( import (
"errors"
"fmt" "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" outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" redisinfra "github.com/LoveLosita/smartflow/backend/shared/infra/redis"
"github.com/spf13/viper" "github.com/go-redis/redis/v8"
"gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -14,22 +16,11 @@ import (
// OpenDBFromConfig 创建 token-store 服务自己的数据库句柄,并迁移本服务私有表。 // OpenDBFromConfig 创建 token-store 服务自己的数据库句柄,并迁移本服务私有表。
// //
// 职责边界: // 职责边界:
// 1. 只迁移 token_* 表和 token-store outbox 表,不迁移 users避免和 user/auth 服务边界冲突 // 1. 只迁移 token_*、credit_* 以及 token-store outbox 表,不迁移其它服务表
// 2. 自动迁移后执行 P0 seed确保前端商品页有可展示商品 // 2. 自动迁移后执行默认 seed保证旧 Token 链路和新 Credit 链路都能并行跑通
// 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。 // 3. 返回 *gorm.DB 供 DAO 复用,调用方负责进程生命周期。
func OpenDBFromConfig() (*gorm.DB, error) { func OpenDBFromConfig() (*gorm.DB, error) {
host := viper.GetString("database.host") db, err := mysqlinfra.OpenDBFromConfig()
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 { if err != nil {
return nil, err return nil, err
} }
@@ -42,22 +33,33 @@ func OpenDBFromConfig() (*gorm.DB, error) {
return db, nil 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 服务拥有的表。 // AutoMigrate 只迁移 token-store 服务拥有的表。
// //
// 步骤说明: // 步骤说明:
// 1. 先创建商品、订单、获取账本和奖励规则表; // 1. 只迁移 Credit 权威账本表;
// 2. 再按 service catalog 创建 token-store outbox 表,保证论坛奖励事件有稳定落表目录 // 2. 最后迁移 token-store outbox 表,保证论坛奖励与 Credit 扣费消费都能稳定落表;
// 3. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入; // 3. 任一步失败都直接返回,避免服务在 schema 不完整时继续启动。
// 4. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。
func AutoMigrate(db *gorm.DB) error { func AutoMigrate(db *gorm.DB) error {
if db == nil { if db == nil {
return fmt.Errorf("tokenstore auto migrate failed: db is nil") return fmt.Errorf("tokenstore auto migrate failed: db is nil")
} }
if err := db.AutoMigrate( if err := db.AutoMigrate(
&tokenmodel.TokenProduct{}, &storemodel.CreditAccount{},
&tokenmodel.TokenOrder{}, &storemodel.CreditLedger{},
&tokenmodel.TokenGrant{}, &storemodel.CreditProduct{},
&tokenmodel.TokenRewardRule{}, &storemodel.CreditOrder{},
&storemodel.CreditPriceRule{},
&storemodel.CreditRewardRule{},
); err != nil { ); err != nil {
return fmt.Errorf("auto migrate tokenstore tables failed: %w", err) return fmt.Errorf("auto migrate tokenstore tables failed: %w", err)
} }
@@ -67,88 +69,62 @@ func AutoMigrate(db *gorm.DB) error {
return nil return nil
} }
// SeedDefaults 写入 P0 默认商品和奖励规则。 // SeedDefaults 写入 Token 与 Credit 默认商品/规则。
// //
// 步骤说明: // 步骤说明:
// 1. 商品奖励规则都用稳定业务键做 upsert允许重复启动服务 // 1. 只保留 Credit 商品奖励规则 seed
// 2. seed 只提供 P0 默认数据,不代表有管理后台能力; // 2. Credit 价格规则本轮只建表不写默认价格,避免误用错误计费参数。
// 3. 后续若商品或规则由运营后台维护,可替换本函数或仅保留初始化兜底。
func SeedDefaults(db *gorm.DB) error { func SeedDefaults(db *gorm.DB) error {
if db == nil { if db == nil {
return fmt.Errorf("tokenstore seed failed: db is nil") return fmt.Errorf("tokenstore seed failed: db is nil")
} }
if err := seedDefaultProducts(db); err != nil { if err := seedDefaultCreditProducts(db); err != nil {
return err 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 err
} }
return nil return nil
} }
func seedDefaultProducts(db *gorm.DB) error { func seedDefaultCreditProducts(db *gorm.DB) error {
products := defaultTokenProducts() products := defaultCreditProducts()
for _, product := range products { for _, product := range products {
if err := db.Clauses(clause.OnConflict{ // 1. 这里只负责“缺失即补齐”的默认商品播种,不再把服务启动当成运营配置同步器。
Columns: []clause.Column{{Name: "sku"}}, // 2. 一旦线上已经存在同 SKU 商品,说明运营侧可能手动改过价格、文案或状态,此时必须保留现状。
DoUpdates: clause.AssignmentColumns([]string{ // 3. 真正需要批量改默认套餐时,应该走显式 migration 或脚本,而不是依赖服务重启覆盖。
"name", if err := db.Clauses(creditProductSeedOnConflict()).Create(&product).Error; err != nil {
"description", return fmt.Errorf("seed credit product %s failed: %w", product.SKU, err)
"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 return nil
} }
func defaultTokenProducts() []tokenmodel.TokenProduct { func creditProductSeedOnConflict() clause.OnConflict {
return []tokenmodel.TokenProduct{ return clause.OnConflict{
{ Columns: []clause.Column{{Name: "sku"}},
SKU: "token_basic_100", DoNothing: true,
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 { func backfillCreditProductOriginalPrice(db *gorm.DB) error {
rules := defaultTokenRewardRules() // 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 { for _, rule := range rules {
if err := db.Clauses(clause.OnConflict{ if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}}, Columns: []clause.Column{{Name: "source"}},
@@ -156,29 +132,141 @@ func seedDefaultRewardRules(db *gorm.DB) error {
"name", "name",
"amount", "amount",
"status", "status",
"description",
"config_json", "config_json",
"updated_at", "updated_at",
}), }),
}).Create(&rule).Error; err != nil { }).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 return nil
} }
func defaultTokenRewardRules() []tokenmodel.TokenRewardRule { func seedDefaultCreditPriceRules(db *gorm.DB) error {
return []tokenmodel.TokenRewardRule{ 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, SKU: "credit_free_100",
Name: "计划被点赞奖励", Name: "Free",
Amount: 1, Description: "每日免费发放,适合基础功能体验。",
Status: tokenmodel.TokenRewardRuleStatusActive, CreditAmount: 100,
PriceCent: 0,
OriginalPriceCent: 0,
Currency: "CNY",
Badge: "每日",
Status: storemodel.CreditProductStatusActive,
SortOrder: 10,
}, },
{ {
Source: tokenmodel.TokenGrantSourceForumImport, SKU: "credit_starter_1000",
Name: "计划被导入奖励", Name: "Starter",
Amount: 5, Description: "入门级额度,有效期 1 个月。续费时时间和额度均可累加。",
Status: tokenmodel.TokenRewardRuleStatusActive, 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.",
}, },
} }
} }

View File

@@ -0,0 +1,380 @@
package dao
import (
"context"
"errors"
"strings"
"time"
creditmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// CreditStoreDAO 承载 Credit 权威账本相关表的持久化访问。
//
// 职责边界:
// 1. 只访问 credit_accounts、credit_ledger、credit_products、credit_orders、credit_price_rules、credit_reward_rules
// 2. 只提供查询、事务、行锁与原子状态更新,不承载 RPC/前端展示拼装;
// 3. 幂等语义、扣费校验和缓存同步策略由服务层负责。
type CreditStoreDAO struct {
db *gorm.DB
}
func NewCreditStoreDAO(db *gorm.DB) *CreditStoreDAO {
return &CreditStoreDAO{db: db}
}
func (dao *CreditStoreDAO) WithTx(tx *gorm.DB) *CreditStoreDAO {
return &CreditStoreDAO{db: tx}
}
// Transaction 在一个数据库事务内执行 Credit 账本写操作。
func (dao *CreditStoreDAO) Transaction(ctx context.Context, fn func(txDAO *CreditStoreDAO) error) error {
return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(dao.WithTx(tx))
})
}
type ListCreditOrdersQuery struct {
UserID uint64
Page int
PageSize int
Status string
}
type ListCreditTransactionsQuery struct {
UserID uint64
Page int
PageSize int
Source string
Direction string
}
type GetCreditConsumptionDashboardQuery struct {
UserID uint64
CreatedFrom *time.Time
}
type CreditConsumptionDashboardAggregate struct {
CreditConsumed int64
TokenConsumed int64
}
type ListCreditPriceRulesQuery struct {
Scene string
ProviderName string
ModelName string
Status string
}
type ListCreditRewardRulesQuery struct {
Source string
Status string
}
func (dao *CreditStoreDAO) ListActiveProducts(ctx context.Context) ([]creditmodel.CreditProduct, error) {
var products []creditmodel.CreditProduct
err := dao.db.WithContext(ctx).
Where("status = ?", creditmodel.CreditProductStatusActive).
Order("sort_order ASC, id ASC").
Find(&products).Error
return products, err
}
func (dao *CreditStoreDAO) FindActiveProductByID(ctx context.Context, productID uint64) (*creditmodel.CreditProduct, error) {
var product creditmodel.CreditProduct
err := dao.db.WithContext(ctx).
Where("id = ? AND status = ?", productID, creditmodel.CreditProductStatusActive).
First(&product).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &product, nil
}
func (dao *CreditStoreDAO) FindOrderByUserIdempotencyKey(ctx context.Context, userID uint64, key string) (*creditmodel.CreditOrder, error) {
var order creditmodel.CreditOrder
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 *CreditStoreDAO) CreateOrder(ctx context.Context, order *creditmodel.CreditOrder) error {
return dao.db.WithContext(ctx).Create(order).Error
}
func (dao *CreditStoreDAO) CountOrders(ctx context.Context, query ListCreditOrdersQuery) (int64, error) {
db := dao.db.WithContext(ctx).
Model(&creditmodel.CreditOrder{}).
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 *CreditStoreDAO) ListOrders(ctx context.Context, query ListCreditOrdersQuery) ([]creditmodel.CreditOrder, 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 []creditmodel.CreditOrder
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 *CreditStoreDAO) FindOrderByID(ctx context.Context, orderID uint64) (*creditmodel.CreditOrder, error) {
var order creditmodel.CreditOrder
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 *CreditStoreDAO) LockOrderByID(ctx context.Context, orderID uint64) (*creditmodel.CreditOrder, error) {
var order creditmodel.CreditOrder
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 只负责把 Credit 订单持久化到最新状态。
func (dao *CreditStoreDAO) UpdateOrderState(ctx context.Context, orderID uint64, status string, paidAt *time.Time, creditedAt *time.Time, paymentMode string) error {
updates := map[string]any{
"status": status,
"paid_at": paidAt,
"credited_at": creditedAt,
"payment_mode": paymentMode,
"updated_at": time.Now(),
}
return dao.db.WithContext(ctx).
Model(&creditmodel.CreditOrder{}).
Where("id = ?", orderID).
Updates(updates).Error
}
func (dao *CreditStoreDAO) FindLedgerByEventID(ctx context.Context, eventID string) (*creditmodel.CreditLedger, error) {
var ledger creditmodel.CreditLedger
err := dao.db.WithContext(ctx).
Where("event_id = ?", eventID).
First(&ledger).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &ledger, nil
}
func (dao *CreditStoreDAO) FindLatestLedgerByOrderID(ctx context.Context, orderID uint64) (*creditmodel.CreditLedger, error) {
var ledger creditmodel.CreditLedger
err := dao.db.WithContext(ctx).
Where("order_id = ?", orderID).
Order("created_at DESC, id DESC").
First(&ledger).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &ledger, nil
}
func (dao *CreditStoreDAO) ListLedgerByOrderIDs(ctx context.Context, orderIDs []uint64) ([]creditmodel.CreditLedger, error) {
if len(orderIDs) == 0 {
return []creditmodel.CreditLedger{}, nil
}
var ledgers []creditmodel.CreditLedger
err := dao.db.WithContext(ctx).
Where("order_id IN ?", orderIDs).
Order("created_at DESC, id DESC").
Find(&ledgers).Error
return ledgers, err
}
func (dao *CreditStoreDAO) CreateLedger(ctx context.Context, ledger *creditmodel.CreditLedger) error {
return dao.db.WithContext(ctx).Create(ledger).Error
}
func (dao *CreditStoreDAO) CountTransactions(ctx context.Context, query ListCreditTransactionsQuery) (int64, error) {
db := dao.db.WithContext(ctx).
Model(&creditmodel.CreditLedger{}).
Where("user_id = ?", query.UserID)
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
if direction := strings.TrimSpace(query.Direction); direction != "" {
db = db.Where("direction = ?", direction)
}
var total int64
err := db.Count(&total).Error
return total, err
}
func (dao *CreditStoreDAO) ListTransactions(ctx context.Context, query ListCreditTransactionsQuery) ([]creditmodel.CreditLedger, error) {
db := dao.db.WithContext(ctx).
Where("user_id = ?", query.UserID)
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
if direction := strings.TrimSpace(query.Direction); direction != "" {
db = db.Where("direction = ?", direction)
}
var items []creditmodel.CreditLedger
err := db.Order("created_at DESC, id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&items).Error
return items, err
}
// GetCreditConsumptionDashboard 只聚合当前用户 AI 扣费流水对应的消耗看板数据。
//
// 职责边界:
// 1. 只统计 source=charge 且 direction=expense 的流水,保证商店页口径和真实扣费一致。
// 2. 默认排除 failed 流水skipped 会保留,这样可展示“有 Token 消耗但 Credit 未扣减”的真实情况。
// 3. 这里只做聚合查询,不负责周期归一化、权限校验和前端文案拼装。
func (dao *CreditStoreDAO) GetCreditConsumptionDashboard(ctx context.Context, query GetCreditConsumptionDashboardQuery) (CreditConsumptionDashboardAggregate, error) {
type aggregateRow struct {
CreditConsumed int64 `gorm:"column:credit_consumed"`
TokenConsumed int64 `gorm:"column:token_consumed"`
}
db := dao.db.WithContext(ctx).
Model(&creditmodel.CreditLedger{}).
Select(`
COALESCE(SUM(CASE WHEN amount < 0 THEN -amount ELSE 0 END), 0) AS credit_consumed,
COALESCE(SUM(
CASE
WHEN COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.total_tokens')) AS SIGNED), 0) > 0
THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.total_tokens')) AS SIGNED)
ELSE GREATEST(
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.input_tokens')) AS SIGNED), 0) +
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.output_tokens')) AS SIGNED), 0),
0
)
END
), 0) AS token_consumed
`).
Where("user_id = ?", query.UserID).
Where("source = ?", creditmodel.CreditLedgerSourceCharge).
Where("direction = ?", creditmodel.CreditLedgerDirectionExpense).
Where("status <> ?", creditmodel.CreditLedgerStatusFailed)
if query.CreatedFrom != nil {
db = db.Where("created_at >= ?", *query.CreatedFrom)
}
var row aggregateRow
if err := db.Scan(&row).Error; err != nil {
return CreditConsumptionDashboardAggregate{}, err
}
return CreditConsumptionDashboardAggregate{
CreditConsumed: row.CreditConsumed,
TokenConsumed: row.TokenConsumed,
}, nil
}
func (dao *CreditStoreDAO) FindAccountByUserID(ctx context.Context, userID uint64) (*creditmodel.CreditAccount, error) {
var account creditmodel.CreditAccount
err := dao.db.WithContext(ctx).
Where("user_id = ?", userID).
First(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &account, nil
}
func (dao *CreditStoreDAO) LockAccountByUserID(ctx context.Context, userID uint64) (*creditmodel.CreditAccount, error) {
var account creditmodel.CreditAccount
err := dao.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ?", userID).
First(&account).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &account, nil
}
func (dao *CreditStoreDAO) CreateAccount(ctx context.Context, account *creditmodel.CreditAccount) error {
return dao.db.WithContext(ctx).Create(account).Error
}
func (dao *CreditStoreDAO) SaveAccount(ctx context.Context, account *creditmodel.CreditAccount) error {
return dao.db.WithContext(ctx).Save(account).Error
}
func (dao *CreditStoreDAO) ListPriceRules(ctx context.Context, query ListCreditPriceRulesQuery) ([]creditmodel.CreditPriceRule, error) {
db := dao.db.WithContext(ctx).Model(&creditmodel.CreditPriceRule{})
if scene := strings.TrimSpace(query.Scene); scene != "" {
db = db.Where("scene = ?", scene)
}
if providerName := strings.TrimSpace(query.ProviderName); providerName != "" {
db = db.Where("provider_name = ?", providerName)
}
if modelName := strings.TrimSpace(query.ModelName); modelName != "" {
db = db.Where("model_name = ?", modelName)
}
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var rules []creditmodel.CreditPriceRule
err := db.Order("priority DESC, id ASC").Find(&rules).Error
return rules, err
}
func (dao *CreditStoreDAO) ListRewardRules(ctx context.Context, query ListCreditRewardRulesQuery) ([]creditmodel.CreditRewardRule, error) {
db := dao.db.WithContext(ctx).Model(&creditmodel.CreditRewardRule{})
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var rules []creditmodel.CreditRewardRule
err := db.Order("id ASC").Find(&rules).Error
return rules, err
}

View File

@@ -1,285 +0,0 @@
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
}

View File

@@ -0,0 +1,206 @@
package model
import "time"
const (
// CreditProductStatusActive 表示商品可在 Credit 商店展示和购买。
CreditProductStatusActive = "active"
// CreditProductStatusInactive 表示商品已下架。
CreditProductStatusInactive = "inactive"
)
const (
// CreditOrderStatusPending 表示订单已创建,等待支付确认。
CreditOrderStatusPending = "pending"
// CreditOrderStatusPaid 表示订单已确认支付,等待入账。
CreditOrderStatusPaid = "paid"
// CreditOrderStatusCredited 表示订单对应的 Credit 已经写入账本。
CreditOrderStatusCredited = "credited"
// CreditOrderStatusClosed 表示订单已关闭。
CreditOrderStatusClosed = "closed"
)
const (
// CreditLedgerDirectionIncome 表示正向入账。
CreditLedgerDirectionIncome = "income"
// CreditLedgerDirectionExpense 表示扣费出账。
CreditLedgerDirectionExpense = "expense"
)
const (
// CreditLedgerStatusApplied 表示该笔流水已经成为权威账本事实。
CreditLedgerStatusApplied = "applied"
// CreditLedgerStatusSkipped 表示事件被消费但不影响余额。
CreditLedgerStatusSkipped = "skipped"
// CreditLedgerStatusFailed 预留给后续补偿或人工处理。
CreditLedgerStatusFailed = "failed"
)
const (
// CreditLedgerSourcePurchase 表示用户购买 Credit 商品。
CreditLedgerSourcePurchase = "purchase"
// CreditLedgerSourceCharge 表示 LLM 调用扣费。
CreditLedgerSourceCharge = "charge"
// CreditLedgerSourceForumLike 预留论坛点赞奖励。
CreditLedgerSourceForumLike = "forum_like"
// CreditLedgerSourceForumImport 预留论坛导入奖励。
CreditLedgerSourceForumImport = "forum_import"
// CreditLedgerSourceManual 预留人工补偿。
CreditLedgerSourceManual = "manual"
)
const (
// CreditPriceRuleStatusActive 表示价格规则启用。
CreditPriceRuleStatusActive = "active"
// CreditPriceRuleStatusInactive 表示价格规则停用。
CreditPriceRuleStatusInactive = "inactive"
)
const (
// CreditRewardRuleStatusActive 表示奖励规则启用。
CreditRewardRuleStatusActive = "active"
// CreditRewardRuleStatusInactive 表示奖励规则停用。
CreditRewardRuleStatusInactive = "inactive"
)
// CreditAccount 是 Credit 权威余额表。
//
// 职责边界:
// 1. 只保存用户在 TokenStore 账本口径下的当前余额与累计统计;
// 2. balance 允许被异步结算扣到 0 以下,后续由 Guard 和充值链路阻断新增调用;
// 3. 不保存逐笔明细,逐笔事实统一以 credit_ledger 为准。
type CreditAccount struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_credit_accounts_user;comment:用户ID"`
Balance int64 `gorm:"column:balance;not null;default:0;comment:当前Credit余额"`
TotalRecharged int64 `gorm:"column:total_recharged;not null;default:0;comment:累计购买入账"`
TotalRewarded int64 `gorm:"column:total_rewarded;not null;default:0;comment:累计奖励入账"`
TotalConsumed int64 `gorm:"column:total_consumed;not null;default:0;comment:累计扣费出账"`
LastLedgerEventID string `gorm:"column:last_ledger_event_id;type:varchar(128);comment:最近一次账本事件ID"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditAccount) TableName() string {
return "credit_accounts"
}
// CreditLedger 是 Credit 权威流水表。
//
// 职责边界:
// 1. event_id 是最终幂等键,所有异步扣费、充值、奖励都依赖它去重;
// 2. amount 使用带符号值正数表示入账负数表示扣费0 表示消费成功但不影响余额;
// 3. balance_before / balance_after 记录事件落账时的权威余额快照。
type CreditLedger struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_credit_ledger_event;comment:最终幂等事件ID"`
UserID uint64 `gorm:"column:user_id;not null;index:idx_credit_ledger_user_created,priority:1;comment:用户ID"`
Source string `gorm:"column:source;type:varchar(32);not null;index:idx_credit_ledger_user_created,priority:2;comment:purchase/charge/forum_like/forum_import/manual"`
SourceLabel string `gorm:"column:source_label;type:varchar(64);comment:来源展示文案"`
Direction string `gorm:"column:direction;type:varchar(16);not null;comment:income/expense"`
OrderID *uint64 `gorm:"column:order_id;index:idx_credit_ledger_order;comment:关联订单ID"`
SourceRefID *string `gorm:"column:source_ref_id;type:varchar(128);index:idx_credit_ledger_source_ref;comment:来源业务ID"`
Amount int64 `gorm:"column:amount;not null;comment:本次Credit变动正数入账负数扣费"`
BalanceBefore int64 `gorm:"column:balance_before;not null;default:0;comment:落账前余额"`
BalanceAfter int64 `gorm:"column:balance_after;not null;default:0;comment:落账后余额"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'applied';index:idx_credit_ledger_status;comment:applied/skipped/failed"`
Description string `gorm:"column:description;type:varchar(255);comment:展示描述"`
MetadataJSON string `gorm:"column:metadata_json;type:json;comment:扩展元数据"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_credit_ledger_user_created,priority:3;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditLedger) TableName() string {
return "credit_ledger"
}
// CreditProduct 是 Credit 商店商品表。
type CreditProduct struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
SKU string `gorm:"column:sku;type:varchar(64);not null;uniqueIndex:uk_credit_products_sku;comment:商品稳定编码"`
Name string `gorm:"column:name;type:varchar(80);not null;comment:商品名称"`
Description string `gorm:"column:description;type:varchar(255);comment:商品描述"`
CreditAmount int64 `gorm:"column:credit_amount;not null;comment:包含Credit数量"`
PriceCent int64 `gorm:"column:price_cent;not null;comment:价格,单位分"`
OriginalPriceCent int64 `gorm:"column:original_price_cent;not null;default:0;comment:优惠前价格,单位分"`
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
Badge string `gorm:"column:badge;type:varchar(32);comment:角标"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_credit_products_status_sort,priority:1;comment:active/inactive"`
SortOrder int `gorm:"column:sort_order;not null;default:0;index:idx_credit_products_status_sort,priority:2;comment:展示排序"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditProduct) TableName() string {
return "credit_products"
}
// CreditOrder 是 Credit 商品订单表。
type CreditOrder struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex:uk_credit_orders_order_no;comment:订单号"`
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_credit_orders_user_idem,priority:1;index:idx_credit_orders_user_status_created,priority:1;comment:下单用户ID"`
ProductID uint64 `gorm:"column:product_id;not null;index:idx_credit_orders_product;comment:商品ID"`
ProductSKU string `gorm:"column:product_sku;type:varchar(64);not null;comment:商品SKU快照"`
ProductName string `gorm:"column:product_name;type:varchar(80);not null;comment:商品名称快照"`
ProductSnapshotJSON string `gorm:"column:product_snapshot_json;type:json;not null;comment:商品完整快照JSON"`
Quantity int `gorm:"column:quantity;not null;default:1;comment:购买数量"`
CreditAmount int64 `gorm:"column:credit_amount;not null;comment:订单总Credit数量"`
AmountCent int64 `gorm:"column:amount_cent;not null;comment:订单总金额,单位分"`
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_credit_orders_user_status_created,priority:2;comment:pending/paid/credited/closed"`
PaymentMode string `gorm:"column:payment_mode;type:varchar(32);not null;default:'mock';comment:支付模式"`
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_credit_orders_user_idem,priority:2;comment:创建订单幂等键"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付确认时间"`
CreditedAt *time.Time `gorm:"column:credited_at;comment:入账时间"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_credit_orders_user_status_created,priority:3;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditOrder) TableName() string {
return "credit_orders"
}
// CreditPriceRule 是 LLM Credit 计价规则表。
//
// 职责边界:
// 1. 该表表达“某个 provider/model 在某场景下如何换算人民币与 Credit”的运营配置
// 2. 第二步先完成表结构与读取能力,具体由 LLM 服务如何引用放到后续切流阶段;
// 3. 当前结算事件已带出最终 rmb_cost_micros 与 credit_cost因此消费侧不在这里二次计算。
type CreditPriceRule struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Scene string `gorm:"column:scene;type:varchar(64);not null;index:idx_credit_price_rules_scene_status,priority:1;comment:计费场景"`
ProviderName string `gorm:"column:provider_name;type:varchar(64);not null;comment:模型提供方"`
ModelName string `gorm:"column:model_name;type:varchar(128);not null;comment:模型名称"`
InputPriceMicros int64 `gorm:"column:input_price_micros;not null;default:0;comment:输入Token单价单位微人民币"`
OutputPriceMicros int64 `gorm:"column:output_price_micros;not null;default:0;comment:输出Token单价单位微人民币"`
CachedPriceMicros int64 `gorm:"column:cached_price_micros;not null;default:0;comment:缓存Token单价单位微人民币"`
ReasoningPriceMicros int64 `gorm:"column:reasoning_price_micros;not null;default:0;comment:推理Token单价单位微人民币"`
CreditPerYuan int64 `gorm:"column:credit_per_yuan;not null;default:0;comment:1元人民币换算多少Credit"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'inactive';index:idx_credit_price_rules_scene_status,priority:2;comment:active/inactive"`
Priority int `gorm:"column:priority;not null;default:0;comment:匹配优先级,越大越优先"`
Description string `gorm:"column:description;type:varchar(255);comment:规则说明"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditPriceRule) TableName() string {
return "credit_price_rules"
}
// CreditRewardRule 是 Credit 奖励规则表。
type CreditRewardRule struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Source string `gorm:"column:source;type:varchar(32);not null;uniqueIndex:uk_credit_reward_rules_source;comment:奖励来源"`
Name string `gorm:"column:name;type:varchar(80);not null;comment:规则名称"`
Amount int64 `gorm:"column:amount;not null;comment:奖励Credit数量"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_credit_reward_rules_status;comment:active/inactive"`
Description string `gorm:"column:description;type:varchar(255);comment:规则描述"`
ConfigJSON *string `gorm:"column:config_json;type:json;comment:扩展配置"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (CreditRewardRule) TableName() string {
return "credit_reward_rules"
}

View File

@@ -1,155 +0,0 @@
package model
import "time"
const (
// TokenProductStatusActive 表示商品可在 Token 商店展示和购买。
TokenProductStatusActive = "active"
// TokenProductStatusInactive 表示商品已下架,不再对前端展示。
TokenProductStatusInactive = "inactive"
)
const (
// TokenOrderStatusPending 表示订单已创建,等待支付确认。
TokenOrderStatusPending = "pending"
// TokenOrderStatusPaid 表示订单已确认支付,等待写入获取账本。
TokenOrderStatusPaid = "paid"
// TokenOrderStatusGranted 表示订单已经写入 token_grants 获取账本。
TokenOrderStatusGranted = "granted"
// TokenOrderStatusClosed 表示订单关闭P0 暂不实现复杂关闭流程。
TokenOrderStatusClosed = "closed"
)
const (
// TokenGrantStatusRecorded 表示 Token 获取事实已记录在 token-store 内。
TokenGrantStatusRecorded = "recorded"
// TokenGrantStatusApplied 表示后续已同步到 user/auth 权威额度。
TokenGrantStatusApplied = "applied"
// TokenGrantStatusSkipped 表示命中奖励规则或幂等条件后跳过发放。
TokenGrantStatusSkipped = "skipped"
// TokenGrantStatusFailed 表示记录或后续同步失败,可按 event_id 重试。
TokenGrantStatusFailed = "failed"
)
const (
// TokenGrantSourcePurchase 表示购买 Token 商品产生的获取记录。
TokenGrantSourcePurchase = "purchase"
// TokenGrantSourceForumLike 表示计划被点赞产生的作者奖励。
TokenGrantSourceForumLike = "forum_like"
// TokenGrantSourceForumImport 表示计划被导入产生的作者奖励。
TokenGrantSourceForumImport = "forum_import"
// TokenGrantSourceManual 预留人工补偿来源P0 不做管理后台。
TokenGrantSourceManual = "manual"
)
const (
// TokenRewardRuleStatusActive 表示奖励规则启用。
TokenRewardRuleStatusActive = "active"
// TokenRewardRuleStatusInactive 表示奖励规则停用。
TokenRewardRuleStatusInactive = "inactive"
)
// TokenProduct 是 Token 商店商品表。
//
// 职责边界:
// 1. P0 从表读取商品,由 seed 初始化 2-3 个固定商品;
// 2. 不承载真实支付渠道配置,也不做商品管理后台;
// 3. 下单时会复制商品快照到订单,避免后续改价影响历史订单。
type TokenProduct struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
SKU string `gorm:"column:sku;type:varchar(64);not null;uniqueIndex:uk_token_products_sku;comment:商品稳定编码"`
Name string `gorm:"column:name;type:varchar(80);not null;comment:商品名称"`
Description string `gorm:"column:description;type:varchar(255);comment:商品描述"`
TokenAmount int64 `gorm:"column:token_amount;not null;comment:商品包含Token数量"`
PriceCent int64 `gorm:"column:price_cent;not null;comment:价格,单位分"`
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
Badge string `gorm:"column:badge;type:varchar(32);comment:前端角标"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_token_products_status_sort,priority:1;comment:active/inactive"`
SortOrder int `gorm:"column:sort_order;not null;default:0;index:idx_token_products_status_sort,priority:2;comment:展示排序"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (TokenProduct) TableName() string {
return "token_products"
}
// TokenOrder 是 Token 商品订单表。
//
// 职责边界:
// 1. 记录用户购买商品的订单状态机;
// 2. P0 只支持 mock paid不接真实支付网关
// 3. granted 只表示已写入 token-store 获取账本,不代表已同步到 user/auth 权威额度。
type TokenOrder struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex:uk_token_orders_order_no;comment:订单号"`
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_token_orders_user_idem,priority:1;index:idx_token_orders_user_status_created,priority:1;comment:下单用户ID"`
ProductID uint64 `gorm:"column:product_id;not null;index:idx_token_orders_product;comment:商品ID"`
ProductSKU string `gorm:"column:product_sku;type:varchar(64);not null;comment:商品SKU快照"`
ProductName string `gorm:"column:product_name;type:varchar(80);not null;comment:商品名称快照"`
ProductSnapshotJSON string `gorm:"column:product_snapshot_json;type:json;not null;comment:商品完整快照JSON"`
Quantity int `gorm:"column:quantity;not null;default:1;comment:购买数量"`
TokenAmount int64 `gorm:"column:token_amount;not null;comment:订单总Token数量"`
AmountCent int64 `gorm:"column:amount_cent;not null;comment:订单总金额,单位分"`
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_token_orders_user_status_created,priority:2;comment:pending/paid/granted/closed"`
PaymentMode string `gorm:"column:payment_mode;type:varchar(32);not null;default:'mock';comment:支付模式P0为mock"`
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_token_orders_user_idem,priority:2;comment:创建订单幂等键"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付确认时间"`
GrantedAt *time.Time `gorm:"column:granted_at;comment:写入获取账本时间"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_token_orders_user_status_created,priority:3;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (TokenOrder) TableName() string {
return "token_orders"
}
// TokenGrant 是 Token 获取账本表。
//
// 职责边界:
// 1. 记录购买、论坛点赞奖励、论坛导入奖励等 Token 获取事实;
// 2. event_id 是最终幂等边界,避免订单或 outbox 重试重复发放;
// 3. P0 不直接修改 users 表quota_applied 默认为 false。
type TokenGrant struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_token_grants_event;comment:幂等事件ID"`
UserID uint64 `gorm:"column:user_id;not null;index:idx_token_grants_user_source_created,priority:1;comment:获得Token的用户ID"`
Source string `gorm:"column:source;type:varchar(32);not null;index:idx_token_grants_user_source_created,priority:2;comment:purchase/forum_like/forum_import/manual"`
SourceLabel string `gorm:"column:source_label;type:varchar(64);comment:前端展示来源"`
SourceRefID *uint64 `gorm:"column:source_ref_id;index:idx_token_grants_source_ref;comment:来源业务ID如order_id/post_id/import_id"`
OrderID *uint64 `gorm:"column:order_id;index:idx_token_grants_order;comment:购买订单ID非购买来源为空"`
Amount int64 `gorm:"column:amount;not null;comment:获取Token数量"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'recorded';index:idx_token_grants_status;comment:recorded/applied/skipped/failed"`
QuotaApplied bool `gorm:"column:quota_applied;not null;default:false;comment:是否已同步到user/auth权威额度"`
Description string `gorm:"column:description;type:varchar(255);comment:前端展示描述"`
AppliedAt *time.Time `gorm:"column:applied_at;comment:同步到权威额度时间"`
LastError *string `gorm:"column:last_error;type:text;comment:后续同步失败原因"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_token_grants_user_source_created,priority:3;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (TokenGrant) TableName() string {
return "token_grants"
}
// TokenRewardRule 是社区奖励规则表。
//
// 职责边界:
// 1. P0 可用 seed 初始化点赞、导入奖励额度;
// 2. 不提供管理后台,规则调整先通过配置或 seed 变更;
// 3. 规则命中后的最终发放仍以 token_grants.event_id 幂等为准。
type TokenRewardRule struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
Source string `gorm:"column:source;type:varchar(32);not null;uniqueIndex:uk_token_reward_rules_source;comment:forum_like/forum_import"`
Name string `gorm:"column:name;type:varchar(80);not null;comment:规则名称"`
Amount int64 `gorm:"column:amount;not null;comment:奖励Token数量"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_token_reward_rules_status;comment:active/inactive"`
ConfigJSON *string `gorm:"column:config_json;type:json;comment:预留扩展配置"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
}
func (TokenRewardRule) TableName() string {
return "token_reward_rules"
}

View File

@@ -0,0 +1,392 @@
package rpc
import (
"context"
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
func (h *Handler) GetCreditBalanceSnapshot(ctx context.Context, req *pb.GetCreditBalanceSnapshotRequest) (*pb.GetCreditBalanceSnapshotResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
snapshot, err := svc.GetCreditBalanceSnapshot(ctx, req.UserId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetCreditBalanceSnapshotResponse{Snapshot: creditBalanceSnapshotToPB(snapshot)}, nil
}
func (h *Handler) GetCreditConsumptionDashboard(ctx context.Context, req *pb.GetCreditConsumptionDashboardRequest) (*pb.GetCreditConsumptionDashboardResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
dashboard, err := svc.GetCreditConsumptionDashboard(ctx, creditcontracts.GetCreditConsumptionDashboardRequest{
ActorUserID: req.ActorUserId,
Period: req.Period,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetCreditConsumptionDashboardResponse{Dashboard: creditConsumptionDashboardToPB(dashboard)}, nil
}
func (h *Handler) ListCreditProducts(ctx context.Context, req *pb.ListCreditProductsRequest) (*pb.ListCreditProductsResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, err := svc.ListCreditProducts(ctx, req.ActorUserId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditProductsResponse{Items: creditProductsToPB(items)}, nil
}
func (h *Handler) CreateCreditOrder(ctx context.Context, req *pb.CreateCreditOrderRequest) (*pb.CreateCreditOrderResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.CreateCreditOrder(ctx, creditcontracts.CreateCreditOrderRequest{
ActorUserID: req.ActorUserId,
ProductID: req.ProductId,
Quantity: int(req.Quantity),
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.CreateCreditOrderResponse{Order: creditOrderToPB(order)}, nil
}
func (h *Handler) ListCreditOrders(ctx context.Context, req *pb.ListCreditOrdersRequest) (*pb.ListCreditOrdersResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, page, err := svc.ListCreditOrders(ctx, creditcontracts.ListCreditOrdersRequest{
ActorUserID: req.ActorUserId,
Page: int(req.Page),
PageSize: int(req.PageSize),
Status: req.Status,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditOrdersResponse{
Items: creditOrdersToPB(items),
Page: creditPageToPB(page),
}, nil
}
func (h *Handler) GetCreditOrder(ctx context.Context, req *pb.GetCreditOrderRequest) (*pb.GetCreditOrderResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.GetCreditOrder(ctx, req.ActorUserId, req.OrderId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetCreditOrderResponse{Order: creditOrderToPB(order)}, nil
}
func (h *Handler) MockPaidCreditOrder(ctx context.Context, req *pb.MockPaidCreditOrderRequest) (*pb.MockPaidCreditOrderResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.MockPaidCreditOrder(ctx, creditcontracts.MockPaidCreditOrderRequest{
ActorUserID: req.ActorUserId,
OrderID: req.OrderId,
MockChannel: req.MockChannel,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.MockPaidCreditOrderResponse{Order: creditOrderToPB(order)}, nil
}
func (h *Handler) ListCreditTransactions(ctx context.Context, req *pb.ListCreditTransactionsRequest) (*pb.ListCreditTransactionsResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, page, err := svc.ListCreditTransactions(ctx, creditcontracts.ListCreditTransactionsRequest{
ActorUserID: req.ActorUserId,
Page: int(req.Page),
PageSize: int(req.PageSize),
Source: req.Source,
Direction: req.Direction,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditTransactionsResponse{
Items: creditTransactionsToPB(items),
Page: creditPageToPB(page),
}, nil
}
func (h *Handler) ListCreditPriceRules(ctx context.Context, req *pb.ListCreditPriceRulesRequest) (*pb.ListCreditPriceRulesResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, err := svc.ListCreditPriceRules(ctx, creditcontracts.ListCreditPriceRulesRequest{
Scene: req.Scene,
ProviderName: req.ProviderName,
ModelName: req.ModelName,
Status: req.Status,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditPriceRulesResponse{Items: creditPriceRulesToPB(items)}, nil
}
func (h *Handler) ListCreditRewardRules(ctx context.Context, req *pb.ListCreditRewardRulesRequest) (*pb.ListCreditRewardRulesResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, err := svc.ListCreditRewardRules(ctx, creditcontracts.ListCreditRewardRulesRequest{
Source: req.Source,
Status: req.Status,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListCreditRewardRulesResponse{Items: creditRewardRulesToPB(items)}, nil
}
func creditPageToPB(page creditcontracts.PageResult) *pb.PageResponse {
return &pb.PageResponse{
Page: int32(page.Page),
PageSize: int32(page.PageSize),
Total: int32(page.Total),
HasMore: page.HasMore,
}
}
func creditBalanceSnapshotToPB(snapshot *creditcontracts.CreditBalanceSnapshot) *pb.CreditBalanceSnapshotView {
if snapshot == nil {
return nil
}
return &pb.CreditBalanceSnapshotView{
UserId: snapshot.UserID,
Balance: snapshot.Balance,
TotalRecharged: snapshot.TotalRecharged,
TotalRewarded: snapshot.TotalRewarded,
TotalConsumed: snapshot.TotalConsumed,
IsBlocked: snapshot.IsBlocked,
SnapshotSource: snapshot.SnapshotSource,
UpdatedAt: snapshot.UpdatedAt,
}
}
func creditConsumptionDashboardToPB(view *creditcontracts.CreditConsumptionDashboardView) *pb.CreditConsumptionDashboardView {
if view == nil {
return nil
}
return &pb.CreditConsumptionDashboardView{
Period: view.Period,
CreditConsumed: view.CreditConsumed,
TokenConsumed: view.TokenConsumed,
}
}
func creditProductToPB(product creditcontracts.CreditProductView) *pb.CreditProductView {
return &pb.CreditProductView{
ProductId: product.ProductID,
Name: product.Name,
Description: product.Description,
CreditAmount: product.CreditAmount,
PriceCent: product.PriceCent,
OriginalPriceCent: product.OriginalPriceCent,
PriceText: product.PriceText,
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: int32(product.SortOrder),
}
}
func creditProductsToPB(items []creditcontracts.CreditProductView) []*pb.CreditProductView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditProductView, 0, len(items))
for i := range items {
result = append(result, creditProductToPB(items[i]))
}
return result
}
func creditOrderToPB(order *creditcontracts.CreditOrderView) *pb.CreditOrderView {
if order == nil {
return nil
}
return &pb.CreditOrderView{
OrderId: order.OrderID,
OrderNo: order.OrderNo,
Status: order.Status,
CreditAmount: order.CreditAmount,
AmountCent: order.AmountCent,
PriceText: order.PriceText,
Currency: order.Currency,
PaymentMode: order.PaymentMode,
CreatedAt: order.CreatedAt,
PaidAt: tokenStringFromPtr(order.PaidAt),
CreditedAt: tokenStringFromPtr(order.CreditedAt),
ProductSnapshot: order.ProductSnapshot,
ProductName: order.ProductName,
Quantity: int32(order.Quantity),
}
}
func creditOrdersToPB(items []creditcontracts.CreditOrderView) []*pb.CreditOrderView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditOrderView, 0, len(items))
for i := range items {
item := items[i]
result = append(result, creditOrderToPB(&item))
}
return result
}
func creditTransactionToPB(item creditcontracts.CreditTransactionView) *pb.CreditTransactionView {
result := &pb.CreditTransactionView{
TransactionId: item.TransactionID,
EventId: item.EventID,
Source: item.Source,
SourceLabel: item.SourceLabel,
Direction: item.Direction,
Amount: item.Amount,
BalanceAfter: item.BalanceAfter,
Status: item.Status,
Description: item.Description,
MetadataJson: item.MetadataJSON,
CreatedAt: item.CreatedAt,
}
if item.OrderID != nil {
result.OrderId = *item.OrderID
}
return result
}
func creditTransactionsToPB(items []creditcontracts.CreditTransactionView) []*pb.CreditTransactionView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditTransactionView, 0, len(items))
for i := range items {
result = append(result, creditTransactionToPB(items[i]))
}
return result
}
func creditPriceRuleToPB(rule creditcontracts.CreditPriceRuleView) *pb.CreditPriceRuleView {
return &pb.CreditPriceRuleView{
RuleId: rule.RuleID,
Scene: rule.Scene,
ProviderName: rule.ProviderName,
ModelName: rule.ModelName,
InputPriceMicros: rule.InputPriceMicros,
OutputPriceMicros: rule.OutputPriceMicros,
CachedPriceMicros: rule.CachedPriceMicros,
ReasoningPriceMicros: rule.ReasoningPriceMicros,
CreditPerYuan: rule.CreditPerYuan,
Status: rule.Status,
Priority: int32(rule.Priority),
Description: rule.Description,
}
}
func creditPriceRulesToPB(items []creditcontracts.CreditPriceRuleView) []*pb.CreditPriceRuleView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditPriceRuleView, 0, len(items))
for i := range items {
result = append(result, creditPriceRuleToPB(items[i]))
}
return result
}
func creditRewardRuleToPB(rule creditcontracts.CreditRewardRuleView) *pb.CreditRewardRuleView {
return &pb.CreditRewardRuleView{
RuleId: rule.RuleID,
Source: rule.Source,
Name: rule.Name,
Amount: rule.Amount,
Status: rule.Status,
Description: rule.Description,
}
}
func creditRewardRulesToPB(items []creditcontracts.CreditRewardRuleView) []*pb.CreditRewardRuleView {
if len(items) == 0 {
return nil
}
result := make([]*pb.CreditRewardRuleView, 0, len(items))
for i := range items {
result = append(result, creditRewardRuleToPB(items[i]))
}
return result
}
func tokenStringFromPtr(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -4,10 +4,10 @@ import (
"context" "context"
"errors" "errors"
"github.com/LoveLosita/smartflow/backend/shared/respond"
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb" "github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
tokenstoresv "github.com/LoveLosita/smartflow/backend/services/tokenstore/sv" tokenstoresv "github.com/LoveLosita/smartflow/backend/services/tokenstore/sv"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
type Handler struct { type Handler struct {
@@ -20,11 +20,6 @@ func NewHandler(svc *tokenstoresv.Service) *Handler {
} }
// service 负责统一校验 RPC 层依赖是否已经注入。 // service 负责统一校验 RPC 层依赖是否已经注入。
//
// 职责边界:
// 1. 只判断 handler 自身和业务 service 是否可用;
// 2. 不负责支付状态流转、订单幂等和 grant 账本写入;
// 3. 失败时返回可直接转成 gRPC status 的业务错误。
func (h *Handler) service() (*tokenstoresv.Service, error) { func (h *Handler) service() (*tokenstoresv.Service, error) {
if h == nil || h.svc == nil { if h == nil || h.svc == nil {
return nil, errors.New("tokenstore service dependency not initialized") return nil, errors.New("tokenstore service dependency not initialized")
@@ -32,282 +27,39 @@ func (h *Handler) service() (*tokenstoresv.Service, error) {
return h.svc, nil return h.svc, nil
} }
// GetSummary 负责把 Token 概览请求从 gRPC 协议转成内部服务调用 // GetSummary 保留旧 token RPC 方法壳,统一返回已下线
func (h *Handler) GetSummary(ctx context.Context, req *pb.GetTokenSummaryRequest) (*pb.GetTokenSummaryResponse, error) { func (h *Handler) GetSummary(context.Context, *pb.GetTokenSummaryRequest) (*pb.GetTokenSummaryResponse, error) {
svc, err := h.service() return nil, legacyTokenMethodRemoved()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
summary, err := svc.GetSummary(ctx, req.ActorUserId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetTokenSummaryResponse{Summary: tokenSummaryToPB(summary)}, nil
} }
func (h *Handler) ListProducts(ctx context.Context, req *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, error) { func (h *Handler) ListProducts(context.Context, *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, error) {
svc, err := h.service() return nil, legacyTokenMethodRemoved()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, err := svc.ListProducts(ctx, req.ActorUserId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListTokenProductsResponse{Items: tokenProductsToPB(items)}, nil
} }
func (h *Handler) CreateOrder(ctx context.Context, req *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) { func (h *Handler) CreateOrder(context.Context, *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) {
svc, err := h.service() return nil, legacyTokenMethodRemoved()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.CreateOrder(ctx, tokencontracts.CreateTokenOrderRequest{
ActorUserID: req.ActorUserId,
ProductID: req.ProductId,
Quantity: int(req.Quantity),
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.CreateTokenOrderResponse{Order: tokenOrderToPB(order)}, nil
} }
func (h *Handler) ListOrders(ctx context.Context, req *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, error) { func (h *Handler) ListOrders(context.Context, *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, error) {
svc, err := h.service() return nil, legacyTokenMethodRemoved()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, page, err := svc.ListOrders(ctx, tokencontracts.ListTokenOrdersRequest{
ActorUserID: req.ActorUserId,
Page: int(req.Page),
PageSize: int(req.PageSize),
Status: req.Status,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListTokenOrdersResponse{
Items: tokenOrdersToPB(items),
Page: tokenPageToPB(page),
}, nil
} }
func (h *Handler) GetOrder(ctx context.Context, req *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) { func (h *Handler) GetOrder(context.Context, *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) {
svc, err := h.service() return nil, legacyTokenMethodRemoved()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.GetOrder(ctx, req.ActorUserId, req.OrderId)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.GetTokenOrderResponse{Order: tokenOrderToPB(order)}, nil
} }
func (h *Handler) MockPaidOrder(ctx context.Context, req *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, error) { func (h *Handler) MockPaidOrder(context.Context, *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, error) {
svc, err := h.service() return nil, legacyTokenMethodRemoved()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
order, err := svc.MockPaidOrder(ctx, tokencontracts.MockPaidOrderRequest{
ActorUserID: req.ActorUserId,
OrderID: req.OrderId,
MockChannel: req.MockChannel,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.MockPaidOrderResponse{Order: tokenOrderToPB(order)}, nil
} }
func (h *Handler) ListGrants(ctx context.Context, req *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) { func (h *Handler) ListGrants(context.Context, *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) {
svc, err := h.service() return nil, legacyTokenMethodRemoved()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
items, page, err := svc.ListGrants(ctx, tokencontracts.ListTokenGrantsRequest{
ActorUserID: req.ActorUserId,
Page: int(req.Page),
PageSize: int(req.PageSize),
Source: req.Source,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.ListTokenGrantsResponse{
Items: tokenGrantsToPB(items),
Page: tokenPageToPB(page),
}, nil
} }
// RecordForumRewardGrant 负责把论坛 outbox 奖励事件转成 token-store 内部账本写入调用。 func (h *Handler) RecordForumRewardGrant(context.Context, *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) {
func (h *Handler) RecordForumRewardGrant(ctx context.Context, req *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) { return nil, legacyTokenMethodRemoved()
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{
EventID: req.EventId,
ReceiverUserID: req.ReceiverUserId,
Source: req.Source,
SourceRefID: req.SourceRefId,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.RecordForumRewardGrantResponse{Grant: tokenGrantToPB(grant)}, nil
} }
func tokenPageToPB(page tokencontracts.PageResult) *pb.PageResponse { func legacyTokenMethodRemoved() error {
return &pb.PageResponse{ return status.Error(codes.Unimplemented, "legacy token API has been removed; use credit APIs instead")
Page: int32(page.Page),
PageSize: int32(page.PageSize),
Total: int32(page.Total),
HasMore: page.HasMore,
}
}
func tokenSummaryToPB(summary *tokencontracts.TokenSummary) *pb.TokenSummary {
if summary == nil {
return nil
}
return &pb.TokenSummary{
RecordedTokenTotal: summary.RecordedTokenTotal,
AppliedTokenTotal: summary.AppliedTokenTotal,
PendingApplyTokenTotal: summary.PendingApplyTokenTotal,
QuotaSyncStatus: summary.QuotaSyncStatus,
Tip: summary.Tip,
}
}
func tokenProductToPB(product tokencontracts.TokenProductView) *pb.TokenProductView {
return &pb.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: int32(product.SortOrder),
}
}
func tokenProductsToPB(items []tokencontracts.TokenProductView) []*pb.TokenProductView {
if len(items) == 0 {
return nil
}
result := make([]*pb.TokenProductView, 0, len(items))
for i := range items {
result = append(result, tokenProductToPB(items[i]))
}
return result
}
func tokenGrantToPB(grant *tokencontracts.TokenGrantView) *pb.TokenGrantView {
if grant == nil {
return nil
}
return &pb.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 tokenGrantsToPB(items []tokencontracts.TokenGrantView) []*pb.TokenGrantView {
if len(items) == 0 {
return nil
}
result := make([]*pb.TokenGrantView, 0, len(items))
for i := range items {
item := items[i]
result = append(result, tokenGrantToPB(&item))
}
return result
}
func tokenOrderToPB(order *tokencontracts.TokenOrderView) *pb.TokenOrderView {
if order == nil {
return nil
}
return &pb.TokenOrderView{
OrderId: order.OrderID,
OrderNo: order.OrderNo,
Status: order.Status,
TokenAmount: order.TokenAmount,
AmountCent: order.AmountCent,
PriceText: order.PriceText,
Currency: order.Currency,
PaymentMode: order.PaymentMode,
Grant: tokenGrantToPB(order.Grant),
CreatedAt: order.CreatedAt,
PaidAt: tokenStringFromPtr(order.PaidAt),
GrantedAt: tokenStringFromPtr(order.GrantedAt),
ProductSnapshot: order.ProductSnapshot,
ProductName: order.ProductName,
Quantity: int32(order.Quantity),
}
}
func tokenOrdersToPB(items []tokencontracts.TokenOrderView) []*pb.TokenOrderView {
if len(items) == 0 {
return nil
}
result := make([]*pb.TokenOrderView, 0, len(items))
for i := range items {
item := items[i]
result = append(result, tokenOrderToPB(&item))
}
return result
}
func tokenStringFromPtr(value *string) string {
if value == nil {
return ""
}
return *value
} }

View File

@@ -229,3 +229,299 @@ type RecordForumRewardGrantResponse struct {
func (m *RecordForumRewardGrantResponse) Reset() { *m = RecordForumRewardGrantResponse{} } func (m *RecordForumRewardGrantResponse) Reset() { *m = RecordForumRewardGrantResponse{} }
func (m *RecordForumRewardGrantResponse) String() string { return proto.CompactTextString(m) } func (m *RecordForumRewardGrantResponse) String() string { return proto.CompactTextString(m) }
func (*RecordForumRewardGrantResponse) ProtoMessage() {} func (*RecordForumRewardGrantResponse) ProtoMessage() {}
type CreditBalanceSnapshotView struct {
UserId uint64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Balance int64 `protobuf:"varint,2,opt,name=balance,proto3" json:"balance,omitempty"`
IsBlocked bool `protobuf:"varint,3,opt,name=is_blocked,json=isBlocked,proto3" json:"is_blocked,omitempty"`
SnapshotSource string `protobuf:"bytes,4,opt,name=snapshot_source,json=snapshotSource,proto3" json:"snapshot_source,omitempty"`
UpdatedAt string `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
TotalRecharged int64 `protobuf:"varint,6,opt,name=total_recharged,json=totalRecharged,proto3" json:"total_recharged,omitempty"`
TotalRewarded int64 `protobuf:"varint,7,opt,name=total_rewarded,json=totalRewarded,proto3" json:"total_rewarded,omitempty"`
TotalConsumed int64 `protobuf:"varint,8,opt,name=total_consumed,json=totalConsumed,proto3" json:"total_consumed,omitempty"`
}
func (m *CreditBalanceSnapshotView) Reset() { *m = CreditBalanceSnapshotView{} }
func (m *CreditBalanceSnapshotView) String() string { return proto.CompactTextString(m) }
func (*CreditBalanceSnapshotView) ProtoMessage() {}
type CreditConsumptionDashboardView struct {
Period string `protobuf:"bytes,1,opt,name=period,proto3" json:"period,omitempty"`
CreditConsumed int64 `protobuf:"varint,2,opt,name=credit_consumed,json=creditConsumed,proto3" json:"credit_consumed,omitempty"`
TokenConsumed int64 `protobuf:"varint,3,opt,name=token_consumed,json=tokenConsumed,proto3" json:"token_consumed,omitempty"`
}
func (m *CreditConsumptionDashboardView) Reset() { *m = CreditConsumptionDashboardView{} }
func (m *CreditConsumptionDashboardView) String() string { return proto.CompactTextString(m) }
func (*CreditConsumptionDashboardView) ProtoMessage() {}
type GetCreditConsumptionDashboardRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
Period string `protobuf:"bytes,2,opt,name=period,proto3" json:"period,omitempty"`
}
func (m *GetCreditConsumptionDashboardRequest) Reset() { *m = GetCreditConsumptionDashboardRequest{} }
func (m *GetCreditConsumptionDashboardRequest) String() string { return proto.CompactTextString(m) }
func (*GetCreditConsumptionDashboardRequest) ProtoMessage() {}
type GetCreditConsumptionDashboardResponse struct {
Dashboard *CreditConsumptionDashboardView `protobuf:"bytes,1,opt,name=dashboard,proto3" json:"dashboard,omitempty"`
}
func (m *GetCreditConsumptionDashboardResponse) Reset() { *m = GetCreditConsumptionDashboardResponse{} }
func (m *GetCreditConsumptionDashboardResponse) String() string { return proto.CompactTextString(m) }
func (*GetCreditConsumptionDashboardResponse) ProtoMessage() {}
type CreditProductView struct {
ProductId uint64 `protobuf:"varint,1,opt,name=product_id,json=productId,proto3" json:"product_id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
CreditAmount int64 `protobuf:"varint,4,opt,name=credit_amount,json=creditAmount,proto3" json:"credit_amount,omitempty"`
PriceCent int64 `protobuf:"varint,5,opt,name=price_cent,json=priceCent,proto3" json:"price_cent,omitempty"`
PriceText string `protobuf:"bytes,6,opt,name=price_text,json=priceText,proto3" json:"price_text,omitempty"`
Currency string `protobuf:"bytes,7,opt,name=currency,proto3" json:"currency,omitempty"`
Badge string `protobuf:"bytes,8,opt,name=badge,proto3" json:"badge,omitempty"`
Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"`
SortOrder int32 `protobuf:"varint,10,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
OriginalPriceCent int64 `protobuf:"varint,11,opt,name=original_price_cent,json=originalPriceCent,proto3" json:"original_price_cent,omitempty"`
}
func (m *CreditProductView) Reset() { *m = CreditProductView{} }
func (m *CreditProductView) String() string { return proto.CompactTextString(m) }
func (*CreditProductView) ProtoMessage() {}
type CreditOrderView struct {
OrderId uint64 `protobuf:"varint,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
OrderNo string `protobuf:"bytes,2,opt,name=order_no,json=orderNo,proto3" json:"order_no,omitempty"`
Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"`
CreditAmount int64 `protobuf:"varint,4,opt,name=credit_amount,json=creditAmount,proto3" json:"credit_amount,omitempty"`
AmountCent int64 `protobuf:"varint,5,opt,name=amount_cent,json=amountCent,proto3" json:"amount_cent,omitempty"`
PriceText string `protobuf:"bytes,6,opt,name=price_text,json=priceText,proto3" json:"price_text,omitempty"`
Currency string `protobuf:"bytes,7,opt,name=currency,proto3" json:"currency,omitempty"`
PaymentMode string `protobuf:"bytes,8,opt,name=payment_mode,json=paymentMode,proto3" json:"payment_mode,omitempty"`
CreatedAt string `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
PaidAt string `protobuf:"bytes,10,opt,name=paid_at,json=paidAt,proto3" json:"paid_at,omitempty"`
CreditedAt string `protobuf:"bytes,11,opt,name=credited_at,json=creditedAt,proto3" json:"credited_at,omitempty"`
ProductSnapshot string `protobuf:"bytes,12,opt,name=product_snapshot,json=productSnapshot,proto3" json:"product_snapshot,omitempty"`
ProductName string `protobuf:"bytes,13,opt,name=product_name,json=productName,proto3" json:"product_name,omitempty"`
Quantity int32 `protobuf:"varint,14,opt,name=quantity,proto3" json:"quantity,omitempty"`
}
func (m *CreditOrderView) Reset() { *m = CreditOrderView{} }
func (m *CreditOrderView) String() string { return proto.CompactTextString(m) }
func (*CreditOrderView) ProtoMessage() {}
type CreditTransactionView struct {
TransactionId uint64 `protobuf:"varint,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"`
SourceLabel string `protobuf:"bytes,4,opt,name=source_label,json=sourceLabel,proto3" json:"source_label,omitempty"`
Direction string `protobuf:"bytes,5,opt,name=direction,proto3" json:"direction,omitempty"`
Amount int64 `protobuf:"varint,6,opt,name=amount,proto3" json:"amount,omitempty"`
BalanceAfter int64 `protobuf:"varint,7,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"`
Status string `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"`
Description string `protobuf:"bytes,9,opt,name=description,proto3" json:"description,omitempty"`
MetadataJson string `protobuf:"bytes,10,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"`
CreatedAt string `protobuf:"bytes,11,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
OrderId uint64 `protobuf:"varint,12,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
}
func (m *CreditTransactionView) Reset() { *m = CreditTransactionView{} }
func (m *CreditTransactionView) String() string { return proto.CompactTextString(m) }
func (*CreditTransactionView) ProtoMessage() {}
type CreditPriceRuleView struct {
RuleId uint64 `protobuf:"varint,1,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"`
Scene string `protobuf:"bytes,2,opt,name=scene,proto3" json:"scene,omitempty"`
ProviderName string `protobuf:"bytes,3,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"`
ModelName string `protobuf:"bytes,4,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"`
InputPriceMicros int64 `protobuf:"varint,5,opt,name=input_price_micros,json=inputPriceMicros,proto3" json:"input_price_micros,omitempty"`
OutputPriceMicros int64 `protobuf:"varint,6,opt,name=output_price_micros,json=outputPriceMicros,proto3" json:"output_price_micros,omitempty"`
CachedPriceMicros int64 `protobuf:"varint,7,opt,name=cached_price_micros,json=cachedPriceMicros,proto3" json:"cached_price_micros,omitempty"`
ReasoningPriceMicros int64 `protobuf:"varint,8,opt,name=reasoning_price_micros,json=reasoningPriceMicros,proto3" json:"reasoning_price_micros,omitempty"`
CreditPerYuan int64 `protobuf:"varint,9,opt,name=credit_per_yuan,json=creditPerYuan,proto3" json:"credit_per_yuan,omitempty"`
Status string `protobuf:"bytes,10,opt,name=status,proto3" json:"status,omitempty"`
Priority int32 `protobuf:"varint,11,opt,name=priority,proto3" json:"priority,omitempty"`
Description string `protobuf:"bytes,12,opt,name=description,proto3" json:"description,omitempty"`
}
func (m *CreditPriceRuleView) Reset() { *m = CreditPriceRuleView{} }
func (m *CreditPriceRuleView) String() string { return proto.CompactTextString(m) }
func (*CreditPriceRuleView) ProtoMessage() {}
type CreditRewardRuleView struct {
RuleId uint64 `protobuf:"varint,1,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"`
Source string `protobuf:"bytes,2,opt,name=source,proto3" json:"source,omitempty"`
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"`
Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
}
func (m *CreditRewardRuleView) Reset() { *m = CreditRewardRuleView{} }
func (m *CreditRewardRuleView) String() string { return proto.CompactTextString(m) }
func (*CreditRewardRuleView) ProtoMessage() {}
type GetCreditBalanceSnapshotRequest struct {
UserId uint64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
}
func (m *GetCreditBalanceSnapshotRequest) Reset() { *m = GetCreditBalanceSnapshotRequest{} }
func (m *GetCreditBalanceSnapshotRequest) String() string { return proto.CompactTextString(m) }
func (*GetCreditBalanceSnapshotRequest) ProtoMessage() {}
type GetCreditBalanceSnapshotResponse struct {
Snapshot *CreditBalanceSnapshotView `protobuf:"bytes,1,opt,name=snapshot,proto3" json:"snapshot,omitempty"`
}
func (m *GetCreditBalanceSnapshotResponse) Reset() { *m = GetCreditBalanceSnapshotResponse{} }
func (m *GetCreditBalanceSnapshotResponse) String() string { return proto.CompactTextString(m) }
func (*GetCreditBalanceSnapshotResponse) ProtoMessage() {}
type ListCreditProductsRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
}
func (m *ListCreditProductsRequest) Reset() { *m = ListCreditProductsRequest{} }
func (m *ListCreditProductsRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditProductsRequest) ProtoMessage() {}
type ListCreditProductsResponse struct {
Items []*CreditProductView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
}
func (m *ListCreditProductsResponse) Reset() { *m = ListCreditProductsResponse{} }
func (m *ListCreditProductsResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditProductsResponse) ProtoMessage() {}
type CreateCreditOrderRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
ProductId uint64 `protobuf:"varint,2,opt,name=product_id,json=productId,proto3" json:"product_id,omitempty"`
Quantity int32 `protobuf:"varint,3,opt,name=quantity,proto3" json:"quantity,omitempty"`
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
}
func (m *CreateCreditOrderRequest) Reset() { *m = CreateCreditOrderRequest{} }
func (m *CreateCreditOrderRequest) String() string { return proto.CompactTextString(m) }
func (*CreateCreditOrderRequest) ProtoMessage() {}
type CreateCreditOrderResponse struct {
Order *CreditOrderView `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
}
func (m *CreateCreditOrderResponse) Reset() { *m = CreateCreditOrderResponse{} }
func (m *CreateCreditOrderResponse) String() string { return proto.CompactTextString(m) }
func (*CreateCreditOrderResponse) ProtoMessage() {}
type ListCreditOrdersRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"`
PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
}
func (m *ListCreditOrdersRequest) Reset() { *m = ListCreditOrdersRequest{} }
func (m *ListCreditOrdersRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditOrdersRequest) ProtoMessage() {}
type ListCreditOrdersResponse struct {
Items []*CreditOrderView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
}
func (m *ListCreditOrdersResponse) Reset() { *m = ListCreditOrdersResponse{} }
func (m *ListCreditOrdersResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditOrdersResponse) ProtoMessage() {}
type GetCreditOrderRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
OrderId uint64 `protobuf:"varint,2,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
}
func (m *GetCreditOrderRequest) Reset() { *m = GetCreditOrderRequest{} }
func (m *GetCreditOrderRequest) String() string { return proto.CompactTextString(m) }
func (*GetCreditOrderRequest) ProtoMessage() {}
type GetCreditOrderResponse struct {
Order *CreditOrderView `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
}
func (m *GetCreditOrderResponse) Reset() { *m = GetCreditOrderResponse{} }
func (m *GetCreditOrderResponse) String() string { return proto.CompactTextString(m) }
func (*GetCreditOrderResponse) ProtoMessage() {}
type MockPaidCreditOrderRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
OrderId uint64 `protobuf:"varint,2,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
MockChannel string `protobuf:"bytes,3,opt,name=mock_channel,json=mockChannel,proto3" json:"mock_channel,omitempty"`
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
}
func (m *MockPaidCreditOrderRequest) Reset() { *m = MockPaidCreditOrderRequest{} }
func (m *MockPaidCreditOrderRequest) String() string { return proto.CompactTextString(m) }
func (*MockPaidCreditOrderRequest) ProtoMessage() {}
type MockPaidCreditOrderResponse struct {
Order *CreditOrderView `protobuf:"bytes,1,opt,name=order,proto3" json:"order,omitempty"`
}
func (m *MockPaidCreditOrderResponse) Reset() { *m = MockPaidCreditOrderResponse{} }
func (m *MockPaidCreditOrderResponse) String() string { return proto.CompactTextString(m) }
func (*MockPaidCreditOrderResponse) ProtoMessage() {}
type ListCreditTransactionsRequest struct {
ActorUserId uint64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
Page int32 `protobuf:"varint,2,opt,name=page,proto3" json:"page,omitempty"`
PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
Source string `protobuf:"bytes,4,opt,name=source,proto3" json:"source,omitempty"`
Direction string `protobuf:"bytes,5,opt,name=direction,proto3" json:"direction,omitempty"`
}
func (m *ListCreditTransactionsRequest) Reset() { *m = ListCreditTransactionsRequest{} }
func (m *ListCreditTransactionsRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditTransactionsRequest) ProtoMessage() {}
type ListCreditTransactionsResponse struct {
Items []*CreditTransactionView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
Page *PageResponse `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"`
}
func (m *ListCreditTransactionsResponse) Reset() { *m = ListCreditTransactionsResponse{} }
func (m *ListCreditTransactionsResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditTransactionsResponse) ProtoMessage() {}
type ListCreditPriceRulesRequest struct {
Scene string `protobuf:"bytes,1,opt,name=scene,proto3" json:"scene,omitempty"`
ProviderName string `protobuf:"bytes,2,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"`
ModelName string `protobuf:"bytes,3,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"`
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
}
func (m *ListCreditPriceRulesRequest) Reset() { *m = ListCreditPriceRulesRequest{} }
func (m *ListCreditPriceRulesRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditPriceRulesRequest) ProtoMessage() {}
type ListCreditPriceRulesResponse struct {
Items []*CreditPriceRuleView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
}
func (m *ListCreditPriceRulesResponse) Reset() { *m = ListCreditPriceRulesResponse{} }
func (m *ListCreditPriceRulesResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditPriceRulesResponse) ProtoMessage() {}
type ListCreditRewardRulesRequest struct {
Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
}
func (m *ListCreditRewardRulesRequest) Reset() { *m = ListCreditRewardRulesRequest{} }
func (m *ListCreditRewardRulesRequest) String() string { return proto.CompactTextString(m) }
func (*ListCreditRewardRulesRequest) ProtoMessage() {}
type ListCreditRewardRulesResponse struct {
Items []*CreditRewardRuleView `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
}
func (m *ListCreditRewardRulesResponse) Reset() { *m = ListCreditRewardRulesResponse{} }
func (m *ListCreditRewardRulesResponse) String() string { return proto.CompactTextString(m) }
func (*ListCreditRewardRulesResponse) ProtoMessage() {}

View File

@@ -9,14 +9,24 @@ import (
) )
const ( const (
TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary" TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary"
TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts" TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts"
TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder" TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder"
TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders" TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders"
TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder" TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder"
TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder" TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder"
TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants" TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants"
TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant" TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant"
TokenStoreService_GetCreditBalanceSnapshot_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetCreditBalanceSnapshot"
TokenStoreService_GetCreditConsumptionDashboard_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetCreditConsumptionDashboard"
TokenStoreService_ListCreditProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditProducts"
TokenStoreService_CreateCreditOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateCreditOrder"
TokenStoreService_ListCreditOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditOrders"
TokenStoreService_GetCreditOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetCreditOrder"
TokenStoreService_MockPaidCreditOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidCreditOrder"
TokenStoreService_ListCreditTransactions_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditTransactions"
TokenStoreService_ListCreditPriceRules_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditPriceRules"
TokenStoreService_ListCreditRewardRules_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditRewardRules"
) )
type TokenStoreServiceClient interface { type TokenStoreServiceClient interface {
@@ -28,6 +38,16 @@ type TokenStoreServiceClient interface {
MockPaidOrder(ctx context.Context, in *MockPaidOrderRequest, opts ...grpc.CallOption) (*MockPaidOrderResponse, error) MockPaidOrder(ctx context.Context, in *MockPaidOrderRequest, opts ...grpc.CallOption) (*MockPaidOrderResponse, error)
ListGrants(ctx context.Context, in *ListTokenGrantsRequest, opts ...grpc.CallOption) (*ListTokenGrantsResponse, error) ListGrants(ctx context.Context, in *ListTokenGrantsRequest, opts ...grpc.CallOption) (*ListTokenGrantsResponse, error)
RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error) RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error)
GetCreditBalanceSnapshot(ctx context.Context, in *GetCreditBalanceSnapshotRequest, opts ...grpc.CallOption) (*GetCreditBalanceSnapshotResponse, error)
GetCreditConsumptionDashboard(ctx context.Context, in *GetCreditConsumptionDashboardRequest, opts ...grpc.CallOption) (*GetCreditConsumptionDashboardResponse, error)
ListCreditProducts(ctx context.Context, in *ListCreditProductsRequest, opts ...grpc.CallOption) (*ListCreditProductsResponse, error)
CreateCreditOrder(ctx context.Context, in *CreateCreditOrderRequest, opts ...grpc.CallOption) (*CreateCreditOrderResponse, error)
ListCreditOrders(ctx context.Context, in *ListCreditOrdersRequest, opts ...grpc.CallOption) (*ListCreditOrdersResponse, error)
GetCreditOrder(ctx context.Context, in *GetCreditOrderRequest, opts ...grpc.CallOption) (*GetCreditOrderResponse, error)
MockPaidCreditOrder(ctx context.Context, in *MockPaidCreditOrderRequest, opts ...grpc.CallOption) (*MockPaidCreditOrderResponse, error)
ListCreditTransactions(ctx context.Context, in *ListCreditTransactionsRequest, opts ...grpc.CallOption) (*ListCreditTransactionsResponse, error)
ListCreditPriceRules(ctx context.Context, in *ListCreditPriceRulesRequest, opts ...grpc.CallOption) (*ListCreditPriceRulesResponse, error)
ListCreditRewardRules(ctx context.Context, in *ListCreditRewardRulesRequest, opts ...grpc.CallOption) (*ListCreditRewardRulesResponse, error)
} }
type tokenStoreServiceClient struct { type tokenStoreServiceClient struct {
@@ -70,6 +90,46 @@ func (c *tokenStoreServiceClient) RecordForumRewardGrant(ctx context.Context, in
return invokeTokenStore[RecordForumRewardGrantResponse](ctx, c.cc, TokenStoreService_RecordForumRewardGrant_FullMethodName, in, opts...) return invokeTokenStore[RecordForumRewardGrantResponse](ctx, c.cc, TokenStoreService_RecordForumRewardGrant_FullMethodName, in, opts...)
} }
func (c *tokenStoreServiceClient) GetCreditBalanceSnapshot(ctx context.Context, in *GetCreditBalanceSnapshotRequest, opts ...grpc.CallOption) (*GetCreditBalanceSnapshotResponse, error) {
return invokeTokenStore[GetCreditBalanceSnapshotResponse](ctx, c.cc, TokenStoreService_GetCreditBalanceSnapshot_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) GetCreditConsumptionDashboard(ctx context.Context, in *GetCreditConsumptionDashboardRequest, opts ...grpc.CallOption) (*GetCreditConsumptionDashboardResponse, error) {
return invokeTokenStore[GetCreditConsumptionDashboardResponse](ctx, c.cc, TokenStoreService_GetCreditConsumptionDashboard_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditProducts(ctx context.Context, in *ListCreditProductsRequest, opts ...grpc.CallOption) (*ListCreditProductsResponse, error) {
return invokeTokenStore[ListCreditProductsResponse](ctx, c.cc, TokenStoreService_ListCreditProducts_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) CreateCreditOrder(ctx context.Context, in *CreateCreditOrderRequest, opts ...grpc.CallOption) (*CreateCreditOrderResponse, error) {
return invokeTokenStore[CreateCreditOrderResponse](ctx, c.cc, TokenStoreService_CreateCreditOrder_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditOrders(ctx context.Context, in *ListCreditOrdersRequest, opts ...grpc.CallOption) (*ListCreditOrdersResponse, error) {
return invokeTokenStore[ListCreditOrdersResponse](ctx, c.cc, TokenStoreService_ListCreditOrders_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) GetCreditOrder(ctx context.Context, in *GetCreditOrderRequest, opts ...grpc.CallOption) (*GetCreditOrderResponse, error) {
return invokeTokenStore[GetCreditOrderResponse](ctx, c.cc, TokenStoreService_GetCreditOrder_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) MockPaidCreditOrder(ctx context.Context, in *MockPaidCreditOrderRequest, opts ...grpc.CallOption) (*MockPaidCreditOrderResponse, error) {
return invokeTokenStore[MockPaidCreditOrderResponse](ctx, c.cc, TokenStoreService_MockPaidCreditOrder_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditTransactions(ctx context.Context, in *ListCreditTransactionsRequest, opts ...grpc.CallOption) (*ListCreditTransactionsResponse, error) {
return invokeTokenStore[ListCreditTransactionsResponse](ctx, c.cc, TokenStoreService_ListCreditTransactions_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditPriceRules(ctx context.Context, in *ListCreditPriceRulesRequest, opts ...grpc.CallOption) (*ListCreditPriceRulesResponse, error) {
return invokeTokenStore[ListCreditPriceRulesResponse](ctx, c.cc, TokenStoreService_ListCreditPriceRules_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) ListCreditRewardRules(ctx context.Context, in *ListCreditRewardRulesRequest, opts ...grpc.CallOption) (*ListCreditRewardRulesResponse, error) {
return invokeTokenStore[ListCreditRewardRulesResponse](ctx, c.cc, TokenStoreService_ListCreditRewardRules_FullMethodName, in, opts...)
}
func invokeTokenStore[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) { func invokeTokenStore[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) {
out := new(Resp) out := new(Resp)
err := cc.Invoke(ctx, fullMethod, in, out, opts...) err := cc.Invoke(ctx, fullMethod, in, out, opts...)
@@ -88,6 +148,16 @@ type TokenStoreServiceServer interface {
MockPaidOrder(context.Context, *MockPaidOrderRequest) (*MockPaidOrderResponse, error) MockPaidOrder(context.Context, *MockPaidOrderRequest) (*MockPaidOrderResponse, error)
ListGrants(context.Context, *ListTokenGrantsRequest) (*ListTokenGrantsResponse, error) ListGrants(context.Context, *ListTokenGrantsRequest) (*ListTokenGrantsResponse, error)
RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error) RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error)
GetCreditBalanceSnapshot(context.Context, *GetCreditBalanceSnapshotRequest) (*GetCreditBalanceSnapshotResponse, error)
GetCreditConsumptionDashboard(context.Context, *GetCreditConsumptionDashboardRequest) (*GetCreditConsumptionDashboardResponse, error)
ListCreditProducts(context.Context, *ListCreditProductsRequest) (*ListCreditProductsResponse, error)
CreateCreditOrder(context.Context, *CreateCreditOrderRequest) (*CreateCreditOrderResponse, error)
ListCreditOrders(context.Context, *ListCreditOrdersRequest) (*ListCreditOrdersResponse, error)
GetCreditOrder(context.Context, *GetCreditOrderRequest) (*GetCreditOrderResponse, error)
MockPaidCreditOrder(context.Context, *MockPaidCreditOrderRequest) (*MockPaidCreditOrderResponse, error)
ListCreditTransactions(context.Context, *ListCreditTransactionsRequest) (*ListCreditTransactionsResponse, error)
ListCreditPriceRules(context.Context, *ListCreditPriceRulesRequest) (*ListCreditPriceRulesResponse, error)
ListCreditRewardRules(context.Context, *ListCreditRewardRulesRequest) (*ListCreditRewardRulesResponse, error)
} }
type UnimplementedTokenStoreServiceServer struct{} type UnimplementedTokenStoreServiceServer struct{}
@@ -124,6 +194,46 @@ func (UnimplementedTokenStoreServiceServer) RecordForumRewardGrant(context.Conte
return nil, status.Errorf(codes.Unimplemented, "method RecordForumRewardGrant not implemented") return nil, status.Errorf(codes.Unimplemented, "method RecordForumRewardGrant not implemented")
} }
func (UnimplementedTokenStoreServiceServer) GetCreditBalanceSnapshot(context.Context, *GetCreditBalanceSnapshotRequest) (*GetCreditBalanceSnapshotResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCreditBalanceSnapshot not implemented")
}
func (UnimplementedTokenStoreServiceServer) GetCreditConsumptionDashboard(context.Context, *GetCreditConsumptionDashboardRequest) (*GetCreditConsumptionDashboardResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCreditConsumptionDashboard not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditProducts(context.Context, *ListCreditProductsRequest) (*ListCreditProductsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditProducts not implemented")
}
func (UnimplementedTokenStoreServiceServer) CreateCreditOrder(context.Context, *CreateCreditOrderRequest) (*CreateCreditOrderResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CreateCreditOrder not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditOrders(context.Context, *ListCreditOrdersRequest) (*ListCreditOrdersResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditOrders not implemented")
}
func (UnimplementedTokenStoreServiceServer) GetCreditOrder(context.Context, *GetCreditOrderRequest) (*GetCreditOrderResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCreditOrder not implemented")
}
func (UnimplementedTokenStoreServiceServer) MockPaidCreditOrder(context.Context, *MockPaidCreditOrderRequest) (*MockPaidCreditOrderResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method MockPaidCreditOrder not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditTransactions(context.Context, *ListCreditTransactionsRequest) (*ListCreditTransactionsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditTransactions not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditPriceRules(context.Context, *ListCreditPriceRulesRequest) (*ListCreditPriceRulesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditPriceRules not implemented")
}
func (UnimplementedTokenStoreServiceServer) ListCreditRewardRules(context.Context, *ListCreditRewardRulesRequest) (*ListCreditRewardRulesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListCreditRewardRules not implemented")
}
func RegisterTokenStoreServiceServer(s grpc.ServiceRegistrar, srv TokenStoreServiceServer) { func RegisterTokenStoreServiceServer(s grpc.ServiceRegistrar, srv TokenStoreServiceServer) {
s.RegisterService(&TokenStoreService_ServiceDesc, srv) s.RegisterService(&TokenStoreService_ServiceDesc, srv)
} }
@@ -179,6 +289,36 @@ var TokenStoreService_ServiceDesc = grpc.ServiceDesc{
tokenStoreUnaryHandler[RecordForumRewardGrantRequest]("RecordForumRewardGrant", TokenStoreService_RecordForumRewardGrant_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *RecordForumRewardGrantRequest) (interface{}, error) { tokenStoreUnaryHandler[RecordForumRewardGrantRequest]("RecordForumRewardGrant", TokenStoreService_RecordForumRewardGrant_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *RecordForumRewardGrantRequest) (interface{}, error) {
return s.RecordForumRewardGrant(ctx, req) return s.RecordForumRewardGrant(ctx, req)
}), }),
tokenStoreUnaryHandler[GetCreditBalanceSnapshotRequest]("GetCreditBalanceSnapshot", TokenStoreService_GetCreditBalanceSnapshot_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetCreditBalanceSnapshotRequest) (interface{}, error) {
return s.GetCreditBalanceSnapshot(ctx, req)
}),
tokenStoreUnaryHandler[GetCreditConsumptionDashboardRequest]("GetCreditConsumptionDashboard", TokenStoreService_GetCreditConsumptionDashboard_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetCreditConsumptionDashboardRequest) (interface{}, error) {
return s.GetCreditConsumptionDashboard(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditProductsRequest]("ListCreditProducts", TokenStoreService_ListCreditProducts_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditProductsRequest) (interface{}, error) {
return s.ListCreditProducts(ctx, req)
}),
tokenStoreUnaryHandler[CreateCreditOrderRequest]("CreateCreditOrder", TokenStoreService_CreateCreditOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *CreateCreditOrderRequest) (interface{}, error) {
return s.CreateCreditOrder(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditOrdersRequest]("ListCreditOrders", TokenStoreService_ListCreditOrders_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditOrdersRequest) (interface{}, error) {
return s.ListCreditOrders(ctx, req)
}),
tokenStoreUnaryHandler[GetCreditOrderRequest]("GetCreditOrder", TokenStoreService_GetCreditOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetCreditOrderRequest) (interface{}, error) {
return s.GetCreditOrder(ctx, req)
}),
tokenStoreUnaryHandler[MockPaidCreditOrderRequest]("MockPaidCreditOrder", TokenStoreService_MockPaidCreditOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *MockPaidCreditOrderRequest) (interface{}, error) {
return s.MockPaidCreditOrder(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditTransactionsRequest]("ListCreditTransactions", TokenStoreService_ListCreditTransactions_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditTransactionsRequest) (interface{}, error) {
return s.ListCreditTransactions(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditPriceRulesRequest]("ListCreditPriceRules", TokenStoreService_ListCreditPriceRules_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditPriceRulesRequest) (interface{}, error) {
return s.ListCreditPriceRules(ctx, req)
}),
tokenStoreUnaryHandler[ListCreditRewardRulesRequest]("ListCreditRewardRules", TokenStoreService_ListCreditRewardRules_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditRewardRulesRequest) (interface{}, error) {
return s.ListCreditRewardRules(ctx, req)
}),
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "tokenstore.proto", Metadata: "tokenstore.proto",

View File

@@ -13,6 +13,16 @@ service TokenStoreService {
rpc MockPaidOrder(MockPaidOrderRequest) returns (MockPaidOrderResponse); rpc MockPaidOrder(MockPaidOrderRequest) returns (MockPaidOrderResponse);
rpc ListGrants(ListTokenGrantsRequest) returns (ListTokenGrantsResponse); rpc ListGrants(ListTokenGrantsRequest) returns (ListTokenGrantsResponse);
rpc RecordForumRewardGrant(RecordForumRewardGrantRequest) returns (RecordForumRewardGrantResponse); rpc RecordForumRewardGrant(RecordForumRewardGrantRequest) returns (RecordForumRewardGrantResponse);
rpc GetCreditBalanceSnapshot(GetCreditBalanceSnapshotRequest) returns (GetCreditBalanceSnapshotResponse);
rpc GetCreditConsumptionDashboard(GetCreditConsumptionDashboardRequest) returns (GetCreditConsumptionDashboardResponse);
rpc ListCreditProducts(ListCreditProductsRequest) returns (ListCreditProductsResponse);
rpc CreateCreditOrder(CreateCreditOrderRequest) returns (CreateCreditOrderResponse);
rpc ListCreditOrders(ListCreditOrdersRequest) returns (ListCreditOrdersResponse);
rpc GetCreditOrder(GetCreditOrderRequest) returns (GetCreditOrderResponse);
rpc MockPaidCreditOrder(MockPaidCreditOrderRequest) returns (MockPaidCreditOrderResponse);
rpc ListCreditTransactions(ListCreditTransactionsRequest) returns (ListCreditTransactionsResponse);
rpc ListCreditPriceRules(ListCreditPriceRulesRequest) returns (ListCreditPriceRulesResponse);
rpc ListCreditRewardRules(ListCreditRewardRulesRequest) returns (ListCreditRewardRulesResponse);
} }
message PageResponse { message PageResponse {
@@ -154,3 +164,191 @@ message RecordForumRewardGrantRequest {
message RecordForumRewardGrantResponse { message RecordForumRewardGrantResponse {
TokenGrantView grant = 1; TokenGrantView grant = 1;
} }
message CreditBalanceSnapshotView {
uint64 user_id = 1;
int64 balance = 2;
bool is_blocked = 3;
string snapshot_source = 4;
string updated_at = 5;
int64 total_recharged = 6;
int64 total_rewarded = 7;
int64 total_consumed = 8;
}
message CreditProductView {
uint64 product_id = 1;
string name = 2;
string description = 3;
int64 credit_amount = 4;
int64 price_cent = 5;
string price_text = 6;
string currency = 7;
string badge = 8;
string status = 9;
int32 sort_order = 10;
int64 original_price_cent = 11;
}
message CreditOrderView {
uint64 order_id = 1;
string order_no = 2;
string status = 3;
int64 credit_amount = 4;
int64 amount_cent = 5;
string price_text = 6;
string currency = 7;
string payment_mode = 8;
string created_at = 9;
string paid_at = 10;
string credited_at = 11;
string product_snapshot = 12;
string product_name = 13;
int32 quantity = 14;
}
message CreditTransactionView {
uint64 transaction_id = 1;
string event_id = 2;
string source = 3;
string source_label = 4;
string direction = 5;
int64 amount = 6;
int64 balance_after = 7;
string status = 8;
string description = 9;
string metadata_json = 10;
string created_at = 11;
uint64 order_id = 12;
}
message CreditPriceRuleView {
uint64 rule_id = 1;
string scene = 2;
string provider_name = 3;
string model_name = 4;
int64 input_price_micros = 5;
int64 output_price_micros = 6;
int64 cached_price_micros = 7;
int64 reasoning_price_micros = 8;
int64 credit_per_yuan = 9;
string status = 10;
int32 priority = 11;
string description = 12;
}
message CreditRewardRuleView {
uint64 rule_id = 1;
string source = 2;
string name = 3;
int64 amount = 4;
string status = 5;
string description = 6;
}
message GetCreditBalanceSnapshotRequest {
uint64 user_id = 1;
}
message GetCreditBalanceSnapshotResponse {
CreditBalanceSnapshotView snapshot = 1;
}
message CreditConsumptionDashboardView {
string period = 1;
int64 credit_consumed = 2;
int64 token_consumed = 3;
}
message GetCreditConsumptionDashboardRequest {
uint64 actor_user_id = 1;
string period = 2;
}
message GetCreditConsumptionDashboardResponse {
CreditConsumptionDashboardView dashboard = 1;
}
message ListCreditProductsRequest {
uint64 actor_user_id = 1;
}
message ListCreditProductsResponse {
repeated CreditProductView items = 1;
}
message CreateCreditOrderRequest {
uint64 actor_user_id = 1;
uint64 product_id = 2;
int32 quantity = 3;
string idempotency_key = 4;
}
message CreateCreditOrderResponse {
CreditOrderView order = 1;
}
message ListCreditOrdersRequest {
uint64 actor_user_id = 1;
int32 page = 2;
int32 page_size = 3;
string status = 4;
}
message ListCreditOrdersResponse {
repeated CreditOrderView items = 1;
PageResponse page = 2;
}
message GetCreditOrderRequest {
uint64 actor_user_id = 1;
uint64 order_id = 2;
}
message GetCreditOrderResponse {
CreditOrderView order = 1;
}
message MockPaidCreditOrderRequest {
uint64 actor_user_id = 1;
uint64 order_id = 2;
string mock_channel = 3;
string idempotency_key = 4;
}
message MockPaidCreditOrderResponse {
CreditOrderView order = 1;
}
message ListCreditTransactionsRequest {
uint64 actor_user_id = 1;
int32 page = 2;
int32 page_size = 3;
string source = 4;
string direction = 5;
}
message ListCreditTransactionsResponse {
repeated CreditTransactionView items = 1;
PageResponse page = 2;
}
message ListCreditPriceRulesRequest {
string scene = 1;
string provider_name = 2;
string model_name = 3;
string status = 4;
}
message ListCreditPriceRulesResponse {
repeated CreditPriceRuleView items = 1;
}
message ListCreditRewardRulesRequest {
string source = 1;
string status = 2;
}
message ListCreditRewardRulesResponse {
repeated CreditRewardRuleView items = 1;
}

View File

@@ -0,0 +1,66 @@
package sv
import (
"context"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
// GetCreditBalanceSnapshot 返回用户 Credit 余额快照。
//
// 职责边界:
// 1. 优先读 tokenstore 自己维护的 Redis 快照,未命中再回源 DB
// 2. 只返回余额与阻断状态,不在这里计算价格或校验扣费规则;
// 3. DB 回源成功后会尽力回填缓存,但缓存失败不影响本次查询结果。
func (s *Service) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if userID == 0 {
return nil, respond.MissingParam
}
if s.creditCache != nil {
snapshot, ok, err := s.creditCache.GetCreditBalanceSnapshot(ctx, userID)
if err == nil && ok && snapshot != nil {
blocked, blockedErr := s.creditCache.IsUserCreditBlocked(ctx, userID)
if blockedErr == nil {
return &creditcontracts.CreditBalanceSnapshot{
UserID: userID,
Balance: snapshot.Balance,
TotalRecharged: snapshot.TotalRecharged,
TotalRewarded: snapshot.TotalRewarded,
TotalConsumed: snapshot.TotalConsumed,
IsBlocked: blocked || snapshot.Balance <= 0,
SnapshotSource: creditSnapshotSourceCache,
UpdatedAt: formatTime(snapshot.UpdatedAt),
}, nil
}
}
}
account, err := s.creditDAO.FindAccountByUserID(ctx, userID)
if err != nil {
return nil, err
}
result := &creditcontracts.CreditBalanceSnapshot{
UserID: userID,
SnapshotSource: creditSnapshotSourceDB,
}
if account != nil {
result.Balance = account.Balance
result.TotalRecharged = account.TotalRecharged
result.TotalRewarded = account.TotalRewarded
result.TotalConsumed = account.TotalConsumed
result.IsBlocked = account.Balance <= 0
result.UpdatedAt = formatTime(account.UpdatedAt)
} else {
result.Balance = 0
result.IsBlocked = true
}
s.syncCreditCacheBestEffort(ctx, userID, account, nil)
return result, nil
}

View File

@@ -0,0 +1,112 @@
package sv
import (
"context"
"fmt"
"strings"
"time"
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
)
type creditChargeMetadata struct {
Scene string `json:"scene"`
RequestID string `json:"request_id"`
ConversationID string `json:"conversation_id"`
ModelAlias string `json:"model_alias"`
ProviderName string `json:"provider_name"`
ModelName string `json:"model_name"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CachedTokens int64 `json:"cached_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
TotalTokens int64 `json:"total_tokens"`
RMBCostMicros int64 `json:"rmb_cost_micros"`
CreditCost int64 `json:"credit_cost"`
SkipCharge bool `json:"skip_charge"`
TriggeredAt time.Time `json:"triggered_at"`
}
// RecordCreditCharge 负责把 LLM 扣费事件写入 Credit 权威账本。
func (s *Service) RecordCreditCharge(ctx context.Context, payload sharedevents.CreditChargeRequestedPayload) (*creditcontracts.CreditTransactionView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if err := payload.Validate(); err != nil {
return nil, err
}
sourceRefID := strings.TrimSpace(payload.RequestID)
if sourceRefID == "" {
sourceRefID = strings.TrimSpace(payload.ConversationID)
}
var sourceRefIDPtr *string
if sourceRefID != "" {
sourceRefIDPtr = &sourceRefID
}
amount := -payload.CreditCost
status := storemodel.CreditLedgerStatusApplied
if payload.SkipCharge {
amount = 0
status = storemodel.CreditLedgerStatusSkipped
}
ledger, _, err := s.applyCreditLedger(ctx, applyCreditLedgerRequest{
EventID: strings.TrimSpace(payload.EventID),
UserID: payload.UserID,
Source: storemodel.CreditLedgerSourceCharge,
SourceLabel: creditSourceLabel(storemodel.CreditLedgerSourceCharge, ""),
Direction: storemodel.CreditLedgerDirectionExpense,
SourceRefID: sourceRefIDPtr,
Amount: amount,
Status: status,
Description: creditChargeDescription(payload),
MetadataJSON: creditMetadataJSON(creditChargeMetadataFromPayload(payload)),
CreatedAt: payload.TriggeredAt,
})
if err != nil {
return nil, err
}
view := creditTransactionViewFromModel(*ledger)
return &view, nil
}
func creditChargeMetadataFromPayload(payload sharedevents.CreditChargeRequestedPayload) creditChargeMetadata {
return creditChargeMetadata{
Scene: payload.Scene,
RequestID: payload.RequestID,
ConversationID: payload.ConversationID,
ModelAlias: payload.ModelAlias,
ProviderName: payload.ProviderName,
ModelName: payload.ModelName,
InputTokens: payload.InputTokens,
OutputTokens: payload.OutputTokens,
CachedTokens: payload.CachedTokens,
ReasoningTokens: payload.ReasoningTokens,
TotalTokens: payload.TotalTokens,
RMBCostMicros: payload.RMBCostMicros,
CreditCost: payload.CreditCost,
SkipCharge: payload.SkipCharge,
TriggeredAt: payload.TriggeredAt,
}
}
func creditChargeDescription(payload sharedevents.CreditChargeRequestedPayload) string {
modelText := strings.TrimSpace(payload.ModelAlias)
if modelText == "" {
modelText = strings.TrimSpace(payload.ModelName)
}
sceneText := strings.TrimSpace(payload.Scene)
switch {
case sceneText != "" && modelText != "":
return fmt.Sprintf("AI 调用扣费(%s / %s", sceneText, modelText)
case modelText != "":
return fmt.Sprintf("AI 调用扣费(%s", modelText)
default:
return "AI 调用扣费"
}
}

View File

@@ -0,0 +1,86 @@
package sv
import (
"context"
"strings"
"time"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
// GetCreditConsumptionDashboard 返回当前用户的 Credit 消耗看板。
//
// 职责边界:
// 1. 负责把前端周期参数归一化为 tokenstore 的统一时间窗口。
// 2. 只校验当前用户语义和周期合法性,真正的聚合查询下沉到 DAO。
// 3. 返回值只包含前端顶部看板需要的两个指标,不夹带商品、流水等其它信息。
func (s *Service) GetCreditConsumptionDashboard(ctx context.Context, req creditcontracts.GetCreditConsumptionDashboardRequest) (*creditcontracts.CreditConsumptionDashboardView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 {
return nil, respond.MissingParam
}
period, err := normalizeCreditConsumptionPeriod(req.Period)
if err != nil {
return nil, err
}
query := tokenstoredao.GetCreditConsumptionDashboardQuery{
UserID: req.ActorUserID,
CreatedFrom: resolveCreditConsumptionWindowStart(period, time.Now()),
}
aggregate, err := s.creditDAO.GetCreditConsumptionDashboard(ctx, query)
if err != nil {
return nil, err
}
return &creditcontracts.CreditConsumptionDashboardView{
Period: period,
CreditConsumed: aggregate.CreditConsumed,
TokenConsumed: aggregate.TokenConsumed,
}, nil
}
// normalizeCreditConsumptionPeriod 只负责把前端周期值收敛到固定枚举。
//
// 1. 空值默认回落到 24h保证首页初次进入时可直接展示。
// 2. 非法值直接返回业务坏参,避免网关和前端各自维护一份不一致的枚举。
// 3. 这里不做时间计算,方便后续单独复用和测试。
func normalizeCreditConsumptionPeriod(raw string) (string, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", creditcontracts.CreditConsumptionPeriod24h:
return creditcontracts.CreditConsumptionPeriod24h, nil
case creditcontracts.CreditConsumptionPeriod7d:
return creditcontracts.CreditConsumptionPeriod7d, nil
case creditcontracts.CreditConsumptionPeriod30d:
return creditcontracts.CreditConsumptionPeriod30d, nil
case creditcontracts.CreditConsumptionPeriodAll:
return creditcontracts.CreditConsumptionPeriodAll, nil
default:
return "", tokenStoreBadRequest("period 仅支持 24h、7d、30d 或 all")
}
}
// resolveCreditConsumptionWindowStart 负责把固定周期映射为统计起点。
//
// 1. only "all" 返回 nil表示不加 created_at 过滤。
// 2. 其它周期统一按当前时间回退固定时长,保证前后端口径一致。
// 3. 这里不处理时区格式化,因为最终查询直接使用 time.Time 传给 DAO。
func resolveCreditConsumptionWindowStart(period string, now time.Time) *time.Time {
var startAt time.Time
switch period {
case creditcontracts.CreditConsumptionPeriod24h:
startAt = now.Add(-24 * time.Hour)
case creditcontracts.CreditConsumptionPeriod7d:
startAt = now.Add(-7 * 24 * time.Hour)
case creditcontracts.CreditConsumptionPeriod30d:
startAt = now.Add(-30 * 24 * time.Hour)
default:
return nil
}
return &startAt
}

View File

@@ -0,0 +1,457 @@
package sv
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/google/uuid"
"gorm.io/gorm"
)
const (
creditSnapshotSourceCache = "cache"
creditSnapshotSourceDB = "db"
)
type creditProductSnapshot struct {
ProductID uint64 `json:"product_id"`
SKU string `json:"sku"`
Name string `json:"name"`
Description string `json:"description"`
CreditAmount int64 `json:"credit_amount"`
PriceCent int64 `json:"price_cent"`
OriginalPriceCent int64 `json:"original_price_cent"`
PriceText string `json:"price_text"`
Currency string `json:"currency"`
Badge string `json:"badge"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
type applyCreditLedgerRequest struct {
EventID string
UserID uint64
Source string
SourceLabel string
Direction string
OrderID *uint64
SourceRefID *string
Amount int64
Status string
Description string
MetadataJSON string
CreatedAt time.Time
}
func creditPageResult(page int, pageSize int, total int64) creditcontracts.PageResult {
return creditcontracts.PageResult{
Page: page,
PageSize: pageSize,
Total: int(total),
HasMore: int64(page*pageSize) < total,
}
}
func creditProductViewFromModel(product storemodel.CreditProduct) creditcontracts.CreditProductView {
originalPriceCent := normalizeOriginalPriceCent(product.OriginalPriceCent, product.PriceCent)
return creditcontracts.CreditProductView{
ProductID: product.ID,
Name: product.Name,
Description: product.Description,
CreditAmount: product.CreditAmount,
PriceCent: product.PriceCent,
OriginalPriceCent: originalPriceCent,
PriceText: formatPriceText(product.Currency, product.PriceCent),
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: product.SortOrder,
}
}
func creditOrderViewFromModel(order storemodel.CreditOrder) creditcontracts.CreditOrderView {
return creditcontracts.CreditOrderView{
OrderID: order.ID,
OrderNo: order.OrderNo,
Status: order.Status,
ProductSnapshot: order.ProductSnapshotJSON,
ProductName: order.ProductName,
Quantity: order.Quantity,
CreditAmount: order.CreditAmount,
AmountCent: order.AmountCent,
PriceText: formatPriceText(order.Currency, order.AmountCent),
Currency: order.Currency,
PaymentMode: order.PaymentMode,
CreatedAt: formatTime(order.CreatedAt),
PaidAt: formatTimePtr(order.PaidAt),
CreditedAt: formatTimePtr(order.CreditedAt),
}
}
func creditTransactionViewFromModel(ledger storemodel.CreditLedger) creditcontracts.CreditTransactionView {
return creditcontracts.CreditTransactionView{
TransactionID: ledger.ID,
EventID: ledger.EventID,
Source: ledger.Source,
SourceLabel: creditSourceLabel(ledger.Source, ledger.SourceLabel),
Direction: ledger.Direction,
Amount: ledger.Amount,
BalanceAfter: ledger.BalanceAfter,
Status: ledger.Status,
Description: ledger.Description,
MetadataJSON: ledger.MetadataJSON,
CreatedAt: formatTime(ledger.CreatedAt),
OrderID: ledger.OrderID,
}
}
func creditPriceRuleViewFromModel(rule storemodel.CreditPriceRule) creditcontracts.CreditPriceRuleView {
return creditcontracts.CreditPriceRuleView{
RuleID: rule.ID,
Scene: rule.Scene,
ProviderName: rule.ProviderName,
ModelName: rule.ModelName,
InputPriceMicros: rule.InputPriceMicros,
OutputPriceMicros: rule.OutputPriceMicros,
CachedPriceMicros: rule.CachedPriceMicros,
ReasoningPriceMicros: rule.ReasoningPriceMicros,
CreditPerYuan: rule.CreditPerYuan,
Status: rule.Status,
Priority: rule.Priority,
Description: rule.Description,
}
}
func creditRewardRuleViewFromModel(rule storemodel.CreditRewardRule) creditcontracts.CreditRewardRuleView {
return creditcontracts.CreditRewardRuleView{
RuleID: rule.ID,
Source: rule.Source,
Name: rule.Name,
Amount: rule.Amount,
Status: rule.Status,
Description: rule.Description,
}
}
func buildCreditProductSnapshot(product storemodel.CreditProduct) (string, error) {
originalPriceCent := normalizeOriginalPriceCent(product.OriginalPriceCent, product.PriceCent)
snapshot := creditProductSnapshot{
ProductID: product.ID,
SKU: product.SKU,
Name: product.Name,
Description: product.Description,
CreditAmount: product.CreditAmount,
PriceCent: product.PriceCent,
OriginalPriceCent: originalPriceCent,
PriceText: formatPriceText(product.Currency, product.PriceCent),
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: product.SortOrder,
}
raw, err := json.Marshal(snapshot)
if err != nil {
return "", err
}
return string(raw), nil
}
func normalizeOriginalPriceCent(originalPriceCent int64, priceCent int64) int64 {
if originalPriceCent > 0 {
return originalPriceCent
}
return priceCent
}
func newCreditOrderNo() string {
return fmt.Sprintf(
"CS%s%s",
time.Now().Format("20060102150405"),
strings.ReplaceAll(uuid.NewString(), "-", ""),
)
}
func creditOrderLedgerEventID(orderID uint64) string {
return fmt.Sprintf("credit-order:%d:paid", orderID)
}
func creditSourceLabel(source string, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return fallback
}
switch strings.TrimSpace(source) {
case storemodel.CreditLedgerSourcePurchase:
return "购买充值"
case storemodel.CreditLedgerSourceCharge:
return "AI 调用扣费"
case storemodel.CreditLedgerSourceForumLike:
return "计划被点赞"
case storemodel.CreditLedgerSourceForumImport:
return "计划被导入"
case storemodel.CreditLedgerSourceManual:
return "人工补发"
default:
return "Credit 流水"
}
}
func creditDirectionFromAmount(amount int64) string {
if amount < 0 {
return storemodel.CreditLedgerDirectionExpense
}
return storemodel.CreditLedgerDirectionIncome
}
func creditShouldAffectBalance(req applyCreditLedgerRequest) bool {
return strings.TrimSpace(req.Status) == storemodel.CreditLedgerStatusApplied && req.Amount != 0
}
func (s *Service) applyCreditLedger(ctx context.Context, req applyCreditLedgerRequest) (*storemodel.CreditLedger, *storemodel.CreditAccount, error) {
if err := s.Ready(); err != nil {
return nil, nil, err
}
normalized, err := normalizeApplyCreditLedgerRequest(req)
if err != nil {
return nil, nil, err
}
var resultLedger *storemodel.CreditLedger
var resultAccount *storemodel.CreditAccount
err = s.creditDAO.Transaction(ctx, func(txDAO *tokenstoredao.CreditStoreDAO) error {
ledger, account, err := s.applyCreditLedgerWithDAO(ctx, txDAO, normalized)
if err != nil {
return err
}
resultLedger = ledger
resultAccount = account
return nil
})
if err != nil {
return nil, nil, err
}
s.syncCreditCacheBestEffort(ctx, normalized.UserID, resultAccount, resultLedger)
return resultLedger, resultAccount, nil
}
func (s *Service) applyCreditLedgerWithDAO(ctx context.Context, txDAO *tokenstoredao.CreditStoreDAO, req applyCreditLedgerRequest) (*storemodel.CreditLedger, *storemodel.CreditAccount, error) {
if txDAO == nil {
return nil, nil, errors.New("credit dao is nil")
}
existing, findErr := txDAO.FindLedgerByEventID(ctx, req.EventID)
if findErr != nil {
return nil, nil, findErr
}
if existing != nil {
if err := validateExistingCreditLedger(*existing, req); err != nil {
return nil, nil, err
}
account, accountErr := txDAO.FindAccountByUserID(ctx, req.UserID)
if accountErr != nil {
return nil, nil, accountErr
}
return existing, account, nil
}
account, accountErr := txDAO.LockAccountByUserID(ctx, req.UserID)
if accountErr != nil {
return nil, nil, accountErr
}
if account == nil && creditShouldAffectBalance(req) {
account = &storemodel.CreditAccount{
UserID: req.UserID,
}
if err := txDAO.CreateAccount(ctx, account); err != nil {
if !isDuplicateKeyError(err) {
return nil, nil, err
}
account, err = txDAO.LockAccountByUserID(ctx, req.UserID)
if err != nil {
return nil, nil, err
}
if account == nil {
return nil, nil, errors.New("credit account duplicated but not found by user_id")
}
}
}
balanceBefore := int64(0)
if account != nil {
balanceBefore = account.Balance
}
balanceAfter := balanceBefore
if creditShouldAffectBalance(req) {
balanceAfter += req.Amount
}
ledger := &storemodel.CreditLedger{
EventID: req.EventID,
UserID: req.UserID,
Source: req.Source,
SourceLabel: creditSourceLabel(req.Source, req.SourceLabel),
Direction: req.Direction,
OrderID: req.OrderID,
SourceRefID: req.SourceRefID,
Amount: req.Amount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceAfter,
Status: req.Status,
Description: req.Description,
MetadataJSON: req.MetadataJSON,
CreatedAt: req.CreatedAt,
}
if err := txDAO.CreateLedger(ctx, ledger); err != nil {
if !isDuplicateKeyError(err) {
return nil, nil, err
}
existing, findErr := txDAO.FindLedgerByEventID(ctx, req.EventID)
if findErr != nil {
return nil, nil, findErr
}
if existing != nil {
if err := validateExistingCreditLedger(*existing, req); err != nil {
return nil, nil, err
}
account, accountErr := txDAO.FindAccountByUserID(ctx, req.UserID)
if accountErr != nil {
return nil, nil, accountErr
}
return existing, account, nil
}
return nil, nil, errors.New("credit ledger duplicated but not found by event_id")
}
if creditShouldAffectBalance(req) {
account.Balance = balanceAfter
account.LastLedgerEventID = req.EventID
if req.Amount > 0 {
if req.Source == storemodel.CreditLedgerSourcePurchase {
account.TotalRecharged += req.Amount
} else {
account.TotalRewarded += req.Amount
}
} else {
account.TotalConsumed += -req.Amount
}
if err := txDAO.SaveAccount(ctx, account); err != nil {
return nil, nil, err
}
}
return ledger, account, nil
}
func normalizeApplyCreditLedgerRequest(req applyCreditLedgerRequest) (applyCreditLedgerRequest, error) {
normalized := req
normalized.EventID = strings.TrimSpace(req.EventID)
normalized.Source = strings.ToLower(strings.TrimSpace(req.Source))
normalized.SourceLabel = strings.TrimSpace(req.SourceLabel)
normalized.Direction = strings.ToLower(strings.TrimSpace(req.Direction))
normalized.Status = strings.ToLower(strings.TrimSpace(req.Status))
normalized.Description = strings.TrimSpace(req.Description)
normalized.MetadataJSON = strings.TrimSpace(req.MetadataJSON)
if normalized.CreatedAt.IsZero() {
normalized.CreatedAt = time.Now()
}
switch {
case normalized.EventID == "":
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit event_id 不能为空")
case normalized.UserID == 0:
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit user_id 不能为空")
case normalized.Source == "":
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit source 不能为空")
case normalized.Direction != storemodel.CreditLedgerDirectionIncome && normalized.Direction != storemodel.CreditLedgerDirectionExpense:
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit direction 仅支持 income 或 expense")
case normalized.Status == "":
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit status 不能为空")
}
return normalized, nil
}
func validateExistingCreditLedger(existing storemodel.CreditLedger, req applyCreditLedgerRequest) error {
if existing.UserID != req.UserID ||
existing.Source != req.Source ||
existing.Direction != req.Direction ||
existing.Amount != req.Amount ||
existing.Status != req.Status {
return tokenStoreBadRequest("credit event_id 幂等冲突:已存在流水与本次请求不一致")
}
return nil
}
func (s *Service) syncCreditCacheBestEffort(ctx context.Context, userID uint64, account *storemodel.CreditAccount, ledger *storemodel.CreditLedger) {
if s == nil || s.creditCache == nil || userID == 0 {
return
}
if account == nil {
loaded, err := s.creditDAO.FindAccountByUserID(ctx, userID)
if err != nil {
log.Printf("tokenstore credit cache fallback load failed: user_id=%d err=%v", userID, err)
return
}
account = loaded
}
snapshot := tokenstoredao.CreditBalanceSnapshot{
UserID: userID,
UpdatedAt: time.Now(),
TotalRecharged: 0,
TotalRewarded: 0,
TotalConsumed: 0,
}
if account != nil {
snapshot.Balance = account.Balance
snapshot.TotalRecharged = account.TotalRecharged
snapshot.TotalRewarded = account.TotalRewarded
snapshot.TotalConsumed = account.TotalConsumed
if !account.UpdatedAt.IsZero() {
snapshot.UpdatedAt = account.UpdatedAt
}
} else if ledger != nil && !ledger.CreatedAt.IsZero() {
snapshot.Balance = ledger.BalanceAfter
snapshot.UpdatedAt = ledger.CreatedAt
}
if err := s.creditCache.SetCreditBalanceSnapshot(ctx, userID, snapshot, 0); err != nil {
log.Printf("tokenstore credit cache snapshot write failed: user_id=%d err=%v", userID, err)
}
if snapshot.Balance <= 0 {
if err := s.creditCache.SetUserCreditBlocked(ctx, userID, 0); err != nil {
log.Printf("tokenstore credit blocked flag write failed: user_id=%d err=%v", userID, err)
}
return
}
if err := s.creditCache.DeleteUserCreditBlocked(ctx, userID); err != nil {
log.Printf("tokenstore credit blocked flag delete failed: user_id=%d err=%v", userID, err)
}
}
func creditMetadataJSON(payload any) string {
if payload == nil {
return ""
}
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
return string(raw)
}
func normalizeCreditRecordNotFound(err error, fallback error) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fallback
}
return err
}

View File

@@ -0,0 +1,240 @@
package sv
import (
"context"
"strings"
"time"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
// CreateCreditOrder 创建 Credit 商品订单。
func (s *Service) CreateCreditOrder(ctx context.Context, req creditcontracts.CreateCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 || req.ProductID == 0 {
return nil, respond.MissingParam
}
if req.Quantity < 1 || req.Quantity > 99 {
return nil, tokenStoreBadRequest("quantity 仅支持 1 到 99")
}
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
if idempotencyKey != "" {
existing, err := s.creditDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
if err != nil {
return nil, err
}
if existing != nil {
return s.creditOrderViewByID(ctx, req.ActorUserID, existing.ID)
}
}
product, err := s.creditDAO.FindActiveProductByID(ctx, req.ProductID)
if err != nil {
return nil, err
}
if product == nil {
return nil, tokenStoreBadRequest("Credit 商品不存在或已下架")
}
snapshot, err := buildCreditProductSnapshot(*product)
if err != nil {
return nil, err
}
order := storemodel.CreditOrder{
OrderNo: newCreditOrderNo(),
UserID: req.ActorUserID,
ProductID: product.ID,
ProductSKU: product.SKU,
ProductName: product.Name,
ProductSnapshotJSON: snapshot,
Quantity: req.Quantity,
CreditAmount: product.CreditAmount * int64(req.Quantity),
AmountCent: product.PriceCent * int64(req.Quantity),
Currency: product.Currency,
Status: storemodel.CreditOrderStatusPending,
PaymentMode: "mock",
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
}
if err := s.creditDAO.CreateOrder(ctx, &order); err != nil {
if idempotencyKey != "" && isDuplicateKeyError(err) {
existing, findErr := s.creditDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
if findErr != nil {
return nil, findErr
}
if existing != nil {
return s.creditOrderViewByID(ctx, req.ActorUserID, existing.ID)
}
}
return nil, err
}
return s.creditOrderViewByID(ctx, req.ActorUserID, order.ID)
}
// ListCreditOrders 按用户分页查询 Credit 订单。
func (s *Service) ListCreditOrders(ctx context.Context, req creditcontracts.ListCreditOrdersRequest) ([]creditcontracts.CreditOrderView, creditcontracts.PageResult, error) {
if err := s.Ready(); err != nil {
return nil, creditcontracts.PageResult{}, err
}
if req.ActorUserID == 0 {
return nil, creditcontracts.PageResult{}, respond.MissingParam
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := tokenstoredao.ListCreditOrdersQuery{
UserID: req.ActorUserID,
Page: page,
PageSize: pageSize,
Status: strings.TrimSpace(req.Status),
}
total, err := s.creditDAO.CountOrders(ctx, query)
if err != nil {
return nil, creditcontracts.PageResult{}, err
}
orders, err := s.creditDAO.ListOrders(ctx, query)
if err != nil {
return nil, creditcontracts.PageResult{}, err
}
if len(orders) == 0 {
return []creditcontracts.CreditOrderView{}, creditPageResult(page, pageSize, total), nil
}
result := make([]creditcontracts.CreditOrderView, 0, len(orders))
for _, order := range orders {
result = append(result, creditOrderViewFromModel(order))
}
return result, creditPageResult(page, pageSize, total), nil
}
// GetCreditOrder 查询单个 Credit 订单详情。
func (s *Service) GetCreditOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if actorUserID == 0 || orderID == 0 {
return nil, respond.MissingParam
}
return s.creditOrderViewByID(ctx, actorUserID, orderID)
}
// MockPaidCreditOrder 在同步事务里完成 mock paid 和 Credit 入账。
func (s *Service) MockPaidCreditOrder(ctx context.Context, req creditcontracts.MockPaidCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 || req.OrderID == 0 {
return nil, respond.MissingParam
}
var resultOrder storemodel.CreditOrder
err := s.creditDAO.Transaction(ctx, func(txDAO *tokenstoredao.CreditStoreDAO) error {
now := time.Now()
order, err := txDAO.LockOrderByID(ctx, req.OrderID)
if err != nil {
return normalizeCreditRecordNotFound(err, tokenStoreBadRequest("Credit 订单不存在"))
}
if order.UserID != req.ActorUserID {
return tokenStoreBadRequest("Credit 订单不属于当前用户")
}
switch order.Status {
case storemodel.CreditOrderStatusPending, storemodel.CreditOrderStatusPaid, storemodel.CreditOrderStatusCredited:
case storemodel.CreditOrderStatusClosed:
return tokenStoreBadRequest("Credit 订单已关闭,不能执行 mock paid")
default:
return tokenStoreBadRequest("Credit 订单状态不支持执行 mock paid")
}
eventID := creditOrderLedgerEventID(order.ID)
paymentMode := paymentModeOrDefault(order.PaymentMode, req.MockChannel)
ledger, _, err := s.applyCreditLedgerWithDAO(ctx, txDAO, applyCreditLedgerRequest{
EventID: eventID,
UserID: order.UserID,
Source: storemodel.CreditLedgerSourcePurchase,
SourceLabel: creditSourceLabel(storemodel.CreditLedgerSourcePurchase, ""),
Direction: storemodel.CreditLedgerDirectionIncome,
OrderID: &order.ID,
Amount: order.CreditAmount,
Status: storemodel.CreditLedgerStatusApplied,
Description: creditPurchaseDescription(order.ProductName),
MetadataJSON: creditMetadataJSON(map[string]any{"order_no": order.OrderNo, "payment_mode": paymentMode}),
CreatedAt: now,
})
if err != nil {
return err
}
paidAt := order.PaidAt
if paidAt == nil || paidAt.IsZero() {
paidAt = &now
}
creditedAt := order.CreditedAt
if creditedAt == nil || creditedAt.IsZero() {
ledgerCreatedAt := ledger.CreatedAt
if ledgerCreatedAt.IsZero() {
ledgerCreatedAt = now
}
creditedAt = &ledgerCreatedAt
}
if err := txDAO.UpdateOrderState(ctx, order.ID, storemodel.CreditOrderStatusCredited, paidAt, creditedAt, paymentMode); err != nil {
return err
}
order.Status = storemodel.CreditOrderStatusCredited
order.PaidAt = paidAt
order.CreditedAt = creditedAt
order.PaymentMode = paymentMode
resultOrder = *order
return nil
})
if err != nil {
return nil, err
}
s.syncCreditCacheBestEffort(ctx, req.ActorUserID, nil, nil)
view := creditOrderViewFromModel(resultOrder)
return &view, nil
}
func (s *Service) creditOrderViewByID(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, error) {
order, err := s.creditDAO.FindOrderByID(ctx, orderID)
if err != nil {
return nil, err
}
if order == nil {
return nil, tokenStoreBadRequest("Credit 订单不存在")
}
if order.UserID != actorUserID {
return nil, tokenStoreBadRequest("Credit 订单不属于当前用户")
}
view := creditOrderViewFromModel(*order)
return &view, nil
}
func creditPurchaseDescription(productName string) string {
trimmed := strings.TrimSpace(productName)
if trimmed == "" {
return "购买 Credit 商品"
}
return "购买" + trimmed
}
func paymentModeOrDefault(current string, fallback string) string {
if trimmed := strings.TrimSpace(current); trimmed != "" {
return trimmed
}
if trimmed := strings.TrimSpace(fallback); trimmed != "" {
return trimmed
}
return "mock"
}

View File

@@ -0,0 +1,104 @@
package sv
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
)
// RegisterCreditChargeRoutes 只登记 token-store 负责消费的 Credit 扣费事件归属。
func RegisterCreditChargeRoutes() error {
return outboxinfra.RegisterEventService(sharedevents.CreditChargeRequestedEventType, outboxinfra.ServiceTokenStore)
}
// RegisterCreditChargeHandlers 注册 token-store 对 Credit 扣费事件的消费处理器。
func RegisterCreditChargeHandlers(bus OutboxBus, outboxRepo *outboxinfra.Repository, svc *Service) error {
if bus == nil {
return errors.New("event bus is nil")
}
if outboxRepo == nil {
return errors.New("outbox repository is nil")
}
if svc == nil {
return errors.New("tokenstore service is nil")
}
if err := RegisterCreditChargeRoutes(); err != nil {
return err
}
route, ok := outboxinfra.ResolveEventRoute(sharedevents.CreditChargeRequestedEventType)
if !ok {
return fmt.Errorf("credit charge outbox route is missing: eventType=%s", sharedevents.CreditChargeRequestedEventType)
}
eventOutboxRepo := outboxRepo.WithRoute(route)
handler := func(ctx context.Context, envelope kafkabus.Envelope) error {
if !isAllowedCreditChargeEventVersion(envelope.EventVersion) {
if err := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("Credit 扣费事件版本不受支持: %s", envelope.EventVersion)); err != nil {
return err
}
return nil
}
var payload sharedevents.CreditChargeRequestedPayload
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "解析 Credit 扣费载荷失败: "+err.Error()); markErr != nil {
return markErr
}
return nil
}
if strings.TrimSpace(payload.EventID) == "" {
payload.EventID = strings.TrimSpace(envelope.EventID)
}
if err := payload.Validate(); err != nil {
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "Credit 扣费载荷非法: "+err.Error()); markErr != nil {
return markErr
}
return nil
}
if payload.EventType() != sharedevents.CreditChargeRequestedEventType {
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("Credit 扣费事件类型不匹配: envelope=%s payload=%s", sharedevents.CreditChargeRequestedEventType, payload.EventType())); markErr != nil {
return markErr
}
return nil
}
if !payload.SkipCharge && payload.CreditCost <= 0 && payload.RMBCostMicros <= 0 {
if err := eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID); err != nil {
return err
}
log.Printf("credit charge event skipped with zero cost: event_id=%s outbox_id=%d", payload.EventID, envelope.OutboxID)
return nil
}
tx, err := svc.RecordCreditCharge(ctx, payload)
if err != nil {
return err
}
if err := eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID); err != nil {
return err
}
log.Printf(
"credit charge event consumed by tokenstore: event_id=%s transaction_id=%d outbox_id=%d",
payload.EventID,
tx.TransactionID,
envelope.OutboxID,
)
return nil
}
return bus.RegisterEventHandler(sharedevents.CreditChargeRequestedEventType, handler)
}
func isAllowedCreditChargeEventVersion(version string) bool {
version = strings.TrimSpace(version)
return version == "" || version == sharedevents.CreditChargeEventVersion
}

View File

@@ -0,0 +1,29 @@
package sv
import (
"context"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
)
// ListCreditProducts 返回当前可售 Credit 商品列表。
func (s *Service) ListCreditProducts(ctx context.Context, actorUserID uint64) ([]creditcontracts.CreditProductView, error) {
_ = actorUserID
if err := s.Ready(); err != nil {
return nil, err
}
products, err := s.creditDAO.ListActiveProducts(ctx)
if err != nil {
return nil, err
}
if len(products) == 0 {
return []creditcontracts.CreditProductView{}, nil
}
result := make([]creditcontracts.CreditProductView, 0, len(products))
for _, product := range products {
result = append(result, creditProductViewFromModel(product))
}
return result, nil
}

View File

@@ -0,0 +1,90 @@
package sv
import (
"context"
"strconv"
"strings"
"time"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
)
// RecordForumRewardCredit 把论坛点赞/导入奖励直接写入 Credit 权威账本。
//
// 职责边界:
// 1. 只处理 forum_like / forum_import 两类论坛正向奖励;
// 2. 复用 event_id 做最终幂等键,重复消费时直接返回既有账本结果;
// 3. 奖励金额优先读取 credit_reward_rules规则缺失时再走默认兜底。
func (s *Service) RecordForumRewardCredit(ctx context.Context, req forumRewardGrantRequest) (*creditcontracts.CreditTransactionView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
decision, err := s.forumRewardCreditDecision(ctx, req.Source)
if err != nil {
return nil, err
}
sourceRefID := strconv.FormatUint(req.SourceRefID, 10)
ledger, _, err := s.applyCreditLedger(ctx, applyCreditLedgerRequest{
EventID: req.EventID,
UserID: req.ReceiverUserID,
Source: req.Source,
SourceLabel: creditSourceLabel(req.Source, ""),
Direction: creditDirectionFromAmount(decision.Amount),
SourceRefID: &sourceRefID,
Amount: decision.Amount,
Status: decision.Status,
Description: decision.Description,
MetadataJSON: creditMetadataJSON(map[string]any{"reward_source": req.Source, "source_ref_id": req.SourceRefID}),
CreatedAt: time.Now(),
})
if err != nil {
return nil, err
}
view := creditTransactionViewFromModel(*ledger)
return &view, nil
}
func (s *Service) forumRewardCreditDecision(ctx context.Context, source string) (forumRewardDecision, error) {
rules, err := s.creditDAO.ListRewardRules(ctx, tokenstoredao.ListCreditRewardRulesQuery{
Source: strings.TrimSpace(source),
})
if err != nil {
return forumRewardDecision{}, err
}
if len(rules) > 0 {
rule := rules[0]
if strings.TrimSpace(rule.Status) != storemodel.CreditRewardRuleStatusActive {
return skippedForumRewardDecision(source, "奖励规则已停用,未发放 Credit"), nil
}
if rule.Amount <= 0 {
return skippedForumRewardDecision(source, "奖励规则金额非正,未发放 Credit"), nil
}
return forumRewardDecision{
Amount: rule.Amount,
Status: storemodel.CreditLedgerStatusApplied,
Description: forumRewardDescription(source),
}, nil
}
switch strings.TrimSpace(source) {
case storemodel.CreditLedgerSourceForumLike:
return forumRewardDecision{
Amount: defaultForumLikeRewardAmount,
Status: storemodel.CreditLedgerStatusApplied,
Description: forumRewardDescription(source),
}, nil
case storemodel.CreditLedgerSourceForumImport:
return forumRewardDecision{
Amount: defaultForumImportRewardAmount,
Status: storemodel.CreditLedgerStatusApplied,
Description: forumRewardDescription(source),
}, nil
default:
return skippedForumRewardDecision(source, "未知论坛奖励来源,未发放 Credit"), nil
}
}

View File

@@ -0,0 +1,59 @@
package sv
import (
"context"
"strings"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
)
// ListCreditPriceRules 查询 Credit 价格规则。
func (s *Service) ListCreditPriceRules(ctx context.Context, req creditcontracts.ListCreditPriceRulesRequest) ([]creditcontracts.CreditPriceRuleView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
rules, err := s.creditDAO.ListPriceRules(ctx, tokenstoredao.ListCreditPriceRulesQuery{
Scene: strings.TrimSpace(req.Scene),
ProviderName: strings.TrimSpace(req.ProviderName),
ModelName: strings.TrimSpace(req.ModelName),
Status: strings.TrimSpace(req.Status),
})
if err != nil {
return nil, err
}
if len(rules) == 0 {
return []creditcontracts.CreditPriceRuleView{}, nil
}
result := make([]creditcontracts.CreditPriceRuleView, 0, len(rules))
for _, rule := range rules {
result = append(result, creditPriceRuleViewFromModel(rule))
}
return result, nil
}
// ListCreditRewardRules 查询 Credit 奖励规则。
func (s *Service) ListCreditRewardRules(ctx context.Context, req creditcontracts.ListCreditRewardRulesRequest) ([]creditcontracts.CreditRewardRuleView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
rules, err := s.creditDAO.ListRewardRules(ctx, tokenstoredao.ListCreditRewardRulesQuery{
Source: strings.TrimSpace(req.Source),
Status: strings.TrimSpace(req.Status),
})
if err != nil {
return nil, err
}
if len(rules) == 0 {
return []creditcontracts.CreditRewardRuleView{}, nil
}
result := make([]creditcontracts.CreditRewardRuleView, 0, len(rules))
for _, rule := range rules {
result = append(result, creditRewardRuleViewFromModel(rule))
}
return result, nil
}

View File

@@ -0,0 +1,47 @@
package sv
import (
"context"
"strings"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
// ListCreditTransactions 查询当前用户自己的 Credit 流水。
func (s *Service) ListCreditTransactions(ctx context.Context, req creditcontracts.ListCreditTransactionsRequest) ([]creditcontracts.CreditTransactionView, creditcontracts.PageResult, error) {
if err := s.Ready(); err != nil {
return nil, creditcontracts.PageResult{}, err
}
if req.ActorUserID == 0 {
return nil, creditcontracts.PageResult{}, respond.MissingParam
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := tokenstoredao.ListCreditTransactionsQuery{
UserID: req.ActorUserID,
Page: page,
PageSize: pageSize,
Source: strings.TrimSpace(req.Source),
Direction: strings.TrimSpace(req.Direction),
}
total, err := s.creditDAO.CountTransactions(ctx, query)
if err != nil {
return nil, creditcontracts.PageResult{}, err
}
items, err := s.creditDAO.ListTransactions(ctx, query)
if err != nil {
return nil, creditcontracts.PageResult{}, err
}
if len(items) == 0 {
return []creditcontracts.CreditTransactionView{}, creditPageResult(page, pageSize, total), nil
}
result := make([]creditcontracts.CreditTransactionView, 0, len(items))
for _, item := range items {
result = append(result, creditTransactionViewFromModel(item))
}
return result, creditPageResult(page, pageSize, total), nil
}

View File

@@ -1,84 +0,0 @@
package sv
import (
"context"
"strings"
"github.com/LoveLosita/smartflow/backend/shared/respond"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
)
// GetSummary 聚合当前用户在 token-store 账本中的获得记录。
//
// 职责边界:
// 1. 只统计 token_grants不读取 user/auth 的权威额度。
// 2. 只汇总正向获取额度,避免把未来的冲正或补偿误算进 P0 展示口径。
// 3. quota_sync_status 在 P0 固定为 not_connected明确告知尚未打通权威额度。
func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if actorUserID == 0 {
return nil, respond.MissingParam
}
summary, err := s.tokenDAO.SummarizePositiveGrants(ctx, actorUserID)
if err != nil {
return nil, err
}
pending := summary.RecordedTokenTotal - summary.AppliedTokenTotal
if pending < 0 {
pending = 0
}
return &tokencontracts.TokenSummary{
RecordedTokenTotal: summary.RecordedTokenTotal,
AppliedTokenTotal: summary.AppliedTokenTotal,
PendingApplyTokenTotal: pending,
QuotaSyncStatus: tokenSummaryQuotaStatusNotConnected,
Tip: tokenSummaryTipP0,
}, nil
}
// ListGrants 按用户分页查询 Token 获得记录。
//
// 职责边界:
// 1. 只支持 user_id 维度分页和 source 过滤,不做跨用户检索。
// 2. 负责把空 source 归一化为“不筛选”,避免 DAO 层重复处理入口噪音。
// 3. 结果只来自账本事实,不推导 user/auth 可用额度。
func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
if err := s.Ready(); err != nil {
return nil, tokencontracts.PageResult{}, err
}
if req.ActorUserID == 0 {
return nil, tokencontracts.PageResult{}, respond.MissingParam
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := tokenstoredao.ListTokenGrantsQuery{
UserID: req.ActorUserID,
Page: page,
PageSize: pageSize,
Source: strings.TrimSpace(req.Source),
}
total, err := s.tokenDAO.CountGrants(ctx, query)
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
grants, err := s.tokenDAO.ListGrants(ctx, query)
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
if len(grants) == 0 {
return []tokencontracts.TokenGrantView{}, pageResult(page, pageSize, total), nil
}
result := make([]tokencontracts.TokenGrantView, 0, len(grants))
for _, grant := range grants {
result = append(result, grantViewFromModel(grant))
}
return result, pageResult(page, pageSize, total), nil
}

View File

@@ -1,16 +1,12 @@
package sv package sv
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"github.com/LoveLosita/smartflow/backend/shared/respond" "github.com/LoveLosita/smartflow/backend/shared/respond"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -18,25 +14,8 @@ const (
defaultPage = 1 defaultPage = 1
defaultPageSize = 20 defaultPageSize = 20
maxPageSize = 50 maxPageSize = 50
tokenSummaryQuotaStatusNotConnected = "not_connected"
tokenSummaryTipP0 = "当前仅统计 Token 商店已记录的获得记录,尚未同步到 user/auth 可用额度。"
) )
type productSnapshot struct {
ProductID uint64 `json:"product_id"`
SKU string `json:"sku"`
Name string `json:"name"`
Description string `json:"description"`
TokenAmount int64 `json:"token_amount"`
PriceCent int64 `json:"price_cent"`
PriceText string `json:"price_text"`
Currency string `json:"currency"`
Badge string `json:"badge"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
func normalizePage(page int, pageSize int) (int, int) { func normalizePage(page int, pageSize int) (int, int) {
if page <= 0 { if page <= 0 {
page = defaultPage page = defaultPage
@@ -50,15 +29,6 @@ func normalizePage(page int, pageSize int) (int, int) {
return page, pageSize return page, pageSize
} }
func pageResult(page int, pageSize int, total int64) tokencontracts.PageResult {
return tokencontracts.PageResult{
Page: page,
PageSize: pageSize,
Total: int(total),
HasMore: int64(page*pageSize) < total,
}
}
func formatTime(value time.Time) string { func formatTime(value time.Time) string {
if value.IsZero() { if value.IsZero() {
return "" return ""
@@ -75,6 +45,9 @@ func formatTimePtr(value *time.Time) *string {
} }
func formatPriceText(currency string, amountCent int64) string { func formatPriceText(currency string, amountCent int64) string {
if amountCent == 0 {
return "免费"
}
if strings.EqualFold(strings.TrimSpace(currency), "CNY") { if strings.EqualFold(strings.TrimSpace(currency), "CNY") {
return fmt.Sprintf("¥%.2f", float64(amountCent)/100) return fmt.Sprintf("¥%.2f", float64(amountCent)/100)
} }
@@ -89,120 +62,6 @@ func stringPtrFromNonEmpty(value string) *string {
return &trimmed return &trimmed
} }
func productViewFromModel(product tokenmodel.TokenProduct) tokencontracts.TokenProductView {
return tokencontracts.TokenProductView{
ProductID: product.ID,
Name: product.Name,
Description: product.Description,
TokenAmount: product.TokenAmount,
PriceCent: product.PriceCent,
PriceText: formatPriceText(product.Currency, product.PriceCent),
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: product.SortOrder,
}
}
func grantViewFromModel(grant tokenmodel.TokenGrant) tokencontracts.TokenGrantView {
return tokencontracts.TokenGrantView{
GrantID: grant.ID,
EventID: grant.EventID,
Source: grant.Source,
SourceLabel: grantSourceLabel(grant.Source, grant.SourceLabel),
Amount: grant.Amount,
Status: grant.Status,
QuotaApplied: grant.QuotaApplied,
Description: grant.Description,
CreatedAt: formatTime(grant.CreatedAt),
}
}
func orderViewFromModel(order tokenmodel.TokenOrder, grant *tokenmodel.TokenGrant) tokencontracts.TokenOrderView {
var grantView *tokencontracts.TokenGrantView
if grant != nil {
view := grantViewFromModel(*grant)
grantView = &view
}
return tokencontracts.TokenOrderView{
OrderID: order.ID,
OrderNo: order.OrderNo,
Status: order.Status,
ProductSnapshot: order.ProductSnapshotJSON,
ProductName: order.ProductName,
Quantity: order.Quantity,
TokenAmount: order.TokenAmount,
AmountCent: order.AmountCent,
PriceText: formatPriceText(order.Currency, order.AmountCent),
Currency: order.Currency,
PaymentMode: order.PaymentMode,
Grant: grantView,
CreatedAt: formatTime(order.CreatedAt),
PaidAt: formatTimePtr(order.PaidAt),
GrantedAt: formatTimePtr(order.GrantedAt),
}
}
func grantSourceLabel(source string, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return fallback
}
switch strings.TrimSpace(source) {
case tokenmodel.TokenGrantSourcePurchase:
return "购买充值"
case tokenmodel.TokenGrantSourceForumLike:
return "计划被点赞"
case tokenmodel.TokenGrantSourceForumImport:
return "计划被导入"
case tokenmodel.TokenGrantSourceManual:
return "人工补发"
default:
return "Token 获得记录"
}
}
func buildProductSnapshot(product tokenmodel.TokenProduct) (string, error) {
snapshot := productSnapshot{
ProductID: product.ID,
SKU: product.SKU,
Name: product.Name,
Description: product.Description,
TokenAmount: product.TokenAmount,
PriceCent: product.PriceCent,
PriceText: formatPriceText(product.Currency, product.PriceCent),
Currency: product.Currency,
Badge: product.Badge,
Status: product.Status,
SortOrder: product.SortOrder,
}
raw, err := json.Marshal(snapshot)
if err != nil {
return "", err
}
return string(raw), nil
}
func newOrderNo() string {
return fmt.Sprintf(
"TS%s%s",
time.Now().Format("20060102150405"),
strings.ReplaceAll(uuid.NewString(), "-", ""),
)
}
func purchaseGrantEventID(orderID uint64) string {
return fmt.Sprintf("order:%d:paid", orderID)
}
func purchaseGrantDescription(productName string) string {
trimmed := strings.TrimSpace(productName)
if trimmed == "" {
return "购买 Token 商品"
}
return fmt.Sprintf("购买%s", trimmed)
}
func isDuplicateKeyError(err error) bool { func isDuplicateKeyError(err error) bool {
if err == nil { if err == nil {
return false return false
@@ -226,8 +85,6 @@ func errorsIsRecordNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound) return errors.Is(err, gorm.ErrRecordNotFound)
} }
// tokenStoreBadRequestStatus 是 token-store P0 统一业务校验错误码。
// 具体错误原因仍放在 Info避免为每个商品/订单校验分支提前扩散大量细分码。
const tokenStoreBadRequestStatus = "40067" const tokenStoreBadRequestStatus = "40067"
func tokenStoreBadRequest(message string) respond.Response { func tokenStoreBadRequest(message string) respond.Response {

View File

@@ -1,312 +0,0 @@
package sv
import (
"context"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/shared/respond"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
)
// CreateOrder 创建 Token 商品订单。
//
// 职责边界:
// 1. 校验 actor_user_id、product_id、quantity 与幂等键。
// 2. 只生成 pending 订单和商品快照,不触发真实支付或 user/auth 同步。
// 3. 并发冲突时优先按 user_id + idempotency_key 回查旧单,保证 P0 幂等语义。
func (s *Service) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*tokencontracts.TokenOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 || req.ProductID == 0 {
return nil, respond.MissingParam
}
if req.Quantity < 1 || req.Quantity > 99 {
return nil, tokenStoreBadRequest("quantity 仅支持 1 到 99")
}
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
if idempotencyKey != "" {
existing, err := s.tokenDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
if err != nil {
return nil, err
}
if existing != nil {
return s.orderViewByID(ctx, req.ActorUserID, existing.ID)
}
}
product, err := s.tokenDAO.FindActiveProductByID(ctx, req.ProductID)
if err != nil {
return nil, err
}
if product == nil {
return nil, tokenStoreBadRequest("商品不存在或已下架")
}
productSnapshot, err := buildProductSnapshot(*product)
if err != nil {
return nil, err
}
order := tokenmodel.TokenOrder{
OrderNo: newOrderNo(),
UserID: req.ActorUserID,
ProductID: product.ID,
ProductSKU: product.SKU,
ProductName: product.Name,
ProductSnapshotJSON: productSnapshot,
Quantity: req.Quantity,
TokenAmount: product.TokenAmount * int64(req.Quantity),
AmountCent: product.PriceCent * int64(req.Quantity),
Currency: product.Currency,
Status: tokenmodel.TokenOrderStatusPending,
PaymentMode: "mock",
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
}
if err := s.tokenDAO.CreateOrder(ctx, &order); err != nil {
if idempotencyKey != "" && isDuplicateKeyError(err) {
existing, findErr := s.tokenDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
if findErr != nil {
return nil, findErr
}
if existing != nil {
return s.orderViewByID(ctx, req.ActorUserID, existing.ID)
}
}
return nil, err
}
return s.orderViewByID(ctx, req.ActorUserID, order.ID)
}
// ListOrders 按用户分页查询订单列表。
//
// 职责边界:
// 1. 只支持当前用户维度分页,不做跨用户检索。
// 2. status 为空时不过滤,非空时按精确值过滤。
// 3. 负责把订单与 grant 账本拼装成统一视图。
func (s *Service) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]tokencontracts.TokenOrderView, tokencontracts.PageResult, error) {
if err := s.Ready(); err != nil {
return nil, tokencontracts.PageResult{}, err
}
if req.ActorUserID == 0 {
return nil, tokencontracts.PageResult{}, respond.MissingParam
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := tokenstoredao.ListTokenOrdersQuery{
UserID: req.ActorUserID,
Page: page,
PageSize: pageSize,
Status: strings.TrimSpace(req.Status),
}
total, err := s.tokenDAO.CountOrders(ctx, query)
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
orders, err := s.tokenDAO.ListOrders(ctx, query)
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
if len(orders) == 0 {
return []tokencontracts.TokenOrderView{}, pageResult(page, pageSize, total), nil
}
grantMap, err := s.orderGrantMap(ctx, collectOrderIDs(orders))
if err != nil {
return nil, tokencontracts.PageResult{}, err
}
result := make([]tokencontracts.TokenOrderView, 0, len(orders))
for _, order := range orders {
result = append(result, orderViewFromModel(order, grantMap[order.ID]))
}
return result, pageResult(page, pageSize, total), nil
}
// GetOrder 查询单个订单详情,并校验归属用户。
func (s *Service) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if actorUserID == 0 || orderID == 0 {
return nil, respond.MissingParam
}
return s.orderViewByID(ctx, actorUserID, orderID)
}
// MockPaidOrder 在同步事务里完成 mock paid 和 grant 入账。
//
// 职责边界:
// 1. 只处理订单状态流转与 token_grants 幂等写入,不调用 user/auth。
// 2. event_id 固定为 order:{order_id}:paid作为最终 grant 幂等边界。
// 3. 重复调用优先复用既有 grant再把订单补齐到 granted避免重复写账本。
func (s *Service) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*tokencontracts.TokenOrderView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
if req.ActorUserID == 0 || req.OrderID == 0 {
return nil, respond.MissingParam
}
var resultOrder tokenmodel.TokenOrder
var resultGrant *tokenmodel.TokenGrant
err := s.tokenDAO.Transaction(ctx, func(txDAO *tokenstoredao.TokenStoreDAO) error {
now := time.Now()
// 1. 先锁订单并校验归属,避免并发 mock paid 重复写 grant。
// 2. 订单不存在直接返回;订单不属于当前用户时明确拒绝。
// 3. closed 状态不允许继续支付,避免把关闭单重新拉回可用态。
order, err := txDAO.LockOrderByID(ctx, req.OrderID)
if err != nil {
return normalizeRecordNotFound(err, tokenStoreBadRequest("订单不存在"))
}
if order.UserID != req.ActorUserID {
return tokenStoreBadRequest("订单不属于当前用户")
}
switch order.Status {
case tokenmodel.TokenOrderStatusPending, tokenmodel.TokenOrderStatusPaid, tokenmodel.TokenOrderStatusGranted:
case tokenmodel.TokenOrderStatusClosed:
return tokenStoreBadRequest("订单已关闭,不能执行 mock paid")
default:
return tokenStoreBadRequest("订单状态不支持执行 mock paid")
}
eventID := purchaseGrantEventID(order.ID)
grant, err := txDAO.FindGrantByEventID(ctx, eventID)
if err != nil {
return err
}
// 1. grant 不存在时才尝试创建,保证账本幂等写入边界只在 event_id。
// 2. 即使因为历史脏数据或极端并发触发唯一冲突,也要立刻按 event_id 反查旧 grant。
// 3. 这里不写 user/auth只把 token-store 自己的账本事实补齐。
if grant == nil {
sourceRefID := order.ID
orderID := order.ID
newGrant := &tokenmodel.TokenGrant{
EventID: eventID,
UserID: order.UserID,
Source: tokenmodel.TokenGrantSourcePurchase,
SourceLabel: grantSourceLabel(tokenmodel.TokenGrantSourcePurchase, ""),
SourceRefID: &sourceRefID,
OrderID: &orderID,
Amount: order.TokenAmount,
Status: tokenmodel.TokenGrantStatusRecorded,
QuotaApplied: false,
Description: purchaseGrantDescription(order.ProductName),
}
if err := txDAO.CreateGrant(ctx, newGrant); err != nil {
if !isDuplicateKeyError(err) {
return err
}
newGrant, err = txDAO.FindGrantByEventID(ctx, eventID)
if err != nil {
return err
}
if newGrant == nil {
return tokenStoreBadRequest("Token 发放记录创建后未找到")
}
}
grant = newGrant
}
// 1. 无论订单原来是 pending、paid 还是 granted只要 grant 已确定,就把订单补齐到 granted。
// 2. paid_at 缺失时使用本次确认时间granted_at 缺失时优先复用 grant.created_at保证链路时间可追溯。
// 3. 这样即便出现“grant 已有、订单未完成切流”的历史半状态,也能在重复调用时自愈。
paidAt := order.PaidAt
if paidAt == nil || paidAt.IsZero() {
paidAt = &now
}
grantedAt := order.GrantedAt
if grantedAt == nil || grantedAt.IsZero() {
if grant != nil && !grant.CreatedAt.IsZero() {
grantCreatedAt := grant.CreatedAt
grantedAt = &grantCreatedAt
} else {
grantedAt = &now
}
}
paymentMode := strings.TrimSpace(order.PaymentMode)
if paymentMode == "" {
paymentMode = strings.TrimSpace(req.MockChannel)
}
if paymentMode == "" {
paymentMode = "mock"
}
if err := txDAO.UpdateOrderState(ctx, order.ID, tokenmodel.TokenOrderStatusGranted, paidAt, grantedAt, paymentMode); err != nil {
return err
}
order.Status = tokenmodel.TokenOrderStatusGranted
order.PaidAt = paidAt
order.GrantedAt = grantedAt
order.PaymentMode = paymentMode
resultOrder = *order
resultGrant = grant
return nil
})
if err != nil {
return nil, err
}
view := orderViewFromModel(resultOrder, resultGrant)
return &view, nil
}
func (s *Service) orderViewByID(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) {
order, err := s.tokenDAO.FindOrderByID(ctx, orderID)
if err != nil {
return nil, err
}
if order == nil {
return nil, tokenStoreBadRequest("订单不存在")
}
if order.UserID != actorUserID {
return nil, tokenStoreBadRequest("订单不属于当前用户")
}
grant, err := s.tokenDAO.FindGrantByOrderID(ctx, order.ID)
if err != nil {
return nil, err
}
view := orderViewFromModel(*order, grant)
return &view, nil
}
func (s *Service) orderGrantMap(ctx context.Context, orderIDs []uint64) (map[uint64]*tokenmodel.TokenGrant, error) {
result := make(map[uint64]*tokenmodel.TokenGrant, len(orderIDs))
if len(orderIDs) == 0 {
return result, nil
}
grants, err := s.tokenDAO.ListGrantsByOrderIDs(ctx, orderIDs)
if err != nil {
return nil, err
}
for i := range grants {
grant := grants[i]
if grant.OrderID == nil {
continue
}
if _, exists := result[*grant.OrderID]; exists {
continue
}
grantCopy := grant
result[*grant.OrderID] = &grantCopy
}
return result, nil
}
func collectOrderIDs(orders []tokenmodel.TokenOrder) []uint64 {
result := make([]uint64, 0, len(orders))
for _, order := range orders {
result = append(result, order.ID)
}
return result
}

View File

@@ -9,7 +9,6 @@ import (
"strconv" "strconv"
"strings" "strings"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka" kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox" outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
@@ -110,11 +109,19 @@ func registerForumRewardHandler(
return nil return nil
} }
grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{ sourceRefID, parseErr := parseForumRewardSourceRefID(forumRewardSourceRefID(payload, source))
if parseErr != nil {
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "论坛奖励 source_ref_id 非法: "+parseErr.Error()); markErr != nil {
return markErr
}
return nil
}
transaction, err := svc.RecordForumRewardCredit(ctx, forumRewardGrantRequest{
EventID: eventID, EventID: eventID,
ReceiverUserID: payload.RewardReceiverUserID, ReceiverUserID: payload.RewardReceiverUserID,
Source: forumRewardSource(payload, source), Source: forumRewardSource(payload, source),
SourceRefID: forumRewardSourceRefID(payload, source), SourceRefID: sourceRefID,
}) })
if err != nil { if err != nil {
return err return err
@@ -124,10 +131,10 @@ func registerForumRewardHandler(
} }
log.Printf( log.Printf(
"forum reward event consumed by tokenstore: event_type=%s event_id=%s grant_id=%d outbox_id=%d", "forum reward event consumed by tokenstore: event_type=%s event_id=%s transaction_id=%d outbox_id=%d",
eventType, eventType,
eventID, eventID,
grant.GrantID, transaction.TransactionID,
envelope.OutboxID, envelope.OutboxID,
) )
return nil return nil

View File

@@ -1,34 +0,0 @@
package sv
import (
"context"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
)
// ListProducts 返回当前可售商品列表。
//
// 职责边界:
// 1. 只返回 active 商品,不负责后台商品管理。
// 2. 负责补齐 price_text保持前端不必重复格式化价格。
// 3. actorUserID 当前仅保留为统一接口形状P0 不参与筛选。
func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
_ = actorUserID
if err := s.Ready(); err != nil {
return nil, err
}
products, err := s.tokenDAO.ListActiveProducts(ctx)
if err != nil {
return nil, err
}
if len(products) == 0 {
return []tokencontracts.TokenProductView{}, nil
}
result := make([]tokencontracts.TokenProductView, 0, len(products))
for _, product := range products {
result = append(result, productViewFromModel(product))
}
return result, nil
}

View File

@@ -1,13 +1,10 @@
package sv package sv
import ( import (
"context"
"errors"
"strconv" "strconv"
"strings" "strings"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -32,85 +29,12 @@ type forumRewardDecision struct {
Description string Description string
} }
// RecordForumRewardGrant 负责把论坛点赞/导入奖励写入 token_grants。 func normalizeForumRewardGrantRequest(req forumRewardGrantRequest) (forumRewardGrantRequest, error) {
//
// 职责边界:
// 1. 只处理 forum_like / forum_import 两类奖励账本写入,不修改 users也不调用 user/auth
// 2. 以 event_id 作为最终幂等边界,重复请求校验一致后返回既有 grant
// 3. 奖励金额优先读取 token_reward_rules配置和代码默认值只作为兜底。
func (s *Service) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) {
if err := s.Ready(); err != nil {
return nil, err
}
normalized, err := normalizeForumRewardGrantRequest(req)
if err != nil {
return nil, err
}
// 1. 先按 event_id 回查,命中时直接视为成功,避免 outbox 重试重复写账本。
// 2. 命中后必须校验用户、来源和来源业务 ID避免错误复用 event_id 时静默吞掉错账。
// 3. 校验通过才返回既有 grant兼容“首次已成功、调用方超时后重试”的常见场景。
existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID)
if err != nil {
return nil, err
}
if existing != nil {
if err := validateExistingForumRewardGrant(*existing, normalized); err != nil {
return nil, err
}
view := grantViewFromModel(*existing)
return &view, nil
}
sourceRefID := normalized.SourceRefID
decision, err := s.forumRewardDecision(ctx, normalized.Source)
if err != nil {
return nil, err
}
grant := tokenmodel.TokenGrant{
EventID: normalized.EventID,
UserID: normalized.ReceiverUserID,
Source: normalized.Source,
SourceLabel: grantSourceLabel(normalized.Source, ""),
SourceRefID: &sourceRefID,
Amount: decision.Amount,
Status: decision.Status,
QuotaApplied: false,
Description: decision.Description,
}
// 1. 账本写入只依赖 token_grants.event_id 唯一约束兜底并发幂等。
// 2. 若并发下插入触发唯一键冲突,立刻回查 event_id把已有 grant 当作成功结果返回。
// 3. 只有“冲突后仍查不到旧记录”这种异常态才上抛内部错误,避免吞掉真实一致性问题。
if err := s.tokenDAO.CreateGrant(ctx, &grant); err != nil {
if !isDuplicateKeyError(err) {
return nil, err
}
existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID)
if err != nil {
return nil, err
}
if existing == nil {
return nil, errors.New("forum reward grant duplicated but not found by event_id")
}
if err := validateExistingForumRewardGrant(*existing, normalized); err != nil {
return nil, err
}
view := grantViewFromModel(*existing)
return &view, nil
}
view := grantViewFromModel(grant)
return &view, nil
}
func normalizeForumRewardGrantRequest(req tokencontracts.RecordForumRewardGrantRequest) (forumRewardGrantRequest, error) {
normalized := forumRewardGrantRequest{ normalized := forumRewardGrantRequest{
EventID: strings.TrimSpace(req.EventID), EventID: strings.TrimSpace(req.EventID),
ReceiverUserID: req.ReceiverUserID, ReceiverUserID: req.ReceiverUserID,
Source: strings.ToLower(strings.TrimSpace(req.Source)), Source: strings.ToLower(strings.TrimSpace(req.Source)),
SourceRefID: req.SourceRefID,
} }
switch { switch {
@@ -118,16 +42,12 @@ func normalizeForumRewardGrantRequest(req tokencontracts.RecordForumRewardGrantR
return forumRewardGrantRequest{}, tokenStoreBadRequest("event_id 不能为空") return forumRewardGrantRequest{}, tokenStoreBadRequest("event_id 不能为空")
case normalized.ReceiverUserID == 0: case normalized.ReceiverUserID == 0:
return forumRewardGrantRequest{}, tokenStoreBadRequest("receiver_user_id 不能为空") return forumRewardGrantRequest{}, tokenStoreBadRequest("receiver_user_id 不能为空")
case normalized.SourceRefID == 0:
return forumRewardGrantRequest{}, tokenStoreBadRequest("source_ref_id 不能为空")
} }
sourceRefID, err := parseForumRewardSourceRefID(req.SourceRefID)
if err != nil {
return forumRewardGrantRequest{}, err
}
normalized.SourceRefID = sourceRefID
switch normalized.Source { switch normalized.Source {
case tokenmodel.TokenGrantSourceForumLike, tokenmodel.TokenGrantSourceForumImport: case storemodel.CreditLedgerSourceForumLike, storemodel.CreditLedgerSourceForumImport:
return normalized, nil return normalized, nil
default: default:
return forumRewardGrantRequest{}, tokenStoreBadRequest("source 仅支持 forum_like 或 forum_import") return forumRewardGrantRequest{}, tokenStoreBadRequest("source 仅支持 forum_like 或 forum_import")
@@ -147,69 +67,10 @@ func parseForumRewardSourceRefID(raw string) (uint64, error) {
return parsed, nil return parsed, nil
} }
// validateExistingForumRewardGrant 校验重复 event_id 是否真的是同一条论坛奖励。
//
// 职责边界:
// 1. 只比较幂等所需的最小字段:接收人、来源和来源业务 ID
// 2. 不比较金额和状态,避免规则调整后重放旧事件被误判;
// 3. 不一致时返回业务校验错误,让上游暴露这类错账风险。
func validateExistingForumRewardGrant(existing tokenmodel.TokenGrant, req forumRewardGrantRequest) error {
sourceRefID := uint64(0)
if existing.SourceRefID != nil {
sourceRefID = *existing.SourceRefID
}
if existing.UserID != req.ReceiverUserID || existing.Source != req.Source || sourceRefID != req.SourceRefID {
return tokenStoreBadRequest("event_id 幂等冲突:已有奖励记录与本次论坛奖励请求不一致")
}
return nil
}
// forumRewardDecision 解析论坛奖励发放决策。
//
// 职责边界:
// 1. 优先读取 token_reward_rules保持“从表里读”的 P0 口径;
// 2. 规则停用或金额非正时写 skipped 账本,消费 outbox 但不增加 Token
// 3. 表规则缺失时再读取配置和代码默认值,兼容旧环境尚未 seed 的情况。
func (s *Service) forumRewardDecision(ctx context.Context, source string) (forumRewardDecision, error) {
rule, err := s.tokenDAO.FindRewardRuleBySource(ctx, source)
if err != nil {
return forumRewardDecision{}, err
}
if rule != nil {
if strings.TrimSpace(rule.Status) != tokenmodel.TokenRewardRuleStatusActive {
return skippedForumRewardDecision(source, "奖励规则已停用,未发放 Token"), nil
}
if rule.Amount <= 0 {
return skippedForumRewardDecision(source, "奖励规则金额非正,未发放 Token"), nil
}
return recordedForumRewardDecision(source, rule.Amount), nil
}
switch strings.TrimSpace(source) {
case tokenmodel.TokenGrantSourceForumLike:
return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumLikeRewardConfigKey, defaultForumLikeRewardAmount)), nil
case tokenmodel.TokenGrantSourceForumImport:
return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumImportRewardConfigKey, defaultForumImportRewardAmount)), nil
default:
return skippedForumRewardDecision(source, "未知论坛奖励来源,未发放 Token"), nil
}
}
func recordedForumRewardDecision(source string, amount int64) forumRewardDecision {
if amount <= 0 {
return skippedForumRewardDecision(source, "奖励金额非正,未发放 Token")
}
return forumRewardDecision{
Amount: amount,
Status: tokenmodel.TokenGrantStatusRecorded,
Description: forumRewardDescription(source),
}
}
func skippedForumRewardDecision(source string, description string) forumRewardDecision { func skippedForumRewardDecision(source string, description string) forumRewardDecision {
return forumRewardDecision{ return forumRewardDecision{
Amount: 0, Amount: 0,
Status: tokenmodel.TokenGrantStatusSkipped, Status: storemodel.CreditLedgerStatusSkipped,
Description: strings.TrimSpace(description), Description: strings.TrimSpace(description),
} }
} }
@@ -224,9 +85,9 @@ func positiveConfigAmountOrDefault(configKey string, fallback int64) int64 {
func forumRewardDescription(source string) string { func forumRewardDescription(source string) string {
switch strings.TrimSpace(source) { switch strings.TrimSpace(source) {
case tokenmodel.TokenGrantSourceForumLike: case storemodel.CreditLedgerSourceForumLike:
return "计划被点赞奖励" return "计划被点赞奖励"
case tokenmodel.TokenGrantSourceForumImport: case storemodel.CreditLedgerSourceForumImport:
return "计划被导入奖励" return "计划被导入奖励"
default: default:
return "论坛奖励入账" return "论坛奖励入账"

View File

@@ -1,54 +1,41 @@
package sv package sv
import ( import (
"context"
"errors" "errors"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao" tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"gorm.io/gorm" "gorm.io/gorm"
) )
// ErrNotImplemented 表示 RPC 骨架已接线,但对应业务用例还在后续步骤实现。
var ErrNotImplemented = errors.New("tokenstore service method not implemented") var ErrNotImplemented = errors.New("tokenstore service method not implemented")
// TokenGrantOutlet 是 token-store 后续切到 user/auth 权威额度的内部发放出口。
//
// 职责边界:
// 1. P0 只记录 token-store 自己的获取事实和账本;
// 2. 禁止直接修改 users 表;
// 3. 后续切 user/auth 时新增 adapter服务编排层不重写。
type TokenGrantOutlet interface {
RecordAcquisition(ctx context.Context, grant tokencontracts.TokenGrantRecord) error
}
// Options 是 token-store 服务的依赖注入参数。 // Options 是 token-store 服务的依赖注入参数。
type Options struct { type Options struct {
DB *gorm.DB DB *gorm.DB
GrantOutlet TokenGrantOutlet CreditCache *tokenstoredao.CreditCacheDAO
} }
// Service 承载 Token 商店服务内部业务编排。 // Service 承载 token-store 内部业务编排。
// //
// 职责边界: // 职责边界:
// 1. 负责商品、订单、mock paid、grant 账本和奖励规则 // 1. 同时承载旧 Token 商店与新 Credit 权威账本两套能力,服务进程先并行存在
// 2. 不负责登录鉴权,也不直接修改 user/auth 权威额度 // 2. Token 与 Credit 分别走各自 DAO不在服务层混写数据表访问
// 3. 不负责真实第三方支付回调P0 只处理 mock paid // 3. 真正的跨服务 HTTP/gateway 接线留给后续第三步,本层只暴露 RPC 可用能力
type Service struct { type Service struct {
db *gorm.DB db *gorm.DB
tokenDAO *tokenstoredao.TokenStoreDAO creditDAO *tokenstoredao.CreditStoreDAO
grantOutlet TokenGrantOutlet creditCache *tokenstoredao.CreditCacheDAO
} }
func New(opts Options) *Service { func New(opts Options) *Service {
return &Service{ return &Service{
db: opts.DB, db: opts.DB,
tokenDAO: tokenstoredao.NewTokenStoreDAO(opts.DB), creditDAO: tokenstoredao.NewCreditStoreDAO(opts.DB),
grantOutlet: opts.GrantOutlet, creditCache: opts.CreditCache,
} }
} }
// Ready 用于第二步骨架阶段的依赖检查。 // Ready 用于服务依赖检查。
func (s *Service) Ready() error { func (s *Service) Ready() error {
if s == nil { if s == nil {
return errors.New("tokenstore service is nil") return errors.New("tokenstore service is nil")

View File

@@ -9,6 +9,8 @@ import (
userauthsv "github.com/LoveLosita/smartflow/backend/services/userauth/sv" userauthsv "github.com/LoveLosita/smartflow/backend/services/userauth/sv"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
"github.com/LoveLosita/smartflow/backend/shared/respond" "github.com/LoveLosita/smartflow/backend/shared/respond"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
) )
type Handler struct { type Handler struct {
@@ -124,49 +126,15 @@ func (h *Handler) ValidateAccessToken(ctx context.Context, req *pb.ValidateAcces
} }
func (h *Handler) CheckTokenQuota(ctx context.Context, req *pb.CheckTokenQuotaRequest) (*pb.CheckTokenQuotaResponse, error) { func (h *Handler) CheckTokenQuota(ctx context.Context, req *pb.CheckTokenQuotaRequest) (*pb.CheckTokenQuotaResponse, error) {
if h == nil || h.svc == nil { _ = ctx
return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) _ = req
} return nil, status.Error(codes.Unimplemented, "legacy token quota API has been removed")
if req == nil {
return nil, grpcErrorFromServiceError(respond.ErrUnauthorized)
}
resp, err := h.svc.CheckTokenQuota(ctx, contracts.CheckTokenQuotaRequest{
UserID: int(req.UserId),
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.CheckTokenQuotaResponse{
Allowed: resp.Allowed,
TokenLimit: int64(resp.TokenLimit),
TokenUsage: int64(resp.TokenUsage),
LastResetAtUnixNano: timeToUnixNano(resp.LastResetAt),
}, nil
} }
func (h *Handler) AdjustTokenUsage(ctx context.Context, req *pb.AdjustTokenUsageRequest) (*pb.CheckTokenQuotaResponse, error) { func (h *Handler) AdjustTokenUsage(ctx context.Context, req *pb.AdjustTokenUsageRequest) (*pb.CheckTokenQuotaResponse, error) {
if h == nil || h.svc == nil { _ = ctx
return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) _ = req
} return nil, status.Error(codes.Unimplemented, "legacy token usage adjust API has been removed")
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
resp, err := h.svc.AdjustTokenUsage(ctx, contracts.AdjustTokenUsageRequest{
EventID: req.EventId,
UserID: int(req.UserId),
TokenDelta: int(req.TokenDelta),
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.CheckTokenQuotaResponse{
Allowed: resp.Allowed,
TokenLimit: int64(resp.TokenLimit),
TokenUsage: int64(resp.TokenUsage),
LastResetAtUnixNano: timeToUnixNano(resp.LastResetAt),
}, nil
} }
func timeToUnixNano(value time.Time) int64 { func timeToUnixNano(value time.Time) int64 {

View File

@@ -1,192 +0,0 @@
package sv
import (
"context"
"errors"
"log"
"strings"
"time"
userauthdao "github.com/LoveLosita/smartflow/backend/services/userauth/dao"
userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
const (
userTokenResetInterval = 7 * 24 * time.Hour
userTokenQuotaSnapshotTTL = 60 * time.Second
minUserTokenBlockTTL = 30 * time.Second
)
// CheckTokenQuota 是 user/auth 服务内的 token 额度门禁。
//
// 职责边界:
// 1. 判断用户是否还能继续发起高消耗 agent/chat 请求;
// 2. 维护额度周期懒重置、Redis 快照和封禁键;
// 3. 不负责本轮对话完成后的 token 记账,记账由 AdjustTokenUsage 处理。
func (s *Service) CheckTokenQuota(ctx context.Context, req contracts.CheckTokenQuotaRequest) (*contracts.CheckTokenQuotaResponse, error) {
if s == nil || s.userRepo == nil || s.cacheRepo == nil {
return nil, errors.New("userauth quota dependencies not initialized")
}
if req.UserID <= 0 {
return nil, respond.ErrUnauthorized
}
now := time.Now()
// 1. 先查封禁键。封禁键的 TTL 按重置窗口计算,命中时可以避免每次回源 DB。
blocked, blockedErr := s.cacheRepo.IsUserTokenBlocked(ctx, req.UserID)
if blockedErr != nil {
log.Printf("userauth quota: 查询封禁键失败 user_id=%d err=%v回源 DB 校验", req.UserID, blockedErr)
} else if blocked {
return &contracts.CheckTokenQuotaResponse{Allowed: false}, nil
}
// 2. 快照未到重置窗口时直接判断;快照损坏或过期则回源 DB。
snapshot, hit, snapshotErr := s.cacheRepo.GetUserTokenQuotaSnapshot(ctx, req.UserID)
if snapshotErr != nil {
log.Printf("userauth quota: 读取额度快照失败 user_id=%d err=%v回源 DB 校验", req.UserID, snapshotErr)
}
if hit && snapshot != nil && !isResetDue(snapshot.LastResetAt, now) {
if isQuotaExceeded(snapshot.TokenLimit, snapshot.TokenUsage) {
ttl := calcBlockTTL(snapshot.LastResetAt, now)
if err := s.cacheRepo.SetUserTokenBlocked(ctx, req.UserID, ttl); err != nil {
log.Printf("userauth quota: 写入封禁键失败 user_id=%d err=%v", req.UserID, err)
}
return quotaResponse(false, snapshot.TokenLimit, snapshot.TokenUsage, snapshot.LastResetAt), nil
}
return quotaResponse(true, snapshot.TokenLimit, snapshot.TokenUsage, snapshot.LastResetAt), nil
}
// 3. 回源 DB 做权威判断;到 7 天窗口则先懒重置,再回读最新额度。
quota, err := s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID)
if err != nil {
return nil, err
}
if isResetDue(quota.LastResetAt, now) {
if _, err = s.userRepo.ResetUserTokenUsageIfDue(ctx, req.UserID, now.Add(-userTokenResetInterval), now); err != nil {
return nil, err
}
quota, err = s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID)
if err != nil {
return nil, err
}
if delErr := s.cacheRepo.DeleteUserTokenBlocked(ctx, req.UserID); delErr != nil {
log.Printf("userauth quota: 清理封禁键失败 user_id=%d err=%v", req.UserID, delErr)
}
}
return s.cacheQuotaAndBuildResponse(ctx, req.UserID, quota, now, "quota")
}
// AdjustTokenUsage 在 user/auth 服务内回写用户 token 账本。
//
// 职责边界:
// 1. 只负责 users.token_usage 的增量调整与 quota 缓存刷新;
// 2. 不负责 agent 会话 token_total调用方仍需在各自领域内维护会话统计
// 3. event_id 非空时通过 MySQL 幂等表和 users 更新同事务提交,避免 outbox 重试或并发重放重复记账。
func (s *Service) AdjustTokenUsage(ctx context.Context, req contracts.AdjustTokenUsageRequest) (*contracts.CheckTokenQuotaResponse, error) {
if s == nil || s.userRepo == nil || s.cacheRepo == nil {
return nil, errors.New("userauth adjust dependencies not initialized")
}
if req.UserID <= 0 || req.TokenDelta <= 0 {
return nil, respond.MissingParam
}
now := time.Now()
eventID := strings.TrimSpace(req.EventID)
var currentQuota *userauthmodel.User
var err error
if eventID != "" {
var duplicated bool
currentQuota, duplicated, err = s.userRepo.AdjustTokenUsageOnce(ctx, eventID, req.UserID, req.TokenDelta, now.Add(-userTokenResetInterval), now)
if err != nil {
return nil, err
}
if duplicated {
return s.CheckTokenQuota(ctx, contracts.CheckTokenQuotaRequest{UserID: req.UserID})
}
} else {
currentQuota, err = s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID)
if err != nil {
return nil, err
}
if isResetDue(currentQuota.LastResetAt, now) {
if _, err = s.userRepo.ResetUserTokenUsageIfDue(ctx, req.UserID, now.Add(-userTokenResetInterval), now); err != nil {
return nil, err
}
}
if _, err = s.userRepo.AddTokenUsage(ctx, req.UserID, req.TokenDelta); err != nil {
return nil, err
}
currentQuota, err = s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID)
if err != nil {
return nil, err
}
}
return s.cacheQuotaAndBuildResponse(ctx, req.UserID, currentQuota, now, "adjust")
}
func (s *Service) cacheQuotaAndBuildResponse(ctx context.Context, userID int, quota *userauthmodel.User, now time.Time, source string) (*contracts.CheckTokenQuotaResponse, error) {
if quota == nil {
return nil, errors.New("userauth quota is nil")
}
snapshot := userauthdao.TokenQuotaSnapshot{
TokenLimit: quota.TokenLimit,
TokenUsage: quota.TokenUsage,
LastResetAt: quota.LastResetAt,
}
if setErr := s.cacheRepo.SetUserTokenQuotaSnapshot(ctx, userID, snapshot, userTokenQuotaSnapshotTTL); setErr != nil {
log.Printf("userauth %s: 回填额度快照失败 user_id=%d err=%v", source, userID, setErr)
if delErr := s.cacheRepo.DeleteUserTokenQuotaSnapshot(ctx, userID); delErr != nil {
log.Printf("userauth %s: 清理失效额度快照失败 user_id=%d err=%v", source, userID, delErr)
}
}
if isQuotaExceeded(quota.TokenLimit, quota.TokenUsage) {
ttl := calcBlockTTL(quota.LastResetAt, now)
if err := s.cacheRepo.SetUserTokenBlocked(ctx, userID, ttl); err != nil {
log.Printf("userauth %s: 写入封禁标记失败 user_id=%d err=%v", source, userID, err)
}
return quotaResponse(false, quota.TokenLimit, quota.TokenUsage, quota.LastResetAt), nil
}
if delErr := s.cacheRepo.DeleteUserTokenBlocked(ctx, userID); delErr != nil {
log.Printf("userauth %s: 清理封禁标记失败 user_id=%d err=%v", source, userID, delErr)
}
return quotaResponse(true, quota.TokenLimit, quota.TokenUsage, quota.LastResetAt), nil
}
func quotaResponse(allowed bool, tokenLimit int, tokenUsage int, lastResetAt time.Time) *contracts.CheckTokenQuotaResponse {
return &contracts.CheckTokenQuotaResponse{
Allowed: allowed,
TokenLimit: tokenLimit,
TokenUsage: tokenUsage,
LastResetAt: lastResetAt,
}
}
func isQuotaExceeded(tokenLimit int, tokenUsage int) bool {
return tokenUsage >= tokenLimit
}
func isResetDue(lastResetAt time.Time, now time.Time) bool {
if lastResetAt.IsZero() {
return true
}
return !lastResetAt.Add(userTokenResetInterval).After(now)
}
func calcBlockTTL(lastResetAt time.Time, now time.Time) time.Duration {
if lastResetAt.IsZero() {
return minUserTokenBlockTTL
}
ttl := lastResetAt.Add(userTokenResetInterval).Sub(now)
if ttl <= 0 {
return minUserTokenBlockTTL
}
return ttl
}

View File

@@ -6,7 +6,6 @@ import (
"strings" "strings"
"time" "time"
userauthdao "github.com/LoveLosita/smartflow/backend/services/userauth/dao"
userauthauth "github.com/LoveLosita/smartflow/backend/services/userauth/internal/auth" userauthauth "github.com/LoveLosita/smartflow/backend/services/userauth/internal/auth"
userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model" userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
@@ -19,10 +18,6 @@ type UserRepo interface {
IfUsernameExists(ctx context.Context, name string) (bool, error) IfUsernameExists(ctx context.Context, name string) (bool, error)
GetUserHashedPasswordByName(ctx context.Context, name string) (string, error) GetUserHashedPasswordByName(ctx context.Context, name string) (string, error)
GetUserIDByName(ctx context.Context, name string) (int, error) GetUserIDByName(ctx context.Context, name string) (int, error)
GetUserTokenQuotaByID(ctx context.Context, id int) (*userauthmodel.User, error)
ResetUserTokenUsageIfDue(ctx context.Context, id int, dueBefore time.Time, resetAt time.Time) (bool, error)
AddTokenUsage(ctx context.Context, id int, delta int) (bool, error)
AdjustTokenUsageOnce(ctx context.Context, eventID string, id int, delta int, dueBefore time.Time, resetAt time.Time) (*userauthmodel.User, bool, error)
} }
type CacheRepo interface { type CacheRepo interface {
@@ -31,20 +26,14 @@ type CacheRepo interface {
SetBlacklistIfAbsent(jti string, expiration time.Duration) (bool, error) SetBlacklistIfAbsent(jti string, expiration time.Duration) (bool, error)
IsSessionBlacklisted(sessionID string) (bool, error) IsSessionBlacklisted(sessionID string) (bool, error)
SetSessionBlacklist(sessionID string, expiration time.Duration) error SetSessionBlacklist(sessionID string, expiration time.Duration) error
IsUserTokenBlocked(ctx context.Context, userID int) (bool, error)
GetUserTokenQuotaSnapshot(ctx context.Context, userID int) (*userauthdao.TokenQuotaSnapshot, bool, error)
SetUserTokenQuotaSnapshot(ctx context.Context, userID int, snapshot userauthdao.TokenQuotaSnapshot, ttl time.Duration) error
DeleteUserTokenQuotaSnapshot(ctx context.Context, userID int) error
SetUserTokenBlocked(ctx context.Context, userID int, ttl time.Duration) error
DeleteUserTokenBlocked(ctx context.Context, userID int) error
} }
// Service 承载 user/auth 服务内部业务规则。 // Service 承载 user/auth 服务内部业务规则。
// //
// 职责边界: // 职责边界:
// 1. 负责注册、登录、刷新、登出、JWT 签发/校验黑名单和 token 额度门禁 // 1. 负责注册、登录、刷新、登出、JWT 签发/校验黑名单;
// 2. 不负责 Gin gateway 的响应适配、路由聚合和 SSE 等边缘职责; // 2. 不负责 Gin gateway 的响应适配、路由聚合和 SSE 等边缘职责;
// 3. 不负责 agent 会话 token 统计,迁移期该链路仍由 agent 持久化事件触发 userauth 账本调整 // 3. 旧 token 额度门禁与记账能力已下线,不再由 userauth 承担计费相关职责
type Service struct { type Service struct {
userRepo UserRepo userRepo UserRepo
cacheRepo CacheRepo cacheRepo CacheRepo

View File

@@ -37,6 +37,7 @@ type ImportCoursesResult struct {
} }
type CourseImageParseRequest struct { type CourseImageParseRequest struct {
UserID int `json:"user_id"`
Filename string `json:"filename"` Filename string `json:"filename"`
MIMEType string `json:"mime_type"` MIMEType string `json:"mime_type"`
ImageBytes []byte `json:"image_bytes"` ImageBytes []byte `json:"image_bytes"`

View File

@@ -0,0 +1,172 @@
package creditstore
// PageResult 是 Credit 领域的分页结果契约。
type PageResult struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
HasMore bool `json:"has_more"`
}
// CreditBalanceSnapshot 提供给商店页和 LLM 准入 Guard 的余额快照。
//
// 职责边界:
// 1. 只表达 TokenStore 权威账本视角下的 Credit 余额与阻断状态。
// 2. snapshot_source 用于说明结果来自 cache 还是 db。
// 3. 不承载任何扣费规则细节,避免把价格表语义耦合到余额查询。
type CreditBalanceSnapshot struct {
UserID uint64 `json:"user_id"`
Balance int64 `json:"balance"`
TotalRecharged int64 `json:"total_recharged"`
TotalRewarded int64 `json:"total_rewarded"`
TotalConsumed int64 `json:"total_consumed"`
IsBlocked bool `json:"is_blocked"`
SnapshotSource string `json:"snapshot_source"`
UpdatedAt string `json:"updated_at"`
}
// CreditProductView 是 Credit 商品卡片展示结构。
type CreditProductView struct {
ProductID uint64 `json:"product_id"`
Name string `json:"name"`
Description string `json:"description"`
CreditAmount int64 `json:"credit_amount"`
PriceCent int64 `json:"price_cent"`
OriginalPriceCent int64 `json:"original_price_cent"`
PriceText string `json:"price_text"`
Currency string `json:"currency"`
Badge string `json:"badge"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
// CreditOrderView 是 Credit 订单展示结构。
type CreditOrderView struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
Status string `json:"status"`
ProductSnapshot string `json:"product_snapshot"`
ProductName string `json:"product_name"`
Quantity int `json:"quantity"`
CreditAmount int64 `json:"credit_amount"`
AmountCent int64 `json:"amount_cent"`
PriceText string `json:"price_text"`
Currency string `json:"currency"`
PaymentMode string `json:"payment_mode"`
CreatedAt string `json:"created_at"`
PaidAt *string `json:"paid_at"`
CreditedAt *string `json:"credited_at"`
}
// CreditTransactionView 是 Credit 流水展示结构。
type CreditTransactionView struct {
TransactionID uint64 `json:"transaction_id"`
EventID string `json:"event_id"`
Source string `json:"source"`
SourceLabel string `json:"source_label"`
Direction string `json:"direction"`
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balance_after"`
Status string `json:"status"`
Description string `json:"description"`
MetadataJSON string `json:"metadata_json"`
CreatedAt string `json:"created_at"`
OrderID *uint64 `json:"order_id"`
}
const (
// CreditConsumptionPeriod24h 表示统计最近 24 小时内的消耗。
CreditConsumptionPeriod24h = "24h"
// CreditConsumptionPeriod7d 表示统计最近 7 天内的消耗。
CreditConsumptionPeriod7d = "7d"
// CreditConsumptionPeriod30d 表示统计最近 30 天内的消耗。
CreditConsumptionPeriod30d = "30d"
// CreditConsumptionPeriodAll 表示统计全部历史消耗。
CreditConsumptionPeriodAll = "all"
)
// CreditConsumptionDashboardView 是商店页顶部消耗看板的展示结构。
type CreditConsumptionDashboardView struct {
Period string `json:"period"`
CreditConsumed int64 `json:"credit_consumed"`
TokenConsumed int64 `json:"token_consumed"`
}
// CreditPriceRuleView 是 Credit 计价规则展示结构。
type CreditPriceRuleView struct {
RuleID uint64 `json:"rule_id"`
Scene string `json:"scene"`
ProviderName string `json:"provider_name"`
ModelName string `json:"model_name"`
InputPriceMicros int64 `json:"input_price_micros"`
OutputPriceMicros int64 `json:"output_price_micros"`
CachedPriceMicros int64 `json:"cached_price_micros"`
ReasoningPriceMicros int64 `json:"reasoning_price_micros"`
CreditPerYuan int64 `json:"credit_per_yuan"`
Status string `json:"status"`
Priority int `json:"priority"`
Description string `json:"description"`
}
// CreditRewardRuleView 是 Credit 奖励规则展示结构。
type CreditRewardRuleView struct {
RuleID uint64 `json:"rule_id"`
Source string `json:"source"`
Name string `json:"name"`
Amount int64 `json:"amount"`
Status string `json:"status"`
Description string `json:"description"`
}
// CreateCreditOrderRequest 是创建 Credit 订单请求契约。
type CreateCreditOrderRequest struct {
ActorUserID uint64 `json:"actor_user_id"`
ProductID uint64 `json:"product_id"`
Quantity int `json:"quantity"`
IdempotencyKey string `json:"idempotency_key"`
}
// ListCreditOrdersRequest 是 Credit 订单列表查询契约。
type ListCreditOrdersRequest struct {
ActorUserID uint64 `json:"actor_user_id"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Status string `json:"status"`
}
// GetCreditConsumptionDashboardRequest 是查询当前用户消耗看板的契约。
type GetCreditConsumptionDashboardRequest struct {
ActorUserID uint64 `json:"actor_user_id"`
Period string `json:"period"`
}
// MockPaidCreditOrderRequest 是 Credit 商店 mock paid 请求契约。
type MockPaidCreditOrderRequest struct {
ActorUserID uint64 `json:"actor_user_id"`
OrderID uint64 `json:"order_id"`
MockChannel string `json:"mock_channel"`
IdempotencyKey string `json:"idempotency_key"`
}
// ListCreditTransactionsRequest 是当前用户查询自己 Credit 流水的契约。
type ListCreditTransactionsRequest struct {
ActorUserID uint64 `json:"actor_user_id"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Source string `json:"source"`
Direction string `json:"direction"`
}
// ListCreditPriceRulesRequest 是 Credit 价格规则查询契约。
type ListCreditPriceRulesRequest struct {
Scene string `json:"scene"`
ProviderName string `json:"provider_name"`
ModelName string `json:"model_name"`
Status string `json:"status"`
}
// ListCreditRewardRulesRequest 是 Credit 奖励规则查询契约。
type ListCreditRewardRulesRequest struct {
Source string `json:"source"`
Status string `json:"status"`
}

View File

@@ -0,0 +1,131 @@
package llm
import (
"strings"
"github.com/cloudwego/eino/schema"
)
const (
ModelAliasLite = "lite"
ModelAliasPro = "pro"
ModelAliasMax = "max"
ModelAliasCourseImageResponses = "course_image_responses"
DefaultTextModelAlias = ModelAliasPro
ProviderNameArk = "ark"
)
// PingRequest 只用于跨进程探活,不承载业务字段。
type PingRequest struct{}
// PingResponse 只用于跨进程探活,不承载业务字段。
type PingResponse struct{}
// BillingContext 是 LLM RPC 透传的计费上下文副本。
type BillingContext struct {
UserID uint64 `json:"user_id"`
EventID string `json:"event_id"`
Scene string `json:"scene"`
RequestID string `json:"request_id"`
ConversationID string `json:"conversation_id"`
ModelAlias string `json:"model_alias"`
SkipCharge bool `json:"skip_charge"`
}
// GenerateOptions 是文本模型跨进程调用时的最小公共参数。
type GenerateOptions struct {
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
Thinking string `json:"thinking"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// TextRequest 描述一次非流式文本调用。
type TextRequest struct {
ModelAlias string `json:"model_alias"`
Messages []*schema.Message `json:"messages"`
Options GenerateOptions `json:"options"`
Billing *BillingContext `json:"billing,omitempty"`
}
// StreamTextRequest 描述一次流式文本调用。
type StreamTextRequest struct {
ModelAlias string `json:"model_alias"`
Messages []*schema.Message `json:"messages"`
Options GenerateOptions `json:"options"`
Billing *BillingContext `json:"billing,omitempty"`
}
// TextResult 保存文本模型最终输出。
type TextResult struct {
Text string `json:"text"`
Usage *schema.TokenUsage `json:"usage,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
}
// TextResponse 统一包住文本调用结果,便于后续扩字段。
type TextResponse struct {
Result *TextResult `json:"result,omitempty"`
}
// StreamChunk 是流式返回的最小块协议。
type StreamChunk struct {
Message *schema.Message `json:"message,omitempty"`
}
// ResponsesMessage 描述 Responses 模型单条输入。
type ResponsesMessage struct {
Role string `json:"role"`
Text string `json:"text,omitempty"`
ImageURL string `json:"image_url,omitempty"`
ImageDetail string `json:"image_detail,omitempty"`
}
// ResponsesOptions 描述 Responses 模型公共参数。
type ResponsesOptions struct {
Model string `json:"model,omitempty"`
Temperature float64 `json:"temperature"`
MaxOutputTokens int `json:"max_output_tokens"`
Thinking string `json:"thinking"`
TextFormat string `json:"text_format,omitempty"`
}
// ResponsesRequest 描述一次 Responses 文本调用。
type ResponsesRequest struct {
ModelAlias string `json:"model_alias"`
Messages []ResponsesMessage `json:"messages"`
Options ResponsesOptions `json:"options"`
Billing *BillingContext `json:"billing,omitempty"`
}
// ResponsesUsage 是 Responses 结果里的最小 usage 结构。
type ResponsesUsage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
}
// ResponsesResult 统一承载 Responses 输出。
type ResponsesResult struct {
Text string `json:"text"`
Status string `json:"status,omitempty"`
IncompleteReason string `json:"incomplete_reason,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
Usage *ResponsesUsage `json:"usage,omitempty"`
}
// ResponsesResponse 统一包住 Responses 结果。
type ResponsesResponse struct {
Result *ResponsesResult `json:"result,omitempty"`
}
// NormalizeModelAlias 负责把空别名收敛到默认文本模型。
func NormalizeModelAlias(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return DefaultTextModelAlias
}
return trimmed
}

View File

@@ -1,125 +0,0 @@
package tokenstore
// PageResult 是 token-store 分页响应的跨层契约。
type PageResult struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
HasMore bool `json:"has_more"`
}
// TokenSummary 是 Token 商店概览响应。
//
// 职责边界:
// 1. P0 展示 token-store 已记录的获取事实;
// 2. 不承诺这些 Token 已经同步到 user/auth 权威额度;
// 3. 后续接入 user/auth 后可把 QuotaSyncStatus 调整为 synced。
type TokenSummary struct {
RecordedTokenTotal int64 `json:"recorded_token_total"`
AppliedTokenTotal int64 `json:"applied_token_total"`
PendingApplyTokenTotal int64 `json:"pending_apply_token_total"`
QuotaSyncStatus string `json:"quota_sync_status"`
Tip string `json:"tip"`
}
// TokenProductView 是商品卡片展示结构。
type TokenProductView struct {
ProductID uint64 `json:"product_id"`
Name string `json:"name"`
Description string `json:"description"`
TokenAmount int64 `json:"token_amount"`
PriceCent int64 `json:"price_cent"`
PriceText string `json:"price_text"`
Currency string `json:"currency"`
Badge string `json:"badge"`
Status string `json:"status"`
SortOrder int `json:"sort_order"`
}
// TokenGrantView 是 Token 获取记录展示结构。
type TokenGrantView struct {
GrantID uint64 `json:"grant_id"`
EventID string `json:"event_id"`
Source string `json:"source"`
SourceLabel string `json:"source_label"`
Amount int64 `json:"amount"`
Status string `json:"status"`
QuotaApplied bool `json:"quota_applied"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
}
// TokenOrderView 是订单展示结构。
type TokenOrderView struct {
OrderID uint64 `json:"order_id"`
OrderNo string `json:"order_no"`
Status string `json:"status"`
ProductSnapshot string `json:"product_snapshot"`
ProductName string `json:"product_name"`
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 *TokenGrantView `json:"grant"`
CreatedAt string `json:"created_at"`
PaidAt *string `json:"paid_at"`
GrantedAt *string `json:"granted_at"`
}
// CreateTokenOrderRequest 是创建订单请求契约。
type CreateTokenOrderRequest struct {
ActorUserID uint64 `json:"actor_user_id"`
ProductID uint64 `json:"product_id"`
Quantity int `json:"quantity"`
IdempotencyKey string `json:"idempotency_key"`
}
// ListTokenOrdersRequest 是订单列表查询契约。
type ListTokenOrdersRequest struct {
ActorUserID uint64 `json:"actor_user_id"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Status string `json:"status"`
}
// MockPaidOrderRequest 是 P0 mock paid 请求契约。
type MockPaidOrderRequest struct {
ActorUserID uint64 `json:"actor_user_id"`
OrderID uint64 `json:"order_id"`
MockChannel string `json:"mock_channel"`
IdempotencyKey string `json:"idempotency_key"`
}
// ListTokenGrantsRequest 是 Token 获取记录列表查询契约。
type ListTokenGrantsRequest struct {
ActorUserID uint64 `json:"actor_user_id"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Source string `json:"source"`
}
// RecordForumRewardGrantRequest 是论坛奖励入账的内部 RPC 契约。
//
// 职责边界:
// 1. 只描述一条待记录到 token_grants 的论坛奖励事实;
// 2. 不携带最终奖励金额,金额由 token-store 按 source 和配置解析;
// 3. source_ref_id 使用字符串承接 post_id / import_id服务层再按当前库表结构落成整数。
type RecordForumRewardGrantRequest struct {
EventID string `json:"event_id"`
ReceiverUserID uint64 `json:"receiver_user_id"`
Source string `json:"source"`
SourceRefID string `json:"source_ref_id"`
}
// TokenGrantRecord 是 token-store 内部发放出口使用的获取事实。
type TokenGrantRecord struct {
EventID string `json:"event_id"`
UserID uint64 `json:"user_id"`
Source string `json:"source"`
SourceRefID uint64 `json:"source_ref_id"`
OrderID uint64 `json:"order_id"`
Amount int64 `json:"amount"`
Description string `json:"description"`
}

View File

@@ -46,23 +46,3 @@ type ValidateAccessTokenResponse struct {
JTI string `json:"jti"` JTI string `json:"jti"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
} }
// CheckTokenQuotaRequest 是 agent/chat 进入业务前的额度门禁请求。
type CheckTokenQuotaRequest struct {
UserID int `json:"user_id"`
}
// AdjustTokenUsageRequest 是业务链路回写用户 token 账本的请求。
type AdjustTokenUsageRequest struct {
EventID string `json:"event_id"`
UserID int `json:"user_id"`
TokenDelta int `json:"token_delta"`
}
// CheckTokenQuotaResponse 返回额度门禁判断结果。
type CheckTokenQuotaResponse struct {
Allowed bool `json:"allowed"`
TokenLimit int `json:"token_limit"`
TokenUsage int `json:"token_usage"`
LastResetAt time.Time `json:"last_reset_at"`
}

View File

@@ -0,0 +1,91 @@
package events
import (
"errors"
"fmt"
"strings"
"time"
)
const (
// CreditChargeRequestedEventType 表示 LLM 服务已经拿到最终 usage等待 TokenStore 异步结算。
CreditChargeRequestedEventType = "credit.charge.requested"
// CreditChargeEventVersion 是当前 Credit 扣费事件版本。
CreditChargeEventVersion = "v1"
)
// CreditChargeRequestedPayload 是 LLM -> TokenStore 的统一扣费事件。
//
// 职责边界:
// 1. 只表达“一次已经完成的模型调用需要如何结算”,不承载准入决策;
// 2. event_id 是最终幂等键LLM outbox 重试和 TokenStore 记账都依赖它去重;
// 3. credit_cost / rmb_cost_micros 在 LLM 侧就要算好TokenStore 只负责权威落账。
type CreditChargeRequestedPayload struct {
EventID string `json:"event_id"`
UserID uint64 `json:"user_id"`
Scene string `json:"scene"`
RequestID string `json:"request_id"`
ConversationID string `json:"conversation_id"`
ModelAlias string `json:"model_alias"`
ProviderName string `json:"provider_name"`
ModelName string `json:"model_name"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CachedTokens int64 `json:"cached_tokens"`
ReasoningTokens int64 `json:"reasoning_tokens"`
TotalTokens int64 `json:"total_tokens"`
RMBCostMicros int64 `json:"rmb_cost_micros"`
CreditCost int64 `json:"credit_cost"`
TriggeredAt time.Time `json:"triggered_at"`
SkipCharge bool `json:"skip_charge,omitempty"`
}
// EventType 返回当前 payload 对应的标准事件类型。
func (p CreditChargeRequestedPayload) EventType() string {
return CreditChargeRequestedEventType
}
// MessageKey 返回 Kafka / outbox 统一消息键。
func (p CreditChargeRequestedPayload) MessageKey() string {
return strings.TrimSpace(p.EventID)
}
// AggregateID 返回扣费事件聚合键。
func (p CreditChargeRequestedPayload) AggregateID() string {
if p.UserID <= 0 {
return ""
}
return fmt.Sprintf("user:%d", p.UserID)
}
// Validate 校验扣费事件最小字段集。
func (p CreditChargeRequestedPayload) Validate() error {
if strings.TrimSpace(p.EventID) == "" {
return errors.New("credit charge event_id 不能为空")
}
if p.UserID == 0 {
return errors.New("credit charge user_id 不能为空")
}
if strings.TrimSpace(p.Scene) == "" {
return errors.New("credit charge scene 不能为空")
}
if strings.TrimSpace(p.ProviderName) == "" {
return errors.New("credit charge provider_name 不能为空")
}
if strings.TrimSpace(p.ModelName) == "" {
return errors.New("credit charge model_name 不能为空")
}
if p.TriggeredAt.IsZero() {
return errors.New("credit charge triggered_at 不能为空")
}
if p.SkipCharge {
return nil
}
if p.CreditCost < 0 {
return errors.New("credit charge credit_cost 不能为负数")
}
if p.RMBCostMicros < 0 {
return errors.New("credit charge rmb_cost_micros 不能为负数")
}
return nil
}

View File

@@ -16,6 +16,7 @@ const (
ServiceActiveScheduler = "active-scheduler" ServiceActiveScheduler = "active-scheduler"
ServiceNotification = "notification" ServiceNotification = "notification"
ServiceTaskClassForum = "taskclass-forum" ServiceTaskClassForum = "taskclass-forum"
ServiceLLM = "llm"
ServiceTokenStore = "token-store" ServiceTokenStore = "token-store"
) )
@@ -91,6 +92,12 @@ func LoadServiceConfigs() map[string]ServiceConfig {
GroupID: "smartflow-taskclass-forum-outbox-consumer", GroupID: "smartflow-taskclass-forum-outbox-consumer",
TableName: "taskclass_forum_outbox_messages", TableName: "taskclass_forum_outbox_messages",
}, },
ServiceLLM: {
Name: ServiceLLM,
Topic: "smartflow.llm.outbox",
GroupID: "smartflow-llm-outbox-consumer",
TableName: "llm_outbox_messages",
},
ServiceTokenStore: { ServiceTokenStore: {
Name: ServiceTokenStore, Name: ServiceTokenStore,
Topic: "smartflow.token-store.outbox", Topic: "smartflow.token-store.outbox",

View File

@@ -11,6 +11,7 @@ const (
ServiceNameActiveScheduler = "active-scheduler" ServiceNameActiveScheduler = "active-scheduler"
ServiceNameNotification = "notification" ServiceNameNotification = "notification"
ServiceNameTaskClassForum = "taskclass-forum" ServiceNameTaskClassForum = "taskclass-forum"
ServiceNameLLM = "llm"
ServiceNameTokenStore = "token-store" ServiceNameTokenStore = "token-store"
) )
@@ -64,6 +65,12 @@ var builtinServiceRoutes = map[string]ServiceRoute{
Topic: "smartflow.taskclass-forum.outbox", Topic: "smartflow.taskclass-forum.outbox",
GroupID: "smartflow-taskclass-forum-outbox-consumer", GroupID: "smartflow-taskclass-forum-outbox-consumer",
}, },
ServiceNameLLM: {
ServiceName: ServiceNameLLM,
TableName: "llm_outbox_messages",
Topic: "smartflow.llm.outbox",
GroupID: "smartflow-llm-outbox-consumer",
},
ServiceNameTokenStore: { ServiceNameTokenStore: {
ServiceName: ServiceNameTokenStore, ServiceName: ServiceNameTokenStore,
TableName: "token_store_outbox_messages", TableName: "token_store_outbox_messages",
@@ -86,6 +93,7 @@ func DefaultServiceRoutes() []ServiceRoute {
builtinServiceRoutes[ServiceNameActiveScheduler], builtinServiceRoutes[ServiceNameActiveScheduler],
builtinServiceRoutes[ServiceNameNotification], builtinServiceRoutes[ServiceNameNotification],
builtinServiceRoutes[ServiceNameTaskClassForum], builtinServiceRoutes[ServiceNameTaskClassForum],
builtinServiceRoutes[ServiceNameLLM],
builtinServiceRoutes[ServiceNameTokenStore], builtinServiceRoutes[ServiceNameTokenStore],
} }
} }

View File

@@ -24,23 +24,9 @@ type AccessTokenValidator interface {
ValidateAccessToken(ctx context.Context, accessToken string) (*contracts.ValidateAccessTokenResponse, error) ValidateAccessToken(ctx context.Context, accessToken string) (*contracts.ValidateAccessTokenResponse, error)
} }
// TokenQuotaChecker 是 agent/chat 入口做额度门禁时依赖的最小接口。
// 职责边界:只判断当前用户是否允许继续消费 token不负责 token 入账。
type TokenQuotaChecker interface {
CheckTokenQuota(ctx context.Context, userID int) (*contracts.CheckTokenQuotaResponse, error)
}
// TokenUsageAdjuster 是业务链路回写 token 账本时依赖的最小接口。
// 职责边界:只做 token 账本增量调整,不承载鉴权与登录逻辑。
type TokenUsageAdjuster interface {
AdjustTokenUsage(ctx context.Context, req contracts.AdjustTokenUsageRequest) (*contracts.CheckTokenQuotaResponse, error)
}
// UserAuthClient 组合当前阶段需要的 user/auth 能力。 // UserAuthClient 组合当前阶段需要的 user/auth 能力。
// 职责边界:作为统一装配口径,避免 gateway 和 core service 各自维护一份接口。 // 职责边界:作为统一装配口径,避免 gateway 和 core service 各自维护一份接口。
type UserAuthClient interface { type UserAuthClient interface {
UserCommandClient UserCommandClient
AccessTokenValidator AccessTokenValidator
TokenQuotaChecker
TokenUsageAdjuster
} }

View File

@@ -71,15 +71,29 @@ services:
depends_on: depends_on:
kafka: kafka:
condition: service_healthy condition: service_healthy
entrypoint: ["/bin/bash", "-c"] entrypoint:
command: > - /bin/bash
/opt/kafka/bin/kafka-topics.sh - -lc
--bootstrap-server kafka:9094 - |
--create set -e
--if-not-exists for topic in \
--topic smartflow.agent.outbox smartflow.agent.outbox \
--partitions 3 smartflow.task.outbox \
--replication-factor 1 smartflow.memory.outbox \
smartflow.active-scheduler.outbox \
smartflow.notification.outbox \
smartflow.taskclass-forum.outbox \
smartflow.llm.outbox \
smartflow.token-store.outbox
do
/opt/kafka/bin/kafka-topics.sh \
--bootstrap-server kafka:9094 \
--create \
--if-not-exists \
--topic "$$topic" \
--partitions 3 \
--replication-factor 1
done
restart: "no" restart: "no"
etcd: etcd:

View File

@@ -0,0 +1,602 @@
# 统一出口 Credit 计费最终计划
## 1. 文档目的
本计划用于把当前系统从“各业务服务进程内直接持有 LLM 组件、调用结束后再走旧 token 累加”的模式,重构为:
1. `LLM` 成为真正的独立服务统一掌管模型调用入口、出口、usage 采集与计费事件发布。
2. `TokenStore` 服务切换为 Credit 语义权威服务,掌管余额、商品、订单、流水与最终扣费落账。
3. 前端商店直接消费 Credit 语义接口,并能展示“当前登录用户自己的 Credit 流水”。
这份计划改成“三步走”版本,便于直接按阶段批准与执行。
---
## 2. 总体结论
### 2.1 核心结论
1. `backend/services/llm` 不能再以“进程内共享组件”存在,必须升级为独立服务。
2. 所有用户态模型调用统一经过 `LLM RPC`,由 `LLM` 服务统一采集 usage。
3. `LLM` 服务自己做同步准入 `CreditBalanceGuard`,并使用 Redis 余额快照提速。
4. `LLM` 服务不做同步扣费,也不允许裸 goroutine 异步扣费。
5. `LLM` 服务在拿到最终 usage 后,把 `credit.charge.requested` 写入 **自己的 outbox 表**
6. `TokenStore` 服务异步消费扣费事件,更新 Credit 余额与流水。
7. 旧 token 累加链路、旧 token 商店链路和旧 `/token-store` 接口本轮直接删除,不保留兼容层。
### 2.2 关键原则
1. **统一出口**:所有 LLM 计费只在 `LLM` 服务出口发生,不允许业务层各自重复扣费。
2. **强制入参**:不能只靠 `Metadata` 约定传 `user_id`,必须改 RPC/函数签名,让漏传在编译期报错。
3. **准入同步、结算异步**:调用模型前同步做 `CreditBalanceGuard`,拿到最终 usage 后通过 outbox 异步结算。
4. **旧商店直删**:旧 token 商店前后端都未真正接线,本轮不做 token/credit 双轨并存。
5. **调用面兼容优先**`LLM RPC` 可以新增内部协议,但 `backend/client/llm` 对业务侧暴露的调用方式应尽量贴近当前 `services/llm`,把迁移成本压到最低。
---
## 3. 为什么要这样改
### 3.1 当前最大问题
1. `services/llm` 名字像服务,实际上不是服务,而是多个进程直接 new 的本地组件。
2. 计费边界分散,谁发起调用谁就得自己记账,很容易遗漏。
3. 当前只有旧 `token_usage` 累加,没有人民币成本与 Credit 扣费真相。
4. `userauth` 当前承担的是 `token quota` 门禁,不是 Credit 余额门禁。
5. 旧 token 商店前后端都没有真正接上,因此没有保留兼容层的价值。
### 3.2 这次改完后的好处
1. LLM 调用入口统一。
2. usage 采集统一。
3. 余额准入统一。
4. 扣费事件发布统一。
5. TokenStore 只做 Credit 权威账本,不再和模型调用散乱耦合。
---
## 4. 三步走总览
### 第一步:把 LLM 变成真正独立服务
目标:
1. 所有模型调用统一改走 `LLM RPC`
2. `LLM` 服务拥有自己的 `CreditBalanceGuard`
3. `LLM` 服务拥有自己的 outbox 表
### 第二步:把 TokenStore 改造成 Credit 权威服务
目标:
1. 所有余额、流水、商品、订单都切成 Credit 语义
2. 异步消费 `credit.charge.requested`
3. 提供“查看自己流水”的正式接口
### 第三步:切 Gateway 与前端,并删旧链路
目标:
1. 上线 `/credit-store/*`
2. 商店页接 Credit 真接口与流水列表
3. 删掉旧 token 累加和旧 token 商店链路
---
## 5. 第一步:把 LLM 变成真正独立服务
## 5.1 这一步的目标
把当前“各进程内直接持有 LLM 组件”的模式,改成“统一调 LLM 服务”。
## 5.2 新增/改造内容
### 5.2.1 新增独立 LLM 进程与 RPC
新增:
1. `backend/cmd/llm/main.go`
2. `backend/client/llm/client.go`
3. `backend/services/llm/rpc/pb/llm.proto`
4. `backend/services/llm/rpc/handler.go`
5. `backend/services/llm/rpc/server.go`
6. `backend/services/llm/dao/connect.go`
说明:
1. 这样 `services/llm` 放在 `services` 目录下才真正名副其实。
2. 业务服务以后全部只依赖 `client/llm`,不再直接 import `services/llm` 的本地组件。
### 5.2.2 统一 RPC 能力,但业务侧调用面尽量保持原状
服务间 `LLM RPC` 至少提供三类底层能力:
1. `GenerateText`
2. `StreamText`
3. `GenerateResponsesText`
但是对业务代码,不建议直接散落调用底层 RPC 方法名,而是由 `backend/client/llm` 继续暴露与当前本地 `llm client` 基本一致的门面:
1. `(*Client).GenerateText(ctx, messages, options)`
2. `GenerateJSON[T](ctx, client, messages, options)`
3. `(*Client).Stream(ctx, messages, options)`
4. `(*ArkResponsesClient).GenerateText(ctx, messages, options)`
5. `GenerateArkResponsesJSON[T](ctx, client, messages, options)`
迁移目标:
1. 业务层尽量只替换 client 来源,不重写 prompt 组织方式。
2. `GenerateOptions``ArkResponsesOptions` 现有字段语义尽量不变。
3. `generateJson / stream / responses json` 这些现有能力名和使用习惯尽量延续,避免把改造面扩大成“全链路重写调用协议”。
使用分工:
1. `GenerateText`memory、active-scheduler、agent 非流式调用
2. `StreamText`agent chat / plan / execute / deliver
3. `GenerateResponsesText`course 图片解析
### 5.2.3 统一 BillingContext但不要污染现有 GenerateOptions
所有请求都必须携带:
```go
type BillingContext struct {
UserID int
EventID string
Scene string
RequestID string
ConversationID string
ModelAlias string
SkipCharge bool
}
```
要求:
1. `UserID` 必须明确,普通用户态调用禁止省略。
2. `EventID` 必须稳定,作为幂等扣费键。
3. `Scene` 必须明确,方便价格表与审计。
4. `SkipCharge` 只允许系统内部场景显式声明。
5. `BillingContext` 不再塞进 `GenerateOptions.Metadata` 里,避免继续靠弱约定传关键字段。
6. `GenerateOptions``ArkResponsesOptions` 继续只承载模型行为参数,例如 `Temperature / MaxTokens / Thinking`
落地建议:
1. `client/llm` 对调用方继续保留现有 `GenerateText / GenerateJSON / Stream / Responses` 风格方法。
2. 计费必填信息通过独立的调用包装层传入,例如“绑定过 BillingContext 的 client”或“显式 request 参数对象”,但要由 `client/llm` 吸收复杂度。
3. 调用方改造目标应控制在“补一处 BillingContext 组装 + 替换 client 初始化来源”,而不是每个业务点重写整段调用代码。
### 5.2.4 LLM 服务自己的 CreditBalanceGuard
这一步必须同时完成,否则独立 LLM 服务没有准入价值。
Guard 设计要求:
1. 模型真正发起前同步执行。
2. 不直接查 MySQL 真相表,必须走 Redis 快照优先。
3. 缓存 miss 或过期时,再回源 `TokenStore RPC`
建议缓存 key
1. `smartflow:credit_balance_snapshot:{user_id}`
2. `smartflow:credit_balance_blocked:{user_id}`
执行顺序:
1. 先查 `blocked`
2. 再查 `snapshot`
3. miss 时调用 TokenStore 获取余额快照
4. 若余额不足,写短 TTL 的 `blocked`
### 5.2.5 LLM 服务自己的 outbox
这一步也必须同时完成。
新增:
1. `llm_outbox_messages`
并在 outbox 目录注册:
1. `ServiceLLM`
2. `ServiceNameLLM`
3. `smartflow.llm.outbox`
4. `smartflow-llm-outbox-consumer`
涉及文件:
1. `backend/shared/infra/outbox/service_catalog.go`
2. `backend/shared/infra/outbox/service_route.go`
3. `backend/services/llm/dao/connect.go`
### 5.2.6 LLM 服务统一 usage 采集
`LLM` 服务内部统一输出:
```go
type BillingUsage struct {
InputTokens int64
OutputTokens int64
CachedTokens int64
ReasoningTokens int64
TotalTokens int64
ModelName string
ProviderName string
}
```
### 5.2.7 LLM 服务只发 charge 事件,不同步扣费
新增事件:
1. `credit.charge.requested`
事件载荷至少包含:
1. `event_id`
2. `user_id`
3. `scene`
4. `request_id`
5. `conversation_id`
6. `provider_name`
7. `model_name`
8. `input_tokens`
9. `output_tokens`
10. `cached_tokens`
11. `reasoning_tokens`
12. `rmb_cost_micros`
13. `credit_cost`
14. `triggered_at`
时机:
1. 拿到最终 usage
2. 算出本次 `credit_cost`
3. 写入 `llm_outbox_messages`
4. 结果即可返回调用方
## 5.3 这一步涉及哪些调用方改造
以下服务都要从“本地 import `services/llm`”改成“调用 `client/llm`”:
1. `agent`
2. `memory`
3. `active-scheduler`
4. `course`
---
## 6. 第二步:把 TokenStore 改造成 Credit 权威服务
## 6.1 这一步的目标
`TokenStore` 不再只是“充值/奖励入账中心”,而是:
1. Credit 余额真相
2. Credit 商品与订单中心
3. Credit 流水中心
4. LLM 扣费事件的最终结算方
## 6.2 新的 Credit 数据表
本轮不复用旧 `token_*` 表,直接建立纯 Credit 语义的新表:
1. `credit_products`
2. `credit_orders`
3. `credit_accounts`
4. `credit_ledger`
5. `credit_price_rules`
6. `credit_reward_rules`
### 6.2.1 `credit_products`
用途:商店展示的 Credit 商品
核心字段:
1. `sku`
2. `name`
3. `description`
4. `credit_amount`
5. `price_cent`
6. `currency`
7. `badge`
8. `status`
9. `sort_order`
### 6.2.2 `credit_orders`
用途:购买订单与 mock paid 状态机
核心字段:
1. `order_no`
2. `user_id`
3. `product_id`
4. `product_snapshot_json`
5. `quantity`
6. `credit_amount`
7. `amount_cent`
8. `status`
9. `payment_mode`
10. `idempotency_key`
11. `paid_at`
12. `credited_at`
### 6.2.3 `credit_accounts`
用途:余额真相与门禁回源表
核心字段:
1. `user_id`
2. `balance`
3. `total_recharged`
4. `total_rewarded`
5. `total_consumed`
6. `version`
### 6.2.4 `credit_ledger`
用途:统一正负流水,也是“查看自己流水”的唯一真相源
核心字段:
1. `event_id`
2. `user_id`
3. `direction`
4. `source`
5. `scene`
6. `description`
7. `credit_delta`
8. `balance_after`
9. `input_tokens`
10. `output_tokens`
11. `cached_tokens`
12. `reasoning_tokens`
13. `rmb_cost_micros`
14. `created_at`
### 6.2.5 `credit_price_rules`
用途:模型价格表
核心字段:
1. `provider_name`
2. `model_name`
3. `scene`
4. `input_price_micros_per_1k`
5. `output_price_micros_per_1k`
6. `cached_price_micros_per_1k`
7. `reasoning_price_micros_per_1k`
8. `credits_per_yuan`
9. `status`
### 6.2.6 `credit_reward_rules`
用途:社区奖励规则
核心字段:
1. `source`
2. `name`
3. `credit_amount`
4. `status`
5. `config_json`
## 6.3 TokenStore 如何消费 charge 事件
新增消费逻辑:
1. 订阅 `credit.charge.requested`
2. 幂等校验 `event_id`
3. 在事务中写 `credit_ledger`
4. 扣减 `credit_accounts.balance`
5. 刷新或删除 Redis 余额快照
## 6.4 用户查看自己流水接口
这是本轮必须落的能力。
对外新增:
1. `ListCreditTransactions`
要求:
1. 明确用于“当前登录用户查看自己的 Credit 流水”
2. 必须覆盖购买入账、社区奖励入账、LLM 消费扣费三类记录
HTTP 接口:
1. `GET /api/v1/credit-store/transactions`
必须满足:
1. 只返回当前登录用户自己的 Credit 流水
2. 支持分页:`page``page_size`
3. 支持可选筛选:`direction``source``scene`
4. 返回字段至少包含:
- `transaction_id`
- `event_id`
- `direction`
- `source`
- `scene`
- `description`
- `credit_delta`
- `balance_after`
- `created_at`
## 6.5 这一步要删除什么旧东西
直接删除:
1. `token_products`
2. `token_orders`
3. `token_grants`
4. `token_reward_rules`
---
## 7. 第三步:切 Gateway/前端并删旧链路
## 7.1 这一步的目标
1. 上线 `/credit-store/*`
2. 商店页接 Credit 真接口
3. 删掉旧 token 累加和旧 token 商店链路
## 7.2 Gateway 切换
只保留 Credit 语义路由:
1. `GET /api/v1/credit-store/summary`
2. `GET /api/v1/credit-store/products`
3. `POST /api/v1/credit-store/orders`
4. `POST /api/v1/credit-store/orders/:order_id/mock-paid`
5. `GET /api/v1/credit-store/transactions`
直接删除:
1. `/token-store/*`
2. `backend/gateway/api/tokenstoreapi/*`
## 7.3 前端商店页切换
商店页必须改成:
1.`/credit-store/*`
2. 新增“我的 Credit 流水”展示区
3. 流水数据源只允许来自 `GET /api/v1/credit-store/transactions`
4. 至少展示:
- 流水类型
- 流水说明
- Credit 增减值
- 流水发生时间
- 可选余额快照
## 7.4 旧 token 累加机制下线
直接删除:
1. `backend/services/runtime/eventsvc/chat_token_usage_adjust.go`
2. `backend/services/runtime/model/agent.go``ChatTokenUsageAdjustPayload`
3. `backend/services/agent/sv/agent_graph.go` 中旧 token 调整调用
4. `backend/services/agent/sv/agent_meta.go` 中旧 token 调整调用
5. `backend/services/runtime/dao/agent.go``tokens_total` 累加写路径
6. `backend/gateway/middleware/token_quota_guard.go` 在聊天主链的使用
7. `userauth``CheckTokenQuota / AdjustTokenUsage` 的计费用途
8. `agent_chats.tokens_total` 字段
---
## 8. 这三步分别改哪些地方
### 第一步改动面
1. `backend/services/llm/*`
2. `backend/client/llm/*`
3. `backend/cmd/llm/*`
4. `backend/shared/infra/outbox/service_catalog.go`
5. `backend/shared/infra/outbox/service_route.go`
6. `agent / memory / active-scheduler / course` 的所有本地 LLM 调用点
### 第二步改动面
1. `backend/services/tokenstore/*`
2. `backend/client/tokenstore/*`
3. TokenStore 的 outbox consumer
4. Redis 余额快照与失效逻辑
### 第三步改动面
1. `backend/gateway/*`
2. `frontend/src/views/StoreView.vue`
3. 可能新增商店流水列表组件
4. `runtime/userauth` 旧 token 累加链删除
---
## 9. 验证清单
### 9.1 第一步验证
1. 业务服务代码里不再直接 import `services/llm`
2. 所有模型调用都改经 `LLM RPC`
3. 所有请求都必须带 `BillingContext`
4. `CreditBalanceGuard` 命中缓存、miss 回源、余额不足封禁都通过测试
5. `llm_outbox_messages` 能正常写入 `credit.charge.requested`
### 9.2 第二步验证
1. `TokenStore` 能幂等消费 `credit.charge.requested`
2. `credit_accounts` 余额更新正确
3. `credit_ledger` 流水写入正确
4. `ListCreditTransactions` 返回当前用户自己的流水
### 9.3 第三步验证
1. 商店页能展示 Credit 概览
2. 商店页能展示商品
3. 商店页能展示“我的 Credit 流水”
4. 全文搜索确认以下旧链路不再出现在主路径:
- `PublishChatTokenUsageAdjustRequested`
- `AdjustTokenUsage(`
- `CheckTokenQuota(`
- `TokenQuotaGuard`
- `tokens_total +`
- `tokenstoreapi`
- `/token-store`
- `token_products`
- `token_orders`
- `token_grants`
- `token_reward_rules`
---
## 10. 风险与取舍
### 10.1 这次改造面确实更大
因为这次不是只改计费,而是顺带把 `LLM` 从“共享组件”升级成真正独立服务,所以会涉及:
1. 新 RPC
2. 新 client
3. 新 outbox
4. 业务服务调用方式切换
### 10.2 但长期结构更对
收益是:
1. LLM 调用入口统一
2. usage 采集统一
3. 准入 guard 统一
4. 计费事件发布统一
5. TokenStore 只做账本,不再和模型调用散乱耦合
---
## 11. 服务影响总表
### 必改服务
1. `backend/services/llm`
2. `backend/services/tokenstore`
3. `backend/gateway`
4. `backend/services/agent`
5. `backend/services/course`
6. `backend/services/memory`
7. `backend/services/active_scheduler`
### 新增
1. `backend/cmd/llm`
2. `backend/client/llm`
3. `backend/services/llm/rpc/*`
4. `llm_outbox_messages`
### 后续前端接入
1. `frontend/src/views/StoreView.vue`
2. 若商店页拆组件,则补充独立的 Credit 流水列表组件

145
frontend/src/api/forum.ts Normal file
View File

@@ -0,0 +1,145 @@
import http from '@/api/http'
import type { ApiResponse } from '@/types/api'
import type {
CreateForumCommentPayload,
CreateForumPostPayload,
ForumCommentListQuery,
ForumCommentNode,
ForumDeleteCommentResult,
ForumImportResult,
ForumInteractionResult,
ForumPageEnvelope,
ForumPostBrief,
ForumPostDetail,
ForumPostListQuery,
ForumTagItem,
} from '@/types/forum'
import { createIdempotencyKey } from '@/utils/idempotency'
import { extractErrorMessage } from '@/utils/http'
interface ForumTagsEnvelope {
items: ForumTagItem[]
}
export async function listForumPosts(query: ForumPostListQuery = {}) {
try {
const response = await http.get<ApiResponse<ForumPageEnvelope<ForumPostBrief>>>('/plan-square/posts', {
params: query,
})
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '计划广场列表加载失败,请稍后重试'))
}
}
export async function listForumTags(limit = 20) {
try {
const response = await http.get<ApiResponse<ForumTagsEnvelope>>('/plan-square/tags', {
params: { limit },
})
return response.data.data?.items ?? []
} catch (error) {
throw new Error(extractErrorMessage(error, '计划广场标签加载失败,请稍后重试'))
}
}
export async function createForumPost(
payload: CreateForumPostPayload,
idempotencyKey = createIdempotencyKey('forum-post-create'),
) {
try {
const response = await http.post<ApiResponse<ForumPostBrief>>('/plan-square/posts', payload, {
headers: {
'X-Idempotency-Key': idempotencyKey,
},
})
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '发布计划失败,请稍后重试'))
}
}
export async function getForumPostDetail(postId: number) {
try {
const response = await http.get<ApiResponse<ForumPostDetail>>(`/plan-square/posts/${postId}`)
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '计划详情加载失败,请稍后重试'))
}
}
export async function likeForumPost(postId: number) {
try {
const response = await http.post<ApiResponse<ForumInteractionResult>>(`/plan-square/posts/${postId}/like`)
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '点赞失败,请稍后重试'))
}
}
export async function unlikeForumPost(postId: number) {
try {
const response = await http.delete<ApiResponse<ForumInteractionResult>>(`/plan-square/posts/${postId}/like`)
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '取消点赞失败,请稍后重试'))
}
}
export async function listForumComments(postId: number, query: ForumCommentListQuery = {}) {
try {
const response = await http.get<ApiResponse<ForumPageEnvelope<ForumCommentNode>>>(`/plan-square/posts/${postId}/comments`, {
params: query,
})
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '评论列表加载失败,请稍后重试'))
}
}
export async function createForumComment(
postId: number,
payload: CreateForumCommentPayload,
idempotencyKey = createIdempotencyKey('forum-comment-create'),
) {
try {
const response = await http.post<ApiResponse<ForumCommentNode>>(`/plan-square/posts/${postId}/comments`, payload, {
headers: {
'X-Idempotency-Key': idempotencyKey,
},
})
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '发表评论失败,请稍后重试'))
}
}
export async function deleteForumComment(commentId: number) {
try {
const response = await http.delete<ApiResponse<ForumDeleteCommentResult>>(`/plan-square/comments/${commentId}`)
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '删除评论失败,请稍后重试'))
}
}
export async function importForumPost(
postId: number,
targetTitle = '',
idempotencyKey = createIdempotencyKey('forum-post-import'),
) {
try {
const response = await http.post<ApiResponse<ForumImportResult>>(
`/plan-square/posts/${postId}/import`,
{ target_title: targetTitle },
{
headers: {
'X-Idempotency-Key': idempotencyKey,
},
},
)
return response.data.data
} catch (error) {
throw new Error(extractErrorMessage(error, '导入计划失败,请稍后重试'))
}
}

Some files were not shown because too many files have changed in this diff Show More