From 61db6468052a8e0c0e6c8ee322803aebbb128379 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Wed, 6 May 2026 20:16:53 +0800 Subject: [PATCH] Version: 0.9.80.dev.260506 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: 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。 --- backend/client/course/client.go | 1 + backend/client/llm/client.go | 301 +++++ backend/client/llm/errors.go | 73 ++ backend/client/tokenstore/client.go | 335 +----- backend/client/tokenstore/credit.go | 383 +++++++ backend/client/userauth/client.go | 44 - backend/cmd/active-scheduler/main.go | 20 +- backend/cmd/agent/runtime.go | 24 +- backend/cmd/course/main.go | 20 +- backend/cmd/llm/main.go | 157 +++ backend/cmd/memory/main.go | 21 +- backend/cmd/start.go | 19 +- backend/cmd/tokenstore/main.go | 17 +- backend/config.example.yaml | 43 + backend/gateway/api/course.go | 1 + backend/gateway/api/tokenstoreapi/handler.go | 471 ++++++-- backend/gateway/api/tokenstoreapi/routes.go | 11 +- .../gateway/middleware/token_quota_guard.go | 51 - backend/gateway/router/router.go | 2 +- backend/go.sum | 74 ++ backend/scripts/dev-common.ps1 | 84 +- .../core/feedbacklocate/service.go | 23 +- .../core/selection/service.go | 23 +- backend/services/agent/sv/agent_graph.go | 43 +- backend/services/agent/sv/agent_meta.go | 26 +- backend/services/course/rpc/course.proto | 1 + backend/services/course/rpc/handler.go | 1 + backend/services/course/rpc/pb/course.pb.go | 1 + .../services/course/sv/course_parse_ark.go | 28 +- backend/services/llm/ark_responses_client.go | 25 +- backend/services/llm/billing.go | 76 ++ backend/services/llm/billing_compat.go | 68 ++ backend/services/llm/dao/cache.go | 107 ++ backend/services/llm/dao/connect.go | 42 + backend/services/llm/dao/pricing.go | 53 + backend/services/llm/guard.go | 152 +++ backend/services/llm/outbox.go | 211 ++++ backend/services/llm/pricing.go | 207 ++++ backend/services/llm/rpc/errors.go | 71 ++ backend/services/llm/rpc/handler.go | 122 ++ backend/services/llm/rpc/json_codec.go | 38 + backend/services/llm/rpc/llm.proto | 19 + backend/services/llm/rpc/server.go | 55 + backend/services/llm/rpc/transport.go | 195 ++++ backend/services/llm/runtime_service.go | 315 +++++ backend/services/llm/service.go | 23 + backend/services/llm/stream_accounting.go | 61 + .../orchestrator/llm_decision_orchestrator.go | 4 +- .../orchestrator/llm_write_orchestrator.go | 18 +- .../memory/internal/worker/decision_flow.go | 27 +- .../runtime/eventsvc/chat_history_persist.go | 18 +- .../eventsvc/chat_token_usage_adjust.go | 126 -- .../runtime/eventsvc/core_outbox_handlers.go | 19 +- .../services/runtime/model/course_parse.go | 1 + backend/services/taskclassforum/sv/errors.go | 17 +- backend/services/taskclassforum/sv/helpers.go | 79 +- backend/services/taskclassforum/sv/post.go | 32 +- backend/services/tokenstore/dao/cache.go | 131 +++ backend/services/tokenstore/dao/connect.go | 282 +++-- .../services/tokenstore/dao/creditstore.go | 380 ++++++ backend/services/tokenstore/dao/tokenstore.go | 285 ----- backend/services/tokenstore/model/credit.go | 206 ++++ backend/services/tokenstore/model/token.go | 155 --- backend/services/tokenstore/rpc/credit.go | 392 +++++++ backend/services/tokenstore/rpc/handler.go | 290 +---- .../tokenstore/rpc/pb/tokenstore.pb.go | 296 +++++ .../tokenstore/rpc/pb/tokenstore_grpc.pb.go | 156 ++- .../services/tokenstore/rpc/tokenstore.proto | 198 ++++ .../services/tokenstore/sv/credit_balance.go | 66 ++ .../services/tokenstore/sv/credit_charge.go | 112 ++ .../tokenstore/sv/credit_dashboard.go | 86 ++ .../services/tokenstore/sv/credit_helpers.go | 457 ++++++++ .../services/tokenstore/sv/credit_order.go | 240 ++++ .../services/tokenstore/sv/credit_outbox.go | 104 ++ .../services/tokenstore/sv/credit_product.go | 29 + .../services/tokenstore/sv/credit_reward.go | 90 ++ backend/services/tokenstore/sv/credit_rule.go | 59 + .../tokenstore/sv/credit_transaction.go | 47 + backend/services/tokenstore/sv/grant.go | 84 -- backend/services/tokenstore/sv/helpers.go | 149 +-- backend/services/tokenstore/sv/order.go | 312 ----- backend/services/tokenstore/sv/outbox.go | 17 +- backend/services/tokenstore/sv/product.go | 34 - backend/services/tokenstore/sv/reward.go | 157 +-- backend/services/tokenstore/sv/service.go | 33 +- backend/services/userauth/rpc/handler.go | 48 +- backend/services/userauth/sv/quota.go | 192 ---- backend/services/userauth/sv/service.go | 15 +- backend/shared/contracts/course/types.go | 1 + backend/shared/contracts/creditstore/types.go | 172 +++ backend/shared/contracts/llm/contracts.go | 131 +++ backend/shared/contracts/tokenstore/types.go | 125 -- backend/shared/contracts/userauth/types.go | 20 - backend/shared/events/credit.go | 91 ++ .../shared/infra/outbox/service_catalog.go | 7 + backend/shared/infra/outbox/service_route.go | 8 + backend/shared/ports/userauth.go | 14 - docker-compose.yml | 32 +- docs/backend/统一出口Credit计费最终计划.md | 602 ++++++++++ frontend/src/api/forum.ts | 145 +++ frontend/src/types/forum.ts | 131 +++ frontend/src/views/ForumView.vue | 880 ++++++-------- frontend/src/views/PlanDetailView.vue | 497 ++++---- frontend/src/views/StoreView.vue | 1015 ++++++++++++----- 104 files changed, 9527 insertions(+), 3925 deletions(-) create mode 100644 backend/client/llm/client.go create mode 100644 backend/client/llm/errors.go create mode 100644 backend/client/tokenstore/credit.go create mode 100644 backend/cmd/llm/main.go delete mode 100644 backend/gateway/middleware/token_quota_guard.go create mode 100644 backend/services/llm/billing.go create mode 100644 backend/services/llm/billing_compat.go create mode 100644 backend/services/llm/dao/cache.go create mode 100644 backend/services/llm/dao/connect.go create mode 100644 backend/services/llm/dao/pricing.go create mode 100644 backend/services/llm/guard.go create mode 100644 backend/services/llm/outbox.go create mode 100644 backend/services/llm/pricing.go create mode 100644 backend/services/llm/rpc/errors.go create mode 100644 backend/services/llm/rpc/handler.go create mode 100644 backend/services/llm/rpc/json_codec.go create mode 100644 backend/services/llm/rpc/llm.proto create mode 100644 backend/services/llm/rpc/server.go create mode 100644 backend/services/llm/rpc/transport.go create mode 100644 backend/services/llm/runtime_service.go create mode 100644 backend/services/llm/stream_accounting.go delete mode 100644 backend/services/runtime/eventsvc/chat_token_usage_adjust.go create mode 100644 backend/services/tokenstore/dao/cache.go create mode 100644 backend/services/tokenstore/dao/creditstore.go delete mode 100644 backend/services/tokenstore/dao/tokenstore.go create mode 100644 backend/services/tokenstore/model/credit.go delete mode 100644 backend/services/tokenstore/model/token.go create mode 100644 backend/services/tokenstore/rpc/credit.go create mode 100644 backend/services/tokenstore/sv/credit_balance.go create mode 100644 backend/services/tokenstore/sv/credit_charge.go create mode 100644 backend/services/tokenstore/sv/credit_dashboard.go create mode 100644 backend/services/tokenstore/sv/credit_helpers.go create mode 100644 backend/services/tokenstore/sv/credit_order.go create mode 100644 backend/services/tokenstore/sv/credit_outbox.go create mode 100644 backend/services/tokenstore/sv/credit_product.go create mode 100644 backend/services/tokenstore/sv/credit_reward.go create mode 100644 backend/services/tokenstore/sv/credit_rule.go create mode 100644 backend/services/tokenstore/sv/credit_transaction.go delete mode 100644 backend/services/tokenstore/sv/grant.go delete mode 100644 backend/services/tokenstore/sv/order.go delete mode 100644 backend/services/tokenstore/sv/product.go delete mode 100644 backend/services/userauth/sv/quota.go create mode 100644 backend/shared/contracts/creditstore/types.go create mode 100644 backend/shared/contracts/llm/contracts.go delete mode 100644 backend/shared/contracts/tokenstore/types.go create mode 100644 backend/shared/events/credit.go create mode 100644 docs/backend/统一出口Credit计费最终计划.md create mode 100644 frontend/src/api/forum.ts create mode 100644 frontend/src/types/forum.ts diff --git a/backend/client/course/client.go b/backend/client/course/client.go index 718a90a..cf5f85e 100644 --- a/backend/client/course/client.go +++ b/backend/client/course/client.go @@ -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) { resp, err := c.rpc.ParseCourseImage(ctx, &coursepb.CourseImageRequest{ + UserId: uint64(req.UserID), Filename: req.Filename, MimeType: req.MIMEType, ImageBytes: req.ImageBytes, diff --git a/backend/client/llm/client.go b/backend/client/llm/client.go new file mode 100644 index 0000000..63e8323 --- /dev/null +++ b/backend/client/llm/client.go @@ -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 +} diff --git a/backend/client/llm/errors.go b/backend/client/llm/errors.go new file mode 100644 index 0000000..8568e57 --- /dev/null +++ b/backend/client/llm/errors.go @@ -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) +} diff --git a/backend/client/tokenstore/client.go b/backend/client/tokenstore/client.go index 398386c..6ffeb33 100644 --- a/backend/client/tokenstore/client.go +++ b/backend/client/tokenstore/client.go @@ -1,14 +1,11 @@ package tokenstore import ( - "context" - "encoding/json" "errors" "strings" "time" "github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb" - tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" "github.com/zeromicro/go-zero/zrpc" ) @@ -23,48 +20,12 @@ type ClientConfig struct { Timeout time.Duration } -// ProductSnapshot 是订单详情里内嵌的商品快照。 +// Client 是 gateway 访问 tokenstore zrpc 的统一 Credit 语义适配层。 // // 职责边界: -// 1. 只承载 HTTP gateway 当前需要透出的商品摘要; -// 2. 不补充 description、price 等商品列表字段,避免把详情快照扩成第二份商品实体; -// 3. 若下游 proto/contract 还未合入对应字段,这里允许保持 nil/零值兜底。 -type ProductSnapshot struct { - ProductID uint64 `json:"product_id"` - Name string `json:"name"` - TokenAmount int64 `json:"token_amount"` -} - -// OrderView 是 gateway 侧订单展示结构。 -// -// 职责边界: -// 1. 复用 token-store contract 里已稳定的订单字段; -// 2. 为前端 P0 额外承载 product_snapshot / product_name / quantity 三个 HTTP 所需字段; -// 3. 不反向影响 shared/contracts,等并行 worker 合入正式字段后可再收敛。 -type OrderView struct { - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - Status string `json:"status"` - ProductSnapshot *ProductSnapshot `json:"product_snapshot,omitempty"` - ProductName string `json:"product_name,omitempty"` - Quantity int `json:"quantity"` - TokenAmount int64 `json:"token_amount"` - AmountCent int64 `json:"amount_cent"` - PriceText string `json:"price_text"` - Currency string `json:"currency"` - PaymentMode string `json:"payment_mode"` - Grant *tokencontracts.TokenGrantView `json:"grant"` - CreatedAt string `json:"created_at"` - PaidAt *string `json:"paid_at"` - GrantedAt *string `json:"granted_at"` -} - -// Client 是 gateway 侧访问 token-store zrpc 的适配层。 -// -// 职责边界: -// 1. 只负责 HTTP gateway 与 token-store zrpc 之间的协议转译; -// 2. 不直连 token_* 表,也不承载订单/支付业务规则; -// 3. gRPC 业务错误会在这里反解回 respond.Response,便于 HTTP 层统一返回。 +// 1. 只负责 HTTP gateway 与 tokenstore zrpc 之间的协议转译; +// 2. 不直连底层 credit_* 表,也不承载订单/充值/扣费业务规则; +// 3. gRPC 业务错误会在这里反解成普通 error / respond.Response,交给 HTTP 层统一处理。 type Client struct { rpc pb.TokenStoreServiceClient } @@ -92,150 +53,6 @@ func NewClient(cfg ClientConfig) (*Client, error) { return &Client{rpc: pb.NewTokenStoreServiceClient(zclient.Conn())}, nil } -func (c *Client) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) { - if err := c.ensureReady(); err != nil { - return nil, err - } - resp, err := c.rpc.GetSummary(ctx, &pb.GetTokenSummaryRequest{ActorUserId: actorUserID}) - if err != nil { - return nil, responseFromRPCError(err) - } - if resp == nil { - return nil, errors.New("tokenstore zrpc service returned empty get summary response") - } - summary := tokenSummaryFromPB(resp.Summary) - return &summary, nil -} - -func (c *Client) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) { - if err := c.ensureReady(); err != nil { - return nil, err - } - resp, err := c.rpc.ListProducts(ctx, &pb.ListTokenProductsRequest{ActorUserId: actorUserID}) - if err != nil { - return nil, responseFromRPCError(err) - } - if resp == nil { - return nil, errors.New("tokenstore zrpc service returned empty list products response") - } - return tokenProductsFromPB(resp.Items), nil -} - -func (c *Client) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*OrderView, error) { - if err := c.ensureReady(); err != nil { - return nil, err - } - resp, err := c.rpc.CreateOrder(ctx, &pb.CreateTokenOrderRequest{ - ActorUserId: req.ActorUserID, - ProductId: req.ProductID, - Quantity: int32(req.Quantity), - IdempotencyKey: req.IdempotencyKey, - }) - if err != nil { - return nil, responseFromRPCError(err) - } - if resp == nil { - return nil, errors.New("tokenstore zrpc service returned empty create order response") - } - order := tokenOrderFromPB(resp.Order) - return &order, nil -} - -func (c *Client) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]OrderView, tokencontracts.PageResult, error) { - if err := c.ensureReady(); err != nil { - return nil, tokencontracts.PageResult{}, err - } - resp, err := c.rpc.ListOrders(ctx, &pb.ListTokenOrdersRequest{ - ActorUserId: req.ActorUserID, - Page: int32(req.Page), - PageSize: int32(req.PageSize), - Status: req.Status, - }) - if err != nil { - return nil, tokencontracts.PageResult{}, responseFromRPCError(err) - } - if resp == nil { - return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list orders response") - } - return tokenOrdersFromPB(resp.Items), pageFromPB(resp.Page), nil -} - -func (c *Client) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*OrderView, error) { - if err := c.ensureReady(); err != nil { - return nil, err - } - resp, err := c.rpc.GetOrder(ctx, &pb.GetTokenOrderRequest{ - ActorUserId: actorUserID, - OrderId: orderID, - }) - if err != nil { - return nil, responseFromRPCError(err) - } - if resp == nil { - return nil, errors.New("tokenstore zrpc service returned empty get order response") - } - order := tokenOrderFromPB(resp.Order) - return &order, nil -} - -func (c *Client) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*OrderView, error) { - if err := c.ensureReady(); err != nil { - return nil, err - } - resp, err := c.rpc.MockPaidOrder(ctx, &pb.MockPaidOrderRequest{ - ActorUserId: req.ActorUserID, - OrderId: req.OrderID, - MockChannel: req.MockChannel, - IdempotencyKey: req.IdempotencyKey, - }) - if err != nil { - return nil, responseFromRPCError(err) - } - if resp == nil { - return nil, errors.New("tokenstore zrpc service returned empty mock paid response") - } - order := tokenOrderFromPB(resp.Order) - return &order, nil -} - -func (c *Client) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) { - if err := c.ensureReady(); err != nil { - return nil, tokencontracts.PageResult{}, err - } - resp, err := c.rpc.ListGrants(ctx, &pb.ListTokenGrantsRequest{ - ActorUserId: req.ActorUserID, - Page: int32(req.Page), - PageSize: int32(req.PageSize), - Source: req.Source, - }) - if err != nil { - return nil, tokencontracts.PageResult{}, responseFromRPCError(err) - } - if resp == nil { - return nil, tokencontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list grants response") - } - return tokenGrantsFromPB(resp.Items), pageFromPB(resp.Page), nil -} - -func (c *Client) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) { - if err := c.ensureReady(); err != nil { - return nil, err - } - resp, err := c.rpc.RecordForumRewardGrant(ctx, &pb.RecordForumRewardGrantRequest{ - EventId: req.EventID, - ReceiverUserId: req.ReceiverUserID, - Source: req.Source, - SourceRefId: req.SourceRefID, - }) - if err != nil { - return nil, responseFromRPCError(err) - } - if resp == nil { - return nil, errors.New("tokenstore zrpc service returned empty record forum reward grant response") - } - return tokenGrantFromPB(resp.Grant), nil -} - func (c *Client) ensureReady() error { if c == nil || c.rpc == nil { return errors.New("tokenstore zrpc client is not initialized") @@ -254,150 +71,6 @@ func normalizeEndpoints(values []string) []string { return endpoints } -func pageFromPB(page *pb.PageResponse) tokencontracts.PageResult { - if page == nil { - return tokencontracts.PageResult{} - } - return tokencontracts.PageResult{ - Page: int(page.Page), - PageSize: int(page.PageSize), - Total: int(page.Total), - HasMore: page.HasMore, - } -} - -func tokenSummaryFromPB(summary *pb.TokenSummary) tokencontracts.TokenSummary { - if summary == nil { - return tokencontracts.TokenSummary{} - } - return tokencontracts.TokenSummary{ - RecordedTokenTotal: summary.RecordedTokenTotal, - AppliedTokenTotal: summary.AppliedTokenTotal, - PendingApplyTokenTotal: summary.PendingApplyTokenTotal, - QuotaSyncStatus: summary.QuotaSyncStatus, - Tip: summary.Tip, - } -} - -func tokenProductFromPB(product *pb.TokenProductView) tokencontracts.TokenProductView { - if product == nil { - return tokencontracts.TokenProductView{} - } - return tokencontracts.TokenProductView{ - ProductID: product.ProductId, - Name: product.Name, - Description: product.Description, - TokenAmount: product.TokenAmount, - PriceCent: product.PriceCent, - PriceText: product.PriceText, - Currency: product.Currency, - Badge: product.Badge, - Status: product.Status, - SortOrder: int(product.SortOrder), - } -} - -func tokenProductsFromPB(items []*pb.TokenProductView) []tokencontracts.TokenProductView { - if len(items) == 0 { - return []tokencontracts.TokenProductView{} - } - result := make([]tokencontracts.TokenProductView, 0, len(items)) - for _, item := range items { - result = append(result, tokenProductFromPB(item)) - } - return result -} - -func tokenGrantFromPB(grant *pb.TokenGrantView) *tokencontracts.TokenGrantView { - if grant == nil { - return nil - } - return &tokencontracts.TokenGrantView{ - GrantID: grant.GrantId, - EventID: grant.EventId, - Source: grant.Source, - SourceLabel: grant.SourceLabel, - Amount: grant.Amount, - Status: grant.Status, - QuotaApplied: grant.QuotaApplied, - Description: grant.Description, - CreatedAt: grant.CreatedAt, - } -} - -func tokenGrantsFromPB(items []*pb.TokenGrantView) []tokencontracts.TokenGrantView { - if len(items) == 0 { - return []tokencontracts.TokenGrantView{} - } - result := make([]tokencontracts.TokenGrantView, 0, len(items)) - for _, item := range items { - if grant := tokenGrantFromPB(item); grant != nil { - result = append(result, *grant) - } - } - return result -} - -func tokenOrderFromPB(order *pb.TokenOrderView) OrderView { - if order == nil { - return OrderView{} - } - productSnapshot := tokenProductSnapshotFromJSON(order.ProductSnapshot) - productName := strings.TrimSpace(order.ProductName) - if productName == "" && productSnapshot != nil { - productName = productSnapshot.Name - } - return OrderView{ - OrderID: order.OrderId, - OrderNo: order.OrderNo, - Status: order.Status, - ProductSnapshot: productSnapshot, - ProductName: productName, - Quantity: int(order.Quantity), - TokenAmount: order.TokenAmount, - AmountCent: order.AmountCent, - PriceText: order.PriceText, - Currency: order.Currency, - PaymentMode: order.PaymentMode, - Grant: tokenGrantFromPB(order.Grant), - CreatedAt: order.CreatedAt, - PaidAt: stringPtrFromNonEmpty(order.PaidAt), - GrantedAt: stringPtrFromNonEmpty(order.GrantedAt), - } -} - -func tokenOrdersFromPB(items []*pb.TokenOrderView) []OrderView { - if len(items) == 0 { - return []OrderView{} - } - result := make([]OrderView, 0, len(items)) - for _, item := range items { - result = append(result, tokenOrderFromPB(item)) - } - return result -} - -// tokenProductSnapshotFromJSON 负责把 RPC 内部快照字符串转成 HTTP 展示对象。 -// -// 职责边界: -// 1. 只解析 product_id / name / token_amount 三个前端需要的字段; -// 2. 不把解析失败暴露成接口错误,避免历史脏快照影响订单主流程展示; -// 3. 不反查商品表,订单详情必须以当时下单快照为准。 -func tokenProductSnapshotFromJSON(raw string) *ProductSnapshot { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil - } - var snapshot ProductSnapshot - if err := json.Unmarshal([]byte(trimmed), &snapshot); err != nil { - return nil - } - if snapshot.ProductID == 0 && snapshot.Name == "" && snapshot.TokenAmount == 0 { - return nil - } - return &snapshot -} - func stringPtrFromNonEmpty(value string) *string { trimmed := strings.TrimSpace(value) if trimmed == "" { diff --git a/backend/client/tokenstore/credit.go b/backend/client/tokenstore/credit.go new file mode 100644 index 0000000..d92656f --- /dev/null +++ b/backend/client/tokenstore/credit.go @@ -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, + } +} diff --git a/backend/client/userauth/client.go b/backend/client/userauth/client.go index 19c39fc..e45c943 100644 --- a/backend/client/userauth/client.go +++ b/backend/client/userauth/client.go @@ -138,50 +138,6 @@ func (c *Client) ValidateAccessToken(ctx context.Context, accessToken string) (* }, 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 { if c == nil || c.rpc == nil { return errors.New("userauth zrpc client is not initialized") diff --git a/backend/cmd/active-scheduler/main.go b/backend/cmd/active-scheduler/main.go index dfce38f..ebaca41 100644 --- a/backend/cmd/active-scheduler/main.go +++ b/backend/cmd/active-scheduler/main.go @@ -7,13 +7,12 @@ import ( "os/signal" "syscall" + llmclient "github.com/LoveLosita/smartflow/backend/client/llm" activeadapters "github.com/LoveLosita/smartflow/backend/services/active_scheduler/core/adapters" activeschedulerdao "github.com/LoveLosita/smartflow/backend/services/active_scheduler/dao" activeschedulerrpc "github.com/LoveLosita/smartflow/backend/services/active_scheduler/rpc" 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" - einoinfra "github.com/LoveLosita/smartflow/backend/shared/infra/eino" kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka" "github.com/spf13/viper" ) @@ -31,16 +30,17 @@ func main() { log.Fatalf("failed to connect active-scheduler database: %v", err) } - aiHub, err := einoinfra.InitEino() - if err != nil { - log.Fatalf("failed to initialize active-scheduler Eino runtime: %v", err) - } - llmService := llmservice.New(llmservice.Options{ - AIHub: aiHub, - APIKey: os.Getenv("ARK_API_KEY"), - BaseURL: viper.GetString("agent.baseURL"), + llmService, 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 { + log.Fatalf("failed to initialize active-scheduler llm client: %v", err) + } svc, err := activeschedulersv.New(db, llmService, activeschedulersv.Options{ JobScanEvery: viper.GetDuration("activeScheduler.jobScanEvery"), diff --git a/backend/cmd/agent/runtime.go b/backend/cmd/agent/runtime.go index 7e62d39..b243115 100644 --- a/backend/cmd/agent/runtime.go +++ b/backend/cmd/agent/runtime.go @@ -5,9 +5,9 @@ import ( "encoding/json" "fmt" "log" - "os" "strings" + llmclient "github.com/LoveLosita/smartflow/backend/client/llm" memoryclient "github.com/LoveLosita/smartflow/backend/client/memory" scheduleclient "github.com/LoveLosita/smartflow/backend/client/schedule" taskclient "github.com/LoveLosita/smartflow/backend/client/task" @@ -34,7 +34,6 @@ import ( schedulesv "github.com/LoveLosita/smartflow/backend/services/schedule/sv" taskdao "github.com/LoveLosita/smartflow/backend/services/task/dao" 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" kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka" 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") return nil } - if r.userAuthClient == nil { - return fmt.Errorf("agent outbox consumer requires userauth zrpc client") - } - // 1. 先登记 agent 自己消费的 handler,同时补齐 memory.extract.requested 的服务路由。 // 2. 这里明确只接 agent 边界;memory 消费仍归 cmd/memory,task 事件仍是 publish-only 写入 task outbox。 // 3. 注册完成后再启动总线,避免服务一起来就抢先消费到尚未挂 handler 的消息。 @@ -268,7 +263,6 @@ func (r *agentRuntime) startWorkers(ctx context.Context) error { r.agentRepo, r.cacheRepo, nil, - r.userAuthClient, ); err != nil { 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) { - aiHub, err := einoinfra.InitEino() - if err != nil { - return nil, err - } - return llmservice.New(llmservice.Options{ - AIHub: aiHub, - APIKey: os.Getenv("ARK_API_KEY"), - BaseURL: viper.GetString("agent.baseURL"), + return 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"), - }), nil + }) } func buildAgentRAGService(ctx context.Context) (*ragservice.Service, error) { diff --git a/backend/cmd/course/main.go b/backend/cmd/course/main.go index 0a93052..bc34017 100644 --- a/backend/cmd/course/main.go +++ b/backend/cmd/course/main.go @@ -7,10 +7,10 @@ import ( "os/signal" "syscall" + llmclient "github.com/LoveLosita/smartflow/backend/client/llm" coursedao "github.com/LoveLosita/smartflow/backend/services/course/dao" courserpc "github.com/LoveLosita/smartflow/backend/services/course/rpc" 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" "github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap" "github.com/spf13/viper" @@ -33,15 +33,21 @@ func main() { // 2. scheduleRepo 用于复用既有冲突检查,后续若切 schedule RPC bridge 再替换这里。 courseRepo := coursedao.NewCourseDAO(db) scheduleRepo := rootdao.NewScheduleDAO(db) - courseImageClient := llmservice.NewArkResponsesClient( - os.Getenv("ARK_API_KEY"), - viper.GetString("agent.baseURL"), - viper.GetString("courseImport.visionModel"), - ) + llmService, 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 { + log.Fatalf("failed to initialize course llm client: %v", err) + } svc := coursesv.NewCourseService( courseRepo, scheduleRepo, - courseImageClient, + llmService.CourseImageResponsesClient(), coursesv.NewCourseImageParseConfig( viper.GetInt64("courseImport.maxImageBytes"), viper.GetInt("courseImport.maxTokens"), diff --git a/backend/cmd/llm/main.go b/backend/cmd/llm/main.go new file mode 100644 index 0000000..d859243 --- /dev/null +++ b/backend/cmd/llm/main.go @@ -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 +} diff --git a/backend/cmd/memory/main.go b/backend/cmd/memory/main.go index 1b3dcb3..fb29022 100644 --- a/backend/cmd/memory/main.go +++ b/backend/cmd/memory/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "syscall" + llmclient "github.com/LoveLosita/smartflow/backend/client/llm" llmservice "github.com/LoveLosita/smartflow/backend/services/llm" memorymodule "github.com/LoveLosita/smartflow/backend/services/memory" memorydao "github.com/LoveLosita/smartflow/backend/services/memory/dao" @@ -17,7 +18,6 @@ import ( ragservice "github.com/LoveLosita/smartflow/backend/services/rag" ragconfig "github.com/LoveLosita/smartflow/backend/services/rag/config" "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" "github.com/spf13/viper" @@ -96,20 +96,21 @@ func main() { // // 说明: // 1. CP1 先复用既有 llm-service canonical 入口,不在 memory 服务里重建模型调用封装; -// 2. 当前启动入口与 cmd/start.go / cmd/active-scheduler 都需要 Eino 初始化,后续若出现第三处重复装配,应抽公共 bootstrap; +// 2. 现在统一改走独立 llm zrpc client,memory 进程不再本地初始化 AIHub; // 3. 返回 ProClient 是因为现有 memory.Module 只需要 llmservice.Client,不需要完整 Service。 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 { return nil, err } - llmService := llmservice.New(llmservice.Options{ - AIHub: aiHub, - APIKey: os.Getenv("ARK_API_KEY"), - BaseURL: viper.GetString("agent.baseURL"), - CourseVisionModel: viper.GetString("courseImport.visionModel"), - }) - return llmService.ProClient(), nil + return remoteService.ProClient(), nil } // buildMemoryRAGRuntime 初始化 memory 检索与向量同步使用的 RAG Runtime。 diff --git a/backend/cmd/start.go b/backend/cmd/start.go index 0450ef2..1d3998b 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -14,6 +14,7 @@ import ( activeschedulerclient "github.com/LoveLosita/smartflow/backend/client/activescheduler" agentclient "github.com/LoveLosita/smartflow/backend/client/agent" courseclient "github.com/LoveLosita/smartflow/backend/client/course" + llmclient "github.com/LoveLosita/smartflow/backend/client/llm" memoryclient "github.com/LoveLosita/smartflow/backend/client/memory" notificationclient "github.com/LoveLosita/smartflow/backend/client/notification" scheduleclient "github.com/LoveLosita/smartflow/backend/client/schedule" @@ -53,7 +54,6 @@ import ( taskdao "github.com/LoveLosita/smartflow/backend/services/task/dao" tasksv "github.com/LoveLosita/smartflow/backend/services/task/sv" "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" kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka" outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox" @@ -273,16 +273,17 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { if shouldBuildGatewayAgentFallback() { log.Println("Gateway agent RPC fallback is enabled; building local AgentService compatibility path") - aiHub, err := einoinfra.InitEino() - if err != nil { - return nil, fmt.Errorf("failed to initialize Eino: %w", err) - } - llmService := llmservice.New(llmservice.Options{ - AIHub: aiHub, - APIKey: os.Getenv("ARK_API_KEY"), - BaseURL: viper.GetString("agent.baseURL"), + llmService, 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 { + return nil, fmt.Errorf("failed to initialize llm zrpc client: %w", err) + } ragService, err := buildRAGService(ctx) if err != nil { diff --git a/backend/cmd/tokenstore/main.go b/backend/cmd/tokenstore/main.go index 4b13c60..72fa145 100644 --- a/backend/cmd/tokenstore/main.go +++ b/backend/cmd/tokenstore/main.go @@ -29,7 +29,19 @@ func main() { 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) eventBus, err := outboxinfra.NewEventBus(outboxRepo, kafkabus.LoadConfig()) @@ -40,6 +52,9 @@ func main() { if err := tokenstoresv.RegisterForumRewardHandlers(eventBus, outboxRepo, svc); err != nil { 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) defer eventBus.Close() log.Println("Tokenstore outbox consumer started") diff --git a/backend/config.example.yaml b/backend/config.example.yaml index 758254b..558ed3d 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -56,6 +56,14 @@ tokenstore: - "127.0.0.1:9095" timeout: 2s +# LLM zrpc 独立服务与各业务服务客户端配置。 +llm: + rpc: + listenOn: "0.0.0.0:9096" + endpoints: + - "127.0.0.1:9096" + timeout: 0s + # Kafka outbox 事件总线配置。 kafka: enabled: true @@ -67,6 +75,41 @@ kafka: retryBatchSize: 100 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: rpc: diff --git a/backend/gateway/api/course.go b/backend/gateway/api/course.go index 52acf75..871e092 100644 --- a/backend/gateway/api/course.go +++ b/backend/gateway/api/course.go @@ -117,6 +117,7 @@ func (sa *CourseHandler) ParseCourseTableImage(c *gin.Context) { defer cancel() rawDraft, err := sa.client.ParseCourseTableImage(ctx, coursecontracts.CourseImageParseRequest{ + UserID: userID, Filename: fileHeader.Filename, MIMEType: fileHeader.Header.Get("Content-Type"), ImageBytes: imageBytes, diff --git a/backend/gateway/api/tokenstoreapi/handler.go b/backend/gateway/api/tokenstoreapi/handler.go index 659863a..7a44c84 100644 --- a/backend/gateway/api/tokenstoreapi/handler.go +++ b/backend/gateway/api/tokenstoreapi/handler.go @@ -2,28 +2,43 @@ package tokenstoreapi import ( "context" + "encoding/json" "errors" "net/http" "strconv" "strings" "time" - gatewaytokenstore "github.com/LoveLosita/smartflow/backend/client/tokenstore" "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" ) -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 { - GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) - ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) - CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*gatewaytokenstore.OrderView, error) - ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]gatewaytokenstore.OrderView, tokencontracts.PageResult, error) - GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*gatewaytokenstore.OrderView, error) - MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*gatewaytokenstore.OrderView, error) - ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) + GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error) + GetCreditConsumptionDashboard(ctx context.Context, req creditcontracts.GetCreditConsumptionDashboardRequest) (*creditcontracts.CreditConsumptionDashboardView, error) + ListCreditProducts(ctx context.Context, actorUserID uint64) ([]creditcontracts.CreditProductView, error) + CreateCreditOrder(ctx context.Context, req creditcontracts.CreateCreditOrderRequest) (*creditcontracts.CreditOrderView, error) + ListCreditOrders(ctx context.Context, req creditcontracts.ListCreditOrdersRequest) ([]creditcontracts.CreditOrderView, creditcontracts.PageResult, error) + GetCreditOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, 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 { @@ -42,53 +57,50 @@ type pageEnvelope[T any] struct { 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 string `json:"type"` Label string `json:"label"` } -type orderCreateEnvelope 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"` - PaymentAction paymentAction `json:"payment_action"` - CreatedAt string `json:"created_at"` +type creditOrderEnvelope struct { + OrderID uint64 `json:"order_id"` + OrderNo string `json:"order_no"` + Status string `json:"status"` + 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"` + ProductName string `json:"product_name"` + ProductDetail map[string]any `json:"product_snapshot,omitempty"` + CreatedAt string `json:"created_at"` + PaidAt *string `json:"paid_at"` + CreditedAt *string `json:"credited_at"` + PaymentAction paymentAction `json:"payment_action"` } -type orderListItemEnvelope struct { - OrderID uint64 `json:"order_id"` - OrderNo string `json:"order_no"` - Status string `json:"status"` - ProductName string `json:"product_name"` - TokenAmount int64 `json:"token_amount"` - PriceText string `json:"price_text"` - CreatedAt string `json:"created_at"` - PaidAt *string `json:"paid_at"` - GrantedAt *string `json:"granted_at"` -} - -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 creditTransactionEnvelope struct { + GrantID uint64 `json:"grant_id"` + SourceLabel string `json:"source_label"` + Amount int64 `json:"amount"` + Status string `json:"status"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + Direction string `json:"direction"` + BalanceAfter int64 `json:"balance_after"` + EventID string `json:"event_id"` + OrderID *uint64 `json:"order_id"` } type createOrderBody struct { @@ -105,15 +117,36 @@ func (h *Handler) GetSummary(c *gin.Context) { if !ok { return } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() - summary, err := client.GetSummary(ctx, currentUserID(c)) + snapshot, err := client.GetCreditBalanceSnapshot(ctx, currentUserID(c)) if err != nil { respond.DealWithError(c, err) 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) { @@ -121,10 +154,11 @@ func (h *Handler) ListProducts(c *gin.Context) { if !ok { return } + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() - items, err := client.ListProducts(ctx, currentUserID(c)) + items, err := client.ListCreditProducts(ctx, currentUserID(c)) if err != nil { respond.DealWithError(c, err) return @@ -137,6 +171,7 @@ func (h *Handler) CreateOrder(c *gin.Context) { if !ok { return } + var body createOrderBody if err := c.ShouldBindJSON(&body); err != nil { 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) defer cancel() - order, err := client.CreateOrder(ctx, tokencontracts.CreateTokenOrderRequest{ + + order, err := client.CreateCreditOrder(ctx, creditcontracts.CreateCreditOrderRequest{ ActorUserID: currentUserID(c), ProductID: body.ProductID, Quantity: body.Quantity, @@ -155,7 +191,7 @@ func (h *Handler) CreateOrder(c *gin.Context) { respond.DealWithError(c, err) 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) { @@ -163,6 +199,7 @@ func (h *Handler) ListOrders(c *gin.Context) { if !ok { return } + pageValue, ok := intQuery(c, "page") if !ok { return @@ -174,7 +211,8 @@ func (h *Handler) ListOrders(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() - items, page, err := client.ListOrders(ctx, tokencontracts.ListTokenOrdersRequest{ + + items, page, err := client.ListCreditOrders(ctx, creditcontracts.ListCreditOrdersRequest{ ActorUserID: currentUserID(c), Page: pageValue, PageSize: pageSize, @@ -184,7 +222,13 @@ func (h *Handler) ListOrders(c *gin.Context) { respond.DealWithError(c, err) 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) { @@ -192,6 +236,7 @@ func (h *Handler) GetOrder(c *gin.Context) { if !ok { return } + orderID, ok := uint64Param(c, "order_id") if !ok { return @@ -199,12 +244,13 @@ func (h *Handler) GetOrder(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() - order, err := client.GetOrder(ctx, currentUserID(c), orderID) + + order, err := client.GetCreditOrder(ctx, currentUserID(c), orderID) if err != nil { respond.DealWithError(c, err) 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) { @@ -212,10 +258,12 @@ func (h *Handler) MockPaidOrder(c *gin.Context) { if !ok { return } + orderID, ok := uint64Param(c, "order_id") if !ok { return } + var body mockPaidBody if err := c.ShouldBindJSON(&body); err != nil { 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) defer cancel() - order, err := client.MockPaidOrder(ctx, tokencontracts.MockPaidOrderRequest{ + + order, err := client.MockPaidCreditOrder(ctx, creditcontracts.MockPaidCreditOrderRequest{ ActorUserID: currentUserID(c), OrderID: orderID, - MockChannel: body.MockChannel, + MockChannel: firstNonEmptyString(strings.TrimSpace(body.MockChannel), "mock"), IdempotencyKey: strings.TrimSpace(c.GetHeader("X-Idempotency-Key")), }) if err != nil { respond.DealWithError(c, err) 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) if !ok { return } + pageValue, ok := intQuery(c, "page") if !ok { return @@ -253,22 +303,29 @@ func (h *Handler) ListGrants(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() - items, page, err := client.ListGrants(ctx, tokencontracts.ListTokenGrantsRequest{ + + items, page, err := client.ListCreditTransactions(ctx, creditcontracts.ListCreditTransactionsRequest{ ActorUserID: currentUserID(c), Page: pageValue, PageSize: pageSize, Source: c.Query("source"), + Direction: c.Query("direction"), }) if err != nil { respond.DealWithError(c, err) 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) { 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 h.client, true @@ -282,82 +339,107 @@ func currentUserID(c *gin.Context) uint64 { 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 { - return orderCreateEnvelope{ + return creditOrderEnvelope{ PaymentAction: paymentAction{ Type: "mock_paid", Label: "确认支付", }, } } - return orderCreateEnvelope{ - 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, + + return creditOrderEnvelope{ + OrderID: order.OrderID, + OrderNo: order.OrderNo, + Status: order.Status, + Quantity: order.Quantity, + CreditAmount: order.CreditAmount, + AmountCent: order.AmountCent, + PriceText: order.PriceText, + Currency: order.Currency, + PaymentMode: order.PaymentMode, + ProductName: order.ProductName, + ProductDetail: parseJSONMap(order.ProductSnapshot), + CreatedAt: order.CreatedAt, + PaidAt: order.PaidAt, + CreditedAt: order.CreditedAt, PaymentAction: paymentAction{ Type: "mock_paid", Label: "确认支付", }, - CreatedAt: order.CreatedAt, } } -func newOrderListItemEnvelopes(items []gatewaytokenstore.OrderView) []orderListItemEnvelope { - if len(items) == 0 { - return []orderListItemEnvelope{} +func buildCreditTransactionEnvelope(item creditcontracts.CreditTransactionView) creditTransactionEnvelope { + return creditTransactionEnvelope{ + 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 - if productName == "" && item.ProductSnapshot != nil { - productName = item.ProductSnapshot.Name - } - result = append(result, orderListItemEnvelope{ - OrderID: item.OrderID, - OrderNo: item.OrderNo, - Status: item.Status, - ProductName: productName, - TokenAmount: item.TokenAmount, - PriceText: item.PriceText, - CreatedAt: item.CreatedAt, - PaidAt: item.PaidAt, - GrantedAt: item.GrantedAt, - }) +} + +func parseJSONMap(raw string) map[string]any { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + + result := make(map[string]any) + if err := json.Unmarshal([]byte(trimmed), &result); err != nil { + return nil } return result } -func newOrderDetailEnvelope(order *gatewaytokenstore.OrderView) orderDetailEnvelope { - 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] { +func newPageEnvelope[T any](items []T, page creditcontracts.PageResult) pageEnvelope[T] { return pageEnvelope[T]{ Items: items, Page: page.Page, @@ -372,6 +454,7 @@ func intQuery(c *gin.Context, key string) (int, bool) { if raw == "" { return 0, true } + value, err := strconv.Atoi(raw) if err != nil { c.JSON(http.StatusBadRequest, respond.WrongParamType) @@ -388,3 +471,145 @@ func uint64Param(c *gin.Context, key string) (uint64, bool) { } 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 +} diff --git a/backend/gateway/api/tokenstoreapi/routes.go b/backend/gateway/api/tokenstoreapi/routes.go index 1160bb2..87565e9 100644 --- a/backend/gateway/api/tokenstoreapi/routes.go +++ b/backend/gateway/api/tokenstoreapi/routes.go @@ -1,18 +1,18 @@ package tokenstoreapi import ( - "github.com/LoveLosita/smartflow/backend/services/runtime/dao" gatewaymiddleware "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" "github.com/LoveLosita/smartflow/backend/shared/ports" "github.com/gin-gonic/gin" ) -// RegisterRoutes 把 Token 商店 HTTP 入口挂到 gateway 路由组。 +// RegisterRoutes 把 Credit 商店 HTTP 入口挂到 gateway 路由组。 // // 职责边界: -// 1. 只注册 /token-store 下的边缘路由,不承载订单和 grant 业务规则; +// 1. 只注册 /credit-store 下的边缘路由,不承载底层订单和账本实现细节; // 2. P0 全部接口都要求登录,并统一走限流保护; // 3. 只有创建订单与 mock paid 需要幂等键,避免重复下单或重复确认支付。 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 } - tokenStoreGroup := apiGroup.Group("/token-store") + tokenStoreGroup := apiGroup.Group("/credit-store") tokenStoreGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1)) { tokenStoreGroup.GET("/summary", handler.GetSummary) + tokenStoreGroup.GET("/consumption-dashboard", handler.GetConsumptionDashboard) tokenStoreGroup.GET("/products", handler.ListProducts) tokenStoreGroup.POST("/orders", rootmiddleware.IdempotencyMiddleware(cache), handler.CreateOrder) tokenStoreGroup.GET("/orders", handler.ListOrders) tokenStoreGroup.GET("/orders/:order_id", handler.GetOrder) tokenStoreGroup.POST("/orders/:order_id/mock-paid", rootmiddleware.IdempotencyMiddleware(cache), handler.MockPaidOrder) - tokenStoreGroup.GET("/grants", handler.ListGrants) + tokenStoreGroup.GET("/transactions", handler.ListTransactions) } } diff --git a/backend/gateway/middleware/token_quota_guard.go b/backend/gateway/middleware/token_quota_guard.go deleted file mode 100644 index 26e6bdf..0000000 --- a/backend/gateway/middleware/token_quota_guard.go +++ /dev/null @@ -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() - } -} diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index b426d75..f7e2c15 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -130,7 +130,7 @@ func RegisterRouters( agentGroup := apiGroup.Group("/agent") { 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-list", handlers.AgentHandler.GetConversationList) agentGroup.GET("/conversation-timeline", handlers.AgentHandler.GetConversationTimeline) diff --git a/backend/go.sum b/backend/go.sum index c24c2ed..824fae7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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/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/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/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= @@ -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/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/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/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= 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/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/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/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/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.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 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/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= 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/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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 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/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/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 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/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-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.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 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/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/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/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 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/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 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/protobuf v1.2.0/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.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 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/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= 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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 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/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 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/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/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/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/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/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/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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 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/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/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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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/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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 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/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/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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 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/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/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/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= 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/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/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.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 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.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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/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/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/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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/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/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= 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/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= 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.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= 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/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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 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/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY= 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/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/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= 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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= 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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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-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-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/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 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/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 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= 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/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= 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= @@ -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/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= 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/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/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/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/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/backend/scripts/dev-common.ps1 b/backend/scripts/dev-common.ps1 index e1b9e05..9d157bc 100644 --- a/backend/scripts/dev-common.ps1 +++ b/backend/scripts/dev-common.ps1 @@ -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 { return @( [pscustomobject]@{ @@ -160,6 +173,16 @@ function Get-BackendServiceDefinitions { StartTimeoutSec = 90 Dependencies = @() }, + [pscustomobject]@{ + Name = "llm" + Package = "./cmd/llm" + BinaryPath = (Join-Path $BinRoot "llm.exe") + Port = 9096 + ProbeType = "tcp" + ProbeTarget = $null + StartTimeoutSec = 120 + Dependencies = @() + }, [pscustomobject]@{ Name = "course" Package = "./cmd/course" @@ -168,7 +191,7 @@ function Get-BackendServiceDefinitions { ProbeType = "tcp" ProbeTarget = $null StartTimeoutSec = 120 - Dependencies = @() + Dependencies = @("llm") }, [pscustomobject]@{ Name = "tokenstore" @@ -198,10 +221,11 @@ function Get-BackendServiceDefinitions { ProbeType = "tcp" ProbeTarget = $null StartTimeoutSec = 150 - Dependencies = @() + Dependencies = @("llm") }, [pscustomobject]@{ Name = "taskclassforum" + Aliases = @("forum") Package = "./cmd/taskclassforum" BinaryPath = (Join-Path $BinRoot "taskclassforum.exe") Port = 9090 @@ -218,7 +242,7 @@ function Get-BackendServiceDefinitions { ProbeType = "tcp" ProbeTarget = $null StartTimeoutSec = 120 - Dependencies = @("task", "schedule") + Dependencies = @("task", "schedule", "llm") }, [pscustomobject]@{ Name = "agent" @@ -228,7 +252,7 @@ function Get-BackendServiceDefinitions { ProbeType = "tcp" ProbeTarget = $null StartTimeoutSec = 180 - Dependencies = @("task", "schedule", "task-class", "memory") + Dependencies = @("task", "schedule", "task-class", "memory", "llm") }, [pscustomobject]@{ Name = "api" @@ -244,6 +268,7 @@ function Get-BackendServiceDefinitions { "schedule", "task-class", "course", + "llm", "tokenstore", "notification", "memory", @@ -261,13 +286,33 @@ function Get-BackendServiceDefinition { [string]$Name ) - foreach ($service in (Get-BackendServiceDefinitions)) { - if ($service.Name -eq $Name) { + $serviceDefinitions = @(Get-BackendServiceDefinitions) + 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 } } - 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 { @@ -859,6 +904,31 @@ function Start-BackendInfrastructure { foreach ($definition in (Get-InfrastructureDefinitions)) { 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 { diff --git a/backend/services/active_scheduler/core/feedbacklocate/service.go b/backend/services/active_scheduler/core/feedbacklocate/service.go index 6ee382c..f9fb590 100644 --- a/backend/services/active_scheduler/core/feedbacklocate/service.go +++ b/backend/services/active_scheduler/core/feedbacklocate/service.go @@ -2,6 +2,8 @@ package feedbacklocate import ( "context" + "crypto/sha1" + "encoding/hex" "errors" "fmt" "log" @@ -102,8 +104,9 @@ func (s *Service) Resolve(ctx context.Context, req Request) (Result, error) { } messages := llmservice.BuildSystemUserMessages(strings.TrimSpace(locateSystemPrompt), nil, userPrompt) + invokeCtx := llmservice.WithBillingContext(ctx, buildFeedbackLocateBillingContext(req)) resp, rawResult, err := llmservice.GenerateJSON[llmResponse]( - ctx, + invokeCtx, s.client, messages, llmservice.GenerateOptions{ @@ -365,3 +368,21 @@ func minInt(left, right int) int { } 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", + } +} diff --git a/backend/services/active_scheduler/core/selection/service.go b/backend/services/active_scheduler/core/selection/service.go index ac51588..ae7dc2f 100644 --- a/backend/services/active_scheduler/core/selection/service.go +++ b/backend/services/active_scheduler/core/selection/service.go @@ -75,8 +75,9 @@ func (s *Service) Select(ctx context.Context, req SelectRequest) (Result, error) nil, userPrompt, ) + invokeCtx := llmservice.WithBillingContext(ctx, buildSelectionBillingContext(req)) resp, rawResult, err := llmservice.GenerateJSON[llmSelectionResponse]( - ctx, + invokeCtx, s.client, messages, llmservice.GenerateOptions{ @@ -294,6 +295,26 @@ func (s *Service) now() time.Time { 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 { return fmt.Sprintf("active_scheduler_selection(action=%s, selected=%s, fallback=%t)", r.Action, diff --git a/backend/services/agent/sv/agent_graph.go b/backend/services/agent/sv/agent_graph.go index a9c65df..f832ac9 100644 --- a/backend/services/agent/sv/agent_graph.go +++ b/backend/services/agent/sv/agent_graph.go @@ -14,6 +14,7 @@ import ( agentstream "github.com/LoveLosita/smartflow/backend/services/agent/stream" agenttools "github.com/LoveLosita/smartflow/backend/services/agent/tools" 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/spf13/viper" @@ -65,6 +66,13 @@ func (s *AgentService) runAgentGraph( // 1. 规范会话 ID 和模型选择。 chatID = normalizeConversationID(chatID) _, 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)。 result, err := s.agentCache.GetConversationStatus(requestCtx, chatID) @@ -543,36 +551,17 @@ func (s *AgentService) persistNewAgentConversationMessage( // placement,普通时段放置的任务全部被丢弃。 // 正确做法:使用第一个返回值 []HybridScheduleEntry,过滤 Status="suggested" 且 TaskItemID>0 的条目, // 这样嵌入和非嵌入的粗排结果都能正确写入 ScheduleState。 -// adjustAgentRequestTokenUsage 负责把本轮 graph 的请求级 token 一次性回写到账本。 +// adjustAgentRequestTokenUsage 保留为迁移期兼容空实现。 // // 说明: -// 1. agent 逐条可见消息都按 0 token 落库,最终统一在这里补记整轮消耗; -// 2. 如果启用了 outbox,就沿用异步 token 调整事件,保持写账口径一致; -// 3. 该步骤属于请求收尾,不应反过来打断用户已看到的回复。 +// 1. Credit 计费已切到独立 LLM 服务出口,这里不再回写旧 token 账本; +// 2. 会话级 tokens_total 仍由聊天历史持久化自己记录,不需要在这里二次补写; +// 3. 先保留方法壳,避免同轮大面积改调用点。 func (s *AgentService) adjustAgentRequestTokenUsage(ctx context.Context, userID int, chatID string, deltaTokens int) { - if s == nil || userID <= 0 || strings.TrimSpace(chatID) == "" || deltaTokens <= 0 { - return - } - if ctx == nil { - 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) - } + _ = ctx + _ = userID + _ = chatID + _ = deltaTokens } func (s *AgentService) makeRoughBuildFunc() agentmodel.RoughBuildFunc { diff --git a/backend/services/agent/sv/agent_meta.go b/backend/services/agent/sv/agent_meta.go index 509d82d..113f999 100644 --- a/backend/services/agent/sv/agent_meta.go +++ b/backend/services/agent/sv/agent_meta.go @@ -9,7 +9,6 @@ import ( "unicode/utf8" 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/shared/respond" "github.com/cloudwego/eino/schema" @@ -202,27 +201,10 @@ func (s *AgentService) ensureConversationTitleAsync(userID int, chatID string) { return } - // 4.1 标题生成成功后,把本次异步模型 token 记账: - // 4.1.1 启用 outbox 时走 adjust 事件,异步可靠入账; - // 4.1.2 未启用 outbox 时走同步兜底,直接更新账本。 - if titleTokens > 0 { - 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) - } - } - } + // 4.1 标题生成的模型消耗不再走旧 token 账本。 + // 4.1.1 当前 Credit 计费统一由独立 LLM 服务出口处理; + // 4.1.2 这里只保留 titleTokens 变量,避免同轮继续改动模型返回签名。 + _ = titleTokens // 5. 只在标题仍为空时写入,保证并发幂等。 if err = s.repo.UpdateConversationTitleIfEmpty(ctx, userID, chatID, generated); err != nil { diff --git a/backend/services/course/rpc/course.proto b/backend/services/course/rpc/course.proto index c145ec2..8ec3a88 100644 --- a/backend/services/course/rpc/course.proto +++ b/backend/services/course/rpc/course.proto @@ -20,6 +20,7 @@ message JSONResponse { } message CourseImageRequest { + uint64 user_id = 4; string filename = 1; string mime_type = 2; bytes image_bytes = 3; diff --git a/backend/services/course/rpc/handler.go b/backend/services/course/rpc/handler.go index e30c267..d238b85 100644 --- a/backend/services/course/rpc/handler.go +++ b/backend/services/course/rpc/handler.go @@ -70,6 +70,7 @@ func (h *Handler) ParseCourseImage(ctx context.Context, req *pb.CourseImageReque return nil, err } draft, err := h.svc.ParseCourseTableImage(ctx, model.CourseImageParseRequest{ + UserID: int(req.UserId), Filename: req.Filename, MIMEType: req.MimeType, ImageBytes: req.ImageBytes, diff --git a/backend/services/course/rpc/pb/course.pb.go b/backend/services/course/rpc/pb/course.pb.go index f012dae..bef482e 100644 --- a/backend/services/course/rpc/pb/course.pb.go +++ b/backend/services/course/rpc/pb/course.pb.go @@ -29,6 +29,7 @@ func (m *JSONResponse) String() string { return proto.CompactTextString(m) } func (*JSONResponse) ProtoMessage() {} 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"` 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"` diff --git a/backend/services/course/sv/course_parse_ark.go b/backend/services/course/sv/course_parse_ark.go index 3e31411..33d5035 100644 --- a/backend/services/course/sv/course_parse_ark.go +++ b/backend/services/course/sv/course_parse_ark.go @@ -2,9 +2,12 @@ package sv import ( "context" + "crypto/sha1" "encoding/base64" + "encoding/hex" "fmt" "log" + "strconv" "strings" "time" @@ -70,7 +73,8 @@ func (ss *CourseService) ParseCourseTableImage(ctx context.Context, req model.Co // 1. 课程表图片识别输出体量大,显式透传 max_output_tokens,避免被默认值截断。 // 2. text_format 固定为 json_object,降低输出混入解释文本导致解析失败的概率。 // 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, MaxOutputTokens: ss.courseImageConfig.MaxTokens, Thinking: llmservice.ThinkingModeDisabled, @@ -226,3 +230,25 @@ func isCourseImageOutputTruncated(rawResult *llmservice.ArkResponsesResult) bool 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), + } +} diff --git a/backend/services/llm/ark_responses_client.go b/backend/services/llm/ark_responses_client.go index 7f7eede..f03326a 100644 --- a/backend/services/llm/ark_responses_client.go +++ b/backend/services/llm/ark_responses_client.go @@ -46,8 +46,9 @@ type ArkResponsesResult struct { // ArkResponsesClient 是 Ark SDK Responses 的统一模型出口。 type ArkResponsesClient struct { - model string - client *arkruntime.Client + model string + client *arkruntime.Client + generateText func(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error) } // 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 调用并提取文本。 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) if err != nil { return nil, err diff --git a/backend/services/llm/billing.go b/backend/services/llm/billing.go new file mode 100644 index 0000000..53cff9f --- /dev/null +++ b/backend/services/llm/billing.go @@ -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 +} diff --git a/backend/services/llm/billing_compat.go b/backend/services/llm/billing_compat.go new file mode 100644 index 0000000..77e9d4a --- /dev/null +++ b/backend/services/llm/billing_compat.go @@ -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]) +} diff --git a/backend/services/llm/dao/cache.go b/backend/services/llm/dao/cache.go new file mode 100644 index 0000000..a59ce22 --- /dev/null +++ b/backend/services/llm/dao/cache.go @@ -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() +} diff --git a/backend/services/llm/dao/connect.go b/backend/services/llm/dao/connect.go new file mode 100644 index 0000000..fccb4be --- /dev/null +++ b/backend/services/llm/dao/connect.go @@ -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 +} diff --git a/backend/services/llm/dao/pricing.go b/backend/services/llm/dao/pricing.go new file mode 100644 index 0000000..dca0cfb --- /dev/null +++ b/backend/services/llm/dao/pricing.go @@ -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 +} diff --git a/backend/services/llm/guard.go b/backend/services/llm/guard.go new file mode 100644 index 0000000..4563541 --- /dev/null +++ b/backend/services/llm/guard.go @@ -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 +} diff --git a/backend/services/llm/outbox.go b/backend/services/llm/outbox.go new file mode 100644 index 0000000..4c6229a --- /dev/null +++ b/backend/services/llm/outbox.go @@ -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 "" +} diff --git a/backend/services/llm/pricing.go b/backend/services/llm/pricing.go new file mode 100644 index 0000000..9ce8f5d --- /dev/null +++ b/backend/services/llm/pricing.go @@ -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 +} diff --git a/backend/services/llm/rpc/errors.go b/backend/services/llm/rpc/errors.go new file mode 100644 index 0000000..9257cd4 --- /dev/null +++ b/backend/services/llm/rpc/errors.go @@ -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 +} diff --git a/backend/services/llm/rpc/handler.go b/backend/services/llm/rpc/handler.go new file mode 100644 index 0000000..d5303b5 --- /dev/null +++ b/backend/services/llm/rpc/handler.go @@ -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 +} diff --git a/backend/services/llm/rpc/json_codec.go b/backend/services/llm/rpc/json_codec.go new file mode 100644 index 0000000..a8ed433 --- /dev/null +++ b/backend/services/llm/rpc/json_codec.go @@ -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{}) +} diff --git a/backend/services/llm/rpc/llm.proto b/backend/services/llm/rpc/llm.proto new file mode 100644 index 0000000..8d7d0f4 --- /dev/null +++ b/backend/services/llm/rpc/llm.proto @@ -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 {} diff --git a/backend/services/llm/rpc/server.go b/backend/services/llm/rpc/server.go new file mode 100644 index 0000000..58a942c --- /dev/null +++ b/backend/services/llm/rpc/server.go @@ -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 +} diff --git a/backend/services/llm/rpc/transport.go b/backend/services/llm/rpc/transport.go new file mode 100644 index 0000000..1baa883 --- /dev/null +++ b/backend/services/llm/rpc/transport.go @@ -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", +} diff --git a/backend/services/llm/runtime_service.go b/backend/services/llm/runtime_service.go new file mode 100644 index 0000000..79be884 --- /dev/null +++ b/backend/services/llm/runtime_service.go @@ -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 +} diff --git a/backend/services/llm/service.go b/backend/services/llm/service.go index dbda2f4..fecfd76 100644 --- a/backend/services/llm/service.go +++ b/backend/services/llm/service.go @@ -35,6 +35,19 @@ type AgentModelClients struct { 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。 // 1. 不返回 error,是为了让上层继续按 nil 客户端做逐步降级。 // 2. 只要 AIHub 已初始化,就把其中的 ChatModel 收敛成统一 Client。 @@ -62,6 +75,16 @@ func New(opts Options) *Service { return svc } +// NewWithClients 使用外部注入的现成客户端构造 Service。 +func NewWithClients(clients StaticClients) *Service { + return &Service{ + liteClient: clients.Lite, + proClient: clients.Pro, + maxClient: clients.Max, + courseImageResponsesClient: clients.CourseImageResponses, + } +} + // LiteClient 返回低成本短输出模型客户端。 func (s *Service) LiteClient() *Client { if s == nil { diff --git a/backend/services/llm/stream_accounting.go b/backend/services/llm/stream_accounting.go new file mode 100644 index 0000000..b7923df --- /dev/null +++ b/backend/services/llm/stream_accounting.go @@ -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)) + } + }) +} diff --git a/backend/services/memory/internal/orchestrator/llm_decision_orchestrator.go b/backend/services/memory/internal/orchestrator/llm_decision_orchestrator.go index e8663e1..56aca85 100644 --- a/backend/services/memory/internal/orchestrator/llm_decision_orchestrator.go +++ b/backend/services/memory/internal/orchestrator/llm_decision_orchestrator.go @@ -41,6 +41,7 @@ func NewLLMDecisionOrchestrator(client *llmservice.Client, cfg memorymodel.Confi // 3. 不做最终决策,最终动作由确定性汇总逻辑产出。 func (o *LLMDecisionOrchestrator) Compare( ctx context.Context, + billing llmservice.BillingContext, fact memorymodel.NormalizedFact, candidate memorymodel.CandidateSnapshot, ) (*memorymodel.ComparisonResult, error) { @@ -53,10 +54,11 @@ func (o *LLMDecisionOrchestrator) Compare( userPrompt := buildDecisionCompareUserPrompt(fact, candidate) messages := llmservice.BuildSystemUserMessages(systemPrompt, nil, userPrompt) + invokeCtx := llmservice.WithBillingContext(ctx, billing) // 2. 调用 LLM 做结构化输出,温度用低值保证判断稳定。 resp, _, err := llmservice.GenerateJSON[decisionCompareResponse]( - ctx, + invokeCtx, o.client, messages, llmservice.GenerateOptions{ diff --git a/backend/services/memory/internal/orchestrator/llm_write_orchestrator.go b/backend/services/memory/internal/orchestrator/llm_write_orchestrator.go index 6800df1..a035b2e 100644 --- a/backend/services/memory/internal/orchestrator/llm_write_orchestrator.go +++ b/backend/services/memory/internal/orchestrator/llm_write_orchestrator.go @@ -59,9 +59,10 @@ func (o *LLMWriteOrchestrator) ExtractFacts(ctx context.Context, payload memorym nil, buildMemoryExtractUserPrompt(payload), ) + invokeCtx := llmservice.WithBillingContext(ctx, buildMemoryExtractBillingContext(payload)) resp, rawResult, err := llmservice.GenerateJSON[memoryExtractResponse]( - ctx, + invokeCtx, o.client, messages, llmservice.GenerateOptions{ @@ -329,3 +330,18 @@ func truncateForLog(raw *llmservice.TextResult) string { } 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", + } +} diff --git a/backend/services/memory/internal/worker/decision_flow.go b/backend/services/memory/internal/worker/decision_flow.go index bf6294d..6e64a01 100644 --- a/backend/services/memory/internal/worker/decision_flow.go +++ b/backend/services/memory/internal/worker/decision_flow.go @@ -3,7 +3,9 @@ package worker import ( "context" "fmt" + "strings" + llmservice "github.com/LoveLosita/smartflow/backend/services/llm" memoryrepo "github.com/LoveLosita/smartflow/backend/services/memory/internal/repo" memoryutils "github.com/LoveLosita/smartflow/backend/services/memory/internal/utils" memorymodel "github.com/LoveLosita/smartflow/backend/services/memory/model" @@ -144,7 +146,7 @@ func (r *Runner) executeDecisionForFact( } // Step 3: 逐对 LLM 比对。 - comparisons := r.compareWithCandidates(ctx, fact, candidates) + comparisons := r.compareWithCandidates(ctx, payload, fact, candidates) // Step 4: 确定性汇总。 decision := memoryutils.AggregateComparisons(fact, comparisons, candidates) @@ -298,6 +300,7 @@ func (r *Runner) recallCandidatesFromMySQL( // 3. 无候选或决策编排器为空时返回空切片,上层直接走 ADD 路径。 func (r *Runner) compareWithCandidates( ctx context.Context, + payload memorymodel.ExtractJobPayload, fact memorymodel.NormalizedFact, candidates []memorymodel.CandidateSnapshot, ) []memorymodel.ComparisonResult { @@ -307,7 +310,7 @@ func (r *Runner) compareWithCandidates( comparisons := make([]memorymodel.ComparisonResult, 0, len(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 { // LLM 调用失败 → 视为 unrelated,不影响其他候选。 if r.logger != nil { @@ -335,6 +338,26 @@ func (r *Runner) compareWithCandidates( 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。 func (r *Runner) collectActionOutcome(outcome *DecisionFlowOutcome, actionOutcome *ApplyActionOutcome) { if actionOutcome == nil { diff --git a/backend/services/runtime/eventsvc/chat_history_persist.go b/backend/services/runtime/eventsvc/chat_history_persist.go index 12e9bc3..ea31608 100644 --- a/backend/services/runtime/eventsvc/chat_history_persist.go +++ b/backend/services/runtime/eventsvc/chat_history_persist.go @@ -9,10 +9,8 @@ import ( "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" ) @@ -25,13 +23,12 @@ const ( // 职责边界: // 1. 只处理聊天历史事件,不处理其它业务事件; // 2. 只负责注册,不负责总线启动; -// 3. 先写本地 chat 相关表,再调用 userauth 调整 token 额度; +// 3. 先写本地 chat 相关表,不再把聊天 token 消耗同步到旧 userauth 额度账本; // 4. 当前版本仅注册新路由键,不再注册旧兼容键。 func RegisterChatHistoryPersistHandler( bus OutboxBus, outboxRepo *outboxinfra.Repository, repoManager *dao.RepoManager, - adjuster ports.TokenUsageAdjuster, ) error { if bus == nil { return errors.New("event bus is nil") @@ -77,19 +74,6 @@ func RegisterChatHistoryPersistHandler( 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) } diff --git a/backend/services/runtime/eventsvc/chat_token_usage_adjust.go b/backend/services/runtime/eventsvc/chat_token_usage_adjust.go deleted file mode 100644 index 5954429..0000000 --- a/backend/services/runtime/eventsvc/chat_token_usage_adjust.go +++ /dev/null @@ -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, - }) -} diff --git a/backend/services/runtime/eventsvc/core_outbox_handlers.go b/backend/services/runtime/eventsvc/core_outbox_handlers.go index 6b742c1..ae7652a 100644 --- a/backend/services/runtime/eventsvc/core_outbox_handlers.go +++ b/backend/services/runtime/eventsvc/core_outbox_handlers.go @@ -7,7 +7,6 @@ import ( "github.com/LoveLosita/smartflow/backend/services/runtime/dao" sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox" - "github.com/LoveLosita/smartflow/backend/shared/ports" ) // RegisterCoreOutboxHandlers 注册单体残留内仍由 agent 边界消费的 outbox handler。 @@ -24,7 +23,6 @@ func RegisterCoreOutboxHandlers( agentRepo *dao.AgentDAO, cacheRepo *dao.CacheDAO, memoryModule *memory.Module, - adjuster ports.TokenUsageAdjuster, ) error { if err := validateCoreOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo); err != nil { return err @@ -33,7 +31,7 @@ func RegisterCoreOutboxHandlers( return err } - return registerOutboxHandlerRoutes(coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, adjuster)) + return registerOutboxHandlerRoutes(coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule)) } // RegisterAllOutboxHandlers 注册当前阶段所有 outbox handler。 @@ -51,7 +49,6 @@ func RegisterAllOutboxHandlers( cacheRepo *dao.CacheDAO, memoryModule *memory.Module, activeTriggerWorkflow ActiveScheduleTriggeredProcessor, - adjuster ports.TokenUsageAdjuster, ) error { if err := validateAllOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, activeTriggerWorkflow); err != nil { return err @@ -65,7 +62,6 @@ func RegisterAllOutboxHandlers( cacheRepo, memoryModule, activeTriggerWorkflow, - adjuster, )) } @@ -126,21 +122,13 @@ func coreOutboxHandlerRoutes( agentRepo *dao.AgentDAO, cacheRepo *dao.CacheDAO, memoryModule *memory.Module, - adjuster ports.TokenUsageAdjuster, ) []outboxHandlerRoute { return []outboxHandlerRoute{ { EventType: EventTypeChatHistoryPersistRequested, Service: outboxHandlerServiceAgent, Register: func() error { - return RegisterChatHistoryPersistHandler(eventBus, outboxRepo, repoManager, adjuster) - }, - }, - { - EventType: EventTypeChatTokenUsageAdjustRequested, - Service: outboxHandlerServiceAgent, - Register: func() error { - return RegisterChatTokenUsageAdjustHandler(eventBus, outboxRepo, repoManager, adjuster) + return RegisterChatHistoryPersistHandler(eventBus, outboxRepo, repoManager) }, }, { @@ -169,9 +157,8 @@ func allOutboxHandlerRoutes( cacheRepo *dao.CacheDAO, memoryModule *memory.Module, activeTriggerWorkflow ActiveScheduleTriggeredProcessor, - adjuster ports.TokenUsageAdjuster, ) []outboxHandlerRoute { - routes := coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, adjuster) + routes := coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule) routes = append(routes, outboxHandlerRoute{ EventType: sharedevents.ActiveScheduleTriggeredEventType, diff --git a/backend/services/runtime/model/course_parse.go b/backend/services/runtime/model/course_parse.go index 0f69adb..8cf60bf 100644 --- a/backend/services/runtime/model/course_parse.go +++ b/backend/services/runtime/model/course_parse.go @@ -32,6 +32,7 @@ type CourseImageParseResponse struct { } type CourseImageParseRequest struct { + UserID int Filename string MIMEType string ImageBytes []byte diff --git a/backend/services/taskclassforum/sv/errors.go b/backend/services/taskclassforum/sv/errors.go index c5e4ec9..1fc3a19 100644 --- a/backend/services/taskclassforum/sv/errors.go +++ b/backend/services/taskclassforum/sv/errors.go @@ -1,8 +1,23 @@ package sv -import "errors" +import ( + "errors" + + "github.com/LoveLosita/smartflow/backend/shared/respond" +) var ( // ErrTaskClassPortMissing 表示计划广场需要访问旧 TaskClass,但 adapter 尚未注入。 ErrTaskClassPortMissing = errors.New("taskclassforum taskclass adapter is nil") + + // ErrForumTagsRequired 表示发布帖子时至少要选择一个标签。 + // + // 说明: + // 1. 复用 MissingParam 的状态码,保持 RPC/HTTP 错误映射链路不变; + // 2. 单独覆写 info,保证前端能直接展示更明确的中文提示; + // 3. 仅用于“标签必填”这条业务规则,不替代其他参数校验。 + ErrForumTagsRequired = respond.Response{ + Status: respond.MissingParam.Status, + Info: "至少选择一个标签", + } ) diff --git a/backend/services/taskclassforum/sv/helpers.go b/backend/services/taskclassforum/sv/helpers.go index c8888f0..316abe6 100644 --- a/backend/services/taskclassforum/sv/helpers.go +++ b/backend/services/taskclassforum/sv/helpers.go @@ -7,9 +7,9 @@ import ( "strings" "time" - "github.com/LoveLosita/smartflow/backend/shared/respond" forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + "github.com/LoveLosita/smartflow/backend/shared/respond" ) const ( @@ -24,6 +24,66 @@ const ( 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) { if page <= 0 { page = defaultPage @@ -69,6 +129,23 @@ func normalizeTags(tags []string) ([]string, error) { 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 { if len([]rune(strings.TrimSpace(value))) > maxLen { return respond.ParamTooLong diff --git a/backend/services/taskclassforum/sv/post.go b/backend/services/taskclassforum/sv/post.go index 5196580..19b7403 100644 --- a/backend/services/taskclassforum/sv/post.go +++ b/backend/services/taskclassforum/sv/post.go @@ -7,10 +7,10 @@ import ( "sort" "strings" - "github.com/LoveLosita/smartflow/backend/shared/respond" forumdao "github.com/LoveLosita/smartflow/backend/services/taskclassforum/dao" forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model" forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum" + "github.com/LoveLosita/smartflow/backend/shared/respond" "gorm.io/gorm" ) @@ -73,38 +73,12 @@ func (s *Service) ListTags(ctx context.Context, actorUserID uint64, limit int) ( if err := s.Ready(); err != nil { return nil, err } - if limit <= 0 || limit > 50 { - limit = 20 - } rawTags, err := s.forumDAO.ListPublishedTagJSONs(ctx) if err != nil { return nil, err } - counter := make(map[string]int) - 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 + return buildForumTagItems(rawTags, limit), nil } // 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 { return nil, err } diff --git a/backend/services/tokenstore/dao/cache.go b/backend/services/tokenstore/dao/cache.go new file mode 100644 index 0000000..82dc537 --- /dev/null +++ b/backend/services/tokenstore/dao/cache.go @@ -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() +} diff --git a/backend/services/tokenstore/dao/connect.go b/backend/services/tokenstore/dao/connect.go index e7c8742..4a2f5d1 100644 --- a/backend/services/tokenstore/dao/connect.go +++ b/backend/services/tokenstore/dao/connect.go @@ -1,12 +1,14 @@ package dao import ( + "errors" "fmt" + storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" + mysqlinfra "github.com/LoveLosita/smartflow/backend/shared/infra/mysql" outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox" - tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" - "github.com/spf13/viper" - "gorm.io/driver/mysql" + redisinfra "github.com/LoveLosita/smartflow/backend/shared/infra/redis" + "github.com/go-redis/redis/v8" "gorm.io/gorm" "gorm.io/gorm/clause" ) @@ -14,22 +16,11 @@ import ( // OpenDBFromConfig 创建 token-store 服务自己的数据库句柄,并迁移本服务私有表。 // // 职责边界: -// 1. 只迁移 token_* 表和 token-store outbox 表,不迁移 users,避免和 user/auth 服务边界冲突; -// 2. 自动迁移后执行 P0 seed,确保前端商品页有可展示商品; -// 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。 +// 1. 只迁移 token_*、credit_* 以及 token-store outbox 表,不迁移其它服务表; +// 2. 自动迁移后执行默认 seed,保证旧 Token 链路和新 Credit 链路都能并行跑通; +// 3. 返回 *gorm.DB 供 DAO 复用,调用方负责进程生命周期。 func OpenDBFromConfig() (*gorm.DB, error) { - host := viper.GetString("database.host") - port := viper.GetString("database.port") - user := viper.GetString("database.user") - password := viper.GetString("database.password") - dbname := viper.GetString("database.dbname") - - dsn := fmt.Sprintf( - "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", - user, password, host, port, dbname, - ) - - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + db, err := mysqlinfra.OpenDBFromConfig() if err != nil { return nil, err } @@ -42,22 +33,33 @@ func OpenDBFromConfig() (*gorm.DB, error) { return db, nil } +// OpenRedisFromConfig 创建 token-store 服务自己的 Redis 句柄。 +// +// 职责边界: +// 1. 这里只负责初始化通用 Redis 连接,不决定是否必须启用; +// 2. 调用方可以把失败视为“可选能力不可用”,而不是必须退出进程; +// 3. Credit 缓存 key 语义统一放在 tokenstore 自己的 cache DAO 内维护。 +func OpenRedisFromConfig() (*redis.Client, error) { + return redisinfra.OpenRedisFromConfig() +} + // AutoMigrate 只迁移 token-store 服务拥有的表。 // // 步骤说明: -// 1. 先创建商品、订单、获取账本和奖励规则表; -// 2. 再按 service catalog 创建 token-store outbox 表,保证论坛奖励事件有稳定落表目录; -// 3. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入; -// 4. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。 +// 1. 只迁移 Credit 权威账本表; +// 2. 最后迁移 token-store 的 outbox 表,保证论坛奖励与 Credit 扣费消费都能稳定落表; +// 3. 任一步失败都直接返回,避免服务在 schema 不完整时继续启动。 func AutoMigrate(db *gorm.DB) error { if db == nil { return fmt.Errorf("tokenstore auto migrate failed: db is nil") } if err := db.AutoMigrate( - &tokenmodel.TokenProduct{}, - &tokenmodel.TokenOrder{}, - &tokenmodel.TokenGrant{}, - &tokenmodel.TokenRewardRule{}, + &storemodel.CreditAccount{}, + &storemodel.CreditLedger{}, + &storemodel.CreditProduct{}, + &storemodel.CreditOrder{}, + &storemodel.CreditPriceRule{}, + &storemodel.CreditRewardRule{}, ); err != nil { return fmt.Errorf("auto migrate tokenstore tables failed: %w", err) } @@ -67,88 +69,62 @@ func AutoMigrate(db *gorm.DB) error { return nil } -// SeedDefaults 写入 P0 默认商品和奖励规则。 +// SeedDefaults 写入 Token 与 Credit 默认商品/规则。 // // 步骤说明: -// 1. 商品和奖励规则都用稳定业务键做 upsert,允许重复启动服务; -// 2. seed 只提供 P0 默认数据,不代表有管理后台能力; -// 3. 后续若商品或规则由运营后台维护,可替换本函数或仅保留初始化兜底。 +// 1. 只保留 Credit 商品与奖励规则 seed; +// 2. Credit 价格规则本轮只建表不写默认价格,避免误用错误计费参数。 func SeedDefaults(db *gorm.DB) error { if db == nil { return fmt.Errorf("tokenstore seed failed: db is nil") } - if err := seedDefaultProducts(db); err != nil { + if err := seedDefaultCreditProducts(db); err != nil { return err } - if err := seedDefaultRewardRules(db); err != nil { + if err := backfillCreditProductOriginalPrice(db); err != nil { + return err + } + if err := seedDefaultCreditPriceRules(db); err != nil { + return err + } + if err := seedDefaultCreditRewardRules(db); err != nil { return err } return nil } -func seedDefaultProducts(db *gorm.DB) error { - products := defaultTokenProducts() +func seedDefaultCreditProducts(db *gorm.DB) error { + products := defaultCreditProducts() for _, product := range products { - if err := db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "sku"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "name", - "description", - "token_amount", - "price_cent", - "currency", - "badge", - "status", - "sort_order", - "updated_at", - }), - }).Create(&product).Error; err != nil { - return fmt.Errorf("seed token product %s failed: %w", product.SKU, err) + // 1. 这里只负责“缺失即补齐”的默认商品播种,不再把服务启动当成运营配置同步器。 + // 2. 一旦线上已经存在同 SKU 商品,说明运营侧可能手动改过价格、文案或状态,此时必须保留现状。 + // 3. 真正需要批量改默认套餐时,应该走显式 migration 或脚本,而不是依赖服务重启覆盖。 + if err := db.Clauses(creditProductSeedOnConflict()).Create(&product).Error; err != nil { + return fmt.Errorf("seed credit product %s failed: %w", product.SKU, err) } } return nil } -func defaultTokenProducts() []tokenmodel.TokenProduct { - return []tokenmodel.TokenProduct{ - { - SKU: "token_basic_100", - Name: "基础 Token 包", - Description: "适合轻量使用 Agent。", - TokenAmount: 100, - PriceCent: 990, - Currency: "CNY", - Badge: "入门", - Status: tokenmodel.TokenProductStatusActive, - SortOrder: 10, - }, - { - SKU: "token_plus_300", - Name: "进阶 Token 包", - Description: "适合高频规划和复盘。", - TokenAmount: 300, - PriceCent: 1990, - Currency: "CNY", - Badge: "推荐", - Status: tokenmodel.TokenProductStatusActive, - SortOrder: 20, - }, - { - SKU: "token_pro_800", - Name: "专业 Token 包", - Description: "适合长周期学习计划和高频 Agent 使用。", - TokenAmount: 800, - PriceCent: 3990, - Currency: "CNY", - Badge: "高频", - Status: tokenmodel.TokenProductStatusActive, - SortOrder: 30, - }, +func creditProductSeedOnConflict() clause.OnConflict { + return clause.OnConflict{ + Columns: []clause.Column{{Name: "sku"}}, + DoNothing: true, } } -func seedDefaultRewardRules(db *gorm.DB) error { - rules := defaultTokenRewardRules() +func backfillCreditProductOriginalPrice(db *gorm.DB) error { + // 1. 只回填 original_price_cent 为空的旧数据,避免覆盖运营已手工维护的划线价。 + // 2. 回填时直接复用当前售价 price_cent,保证接口上线后这个字段立刻可用。 + // 3. 这里不改商品文案、状态和现价,继续遵守“服务启动不是配置覆盖器”的边界。 + return db. + Model(&storemodel.CreditProduct{}). + Where("original_price_cent = 0"). + Update("original_price_cent", gorm.Expr("price_cent")).Error +} + +func seedDefaultCreditRewardRules(db *gorm.DB) error { + rules := defaultCreditRewardRules() for _, rule := range rules { if err := db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "source"}}, @@ -156,29 +132,141 @@ func seedDefaultRewardRules(db *gorm.DB) error { "name", "amount", "status", + "description", "config_json", "updated_at", }), }).Create(&rule).Error; err != nil { - return fmt.Errorf("seed token reward rule %s failed: %w", rule.Source, err) + return fmt.Errorf("seed credit reward rule %s failed: %w", rule.Source, err) } } return nil } -func defaultTokenRewardRules() []tokenmodel.TokenRewardRule { - return []tokenmodel.TokenRewardRule{ +func seedDefaultCreditPriceRules(db *gorm.DB) error { + rules := defaultCreditPriceRules() + for _, rule := range rules { + var existing storemodel.CreditPriceRule + err := db. + Where( + "scene = ? AND provider_name = ? AND model_name = ? AND status = ?", + rule.Scene, + rule.ProviderName, + rule.ModelName, + storemodel.CreditPriceRuleStatusActive, + ). + First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + if createErr := db.Create(&rule).Error; createErr != nil { + return fmt.Errorf("seed credit price rule %s/%s/%s failed: %w", rule.Scene, rule.ProviderName, rule.ModelName, createErr) + } + continue + } + if err != nil { + return fmt.Errorf("load credit price rule %s/%s/%s failed: %w", rule.Scene, rule.ProviderName, rule.ModelName, err) + } + } + return nil +} + +func defaultCreditProducts() []storemodel.CreditProduct { + return []storemodel.CreditProduct{ { - Source: tokenmodel.TokenGrantSourceForumLike, - Name: "计划被点赞奖励", - Amount: 1, - Status: tokenmodel.TokenRewardRuleStatusActive, + SKU: "credit_free_100", + Name: "Free", + Description: "每日免费发放,适合基础功能体验。", + CreditAmount: 100, + PriceCent: 0, + OriginalPriceCent: 0, + Currency: "CNY", + Badge: "每日", + Status: storemodel.CreditProductStatusActive, + SortOrder: 10, }, { - Source: tokenmodel.TokenGrantSourceForumImport, - Name: "计划被导入奖励", - Amount: 5, - Status: tokenmodel.TokenRewardRuleStatusActive, + SKU: "credit_starter_1000", + Name: "Starter", + Description: "入门级额度,有效期 1 个月。续费时时间和额度均可累加。", + CreditAmount: 1000, + PriceCent: 990, + OriginalPriceCent: 990, + Currency: "CNY", + Badge: "入门", + Status: storemodel.CreditProductStatusActive, + SortOrder: 20, + }, + { + SKU: "credit_lite_3000", + Name: "Lite", + Description: "经济型套餐,有效期 1 个月。适合日常轻度规划。", + CreditAmount: 3000, + PriceCent: 1990, + OriginalPriceCent: 1990, + Currency: "CNY", + Badge: "经济", + Status: storemodel.CreditProductStatusActive, + SortOrder: 30, + }, + { + SKU: "credit_pro_10000", + Name: "Pro", + Description: "专业版套餐,有效期 1 个月。最受深度规划用户欢迎。", + CreditAmount: 10000, + PriceCent: 3990, + OriginalPriceCent: 3990, + Currency: "CNY", + Badge: "Most Popular", + Status: storemodel.CreditProductStatusActive, + SortOrder: 40, + }, + { + SKU: "credit_max_40000", + Name: "Max", + Description: "旗舰级套餐,有效期 1 个月。极致体验,额度充沛。", + CreditAmount: 40000, + PriceCent: 9990, + OriginalPriceCent: 9990, + Currency: "CNY", + Badge: "旗舰", + Status: storemodel.CreditProductStatusActive, + SortOrder: 50, + }, + } +} + +func defaultCreditRewardRules() []storemodel.CreditRewardRule { + return []storemodel.CreditRewardRule{ + { + Source: storemodel.CreditLedgerSourceForumLike, + Name: "计划被点赞奖励", + Amount: 1, + Status: storemodel.CreditRewardRuleStatusActive, + Description: "预留论坛点赞正向激励。", + }, + { + Source: storemodel.CreditLedgerSourceForumImport, + Name: "计划被导入奖励", + Amount: 5, + Status: storemodel.CreditRewardRuleStatusActive, + Description: "预留论坛导入正向激励。", + }, + } +} + +func defaultCreditPriceRules() []storemodel.CreditPriceRule { + return []storemodel.CreditPriceRule{ + { + Scene: "*", + ProviderName: "ark", + ModelName: "*", + InputPriceMicros: 3200, + OutputPriceMicros: 16000, + CachedPriceMicros: 800, + ReasoningPriceMicros: 16000, + CreditPerYuan: 100, + Status: storemodel.CreditPriceRuleStatusActive, + Priority: 100, + Description: "Default Ark rule, prices are expressed in micros CNY per 1K tokens.", }, } } diff --git a/backend/services/tokenstore/dao/creditstore.go b/backend/services/tokenstore/dao/creditstore.go new file mode 100644 index 0000000..ffbc40c --- /dev/null +++ b/backend/services/tokenstore/dao/creditstore.go @@ -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 +} diff --git a/backend/services/tokenstore/dao/tokenstore.go b/backend/services/tokenstore/dao/tokenstore.go deleted file mode 100644 index e8436ae..0000000 --- a/backend/services/tokenstore/dao/tokenstore.go +++ /dev/null @@ -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 -} diff --git a/backend/services/tokenstore/model/credit.go b/backend/services/tokenstore/model/credit.go new file mode 100644 index 0000000..939bac9 --- /dev/null +++ b/backend/services/tokenstore/model/credit.go @@ -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" +} diff --git a/backend/services/tokenstore/model/token.go b/backend/services/tokenstore/model/token.go deleted file mode 100644 index c33f52b..0000000 --- a/backend/services/tokenstore/model/token.go +++ /dev/null @@ -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" -} diff --git a/backend/services/tokenstore/rpc/credit.go b/backend/services/tokenstore/rpc/credit.go new file mode 100644 index 0000000..7015853 --- /dev/null +++ b/backend/services/tokenstore/rpc/credit.go @@ -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 +} diff --git a/backend/services/tokenstore/rpc/handler.go b/backend/services/tokenstore/rpc/handler.go index fd90bc8..206c743 100644 --- a/backend/services/tokenstore/rpc/handler.go +++ b/backend/services/tokenstore/rpc/handler.go @@ -4,10 +4,10 @@ import ( "context" "errors" - "github.com/LoveLosita/smartflow/backend/shared/respond" "github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb" 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 { @@ -20,11 +20,6 @@ func NewHandler(svc *tokenstoresv.Service) *Handler { } // service 负责统一校验 RPC 层依赖是否已经注入。 -// -// 职责边界: -// 1. 只判断 handler 自身和业务 service 是否可用; -// 2. 不负责支付状态流转、订单幂等和 grant 账本写入; -// 3. 失败时返回可直接转成 gRPC status 的业务错误。 func (h *Handler) service() (*tokenstoresv.Service, error) { if h == nil || h.svc == nil { return nil, errors.New("tokenstore service dependency not initialized") @@ -32,282 +27,39 @@ func (h *Handler) service() (*tokenstoresv.Service, error) { return h.svc, nil } -// GetSummary 负责把 Token 概览请求从 gRPC 协议转成内部服务调用。 -func (h *Handler) GetSummary(ctx context.Context, req *pb.GetTokenSummaryRequest) (*pb.GetTokenSummaryResponse, error) { - svc, err := h.service() - 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 +// GetSummary 保留旧 token RPC 方法壳,统一返回已下线。 +func (h *Handler) GetSummary(context.Context, *pb.GetTokenSummaryRequest) (*pb.GetTokenSummaryResponse, error) { + return nil, legacyTokenMethodRemoved() } -func (h *Handler) ListProducts(ctx context.Context, req *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, error) { - svc, err := h.service() - 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) ListProducts(context.Context, *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, error) { + return nil, legacyTokenMethodRemoved() } -func (h *Handler) CreateOrder(ctx context.Context, req *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) { - svc, err := h.service() - 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) CreateOrder(context.Context, *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) { + return nil, legacyTokenMethodRemoved() } -func (h *Handler) ListOrders(ctx context.Context, req *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, error) { - svc, err := h.service() - 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) ListOrders(context.Context, *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, error) { + return nil, legacyTokenMethodRemoved() } -func (h *Handler) GetOrder(ctx context.Context, req *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) { - svc, err := h.service() - 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) GetOrder(context.Context, *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) { + return nil, legacyTokenMethodRemoved() } -func (h *Handler) MockPaidOrder(ctx context.Context, req *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, error) { - svc, err := h.service() - 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) MockPaidOrder(context.Context, *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, error) { + return nil, legacyTokenMethodRemoved() } -func (h *Handler) ListGrants(ctx context.Context, req *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) { - svc, err := h.service() - 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 +func (h *Handler) ListGrants(context.Context, *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) { + return nil, legacyTokenMethodRemoved() } -// RecordForumRewardGrant 负责把论坛 outbox 奖励事件转成 token-store 内部账本写入调用。 -func (h *Handler) RecordForumRewardGrant(ctx context.Context, req *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) { - 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 (h *Handler) RecordForumRewardGrant(context.Context, *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) { + return nil, legacyTokenMethodRemoved() } -func tokenPageToPB(page tokencontracts.PageResult) *pb.PageResponse { - return &pb.PageResponse{ - 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 +func legacyTokenMethodRemoved() error { + return status.Error(codes.Unimplemented, "legacy token API has been removed; use credit APIs instead") } diff --git a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go index bda3321..43decb6 100644 --- a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go +++ b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go @@ -229,3 +229,299 @@ type RecordForumRewardGrantResponse struct { func (m *RecordForumRewardGrantResponse) Reset() { *m = RecordForumRewardGrantResponse{} } func (m *RecordForumRewardGrantResponse) String() string { return proto.CompactTextString(m) } 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() {} diff --git a/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go b/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go index d9b02b0..31ff3b5 100644 --- a/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go +++ b/backend/services/tokenstore/rpc/pb/tokenstore_grpc.pb.go @@ -9,14 +9,24 @@ import ( ) const ( - TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary" - TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts" - TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder" - TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders" - TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder" - TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder" - TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants" - TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant" + TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary" + TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts" + TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder" + TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders" + TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder" + TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder" + TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants" + 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 { @@ -28,6 +38,16 @@ type TokenStoreServiceClient interface { MockPaidOrder(ctx context.Context, in *MockPaidOrderRequest, opts ...grpc.CallOption) (*MockPaidOrderResponse, error) ListGrants(ctx context.Context, in *ListTokenGrantsRequest, opts ...grpc.CallOption) (*ListTokenGrantsResponse, 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 { @@ -70,6 +90,46 @@ func (c *tokenStoreServiceClient) RecordForumRewardGrant(ctx context.Context, in 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) { out := new(Resp) err := cc.Invoke(ctx, fullMethod, in, out, opts...) @@ -88,6 +148,16 @@ type TokenStoreServiceServer interface { MockPaidOrder(context.Context, *MockPaidOrderRequest) (*MockPaidOrderResponse, error) ListGrants(context.Context, *ListTokenGrantsRequest) (*ListTokenGrantsResponse, 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{} @@ -124,6 +194,46 @@ func (UnimplementedTokenStoreServiceServer) RecordForumRewardGrant(context.Conte 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) { 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) { 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{}, Metadata: "tokenstore.proto", diff --git a/backend/services/tokenstore/rpc/tokenstore.proto b/backend/services/tokenstore/rpc/tokenstore.proto index 9669777..7e1c476 100644 --- a/backend/services/tokenstore/rpc/tokenstore.proto +++ b/backend/services/tokenstore/rpc/tokenstore.proto @@ -13,6 +13,16 @@ service TokenStoreService { rpc MockPaidOrder(MockPaidOrderRequest) returns (MockPaidOrderResponse); rpc ListGrants(ListTokenGrantsRequest) returns (ListTokenGrantsResponse); 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 { @@ -154,3 +164,191 @@ message RecordForumRewardGrantRequest { message RecordForumRewardGrantResponse { 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; +} diff --git a/backend/services/tokenstore/sv/credit_balance.go b/backend/services/tokenstore/sv/credit_balance.go new file mode 100644 index 0000000..24eaa4c --- /dev/null +++ b/backend/services/tokenstore/sv/credit_balance.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/credit_charge.go b/backend/services/tokenstore/sv/credit_charge.go new file mode 100644 index 0000000..997945c --- /dev/null +++ b/backend/services/tokenstore/sv/credit_charge.go @@ -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 调用扣费" + } +} diff --git a/backend/services/tokenstore/sv/credit_dashboard.go b/backend/services/tokenstore/sv/credit_dashboard.go new file mode 100644 index 0000000..de84b63 --- /dev/null +++ b/backend/services/tokenstore/sv/credit_dashboard.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/credit_helpers.go b/backend/services/tokenstore/sv/credit_helpers.go new file mode 100644 index 0000000..f3461f9 --- /dev/null +++ b/backend/services/tokenstore/sv/credit_helpers.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/credit_order.go b/backend/services/tokenstore/sv/credit_order.go new file mode 100644 index 0000000..4cfadd4 --- /dev/null +++ b/backend/services/tokenstore/sv/credit_order.go @@ -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" +} diff --git a/backend/services/tokenstore/sv/credit_outbox.go b/backend/services/tokenstore/sv/credit_outbox.go new file mode 100644 index 0000000..01d70df --- /dev/null +++ b/backend/services/tokenstore/sv/credit_outbox.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/credit_product.go b/backend/services/tokenstore/sv/credit_product.go new file mode 100644 index 0000000..2427bff --- /dev/null +++ b/backend/services/tokenstore/sv/credit_product.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/credit_reward.go b/backend/services/tokenstore/sv/credit_reward.go new file mode 100644 index 0000000..d7c7406 --- /dev/null +++ b/backend/services/tokenstore/sv/credit_reward.go @@ -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 + } +} diff --git a/backend/services/tokenstore/sv/credit_rule.go b/backend/services/tokenstore/sv/credit_rule.go new file mode 100644 index 0000000..cc097f2 --- /dev/null +++ b/backend/services/tokenstore/sv/credit_rule.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/credit_transaction.go b/backend/services/tokenstore/sv/credit_transaction.go new file mode 100644 index 0000000..9f9a1e0 --- /dev/null +++ b/backend/services/tokenstore/sv/credit_transaction.go @@ -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 +} diff --git a/backend/services/tokenstore/sv/grant.go b/backend/services/tokenstore/sv/grant.go deleted file mode 100644 index 6cb4cde..0000000 --- a/backend/services/tokenstore/sv/grant.go +++ /dev/null @@ -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 -} diff --git a/backend/services/tokenstore/sv/helpers.go b/backend/services/tokenstore/sv/helpers.go index 21ae054..d8729dd 100644 --- a/backend/services/tokenstore/sv/helpers.go +++ b/backend/services/tokenstore/sv/helpers.go @@ -1,16 +1,12 @@ package sv import ( - "encoding/json" "errors" "fmt" "strings" "time" "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" ) @@ -18,25 +14,8 @@ const ( defaultPage = 1 defaultPageSize = 20 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) { if page <= 0 { page = defaultPage @@ -50,15 +29,6 @@ func normalizePage(page int, pageSize int) (int, int) { 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 { if value.IsZero() { return "" @@ -75,6 +45,9 @@ func formatTimePtr(value *time.Time) *string { } func formatPriceText(currency string, amountCent int64) string { + if amountCent == 0 { + return "免费" + } if strings.EqualFold(strings.TrimSpace(currency), "CNY") { return fmt.Sprintf("¥%.2f", float64(amountCent)/100) } @@ -89,120 +62,6 @@ func stringPtrFromNonEmpty(value string) *string { 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 { if err == nil { return false @@ -226,8 +85,6 @@ func errorsIsRecordNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } -// tokenStoreBadRequestStatus 是 token-store P0 统一业务校验错误码。 -// 具体错误原因仍放在 Info,避免为每个商品/订单校验分支提前扩散大量细分码。 const tokenStoreBadRequestStatus = "40067" func tokenStoreBadRequest(message string) respond.Response { diff --git a/backend/services/tokenstore/sv/order.go b/backend/services/tokenstore/sv/order.go deleted file mode 100644 index fc4ae36..0000000 --- a/backend/services/tokenstore/sv/order.go +++ /dev/null @@ -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 -} diff --git a/backend/services/tokenstore/sv/outbox.go b/backend/services/tokenstore/sv/outbox.go index 5261e53..44521e2 100644 --- a/backend/services/tokenstore/sv/outbox.go +++ b/backend/services/tokenstore/sv/outbox.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" 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" @@ -110,11 +109,19 @@ func registerForumRewardHandler( 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, ReceiverUserID: payload.RewardReceiverUserID, Source: forumRewardSource(payload, source), - SourceRefID: forumRewardSourceRefID(payload, source), + SourceRefID: sourceRefID, }) if err != nil { return err @@ -124,10 +131,10 @@ func registerForumRewardHandler( } 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, eventID, - grant.GrantID, + transaction.TransactionID, envelope.OutboxID, ) return nil diff --git a/backend/services/tokenstore/sv/product.go b/backend/services/tokenstore/sv/product.go deleted file mode 100644 index 57d3532..0000000 --- a/backend/services/tokenstore/sv/product.go +++ /dev/null @@ -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 -} diff --git a/backend/services/tokenstore/sv/reward.go b/backend/services/tokenstore/sv/reward.go index 51654d6..97974a8 100644 --- a/backend/services/tokenstore/sv/reward.go +++ b/backend/services/tokenstore/sv/reward.go @@ -1,13 +1,10 @@ package sv import ( - "context" - "errors" "strconv" "strings" - tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" - tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" + storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model" "github.com/spf13/viper" ) @@ -32,85 +29,12 @@ type forumRewardDecision struct { Description string } -// RecordForumRewardGrant 负责把论坛点赞/导入奖励写入 token_grants。 -// -// 职责边界: -// 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) { +func normalizeForumRewardGrantRequest(req forumRewardGrantRequest) (forumRewardGrantRequest, error) { normalized := forumRewardGrantRequest{ EventID: strings.TrimSpace(req.EventID), ReceiverUserID: req.ReceiverUserID, Source: strings.ToLower(strings.TrimSpace(req.Source)), + SourceRefID: req.SourceRefID, } switch { @@ -118,16 +42,12 @@ func normalizeForumRewardGrantRequest(req tokencontracts.RecordForumRewardGrantR return forumRewardGrantRequest{}, tokenStoreBadRequest("event_id 不能为空") case normalized.ReceiverUserID == 0: 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 { - case tokenmodel.TokenGrantSourceForumLike, tokenmodel.TokenGrantSourceForumImport: + case storemodel.CreditLedgerSourceForumLike, storemodel.CreditLedgerSourceForumImport: return normalized, nil default: return forumRewardGrantRequest{}, tokenStoreBadRequest("source 仅支持 forum_like 或 forum_import") @@ -147,69 +67,10 @@ func parseForumRewardSourceRefID(raw string) (uint64, error) { 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 { return forumRewardDecision{ Amount: 0, - Status: tokenmodel.TokenGrantStatusSkipped, + Status: storemodel.CreditLedgerStatusSkipped, Description: strings.TrimSpace(description), } } @@ -224,9 +85,9 @@ func positiveConfigAmountOrDefault(configKey string, fallback int64) int64 { func forumRewardDescription(source string) string { switch strings.TrimSpace(source) { - case tokenmodel.TokenGrantSourceForumLike: + case storemodel.CreditLedgerSourceForumLike: return "计划被点赞奖励" - case tokenmodel.TokenGrantSourceForumImport: + case storemodel.CreditLedgerSourceForumImport: return "计划被导入奖励" default: return "论坛奖励入账" diff --git a/backend/services/tokenstore/sv/service.go b/backend/services/tokenstore/sv/service.go index 3dbcc44..727061e 100644 --- a/backend/services/tokenstore/sv/service.go +++ b/backend/services/tokenstore/sv/service.go @@ -1,54 +1,41 @@ package sv import ( - "context" "errors" tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao" - tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore" "gorm.io/gorm" ) -// ErrNotImplemented 表示 RPC 骨架已接线,但对应业务用例还在后续步骤实现。 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 服务的依赖注入参数。 type Options struct { DB *gorm.DB - GrantOutlet TokenGrantOutlet + CreditCache *tokenstoredao.CreditCacheDAO } -// Service 承载 Token 商店服务内部业务编排。 +// Service 承载 token-store 内部业务编排。 // // 职责边界: -// 1. 负责商品、订单、mock paid、grant 账本和奖励规则; -// 2. 不负责登录鉴权,也不直接修改 user/auth 权威额度; -// 3. 不负责真实第三方支付回调,P0 只处理 mock paid。 +// 1. 同时承载旧 Token 商店与新 Credit 权威账本两套能力,服务进程先并行存在; +// 2. Token 与 Credit 分别走各自 DAO,不在服务层混写数据表访问; +// 3. 真正的跨服务 HTTP/gateway 接线留给后续第三步,本层只暴露 RPC 可用能力。 type Service struct { db *gorm.DB - tokenDAO *tokenstoredao.TokenStoreDAO - grantOutlet TokenGrantOutlet + creditDAO *tokenstoredao.CreditStoreDAO + creditCache *tokenstoredao.CreditCacheDAO } func New(opts Options) *Service { return &Service{ db: opts.DB, - tokenDAO: tokenstoredao.NewTokenStoreDAO(opts.DB), - grantOutlet: opts.GrantOutlet, + creditDAO: tokenstoredao.NewCreditStoreDAO(opts.DB), + creditCache: opts.CreditCache, } } -// Ready 用于第二步骨架阶段的依赖检查。 +// Ready 用于服务依赖检查。 func (s *Service) Ready() error { if s == nil { return errors.New("tokenstore service is nil") diff --git a/backend/services/userauth/rpc/handler.go b/backend/services/userauth/rpc/handler.go index 425ec13..d41ed83 100644 --- a/backend/services/userauth/rpc/handler.go +++ b/backend/services/userauth/rpc/handler.go @@ -9,6 +9,8 @@ import ( userauthsv "github.com/LoveLosita/smartflow/backend/services/userauth/sv" contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" "github.com/LoveLosita/smartflow/backend/shared/respond" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) 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) { - if h == nil || h.svc == nil { - return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) - } - 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 + _ = ctx + _ = req + return nil, status.Error(codes.Unimplemented, "legacy token quota API has been removed") } func (h *Handler) AdjustTokenUsage(ctx context.Context, req *pb.AdjustTokenUsageRequest) (*pb.CheckTokenQuotaResponse, error) { - if h == nil || h.svc == nil { - return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) - } - 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 + _ = ctx + _ = req + return nil, status.Error(codes.Unimplemented, "legacy token usage adjust API has been removed") } func timeToUnixNano(value time.Time) int64 { diff --git a/backend/services/userauth/sv/quota.go b/backend/services/userauth/sv/quota.go deleted file mode 100644 index 60f46f5..0000000 --- a/backend/services/userauth/sv/quota.go +++ /dev/null @@ -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 -} diff --git a/backend/services/userauth/sv/service.go b/backend/services/userauth/sv/service.go index 2f30bb1..1522073 100644 --- a/backend/services/userauth/sv/service.go +++ b/backend/services/userauth/sv/service.go @@ -6,7 +6,6 @@ import ( "strings" "time" - userauthdao "github.com/LoveLosita/smartflow/backend/services/userauth/dao" userauthauth "github.com/LoveLosita/smartflow/backend/services/userauth/internal/auth" userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model" contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" @@ -19,10 +18,6 @@ type UserRepo interface { IfUsernameExists(ctx context.Context, name string) (bool, error) GetUserHashedPasswordByName(ctx context.Context, name string) (string, 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 { @@ -31,20 +26,14 @@ type CacheRepo interface { SetBlacklistIfAbsent(jti string, expiration time.Duration) (bool, error) IsSessionBlacklisted(sessionID string) (bool, 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 服务内部业务规则。 // // 职责边界: -// 1. 负责注册、登录、刷新、登出、JWT 签发/校验、黑名单和 token 额度门禁; +// 1. 负责注册、登录、刷新、登出、JWT 签发/校验和黑名单; // 2. 不负责 Gin gateway 的响应适配、路由聚合和 SSE 等边缘职责; -// 3. 不负责 agent 会话 token 统计,迁移期该链路仍由 agent 持久化事件触发 userauth 账本调整。 +// 3. 旧 token 额度门禁与记账能力已下线,不再由 userauth 承担计费相关职责。 type Service struct { userRepo UserRepo cacheRepo CacheRepo diff --git a/backend/shared/contracts/course/types.go b/backend/shared/contracts/course/types.go index fbe9bac..71b7740 100644 --- a/backend/shared/contracts/course/types.go +++ b/backend/shared/contracts/course/types.go @@ -37,6 +37,7 @@ type ImportCoursesResult struct { } type CourseImageParseRequest struct { + UserID int `json:"user_id"` Filename string `json:"filename"` MIMEType string `json:"mime_type"` ImageBytes []byte `json:"image_bytes"` diff --git a/backend/shared/contracts/creditstore/types.go b/backend/shared/contracts/creditstore/types.go new file mode 100644 index 0000000..9a430e2 --- /dev/null +++ b/backend/shared/contracts/creditstore/types.go @@ -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"` +} diff --git a/backend/shared/contracts/llm/contracts.go b/backend/shared/contracts/llm/contracts.go new file mode 100644 index 0000000..52fb149 --- /dev/null +++ b/backend/shared/contracts/llm/contracts.go @@ -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 +} diff --git a/backend/shared/contracts/tokenstore/types.go b/backend/shared/contracts/tokenstore/types.go deleted file mode 100644 index 576ce81..0000000 --- a/backend/shared/contracts/tokenstore/types.go +++ /dev/null @@ -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"` -} diff --git a/backend/shared/contracts/userauth/types.go b/backend/shared/contracts/userauth/types.go index bb323ed..65080ab 100644 --- a/backend/shared/contracts/userauth/types.go +++ b/backend/shared/contracts/userauth/types.go @@ -46,23 +46,3 @@ type ValidateAccessTokenResponse struct { JTI string `json:"jti"` 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"` -} diff --git a/backend/shared/events/credit.go b/backend/shared/events/credit.go new file mode 100644 index 0000000..6dfc0c9 --- /dev/null +++ b/backend/shared/events/credit.go @@ -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 +} diff --git a/backend/shared/infra/outbox/service_catalog.go b/backend/shared/infra/outbox/service_catalog.go index bf715e9..493e5e9 100644 --- a/backend/shared/infra/outbox/service_catalog.go +++ b/backend/shared/infra/outbox/service_catalog.go @@ -16,6 +16,7 @@ const ( ServiceActiveScheduler = "active-scheduler" ServiceNotification = "notification" ServiceTaskClassForum = "taskclass-forum" + ServiceLLM = "llm" ServiceTokenStore = "token-store" ) @@ -91,6 +92,12 @@ func LoadServiceConfigs() map[string]ServiceConfig { GroupID: "smartflow-taskclass-forum-outbox-consumer", TableName: "taskclass_forum_outbox_messages", }, + ServiceLLM: { + Name: ServiceLLM, + Topic: "smartflow.llm.outbox", + GroupID: "smartflow-llm-outbox-consumer", + TableName: "llm_outbox_messages", + }, ServiceTokenStore: { Name: ServiceTokenStore, Topic: "smartflow.token-store.outbox", diff --git a/backend/shared/infra/outbox/service_route.go b/backend/shared/infra/outbox/service_route.go index 66ed937..df25b9c 100644 --- a/backend/shared/infra/outbox/service_route.go +++ b/backend/shared/infra/outbox/service_route.go @@ -11,6 +11,7 @@ const ( ServiceNameActiveScheduler = "active-scheduler" ServiceNameNotification = "notification" ServiceNameTaskClassForum = "taskclass-forum" + ServiceNameLLM = "llm" ServiceNameTokenStore = "token-store" ) @@ -64,6 +65,12 @@ var builtinServiceRoutes = map[string]ServiceRoute{ Topic: "smartflow.taskclass-forum.outbox", GroupID: "smartflow-taskclass-forum-outbox-consumer", }, + ServiceNameLLM: { + ServiceName: ServiceNameLLM, + TableName: "llm_outbox_messages", + Topic: "smartflow.llm.outbox", + GroupID: "smartflow-llm-outbox-consumer", + }, ServiceNameTokenStore: { ServiceName: ServiceNameTokenStore, TableName: "token_store_outbox_messages", @@ -86,6 +93,7 @@ func DefaultServiceRoutes() []ServiceRoute { builtinServiceRoutes[ServiceNameActiveScheduler], builtinServiceRoutes[ServiceNameNotification], builtinServiceRoutes[ServiceNameTaskClassForum], + builtinServiceRoutes[ServiceNameLLM], builtinServiceRoutes[ServiceNameTokenStore], } } diff --git a/backend/shared/ports/userauth.go b/backend/shared/ports/userauth.go index ed42ffb..3b7e473 100644 --- a/backend/shared/ports/userauth.go +++ b/backend/shared/ports/userauth.go @@ -24,23 +24,9 @@ type AccessTokenValidator interface { 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 能力。 // 职责边界:作为统一装配口径,避免 gateway 和 core service 各自维护一份接口。 type UserAuthClient interface { UserCommandClient AccessTokenValidator - TokenQuotaChecker - TokenUsageAdjuster } diff --git a/docker-compose.yml b/docker-compose.yml index 6fe49ce..20d86c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,15 +71,29 @@ services: depends_on: kafka: condition: service_healthy - entrypoint: ["/bin/bash", "-c"] - command: > - /opt/kafka/bin/kafka-topics.sh - --bootstrap-server kafka:9094 - --create - --if-not-exists - --topic smartflow.agent.outbox - --partitions 3 - --replication-factor 1 + entrypoint: + - /bin/bash + - -lc + - | + set -e + for topic in \ + 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 + 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" etcd: diff --git a/docs/backend/统一出口Credit计费最终计划.md b/docs/backend/统一出口Credit计费最终计划.md new file mode 100644 index 0000000..53e9d4b --- /dev/null +++ b/docs/backend/统一出口Credit计费最终计划.md @@ -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 流水列表组件 diff --git a/frontend/src/api/forum.ts b/frontend/src/api/forum.ts new file mode 100644 index 0000000..afd4b41 --- /dev/null +++ b/frontend/src/api/forum.ts @@ -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>>('/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>('/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>('/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>(`/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>(`/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>(`/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>>(`/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>(`/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>(`/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>( + `/plan-square/posts/${postId}/import`, + { target_title: targetTitle }, + { + headers: { + 'X-Idempotency-Key': idempotencyKey, + }, + }, + ) + return response.data.data + } catch (error) { + throw new Error(extractErrorMessage(error, '导入计划失败,请稍后重试')) + } +} diff --git a/frontend/src/types/forum.ts b/frontend/src/types/forum.ts new file mode 100644 index 0000000..1b190d9 --- /dev/null +++ b/frontend/src/types/forum.ts @@ -0,0 +1,131 @@ +export interface ForumUserBrief { + user_id: number + nickname: string + avatar_url: string +} + +export interface ForumTemplateSummary { + task_count: number + mode: string + start_date: string + end_date: string + strategy_labels: string[] +} + +export interface ForumPostCounters { + like_count: number + comment_count: number + import_count: number +} + +export interface ForumPostViewerState { + liked: boolean + imported_once: boolean +} + +export interface ForumPostBrief { + post_id: number + title: string + summary: string + tags: string[] + author: ForumUserBrief + template_summary: ForumTemplateSummary + counters: ForumPostCounters + viewer_state: ForumPostViewerState + status: string + created_at: string +} + +export interface ForumTemplateItemPreview { + item_id: number + order: number + content: string +} + +export interface ForumTemplateDetail { + mode: string + start_date: string + end_date: string + strategy_labels: string[] + task_count: number + items_preview: ForumTemplateItemPreview[] +} + +export interface ForumPostDetail { + post: ForumPostBrief + template: ForumTemplateDetail +} + +export interface ForumCommentNode { + comment_id: number + post_id: number + parent_comment_id: number | null + content: string + status: string + author: ForumUserBrief + can_delete: boolean + created_at: string + deleted_at: string | null + children: ForumCommentNode[] +} + +export interface ForumTagItem { + tag: string + post_count: number +} + +export interface ForumPageEnvelope { + items: T[] + page: number + page_size: number + total: number + has_more: boolean +} + +export interface ForumInteractionResult { + post_id: number + liked: boolean + like_count: number +} + +export interface ForumImportResult { + import_id: number + post_id: number + new_task_class_id: number + task_class_title: string + import_count: number + created_at: string +} + +export interface ForumDeleteCommentResult { + comment_id: number + status: string + content: string + deleted_at: string | null +} + +export interface ForumPostListQuery { + page?: number + page_size?: number + sort?: string + keyword?: string + tag?: string +} + +export interface ForumCommentListQuery { + page?: number + page_size?: number + sort?: string +} + +export interface CreateForumPostPayload { + task_class_id: number + title: string + summary: string + tags: string[] +} + +export interface CreateForumCommentPayload { + content: string + parent_comment_id?: number | null +} diff --git a/frontend/src/views/ForumView.vue b/frontend/src/views/ForumView.vue index 60688c6..d4abb58 100644 --- a/frontend/src/views/ForumView.vue +++ b/frontend/src/views/ForumView.vue @@ -1,306 +1,261 @@ - -