Version: 0.9.80.dev.260506
后端: 1. LLM 独立服务与统一计费出口落地:新增 `cmd/llm`、`client/llm` 与 `services/llm/rpc`,补齐 BillingContext、CreditBalanceGuard、价格规则解析、stream usage 归集与 `credit.charge.requested` outbox 发布,active-scheduler / agent / course / memory / gateway fallback 全部改走 llm zrpc,不再各自本地初始化模型。 2. TokenStore 收口为 Credit 权威账本:新增 credit account / ledger / product / order / price-rule / reward-rule 能力与 Redis 快照缓存,扩展 tokenstore rpc/client 支撑余额快照、消耗看板、商品、订单、流水、价格规则和奖励规则,并接入 LLM charge 事件消费完成 Credit 扣费落账。 3. 计费旧链路下线与网关切口切换:`/token-store` 语义整体切到 `/credit-store`,agent chat 移除旧 TokenQuotaGuard,userauth 的 CheckTokenQuota / AdjustTokenUsage 改为废弃,聊天历史落库不再同步旧 token 额度账本,course 图片解析请求补 user_id 进入新计费口径。 前端: 4. 计划广场从 mock 数据切到真实接口:新增 forum api/types,首页支持真实列表、标签、搜索、防抖、点赞、导入和发布计划,详情页补齐帖子详情、评论树、回复和删除评论链路,同时补上“至少一个标签”的前后端约束与默认标签兜底。 5. 商店页切到 Credit 体系并重做展示:顶部改为余额 + Credit/Token 消耗看板,支持 24h/7d/30d/all 周期切换;套餐区展示原价与当前价;历史区改为当前用户 Credit 流水并支持查看更多,整体视觉和交互同步收口。 仓库: 6. 配置与本地启动体系补齐 llm / outbox 编排:`config.example.yaml` 增加 llm rpc 和统一 outbox service 配置,`dev-common.ps1` 把 llm 纳入多服务依赖并自动建 Kafka topic,`docker-compose.yml` 同步初始化 agent/task/memory/active-scheduler/notification/taskclass-forum/llm/token-store 全量 outbox topic。
This commit is contained in:
@@ -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,
|
||||
|
||||
301
backend/client/llm/client.go
Normal file
301
backend/client/llm/client.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
llmrpc "github.com/LoveLosita/smartflow/backend/services/llm/rpc"
|
||||
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEndpoint = "127.0.0.1:9096"
|
||||
defaultTimeout = 0
|
||||
defaultPingTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
Endpoints []string
|
||||
Target string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type ServiceConfig struct {
|
||||
ClientConfig
|
||||
CourseVisionModel string
|
||||
}
|
||||
|
||||
// Client 是业务进程访问独立 LLM 服务的最小 RPC 适配层。
|
||||
type Client struct {
|
||||
rpc llmrpc.LLMClient
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) (*Client, error) {
|
||||
timeout := cfg.Timeout
|
||||
if timeout < 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
endpoints := normalizeEndpoints(cfg.Endpoints)
|
||||
target := strings.TrimSpace(cfg.Target)
|
||||
if len(endpoints) == 0 && target == "" {
|
||||
endpoints = []string{defaultEndpoint}
|
||||
}
|
||||
|
||||
zclient, err := zrpc.NewClient(zrpc.RpcClientConf{
|
||||
Endpoints: endpoints,
|
||||
Target: target,
|
||||
NonBlock: true,
|
||||
Timeout: int64(timeout / time.Millisecond),
|
||||
}, zrpc.WithDialOption(llmrpc.JSONCodecDialOption()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &Client{rpc: llmrpc.NewLLMClient(zclient.Conn())}
|
||||
if err = client.ping(resolvePingTimeout(timeout)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// NewService 一次性把远端 LLM RPC 包装回旧的 *llmservice.Service 门面。
|
||||
func NewService(cfg ServiceConfig) (*llmservice.Service, error) {
|
||||
client, err := NewClient(cfg.ClientConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.BuildService(cfg.CourseVisionModel), nil
|
||||
}
|
||||
|
||||
func (c *Client) BuildService(courseVisionModel string) *llmservice.Service {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return llmservice.NewWithClients(llmservice.StaticClients{
|
||||
Lite: buildTextClient(c, llmcontracts.ModelAliasLite),
|
||||
Pro: buildTextClient(c, llmcontracts.ModelAliasPro),
|
||||
Max: buildTextClient(c, llmcontracts.ModelAliasMax),
|
||||
CourseImageResponses: llmservice.NewArkResponsesClientWithFunc(courseVisionModel, func(ctx context.Context, messages []llmservice.ArkResponsesMessage, options llmservice.ArkResponsesOptions) (*llmservice.ArkResponsesResult, error) {
|
||||
return c.GenerateResponsesText(ctx, llmcontracts.ModelAliasCourseImageResponses, messages, options)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.rpc.Ping(ctx, &llmcontracts.PingRequest{})
|
||||
return responseFromRPCError(err)
|
||||
}
|
||||
|
||||
func (c *Client) GenerateText(ctx context.Context, modelAlias string, messages []*schema.Message, options llmservice.GenerateOptions) (*llmservice.TextResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.rpc.GenerateText(ctx, &llmcontracts.TextRequest{
|
||||
ModelAlias: modelAlias,
|
||||
Messages: messages,
|
||||
Options: toContractGenerateOptions(options),
|
||||
Billing: billingFromContext(ctx, modelAlias),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil || resp.Result == nil {
|
||||
return nil, errors.New("llm zrpc service returned empty text response")
|
||||
}
|
||||
return &llmservice.TextResult{
|
||||
Text: resp.Result.Text,
|
||||
Usage: llmservice.CloneUsage(resp.Result.Usage),
|
||||
FinishReason: resp.Result.FinishReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) StreamText(ctx context.Context, modelAlias string, messages []*schema.Message, options llmservice.GenerateOptions) (llmservice.StreamReader, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stream, err := c.rpc.StreamText(ctx, &llmcontracts.StreamTextRequest{
|
||||
ModelAlias: modelAlias,
|
||||
Messages: messages,
|
||||
Options: toContractGenerateOptions(options),
|
||||
Billing: billingFromContext(ctx, modelAlias),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
return &streamReader{stream: stream}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GenerateResponsesText(ctx context.Context, modelAlias string, messages []llmservice.ArkResponsesMessage, options llmservice.ArkResponsesOptions) (*llmservice.ArkResponsesResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.rpc.GenerateResponsesText(ctx, &llmcontracts.ResponsesRequest{
|
||||
ModelAlias: modelAlias,
|
||||
Messages: toContractResponsesMessages(messages),
|
||||
Options: toContractResponsesOptions(options),
|
||||
Billing: billingFromContext(ctx, modelAlias),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil || resp.Result == nil {
|
||||
return nil, errors.New("llm zrpc service returned empty responses response")
|
||||
}
|
||||
return toServiceResponsesResult(resp.Result), nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureReady() error {
|
||||
if c == nil || c.rpc == nil {
|
||||
return errors.New("llm zrpc client is not initialized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ping(timeout time.Duration) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return c.Ping(ctx)
|
||||
}
|
||||
|
||||
type streamReader struct {
|
||||
stream llmrpc.LLM_StreamTextClient
|
||||
}
|
||||
|
||||
func (r *streamReader) Recv() (*schema.Message, error) {
|
||||
if r == nil || r.stream == nil {
|
||||
return nil, errors.New("llm zrpc stream is not initialized")
|
||||
}
|
||||
|
||||
chunk, err := r.stream.Recv()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil, io.EOF
|
||||
}
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if chunk == nil {
|
||||
return nil, errors.New("llm zrpc service returned empty stream chunk")
|
||||
}
|
||||
return chunk.Message, nil
|
||||
}
|
||||
|
||||
func (r *streamReader) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildTextClient(remote *Client, modelAlias string) *llmservice.Client {
|
||||
return llmservice.NewClient(
|
||||
func(ctx context.Context, messages []*schema.Message, options llmservice.GenerateOptions) (*llmservice.TextResult, error) {
|
||||
return remote.GenerateText(ctx, modelAlias, messages, options)
|
||||
},
|
||||
func(ctx context.Context, messages []*schema.Message, options llmservice.GenerateOptions) (llmservice.StreamReader, error) {
|
||||
return remote.StreamText(ctx, modelAlias, messages, options)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func billingFromContext(ctx context.Context, modelAlias string) *llmcontracts.BillingContext {
|
||||
billing, ok := llmservice.BillingContextFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(billing.ModelAlias) == "" {
|
||||
billing.ModelAlias = strings.TrimSpace(modelAlias)
|
||||
}
|
||||
return &llmcontracts.BillingContext{
|
||||
UserID: billing.UserID,
|
||||
EventID: billing.EventID,
|
||||
Scene: billing.Scene,
|
||||
RequestID: billing.RequestID,
|
||||
ConversationID: billing.ConversationID,
|
||||
ModelAlias: billing.ModelAlias,
|
||||
SkipCharge: billing.SkipCharge,
|
||||
}
|
||||
}
|
||||
|
||||
func toContractGenerateOptions(input llmservice.GenerateOptions) llmcontracts.GenerateOptions {
|
||||
return llmcontracts.GenerateOptions{
|
||||
Temperature: input.Temperature,
|
||||
MaxTokens: input.MaxTokens,
|
||||
Thinking: string(input.Thinking),
|
||||
Metadata: input.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
func toContractResponsesMessages(input []llmservice.ArkResponsesMessage) []llmcontracts.ResponsesMessage {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
output := make([]llmcontracts.ResponsesMessage, 0, len(input))
|
||||
for _, item := range input {
|
||||
output = append(output, llmcontracts.ResponsesMessage{
|
||||
Role: item.Role,
|
||||
Text: item.Text,
|
||||
ImageURL: item.ImageURL,
|
||||
ImageDetail: item.ImageDetail,
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func toContractResponsesOptions(input llmservice.ArkResponsesOptions) llmcontracts.ResponsesOptions {
|
||||
return llmcontracts.ResponsesOptions{
|
||||
Model: input.Model,
|
||||
Temperature: input.Temperature,
|
||||
MaxOutputTokens: input.MaxOutputTokens,
|
||||
Thinking: string(input.Thinking),
|
||||
TextFormat: input.TextFormat,
|
||||
}
|
||||
}
|
||||
|
||||
func toServiceResponsesResult(result *llmcontracts.ResponsesResult) *llmservice.ArkResponsesResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
output := &llmservice.ArkResponsesResult{
|
||||
Text: result.Text,
|
||||
Status: result.Status,
|
||||
IncompleteReason: result.IncompleteReason,
|
||||
ErrorCode: result.ErrorCode,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
output.Usage = &llmservice.ArkResponsesUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func normalizeEndpoints(values []string) []string {
|
||||
endpoints := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
endpoints = append(endpoints, trimmed)
|
||||
}
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func resolvePingTimeout(timeout time.Duration) time.Duration {
|
||||
if timeout > 0 && timeout < defaultPingTimeout {
|
||||
return timeout
|
||||
}
|
||||
return defaultPingTimeout
|
||||
}
|
||||
73
backend/client/llm/errors.go
Normal file
73
backend/client/llm/errors.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func responseFromRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return wrapRPCError(err)
|
||||
}
|
||||
if message, ok := messageFromStatus(st); ok {
|
||||
switch st.Code() {
|
||||
case codes.InvalidArgument, codes.ResourceExhausted, codes.FailedPrecondition:
|
||||
return errors.New(message)
|
||||
}
|
||||
}
|
||||
|
||||
switch st.Code() {
|
||||
case codes.InvalidArgument, codes.ResourceExhausted, codes.FailedPrecondition:
|
||||
return errors.New(strings.TrimSpace(st.Message()))
|
||||
case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented:
|
||||
msg := strings.TrimSpace(st.Message())
|
||||
if msg == "" {
|
||||
msg = "llm zrpc service internal error"
|
||||
}
|
||||
return wrapRPCError(errors.New(msg))
|
||||
default:
|
||||
msg := strings.TrimSpace(st.Message())
|
||||
if msg == "" {
|
||||
msg = "llm zrpc service rejected request"
|
||||
}
|
||||
return errors.New(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func messageFromStatus(st *status.Status) (string, bool) {
|
||||
if st == nil {
|
||||
return "", false
|
||||
}
|
||||
for _, detail := range st.Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
message := strings.TrimSpace(st.Message())
|
||||
if message == "" && info.Metadata != nil {
|
||||
message = strings.TrimSpace(info.Metadata["info"])
|
||||
}
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(info.Reason)
|
||||
}
|
||||
return message, message != ""
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func wrapRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("调用 llm zrpc 服务失败: %w", err)
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
383
backend/client/tokenstore/credit.go
Normal file
383
backend/client/tokenstore/credit.go
Normal file
@@ -0,0 +1,383 @@
|
||||
package tokenstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
)
|
||||
|
||||
func (c *Client) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.GetCreditBalanceSnapshot(ctx, &pb.GetCreditBalanceSnapshotRequest{UserId: userID})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty get credit balance snapshot response")
|
||||
}
|
||||
snapshot := creditBalanceSnapshotFromPB(resp.Snapshot)
|
||||
return &snapshot, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetCreditConsumptionDashboard(ctx context.Context, req creditcontracts.GetCreditConsumptionDashboardRequest) (*creditcontracts.CreditConsumptionDashboardView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.GetCreditConsumptionDashboard(ctx, &pb.GetCreditConsumptionDashboardRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
Period: req.Period,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty get credit consumption dashboard response")
|
||||
}
|
||||
dashboard := creditConsumptionDashboardFromPB(resp.Dashboard)
|
||||
return &dashboard, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListCreditProducts(ctx context.Context, actorUserID uint64) ([]creditcontracts.CreditProductView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.ListCreditProducts(ctx, &pb.ListCreditProductsRequest{ActorUserId: actorUserID})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty list credit products response")
|
||||
}
|
||||
return creditProductsFromPB(resp.Items), nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateCreditOrder(ctx context.Context, req creditcontracts.CreateCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.CreateCreditOrder(ctx, &pb.CreateCreditOrderRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
ProductId: req.ProductID,
|
||||
Quantity: int32(req.Quantity),
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty create credit order response")
|
||||
}
|
||||
order := creditOrderFromPB(resp.Order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListCreditOrders(ctx context.Context, req creditcontracts.ListCreditOrdersRequest) ([]creditcontracts.CreditOrderView, creditcontracts.PageResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, creditcontracts.PageResult{}, err
|
||||
}
|
||||
resp, err := c.rpc.ListCreditOrders(ctx, &pb.ListCreditOrdersRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
Page: int32(req.Page),
|
||||
PageSize: int32(req.PageSize),
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, creditcontracts.PageResult{}, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, creditcontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list credit orders response")
|
||||
}
|
||||
return creditOrdersFromPB(resp.Items), creditPageFromPB(resp.Page), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetCreditOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.GetCreditOrder(ctx, &pb.GetCreditOrderRequest{
|
||||
ActorUserId: actorUserID,
|
||||
OrderId: orderID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty get credit order response")
|
||||
}
|
||||
order := creditOrderFromPB(resp.Order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (c *Client) MockPaidCreditOrder(ctx context.Context, req creditcontracts.MockPaidCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.MockPaidCreditOrder(ctx, &pb.MockPaidCreditOrderRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
OrderId: req.OrderID,
|
||||
MockChannel: req.MockChannel,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty mock paid credit order response")
|
||||
}
|
||||
order := creditOrderFromPB(resp.Order)
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListCreditTransactions(ctx context.Context, req creditcontracts.ListCreditTransactionsRequest) ([]creditcontracts.CreditTransactionView, creditcontracts.PageResult, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, creditcontracts.PageResult{}, err
|
||||
}
|
||||
resp, err := c.rpc.ListCreditTransactions(ctx, &pb.ListCreditTransactionsRequest{
|
||||
ActorUserId: req.ActorUserID,
|
||||
Page: int32(req.Page),
|
||||
PageSize: int32(req.PageSize),
|
||||
Source: req.Source,
|
||||
Direction: req.Direction,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, creditcontracts.PageResult{}, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, creditcontracts.PageResult{}, errors.New("tokenstore zrpc service returned empty list credit transactions response")
|
||||
}
|
||||
return creditTransactionsFromPB(resp.Items), creditPageFromPB(resp.Page), nil
|
||||
}
|
||||
|
||||
func (c *Client) ListCreditPriceRules(ctx context.Context, req creditcontracts.ListCreditPriceRulesRequest) ([]creditcontracts.CreditPriceRuleView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.ListCreditPriceRules(ctx, &pb.ListCreditPriceRulesRequest{
|
||||
Scene: req.Scene,
|
||||
ProviderName: req.ProviderName,
|
||||
ModelName: req.ModelName,
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty list credit price rules response")
|
||||
}
|
||||
return creditPriceRulesFromPB(resp.Items), nil
|
||||
}
|
||||
|
||||
func (c *Client) ListCreditRewardRules(ctx context.Context, req creditcontracts.ListCreditRewardRulesRequest) ([]creditcontracts.CreditRewardRuleView, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.ListCreditRewardRules(ctx, &pb.ListCreditRewardRulesRequest{
|
||||
Source: req.Source,
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("tokenstore zrpc service returned empty list credit reward rules response")
|
||||
}
|
||||
return creditRewardRulesFromPB(resp.Items), nil
|
||||
}
|
||||
|
||||
func creditBalanceSnapshotFromPB(snapshot *pb.CreditBalanceSnapshotView) creditcontracts.CreditBalanceSnapshot {
|
||||
if snapshot == nil {
|
||||
return creditcontracts.CreditBalanceSnapshot{}
|
||||
}
|
||||
return creditcontracts.CreditBalanceSnapshot{
|
||||
UserID: snapshot.UserId,
|
||||
Balance: snapshot.Balance,
|
||||
TotalRecharged: snapshot.TotalRecharged,
|
||||
TotalRewarded: snapshot.TotalRewarded,
|
||||
TotalConsumed: snapshot.TotalConsumed,
|
||||
IsBlocked: snapshot.IsBlocked,
|
||||
SnapshotSource: snapshot.SnapshotSource,
|
||||
UpdatedAt: snapshot.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func creditConsumptionDashboardFromPB(view *pb.CreditConsumptionDashboardView) creditcontracts.CreditConsumptionDashboardView {
|
||||
if view == nil {
|
||||
return creditcontracts.CreditConsumptionDashboardView{}
|
||||
}
|
||||
return creditcontracts.CreditConsumptionDashboardView{
|
||||
Period: view.Period,
|
||||
CreditConsumed: view.CreditConsumed,
|
||||
TokenConsumed: view.TokenConsumed,
|
||||
}
|
||||
}
|
||||
|
||||
func creditProductFromPB(product *pb.CreditProductView) creditcontracts.CreditProductView {
|
||||
if product == nil {
|
||||
return creditcontracts.CreditProductView{}
|
||||
}
|
||||
return creditcontracts.CreditProductView{
|
||||
ProductID: product.ProductId,
|
||||
Name: product.Name,
|
||||
Description: product.Description,
|
||||
CreditAmount: product.CreditAmount,
|
||||
PriceCent: product.PriceCent,
|
||||
OriginalPriceCent: product.OriginalPriceCent,
|
||||
PriceText: product.PriceText,
|
||||
Currency: product.Currency,
|
||||
Badge: product.Badge,
|
||||
Status: product.Status,
|
||||
SortOrder: int(product.SortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
func creditProductsFromPB(items []*pb.CreditProductView) []creditcontracts.CreditProductView {
|
||||
if len(items) == 0 {
|
||||
return []creditcontracts.CreditProductView{}
|
||||
}
|
||||
result := make([]creditcontracts.CreditProductView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, creditProductFromPB(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditOrderFromPB(order *pb.CreditOrderView) creditcontracts.CreditOrderView {
|
||||
if order == nil {
|
||||
return creditcontracts.CreditOrderView{}
|
||||
}
|
||||
return creditcontracts.CreditOrderView{
|
||||
OrderID: order.OrderId,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
ProductSnapshot: order.ProductSnapshot,
|
||||
ProductName: order.ProductName,
|
||||
Quantity: int(order.Quantity),
|
||||
CreditAmount: order.CreditAmount,
|
||||
AmountCent: order.AmountCent,
|
||||
PriceText: order.PriceText,
|
||||
Currency: order.Currency,
|
||||
PaymentMode: order.PaymentMode,
|
||||
CreatedAt: order.CreatedAt,
|
||||
PaidAt: stringPtrFromNonEmpty(order.PaidAt),
|
||||
CreditedAt: stringPtrFromNonEmpty(order.CreditedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func creditOrdersFromPB(items []*pb.CreditOrderView) []creditcontracts.CreditOrderView {
|
||||
if len(items) == 0 {
|
||||
return []creditcontracts.CreditOrderView{}
|
||||
}
|
||||
result := make([]creditcontracts.CreditOrderView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, creditOrderFromPB(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditTransactionFromPB(item *pb.CreditTransactionView) creditcontracts.CreditTransactionView {
|
||||
if item == nil {
|
||||
return creditcontracts.CreditTransactionView{}
|
||||
}
|
||||
var orderID *uint64
|
||||
if item.OrderId > 0 {
|
||||
value := item.OrderId
|
||||
orderID = &value
|
||||
}
|
||||
return creditcontracts.CreditTransactionView{
|
||||
TransactionID: item.TransactionId,
|
||||
EventID: item.EventId,
|
||||
Source: item.Source,
|
||||
SourceLabel: item.SourceLabel,
|
||||
Direction: item.Direction,
|
||||
Amount: item.Amount,
|
||||
BalanceAfter: item.BalanceAfter,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
MetadataJSON: item.MetadataJson,
|
||||
CreatedAt: item.CreatedAt,
|
||||
OrderID: orderID,
|
||||
}
|
||||
}
|
||||
|
||||
func creditTransactionsFromPB(items []*pb.CreditTransactionView) []creditcontracts.CreditTransactionView {
|
||||
if len(items) == 0 {
|
||||
return []creditcontracts.CreditTransactionView{}
|
||||
}
|
||||
result := make([]creditcontracts.CreditTransactionView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, creditTransactionFromPB(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditPriceRuleFromPB(item *pb.CreditPriceRuleView) creditcontracts.CreditPriceRuleView {
|
||||
if item == nil {
|
||||
return creditcontracts.CreditPriceRuleView{}
|
||||
}
|
||||
return creditcontracts.CreditPriceRuleView{
|
||||
RuleID: item.RuleId,
|
||||
Scene: item.Scene,
|
||||
ProviderName: item.ProviderName,
|
||||
ModelName: item.ModelName,
|
||||
InputPriceMicros: item.InputPriceMicros,
|
||||
OutputPriceMicros: item.OutputPriceMicros,
|
||||
CachedPriceMicros: item.CachedPriceMicros,
|
||||
ReasoningPriceMicros: item.ReasoningPriceMicros,
|
||||
CreditPerYuan: item.CreditPerYuan,
|
||||
Status: item.Status,
|
||||
Priority: int(item.Priority),
|
||||
Description: item.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func creditPriceRulesFromPB(items []*pb.CreditPriceRuleView) []creditcontracts.CreditPriceRuleView {
|
||||
if len(items) == 0 {
|
||||
return []creditcontracts.CreditPriceRuleView{}
|
||||
}
|
||||
result := make([]creditcontracts.CreditPriceRuleView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, creditPriceRuleFromPB(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditRewardRuleFromPB(item *pb.CreditRewardRuleView) creditcontracts.CreditRewardRuleView {
|
||||
if item == nil {
|
||||
return creditcontracts.CreditRewardRuleView{}
|
||||
}
|
||||
return creditcontracts.CreditRewardRuleView{
|
||||
RuleID: item.RuleId,
|
||||
Source: item.Source,
|
||||
Name: item.Name,
|
||||
Amount: item.Amount,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func creditRewardRulesFromPB(items []*pb.CreditRewardRuleView) []creditcontracts.CreditRewardRuleView {
|
||||
if len(items) == 0 {
|
||||
return []creditcontracts.CreditRewardRuleView{}
|
||||
}
|
||||
result := make([]creditcontracts.CreditRewardRuleView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, creditRewardRuleFromPB(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditPageFromPB(page *pb.PageResponse) creditcontracts.PageResult {
|
||||
if page == nil {
|
||||
return creditcontracts.PageResult{}
|
||||
}
|
||||
return creditcontracts.PageResult{
|
||||
Page: int(page.Page),
|
||||
PageSize: int(page.PageSize),
|
||||
Total: int(page.Total),
|
||||
HasMore: page.HasMore,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
157
backend/cmd/llm/main.go
Normal file
157
backend/cmd/llm/main.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
tokenstoreclient "github.com/LoveLosita/smartflow/backend/client/tokenstore"
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao"
|
||||
llmrpc "github.com/LoveLosita/smartflow/backend/services/llm/rpc"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/infra/bootstrap"
|
||||
einoinfra "github.com/LoveLosita/smartflow/backend/shared/infra/eino"
|
||||
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||
redisinfra "github.com/LoveLosita/smartflow/backend/shared/infra/redis"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := bootstrap.LoadConfig(); err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := llmdao.OpenDBFromConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect llm database: %v", err)
|
||||
}
|
||||
|
||||
redisClient, err := redisinfra.OpenRedisFromConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect llm redis: %v", err)
|
||||
}
|
||||
defer redisClient.Close()
|
||||
|
||||
aiHub, err := einoinfra.InitEino()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize llm Eino runtime: %v", err)
|
||||
}
|
||||
|
||||
legacyService := llmservice.New(llmservice.Options{
|
||||
AIHub: aiHub,
|
||||
APIKey: os.Getenv("ARK_API_KEY"),
|
||||
BaseURL: viper.GetString("agent.baseURL"),
|
||||
CourseVisionModel: viper.GetString("courseImport.visionModel"),
|
||||
})
|
||||
|
||||
balanceSnapshotProvider := &tokenStoreSnapshotProvider{
|
||||
cfg: tokenstoreclient.ClientConfig{
|
||||
Endpoints: viper.GetStringSlice("tokenstore.rpc.endpoints"),
|
||||
Target: viper.GetString("tokenstore.rpc.target"),
|
||||
Timeout: viper.GetDuration("tokenstore.rpc.timeout"),
|
||||
},
|
||||
}
|
||||
|
||||
outboxRepo := outboxinfra.NewRepository(db)
|
||||
priceRuleDAO := llmdao.NewPriceRuleDAO(db)
|
||||
dispatchEngine, err := buildLLMOutboxDispatchEngine(outboxRepo)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize llm outbox dispatch engine: %v", err)
|
||||
}
|
||||
if dispatchEngine != nil {
|
||||
dispatchEngine.StartDispatch(ctx)
|
||||
defer dispatchEngine.Close()
|
||||
log.Println("llm outbox dispatch started")
|
||||
} else {
|
||||
log.Println("llm outbox dispatch is disabled")
|
||||
}
|
||||
|
||||
runtimeService, err := llmservice.NewRuntimeService(llmservice.RuntimeServiceOptions{
|
||||
LegacyService: legacyService,
|
||||
CacheDAO: llmdao.NewCacheDAO(redisClient),
|
||||
PriceRuleDAO: priceRuleDAO,
|
||||
SnapshotProvider: balanceSnapshotProvider,
|
||||
OutboxRepo: outboxRepo,
|
||||
OutboxMaxRetry: kafkabus.LoadConfig().MaxRetry,
|
||||
ProviderName: viper.GetString("llm.providerName"),
|
||||
LiteModelName: viper.GetString("agent.liteModel"),
|
||||
ProModelName: viper.GetString("agent.proModel"),
|
||||
MaxModelName: viper.GetString("agent.maxModel"),
|
||||
CourseVisionModel: viper.GetString("courseImport.visionModel"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize llm runtime service: %v", err)
|
||||
}
|
||||
|
||||
server, listenOn, err := llmrpc.NewServer(llmrpc.ServerOptions{
|
||||
ListenOn: viper.GetString("llm.rpc.listenOn"),
|
||||
Timeout: viper.GetDuration("llm.rpc.timeout"),
|
||||
Service: runtimeService,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build llm zrpc server: %v", err)
|
||||
}
|
||||
defer server.Stop()
|
||||
|
||||
go func() {
|
||||
log.Printf("llm zrpc service starting on %s", listenOn)
|
||||
server.Start()
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("llm service stopping")
|
||||
}
|
||||
|
||||
func buildLLMOutboxDispatchEngine(outboxRepo *outboxinfra.Repository) (*outboxinfra.Engine, error) {
|
||||
kafkaCfg := kafkabus.LoadConfig()
|
||||
if !kafkaCfg.Enabled || outboxRepo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
route, _ := outboxinfra.ResolveServiceRoute(outboxinfra.ServiceLLM)
|
||||
kafkaCfg.ServiceName = outboxinfra.ServiceLLM
|
||||
return outboxinfra.NewEngine(outboxRepo.WithRoute(route), kafkaCfg)
|
||||
}
|
||||
|
||||
type tokenStoreSnapshotProvider struct {
|
||||
cfg tokenstoreclient.ClientConfig
|
||||
|
||||
mu sync.Mutex
|
||||
client *tokenstoreclient.Client
|
||||
}
|
||||
|
||||
func (p *tokenStoreSnapshotProvider) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error) {
|
||||
client, err := p.ensureClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.GetCreditBalanceSnapshot(ctx, userID)
|
||||
}
|
||||
|
||||
func (p *tokenStoreSnapshotProvider) ensureClient() (*tokenstoreclient.Client, error) {
|
||||
if p == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.client != nil {
|
||||
return p.client, nil
|
||||
}
|
||||
|
||||
client, err := tokenstoreclient.NewClient(p.cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.client = client
|
||||
return p.client, nil
|
||||
}
|
||||
@@ -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。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
type creditOrderEnvelope 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"`
|
||||
CreditAmount int64 `json:"credit_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 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"`
|
||||
ProductDetail map[string]any `json:"product_snapshot,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PaidAt *string `json:"paid_at"`
|
||||
GrantedAt *string `json:"granted_at"`
|
||||
CreditedAt *string `json:"credited_at"`
|
||||
PaymentAction paymentAction `json:"payment_action"`
|
||||
}
|
||||
|
||||
type orderDetailEnvelope struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
type creditTransactionEnvelope struct {
|
||||
GrantID uint64 `json:"grant_id"`
|
||||
SourceLabel string `json:"source_label"`
|
||||
Amount int64 `json:"amount"`
|
||||
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"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PaidAt *string `json:"paid_at"`
|
||||
GrantedAt *string `json:"granted_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{
|
||||
|
||||
return creditOrderEnvelope{
|
||||
OrderID: order.OrderID,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
ProductSnapshot: order.ProductSnapshot,
|
||||
Quantity: order.Quantity,
|
||||
TokenAmount: order.TokenAmount,
|
||||
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{}
|
||||
}
|
||||
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,
|
||||
func buildCreditTransactionEnvelope(item creditcontracts.CreditTransactionView) creditTransactionEnvelope {
|
||||
return creditTransactionEnvelope{
|
||||
GrantID: item.TransactionID,
|
||||
SourceLabel: item.SourceLabel,
|
||||
Amount: item.Amount,
|
||||
Status: item.Status,
|
||||
ProductName: productName,
|
||||
TokenAmount: item.TokenAmount,
|
||||
PriceText: item.PriceText,
|
||||
Description: firstNonEmptyString(item.Description, item.SourceLabel),
|
||||
CreatedAt: item.CreatedAt,
|
||||
PaidAt: item.PaidAt,
|
||||
GrantedAt: item.GrantedAt,
|
||||
})
|
||||
Direction: item.Direction,
|
||||
BalanceAfter: item.BalanceAfter,
|
||||
EventID: item.EventID,
|
||||
OrderID: item.OrderID,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,6 +20,7 @@ message JSONResponse {
|
||||
}
|
||||
|
||||
message CourseImageRequest {
|
||||
uint64 user_id = 4;
|
||||
string filename = 1;
|
||||
string mime_type = 2;
|
||||
bytes image_bytes = 3;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ type ArkResponsesResult struct {
|
||||
type ArkResponsesClient struct {
|
||||
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
|
||||
|
||||
76
backend/services/llm/billing.go
Normal file
76
backend/services/llm/billing.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type billingContextKey struct{}
|
||||
|
||||
// BillingContext 描述一次 LLM 调用必需的计费上下文。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只承载计费、审计、幂等所需的调用上下文;
|
||||
// 2. 不承载 Temperature / MaxTokens 这类模型行为参数;
|
||||
// 3. 不混入 prompt 文本,避免把业务输入复制成第二份协议。
|
||||
type BillingContext struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Scene string `json:"scene"`
|
||||
RequestID string `json:"request_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
ModelAlias string `json:"model_alias"`
|
||||
SkipCharge bool `json:"skip_charge"`
|
||||
}
|
||||
|
||||
// Normalize 返回去空格后的 BillingContext 副本。
|
||||
func (c BillingContext) Normalize() BillingContext {
|
||||
c.EventID = strings.TrimSpace(c.EventID)
|
||||
c.Scene = strings.TrimSpace(c.Scene)
|
||||
c.RequestID = strings.TrimSpace(c.RequestID)
|
||||
c.ConversationID = strings.TrimSpace(c.ConversationID)
|
||||
c.ModelAlias = strings.TrimSpace(c.ModelAlias)
|
||||
return c
|
||||
}
|
||||
|
||||
// IsZero 判断是否完全没有注入计费上下文。
|
||||
func (c BillingContext) IsZero() bool {
|
||||
return c.UserID == 0 &&
|
||||
strings.TrimSpace(c.EventID) == "" &&
|
||||
strings.TrimSpace(c.Scene) == "" &&
|
||||
strings.TrimSpace(c.RequestID) == "" &&
|
||||
strings.TrimSpace(c.ConversationID) == "" &&
|
||||
strings.TrimSpace(c.ModelAlias) == "" &&
|
||||
!c.SkipCharge
|
||||
}
|
||||
|
||||
// WithBillingContext 把计费上下文挂进调用 ctx。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 这次优先保持 GenerateText / GenerateJSON / Stream 原有签名基本不变;
|
||||
// 2. 计费必填信息不再塞进 GenerateOptions.Metadata,而是走强语义 ctx;
|
||||
// 3. 后续若统一切为显式 request struct,可继续复用本结构体,不改业务语义。
|
||||
func WithBillingContext(ctx context.Context, billing BillingContext) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
billing = billing.Normalize()
|
||||
return context.WithValue(ctx, billingContextKey{}, billing)
|
||||
}
|
||||
|
||||
// BillingContextFromContext 读取调用上下文中的计费信息。
|
||||
func BillingContextFromContext(ctx context.Context) (BillingContext, bool) {
|
||||
if ctx == nil {
|
||||
return BillingContext{}, false
|
||||
}
|
||||
value := ctx.Value(billingContextKey{})
|
||||
billing, ok := value.(BillingContext)
|
||||
if !ok {
|
||||
return BillingContext{}, false
|
||||
}
|
||||
billing = billing.Normalize()
|
||||
if billing.IsZero() {
|
||||
return BillingContext{}, false
|
||||
}
|
||||
return billing, true
|
||||
}
|
||||
68
backend/services/llm/billing_compat.go
Normal file
68
backend/services/llm/billing_compat.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// EnsureTextBillingIdentity 负责在老调用点未显式提供 event_id 时兜底补稳定事件号。
|
||||
//
|
||||
// 兼容策略:
|
||||
// 1. 只在 user_id、request_id 已具备且 event_id 为空时触发,避免覆盖显式幂等键;
|
||||
// 2. stage 优先从 GenerateOptions.Metadata["stage"] 读取,兼容 agent 现有大量调用点;
|
||||
// 3. 输入摘要使用 messages 的稳定哈希,确保同一 request_id 下不同阶段/不同输入不会串账。
|
||||
func EnsureTextBillingIdentity(billing BillingContext, options llmcontracts.GenerateOptions, messages []*schema.Message) BillingContext {
|
||||
return ensureBillingIdentity(billing, readStageFromMetadata(options.Metadata), messages)
|
||||
}
|
||||
|
||||
// EnsureResponsesBillingIdentity 负责给 Responses 调用补稳定事件号。
|
||||
func EnsureResponsesBillingIdentity(billing BillingContext, messages []llmcontracts.ResponsesMessage) BillingContext {
|
||||
return ensureBillingIdentity(billing, "", messages)
|
||||
}
|
||||
|
||||
func ensureBillingIdentity(billing BillingContext, stage string, payload any) BillingContext {
|
||||
billing = billing.Normalize()
|
||||
if billing.UserID == 0 || strings.TrimSpace(billing.RequestID) == "" || strings.TrimSpace(billing.EventID) != "" {
|
||||
return billing
|
||||
}
|
||||
|
||||
stage = strings.TrimSpace(stage)
|
||||
billing.EventID = buildStableBillingEventID(billing.RequestID, stage, hashPayload(payload))
|
||||
return billing
|
||||
}
|
||||
|
||||
func readStageFromMetadata(metadata map[string]any) string {
|
||||
if len(metadata) == 0 {
|
||||
return ""
|
||||
}
|
||||
raw, ok := metadata["stage"]
|
||||
if !ok || raw == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(raw))
|
||||
}
|
||||
|
||||
func hashPayload(payload any) string {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil || len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
sum := sha1.Sum(raw)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func buildStableBillingEventID(requestID, stage, payloadDigest string) string {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
stage = strings.TrimSpace(stage)
|
||||
payloadDigest = strings.TrimSpace(payloadDigest)
|
||||
|
||||
base := requestID + "|" + stage + "|" + payloadDigest
|
||||
sum := sha1.Sum([]byte(base))
|
||||
return requestID + ":" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
107
backend/services/llm/dao/cache.go
Normal file
107
backend/services/llm/dao/cache.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
const defaultCreditSnapshotTTL = 10 * time.Minute
|
||||
|
||||
// CreditBalanceSnapshot 是 LLM 准入守卫读取的余额快照。
|
||||
type CreditBalanceSnapshot struct {
|
||||
AvailableCredit int64 `json:"balance"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CacheDAO 只承载 LLM 服务私有的 Redis Key 读写。
|
||||
type CacheDAO struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewCacheDAO(client *redis.Client) *CacheDAO {
|
||||
return &CacheDAO{client: client}
|
||||
}
|
||||
|
||||
func userCreditBalanceSnapshotKey(userID uint64) string {
|
||||
return fmt.Sprintf("smartflow:credit_balance_snapshot:%d", userID)
|
||||
}
|
||||
|
||||
func userCreditBlockedKey(userID uint64) string {
|
||||
return fmt.Sprintf("smartflow:credit_blocked:%d", userID)
|
||||
}
|
||||
|
||||
func (d *CacheDAO) GetUserCreditBalanceSnapshot(ctx context.Context, userID uint64) (*CreditBalanceSnapshot, bool, error) {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
value, err := d.client.Get(ctx, userCreditBalanceSnapshotKey(userID)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var snapshot CreditBalanceSnapshot
|
||||
if err = json.Unmarshal([]byte(value), &snapshot); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &snapshot, true, nil
|
||||
}
|
||||
|
||||
func (d *CacheDAO) SetUserCreditBalanceSnapshot(ctx context.Context, userID uint64, snapshot CreditBalanceSnapshot, ttl time.Duration) error {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = defaultCreditSnapshotTTL
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.client.Set(ctx, userCreditBalanceSnapshotKey(userID), raw, ttl).Err()
|
||||
}
|
||||
|
||||
func (d *CacheDAO) DeleteUserCreditBalanceSnapshot(ctx context.Context, userID uint64) error {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.client.Del(ctx, userCreditBalanceSnapshotKey(userID)).Err()
|
||||
}
|
||||
|
||||
func (d *CacheDAO) IsUserCreditBlocked(ctx context.Context, userID uint64) (bool, error) {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
value, err := d.client.Get(ctx, userCreditBlockedKey(userID)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return value == "1", nil
|
||||
}
|
||||
|
||||
func (d *CacheDAO) SetUserCreditBlocked(ctx context.Context, userID uint64, ttl time.Duration) error {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.client.Set(ctx, userCreditBlockedKey(userID), "1", ttl).Err()
|
||||
}
|
||||
|
||||
func (d *CacheDAO) DeleteUserCreditBlocked(ctx context.Context, userID uint64) error {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.client.Del(ctx, userCreditBlockedKey(userID)).Err()
|
||||
}
|
||||
42
backend/services/llm/dao/connect.go
Normal file
42
backend/services/llm/dao/connect.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
|
||||
mysqlinfra "github.com/LoveLosita/smartflow/backend/shared/infra/mysql"
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OpenDBFromConfig 负责打开 LLM 独立服务需要的数据库连接。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只初始化通用 MySQL 连接并补齐 LLM 自己的 outbox 表;
|
||||
// 2. 不负责启动 Kafka relay,也不负责装配 Redis/模型客户端;
|
||||
// 3. 当前阶段不额外声明业务私表,避免和主代理后续 Credit 表迁移交叉。
|
||||
func OpenDBFromConfig() (*gorm.DB, error) {
|
||||
db, err := mysqlinfra.OpenDBFromConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = autoMigrateLLMOutboxTable(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func autoMigrateLLMOutboxTable(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("llm database is not initialized")
|
||||
}
|
||||
|
||||
cfg, ok := outboxinfra.ResolveServiceConfig(outboxinfra.ServiceLLM)
|
||||
if !ok {
|
||||
return fmt.Errorf("resolve llm outbox config failed")
|
||||
}
|
||||
if err := db.Table(cfg.TableName).AutoMigrate(&model.AgentOutboxMessage{}); err != nil {
|
||||
return fmt.Errorf("auto migrate llm outbox table failed for %s (%s): %w", cfg.Name, cfg.TableName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
53
backend/services/llm/dao/pricing.go
Normal file
53
backend/services/llm/dao/pricing.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const creditPriceRuleStatusActive = "active"
|
||||
|
||||
type CreditPriceRule struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
Scene string `gorm:"column:scene"`
|
||||
ProviderName string `gorm:"column:provider_name"`
|
||||
ModelName string `gorm:"column:model_name"`
|
||||
InputPriceMicros int64 `gorm:"column:input_price_micros"`
|
||||
OutputPriceMicros int64 `gorm:"column:output_price_micros"`
|
||||
CachedPriceMicros int64 `gorm:"column:cached_price_micros"`
|
||||
ReasoningPriceMicros int64 `gorm:"column:reasoning_price_micros"`
|
||||
CreditPerYuan int64 `gorm:"column:credit_per_yuan"`
|
||||
Status string `gorm:"column:status"`
|
||||
Priority int `gorm:"column:priority"`
|
||||
Description string `gorm:"column:description"`
|
||||
}
|
||||
|
||||
func (CreditPriceRule) TableName() string {
|
||||
return "credit_price_rules"
|
||||
}
|
||||
|
||||
type PriceRuleDAO struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPriceRuleDAO(db *gorm.DB) *PriceRuleDAO {
|
||||
return &PriceRuleDAO{db: db}
|
||||
}
|
||||
|
||||
func (d *PriceRuleDAO) ListActiveRules(ctx context.Context) ([]CreditPriceRule, error) {
|
||||
if d == nil || d.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rules []CreditPriceRule
|
||||
err := d.db.WithContext(ctx).
|
||||
Model(&CreditPriceRule{}).
|
||||
Where("status = ?", creditPriceRuleStatusActive).
|
||||
Order("priority DESC, id ASC").
|
||||
Find(&rules).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
152
backend/services/llm/guard.go
Normal file
152
backend/services/llm/guard.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRuntimeServiceNotReady = errors.New("llm runtime service dependency not initialized")
|
||||
ErrUnsupportedModelAlias = errors.New("llm model alias is unsupported")
|
||||
ErrCreditBalanceBlocked = errors.New("credit balance is insufficient")
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCreditBlockedTTL = 5 * time.Minute
|
||||
defaultCreditSnapshotTimeout = time.Second
|
||||
)
|
||||
|
||||
type CreditBalanceSnapshotProvider interface {
|
||||
GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error)
|
||||
}
|
||||
|
||||
// CreditBalanceGuard 负责在真正发起 LLM 调用前做一次轻量余额准入。
|
||||
type CreditBalanceGuard struct {
|
||||
cacheDAO *llmdao.CacheDAO
|
||||
snapshotProvider CreditBalanceSnapshotProvider
|
||||
blockTTL time.Duration
|
||||
snapshotTimeout time.Duration
|
||||
}
|
||||
|
||||
type CreditBalanceGuardOptions struct {
|
||||
CacheDAO *llmdao.CacheDAO
|
||||
SnapshotProvider CreditBalanceSnapshotProvider
|
||||
BlockTTL time.Duration
|
||||
SnapshotTimeout time.Duration
|
||||
}
|
||||
|
||||
func NewCreditBalanceGuard(opts CreditBalanceGuardOptions) *CreditBalanceGuard {
|
||||
blockTTL := opts.BlockTTL
|
||||
if blockTTL <= 0 {
|
||||
blockTTL = defaultCreditBlockedTTL
|
||||
}
|
||||
snapshotTimeout := opts.SnapshotTimeout
|
||||
if snapshotTimeout <= 0 {
|
||||
snapshotTimeout = defaultCreditSnapshotTimeout
|
||||
}
|
||||
return &CreditBalanceGuard{
|
||||
cacheDAO: opts.CacheDAO,
|
||||
snapshotProvider: opts.SnapshotProvider,
|
||||
blockTTL: blockTTL,
|
||||
snapshotTimeout: snapshotTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Guard 只做 Redis 快照级别的 fail-open 准入检查。
|
||||
//
|
||||
// 设计说明:
|
||||
// 1. 先查 blocked key,命中则直接拒绝,避免每次都回源余额快照;
|
||||
// 2. 再查余额快照;若快照明确余额 <= 0,则写 blocked key 并拒绝;
|
||||
// 3. Redis 读失败或快照缺失时保持放行,避免基础设施抖动直接阻断全部 LLM 调用。
|
||||
func (g *CreditBalanceGuard) Guard(ctx context.Context, billing BillingContext) error {
|
||||
if g == nil || g.cacheDAO == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
billing = billing.Normalize()
|
||||
if billing.UserID == 0 || billing.SkipCharge {
|
||||
return nil
|
||||
}
|
||||
|
||||
blocked, err := g.cacheDAO.IsUserCreditBlocked(ctx, billing.UserID)
|
||||
if err != nil {
|
||||
log.Printf("llm credit guard read blocked key failed: user_id=%d err=%v", billing.UserID, err)
|
||||
return nil
|
||||
}
|
||||
if blocked {
|
||||
return ErrCreditBalanceBlocked
|
||||
}
|
||||
|
||||
snapshot, found, err := g.cacheDAO.GetUserCreditBalanceSnapshot(ctx, billing.UserID)
|
||||
if err != nil {
|
||||
log.Printf("llm credit guard read balance snapshot failed: user_id=%d err=%v", billing.UserID, err)
|
||||
return nil
|
||||
}
|
||||
if !found || snapshot == nil {
|
||||
snapshot, err = g.fetchSnapshot(ctx, billing.UserID)
|
||||
if err != nil {
|
||||
log.Printf("llm credit guard fetch balance snapshot failed: user_id=%d err=%v", billing.UserID, err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
if snapshot.AvailableCredit > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = g.cacheDAO.SetUserCreditBlocked(ctx, billing.UserID, g.blockTTL); err != nil {
|
||||
log.Printf("llm credit guard set blocked key failed: user_id=%d err=%v", billing.UserID, err)
|
||||
}
|
||||
return ErrCreditBalanceBlocked
|
||||
}
|
||||
|
||||
func (g *CreditBalanceGuard) fetchSnapshot(ctx context.Context, userID uint64) (*llmdao.CreditBalanceSnapshot, error) {
|
||||
if g == nil || g.snapshotProvider == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fetchCtx := ctx
|
||||
if fetchCtx == nil {
|
||||
fetchCtx = context.Background()
|
||||
}
|
||||
if g.snapshotTimeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
fetchCtx, cancel = context.WithTimeout(context.WithoutCancel(fetchCtx), g.snapshotTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
snapshotView, err := g.snapshotProvider.GetCreditBalanceSnapshot(fetchCtx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snapshotView == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
snapshot := &llmdao.CreditBalanceSnapshot{
|
||||
AvailableCredit: snapshotView.Balance,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err = g.cacheDAO.SetUserCreditBalanceSnapshot(fetchCtx, userID, *snapshot, 0); err != nil {
|
||||
log.Printf("llm credit guard backfill balance snapshot failed: user_id=%d err=%v", userID, err)
|
||||
}
|
||||
|
||||
if snapshotView.IsBlocked || snapshotView.Balance <= 0 {
|
||||
if err = g.cacheDAO.SetUserCreditBlocked(fetchCtx, userID, g.blockTTL); err != nil {
|
||||
log.Printf("llm credit guard backfill blocked key failed: user_id=%d err=%v", userID, err)
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
if err = g.cacheDAO.DeleteUserCreditBlocked(fetchCtx, userID); err != nil {
|
||||
log.Printf("llm credit guard clear blocked key failed: user_id=%d err=%v", userID, err)
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
211
backend/services/llm/outbox.go
Normal file
211
backend/services/llm/outbox.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
|
||||
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOutboxMaxRetry = 20
|
||||
defaultBillingPersistWindow = 2 * time.Second
|
||||
)
|
||||
|
||||
// ChargeRecorder 负责把一次已完成的 LLM usage 写入 LLM 自己的 outbox。
|
||||
type ChargeRecorder struct {
|
||||
publisher *outboxinfra.RepositoryPublisher
|
||||
providerName string
|
||||
pricing UsagePricingResolver
|
||||
}
|
||||
|
||||
type ChargeRecorderOptions struct {
|
||||
Repo *outboxinfra.Repository
|
||||
MaxRetry int
|
||||
ProviderName string
|
||||
Pricing UsagePricingResolver
|
||||
}
|
||||
|
||||
func NewChargeRecorder(opts ChargeRecorderOptions) (*ChargeRecorder, error) {
|
||||
if err := RegisterCreditChargeRoute(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
providerName := strings.TrimSpace(opts.ProviderName)
|
||||
if providerName == "" {
|
||||
providerName = llmcontracts.ProviderNameArk
|
||||
}
|
||||
|
||||
if opts.Repo == nil {
|
||||
return &ChargeRecorder{providerName: providerName}, nil
|
||||
}
|
||||
|
||||
maxRetry := opts.MaxRetry
|
||||
if maxRetry <= 0 {
|
||||
maxRetry = defaultOutboxMaxRetry
|
||||
}
|
||||
return &ChargeRecorder{
|
||||
// 1. 当前 outbox infra 仍是“由归属服务自己 dispatch + consume 自己的 outbox”模型。
|
||||
// 2. 因此这里必须让 Repository 按事件归属把 credit 事件写进 token-store 的 outbox,
|
||||
// 不能再强绑到 llm 自己的 route,否则消息只会停在 published 而无人消费。
|
||||
publisher: outboxinfra.NewRepositoryPublisher(opts.Repo, maxRetry),
|
||||
providerName: providerName,
|
||||
pricing: opts.Pricing,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func RegisterCreditChargeRoute() error {
|
||||
return outboxinfra.RegisterEventService(sharedevents.CreditChargeRequestedEventType, outboxinfra.ServiceTokenStore)
|
||||
}
|
||||
|
||||
func (r *ChargeRecorder) RecordTextUsage(ctx context.Context, billing BillingContext, modelAlias, modelName, defaultScene string, usage *schema.TokenUsage) error {
|
||||
if usage == nil {
|
||||
return nil
|
||||
}
|
||||
return r.publish(ctx, billing, publishUsageInput{
|
||||
ModelAlias: modelAlias,
|
||||
ModelName: modelName,
|
||||
DefaultScene: defaultScene,
|
||||
InputTokens: int64(usage.PromptTokens),
|
||||
OutputTokens: int64(usage.CompletionTokens),
|
||||
CachedTokens: int64(usage.PromptTokenDetails.CachedTokens),
|
||||
ReasoningTokens: int64(usage.CompletionTokensDetails.ReasoningTokens),
|
||||
TotalTokens: int64(usage.TotalTokens),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ChargeRecorder) RecordResponsesUsage(ctx context.Context, billing BillingContext, modelAlias, modelName, defaultScene string, usage *ArkResponsesUsage) error {
|
||||
if usage == nil {
|
||||
return nil
|
||||
}
|
||||
return r.publish(ctx, billing, publishUsageInput{
|
||||
ModelAlias: modelAlias,
|
||||
ModelName: modelName,
|
||||
DefaultScene: defaultScene,
|
||||
InputTokens: usage.InputTokens,
|
||||
OutputTokens: usage.OutputTokens,
|
||||
TotalTokens: usage.TotalTokens,
|
||||
})
|
||||
}
|
||||
|
||||
type publishUsageInput struct {
|
||||
ModelAlias string
|
||||
ModelName string
|
||||
DefaultScene string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
CachedTokens int64
|
||||
ReasoningTokens int64
|
||||
TotalTokens int64
|
||||
}
|
||||
|
||||
func (r *ChargeRecorder) publish(ctx context.Context, billing BillingContext, input publishUsageInput) error {
|
||||
if r == nil || r.publisher == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
billing = billing.Normalize()
|
||||
if billing.UserID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
eventID := firstNonEmptyString(strings.TrimSpace(billing.EventID), uuid.NewString())
|
||||
requestID := firstNonEmptyString(strings.TrimSpace(billing.RequestID), eventID)
|
||||
scene := firstNonEmptyString(strings.TrimSpace(billing.Scene), strings.TrimSpace(input.DefaultScene))
|
||||
modelAlias := firstNonEmptyString(strings.TrimSpace(billing.ModelAlias), strings.TrimSpace(input.ModelAlias))
|
||||
modelName := firstNonEmptyString(strings.TrimSpace(input.ModelName), modelAlias)
|
||||
totalTokens := input.TotalTokens
|
||||
if totalTokens <= 0 {
|
||||
totalTokens = input.InputTokens + input.OutputTokens
|
||||
}
|
||||
|
||||
payload := sharedevents.CreditChargeRequestedPayload{
|
||||
EventID: eventID,
|
||||
UserID: billing.UserID,
|
||||
Scene: scene,
|
||||
RequestID: requestID,
|
||||
ConversationID: strings.TrimSpace(billing.ConversationID),
|
||||
ModelAlias: modelAlias,
|
||||
ProviderName: r.providerName,
|
||||
ModelName: modelName,
|
||||
InputTokens: input.InputTokens,
|
||||
OutputTokens: input.OutputTokens,
|
||||
CachedTokens: input.CachedTokens,
|
||||
ReasoningTokens: input.ReasoningTokens,
|
||||
TotalTokens: totalTokens,
|
||||
RMBCostMicros: 0,
|
||||
CreditCost: 0,
|
||||
TriggeredAt: time.Now(),
|
||||
SkipCharge: billing.SkipCharge,
|
||||
}
|
||||
if !billing.SkipCharge {
|
||||
quote, err := r.resolvePriceQuote(ctx, payload)
|
||||
if err != nil {
|
||||
log.Printf("llm price quote resolve failed: event_id=%s user_id=%d err=%v", payload.EventID, payload.UserID, err)
|
||||
} else {
|
||||
payload.RMBCostMicros = quote.RMBCostMicros
|
||||
payload.CreditCost = quote.CreditCost
|
||||
}
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
recordCtx, cancel := detachedBillingContext(ctx)
|
||||
defer cancel()
|
||||
return r.publisher.Publish(recordCtx, outboxinfra.PublishRequest{
|
||||
EventID: payload.EventID,
|
||||
EventType: sharedevents.CreditChargeRequestedEventType,
|
||||
EventVersion: sharedevents.CreditChargeEventVersion,
|
||||
MessageKey: payload.MessageKey(),
|
||||
AggregateID: payload.AggregateID(),
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ChargeRecorder) resolvePriceQuote(ctx context.Context, payload sharedevents.CreditChargeRequestedPayload) (UsagePriceQuote, error) {
|
||||
if r == nil || r.pricing == nil {
|
||||
return UsagePriceQuote{}, nil
|
||||
}
|
||||
|
||||
return r.pricing.Resolve(ctx, UsagePricingInput{
|
||||
Scene: payload.Scene,
|
||||
ProviderName: payload.ProviderName,
|
||||
ModelName: payload.ModelName,
|
||||
InputTokens: payload.InputTokens,
|
||||
OutputTokens: payload.OutputTokens,
|
||||
CachedTokens: payload.CachedTokens,
|
||||
ReasoningTokens: payload.ReasoningTokens,
|
||||
})
|
||||
}
|
||||
|
||||
func detachedBillingContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
base := context.Background()
|
||||
if ctx != nil {
|
||||
base = context.WithoutCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(base, defaultBillingPersistWindow)
|
||||
}
|
||||
|
||||
func logChargeRecordError(scene string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("llm charge record failed: scene=%s err=%v", strings.TrimSpace(scene), err)
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
207
backend/services/llm/pricing.go
Normal file
207
backend/services/llm/pricing.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPriceRuleCacheTTL = time.Minute
|
||||
tokenPriceScalePer1K = int64(1000)
|
||||
rmbMicrosPerYuan = int64(1_000_000)
|
||||
)
|
||||
|
||||
type UsagePricingInput struct {
|
||||
Scene string
|
||||
ProviderName string
|
||||
ModelName string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
CachedTokens int64
|
||||
ReasoningTokens int64
|
||||
}
|
||||
|
||||
type UsagePriceQuote struct {
|
||||
RuleID uint64
|
||||
RMBCostMicros int64
|
||||
CreditCost int64
|
||||
MatchedScene string
|
||||
MatchedProvider string
|
||||
MatchedModel string
|
||||
}
|
||||
|
||||
type UsagePricingResolver interface {
|
||||
Resolve(ctx context.Context, input UsagePricingInput) (UsagePriceQuote, error)
|
||||
}
|
||||
|
||||
type CreditPriceResolverOptions struct {
|
||||
DAO *llmdao.PriceRuleDAO
|
||||
CacheTTL time.Duration
|
||||
}
|
||||
|
||||
type CreditPriceResolver struct {
|
||||
dao *llmdao.PriceRuleDAO
|
||||
cacheTTL time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
cachedAt time.Time
|
||||
cachedSet []llmdao.CreditPriceRule
|
||||
}
|
||||
|
||||
func NewCreditPriceResolver(opts CreditPriceResolverOptions) *CreditPriceResolver {
|
||||
cacheTTL := opts.CacheTTL
|
||||
if cacheTTL <= 0 {
|
||||
cacheTTL = defaultPriceRuleCacheTTL
|
||||
}
|
||||
return &CreditPriceResolver{
|
||||
dao: opts.DAO,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *CreditPriceResolver) Resolve(ctx context.Context, input UsagePricingInput) (UsagePriceQuote, error) {
|
||||
if r == nil || r.dao == nil {
|
||||
return UsagePriceQuote{}, nil
|
||||
}
|
||||
|
||||
rules, err := r.loadRules(ctx)
|
||||
if err != nil {
|
||||
return UsagePriceQuote{}, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return UsagePriceQuote{}, nil
|
||||
}
|
||||
|
||||
scene := strings.TrimSpace(input.Scene)
|
||||
providerName := strings.TrimSpace(input.ProviderName)
|
||||
modelName := strings.TrimSpace(input.ModelName)
|
||||
|
||||
for _, rule := range rules {
|
||||
if !matchesPriceRuleField(rule.Scene, scene) {
|
||||
continue
|
||||
}
|
||||
if !matchesPriceRuleField(rule.ProviderName, providerName) {
|
||||
continue
|
||||
}
|
||||
if !matchesPriceRuleField(rule.ModelName, modelName) {
|
||||
continue
|
||||
}
|
||||
return quoteUsagePrice(rule, input), nil
|
||||
}
|
||||
|
||||
return UsagePriceQuote{}, nil
|
||||
}
|
||||
|
||||
func (r *CreditPriceResolver) loadRules(ctx context.Context) ([]llmdao.CreditPriceRule, error) {
|
||||
now := time.Now()
|
||||
|
||||
r.mu.RLock()
|
||||
if len(r.cachedSet) > 0 && now.Sub(r.cachedAt) < r.cacheTTL {
|
||||
rules := clonePriceRules(r.cachedSet)
|
||||
r.mu.RUnlock()
|
||||
return rules, nil
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if len(r.cachedSet) > 0 && now.Sub(r.cachedAt) < r.cacheTTL {
|
||||
return clonePriceRules(r.cachedSet), nil
|
||||
}
|
||||
|
||||
rules, err := r.dao.ListActiveRules(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.cachedSet = clonePriceRules(rules)
|
||||
r.cachedAt = now
|
||||
return clonePriceRules(r.cachedSet), nil
|
||||
}
|
||||
|
||||
func clonePriceRules(input []llmdao.CreditPriceRule) []llmdao.CreditPriceRule {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
output := make([]llmdao.CreditPriceRule, len(input))
|
||||
copy(output, input)
|
||||
return output
|
||||
}
|
||||
|
||||
func matchesPriceRuleField(ruleValue string, actual string) bool {
|
||||
ruleValue = strings.TrimSpace(ruleValue)
|
||||
actual = strings.TrimSpace(actual)
|
||||
|
||||
if ruleValue == "" || ruleValue == "*" {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(ruleValue, actual)
|
||||
}
|
||||
|
||||
func quoteUsagePrice(rule llmdao.CreditPriceRule, input UsagePricingInput) UsagePriceQuote {
|
||||
inputTokens := maxInt64(input.InputTokens, 0)
|
||||
outputTokens := maxInt64(input.OutputTokens, 0)
|
||||
cachedTokens := clampInt64(input.CachedTokens, 0, inputTokens)
|
||||
reasoningTokens := clampInt64(input.ReasoningTokens, 0, outputTokens)
|
||||
|
||||
nonCachedInputTokens := inputTokens - cachedTokens
|
||||
nonReasoningOutputTokens := outputTokens - reasoningTokens
|
||||
|
||||
cachedPriceMicros := rule.CachedPriceMicros
|
||||
if cachedPriceMicros <= 0 {
|
||||
cachedPriceMicros = rule.InputPriceMicros
|
||||
}
|
||||
reasoningPriceMicros := rule.ReasoningPriceMicros
|
||||
if reasoningPriceMicros <= 0 {
|
||||
reasoningPriceMicros = rule.OutputPriceMicros
|
||||
}
|
||||
|
||||
totalMicrosScaled := nonCachedInputTokens*maxInt64(rule.InputPriceMicros, 0) +
|
||||
cachedTokens*maxInt64(cachedPriceMicros, 0) +
|
||||
nonReasoningOutputTokens*maxInt64(rule.OutputPriceMicros, 0) +
|
||||
reasoningTokens*maxInt64(reasoningPriceMicros, 0)
|
||||
|
||||
rmbCostMicros := ceilDivInt64(totalMicrosScaled, tokenPriceScalePer1K)
|
||||
creditCost := int64(0)
|
||||
if rmbCostMicros > 0 && rule.CreditPerYuan > 0 {
|
||||
creditCost = ceilDivInt64(rmbCostMicros*rule.CreditPerYuan, rmbMicrosPerYuan)
|
||||
}
|
||||
|
||||
return UsagePriceQuote{
|
||||
RuleID: rule.ID,
|
||||
RMBCostMicros: rmbCostMicros,
|
||||
CreditCost: creditCost,
|
||||
MatchedScene: strings.TrimSpace(rule.Scene),
|
||||
MatchedProvider: strings.TrimSpace(rule.ProviderName),
|
||||
MatchedModel: strings.TrimSpace(rule.ModelName),
|
||||
}
|
||||
}
|
||||
|
||||
func ceilDivInt64(numerator int64, denominator int64) int64 {
|
||||
if numerator <= 0 || denominator <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (numerator + denominator - 1) / denominator
|
||||
}
|
||||
|
||||
func clampInt64(value int64, minValue int64, maxValue int64) int64 {
|
||||
if value < minValue {
|
||||
return minValue
|
||||
}
|
||||
if value > maxValue {
|
||||
return maxValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func maxInt64(value int64, minValue int64) int64 {
|
||||
if value < minValue {
|
||||
return minValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
71
backend/services/llm/rpc/errors.go
Normal file
71
backend/services/llm/rpc/errors.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const llmErrorDomain = "smartflow.llm"
|
||||
|
||||
func grpcErrorFromServiceError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var resp respond.Response
|
||||
if errors.As(err, &resp) {
|
||||
return grpcErrorFromResponse(resp)
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, llmservice.ErrUnsupportedModelAlias):
|
||||
return status.Error(codes.InvalidArgument, err.Error())
|
||||
case errors.Is(err, llmservice.ErrCreditBalanceBlocked):
|
||||
return status.Error(codes.ResourceExhausted, err.Error())
|
||||
case errors.Is(err, llmservice.ErrRuntimeServiceNotReady):
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
}
|
||||
|
||||
log.Printf("llm rpc internal error: %v", err)
|
||||
return status.Error(codes.Internal, "llm service internal error")
|
||||
}
|
||||
|
||||
func grpcErrorFromResponse(resp respond.Response) error {
|
||||
code := grpcCodeFromRespondStatus(resp.Status)
|
||||
message := strings.TrimSpace(resp.Info)
|
||||
if message == "" {
|
||||
message = strings.TrimSpace(resp.Status)
|
||||
}
|
||||
|
||||
st := status.New(code, message)
|
||||
detail := &errdetails.ErrorInfo{
|
||||
Domain: llmErrorDomain,
|
||||
Reason: resp.Status,
|
||||
Metadata: map[string]string{
|
||||
"info": resp.Info,
|
||||
},
|
||||
}
|
||||
withDetails, err := st.WithDetails(detail)
|
||||
if err != nil {
|
||||
return st.Err()
|
||||
}
|
||||
return withDetails.Err()
|
||||
}
|
||||
|
||||
func grpcCodeFromRespondStatus(statusValue string) codes.Code {
|
||||
switch strings.TrimSpace(statusValue) {
|
||||
case respond.MissingParam.Status, respond.WrongParamType.Status:
|
||||
return codes.InvalidArgument
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(statusValue), "5") {
|
||||
return codes.Internal
|
||||
}
|
||||
return codes.InvalidArgument
|
||||
}
|
||||
122
backend/services/llm/rpc/handler.go
Normal file
122
backend/services/llm/rpc/handler.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
UnimplementedLLMServer
|
||||
svc *llmservice.RuntimeService
|
||||
}
|
||||
|
||||
func NewHandler(svc *llmservice.RuntimeService) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *Handler) Ping(ctx context.Context, req *llmcontracts.PingRequest) (*llmcontracts.PingResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &llmcontracts.PingResponse{}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GenerateText(ctx context.Context, req *llmcontracts.TextRequest) (*llmcontracts.TextResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := h.svc.GenerateText(ctx, *req)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &llmcontracts.TextResponse{Result: llmserviceToContractTextResult(result)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) StreamText(req *llmcontracts.StreamTextRequest, stream LLM_StreamTextServer) error {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader, err := h.svc.StreamText(stream.Context(), *req)
|
||||
if err != nil {
|
||||
return grpcErrorFromServiceError(err)
|
||||
}
|
||||
if reader == nil {
|
||||
return grpcErrorFromServiceError(llmservice.ErrRuntimeServiceNotReady)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
for {
|
||||
message, recvErr := reader.Recv()
|
||||
if recvErr != nil {
|
||||
if errors.Is(recvErr, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return grpcErrorFromServiceError(recvErr)
|
||||
}
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
if err = stream.Send(&llmcontracts.StreamChunk{Message: message}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) GenerateResponsesText(ctx context.Context, req *llmcontracts.ResponsesRequest) (*llmcontracts.ResponsesResponse, error) {
|
||||
if err := h.ensureReady(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := h.svc.GenerateResponsesText(ctx, *req)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &llmcontracts.ResponsesResponse{Result: llmserviceToContractResponsesResult(result)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ensureReady(req any) error {
|
||||
if h == nil || h.svc == nil {
|
||||
return grpcErrorFromServiceError(llmservice.ErrRuntimeServiceNotReady)
|
||||
}
|
||||
if req == nil {
|
||||
return grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func llmserviceToContractTextResult(result *llmservice.TextResult) *llmcontracts.TextResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return &llmcontracts.TextResult{
|
||||
Text: result.Text,
|
||||
Usage: llmservice.CloneUsage(result.Usage),
|
||||
FinishReason: result.FinishReason,
|
||||
}
|
||||
}
|
||||
|
||||
func llmserviceToContractResponsesResult(result *llmservice.ArkResponsesResult) *llmcontracts.ResponsesResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
output := &llmcontracts.ResponsesResult{
|
||||
Text: result.Text,
|
||||
Status: result.Status,
|
||||
IncompleteReason: result.IncompleteReason,
|
||||
ErrorCode: result.ErrorCode,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
output.Usage = &llmcontracts.ResponsesUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
38
backend/services/llm/rpc/json_codec.go
Normal file
38
backend/services/llm/rpc/json_codec.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/encoding"
|
||||
)
|
||||
|
||||
const jsonCodecName = "smartflow-json"
|
||||
|
||||
type jsonCodec struct{}
|
||||
|
||||
func init() {
|
||||
encoding.RegisterCodec(jsonCodec{})
|
||||
}
|
||||
|
||||
func (jsonCodec) Marshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func (jsonCodec) Unmarshal(data []byte, v any) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (jsonCodec) Name() string {
|
||||
return jsonCodecName
|
||||
}
|
||||
|
||||
// JSONCodecDialOption 负责让 zrpc client 按 JSON 编解码本服务请求体。
|
||||
func JSONCodecDialOption() grpc.DialOption {
|
||||
return grpc.WithDefaultCallOptions(grpc.ForceCodec(jsonCodec{}))
|
||||
}
|
||||
|
||||
// JSONCodecServerOption 负责让 zrpc server 按 JSON 编解码本服务请求体。
|
||||
func JSONCodecServerOption() grpc.ServerOption {
|
||||
return grpc.ForceServerCodec(jsonCodec{})
|
||||
}
|
||||
19
backend/services/llm/rpc/llm.proto
Normal file
19
backend/services/llm/rpc/llm.proto
Normal file
@@ -0,0 +1,19 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package smartflow.llm;
|
||||
|
||||
service LLM {
|
||||
rpc Ping (PingRequest) returns (PingResponse);
|
||||
rpc GenerateText (TextRequest) returns (TextResponse);
|
||||
rpc StreamText (StreamTextRequest) returns (stream StreamChunk);
|
||||
rpc GenerateResponsesText (ResponsesRequest) returns (ResponsesResponse);
|
||||
}
|
||||
|
||||
message PingRequest {}
|
||||
message PingResponse {}
|
||||
message TextRequest {}
|
||||
message TextResponse {}
|
||||
message StreamTextRequest {}
|
||||
message StreamChunk {}
|
||||
message ResponsesRequest {}
|
||||
message ResponsesResponse {}
|
||||
55
backend/services/llm/rpc/server.go
Normal file
55
backend/services/llm/rpc/server.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
llmservice "github.com/LoveLosita/smartflow/backend/services/llm"
|
||||
"github.com/zeromicro/go-zero/core/service"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListenOn = "0.0.0.0:9096"
|
||||
defaultTimeout = 0
|
||||
)
|
||||
|
||||
type ServerOptions struct {
|
||||
ListenOn string
|
||||
Timeout time.Duration
|
||||
Service *llmservice.RuntimeService
|
||||
}
|
||||
|
||||
// NewServer 负责创建 LLM 独立进程的最小 zrpc server。
|
||||
func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) {
|
||||
if opts.Service == nil {
|
||||
return nil, "", errors.New("llm runtime service dependency not initialized")
|
||||
}
|
||||
|
||||
listenOn := strings.TrimSpace(opts.ListenOn)
|
||||
if listenOn == "" {
|
||||
listenOn = defaultListenOn
|
||||
}
|
||||
timeout := opts.Timeout
|
||||
if timeout < 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
|
||||
server, err := zrpc.NewServer(zrpc.RpcServerConf{
|
||||
ServiceConf: service.ServiceConf{
|
||||
Name: "llm.rpc",
|
||||
Mode: service.DevMode,
|
||||
},
|
||||
ListenOn: listenOn,
|
||||
Timeout: int64(timeout / time.Millisecond),
|
||||
}, func(grpcServer *grpc.Server) {
|
||||
RegisterLLMServer(grpcServer, NewHandler(opts.Service))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
server.AddOptions(JSONCodecServerOption())
|
||||
return server, listenOn, nil
|
||||
}
|
||||
195
backend/services/llm/rpc/transport.go
Normal file
195
backend/services/llm/rpc/transport.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
LLM_Ping_FullMethodName = "/smartflow.llm.LLM/Ping"
|
||||
LLM_GenerateText_FullMethodName = "/smartflow.llm.LLM/GenerateText"
|
||||
LLM_StreamText_FullMethodName = "/smartflow.llm.LLM/StreamText"
|
||||
LLM_GenerateResponsesText_FullMethodName = "/smartflow.llm.LLM/GenerateResponsesText"
|
||||
)
|
||||
|
||||
type LLMClient interface {
|
||||
Ping(ctx context.Context, in *llmcontracts.PingRequest, opts ...grpc.CallOption) (*llmcontracts.PingResponse, error)
|
||||
GenerateText(ctx context.Context, in *llmcontracts.TextRequest, opts ...grpc.CallOption) (*llmcontracts.TextResponse, error)
|
||||
StreamText(ctx context.Context, in *llmcontracts.StreamTextRequest, opts ...grpc.CallOption) (LLM_StreamTextClient, error)
|
||||
GenerateResponsesText(ctx context.Context, in *llmcontracts.ResponsesRequest, opts ...grpc.CallOption) (*llmcontracts.ResponsesResponse, error)
|
||||
}
|
||||
|
||||
type llmClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewLLMClient(cc grpc.ClientConnInterface) LLMClient {
|
||||
return &llmClient{cc: cc}
|
||||
}
|
||||
|
||||
func (c *llmClient) Ping(ctx context.Context, in *llmcontracts.PingRequest, opts ...grpc.CallOption) (*llmcontracts.PingResponse, error) {
|
||||
out := new(llmcontracts.PingResponse)
|
||||
err := c.cc.Invoke(ctx, LLM_Ping_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *llmClient) GenerateText(ctx context.Context, in *llmcontracts.TextRequest, opts ...grpc.CallOption) (*llmcontracts.TextResponse, error) {
|
||||
out := new(llmcontracts.TextResponse)
|
||||
err := c.cc.Invoke(ctx, LLM_GenerateText_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *llmClient) StreamText(ctx context.Context, in *llmcontracts.StreamTextRequest, opts ...grpc.CallOption) (LLM_StreamTextClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &LLM_ServiceDesc.Streams[0], LLM_StreamText_FullMethodName, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := &llmStreamTextClient{ClientStream: stream}
|
||||
if err = client.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = client.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *llmClient) GenerateResponsesText(ctx context.Context, in *llmcontracts.ResponsesRequest, opts ...grpc.CallOption) (*llmcontracts.ResponsesResponse, error) {
|
||||
out := new(llmcontracts.ResponsesResponse)
|
||||
err := c.cc.Invoke(ctx, LLM_GenerateResponsesText_FullMethodName, in, out, opts...)
|
||||
return out, err
|
||||
}
|
||||
|
||||
type LLM_StreamTextClient interface {
|
||||
Recv() (*llmcontracts.StreamChunk, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type llmStreamTextClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (x *llmStreamTextClient) Recv() (*llmcontracts.StreamChunk, error) {
|
||||
m := new(llmcontracts.StreamChunk)
|
||||
if err := x.ClientStream.RecvMsg(m); err != nil {
|
||||
if err == io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
type LLMServer interface {
|
||||
Ping(context.Context, *llmcontracts.PingRequest) (*llmcontracts.PingResponse, error)
|
||||
GenerateText(context.Context, *llmcontracts.TextRequest) (*llmcontracts.TextResponse, error)
|
||||
StreamText(*llmcontracts.StreamTextRequest, LLM_StreamTextServer) error
|
||||
GenerateResponsesText(context.Context, *llmcontracts.ResponsesRequest) (*llmcontracts.ResponsesResponse, error)
|
||||
}
|
||||
|
||||
type UnimplementedLLMServer struct{}
|
||||
|
||||
func (UnimplementedLLMServer) Ping(context.Context, *llmcontracts.PingRequest) (*llmcontracts.PingResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedLLMServer) GenerateText(context.Context, *llmcontracts.TextRequest) (*llmcontracts.TextResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GenerateText not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedLLMServer) StreamText(*llmcontracts.StreamTextRequest, LLM_StreamTextServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method StreamText not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedLLMServer) GenerateResponsesText(context.Context, *llmcontracts.ResponsesRequest) (*llmcontracts.ResponsesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GenerateResponsesText not implemented")
|
||||
}
|
||||
|
||||
func RegisterLLMServer(s grpc.ServiceRegistrar, srv LLMServer) {
|
||||
s.RegisterService(&LLM_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
type LLM_StreamTextServer interface {
|
||||
Send(*llmcontracts.StreamChunk) error
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type llmStreamTextServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (x *llmStreamTextServer) Send(m *llmcontracts.StreamChunk) error {
|
||||
return x.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func _LLM_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(llmcontracts.PingRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(LLMServer).Ping(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: LLM_Ping_FullMethodName}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(LLMServer).Ping(ctx, req.(*llmcontracts.PingRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _LLM_GenerateText_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(llmcontracts.TextRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(LLMServer).GenerateText(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: LLM_GenerateText_FullMethodName}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(LLMServer).GenerateText(ctx, req.(*llmcontracts.TextRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _LLM_GenerateResponsesText_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(llmcontracts.ResponsesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(LLMServer).GenerateResponsesText(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: LLM_GenerateResponsesText_FullMethodName}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(LLMServer).GenerateResponsesText(ctx, req.(*llmcontracts.ResponsesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _LLM_StreamText_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(llmcontracts.StreamTextRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(LLMServer).StreamText(m, &llmStreamTextServer{ServerStream: stream})
|
||||
}
|
||||
|
||||
var LLM_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "smartflow.llm.LLM",
|
||||
HandlerType: (*LLMServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{MethodName: "Ping", Handler: _LLM_Ping_Handler},
|
||||
{MethodName: "GenerateText", Handler: _LLM_GenerateText_Handler},
|
||||
{MethodName: "GenerateResponsesText", Handler: _LLM_GenerateResponsesText_Handler},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{StreamName: "StreamText", Handler: _LLM_StreamText_Handler, ServerStreams: true},
|
||||
},
|
||||
Metadata: "services/llm/rpc/llm.proto",
|
||||
}
|
||||
315
backend/services/llm/runtime_service.go
Normal file
315
backend/services/llm/runtime_service.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao"
|
||||
llmcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/llm"
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// RuntimeService 是独立 LLM 进程对外暴露的业务门面。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责模型别名选择、BillingContext 注入、准入守卫与 outbox 写入;
|
||||
// 2. 不负责 prompt 编排,调用方仍然直接传入 messages;
|
||||
// 3. 不负责价格换算细则,本轮先把 usage 事件稳定写入 outbox,价格字段留给后续主代理接线。
|
||||
type RuntimeService struct {
|
||||
legacy *Service
|
||||
textClients map[string]*Client
|
||||
textModelNames map[string]string
|
||||
responsesClient *ArkResponsesClient
|
||||
responsesModel string
|
||||
balanceGuard *CreditBalanceGuard
|
||||
chargeRecorder *ChargeRecorder
|
||||
defaultProvider string
|
||||
}
|
||||
|
||||
type RuntimeServiceOptions struct {
|
||||
LegacyService *Service
|
||||
CacheDAO *llmdao.CacheDAO
|
||||
PriceRuleDAO *llmdao.PriceRuleDAO
|
||||
SnapshotProvider CreditBalanceSnapshotProvider
|
||||
OutboxRepo *outboxinfra.Repository
|
||||
OutboxMaxRetry int
|
||||
ProviderName string
|
||||
LiteModelName string
|
||||
ProModelName string
|
||||
MaxModelName string
|
||||
CourseVisionModel string
|
||||
}
|
||||
|
||||
func NewRuntimeService(opts RuntimeServiceOptions) (*RuntimeService, error) {
|
||||
if opts.LegacyService == nil {
|
||||
return nil, ErrRuntimeServiceNotReady
|
||||
}
|
||||
|
||||
chargeRecorder, err := NewChargeRecorder(ChargeRecorderOptions{
|
||||
Repo: opts.OutboxRepo,
|
||||
MaxRetry: opts.OutboxMaxRetry,
|
||||
ProviderName: opts.ProviderName,
|
||||
Pricing: NewCreditPriceResolver(CreditPriceResolverOptions{DAO: opts.PriceRuleDAO}),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RuntimeService{
|
||||
legacy: opts.LegacyService,
|
||||
textClients: map[string]*Client{
|
||||
llmcontracts.ModelAliasLite: opts.LegacyService.LiteClient(),
|
||||
llmcontracts.ModelAliasPro: opts.LegacyService.ProClient(),
|
||||
llmcontracts.ModelAliasMax: opts.LegacyService.MaxClient(),
|
||||
},
|
||||
textModelNames: map[string]string{
|
||||
llmcontracts.ModelAliasLite: strings.TrimSpace(opts.LiteModelName),
|
||||
llmcontracts.ModelAliasPro: strings.TrimSpace(opts.ProModelName),
|
||||
llmcontracts.ModelAliasMax: strings.TrimSpace(opts.MaxModelName),
|
||||
},
|
||||
responsesClient: opts.LegacyService.CourseImageResponsesClient(),
|
||||
responsesModel: strings.TrimSpace(opts.CourseVisionModel),
|
||||
balanceGuard: NewCreditBalanceGuard(CreditBalanceGuardOptions{
|
||||
CacheDAO: opts.CacheDAO,
|
||||
SnapshotProvider: opts.SnapshotProvider,
|
||||
}),
|
||||
chargeRecorder: chargeRecorder,
|
||||
defaultProvider: firstNonEmptyString(strings.TrimSpace(opts.ProviderName), llmcontracts.ProviderNameArk),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RuntimeService) LegacyService() *Service {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.legacy
|
||||
}
|
||||
|
||||
// GenerateText 负责处理一次非流式文本调用。
|
||||
func (s *RuntimeService) GenerateText(ctx context.Context, req llmcontracts.TextRequest) (*TextResult, error) {
|
||||
client, alias, modelName, err := s.resolveTextClient(req.ModelAlias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1. 先把跨进程 billing 副本还原回 ctx,保持业务侧调用面不改签名。
|
||||
// 2. 再做一次 Redis 快照级准入守卫;守卫失败直接短路,不继续发起模型调用。
|
||||
// 3. 模型成功后同步写 LLM outbox;写失败只打日志,避免因为记账侧抖动反向打挂主链路。
|
||||
ctx, billing := applyRequestBillingContext(ctx, req.Billing, alias)
|
||||
billing = EnsureTextBillingIdentity(billing, req.Options, req.Messages)
|
||||
if !billing.IsZero() {
|
||||
ctx = WithBillingContext(ctx, billing)
|
||||
}
|
||||
if err = s.balanceGuard.Guard(ctx, billing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.GenerateText(ctx, req.Messages, toServiceGenerateOptions(req.Options))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logChargeRecordError("llm.text.generate", s.chargeRecorder.RecordTextUsage(ctx, billing, alias, modelName, "llm.text.generate", result.Usage))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// StreamText 负责处理一次流式文本调用。
|
||||
func (s *RuntimeService) StreamText(ctx context.Context, req llmcontracts.StreamTextRequest) (StreamReader, error) {
|
||||
client, alias, modelName, err := s.resolveTextClient(req.ModelAlias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, billing := applyRequestBillingContext(ctx, req.Billing, alias)
|
||||
billing = EnsureTextBillingIdentity(billing, req.Options, req.Messages)
|
||||
if !billing.IsZero() {
|
||||
ctx = WithBillingContext(ctx, billing)
|
||||
}
|
||||
if err = s.balanceGuard.Guard(ctx, billing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader, err := client.Stream(ctx, req.Messages, toServiceGenerateOptions(req.Options))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewUsageAccountingStreamReader(reader, func(usage *schema.TokenUsage) {
|
||||
logChargeRecordError("llm.text.stream", s.chargeRecorder.RecordTextUsage(ctx, billing, alias, modelName, "llm.text.stream", usage))
|
||||
}), nil
|
||||
}
|
||||
|
||||
// GenerateResponsesText 负责处理课程图片解析使用的 Responses 文本调用。
|
||||
func (s *RuntimeService) GenerateResponsesText(ctx context.Context, req llmcontracts.ResponsesRequest) (*ArkResponsesResult, error) {
|
||||
client, alias, modelName, err := s.resolveResponsesClient(req.ModelAlias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, billing := applyRequestBillingContext(ctx, req.Billing, alias)
|
||||
billing = EnsureResponsesBillingIdentity(billing, req.Messages)
|
||||
if !billing.IsZero() {
|
||||
ctx = WithBillingContext(ctx, billing)
|
||||
}
|
||||
if err = s.balanceGuard.Guard(ctx, billing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := client.GenerateText(ctx, toServiceResponsesMessages(req.Messages), toServiceResponsesOptions(req.Options))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logChargeRecordError("llm.responses.generate", s.chargeRecorder.RecordResponsesUsage(ctx, billing, alias, modelName, "llm.responses.generate", result.Usage))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *RuntimeService) resolveTextClient(modelAlias string) (*Client, string, string, error) {
|
||||
if s == nil {
|
||||
return nil, "", "", ErrRuntimeServiceNotReady
|
||||
}
|
||||
|
||||
alias := llmcontracts.NormalizeModelAlias(modelAlias)
|
||||
client, ok := s.textClients[alias]
|
||||
if !ok {
|
||||
return nil, alias, "", ErrUnsupportedModelAlias
|
||||
}
|
||||
if client == nil {
|
||||
return nil, alias, "", ErrRuntimeServiceNotReady
|
||||
}
|
||||
return client, alias, firstNonEmptyString(s.textModelNames[alias], alias), nil
|
||||
}
|
||||
|
||||
func (s *RuntimeService) resolveResponsesClient(modelAlias string) (*ArkResponsesClient, string, string, error) {
|
||||
if s == nil || s.responsesClient == nil {
|
||||
return nil, "", "", ErrRuntimeServiceNotReady
|
||||
}
|
||||
|
||||
alias := strings.TrimSpace(modelAlias)
|
||||
if alias == "" {
|
||||
alias = llmcontracts.ModelAliasCourseImageResponses
|
||||
}
|
||||
if alias != llmcontracts.ModelAliasCourseImageResponses {
|
||||
return nil, alias, "", ErrUnsupportedModelAlias
|
||||
}
|
||||
return s.responsesClient, alias, firstNonEmptyString(s.responsesModel, alias), nil
|
||||
}
|
||||
|
||||
func applyRequestBillingContext(ctx context.Context, input *llmcontracts.BillingContext, modelAlias string) (context.Context, BillingContext) {
|
||||
billing := BillingContext{}
|
||||
if input != nil {
|
||||
billing = BillingContext{
|
||||
UserID: input.UserID,
|
||||
EventID: input.EventID,
|
||||
Scene: input.Scene,
|
||||
RequestID: input.RequestID,
|
||||
ConversationID: input.ConversationID,
|
||||
ModelAlias: input.ModelAlias,
|
||||
SkipCharge: input.SkipCharge,
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(billing.ModelAlias) == "" {
|
||||
billing.ModelAlias = strings.TrimSpace(modelAlias)
|
||||
}
|
||||
if billing.IsZero() {
|
||||
return ctx, billing
|
||||
}
|
||||
return WithBillingContext(ctx, billing), billing
|
||||
}
|
||||
|
||||
func toServiceGenerateOptions(input llmcontracts.GenerateOptions) GenerateOptions {
|
||||
return GenerateOptions{
|
||||
Temperature: input.Temperature,
|
||||
MaxTokens: input.MaxTokens,
|
||||
Thinking: ThinkingMode(strings.TrimSpace(input.Thinking)),
|
||||
Metadata: input.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
func toServiceResponsesMessages(input []llmcontracts.ResponsesMessage) []ArkResponsesMessage {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
output := make([]ArkResponsesMessage, 0, len(input))
|
||||
for _, item := range input {
|
||||
output = append(output, ArkResponsesMessage{
|
||||
Role: item.Role,
|
||||
Text: item.Text,
|
||||
ImageURL: item.ImageURL,
|
||||
ImageDetail: item.ImageDetail,
|
||||
})
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func toServiceResponsesOptions(input llmcontracts.ResponsesOptions) ArkResponsesOptions {
|
||||
return ArkResponsesOptions{
|
||||
Model: input.Model,
|
||||
Temperature: input.Temperature,
|
||||
MaxOutputTokens: input.MaxOutputTokens,
|
||||
Thinking: ThinkingMode(strings.TrimSpace(input.Thinking)),
|
||||
TextFormat: input.TextFormat,
|
||||
}
|
||||
}
|
||||
|
||||
func toContractTextResult(result *TextResult) *llmcontracts.TextResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return &llmcontracts.TextResult{
|
||||
Text: result.Text,
|
||||
Usage: CloneUsage(result.Usage),
|
||||
FinishReason: result.FinishReason,
|
||||
}
|
||||
}
|
||||
|
||||
func toContractResponsesResult(result *ArkResponsesResult) *llmcontracts.ResponsesResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
output := &llmcontracts.ResponsesResult{
|
||||
Text: result.Text,
|
||||
Status: result.Status,
|
||||
IncompleteReason: result.IncompleteReason,
|
||||
ErrorCode: result.ErrorCode,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
output.Usage = &llmcontracts.ResponsesUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
func toServiceTextResult(result *llmcontracts.TextResult) *TextResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return &TextResult{
|
||||
Text: result.Text,
|
||||
Usage: CloneUsage(result.Usage),
|
||||
FinishReason: result.FinishReason,
|
||||
}
|
||||
}
|
||||
|
||||
func toServiceResponsesResult(result *llmcontracts.ResponsesResult) *ArkResponsesResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
output := &ArkResponsesResult{
|
||||
Text: result.Text,
|
||||
Status: result.Status,
|
||||
IncompleteReason: result.IncompleteReason,
|
||||
ErrorCode: result.ErrorCode,
|
||||
ErrorMessage: result.ErrorMessage,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
output.Usage = &ArkResponsesUsage{
|
||||
InputTokens: result.Usage.InputTokens,
|
||||
OutputTokens: result.Usage.OutputTokens,
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
61
backend/services/llm/stream_accounting.go
Normal file
61
backend/services/llm/stream_accounting.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// usageAccountingStreamReader 负责在流式读取结束时统一回收 usage。
|
||||
type usageAccountingStreamReader struct {
|
||||
source StreamReader
|
||||
onDone func(usage *schema.TokenUsage)
|
||||
|
||||
once sync.Once
|
||||
usage *schema.TokenUsage
|
||||
}
|
||||
|
||||
func NewUsageAccountingStreamReader(source StreamReader, onDone func(usage *schema.TokenUsage)) StreamReader {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
return &usageAccountingStreamReader{
|
||||
source: source,
|
||||
onDone: onDone,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *usageAccountingStreamReader) Recv() (*schema.Message, error) {
|
||||
if r == nil || r.source == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
msg, err := r.source.Recv()
|
||||
if msg != nil && msg.ResponseMeta != nil {
|
||||
r.usage = MergeUsage(r.usage, msg.ResponseMeta.Usage)
|
||||
}
|
||||
if err != nil {
|
||||
r.finish()
|
||||
}
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func (r *usageAccountingStreamReader) Close() error {
|
||||
if r == nil || r.source == nil {
|
||||
return nil
|
||||
}
|
||||
err := r.source.Close()
|
||||
r.finish()
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *usageAccountingStreamReader) finish() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.once.Do(func() {
|
||||
if r.onDone != nil {
|
||||
r.onDone(CloneUsage(r.usage))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,7 @@ type CourseImageParseResponse struct {
|
||||
}
|
||||
|
||||
type CourseImageParseRequest struct {
|
||||
UserID int
|
||||
Filename string
|
||||
MIMEType string
|
||||
ImageBytes []byte
|
||||
|
||||
@@ -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: "至少选择一个标签",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
131
backend/services/tokenstore/dao/cache.go
Normal file
131
backend/services/tokenstore/dao/cache.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCreditSnapshotTTL = 10 * time.Minute
|
||||
defaultCreditBlockedTTL = 30 * time.Minute
|
||||
)
|
||||
|
||||
// CreditBalanceSnapshot 是 TokenStore 在 Redis 中维护的余额快照。
|
||||
type CreditBalanceSnapshot struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
Balance int64 `json:"balance"`
|
||||
TotalRecharged int64 `json:"total_recharged"`
|
||||
TotalRewarded int64 `json:"total_rewarded"`
|
||||
TotalConsumed int64 `json:"total_consumed"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreditCacheDAO 只承载 Credit 余额快照相关的 Redis 能力。
|
||||
type CreditCacheDAO struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewCreditCacheDAO(client *redis.Client) *CreditCacheDAO {
|
||||
return &CreditCacheDAO{client: client}
|
||||
}
|
||||
|
||||
func creditBalanceSnapshotKey(userID uint64) string {
|
||||
return fmt.Sprintf("smartflow:credit_balance_snapshot:%d", userID)
|
||||
}
|
||||
|
||||
func creditBlockedKey(userID uint64) string {
|
||||
return fmt.Sprintf("smartflow:credit_blocked:%d", userID)
|
||||
}
|
||||
|
||||
// SnapshotTTL 返回余额快照默认 TTL。
|
||||
func (d *CreditCacheDAO) SnapshotTTL() time.Duration {
|
||||
return defaultCreditSnapshotTTL
|
||||
}
|
||||
|
||||
// BlockedTTL 返回阻断标记默认 TTL。
|
||||
func (d *CreditCacheDAO) BlockedTTL() time.Duration {
|
||||
return defaultCreditBlockedTTL
|
||||
}
|
||||
|
||||
// GetCreditBalanceSnapshot 读取用户余额快照。
|
||||
func (d *CreditCacheDAO) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*CreditBalanceSnapshot, bool, error) {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
val, err := d.client.Get(ctx, creditBalanceSnapshotKey(userID)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var snapshot CreditBalanceSnapshot
|
||||
if err = json.Unmarshal([]byte(val), &snapshot); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return &snapshot, true, nil
|
||||
}
|
||||
|
||||
// SetCreditBalanceSnapshot 写入用户余额快照。
|
||||
func (d *CreditCacheDAO) SetCreditBalanceSnapshot(ctx context.Context, userID uint64, snapshot CreditBalanceSnapshot, ttl time.Duration) error {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = d.SnapshotTTL()
|
||||
}
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.client.Set(ctx, creditBalanceSnapshotKey(userID), data, ttl).Err()
|
||||
}
|
||||
|
||||
// DeleteCreditBalanceSnapshot 删除余额快照。
|
||||
func (d *CreditCacheDAO) DeleteCreditBalanceSnapshot(ctx context.Context, userID uint64) error {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.client.Del(ctx, creditBalanceSnapshotKey(userID)).Err()
|
||||
}
|
||||
|
||||
// IsUserCreditBlocked 判断用户是否被阻断。
|
||||
func (d *CreditCacheDAO) IsUserCreditBlocked(ctx context.Context, userID uint64) (bool, error) {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
result, err := d.client.Get(ctx, creditBlockedKey(userID)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result == "1", nil
|
||||
}
|
||||
|
||||
// SetUserCreditBlocked 写入用户阻断标记。
|
||||
func (d *CreditCacheDAO) SetUserCreditBlocked(ctx context.Context, userID uint64, ttl time.Duration) error {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = d.BlockedTTL()
|
||||
}
|
||||
return d.client.Set(ctx, creditBlockedKey(userID), "1", ttl).Err()
|
||||
}
|
||||
|
||||
// DeleteUserCreditBlocked 删除用户阻断标记。
|
||||
func (d *CreditCacheDAO) DeleteUserCreditBlocked(ctx context.Context, userID uint64) error {
|
||||
if d == nil || d.client == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
return d.client.Del(ctx, creditBlockedKey(userID)).Err()
|
||||
}
|
||||
@@ -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,
|
||||
SKU: "credit_free_100",
|
||||
Name: "Free",
|
||||
Description: "每日免费发放,适合基础功能体验。",
|
||||
CreditAmount: 100,
|
||||
PriceCent: 0,
|
||||
OriginalPriceCent: 0,
|
||||
Currency: "CNY",
|
||||
Badge: "每日",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 10,
|
||||
},
|
||||
{
|
||||
SKU: "credit_starter_1000",
|
||||
Name: "Starter",
|
||||
Description: "入门级额度,有效期 1 个月。续费时时间和额度均可累加。",
|
||||
CreditAmount: 1000,
|
||||
PriceCent: 990,
|
||||
OriginalPriceCent: 990,
|
||||
Currency: "CNY",
|
||||
Badge: "入门",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 20,
|
||||
},
|
||||
{
|
||||
SKU: "credit_lite_3000",
|
||||
Name: "Lite",
|
||||
Description: "经济型套餐,有效期 1 个月。适合日常轻度规划。",
|
||||
CreditAmount: 3000,
|
||||
PriceCent: 1990,
|
||||
OriginalPriceCent: 1990,
|
||||
Currency: "CNY",
|
||||
Badge: "经济",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 30,
|
||||
},
|
||||
{
|
||||
SKU: "credit_pro_10000",
|
||||
Name: "Pro",
|
||||
Description: "专业版套餐,有效期 1 个月。最受深度规划用户欢迎。",
|
||||
CreditAmount: 10000,
|
||||
PriceCent: 3990,
|
||||
OriginalPriceCent: 3990,
|
||||
Currency: "CNY",
|
||||
Badge: "Most Popular",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 40,
|
||||
},
|
||||
{
|
||||
SKU: "credit_max_40000",
|
||||
Name: "Max",
|
||||
Description: "旗舰级套餐,有效期 1 个月。极致体验,额度充沛。",
|
||||
CreditAmount: 40000,
|
||||
PriceCent: 9990,
|
||||
OriginalPriceCent: 9990,
|
||||
Currency: "CNY",
|
||||
Badge: "旗舰",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 50,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultCreditRewardRules() []storemodel.CreditRewardRule {
|
||||
return []storemodel.CreditRewardRule{
|
||||
{
|
||||
Source: storemodel.CreditLedgerSourceForumLike,
|
||||
Name: "计划被点赞奖励",
|
||||
Amount: 1,
|
||||
Status: tokenmodel.TokenRewardRuleStatusActive,
|
||||
Status: storemodel.CreditRewardRuleStatusActive,
|
||||
Description: "预留论坛点赞正向激励。",
|
||||
},
|
||||
{
|
||||
Source: tokenmodel.TokenGrantSourceForumImport,
|
||||
Source: storemodel.CreditLedgerSourceForumImport,
|
||||
Name: "计划被导入奖励",
|
||||
Amount: 5,
|
||||
Status: tokenmodel.TokenRewardRuleStatusActive,
|
||||
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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
380
backend/services/tokenstore/dao/creditstore.go
Normal file
380
backend/services/tokenstore/dao/creditstore.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
creditmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// CreditStoreDAO 承载 Credit 权威账本相关表的持久化访问。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只访问 credit_accounts、credit_ledger、credit_products、credit_orders、credit_price_rules、credit_reward_rules;
|
||||
// 2. 只提供查询、事务、行锁与原子状态更新,不承载 RPC/前端展示拼装;
|
||||
// 3. 幂等语义、扣费校验和缓存同步策略由服务层负责。
|
||||
type CreditStoreDAO struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCreditStoreDAO(db *gorm.DB) *CreditStoreDAO {
|
||||
return &CreditStoreDAO{db: db}
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) WithTx(tx *gorm.DB) *CreditStoreDAO {
|
||||
return &CreditStoreDAO{db: tx}
|
||||
}
|
||||
|
||||
// Transaction 在一个数据库事务内执行 Credit 账本写操作。
|
||||
func (dao *CreditStoreDAO) Transaction(ctx context.Context, fn func(txDAO *CreditStoreDAO) error) error {
|
||||
return dao.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return fn(dao.WithTx(tx))
|
||||
})
|
||||
}
|
||||
|
||||
type ListCreditOrdersQuery struct {
|
||||
UserID uint64
|
||||
Page int
|
||||
PageSize int
|
||||
Status string
|
||||
}
|
||||
|
||||
type ListCreditTransactionsQuery struct {
|
||||
UserID uint64
|
||||
Page int
|
||||
PageSize int
|
||||
Source string
|
||||
Direction string
|
||||
}
|
||||
|
||||
type GetCreditConsumptionDashboardQuery struct {
|
||||
UserID uint64
|
||||
CreatedFrom *time.Time
|
||||
}
|
||||
|
||||
type CreditConsumptionDashboardAggregate struct {
|
||||
CreditConsumed int64
|
||||
TokenConsumed int64
|
||||
}
|
||||
|
||||
type ListCreditPriceRulesQuery struct {
|
||||
Scene string
|
||||
ProviderName string
|
||||
ModelName string
|
||||
Status string
|
||||
}
|
||||
|
||||
type ListCreditRewardRulesQuery struct {
|
||||
Source string
|
||||
Status string
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) ListActiveProducts(ctx context.Context) ([]creditmodel.CreditProduct, error) {
|
||||
var products []creditmodel.CreditProduct
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("status = ?", creditmodel.CreditProductStatusActive).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&products).Error
|
||||
return products, err
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) FindActiveProductByID(ctx context.Context, productID uint64) (*creditmodel.CreditProduct, error) {
|
||||
var product creditmodel.CreditProduct
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("id = ? AND status = ?", productID, creditmodel.CreditProductStatusActive).
|
||||
First(&product).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &product, nil
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) FindOrderByUserIdempotencyKey(ctx context.Context, userID uint64, key string) (*creditmodel.CreditOrder, error) {
|
||||
var order creditmodel.CreditOrder
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("user_id = ? AND idempotency_key = ?", userID, key).
|
||||
First(&order).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) CreateOrder(ctx context.Context, order *creditmodel.CreditOrder) error {
|
||||
return dao.db.WithContext(ctx).Create(order).Error
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) CountOrders(ctx context.Context, query ListCreditOrdersQuery) (int64, error) {
|
||||
db := dao.db.WithContext(ctx).
|
||||
Model(&creditmodel.CreditOrder{}).
|
||||
Where("user_id = ?", query.UserID)
|
||||
if status := strings.TrimSpace(query.Status); status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var total int64
|
||||
err := db.Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) ListOrders(ctx context.Context, query ListCreditOrdersQuery) ([]creditmodel.CreditOrder, error) {
|
||||
db := dao.db.WithContext(ctx).
|
||||
Where("user_id = ?", query.UserID)
|
||||
if status := strings.TrimSpace(query.Status); status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var orders []creditmodel.CreditOrder
|
||||
err := db.Order("created_at DESC, id DESC").
|
||||
Offset((query.Page - 1) * query.PageSize).
|
||||
Limit(query.PageSize).
|
||||
Find(&orders).Error
|
||||
return orders, err
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) FindOrderByID(ctx context.Context, orderID uint64) (*creditmodel.CreditOrder, error) {
|
||||
var order creditmodel.CreditOrder
|
||||
err := dao.db.WithContext(ctx).Where("id = ?", orderID).First(&order).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) LockOrderByID(ctx context.Context, orderID uint64) (*creditmodel.CreditOrder, error) {
|
||||
var order creditmodel.CreditOrder
|
||||
err := dao.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", orderID).
|
||||
First(&order).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &order, nil
|
||||
}
|
||||
|
||||
// UpdateOrderState 只负责把 Credit 订单持久化到最新状态。
|
||||
func (dao *CreditStoreDAO) UpdateOrderState(ctx context.Context, orderID uint64, status string, paidAt *time.Time, creditedAt *time.Time, paymentMode string) error {
|
||||
updates := map[string]any{
|
||||
"status": status,
|
||||
"paid_at": paidAt,
|
||||
"credited_at": creditedAt,
|
||||
"payment_mode": paymentMode,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
return dao.db.WithContext(ctx).
|
||||
Model(&creditmodel.CreditOrder{}).
|
||||
Where("id = ?", orderID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) FindLedgerByEventID(ctx context.Context, eventID string) (*creditmodel.CreditLedger, error) {
|
||||
var ledger creditmodel.CreditLedger
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("event_id = ?", eventID).
|
||||
First(&ledger).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ledger, nil
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) FindLatestLedgerByOrderID(ctx context.Context, orderID uint64) (*creditmodel.CreditLedger, error) {
|
||||
var ledger creditmodel.CreditLedger
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("order_id = ?", orderID).
|
||||
Order("created_at DESC, id DESC").
|
||||
First(&ledger).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ledger, nil
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) ListLedgerByOrderIDs(ctx context.Context, orderIDs []uint64) ([]creditmodel.CreditLedger, error) {
|
||||
if len(orderIDs) == 0 {
|
||||
return []creditmodel.CreditLedger{}, nil
|
||||
}
|
||||
|
||||
var ledgers []creditmodel.CreditLedger
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("order_id IN ?", orderIDs).
|
||||
Order("created_at DESC, id DESC").
|
||||
Find(&ledgers).Error
|
||||
return ledgers, err
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) CreateLedger(ctx context.Context, ledger *creditmodel.CreditLedger) error {
|
||||
return dao.db.WithContext(ctx).Create(ledger).Error
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) CountTransactions(ctx context.Context, query ListCreditTransactionsQuery) (int64, error) {
|
||||
db := dao.db.WithContext(ctx).
|
||||
Model(&creditmodel.CreditLedger{}).
|
||||
Where("user_id = ?", query.UserID)
|
||||
if source := strings.TrimSpace(query.Source); source != "" {
|
||||
db = db.Where("source = ?", source)
|
||||
}
|
||||
if direction := strings.TrimSpace(query.Direction); direction != "" {
|
||||
db = db.Where("direction = ?", direction)
|
||||
}
|
||||
|
||||
var total int64
|
||||
err := db.Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) ListTransactions(ctx context.Context, query ListCreditTransactionsQuery) ([]creditmodel.CreditLedger, error) {
|
||||
db := dao.db.WithContext(ctx).
|
||||
Where("user_id = ?", query.UserID)
|
||||
if source := strings.TrimSpace(query.Source); source != "" {
|
||||
db = db.Where("source = ?", source)
|
||||
}
|
||||
if direction := strings.TrimSpace(query.Direction); direction != "" {
|
||||
db = db.Where("direction = ?", direction)
|
||||
}
|
||||
|
||||
var items []creditmodel.CreditLedger
|
||||
err := db.Order("created_at DESC, id DESC").
|
||||
Offset((query.Page - 1) * query.PageSize).
|
||||
Limit(query.PageSize).
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// GetCreditConsumptionDashboard 只聚合当前用户 AI 扣费流水对应的消耗看板数据。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只统计 source=charge 且 direction=expense 的流水,保证商店页口径和真实扣费一致。
|
||||
// 2. 默认排除 failed 流水;skipped 会保留,这样可展示“有 Token 消耗但 Credit 未扣减”的真实情况。
|
||||
// 3. 这里只做聚合查询,不负责周期归一化、权限校验和前端文案拼装。
|
||||
func (dao *CreditStoreDAO) GetCreditConsumptionDashboard(ctx context.Context, query GetCreditConsumptionDashboardQuery) (CreditConsumptionDashboardAggregate, error) {
|
||||
type aggregateRow struct {
|
||||
CreditConsumed int64 `gorm:"column:credit_consumed"`
|
||||
TokenConsumed int64 `gorm:"column:token_consumed"`
|
||||
}
|
||||
|
||||
db := dao.db.WithContext(ctx).
|
||||
Model(&creditmodel.CreditLedger{}).
|
||||
Select(`
|
||||
COALESCE(SUM(CASE WHEN amount < 0 THEN -amount ELSE 0 END), 0) AS credit_consumed,
|
||||
COALESCE(SUM(
|
||||
CASE
|
||||
WHEN COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.total_tokens')) AS SIGNED), 0) > 0
|
||||
THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.total_tokens')) AS SIGNED)
|
||||
ELSE GREATEST(
|
||||
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.input_tokens')) AS SIGNED), 0) +
|
||||
COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.output_tokens')) AS SIGNED), 0),
|
||||
0
|
||||
)
|
||||
END
|
||||
), 0) AS token_consumed
|
||||
`).
|
||||
Where("user_id = ?", query.UserID).
|
||||
Where("source = ?", creditmodel.CreditLedgerSourceCharge).
|
||||
Where("direction = ?", creditmodel.CreditLedgerDirectionExpense).
|
||||
Where("status <> ?", creditmodel.CreditLedgerStatusFailed)
|
||||
|
||||
if query.CreatedFrom != nil {
|
||||
db = db.Where("created_at >= ?", *query.CreatedFrom)
|
||||
}
|
||||
|
||||
var row aggregateRow
|
||||
if err := db.Scan(&row).Error; err != nil {
|
||||
return CreditConsumptionDashboardAggregate{}, err
|
||||
}
|
||||
return CreditConsumptionDashboardAggregate{
|
||||
CreditConsumed: row.CreditConsumed,
|
||||
TokenConsumed: row.TokenConsumed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) FindAccountByUserID(ctx context.Context, userID uint64) (*creditmodel.CreditAccount, error) {
|
||||
var account creditmodel.CreditAccount
|
||||
err := dao.db.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
First(&account).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) LockAccountByUserID(ctx context.Context, userID uint64) (*creditmodel.CreditAccount, error) {
|
||||
var account creditmodel.CreditAccount
|
||||
err := dao.db.WithContext(ctx).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("user_id = ?", userID).
|
||||
First(&account).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) CreateAccount(ctx context.Context, account *creditmodel.CreditAccount) error {
|
||||
return dao.db.WithContext(ctx).Create(account).Error
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) SaveAccount(ctx context.Context, account *creditmodel.CreditAccount) error {
|
||||
return dao.db.WithContext(ctx).Save(account).Error
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) ListPriceRules(ctx context.Context, query ListCreditPriceRulesQuery) ([]creditmodel.CreditPriceRule, error) {
|
||||
db := dao.db.WithContext(ctx).Model(&creditmodel.CreditPriceRule{})
|
||||
if scene := strings.TrimSpace(query.Scene); scene != "" {
|
||||
db = db.Where("scene = ?", scene)
|
||||
}
|
||||
if providerName := strings.TrimSpace(query.ProviderName); providerName != "" {
|
||||
db = db.Where("provider_name = ?", providerName)
|
||||
}
|
||||
if modelName := strings.TrimSpace(query.ModelName); modelName != "" {
|
||||
db = db.Where("model_name = ?", modelName)
|
||||
}
|
||||
if status := strings.TrimSpace(query.Status); status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var rules []creditmodel.CreditPriceRule
|
||||
err := db.Order("priority DESC, id ASC").Find(&rules).Error
|
||||
return rules, err
|
||||
}
|
||||
|
||||
func (dao *CreditStoreDAO) ListRewardRules(ctx context.Context, query ListCreditRewardRulesQuery) ([]creditmodel.CreditRewardRule, error) {
|
||||
db := dao.db.WithContext(ctx).Model(&creditmodel.CreditRewardRule{})
|
||||
if source := strings.TrimSpace(query.Source); source != "" {
|
||||
db = db.Where("source = ?", source)
|
||||
}
|
||||
if status := strings.TrimSpace(query.Status); status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var rules []creditmodel.CreditRewardRule
|
||||
err := db.Order("id ASC").Find(&rules).Error
|
||||
return rules, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
206
backend/services/tokenstore/model/credit.go
Normal file
206
backend/services/tokenstore/model/credit.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// CreditProductStatusActive 表示商品可在 Credit 商店展示和购买。
|
||||
CreditProductStatusActive = "active"
|
||||
// CreditProductStatusInactive 表示商品已下架。
|
||||
CreditProductStatusInactive = "inactive"
|
||||
)
|
||||
|
||||
const (
|
||||
// CreditOrderStatusPending 表示订单已创建,等待支付确认。
|
||||
CreditOrderStatusPending = "pending"
|
||||
// CreditOrderStatusPaid 表示订单已确认支付,等待入账。
|
||||
CreditOrderStatusPaid = "paid"
|
||||
// CreditOrderStatusCredited 表示订单对应的 Credit 已经写入账本。
|
||||
CreditOrderStatusCredited = "credited"
|
||||
// CreditOrderStatusClosed 表示订单已关闭。
|
||||
CreditOrderStatusClosed = "closed"
|
||||
)
|
||||
|
||||
const (
|
||||
// CreditLedgerDirectionIncome 表示正向入账。
|
||||
CreditLedgerDirectionIncome = "income"
|
||||
// CreditLedgerDirectionExpense 表示扣费出账。
|
||||
CreditLedgerDirectionExpense = "expense"
|
||||
)
|
||||
|
||||
const (
|
||||
// CreditLedgerStatusApplied 表示该笔流水已经成为权威账本事实。
|
||||
CreditLedgerStatusApplied = "applied"
|
||||
// CreditLedgerStatusSkipped 表示事件被消费但不影响余额。
|
||||
CreditLedgerStatusSkipped = "skipped"
|
||||
// CreditLedgerStatusFailed 预留给后续补偿或人工处理。
|
||||
CreditLedgerStatusFailed = "failed"
|
||||
)
|
||||
|
||||
const (
|
||||
// CreditLedgerSourcePurchase 表示用户购买 Credit 商品。
|
||||
CreditLedgerSourcePurchase = "purchase"
|
||||
// CreditLedgerSourceCharge 表示 LLM 调用扣费。
|
||||
CreditLedgerSourceCharge = "charge"
|
||||
// CreditLedgerSourceForumLike 预留论坛点赞奖励。
|
||||
CreditLedgerSourceForumLike = "forum_like"
|
||||
// CreditLedgerSourceForumImport 预留论坛导入奖励。
|
||||
CreditLedgerSourceForumImport = "forum_import"
|
||||
// CreditLedgerSourceManual 预留人工补偿。
|
||||
CreditLedgerSourceManual = "manual"
|
||||
)
|
||||
|
||||
const (
|
||||
// CreditPriceRuleStatusActive 表示价格规则启用。
|
||||
CreditPriceRuleStatusActive = "active"
|
||||
// CreditPriceRuleStatusInactive 表示价格规则停用。
|
||||
CreditPriceRuleStatusInactive = "inactive"
|
||||
)
|
||||
|
||||
const (
|
||||
// CreditRewardRuleStatusActive 表示奖励规则启用。
|
||||
CreditRewardRuleStatusActive = "active"
|
||||
// CreditRewardRuleStatusInactive 表示奖励规则停用。
|
||||
CreditRewardRuleStatusInactive = "inactive"
|
||||
)
|
||||
|
||||
// CreditAccount 是 Credit 权威余额表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只保存用户在 TokenStore 账本口径下的当前余额与累计统计;
|
||||
// 2. balance 允许被异步结算扣到 0 以下,后续由 Guard 和充值链路阻断新增调用;
|
||||
// 3. 不保存逐笔明细,逐笔事实统一以 credit_ledger 为准。
|
||||
type CreditAccount struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_credit_accounts_user;comment:用户ID"`
|
||||
Balance int64 `gorm:"column:balance;not null;default:0;comment:当前Credit余额"`
|
||||
TotalRecharged int64 `gorm:"column:total_recharged;not null;default:0;comment:累计购买入账"`
|
||||
TotalRewarded int64 `gorm:"column:total_rewarded;not null;default:0;comment:累计奖励入账"`
|
||||
TotalConsumed int64 `gorm:"column:total_consumed;not null;default:0;comment:累计扣费出账"`
|
||||
LastLedgerEventID string `gorm:"column:last_ledger_event_id;type:varchar(128);comment:最近一次账本事件ID"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
}
|
||||
|
||||
func (CreditAccount) TableName() string {
|
||||
return "credit_accounts"
|
||||
}
|
||||
|
||||
// CreditLedger 是 Credit 权威流水表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. event_id 是最终幂等键,所有异步扣费、充值、奖励都依赖它去重;
|
||||
// 2. amount 使用带符号值,正数表示入账,负数表示扣费,0 表示消费成功但不影响余额;
|
||||
// 3. balance_before / balance_after 记录事件落账时的权威余额快照。
|
||||
type CreditLedger struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
EventID string `gorm:"column:event_id;type:varchar(128);not null;uniqueIndex:uk_credit_ledger_event;comment:最终幂等事件ID"`
|
||||
UserID uint64 `gorm:"column:user_id;not null;index:idx_credit_ledger_user_created,priority:1;comment:用户ID"`
|
||||
Source string `gorm:"column:source;type:varchar(32);not null;index:idx_credit_ledger_user_created,priority:2;comment:purchase/charge/forum_like/forum_import/manual"`
|
||||
SourceLabel string `gorm:"column:source_label;type:varchar(64);comment:来源展示文案"`
|
||||
Direction string `gorm:"column:direction;type:varchar(16);not null;comment:income/expense"`
|
||||
OrderID *uint64 `gorm:"column:order_id;index:idx_credit_ledger_order;comment:关联订单ID"`
|
||||
SourceRefID *string `gorm:"column:source_ref_id;type:varchar(128);index:idx_credit_ledger_source_ref;comment:来源业务ID"`
|
||||
Amount int64 `gorm:"column:amount;not null;comment:本次Credit变动,正数入账负数扣费"`
|
||||
BalanceBefore int64 `gorm:"column:balance_before;not null;default:0;comment:落账前余额"`
|
||||
BalanceAfter int64 `gorm:"column:balance_after;not null;default:0;comment:落账后余额"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'applied';index:idx_credit_ledger_status;comment:applied/skipped/failed"`
|
||||
Description string `gorm:"column:description;type:varchar(255);comment:展示描述"`
|
||||
MetadataJSON string `gorm:"column:metadata_json;type:json;comment:扩展元数据"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_credit_ledger_user_created,priority:3;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
}
|
||||
|
||||
func (CreditLedger) TableName() string {
|
||||
return "credit_ledger"
|
||||
}
|
||||
|
||||
// CreditProduct 是 Credit 商店商品表。
|
||||
type CreditProduct struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
SKU string `gorm:"column:sku;type:varchar(64);not null;uniqueIndex:uk_credit_products_sku;comment:商品稳定编码"`
|
||||
Name string `gorm:"column:name;type:varchar(80);not null;comment:商品名称"`
|
||||
Description string `gorm:"column:description;type:varchar(255);comment:商品描述"`
|
||||
CreditAmount int64 `gorm:"column:credit_amount;not null;comment:包含Credit数量"`
|
||||
PriceCent int64 `gorm:"column:price_cent;not null;comment:价格,单位分"`
|
||||
OriginalPriceCent int64 `gorm:"column:original_price_cent;not null;default:0;comment:优惠前价格,单位分"`
|
||||
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
|
||||
Badge string `gorm:"column:badge;type:varchar(32);comment:角标"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_credit_products_status_sort,priority:1;comment:active/inactive"`
|
||||
SortOrder int `gorm:"column:sort_order;not null;default:0;index:idx_credit_products_status_sort,priority:2;comment:展示排序"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
}
|
||||
|
||||
func (CreditProduct) TableName() string {
|
||||
return "credit_products"
|
||||
}
|
||||
|
||||
// CreditOrder 是 Credit 商品订单表。
|
||||
type CreditOrder struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex:uk_credit_orders_order_no;comment:订单号"`
|
||||
UserID uint64 `gorm:"column:user_id;not null;uniqueIndex:uk_credit_orders_user_idem,priority:1;index:idx_credit_orders_user_status_created,priority:1;comment:下单用户ID"`
|
||||
ProductID uint64 `gorm:"column:product_id;not null;index:idx_credit_orders_product;comment:商品ID"`
|
||||
ProductSKU string `gorm:"column:product_sku;type:varchar(64);not null;comment:商品SKU快照"`
|
||||
ProductName string `gorm:"column:product_name;type:varchar(80);not null;comment:商品名称快照"`
|
||||
ProductSnapshotJSON string `gorm:"column:product_snapshot_json;type:json;not null;comment:商品完整快照JSON"`
|
||||
Quantity int `gorm:"column:quantity;not null;default:1;comment:购买数量"`
|
||||
CreditAmount int64 `gorm:"column:credit_amount;not null;comment:订单总Credit数量"`
|
||||
AmountCent int64 `gorm:"column:amount_cent;not null;comment:订单总金额,单位分"`
|
||||
Currency string `gorm:"column:currency;type:varchar(16);not null;default:'CNY';comment:币种"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_credit_orders_user_status_created,priority:2;comment:pending/paid/credited/closed"`
|
||||
PaymentMode string `gorm:"column:payment_mode;type:varchar(32);not null;default:'mock';comment:支付模式"`
|
||||
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(128);uniqueIndex:uk_credit_orders_user_idem,priority:2;comment:创建订单幂等键"`
|
||||
PaidAt *time.Time `gorm:"column:paid_at;comment:支付确认时间"`
|
||||
CreditedAt *time.Time `gorm:"column:credited_at;comment:入账时间"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_credit_orders_user_status_created,priority:3;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
}
|
||||
|
||||
func (CreditOrder) TableName() string {
|
||||
return "credit_orders"
|
||||
}
|
||||
|
||||
// CreditPriceRule 是 LLM Credit 计价规则表。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 该表表达“某个 provider/model 在某场景下如何换算人民币与 Credit”的运营配置;
|
||||
// 2. 第二步先完成表结构与读取能力,具体由 LLM 服务如何引用放到后续切流阶段;
|
||||
// 3. 当前结算事件已带出最终 rmb_cost_micros 与 credit_cost,因此消费侧不在这里二次计算。
|
||||
type CreditPriceRule struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Scene string `gorm:"column:scene;type:varchar(64);not null;index:idx_credit_price_rules_scene_status,priority:1;comment:计费场景"`
|
||||
ProviderName string `gorm:"column:provider_name;type:varchar(64);not null;comment:模型提供方"`
|
||||
ModelName string `gorm:"column:model_name;type:varchar(128);not null;comment:模型名称"`
|
||||
InputPriceMicros int64 `gorm:"column:input_price_micros;not null;default:0;comment:输入Token单价,单位微人民币"`
|
||||
OutputPriceMicros int64 `gorm:"column:output_price_micros;not null;default:0;comment:输出Token单价,单位微人民币"`
|
||||
CachedPriceMicros int64 `gorm:"column:cached_price_micros;not null;default:0;comment:缓存Token单价,单位微人民币"`
|
||||
ReasoningPriceMicros int64 `gorm:"column:reasoning_price_micros;not null;default:0;comment:推理Token单价,单位微人民币"`
|
||||
CreditPerYuan int64 `gorm:"column:credit_per_yuan;not null;default:0;comment:1元人民币换算多少Credit"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'inactive';index:idx_credit_price_rules_scene_status,priority:2;comment:active/inactive"`
|
||||
Priority int `gorm:"column:priority;not null;default:0;comment:匹配优先级,越大越优先"`
|
||||
Description string `gorm:"column:description;type:varchar(255);comment:规则说明"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
}
|
||||
|
||||
func (CreditPriceRule) TableName() string {
|
||||
return "credit_price_rules"
|
||||
}
|
||||
|
||||
// CreditRewardRule 是 Credit 奖励规则表。
|
||||
type CreditRewardRule struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Source string `gorm:"column:source;type:varchar(32);not null;uniqueIndex:uk_credit_reward_rules_source;comment:奖励来源"`
|
||||
Name string `gorm:"column:name;type:varchar(80);not null;comment:规则名称"`
|
||||
Amount int64 `gorm:"column:amount;not null;comment:奖励Credit数量"`
|
||||
Status string `gorm:"column:status;type:varchar(32);not null;default:'active';index:idx_credit_reward_rules_status;comment:active/inactive"`
|
||||
Description string `gorm:"column:description;type:varchar(255);comment:规则描述"`
|
||||
ConfigJSON *string `gorm:"column:config_json;type:json;comment:扩展配置"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间"`
|
||||
}
|
||||
|
||||
func (CreditRewardRule) TableName() string {
|
||||
return "credit_reward_rules"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
392
backend/services/tokenstore/rpc/credit.go
Normal file
392
backend/services/tokenstore/rpc/credit.go
Normal file
@@ -0,0 +1,392 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/tokenstore/rpc/pb"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
)
|
||||
|
||||
func (h *Handler) GetCreditBalanceSnapshot(ctx context.Context, req *pb.GetCreditBalanceSnapshotRequest) (*pb.GetCreditBalanceSnapshotResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
snapshot, err := svc.GetCreditBalanceSnapshot(ctx, req.UserId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.GetCreditBalanceSnapshotResponse{Snapshot: creditBalanceSnapshotToPB(snapshot)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetCreditConsumptionDashboard(ctx context.Context, req *pb.GetCreditConsumptionDashboardRequest) (*pb.GetCreditConsumptionDashboardResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
dashboard, err := svc.GetCreditConsumptionDashboard(ctx, creditcontracts.GetCreditConsumptionDashboardRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
Period: req.Period,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.GetCreditConsumptionDashboardResponse{Dashboard: creditConsumptionDashboardToPB(dashboard)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListCreditProducts(ctx context.Context, req *pb.ListCreditProductsRequest) (*pb.ListCreditProductsResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, err := svc.ListCreditProducts(ctx, req.ActorUserId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListCreditProductsResponse{Items: creditProductsToPB(items)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCreditOrder(ctx context.Context, req *pb.CreateCreditOrderRequest) (*pb.CreateCreditOrderResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
order, err := svc.CreateCreditOrder(ctx, creditcontracts.CreateCreditOrderRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
ProductID: req.ProductId,
|
||||
Quantity: int(req.Quantity),
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.CreateCreditOrderResponse{Order: creditOrderToPB(order)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListCreditOrders(ctx context.Context, req *pb.ListCreditOrdersRequest) (*pb.ListCreditOrdersResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, page, err := svc.ListCreditOrders(ctx, creditcontracts.ListCreditOrdersRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
Page: int(req.Page),
|
||||
PageSize: int(req.PageSize),
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListCreditOrdersResponse{
|
||||
Items: creditOrdersToPB(items),
|
||||
Page: creditPageToPB(page),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetCreditOrder(ctx context.Context, req *pb.GetCreditOrderRequest) (*pb.GetCreditOrderResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
order, err := svc.GetCreditOrder(ctx, req.ActorUserId, req.OrderId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.GetCreditOrderResponse{Order: creditOrderToPB(order)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) MockPaidCreditOrder(ctx context.Context, req *pb.MockPaidCreditOrderRequest) (*pb.MockPaidCreditOrderResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
order, err := svc.MockPaidCreditOrder(ctx, creditcontracts.MockPaidCreditOrderRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
OrderID: req.OrderId,
|
||||
MockChannel: req.MockChannel,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.MockPaidCreditOrderResponse{Order: creditOrderToPB(order)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListCreditTransactions(ctx context.Context, req *pb.ListCreditTransactionsRequest) (*pb.ListCreditTransactionsResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, page, err := svc.ListCreditTransactions(ctx, creditcontracts.ListCreditTransactionsRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
Page: int(req.Page),
|
||||
PageSize: int(req.PageSize),
|
||||
Source: req.Source,
|
||||
Direction: req.Direction,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListCreditTransactionsResponse{
|
||||
Items: creditTransactionsToPB(items),
|
||||
Page: creditPageToPB(page),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListCreditPriceRules(ctx context.Context, req *pb.ListCreditPriceRulesRequest) (*pb.ListCreditPriceRulesResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, err := svc.ListCreditPriceRules(ctx, creditcontracts.ListCreditPriceRulesRequest{
|
||||
Scene: req.Scene,
|
||||
ProviderName: req.ProviderName,
|
||||
ModelName: req.ModelName,
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListCreditPriceRulesResponse{Items: creditPriceRulesToPB(items)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListCreditRewardRules(ctx context.Context, req *pb.ListCreditRewardRulesRequest) (*pb.ListCreditRewardRulesResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, err := svc.ListCreditRewardRules(ctx, creditcontracts.ListCreditRewardRulesRequest{
|
||||
Source: req.Source,
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListCreditRewardRulesResponse{Items: creditRewardRulesToPB(items)}, nil
|
||||
}
|
||||
|
||||
func creditPageToPB(page creditcontracts.PageResult) *pb.PageResponse {
|
||||
return &pb.PageResponse{
|
||||
Page: int32(page.Page),
|
||||
PageSize: int32(page.PageSize),
|
||||
Total: int32(page.Total),
|
||||
HasMore: page.HasMore,
|
||||
}
|
||||
}
|
||||
|
||||
func creditBalanceSnapshotToPB(snapshot *creditcontracts.CreditBalanceSnapshot) *pb.CreditBalanceSnapshotView {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
return &pb.CreditBalanceSnapshotView{
|
||||
UserId: snapshot.UserID,
|
||||
Balance: snapshot.Balance,
|
||||
TotalRecharged: snapshot.TotalRecharged,
|
||||
TotalRewarded: snapshot.TotalRewarded,
|
||||
TotalConsumed: snapshot.TotalConsumed,
|
||||
IsBlocked: snapshot.IsBlocked,
|
||||
SnapshotSource: snapshot.SnapshotSource,
|
||||
UpdatedAt: snapshot.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func creditConsumptionDashboardToPB(view *creditcontracts.CreditConsumptionDashboardView) *pb.CreditConsumptionDashboardView {
|
||||
if view == nil {
|
||||
return nil
|
||||
}
|
||||
return &pb.CreditConsumptionDashboardView{
|
||||
Period: view.Period,
|
||||
CreditConsumed: view.CreditConsumed,
|
||||
TokenConsumed: view.TokenConsumed,
|
||||
}
|
||||
}
|
||||
|
||||
func creditProductToPB(product creditcontracts.CreditProductView) *pb.CreditProductView {
|
||||
return &pb.CreditProductView{
|
||||
ProductId: product.ProductID,
|
||||
Name: product.Name,
|
||||
Description: product.Description,
|
||||
CreditAmount: product.CreditAmount,
|
||||
PriceCent: product.PriceCent,
|
||||
OriginalPriceCent: product.OriginalPriceCent,
|
||||
PriceText: product.PriceText,
|
||||
Currency: product.Currency,
|
||||
Badge: product.Badge,
|
||||
Status: product.Status,
|
||||
SortOrder: int32(product.SortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
func creditProductsToPB(items []creditcontracts.CreditProductView) []*pb.CreditProductView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.CreditProductView, 0, len(items))
|
||||
for i := range items {
|
||||
result = append(result, creditProductToPB(items[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditOrderToPB(order *creditcontracts.CreditOrderView) *pb.CreditOrderView {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
return &pb.CreditOrderView{
|
||||
OrderId: order.OrderID,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
CreditAmount: order.CreditAmount,
|
||||
AmountCent: order.AmountCent,
|
||||
PriceText: order.PriceText,
|
||||
Currency: order.Currency,
|
||||
PaymentMode: order.PaymentMode,
|
||||
CreatedAt: order.CreatedAt,
|
||||
PaidAt: tokenStringFromPtr(order.PaidAt),
|
||||
CreditedAt: tokenStringFromPtr(order.CreditedAt),
|
||||
ProductSnapshot: order.ProductSnapshot,
|
||||
ProductName: order.ProductName,
|
||||
Quantity: int32(order.Quantity),
|
||||
}
|
||||
}
|
||||
|
||||
func creditOrdersToPB(items []creditcontracts.CreditOrderView) []*pb.CreditOrderView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.CreditOrderView, 0, len(items))
|
||||
for i := range items {
|
||||
item := items[i]
|
||||
result = append(result, creditOrderToPB(&item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditTransactionToPB(item creditcontracts.CreditTransactionView) *pb.CreditTransactionView {
|
||||
result := &pb.CreditTransactionView{
|
||||
TransactionId: item.TransactionID,
|
||||
EventId: item.EventID,
|
||||
Source: item.Source,
|
||||
SourceLabel: item.SourceLabel,
|
||||
Direction: item.Direction,
|
||||
Amount: item.Amount,
|
||||
BalanceAfter: item.BalanceAfter,
|
||||
Status: item.Status,
|
||||
Description: item.Description,
|
||||
MetadataJson: item.MetadataJSON,
|
||||
CreatedAt: item.CreatedAt,
|
||||
}
|
||||
if item.OrderID != nil {
|
||||
result.OrderId = *item.OrderID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditTransactionsToPB(items []creditcontracts.CreditTransactionView) []*pb.CreditTransactionView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.CreditTransactionView, 0, len(items))
|
||||
for i := range items {
|
||||
result = append(result, creditTransactionToPB(items[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditPriceRuleToPB(rule creditcontracts.CreditPriceRuleView) *pb.CreditPriceRuleView {
|
||||
return &pb.CreditPriceRuleView{
|
||||
RuleId: rule.RuleID,
|
||||
Scene: rule.Scene,
|
||||
ProviderName: rule.ProviderName,
|
||||
ModelName: rule.ModelName,
|
||||
InputPriceMicros: rule.InputPriceMicros,
|
||||
OutputPriceMicros: rule.OutputPriceMicros,
|
||||
CachedPriceMicros: rule.CachedPriceMicros,
|
||||
ReasoningPriceMicros: rule.ReasoningPriceMicros,
|
||||
CreditPerYuan: rule.CreditPerYuan,
|
||||
Status: rule.Status,
|
||||
Priority: int32(rule.Priority),
|
||||
Description: rule.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func creditPriceRulesToPB(items []creditcontracts.CreditPriceRuleView) []*pb.CreditPriceRuleView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.CreditPriceRuleView, 0, len(items))
|
||||
for i := range items {
|
||||
result = append(result, creditPriceRuleToPB(items[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func creditRewardRuleToPB(rule creditcontracts.CreditRewardRuleView) *pb.CreditRewardRuleView {
|
||||
return &pb.CreditRewardRuleView{
|
||||
RuleId: rule.RuleID,
|
||||
Source: rule.Source,
|
||||
Name: rule.Name,
|
||||
Amount: rule.Amount,
|
||||
Status: rule.Status,
|
||||
Description: rule.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func creditRewardRulesToPB(items []creditcontracts.CreditRewardRuleView) []*pb.CreditRewardRuleView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.CreditRewardRuleView, 0, len(items))
|
||||
for i := range items {
|
||||
result = append(result, creditRewardRuleToPB(items[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tokenStringFromPtr(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
@@ -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)
|
||||
// GetSummary 保留旧 token RPC 方法壳,统一返回已下线。
|
||||
func (h *Handler) GetSummary(context.Context, *pb.GetTokenSummaryRequest) (*pb.GetTokenSummaryResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
summary, err := svc.GetSummary(ctx, req.ActorUserId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.GetTokenSummaryResponse{Summary: tokenSummaryToPB(summary)}, nil
|
||||
func (h *Handler) ListProducts(context.Context, *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, 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)
|
||||
func (h *Handler) CreateOrder(context.Context, *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
items, err := svc.ListProducts(ctx, req.ActorUserId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListTokenProductsResponse{Items: tokenProductsToPB(items)}, nil
|
||||
func (h *Handler) ListOrders(context.Context, *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, 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)
|
||||
func (h *Handler) GetOrder(context.Context, *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
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) MockPaidOrder(context.Context, *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, 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)
|
||||
func (h *Handler) ListGrants(context.Context, *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
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) RecordForumRewardGrant(context.Context, *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, 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) 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) 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
|
||||
}
|
||||
|
||||
// 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 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")
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -17,6 +17,16 @@ const (
|
||||
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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
66
backend/services/tokenstore/sv/credit_balance.go
Normal file
66
backend/services/tokenstore/sv/credit_balance.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
)
|
||||
|
||||
// GetCreditBalanceSnapshot 返回用户 Credit 余额快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 优先读 tokenstore 自己维护的 Redis 快照,未命中再回源 DB;
|
||||
// 2. 只返回余额与阻断状态,不在这里计算价格或校验扣费规则;
|
||||
// 3. DB 回源成功后会尽力回填缓存,但缓存失败不影响本次查询结果。
|
||||
func (s *Service) GetCreditBalanceSnapshot(ctx context.Context, userID uint64) (*creditcontracts.CreditBalanceSnapshot, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
if s.creditCache != nil {
|
||||
snapshot, ok, err := s.creditCache.GetCreditBalanceSnapshot(ctx, userID)
|
||||
if err == nil && ok && snapshot != nil {
|
||||
blocked, blockedErr := s.creditCache.IsUserCreditBlocked(ctx, userID)
|
||||
if blockedErr == nil {
|
||||
return &creditcontracts.CreditBalanceSnapshot{
|
||||
UserID: userID,
|
||||
Balance: snapshot.Balance,
|
||||
TotalRecharged: snapshot.TotalRecharged,
|
||||
TotalRewarded: snapshot.TotalRewarded,
|
||||
TotalConsumed: snapshot.TotalConsumed,
|
||||
IsBlocked: blocked || snapshot.Balance <= 0,
|
||||
SnapshotSource: creditSnapshotSourceCache,
|
||||
UpdatedAt: formatTime(snapshot.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
account, err := s.creditDAO.FindAccountByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &creditcontracts.CreditBalanceSnapshot{
|
||||
UserID: userID,
|
||||
SnapshotSource: creditSnapshotSourceDB,
|
||||
}
|
||||
if account != nil {
|
||||
result.Balance = account.Balance
|
||||
result.TotalRecharged = account.TotalRecharged
|
||||
result.TotalRewarded = account.TotalRewarded
|
||||
result.TotalConsumed = account.TotalConsumed
|
||||
result.IsBlocked = account.Balance <= 0
|
||||
result.UpdatedAt = formatTime(account.UpdatedAt)
|
||||
} else {
|
||||
result.Balance = 0
|
||||
result.IsBlocked = true
|
||||
}
|
||||
|
||||
s.syncCreditCacheBestEffort(ctx, userID, account, nil)
|
||||
return result, nil
|
||||
}
|
||||
112
backend/services/tokenstore/sv/credit_charge.go
Normal file
112
backend/services/tokenstore/sv/credit_charge.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||
)
|
||||
|
||||
type creditChargeMetadata struct {
|
||||
Scene string `json:"scene"`
|
||||
RequestID string `json:"request_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
ModelAlias string `json:"model_alias"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ModelName string `json:"model_name"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
ReasoningTokens int64 `json:"reasoning_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
RMBCostMicros int64 `json:"rmb_cost_micros"`
|
||||
CreditCost int64 `json:"credit_cost"`
|
||||
SkipCharge bool `json:"skip_charge"`
|
||||
TriggeredAt time.Time `json:"triggered_at"`
|
||||
}
|
||||
|
||||
// RecordCreditCharge 负责把 LLM 扣费事件写入 Credit 权威账本。
|
||||
func (s *Service) RecordCreditCharge(ctx context.Context, payload sharedevents.CreditChargeRequestedPayload) (*creditcontracts.CreditTransactionView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceRefID := strings.TrimSpace(payload.RequestID)
|
||||
if sourceRefID == "" {
|
||||
sourceRefID = strings.TrimSpace(payload.ConversationID)
|
||||
}
|
||||
var sourceRefIDPtr *string
|
||||
if sourceRefID != "" {
|
||||
sourceRefIDPtr = &sourceRefID
|
||||
}
|
||||
|
||||
amount := -payload.CreditCost
|
||||
status := storemodel.CreditLedgerStatusApplied
|
||||
if payload.SkipCharge {
|
||||
amount = 0
|
||||
status = storemodel.CreditLedgerStatusSkipped
|
||||
}
|
||||
|
||||
ledger, _, err := s.applyCreditLedger(ctx, applyCreditLedgerRequest{
|
||||
EventID: strings.TrimSpace(payload.EventID),
|
||||
UserID: payload.UserID,
|
||||
Source: storemodel.CreditLedgerSourceCharge,
|
||||
SourceLabel: creditSourceLabel(storemodel.CreditLedgerSourceCharge, ""),
|
||||
Direction: storemodel.CreditLedgerDirectionExpense,
|
||||
SourceRefID: sourceRefIDPtr,
|
||||
Amount: amount,
|
||||
Status: status,
|
||||
Description: creditChargeDescription(payload),
|
||||
MetadataJSON: creditMetadataJSON(creditChargeMetadataFromPayload(payload)),
|
||||
CreatedAt: payload.TriggeredAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view := creditTransactionViewFromModel(*ledger)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func creditChargeMetadataFromPayload(payload sharedevents.CreditChargeRequestedPayload) creditChargeMetadata {
|
||||
return creditChargeMetadata{
|
||||
Scene: payload.Scene,
|
||||
RequestID: payload.RequestID,
|
||||
ConversationID: payload.ConversationID,
|
||||
ModelAlias: payload.ModelAlias,
|
||||
ProviderName: payload.ProviderName,
|
||||
ModelName: payload.ModelName,
|
||||
InputTokens: payload.InputTokens,
|
||||
OutputTokens: payload.OutputTokens,
|
||||
CachedTokens: payload.CachedTokens,
|
||||
ReasoningTokens: payload.ReasoningTokens,
|
||||
TotalTokens: payload.TotalTokens,
|
||||
RMBCostMicros: payload.RMBCostMicros,
|
||||
CreditCost: payload.CreditCost,
|
||||
SkipCharge: payload.SkipCharge,
|
||||
TriggeredAt: payload.TriggeredAt,
|
||||
}
|
||||
}
|
||||
|
||||
func creditChargeDescription(payload sharedevents.CreditChargeRequestedPayload) string {
|
||||
modelText := strings.TrimSpace(payload.ModelAlias)
|
||||
if modelText == "" {
|
||||
modelText = strings.TrimSpace(payload.ModelName)
|
||||
}
|
||||
sceneText := strings.TrimSpace(payload.Scene)
|
||||
switch {
|
||||
case sceneText != "" && modelText != "":
|
||||
return fmt.Sprintf("AI 调用扣费(%s / %s)", sceneText, modelText)
|
||||
case modelText != "":
|
||||
return fmt.Sprintf("AI 调用扣费(%s)", modelText)
|
||||
default:
|
||||
return "AI 调用扣费"
|
||||
}
|
||||
}
|
||||
86
backend/services/tokenstore/sv/credit_dashboard.go
Normal file
86
backend/services/tokenstore/sv/credit_dashboard.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
)
|
||||
|
||||
// GetCreditConsumptionDashboard 返回当前用户的 Credit 消耗看板。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把前端周期参数归一化为 tokenstore 的统一时间窗口。
|
||||
// 2. 只校验当前用户语义和周期合法性,真正的聚合查询下沉到 DAO。
|
||||
// 3. 返回值只包含前端顶部看板需要的两个指标,不夹带商品、流水等其它信息。
|
||||
func (s *Service) GetCreditConsumptionDashboard(ctx context.Context, req creditcontracts.GetCreditConsumptionDashboardRequest) (*creditcontracts.CreditConsumptionDashboardView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ActorUserID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
period, err := normalizeCreditConsumptionPeriod(req.Period)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := tokenstoredao.GetCreditConsumptionDashboardQuery{
|
||||
UserID: req.ActorUserID,
|
||||
CreatedFrom: resolveCreditConsumptionWindowStart(period, time.Now()),
|
||||
}
|
||||
aggregate, err := s.creditDAO.GetCreditConsumptionDashboard(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &creditcontracts.CreditConsumptionDashboardView{
|
||||
Period: period,
|
||||
CreditConsumed: aggregate.CreditConsumed,
|
||||
TokenConsumed: aggregate.TokenConsumed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// normalizeCreditConsumptionPeriod 只负责把前端周期值收敛到固定枚举。
|
||||
//
|
||||
// 1. 空值默认回落到 24h,保证首页初次进入时可直接展示。
|
||||
// 2. 非法值直接返回业务坏参,避免网关和前端各自维护一份不一致的枚举。
|
||||
// 3. 这里不做时间计算,方便后续单独复用和测试。
|
||||
func normalizeCreditConsumptionPeriod(raw string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "", creditcontracts.CreditConsumptionPeriod24h:
|
||||
return creditcontracts.CreditConsumptionPeriod24h, nil
|
||||
case creditcontracts.CreditConsumptionPeriod7d:
|
||||
return creditcontracts.CreditConsumptionPeriod7d, nil
|
||||
case creditcontracts.CreditConsumptionPeriod30d:
|
||||
return creditcontracts.CreditConsumptionPeriod30d, nil
|
||||
case creditcontracts.CreditConsumptionPeriodAll:
|
||||
return creditcontracts.CreditConsumptionPeriodAll, nil
|
||||
default:
|
||||
return "", tokenStoreBadRequest("period 仅支持 24h、7d、30d 或 all")
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCreditConsumptionWindowStart 负责把固定周期映射为统计起点。
|
||||
//
|
||||
// 1. only "all" 返回 nil,表示不加 created_at 过滤。
|
||||
// 2. 其它周期统一按当前时间回退固定时长,保证前后端口径一致。
|
||||
// 3. 这里不处理时区格式化,因为最终查询直接使用 time.Time 传给 DAO。
|
||||
func resolveCreditConsumptionWindowStart(period string, now time.Time) *time.Time {
|
||||
var startAt time.Time
|
||||
switch period {
|
||||
case creditcontracts.CreditConsumptionPeriod24h:
|
||||
startAt = now.Add(-24 * time.Hour)
|
||||
case creditcontracts.CreditConsumptionPeriod7d:
|
||||
startAt = now.Add(-7 * 24 * time.Hour)
|
||||
case creditcontracts.CreditConsumptionPeriod30d:
|
||||
startAt = now.Add(-30 * 24 * time.Hour)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &startAt
|
||||
}
|
||||
457
backend/services/tokenstore/sv/credit_helpers.go
Normal file
457
backend/services/tokenstore/sv/credit_helpers.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
creditSnapshotSourceCache = "cache"
|
||||
creditSnapshotSourceDB = "db"
|
||||
)
|
||||
|
||||
type creditProductSnapshot struct {
|
||||
ProductID uint64 `json:"product_id"`
|
||||
SKU string `json:"sku"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreditAmount int64 `json:"credit_amount"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
OriginalPriceCent int64 `json:"original_price_cent"`
|
||||
PriceText string `json:"price_text"`
|
||||
Currency string `json:"currency"`
|
||||
Badge string `json:"badge"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
type applyCreditLedgerRequest struct {
|
||||
EventID string
|
||||
UserID uint64
|
||||
Source string
|
||||
SourceLabel string
|
||||
Direction string
|
||||
OrderID *uint64
|
||||
SourceRefID *string
|
||||
Amount int64
|
||||
Status string
|
||||
Description string
|
||||
MetadataJSON string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func creditPageResult(page int, pageSize int, total int64) creditcontracts.PageResult {
|
||||
return creditcontracts.PageResult{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: int(total),
|
||||
HasMore: int64(page*pageSize) < total,
|
||||
}
|
||||
}
|
||||
|
||||
func creditProductViewFromModel(product storemodel.CreditProduct) creditcontracts.CreditProductView {
|
||||
originalPriceCent := normalizeOriginalPriceCent(product.OriginalPriceCent, product.PriceCent)
|
||||
return creditcontracts.CreditProductView{
|
||||
ProductID: product.ID,
|
||||
Name: product.Name,
|
||||
Description: product.Description,
|
||||
CreditAmount: product.CreditAmount,
|
||||
PriceCent: product.PriceCent,
|
||||
OriginalPriceCent: originalPriceCent,
|
||||
PriceText: formatPriceText(product.Currency, product.PriceCent),
|
||||
Currency: product.Currency,
|
||||
Badge: product.Badge,
|
||||
Status: product.Status,
|
||||
SortOrder: product.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func creditOrderViewFromModel(order storemodel.CreditOrder) creditcontracts.CreditOrderView {
|
||||
return creditcontracts.CreditOrderView{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
ProductSnapshot: order.ProductSnapshotJSON,
|
||||
ProductName: order.ProductName,
|
||||
Quantity: order.Quantity,
|
||||
CreditAmount: order.CreditAmount,
|
||||
AmountCent: order.AmountCent,
|
||||
PriceText: formatPriceText(order.Currency, order.AmountCent),
|
||||
Currency: order.Currency,
|
||||
PaymentMode: order.PaymentMode,
|
||||
CreatedAt: formatTime(order.CreatedAt),
|
||||
PaidAt: formatTimePtr(order.PaidAt),
|
||||
CreditedAt: formatTimePtr(order.CreditedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func creditTransactionViewFromModel(ledger storemodel.CreditLedger) creditcontracts.CreditTransactionView {
|
||||
return creditcontracts.CreditTransactionView{
|
||||
TransactionID: ledger.ID,
|
||||
EventID: ledger.EventID,
|
||||
Source: ledger.Source,
|
||||
SourceLabel: creditSourceLabel(ledger.Source, ledger.SourceLabel),
|
||||
Direction: ledger.Direction,
|
||||
Amount: ledger.Amount,
|
||||
BalanceAfter: ledger.BalanceAfter,
|
||||
Status: ledger.Status,
|
||||
Description: ledger.Description,
|
||||
MetadataJSON: ledger.MetadataJSON,
|
||||
CreatedAt: formatTime(ledger.CreatedAt),
|
||||
OrderID: ledger.OrderID,
|
||||
}
|
||||
}
|
||||
|
||||
func creditPriceRuleViewFromModel(rule storemodel.CreditPriceRule) creditcontracts.CreditPriceRuleView {
|
||||
return creditcontracts.CreditPriceRuleView{
|
||||
RuleID: rule.ID,
|
||||
Scene: rule.Scene,
|
||||
ProviderName: rule.ProviderName,
|
||||
ModelName: rule.ModelName,
|
||||
InputPriceMicros: rule.InputPriceMicros,
|
||||
OutputPriceMicros: rule.OutputPriceMicros,
|
||||
CachedPriceMicros: rule.CachedPriceMicros,
|
||||
ReasoningPriceMicros: rule.ReasoningPriceMicros,
|
||||
CreditPerYuan: rule.CreditPerYuan,
|
||||
Status: rule.Status,
|
||||
Priority: rule.Priority,
|
||||
Description: rule.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func creditRewardRuleViewFromModel(rule storemodel.CreditRewardRule) creditcontracts.CreditRewardRuleView {
|
||||
return creditcontracts.CreditRewardRuleView{
|
||||
RuleID: rule.ID,
|
||||
Source: rule.Source,
|
||||
Name: rule.Name,
|
||||
Amount: rule.Amount,
|
||||
Status: rule.Status,
|
||||
Description: rule.Description,
|
||||
}
|
||||
}
|
||||
|
||||
func buildCreditProductSnapshot(product storemodel.CreditProduct) (string, error) {
|
||||
originalPriceCent := normalizeOriginalPriceCent(product.OriginalPriceCent, product.PriceCent)
|
||||
snapshot := creditProductSnapshot{
|
||||
ProductID: product.ID,
|
||||
SKU: product.SKU,
|
||||
Name: product.Name,
|
||||
Description: product.Description,
|
||||
CreditAmount: product.CreditAmount,
|
||||
PriceCent: product.PriceCent,
|
||||
OriginalPriceCent: originalPriceCent,
|
||||
PriceText: formatPriceText(product.Currency, product.PriceCent),
|
||||
Currency: product.Currency,
|
||||
Badge: product.Badge,
|
||||
Status: product.Status,
|
||||
SortOrder: product.SortOrder,
|
||||
}
|
||||
raw, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func normalizeOriginalPriceCent(originalPriceCent int64, priceCent int64) int64 {
|
||||
if originalPriceCent > 0 {
|
||||
return originalPriceCent
|
||||
}
|
||||
return priceCent
|
||||
}
|
||||
|
||||
func newCreditOrderNo() string {
|
||||
return fmt.Sprintf(
|
||||
"CS%s%s",
|
||||
time.Now().Format("20060102150405"),
|
||||
strings.ReplaceAll(uuid.NewString(), "-", ""),
|
||||
)
|
||||
}
|
||||
|
||||
func creditOrderLedgerEventID(orderID uint64) string {
|
||||
return fmt.Sprintf("credit-order:%d:paid", orderID)
|
||||
}
|
||||
|
||||
func creditSourceLabel(source string, fallback string) string {
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
return fallback
|
||||
}
|
||||
switch strings.TrimSpace(source) {
|
||||
case storemodel.CreditLedgerSourcePurchase:
|
||||
return "购买充值"
|
||||
case storemodel.CreditLedgerSourceCharge:
|
||||
return "AI 调用扣费"
|
||||
case storemodel.CreditLedgerSourceForumLike:
|
||||
return "计划被点赞"
|
||||
case storemodel.CreditLedgerSourceForumImport:
|
||||
return "计划被导入"
|
||||
case storemodel.CreditLedgerSourceManual:
|
||||
return "人工补发"
|
||||
default:
|
||||
return "Credit 流水"
|
||||
}
|
||||
}
|
||||
|
||||
func creditDirectionFromAmount(amount int64) string {
|
||||
if amount < 0 {
|
||||
return storemodel.CreditLedgerDirectionExpense
|
||||
}
|
||||
return storemodel.CreditLedgerDirectionIncome
|
||||
}
|
||||
|
||||
func creditShouldAffectBalance(req applyCreditLedgerRequest) bool {
|
||||
return strings.TrimSpace(req.Status) == storemodel.CreditLedgerStatusApplied && req.Amount != 0
|
||||
}
|
||||
|
||||
func (s *Service) applyCreditLedger(ctx context.Context, req applyCreditLedgerRequest) (*storemodel.CreditLedger, *storemodel.CreditAccount, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
normalized, err := normalizeApplyCreditLedgerRequest(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var resultLedger *storemodel.CreditLedger
|
||||
var resultAccount *storemodel.CreditAccount
|
||||
err = s.creditDAO.Transaction(ctx, func(txDAO *tokenstoredao.CreditStoreDAO) error {
|
||||
ledger, account, err := s.applyCreditLedgerWithDAO(ctx, txDAO, normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resultLedger = ledger
|
||||
resultAccount = account
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.syncCreditCacheBestEffort(ctx, normalized.UserID, resultAccount, resultLedger)
|
||||
return resultLedger, resultAccount, nil
|
||||
}
|
||||
|
||||
func (s *Service) applyCreditLedgerWithDAO(ctx context.Context, txDAO *tokenstoredao.CreditStoreDAO, req applyCreditLedgerRequest) (*storemodel.CreditLedger, *storemodel.CreditAccount, error) {
|
||||
if txDAO == nil {
|
||||
return nil, nil, errors.New("credit dao is nil")
|
||||
}
|
||||
|
||||
existing, findErr := txDAO.FindLedgerByEventID(ctx, req.EventID)
|
||||
if findErr != nil {
|
||||
return nil, nil, findErr
|
||||
}
|
||||
if existing != nil {
|
||||
if err := validateExistingCreditLedger(*existing, req); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
account, accountErr := txDAO.FindAccountByUserID(ctx, req.UserID)
|
||||
if accountErr != nil {
|
||||
return nil, nil, accountErr
|
||||
}
|
||||
return existing, account, nil
|
||||
}
|
||||
|
||||
account, accountErr := txDAO.LockAccountByUserID(ctx, req.UserID)
|
||||
if accountErr != nil {
|
||||
return nil, nil, accountErr
|
||||
}
|
||||
if account == nil && creditShouldAffectBalance(req) {
|
||||
account = &storemodel.CreditAccount{
|
||||
UserID: req.UserID,
|
||||
}
|
||||
if err := txDAO.CreateAccount(ctx, account); err != nil {
|
||||
if !isDuplicateKeyError(err) {
|
||||
return nil, nil, err
|
||||
}
|
||||
account, err = txDAO.LockAccountByUserID(ctx, req.UserID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if account == nil {
|
||||
return nil, nil, errors.New("credit account duplicated but not found by user_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
balanceBefore := int64(0)
|
||||
if account != nil {
|
||||
balanceBefore = account.Balance
|
||||
}
|
||||
balanceAfter := balanceBefore
|
||||
if creditShouldAffectBalance(req) {
|
||||
balanceAfter += req.Amount
|
||||
}
|
||||
|
||||
ledger := &storemodel.CreditLedger{
|
||||
EventID: req.EventID,
|
||||
UserID: req.UserID,
|
||||
Source: req.Source,
|
||||
SourceLabel: creditSourceLabel(req.Source, req.SourceLabel),
|
||||
Direction: req.Direction,
|
||||
OrderID: req.OrderID,
|
||||
SourceRefID: req.SourceRefID,
|
||||
Amount: req.Amount,
|
||||
BalanceBefore: balanceBefore,
|
||||
BalanceAfter: balanceAfter,
|
||||
Status: req.Status,
|
||||
Description: req.Description,
|
||||
MetadataJSON: req.MetadataJSON,
|
||||
CreatedAt: req.CreatedAt,
|
||||
}
|
||||
if err := txDAO.CreateLedger(ctx, ledger); err != nil {
|
||||
if !isDuplicateKeyError(err) {
|
||||
return nil, nil, err
|
||||
}
|
||||
existing, findErr := txDAO.FindLedgerByEventID(ctx, req.EventID)
|
||||
if findErr != nil {
|
||||
return nil, nil, findErr
|
||||
}
|
||||
if existing != nil {
|
||||
if err := validateExistingCreditLedger(*existing, req); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
account, accountErr := txDAO.FindAccountByUserID(ctx, req.UserID)
|
||||
if accountErr != nil {
|
||||
return nil, nil, accountErr
|
||||
}
|
||||
return existing, account, nil
|
||||
}
|
||||
return nil, nil, errors.New("credit ledger duplicated but not found by event_id")
|
||||
}
|
||||
|
||||
if creditShouldAffectBalance(req) {
|
||||
account.Balance = balanceAfter
|
||||
account.LastLedgerEventID = req.EventID
|
||||
if req.Amount > 0 {
|
||||
if req.Source == storemodel.CreditLedgerSourcePurchase {
|
||||
account.TotalRecharged += req.Amount
|
||||
} else {
|
||||
account.TotalRewarded += req.Amount
|
||||
}
|
||||
} else {
|
||||
account.TotalConsumed += -req.Amount
|
||||
}
|
||||
if err := txDAO.SaveAccount(ctx, account); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return ledger, account, nil
|
||||
}
|
||||
|
||||
func normalizeApplyCreditLedgerRequest(req applyCreditLedgerRequest) (applyCreditLedgerRequest, error) {
|
||||
normalized := req
|
||||
normalized.EventID = strings.TrimSpace(req.EventID)
|
||||
normalized.Source = strings.ToLower(strings.TrimSpace(req.Source))
|
||||
normalized.SourceLabel = strings.TrimSpace(req.SourceLabel)
|
||||
normalized.Direction = strings.ToLower(strings.TrimSpace(req.Direction))
|
||||
normalized.Status = strings.ToLower(strings.TrimSpace(req.Status))
|
||||
normalized.Description = strings.TrimSpace(req.Description)
|
||||
normalized.MetadataJSON = strings.TrimSpace(req.MetadataJSON)
|
||||
if normalized.CreatedAt.IsZero() {
|
||||
normalized.CreatedAt = time.Now()
|
||||
}
|
||||
|
||||
switch {
|
||||
case normalized.EventID == "":
|
||||
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit event_id 不能为空")
|
||||
case normalized.UserID == 0:
|
||||
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit user_id 不能为空")
|
||||
case normalized.Source == "":
|
||||
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit source 不能为空")
|
||||
case normalized.Direction != storemodel.CreditLedgerDirectionIncome && normalized.Direction != storemodel.CreditLedgerDirectionExpense:
|
||||
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit direction 仅支持 income 或 expense")
|
||||
case normalized.Status == "":
|
||||
return applyCreditLedgerRequest{}, tokenStoreBadRequest("credit status 不能为空")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func validateExistingCreditLedger(existing storemodel.CreditLedger, req applyCreditLedgerRequest) error {
|
||||
if existing.UserID != req.UserID ||
|
||||
existing.Source != req.Source ||
|
||||
existing.Direction != req.Direction ||
|
||||
existing.Amount != req.Amount ||
|
||||
existing.Status != req.Status {
|
||||
return tokenStoreBadRequest("credit event_id 幂等冲突:已存在流水与本次请求不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) syncCreditCacheBestEffort(ctx context.Context, userID uint64, account *storemodel.CreditAccount, ledger *storemodel.CreditLedger) {
|
||||
if s == nil || s.creditCache == nil || userID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if account == nil {
|
||||
loaded, err := s.creditDAO.FindAccountByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("tokenstore credit cache fallback load failed: user_id=%d err=%v", userID, err)
|
||||
return
|
||||
}
|
||||
account = loaded
|
||||
}
|
||||
|
||||
snapshot := tokenstoredao.CreditBalanceSnapshot{
|
||||
UserID: userID,
|
||||
UpdatedAt: time.Now(),
|
||||
TotalRecharged: 0,
|
||||
TotalRewarded: 0,
|
||||
TotalConsumed: 0,
|
||||
}
|
||||
if account != nil {
|
||||
snapshot.Balance = account.Balance
|
||||
snapshot.TotalRecharged = account.TotalRecharged
|
||||
snapshot.TotalRewarded = account.TotalRewarded
|
||||
snapshot.TotalConsumed = account.TotalConsumed
|
||||
if !account.UpdatedAt.IsZero() {
|
||||
snapshot.UpdatedAt = account.UpdatedAt
|
||||
}
|
||||
} else if ledger != nil && !ledger.CreatedAt.IsZero() {
|
||||
snapshot.Balance = ledger.BalanceAfter
|
||||
snapshot.UpdatedAt = ledger.CreatedAt
|
||||
}
|
||||
|
||||
if err := s.creditCache.SetCreditBalanceSnapshot(ctx, userID, snapshot, 0); err != nil {
|
||||
log.Printf("tokenstore credit cache snapshot write failed: user_id=%d err=%v", userID, err)
|
||||
}
|
||||
|
||||
if snapshot.Balance <= 0 {
|
||||
if err := s.creditCache.SetUserCreditBlocked(ctx, userID, 0); err != nil {
|
||||
log.Printf("tokenstore credit blocked flag write failed: user_id=%d err=%v", userID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.creditCache.DeleteUserCreditBlocked(ctx, userID); err != nil {
|
||||
log.Printf("tokenstore credit blocked flag delete failed: user_id=%d err=%v", userID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func creditMetadataJSON(payload any) string {
|
||||
if payload == nil {
|
||||
return ""
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func normalizeCreditRecordNotFound(err error, fallback error) error {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fallback
|
||||
}
|
||||
return err
|
||||
}
|
||||
240
backend/services/tokenstore/sv/credit_order.go
Normal file
240
backend/services/tokenstore/sv/credit_order.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
)
|
||||
|
||||
// CreateCreditOrder 创建 Credit 商品订单。
|
||||
func (s *Service) CreateCreditOrder(ctx context.Context, req creditcontracts.CreateCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ActorUserID == 0 || req.ProductID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
if req.Quantity < 1 || req.Quantity > 99 {
|
||||
return nil, tokenStoreBadRequest("quantity 仅支持 1 到 99")
|
||||
}
|
||||
|
||||
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
|
||||
if idempotencyKey != "" {
|
||||
existing, err := s.creditDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return s.creditOrderViewByID(ctx, req.ActorUserID, existing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
product, err := s.creditDAO.FindActiveProductByID(ctx, req.ProductID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if product == nil {
|
||||
return nil, tokenStoreBadRequest("Credit 商品不存在或已下架")
|
||||
}
|
||||
|
||||
snapshot, err := buildCreditProductSnapshot(*product)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
order := storemodel.CreditOrder{
|
||||
OrderNo: newCreditOrderNo(),
|
||||
UserID: req.ActorUserID,
|
||||
ProductID: product.ID,
|
||||
ProductSKU: product.SKU,
|
||||
ProductName: product.Name,
|
||||
ProductSnapshotJSON: snapshot,
|
||||
Quantity: req.Quantity,
|
||||
CreditAmount: product.CreditAmount * int64(req.Quantity),
|
||||
AmountCent: product.PriceCent * int64(req.Quantity),
|
||||
Currency: product.Currency,
|
||||
Status: storemodel.CreditOrderStatusPending,
|
||||
PaymentMode: "mock",
|
||||
IdempotencyKey: stringPtrFromNonEmpty(idempotencyKey),
|
||||
}
|
||||
if err := s.creditDAO.CreateOrder(ctx, &order); err != nil {
|
||||
if idempotencyKey != "" && isDuplicateKeyError(err) {
|
||||
existing, findErr := s.creditDAO.FindOrderByUserIdempotencyKey(ctx, req.ActorUserID, idempotencyKey)
|
||||
if findErr != nil {
|
||||
return nil, findErr
|
||||
}
|
||||
if existing != nil {
|
||||
return s.creditOrderViewByID(ctx, req.ActorUserID, existing.ID)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.creditOrderViewByID(ctx, req.ActorUserID, order.ID)
|
||||
}
|
||||
|
||||
// ListCreditOrders 按用户分页查询 Credit 订单。
|
||||
func (s *Service) ListCreditOrders(ctx context.Context, req creditcontracts.ListCreditOrdersRequest) ([]creditcontracts.CreditOrderView, creditcontracts.PageResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, creditcontracts.PageResult{}, err
|
||||
}
|
||||
if req.ActorUserID == 0 {
|
||||
return nil, creditcontracts.PageResult{}, respond.MissingParam
|
||||
}
|
||||
|
||||
page, pageSize := normalizePage(req.Page, req.PageSize)
|
||||
query := tokenstoredao.ListCreditOrdersQuery{
|
||||
UserID: req.ActorUserID,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
}
|
||||
|
||||
total, err := s.creditDAO.CountOrders(ctx, query)
|
||||
if err != nil {
|
||||
return nil, creditcontracts.PageResult{}, err
|
||||
}
|
||||
orders, err := s.creditDAO.ListOrders(ctx, query)
|
||||
if err != nil {
|
||||
return nil, creditcontracts.PageResult{}, err
|
||||
}
|
||||
if len(orders) == 0 {
|
||||
return []creditcontracts.CreditOrderView{}, creditPageResult(page, pageSize, total), nil
|
||||
}
|
||||
|
||||
result := make([]creditcontracts.CreditOrderView, 0, len(orders))
|
||||
for _, order := range orders {
|
||||
result = append(result, creditOrderViewFromModel(order))
|
||||
}
|
||||
return result, creditPageResult(page, pageSize, total), nil
|
||||
}
|
||||
|
||||
// GetCreditOrder 查询单个 Credit 订单详情。
|
||||
func (s *Service) GetCreditOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if actorUserID == 0 || orderID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
return s.creditOrderViewByID(ctx, actorUserID, orderID)
|
||||
}
|
||||
|
||||
// MockPaidCreditOrder 在同步事务里完成 mock paid 和 Credit 入账。
|
||||
func (s *Service) MockPaidCreditOrder(ctx context.Context, req creditcontracts.MockPaidCreditOrderRequest) (*creditcontracts.CreditOrderView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ActorUserID == 0 || req.OrderID == 0 {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
var resultOrder storemodel.CreditOrder
|
||||
err := s.creditDAO.Transaction(ctx, func(txDAO *tokenstoredao.CreditStoreDAO) error {
|
||||
now := time.Now()
|
||||
|
||||
order, err := txDAO.LockOrderByID(ctx, req.OrderID)
|
||||
if err != nil {
|
||||
return normalizeCreditRecordNotFound(err, tokenStoreBadRequest("Credit 订单不存在"))
|
||||
}
|
||||
if order.UserID != req.ActorUserID {
|
||||
return tokenStoreBadRequest("Credit 订单不属于当前用户")
|
||||
}
|
||||
|
||||
switch order.Status {
|
||||
case storemodel.CreditOrderStatusPending, storemodel.CreditOrderStatusPaid, storemodel.CreditOrderStatusCredited:
|
||||
case storemodel.CreditOrderStatusClosed:
|
||||
return tokenStoreBadRequest("Credit 订单已关闭,不能执行 mock paid")
|
||||
default:
|
||||
return tokenStoreBadRequest("Credit 订单状态不支持执行 mock paid")
|
||||
}
|
||||
|
||||
eventID := creditOrderLedgerEventID(order.ID)
|
||||
paymentMode := paymentModeOrDefault(order.PaymentMode, req.MockChannel)
|
||||
ledger, _, err := s.applyCreditLedgerWithDAO(ctx, txDAO, applyCreditLedgerRequest{
|
||||
EventID: eventID,
|
||||
UserID: order.UserID,
|
||||
Source: storemodel.CreditLedgerSourcePurchase,
|
||||
SourceLabel: creditSourceLabel(storemodel.CreditLedgerSourcePurchase, ""),
|
||||
Direction: storemodel.CreditLedgerDirectionIncome,
|
||||
OrderID: &order.ID,
|
||||
Amount: order.CreditAmount,
|
||||
Status: storemodel.CreditLedgerStatusApplied,
|
||||
Description: creditPurchaseDescription(order.ProductName),
|
||||
MetadataJSON: creditMetadataJSON(map[string]any{"order_no": order.OrderNo, "payment_mode": paymentMode}),
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paidAt := order.PaidAt
|
||||
if paidAt == nil || paidAt.IsZero() {
|
||||
paidAt = &now
|
||||
}
|
||||
creditedAt := order.CreditedAt
|
||||
if creditedAt == nil || creditedAt.IsZero() {
|
||||
ledgerCreatedAt := ledger.CreatedAt
|
||||
if ledgerCreatedAt.IsZero() {
|
||||
ledgerCreatedAt = now
|
||||
}
|
||||
creditedAt = &ledgerCreatedAt
|
||||
}
|
||||
|
||||
if err := txDAO.UpdateOrderState(ctx, order.ID, storemodel.CreditOrderStatusCredited, paidAt, creditedAt, paymentMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
order.Status = storemodel.CreditOrderStatusCredited
|
||||
order.PaidAt = paidAt
|
||||
order.CreditedAt = creditedAt
|
||||
order.PaymentMode = paymentMode
|
||||
resultOrder = *order
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.syncCreditCacheBestEffort(ctx, req.ActorUserID, nil, nil)
|
||||
view := creditOrderViewFromModel(resultOrder)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *Service) creditOrderViewByID(ctx context.Context, actorUserID uint64, orderID uint64) (*creditcontracts.CreditOrderView, error) {
|
||||
order, err := s.creditDAO.FindOrderByID(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order == nil {
|
||||
return nil, tokenStoreBadRequest("Credit 订单不存在")
|
||||
}
|
||||
if order.UserID != actorUserID {
|
||||
return nil, tokenStoreBadRequest("Credit 订单不属于当前用户")
|
||||
}
|
||||
view := creditOrderViewFromModel(*order)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func creditPurchaseDescription(productName string) string {
|
||||
trimmed := strings.TrimSpace(productName)
|
||||
if trimmed == "" {
|
||||
return "购买 Credit 商品"
|
||||
}
|
||||
return "购买" + trimmed
|
||||
}
|
||||
|
||||
func paymentModeOrDefault(current string, fallback string) string {
|
||||
if trimmed := strings.TrimSpace(current); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
if trimmed := strings.TrimSpace(fallback); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return "mock"
|
||||
}
|
||||
104
backend/services/tokenstore/sv/credit_outbox.go
Normal file
104
backend/services/tokenstore/sv/credit_outbox.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
sharedevents "github.com/LoveLosita/smartflow/backend/shared/events"
|
||||
kafkabus "github.com/LoveLosita/smartflow/backend/shared/infra/kafka"
|
||||
outboxinfra "github.com/LoveLosita/smartflow/backend/shared/infra/outbox"
|
||||
)
|
||||
|
||||
// RegisterCreditChargeRoutes 只登记 token-store 负责消费的 Credit 扣费事件归属。
|
||||
func RegisterCreditChargeRoutes() error {
|
||||
return outboxinfra.RegisterEventService(sharedevents.CreditChargeRequestedEventType, outboxinfra.ServiceTokenStore)
|
||||
}
|
||||
|
||||
// RegisterCreditChargeHandlers 注册 token-store 对 Credit 扣费事件的消费处理器。
|
||||
func RegisterCreditChargeHandlers(bus OutboxBus, outboxRepo *outboxinfra.Repository, svc *Service) error {
|
||||
if bus == nil {
|
||||
return errors.New("event bus is nil")
|
||||
}
|
||||
if outboxRepo == nil {
|
||||
return errors.New("outbox repository is nil")
|
||||
}
|
||||
if svc == nil {
|
||||
return errors.New("tokenstore service is nil")
|
||||
}
|
||||
if err := RegisterCreditChargeRoutes(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
route, ok := outboxinfra.ResolveEventRoute(sharedevents.CreditChargeRequestedEventType)
|
||||
if !ok {
|
||||
return fmt.Errorf("credit charge outbox route is missing: eventType=%s", sharedevents.CreditChargeRequestedEventType)
|
||||
}
|
||||
eventOutboxRepo := outboxRepo.WithRoute(route)
|
||||
|
||||
handler := func(ctx context.Context, envelope kafkabus.Envelope) error {
|
||||
if !isAllowedCreditChargeEventVersion(envelope.EventVersion) {
|
||||
if err := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("Credit 扣费事件版本不受支持: %s", envelope.EventVersion)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload sharedevents.CreditChargeRequestedPayload
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "解析 Credit 扣费载荷失败: "+err.Error()); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(payload.EventID) == "" {
|
||||
payload.EventID = strings.TrimSpace(envelope.EventID)
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "Credit 扣费载荷非法: "+err.Error()); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if payload.EventType() != sharedevents.CreditChargeRequestedEventType {
|
||||
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("Credit 扣费事件类型不匹配: envelope=%s payload=%s", sharedevents.CreditChargeRequestedEventType, payload.EventType())); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !payload.SkipCharge && payload.CreditCost <= 0 && payload.RMBCostMicros <= 0 {
|
||||
if err := eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("credit charge event skipped with zero cost: event_id=%s outbox_id=%d", payload.EventID, envelope.OutboxID)
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := svc.RecordCreditCharge(ctx, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"credit charge event consumed by tokenstore: event_id=%s transaction_id=%d outbox_id=%d",
|
||||
payload.EventID,
|
||||
tx.TransactionID,
|
||||
envelope.OutboxID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
return bus.RegisterEventHandler(sharedevents.CreditChargeRequestedEventType, handler)
|
||||
}
|
||||
|
||||
func isAllowedCreditChargeEventVersion(version string) bool {
|
||||
version = strings.TrimSpace(version)
|
||||
return version == "" || version == sharedevents.CreditChargeEventVersion
|
||||
}
|
||||
29
backend/services/tokenstore/sv/credit_product.go
Normal file
29
backend/services/tokenstore/sv/credit_product.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
)
|
||||
|
||||
// ListCreditProducts 返回当前可售 Credit 商品列表。
|
||||
func (s *Service) ListCreditProducts(ctx context.Context, actorUserID uint64) ([]creditcontracts.CreditProductView, error) {
|
||||
_ = actorUserID
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
products, err := s.creditDAO.ListActiveProducts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(products) == 0 {
|
||||
return []creditcontracts.CreditProductView{}, nil
|
||||
}
|
||||
|
||||
result := make([]creditcontracts.CreditProductView, 0, len(products))
|
||||
for _, product := range products {
|
||||
result = append(result, creditProductViewFromModel(product))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
90
backend/services/tokenstore/sv/credit_reward.go
Normal file
90
backend/services/tokenstore/sv/credit_reward.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||
storemodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
)
|
||||
|
||||
// RecordForumRewardCredit 把论坛点赞/导入奖励直接写入 Credit 权威账本。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理 forum_like / forum_import 两类论坛正向奖励;
|
||||
// 2. 复用 event_id 做最终幂等键,重复消费时直接返回既有账本结果;
|
||||
// 3. 奖励金额优先读取 credit_reward_rules,规则缺失时再走默认兜底。
|
||||
func (s *Service) RecordForumRewardCredit(ctx context.Context, req forumRewardGrantRequest) (*creditcontracts.CreditTransactionView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decision, err := s.forumRewardCreditDecision(ctx, req.Source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceRefID := strconv.FormatUint(req.SourceRefID, 10)
|
||||
ledger, _, err := s.applyCreditLedger(ctx, applyCreditLedgerRequest{
|
||||
EventID: req.EventID,
|
||||
UserID: req.ReceiverUserID,
|
||||
Source: req.Source,
|
||||
SourceLabel: creditSourceLabel(req.Source, ""),
|
||||
Direction: creditDirectionFromAmount(decision.Amount),
|
||||
SourceRefID: &sourceRefID,
|
||||
Amount: decision.Amount,
|
||||
Status: decision.Status,
|
||||
Description: decision.Description,
|
||||
MetadataJSON: creditMetadataJSON(map[string]any{"reward_source": req.Source, "source_ref_id": req.SourceRefID}),
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
view := creditTransactionViewFromModel(*ledger)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *Service) forumRewardCreditDecision(ctx context.Context, source string) (forumRewardDecision, error) {
|
||||
rules, err := s.creditDAO.ListRewardRules(ctx, tokenstoredao.ListCreditRewardRulesQuery{
|
||||
Source: strings.TrimSpace(source),
|
||||
})
|
||||
if err != nil {
|
||||
return forumRewardDecision{}, err
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
rule := rules[0]
|
||||
if strings.TrimSpace(rule.Status) != storemodel.CreditRewardRuleStatusActive {
|
||||
return skippedForumRewardDecision(source, "奖励规则已停用,未发放 Credit"), nil
|
||||
}
|
||||
if rule.Amount <= 0 {
|
||||
return skippedForumRewardDecision(source, "奖励规则金额非正,未发放 Credit"), nil
|
||||
}
|
||||
return forumRewardDecision{
|
||||
Amount: rule.Amount,
|
||||
Status: storemodel.CreditLedgerStatusApplied,
|
||||
Description: forumRewardDescription(source),
|
||||
}, nil
|
||||
}
|
||||
|
||||
switch strings.TrimSpace(source) {
|
||||
case storemodel.CreditLedgerSourceForumLike:
|
||||
return forumRewardDecision{
|
||||
Amount: defaultForumLikeRewardAmount,
|
||||
Status: storemodel.CreditLedgerStatusApplied,
|
||||
Description: forumRewardDescription(source),
|
||||
}, nil
|
||||
case storemodel.CreditLedgerSourceForumImport:
|
||||
return forumRewardDecision{
|
||||
Amount: defaultForumImportRewardAmount,
|
||||
Status: storemodel.CreditLedgerStatusApplied,
|
||||
Description: forumRewardDescription(source),
|
||||
}, nil
|
||||
default:
|
||||
return skippedForumRewardDecision(source, "未知论坛奖励来源,未发放 Credit"), nil
|
||||
}
|
||||
}
|
||||
59
backend/services/tokenstore/sv/credit_rule.go
Normal file
59
backend/services/tokenstore/sv/credit_rule.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
)
|
||||
|
||||
// ListCreditPriceRules 查询 Credit 价格规则。
|
||||
func (s *Service) ListCreditPriceRules(ctx context.Context, req creditcontracts.ListCreditPriceRulesRequest) ([]creditcontracts.CreditPriceRuleView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules, err := s.creditDAO.ListPriceRules(ctx, tokenstoredao.ListCreditPriceRulesQuery{
|
||||
Scene: strings.TrimSpace(req.Scene),
|
||||
ProviderName: strings.TrimSpace(req.ProviderName),
|
||||
ModelName: strings.TrimSpace(req.ModelName),
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return []creditcontracts.CreditPriceRuleView{}, nil
|
||||
}
|
||||
|
||||
result := make([]creditcontracts.CreditPriceRuleView, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
result = append(result, creditPriceRuleViewFromModel(rule))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListCreditRewardRules 查询 Credit 奖励规则。
|
||||
func (s *Service) ListCreditRewardRules(ctx context.Context, req creditcontracts.ListCreditRewardRulesRequest) ([]creditcontracts.CreditRewardRuleView, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules, err := s.creditDAO.ListRewardRules(ctx, tokenstoredao.ListCreditRewardRulesQuery{
|
||||
Source: strings.TrimSpace(req.Source),
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return []creditcontracts.CreditRewardRuleView{}, nil
|
||||
}
|
||||
|
||||
result := make([]creditcontracts.CreditRewardRuleView, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
result = append(result, creditRewardRuleViewFromModel(rule))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
47
backend/services/tokenstore/sv/credit_transaction.go
Normal file
47
backend/services/tokenstore/sv/credit_transaction.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
|
||||
creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||||
)
|
||||
|
||||
// ListCreditTransactions 查询当前用户自己的 Credit 流水。
|
||||
func (s *Service) ListCreditTransactions(ctx context.Context, req creditcontracts.ListCreditTransactionsRequest) ([]creditcontracts.CreditTransactionView, creditcontracts.PageResult, error) {
|
||||
if err := s.Ready(); err != nil {
|
||||
return nil, creditcontracts.PageResult{}, err
|
||||
}
|
||||
if req.ActorUserID == 0 {
|
||||
return nil, creditcontracts.PageResult{}, respond.MissingParam
|
||||
}
|
||||
|
||||
page, pageSize := normalizePage(req.Page, req.PageSize)
|
||||
query := tokenstoredao.ListCreditTransactionsQuery{
|
||||
UserID: req.ActorUserID,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Source: strings.TrimSpace(req.Source),
|
||||
Direction: strings.TrimSpace(req.Direction),
|
||||
}
|
||||
|
||||
total, err := s.creditDAO.CountTransactions(ctx, query)
|
||||
if err != nil {
|
||||
return nil, creditcontracts.PageResult{}, err
|
||||
}
|
||||
items, err := s.creditDAO.ListTransactions(ctx, query)
|
||||
if err != nil {
|
||||
return nil, creditcontracts.PageResult{}, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return []creditcontracts.CreditTransactionView{}, creditPageResult(page, pageSize, total), nil
|
||||
}
|
||||
|
||||
result := make([]creditcontracts.CreditTransactionView, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, creditTransactionViewFromModel(item))
|
||||
}
|
||||
return result, creditPageResult(page, pageSize, total), nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "论坛奖励入账"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
172
backend/shared/contracts/creditstore/types.go
Normal file
172
backend/shared/contracts/creditstore/types.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package creditstore
|
||||
|
||||
// PageResult 是 Credit 领域的分页结果契约。
|
||||
type PageResult struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int `json:"total"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// CreditBalanceSnapshot 提供给商店页和 LLM 准入 Guard 的余额快照。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只表达 TokenStore 权威账本视角下的 Credit 余额与阻断状态。
|
||||
// 2. snapshot_source 用于说明结果来自 cache 还是 db。
|
||||
// 3. 不承载任何扣费规则细节,避免把价格表语义耦合到余额查询。
|
||||
type CreditBalanceSnapshot struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
Balance int64 `json:"balance"`
|
||||
TotalRecharged int64 `json:"total_recharged"`
|
||||
TotalRewarded int64 `json:"total_rewarded"`
|
||||
TotalConsumed int64 `json:"total_consumed"`
|
||||
IsBlocked bool `json:"is_blocked"`
|
||||
SnapshotSource string `json:"snapshot_source"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreditProductView 是 Credit 商品卡片展示结构。
|
||||
type CreditProductView struct {
|
||||
ProductID uint64 `json:"product_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreditAmount int64 `json:"credit_amount"`
|
||||
PriceCent int64 `json:"price_cent"`
|
||||
OriginalPriceCent int64 `json:"original_price_cent"`
|
||||
PriceText string `json:"price_text"`
|
||||
Currency string `json:"currency"`
|
||||
Badge string `json:"badge"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// CreditOrderView 是 Credit 订单展示结构。
|
||||
type CreditOrderView struct {
|
||||
OrderID uint64 `json:"order_id"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Status string `json:"status"`
|
||||
ProductSnapshot string `json:"product_snapshot"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
CreditAmount int64 `json:"credit_amount"`
|
||||
AmountCent int64 `json:"amount_cent"`
|
||||
PriceText string `json:"price_text"`
|
||||
Currency string `json:"currency"`
|
||||
PaymentMode string `json:"payment_mode"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PaidAt *string `json:"paid_at"`
|
||||
CreditedAt *string `json:"credited_at"`
|
||||
}
|
||||
|
||||
// CreditTransactionView 是 Credit 流水展示结构。
|
||||
type CreditTransactionView struct {
|
||||
TransactionID uint64 `json:"transaction_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Source string `json:"source"`
|
||||
SourceLabel string `json:"source_label"`
|
||||
Direction string `json:"direction"`
|
||||
Amount int64 `json:"amount"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
MetadataJSON string `json:"metadata_json"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
OrderID *uint64 `json:"order_id"`
|
||||
}
|
||||
|
||||
const (
|
||||
// CreditConsumptionPeriod24h 表示统计最近 24 小时内的消耗。
|
||||
CreditConsumptionPeriod24h = "24h"
|
||||
// CreditConsumptionPeriod7d 表示统计最近 7 天内的消耗。
|
||||
CreditConsumptionPeriod7d = "7d"
|
||||
// CreditConsumptionPeriod30d 表示统计最近 30 天内的消耗。
|
||||
CreditConsumptionPeriod30d = "30d"
|
||||
// CreditConsumptionPeriodAll 表示统计全部历史消耗。
|
||||
CreditConsumptionPeriodAll = "all"
|
||||
)
|
||||
|
||||
// CreditConsumptionDashboardView 是商店页顶部消耗看板的展示结构。
|
||||
type CreditConsumptionDashboardView struct {
|
||||
Period string `json:"period"`
|
||||
CreditConsumed int64 `json:"credit_consumed"`
|
||||
TokenConsumed int64 `json:"token_consumed"`
|
||||
}
|
||||
|
||||
// CreditPriceRuleView 是 Credit 计价规则展示结构。
|
||||
type CreditPriceRuleView struct {
|
||||
RuleID uint64 `json:"rule_id"`
|
||||
Scene string `json:"scene"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ModelName string `json:"model_name"`
|
||||
InputPriceMicros int64 `json:"input_price_micros"`
|
||||
OutputPriceMicros int64 `json:"output_price_micros"`
|
||||
CachedPriceMicros int64 `json:"cached_price_micros"`
|
||||
ReasoningPriceMicros int64 `json:"reasoning_price_micros"`
|
||||
CreditPerYuan int64 `json:"credit_per_yuan"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// CreditRewardRuleView 是 Credit 奖励规则展示结构。
|
||||
type CreditRewardRuleView struct {
|
||||
RuleID uint64 `json:"rule_id"`
|
||||
Source string `json:"source"`
|
||||
Name string `json:"name"`
|
||||
Amount int64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// CreateCreditOrderRequest 是创建 Credit 订单请求契约。
|
||||
type CreateCreditOrderRequest struct {
|
||||
ActorUserID uint64 `json:"actor_user_id"`
|
||||
ProductID uint64 `json:"product_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
}
|
||||
|
||||
// ListCreditOrdersRequest 是 Credit 订单列表查询契约。
|
||||
type ListCreditOrdersRequest struct {
|
||||
ActorUserID uint64 `json:"actor_user_id"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// GetCreditConsumptionDashboardRequest 是查询当前用户消耗看板的契约。
|
||||
type GetCreditConsumptionDashboardRequest struct {
|
||||
ActorUserID uint64 `json:"actor_user_id"`
|
||||
Period string `json:"period"`
|
||||
}
|
||||
|
||||
// MockPaidCreditOrderRequest 是 Credit 商店 mock paid 请求契约。
|
||||
type MockPaidCreditOrderRequest struct {
|
||||
ActorUserID uint64 `json:"actor_user_id"`
|
||||
OrderID uint64 `json:"order_id"`
|
||||
MockChannel string `json:"mock_channel"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
}
|
||||
|
||||
// ListCreditTransactionsRequest 是当前用户查询自己 Credit 流水的契约。
|
||||
type ListCreditTransactionsRequest struct {
|
||||
ActorUserID uint64 `json:"actor_user_id"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Source string `json:"source"`
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
// ListCreditPriceRulesRequest 是 Credit 价格规则查询契约。
|
||||
type ListCreditPriceRulesRequest struct {
|
||||
Scene string `json:"scene"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ModelName string `json:"model_name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ListCreditRewardRulesRequest 是 Credit 奖励规则查询契约。
|
||||
type ListCreditRewardRulesRequest struct {
|
||||
Source string `json:"source"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
131
backend/shared/contracts/llm/contracts.go
Normal file
131
backend/shared/contracts/llm/contracts.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
ModelAliasLite = "lite"
|
||||
ModelAliasPro = "pro"
|
||||
ModelAliasMax = "max"
|
||||
ModelAliasCourseImageResponses = "course_image_responses"
|
||||
|
||||
DefaultTextModelAlias = ModelAliasPro
|
||||
ProviderNameArk = "ark"
|
||||
)
|
||||
|
||||
// PingRequest 只用于跨进程探活,不承载业务字段。
|
||||
type PingRequest struct{}
|
||||
|
||||
// PingResponse 只用于跨进程探活,不承载业务字段。
|
||||
type PingResponse struct{}
|
||||
|
||||
// BillingContext 是 LLM RPC 透传的计费上下文副本。
|
||||
type BillingContext struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Scene string `json:"scene"`
|
||||
RequestID string `json:"request_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
ModelAlias string `json:"model_alias"`
|
||||
SkipCharge bool `json:"skip_charge"`
|
||||
}
|
||||
|
||||
// GenerateOptions 是文本模型跨进程调用时的最小公共参数。
|
||||
type GenerateOptions struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Thinking string `json:"thinking"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// TextRequest 描述一次非流式文本调用。
|
||||
type TextRequest struct {
|
||||
ModelAlias string `json:"model_alias"`
|
||||
Messages []*schema.Message `json:"messages"`
|
||||
Options GenerateOptions `json:"options"`
|
||||
Billing *BillingContext `json:"billing,omitempty"`
|
||||
}
|
||||
|
||||
// StreamTextRequest 描述一次流式文本调用。
|
||||
type StreamTextRequest struct {
|
||||
ModelAlias string `json:"model_alias"`
|
||||
Messages []*schema.Message `json:"messages"`
|
||||
Options GenerateOptions `json:"options"`
|
||||
Billing *BillingContext `json:"billing,omitempty"`
|
||||
}
|
||||
|
||||
// TextResult 保存文本模型最终输出。
|
||||
type TextResult struct {
|
||||
Text string `json:"text"`
|
||||
Usage *schema.TokenUsage `json:"usage,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
}
|
||||
|
||||
// TextResponse 统一包住文本调用结果,便于后续扩字段。
|
||||
type TextResponse struct {
|
||||
Result *TextResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// StreamChunk 是流式返回的最小块协议。
|
||||
type StreamChunk struct {
|
||||
Message *schema.Message `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesMessage 描述 Responses 模型单条输入。
|
||||
type ResponsesMessage struct {
|
||||
Role string `json:"role"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
ImageDetail string `json:"image_detail,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesOptions 描述 Responses 模型公共参数。
|
||||
type ResponsesOptions struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Thinking string `json:"thinking"`
|
||||
TextFormat string `json:"text_format,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesRequest 描述一次 Responses 文本调用。
|
||||
type ResponsesRequest struct {
|
||||
ModelAlias string `json:"model_alias"`
|
||||
Messages []ResponsesMessage `json:"messages"`
|
||||
Options ResponsesOptions `json:"options"`
|
||||
Billing *BillingContext `json:"billing,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesUsage 是 Responses 结果里的最小 usage 结构。
|
||||
type ResponsesUsage struct {
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// ResponsesResult 统一承载 Responses 输出。
|
||||
type ResponsesResult struct {
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status,omitempty"`
|
||||
IncompleteReason string `json:"incomplete_reason,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Usage *ResponsesUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// ResponsesResponse 统一包住 Responses 结果。
|
||||
type ResponsesResponse struct {
|
||||
Result *ResponsesResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// NormalizeModelAlias 负责把空别名收敛到默认文本模型。
|
||||
func NormalizeModelAlias(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return DefaultTextModelAlias
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
91
backend/shared/events/credit.go
Normal file
91
backend/shared/events/credit.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// CreditChargeRequestedEventType 表示 LLM 服务已经拿到最终 usage,等待 TokenStore 异步结算。
|
||||
CreditChargeRequestedEventType = "credit.charge.requested"
|
||||
// CreditChargeEventVersion 是当前 Credit 扣费事件版本。
|
||||
CreditChargeEventVersion = "v1"
|
||||
)
|
||||
|
||||
// CreditChargeRequestedPayload 是 LLM -> TokenStore 的统一扣费事件。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只表达“一次已经完成的模型调用需要如何结算”,不承载准入决策;
|
||||
// 2. event_id 是最终幂等键,LLM outbox 重试和 TokenStore 记账都依赖它去重;
|
||||
// 3. credit_cost / rmb_cost_micros 在 LLM 侧就要算好,TokenStore 只负责权威落账。
|
||||
type CreditChargeRequestedPayload struct {
|
||||
EventID string `json:"event_id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
Scene string `json:"scene"`
|
||||
RequestID string `json:"request_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
ModelAlias string `json:"model_alias"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
ModelName string `json:"model_name"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
ReasoningTokens int64 `json:"reasoning_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
RMBCostMicros int64 `json:"rmb_cost_micros"`
|
||||
CreditCost int64 `json:"credit_cost"`
|
||||
TriggeredAt time.Time `json:"triggered_at"`
|
||||
SkipCharge bool `json:"skip_charge,omitempty"`
|
||||
}
|
||||
|
||||
// EventType 返回当前 payload 对应的标准事件类型。
|
||||
func (p CreditChargeRequestedPayload) EventType() string {
|
||||
return CreditChargeRequestedEventType
|
||||
}
|
||||
|
||||
// MessageKey 返回 Kafka / outbox 统一消息键。
|
||||
func (p CreditChargeRequestedPayload) MessageKey() string {
|
||||
return strings.TrimSpace(p.EventID)
|
||||
}
|
||||
|
||||
// AggregateID 返回扣费事件聚合键。
|
||||
func (p CreditChargeRequestedPayload) AggregateID() string {
|
||||
if p.UserID <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("user:%d", p.UserID)
|
||||
}
|
||||
|
||||
// Validate 校验扣费事件最小字段集。
|
||||
func (p CreditChargeRequestedPayload) Validate() error {
|
||||
if strings.TrimSpace(p.EventID) == "" {
|
||||
return errors.New("credit charge event_id 不能为空")
|
||||
}
|
||||
if p.UserID == 0 {
|
||||
return errors.New("credit charge user_id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(p.Scene) == "" {
|
||||
return errors.New("credit charge scene 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(p.ProviderName) == "" {
|
||||
return errors.New("credit charge provider_name 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(p.ModelName) == "" {
|
||||
return errors.New("credit charge model_name 不能为空")
|
||||
}
|
||||
if p.TriggeredAt.IsZero() {
|
||||
return errors.New("credit charge triggered_at 不能为空")
|
||||
}
|
||||
if p.SkipCharge {
|
||||
return nil
|
||||
}
|
||||
if p.CreditCost < 0 {
|
||||
return errors.New("credit charge credit_cost 不能为负数")
|
||||
}
|
||||
if p.RMBCostMicros < 0 {
|
||||
return errors.New("credit charge rmb_cost_micros 不能为负数")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
|
||||
602
docs/backend/统一出口Credit计费最终计划.md
Normal file
602
docs/backend/统一出口Credit计费最终计划.md
Normal file
@@ -0,0 +1,602 @@
|
||||
# 统一出口 Credit 计费最终计划
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本计划用于把当前系统从“各业务服务进程内直接持有 LLM 组件、调用结束后再走旧 token 累加”的模式,重构为:
|
||||
|
||||
1. `LLM` 成为真正的独立服务,统一掌管模型调用入口、出口、usage 采集与计费事件发布。
|
||||
2. `TokenStore` 服务切换为 Credit 语义权威服务,掌管余额、商品、订单、流水与最终扣费落账。
|
||||
3. 前端商店直接消费 Credit 语义接口,并能展示“当前登录用户自己的 Credit 流水”。
|
||||
|
||||
这份计划改成“三步走”版本,便于直接按阶段批准与执行。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体结论
|
||||
|
||||
### 2.1 核心结论
|
||||
|
||||
1. `backend/services/llm` 不能再以“进程内共享组件”存在,必须升级为独立服务。
|
||||
2. 所有用户态模型调用统一经过 `LLM RPC`,由 `LLM` 服务统一采集 usage。
|
||||
3. `LLM` 服务自己做同步准入 `CreditBalanceGuard`,并使用 Redis 余额快照提速。
|
||||
4. `LLM` 服务不做同步扣费,也不允许裸 goroutine 异步扣费。
|
||||
5. `LLM` 服务在拿到最终 usage 后,把 `credit.charge.requested` 写入 **自己的 outbox 表**。
|
||||
6. `TokenStore` 服务异步消费扣费事件,更新 Credit 余额与流水。
|
||||
7. 旧 token 累加链路、旧 token 商店链路和旧 `/token-store` 接口本轮直接删除,不保留兼容层。
|
||||
|
||||
### 2.2 关键原则
|
||||
|
||||
1. **统一出口**:所有 LLM 计费只在 `LLM` 服务出口发生,不允许业务层各自重复扣费。
|
||||
2. **强制入参**:不能只靠 `Metadata` 约定传 `user_id`,必须改 RPC/函数签名,让漏传在编译期报错。
|
||||
3. **准入同步、结算异步**:调用模型前同步做 `CreditBalanceGuard`,拿到最终 usage 后通过 outbox 异步结算。
|
||||
4. **旧商店直删**:旧 token 商店前后端都未真正接线,本轮不做 token/credit 双轨并存。
|
||||
5. **调用面兼容优先**:`LLM RPC` 可以新增内部协议,但 `backend/client/llm` 对业务侧暴露的调用方式应尽量贴近当前 `services/llm`,把迁移成本压到最低。
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么要这样改
|
||||
|
||||
### 3.1 当前最大问题
|
||||
|
||||
1. `services/llm` 名字像服务,实际上不是服务,而是多个进程直接 new 的本地组件。
|
||||
2. 计费边界分散,谁发起调用谁就得自己记账,很容易遗漏。
|
||||
3. 当前只有旧 `token_usage` 累加,没有人民币成本与 Credit 扣费真相。
|
||||
4. `userauth` 当前承担的是 `token quota` 门禁,不是 Credit 余额门禁。
|
||||
5. 旧 token 商店前后端都没有真正接上,因此没有保留兼容层的价值。
|
||||
|
||||
### 3.2 这次改完后的好处
|
||||
|
||||
1. LLM 调用入口统一。
|
||||
2. usage 采集统一。
|
||||
3. 余额准入统一。
|
||||
4. 扣费事件发布统一。
|
||||
5. TokenStore 只做 Credit 权威账本,不再和模型调用散乱耦合。
|
||||
|
||||
---
|
||||
|
||||
## 4. 三步走总览
|
||||
|
||||
### 第一步:把 LLM 变成真正独立服务
|
||||
|
||||
目标:
|
||||
|
||||
1. 所有模型调用统一改走 `LLM RPC`
|
||||
2. `LLM` 服务拥有自己的 `CreditBalanceGuard`
|
||||
3. `LLM` 服务拥有自己的 outbox 表
|
||||
|
||||
### 第二步:把 TokenStore 改造成 Credit 权威服务
|
||||
|
||||
目标:
|
||||
|
||||
1. 所有余额、流水、商品、订单都切成 Credit 语义
|
||||
2. 异步消费 `credit.charge.requested`
|
||||
3. 提供“查看自己流水”的正式接口
|
||||
|
||||
### 第三步:切 Gateway 与前端,并删旧链路
|
||||
|
||||
目标:
|
||||
|
||||
1. 上线 `/credit-store/*`
|
||||
2. 商店页接 Credit 真接口与流水列表
|
||||
3. 删掉旧 token 累加和旧 token 商店链路
|
||||
|
||||
---
|
||||
|
||||
## 5. 第一步:把 LLM 变成真正独立服务
|
||||
|
||||
## 5.1 这一步的目标
|
||||
|
||||
把当前“各进程内直接持有 LLM 组件”的模式,改成“统一调 LLM 服务”。
|
||||
|
||||
## 5.2 新增/改造内容
|
||||
|
||||
### 5.2.1 新增独立 LLM 进程与 RPC
|
||||
|
||||
新增:
|
||||
|
||||
1. `backend/cmd/llm/main.go`
|
||||
2. `backend/client/llm/client.go`
|
||||
3. `backend/services/llm/rpc/pb/llm.proto`
|
||||
4. `backend/services/llm/rpc/handler.go`
|
||||
5. `backend/services/llm/rpc/server.go`
|
||||
6. `backend/services/llm/dao/connect.go`
|
||||
|
||||
说明:
|
||||
|
||||
1. 这样 `services/llm` 放在 `services` 目录下才真正名副其实。
|
||||
2. 业务服务以后全部只依赖 `client/llm`,不再直接 import `services/llm` 的本地组件。
|
||||
|
||||
### 5.2.2 统一 RPC 能力,但业务侧调用面尽量保持原状
|
||||
|
||||
服务间 `LLM RPC` 至少提供三类底层能力:
|
||||
|
||||
1. `GenerateText`
|
||||
2. `StreamText`
|
||||
3. `GenerateResponsesText`
|
||||
|
||||
但是对业务代码,不建议直接散落调用底层 RPC 方法名,而是由 `backend/client/llm` 继续暴露与当前本地 `llm client` 基本一致的门面:
|
||||
|
||||
1. `(*Client).GenerateText(ctx, messages, options)`
|
||||
2. `GenerateJSON[T](ctx, client, messages, options)`
|
||||
3. `(*Client).Stream(ctx, messages, options)`
|
||||
4. `(*ArkResponsesClient).GenerateText(ctx, messages, options)`
|
||||
5. `GenerateArkResponsesJSON[T](ctx, client, messages, options)`
|
||||
|
||||
迁移目标:
|
||||
|
||||
1. 业务层尽量只替换 client 来源,不重写 prompt 组织方式。
|
||||
2. `GenerateOptions`、`ArkResponsesOptions` 现有字段语义尽量不变。
|
||||
3. `generateJson / stream / responses json` 这些现有能力名和使用习惯尽量延续,避免把改造面扩大成“全链路重写调用协议”。
|
||||
|
||||
使用分工:
|
||||
|
||||
1. `GenerateText`:memory、active-scheduler、agent 非流式调用
|
||||
2. `StreamText`:agent chat / plan / execute / deliver
|
||||
3. `GenerateResponsesText`:course 图片解析
|
||||
|
||||
### 5.2.3 统一 BillingContext,但不要污染现有 GenerateOptions
|
||||
|
||||
所有请求都必须携带:
|
||||
|
||||
```go
|
||||
type BillingContext struct {
|
||||
UserID int
|
||||
EventID string
|
||||
Scene string
|
||||
RequestID string
|
||||
ConversationID string
|
||||
ModelAlias string
|
||||
SkipCharge bool
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. `UserID` 必须明确,普通用户态调用禁止省略。
|
||||
2. `EventID` 必须稳定,作为幂等扣费键。
|
||||
3. `Scene` 必须明确,方便价格表与审计。
|
||||
4. `SkipCharge` 只允许系统内部场景显式声明。
|
||||
5. `BillingContext` 不再塞进 `GenerateOptions.Metadata` 里,避免继续靠弱约定传关键字段。
|
||||
6. `GenerateOptions`、`ArkResponsesOptions` 继续只承载模型行为参数,例如 `Temperature / MaxTokens / Thinking`。
|
||||
|
||||
落地建议:
|
||||
|
||||
1. `client/llm` 对调用方继续保留现有 `GenerateText / GenerateJSON / Stream / Responses` 风格方法。
|
||||
2. 计费必填信息通过独立的调用包装层传入,例如“绑定过 BillingContext 的 client”或“显式 request 参数对象”,但要由 `client/llm` 吸收复杂度。
|
||||
3. 调用方改造目标应控制在“补一处 BillingContext 组装 + 替换 client 初始化来源”,而不是每个业务点重写整段调用代码。
|
||||
|
||||
### 5.2.4 LLM 服务自己的 CreditBalanceGuard
|
||||
|
||||
这一步必须同时完成,否则独立 LLM 服务没有准入价值。
|
||||
|
||||
Guard 设计要求:
|
||||
|
||||
1. 模型真正发起前同步执行。
|
||||
2. 不直接查 MySQL 真相表,必须走 Redis 快照优先。
|
||||
3. 缓存 miss 或过期时,再回源 `TokenStore RPC`。
|
||||
|
||||
建议缓存 key:
|
||||
|
||||
1. `smartflow:credit_balance_snapshot:{user_id}`
|
||||
2. `smartflow:credit_balance_blocked:{user_id}`
|
||||
|
||||
执行顺序:
|
||||
|
||||
1. 先查 `blocked` 键
|
||||
2. 再查 `snapshot` 键
|
||||
3. miss 时调用 TokenStore 获取余额快照
|
||||
4. 若余额不足,写短 TTL 的 `blocked` 键
|
||||
|
||||
### 5.2.5 LLM 服务自己的 outbox
|
||||
|
||||
这一步也必须同时完成。
|
||||
|
||||
新增:
|
||||
|
||||
1. `llm_outbox_messages`
|
||||
|
||||
并在 outbox 目录注册:
|
||||
|
||||
1. `ServiceLLM`
|
||||
2. `ServiceNameLLM`
|
||||
3. `smartflow.llm.outbox`
|
||||
4. `smartflow-llm-outbox-consumer`
|
||||
|
||||
涉及文件:
|
||||
|
||||
1. `backend/shared/infra/outbox/service_catalog.go`
|
||||
2. `backend/shared/infra/outbox/service_route.go`
|
||||
3. `backend/services/llm/dao/connect.go`
|
||||
|
||||
### 5.2.6 LLM 服务统一 usage 采集
|
||||
|
||||
在 `LLM` 服务内部统一输出:
|
||||
|
||||
```go
|
||||
type BillingUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
CachedTokens int64
|
||||
ReasoningTokens int64
|
||||
TotalTokens int64
|
||||
ModelName string
|
||||
ProviderName string
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2.7 LLM 服务只发 charge 事件,不同步扣费
|
||||
|
||||
新增事件:
|
||||
|
||||
1. `credit.charge.requested`
|
||||
|
||||
事件载荷至少包含:
|
||||
|
||||
1. `event_id`
|
||||
2. `user_id`
|
||||
3. `scene`
|
||||
4. `request_id`
|
||||
5. `conversation_id`
|
||||
6. `provider_name`
|
||||
7. `model_name`
|
||||
8. `input_tokens`
|
||||
9. `output_tokens`
|
||||
10. `cached_tokens`
|
||||
11. `reasoning_tokens`
|
||||
12. `rmb_cost_micros`
|
||||
13. `credit_cost`
|
||||
14. `triggered_at`
|
||||
|
||||
时机:
|
||||
|
||||
1. 拿到最终 usage
|
||||
2. 算出本次 `credit_cost`
|
||||
3. 写入 `llm_outbox_messages`
|
||||
4. 结果即可返回调用方
|
||||
|
||||
## 5.3 这一步涉及哪些调用方改造
|
||||
|
||||
以下服务都要从“本地 import `services/llm`”改成“调用 `client/llm`”:
|
||||
|
||||
1. `agent`
|
||||
2. `memory`
|
||||
3. `active-scheduler`
|
||||
4. `course`
|
||||
|
||||
---
|
||||
|
||||
## 6. 第二步:把 TokenStore 改造成 Credit 权威服务
|
||||
|
||||
## 6.1 这一步的目标
|
||||
|
||||
让 `TokenStore` 不再只是“充值/奖励入账中心”,而是:
|
||||
|
||||
1. Credit 余额真相
|
||||
2. Credit 商品与订单中心
|
||||
3. Credit 流水中心
|
||||
4. LLM 扣费事件的最终结算方
|
||||
|
||||
## 6.2 新的 Credit 数据表
|
||||
|
||||
本轮不复用旧 `token_*` 表,直接建立纯 Credit 语义的新表:
|
||||
|
||||
1. `credit_products`
|
||||
2. `credit_orders`
|
||||
3. `credit_accounts`
|
||||
4. `credit_ledger`
|
||||
5. `credit_price_rules`
|
||||
6. `credit_reward_rules`
|
||||
|
||||
### 6.2.1 `credit_products`
|
||||
|
||||
用途:商店展示的 Credit 商品
|
||||
|
||||
核心字段:
|
||||
|
||||
1. `sku`
|
||||
2. `name`
|
||||
3. `description`
|
||||
4. `credit_amount`
|
||||
5. `price_cent`
|
||||
6. `currency`
|
||||
7. `badge`
|
||||
8. `status`
|
||||
9. `sort_order`
|
||||
|
||||
### 6.2.2 `credit_orders`
|
||||
|
||||
用途:购买订单与 mock paid 状态机
|
||||
|
||||
核心字段:
|
||||
|
||||
1. `order_no`
|
||||
2. `user_id`
|
||||
3. `product_id`
|
||||
4. `product_snapshot_json`
|
||||
5. `quantity`
|
||||
6. `credit_amount`
|
||||
7. `amount_cent`
|
||||
8. `status`
|
||||
9. `payment_mode`
|
||||
10. `idempotency_key`
|
||||
11. `paid_at`
|
||||
12. `credited_at`
|
||||
|
||||
### 6.2.3 `credit_accounts`
|
||||
|
||||
用途:余额真相与门禁回源表
|
||||
|
||||
核心字段:
|
||||
|
||||
1. `user_id`
|
||||
2. `balance`
|
||||
3. `total_recharged`
|
||||
4. `total_rewarded`
|
||||
5. `total_consumed`
|
||||
6. `version`
|
||||
|
||||
### 6.2.4 `credit_ledger`
|
||||
|
||||
用途:统一正负流水,也是“查看自己流水”的唯一真相源
|
||||
|
||||
核心字段:
|
||||
|
||||
1. `event_id`
|
||||
2. `user_id`
|
||||
3. `direction`
|
||||
4. `source`
|
||||
5. `scene`
|
||||
6. `description`
|
||||
7. `credit_delta`
|
||||
8. `balance_after`
|
||||
9. `input_tokens`
|
||||
10. `output_tokens`
|
||||
11. `cached_tokens`
|
||||
12. `reasoning_tokens`
|
||||
13. `rmb_cost_micros`
|
||||
14. `created_at`
|
||||
|
||||
### 6.2.5 `credit_price_rules`
|
||||
|
||||
用途:模型价格表
|
||||
|
||||
核心字段:
|
||||
|
||||
1. `provider_name`
|
||||
2. `model_name`
|
||||
3. `scene`
|
||||
4. `input_price_micros_per_1k`
|
||||
5. `output_price_micros_per_1k`
|
||||
6. `cached_price_micros_per_1k`
|
||||
7. `reasoning_price_micros_per_1k`
|
||||
8. `credits_per_yuan`
|
||||
9. `status`
|
||||
|
||||
### 6.2.6 `credit_reward_rules`
|
||||
|
||||
用途:社区奖励规则
|
||||
|
||||
核心字段:
|
||||
|
||||
1. `source`
|
||||
2. `name`
|
||||
3. `credit_amount`
|
||||
4. `status`
|
||||
5. `config_json`
|
||||
|
||||
## 6.3 TokenStore 如何消费 charge 事件
|
||||
|
||||
新增消费逻辑:
|
||||
|
||||
1. 订阅 `credit.charge.requested`
|
||||
2. 幂等校验 `event_id`
|
||||
3. 在事务中写 `credit_ledger`
|
||||
4. 扣减 `credit_accounts.balance`
|
||||
5. 刷新或删除 Redis 余额快照
|
||||
|
||||
## 6.4 用户查看自己流水接口
|
||||
|
||||
这是本轮必须落的能力。
|
||||
|
||||
对外新增:
|
||||
|
||||
1. `ListCreditTransactions`
|
||||
|
||||
要求:
|
||||
|
||||
1. 明确用于“当前登录用户查看自己的 Credit 流水”
|
||||
2. 必须覆盖购买入账、社区奖励入账、LLM 消费扣费三类记录
|
||||
|
||||
HTTP 接口:
|
||||
|
||||
1. `GET /api/v1/credit-store/transactions`
|
||||
|
||||
必须满足:
|
||||
|
||||
1. 只返回当前登录用户自己的 Credit 流水
|
||||
2. 支持分页:`page`、`page_size`
|
||||
3. 支持可选筛选:`direction`、`source`、`scene`
|
||||
4. 返回字段至少包含:
|
||||
- `transaction_id`
|
||||
- `event_id`
|
||||
- `direction`
|
||||
- `source`
|
||||
- `scene`
|
||||
- `description`
|
||||
- `credit_delta`
|
||||
- `balance_after`
|
||||
- `created_at`
|
||||
|
||||
## 6.5 这一步要删除什么旧东西
|
||||
|
||||
直接删除:
|
||||
|
||||
1. `token_products`
|
||||
2. `token_orders`
|
||||
3. `token_grants`
|
||||
4. `token_reward_rules`
|
||||
|
||||
---
|
||||
|
||||
## 7. 第三步:切 Gateway/前端并删旧链路
|
||||
|
||||
## 7.1 这一步的目标
|
||||
|
||||
1. 上线 `/credit-store/*`
|
||||
2. 商店页接 Credit 真接口
|
||||
3. 删掉旧 token 累加和旧 token 商店链路
|
||||
|
||||
## 7.2 Gateway 切换
|
||||
|
||||
只保留 Credit 语义路由:
|
||||
|
||||
1. `GET /api/v1/credit-store/summary`
|
||||
2. `GET /api/v1/credit-store/products`
|
||||
3. `POST /api/v1/credit-store/orders`
|
||||
4. `POST /api/v1/credit-store/orders/:order_id/mock-paid`
|
||||
5. `GET /api/v1/credit-store/transactions`
|
||||
|
||||
直接删除:
|
||||
|
||||
1. `/token-store/*`
|
||||
2. `backend/gateway/api/tokenstoreapi/*`
|
||||
|
||||
## 7.3 前端商店页切换
|
||||
|
||||
商店页必须改成:
|
||||
|
||||
1. 接 `/credit-store/*`
|
||||
2. 新增“我的 Credit 流水”展示区
|
||||
3. 流水数据源只允许来自 `GET /api/v1/credit-store/transactions`
|
||||
4. 至少展示:
|
||||
- 流水类型
|
||||
- 流水说明
|
||||
- Credit 增减值
|
||||
- 流水发生时间
|
||||
- 可选余额快照
|
||||
|
||||
## 7.4 旧 token 累加机制下线
|
||||
|
||||
直接删除:
|
||||
|
||||
1. `backend/services/runtime/eventsvc/chat_token_usage_adjust.go`
|
||||
2. `backend/services/runtime/model/agent.go` 中 `ChatTokenUsageAdjustPayload`
|
||||
3. `backend/services/agent/sv/agent_graph.go` 中旧 token 调整调用
|
||||
4. `backend/services/agent/sv/agent_meta.go` 中旧 token 调整调用
|
||||
5. `backend/services/runtime/dao/agent.go` 中 `tokens_total` 累加写路径
|
||||
6. `backend/gateway/middleware/token_quota_guard.go` 在聊天主链的使用
|
||||
7. `userauth` 中 `CheckTokenQuota / AdjustTokenUsage` 的计费用途
|
||||
8. `agent_chats.tokens_total` 字段
|
||||
|
||||
---
|
||||
|
||||
## 8. 这三步分别改哪些地方
|
||||
|
||||
### 第一步改动面
|
||||
|
||||
1. `backend/services/llm/*`
|
||||
2. `backend/client/llm/*`
|
||||
3. `backend/cmd/llm/*`
|
||||
4. `backend/shared/infra/outbox/service_catalog.go`
|
||||
5. `backend/shared/infra/outbox/service_route.go`
|
||||
6. `agent / memory / active-scheduler / course` 的所有本地 LLM 调用点
|
||||
|
||||
### 第二步改动面
|
||||
|
||||
1. `backend/services/tokenstore/*`
|
||||
2. `backend/client/tokenstore/*`
|
||||
3. TokenStore 的 outbox consumer
|
||||
4. Redis 余额快照与失效逻辑
|
||||
|
||||
### 第三步改动面
|
||||
|
||||
1. `backend/gateway/*`
|
||||
2. `frontend/src/views/StoreView.vue`
|
||||
3. 可能新增商店流水列表组件
|
||||
4. `runtime/userauth` 旧 token 累加链删除
|
||||
|
||||
---
|
||||
|
||||
## 9. 验证清单
|
||||
|
||||
### 9.1 第一步验证
|
||||
|
||||
1. 业务服务代码里不再直接 import `services/llm`
|
||||
2. 所有模型调用都改经 `LLM RPC`
|
||||
3. 所有请求都必须带 `BillingContext`
|
||||
4. `CreditBalanceGuard` 命中缓存、miss 回源、余额不足封禁都通过测试
|
||||
5. `llm_outbox_messages` 能正常写入 `credit.charge.requested`
|
||||
|
||||
### 9.2 第二步验证
|
||||
|
||||
1. `TokenStore` 能幂等消费 `credit.charge.requested`
|
||||
2. `credit_accounts` 余额更新正确
|
||||
3. `credit_ledger` 流水写入正确
|
||||
4. `ListCreditTransactions` 返回当前用户自己的流水
|
||||
|
||||
### 9.3 第三步验证
|
||||
|
||||
1. 商店页能展示 Credit 概览
|
||||
2. 商店页能展示商品
|
||||
3. 商店页能展示“我的 Credit 流水”
|
||||
4. 全文搜索确认以下旧链路不再出现在主路径:
|
||||
- `PublishChatTokenUsageAdjustRequested`
|
||||
- `AdjustTokenUsage(`
|
||||
- `CheckTokenQuota(`
|
||||
- `TokenQuotaGuard`
|
||||
- `tokens_total +`
|
||||
- `tokenstoreapi`
|
||||
- `/token-store`
|
||||
- `token_products`
|
||||
- `token_orders`
|
||||
- `token_grants`
|
||||
- `token_reward_rules`
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与取舍
|
||||
|
||||
### 10.1 这次改造面确实更大
|
||||
|
||||
因为这次不是只改计费,而是顺带把 `LLM` 从“共享组件”升级成真正独立服务,所以会涉及:
|
||||
|
||||
1. 新 RPC
|
||||
2. 新 client
|
||||
3. 新 outbox
|
||||
4. 业务服务调用方式切换
|
||||
|
||||
### 10.2 但长期结构更对
|
||||
|
||||
收益是:
|
||||
|
||||
1. LLM 调用入口统一
|
||||
2. usage 采集统一
|
||||
3. 准入 guard 统一
|
||||
4. 计费事件发布统一
|
||||
5. TokenStore 只做账本,不再和模型调用散乱耦合
|
||||
|
||||
---
|
||||
|
||||
## 11. 服务影响总表
|
||||
|
||||
### 必改服务
|
||||
|
||||
1. `backend/services/llm`
|
||||
2. `backend/services/tokenstore`
|
||||
3. `backend/gateway`
|
||||
4. `backend/services/agent`
|
||||
5. `backend/services/course`
|
||||
6. `backend/services/memory`
|
||||
7. `backend/services/active_scheduler`
|
||||
|
||||
### 新增
|
||||
|
||||
1. `backend/cmd/llm`
|
||||
2. `backend/client/llm`
|
||||
3. `backend/services/llm/rpc/*`
|
||||
4. `llm_outbox_messages`
|
||||
|
||||
### 后续前端接入
|
||||
|
||||
1. `frontend/src/views/StoreView.vue`
|
||||
2. 若商店页拆组件,则补充独立的 Credit 流水列表组件
|
||||
145
frontend/src/api/forum.ts
Normal file
145
frontend/src/api/forum.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import http from '@/api/http'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import type {
|
||||
CreateForumCommentPayload,
|
||||
CreateForumPostPayload,
|
||||
ForumCommentListQuery,
|
||||
ForumCommentNode,
|
||||
ForumDeleteCommentResult,
|
||||
ForumImportResult,
|
||||
ForumInteractionResult,
|
||||
ForumPageEnvelope,
|
||||
ForumPostBrief,
|
||||
ForumPostDetail,
|
||||
ForumPostListQuery,
|
||||
ForumTagItem,
|
||||
} from '@/types/forum'
|
||||
import { createIdempotencyKey } from '@/utils/idempotency'
|
||||
import { extractErrorMessage } from '@/utils/http'
|
||||
|
||||
interface ForumTagsEnvelope {
|
||||
items: ForumTagItem[]
|
||||
}
|
||||
|
||||
export async function listForumPosts(query: ForumPostListQuery = {}) {
|
||||
try {
|
||||
const response = await http.get<ApiResponse<ForumPageEnvelope<ForumPostBrief>>>('/plan-square/posts', {
|
||||
params: query,
|
||||
})
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '计划广场列表加载失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function listForumTags(limit = 20) {
|
||||
try {
|
||||
const response = await http.get<ApiResponse<ForumTagsEnvelope>>('/plan-square/tags', {
|
||||
params: { limit },
|
||||
})
|
||||
return response.data.data?.items ?? []
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '计划广场标签加载失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function createForumPost(
|
||||
payload: CreateForumPostPayload,
|
||||
idempotencyKey = createIdempotencyKey('forum-post-create'),
|
||||
) {
|
||||
try {
|
||||
const response = await http.post<ApiResponse<ForumPostBrief>>('/plan-square/posts', payload, {
|
||||
headers: {
|
||||
'X-Idempotency-Key': idempotencyKey,
|
||||
},
|
||||
})
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '发布计划失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function getForumPostDetail(postId: number) {
|
||||
try {
|
||||
const response = await http.get<ApiResponse<ForumPostDetail>>(`/plan-square/posts/${postId}`)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '计划详情加载失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function likeForumPost(postId: number) {
|
||||
try {
|
||||
const response = await http.post<ApiResponse<ForumInteractionResult>>(`/plan-square/posts/${postId}/like`)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '点赞失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlikeForumPost(postId: number) {
|
||||
try {
|
||||
const response = await http.delete<ApiResponse<ForumInteractionResult>>(`/plan-square/posts/${postId}/like`)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '取消点赞失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function listForumComments(postId: number, query: ForumCommentListQuery = {}) {
|
||||
try {
|
||||
const response = await http.get<ApiResponse<ForumPageEnvelope<ForumCommentNode>>>(`/plan-square/posts/${postId}/comments`, {
|
||||
params: query,
|
||||
})
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '评论列表加载失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function createForumComment(
|
||||
postId: number,
|
||||
payload: CreateForumCommentPayload,
|
||||
idempotencyKey = createIdempotencyKey('forum-comment-create'),
|
||||
) {
|
||||
try {
|
||||
const response = await http.post<ApiResponse<ForumCommentNode>>(`/plan-square/posts/${postId}/comments`, payload, {
|
||||
headers: {
|
||||
'X-Idempotency-Key': idempotencyKey,
|
||||
},
|
||||
})
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '发表评论失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteForumComment(commentId: number) {
|
||||
try {
|
||||
const response = await http.delete<ApiResponse<ForumDeleteCommentResult>>(`/plan-square/comments/${commentId}`)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '删除评论失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function importForumPost(
|
||||
postId: number,
|
||||
targetTitle = '',
|
||||
idempotencyKey = createIdempotencyKey('forum-post-import'),
|
||||
) {
|
||||
try {
|
||||
const response = await http.post<ApiResponse<ForumImportResult>>(
|
||||
`/plan-square/posts/${postId}/import`,
|
||||
{ target_title: targetTitle },
|
||||
{
|
||||
headers: {
|
||||
'X-Idempotency-Key': idempotencyKey,
|
||||
},
|
||||
},
|
||||
)
|
||||
return response.data.data
|
||||
} catch (error) {
|
||||
throw new Error(extractErrorMessage(error, '导入计划失败,请稍后重试'))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user