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:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,9 @@ type ArkResponsesResult struct {
|
||||
|
||||
// ArkResponsesClient 是 Ark SDK Responses 的统一模型出口。
|
||||
type ArkResponsesClient struct {
|
||||
model string
|
||||
client *arkruntime.Client
|
||||
model string
|
||||
client *arkruntime.Client
|
||||
generateText func(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error)
|
||||
}
|
||||
|
||||
// NewArkResponsesClient 创建 Ark SDK Responses 客户端。
|
||||
@@ -71,8 +72,28 @@ func NewArkResponsesClient(apiKey string, baseURL string, model string) *ArkResp
|
||||
}
|
||||
}
|
||||
|
||||
// NewArkResponsesClientWithFunc 使用外部注入的 GenerateText 能力构造客户端。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 供 llm zrpc remote client 和测试替身复用;
|
||||
// 2. 这里只负责挂接统一函数签名,不负责远端连接初始化;
|
||||
// 3. model 仅作为兼容字段保留,真正调用行为以 generateText 为准。
|
||||
func NewArkResponsesClientWithFunc(model string, generateText func(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error)) *ArkResponsesClient {
|
||||
if generateText == nil {
|
||||
return nil
|
||||
}
|
||||
return &ArkResponsesClient{
|
||||
model: strings.TrimSpace(model),
|
||||
generateText: generateText,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateText 执行一次非流式 Responses 调用并提取文本。
|
||||
func (c *ArkResponsesClient) GenerateText(ctx context.Context, messages []ArkResponsesMessage, options ArkResponsesOptions) (*ArkResponsesResult, error) {
|
||||
if c != nil && c.generateText != nil {
|
||||
return c.generateText(ctx, messages, options)
|
||||
}
|
||||
|
||||
req, err := c.buildRequest(messages, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
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,
|
||||
Name: "计划被点赞奖励",
|
||||
Amount: 1,
|
||||
Status: tokenmodel.TokenRewardRuleStatusActive,
|
||||
SKU: "credit_free_100",
|
||||
Name: "Free",
|
||||
Description: "每日免费发放,适合基础功能体验。",
|
||||
CreditAmount: 100,
|
||||
PriceCent: 0,
|
||||
OriginalPriceCent: 0,
|
||||
Currency: "CNY",
|
||||
Badge: "每日",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 10,
|
||||
},
|
||||
{
|
||||
Source: tokenmodel.TokenGrantSourceForumImport,
|
||||
Name: "计划被导入奖励",
|
||||
Amount: 5,
|
||||
Status: tokenmodel.TokenRewardRuleStatusActive,
|
||||
SKU: "credit_starter_1000",
|
||||
Name: "Starter",
|
||||
Description: "入门级额度,有效期 1 个月。续费时时间和额度均可累加。",
|
||||
CreditAmount: 1000,
|
||||
PriceCent: 990,
|
||||
OriginalPriceCent: 990,
|
||||
Currency: "CNY",
|
||||
Badge: "入门",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 20,
|
||||
},
|
||||
{
|
||||
SKU: "credit_lite_3000",
|
||||
Name: "Lite",
|
||||
Description: "经济型套餐,有效期 1 个月。适合日常轻度规划。",
|
||||
CreditAmount: 3000,
|
||||
PriceCent: 1990,
|
||||
OriginalPriceCent: 1990,
|
||||
Currency: "CNY",
|
||||
Badge: "经济",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 30,
|
||||
},
|
||||
{
|
||||
SKU: "credit_pro_10000",
|
||||
Name: "Pro",
|
||||
Description: "专业版套餐,有效期 1 个月。最受深度规划用户欢迎。",
|
||||
CreditAmount: 10000,
|
||||
PriceCent: 3990,
|
||||
OriginalPriceCent: 3990,
|
||||
Currency: "CNY",
|
||||
Badge: "Most Popular",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 40,
|
||||
},
|
||||
{
|
||||
SKU: "credit_max_40000",
|
||||
Name: "Max",
|
||||
Description: "旗舰级套餐,有效期 1 个月。极致体验,额度充沛。",
|
||||
CreditAmount: 40000,
|
||||
PriceCent: 9990,
|
||||
OriginalPriceCent: 9990,
|
||||
Currency: "CNY",
|
||||
Badge: "旗舰",
|
||||
Status: storemodel.CreditProductStatusActive,
|
||||
SortOrder: 50,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultCreditRewardRules() []storemodel.CreditRewardRule {
|
||||
return []storemodel.CreditRewardRule{
|
||||
{
|
||||
Source: storemodel.CreditLedgerSourceForumLike,
|
||||
Name: "计划被点赞奖励",
|
||||
Amount: 1,
|
||||
Status: storemodel.CreditRewardRuleStatusActive,
|
||||
Description: "预留论坛点赞正向激励。",
|
||||
},
|
||||
{
|
||||
Source: storemodel.CreditLedgerSourceForumImport,
|
||||
Name: "计划被导入奖励",
|
||||
Amount: 5,
|
||||
Status: storemodel.CreditRewardRuleStatusActive,
|
||||
Description: "预留论坛导入正向激励。",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultCreditPriceRules() []storemodel.CreditPriceRule {
|
||||
return []storemodel.CreditPriceRule{
|
||||
{
|
||||
Scene: "*",
|
||||
ProviderName: "ark",
|
||||
ModelName: "*",
|
||||
InputPriceMicros: 3200,
|
||||
OutputPriceMicros: 16000,
|
||||
CachedPriceMicros: 800,
|
||||
ReasoningPriceMicros: 16000,
|
||||
CreditPerYuan: 100,
|
||||
Status: storemodel.CreditPriceRuleStatusActive,
|
||||
Priority: 100,
|
||||
Description: "Default Ark rule, prices are expressed in micros CNY per 1K tokens.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
summary, err := svc.GetSummary(ctx, req.ActorUserId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.GetTokenSummaryResponse{Summary: tokenSummaryToPB(summary)}, nil
|
||||
// GetSummary 保留旧 token RPC 方法壳,统一返回已下线。
|
||||
func (h *Handler) GetSummary(context.Context, *pb.GetTokenSummaryRequest) (*pb.GetTokenSummaryResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
func (h *Handler) ListProducts(ctx context.Context, req *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, err := svc.ListProducts(ctx, req.ActorUserId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListTokenProductsResponse{Items: tokenProductsToPB(items)}, nil
|
||||
func (h *Handler) ListProducts(context.Context, *pb.ListTokenProductsRequest) (*pb.ListTokenProductsResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
func (h *Handler) CreateOrder(ctx context.Context, req *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
order, err := svc.CreateOrder(ctx, tokencontracts.CreateTokenOrderRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
ProductID: req.ProductId,
|
||||
Quantity: int(req.Quantity),
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.CreateTokenOrderResponse{Order: tokenOrderToPB(order)}, nil
|
||||
func (h *Handler) CreateOrder(context.Context, *pb.CreateTokenOrderRequest) (*pb.CreateTokenOrderResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
func (h *Handler) ListOrders(ctx context.Context, req *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, page, err := svc.ListOrders(ctx, tokencontracts.ListTokenOrdersRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
Page: int(req.Page),
|
||||
PageSize: int(req.PageSize),
|
||||
Status: req.Status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListTokenOrdersResponse{
|
||||
Items: tokenOrdersToPB(items),
|
||||
Page: tokenPageToPB(page),
|
||||
}, nil
|
||||
func (h *Handler) ListOrders(context.Context, *pb.ListTokenOrdersRequest) (*pb.ListTokenOrdersResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
func (h *Handler) GetOrder(ctx context.Context, req *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
order, err := svc.GetOrder(ctx, req.ActorUserId, req.OrderId)
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.GetTokenOrderResponse{Order: tokenOrderToPB(order)}, nil
|
||||
func (h *Handler) GetOrder(context.Context, *pb.GetTokenOrderRequest) (*pb.GetTokenOrderResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
func (h *Handler) MockPaidOrder(ctx context.Context, req *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
order, err := svc.MockPaidOrder(ctx, tokencontracts.MockPaidOrderRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
OrderID: req.OrderId,
|
||||
MockChannel: req.MockChannel,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.MockPaidOrderResponse{Order: tokenOrderToPB(order)}, nil
|
||||
func (h *Handler) MockPaidOrder(context.Context, *pb.MockPaidOrderRequest) (*pb.MockPaidOrderResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
func (h *Handler) ListGrants(ctx context.Context, req *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
items, page, err := svc.ListGrants(ctx, tokencontracts.ListTokenGrantsRequest{
|
||||
ActorUserID: req.ActorUserId,
|
||||
Page: int(req.Page),
|
||||
PageSize: int(req.PageSize),
|
||||
Source: req.Source,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.ListTokenGrantsResponse{
|
||||
Items: tokenGrantsToPB(items),
|
||||
Page: tokenPageToPB(page),
|
||||
}, nil
|
||||
func (h *Handler) ListGrants(context.Context, *pb.ListTokenGrantsRequest) (*pb.ListTokenGrantsResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
// RecordForumRewardGrant 负责把论坛 outbox 奖励事件转成 token-store 内部账本写入调用。
|
||||
func (h *Handler) RecordForumRewardGrant(ctx context.Context, req *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) {
|
||||
svc, err := h.service()
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
if req == nil {
|
||||
return nil, grpcErrorFromServiceError(respond.MissingParam)
|
||||
}
|
||||
|
||||
grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{
|
||||
EventID: req.EventId,
|
||||
ReceiverUserID: req.ReceiverUserId,
|
||||
Source: req.Source,
|
||||
SourceRefID: req.SourceRefId,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, grpcErrorFromServiceError(err)
|
||||
}
|
||||
return &pb.RecordForumRewardGrantResponse{Grant: tokenGrantToPB(grant)}, nil
|
||||
func (h *Handler) RecordForumRewardGrant(context.Context, *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) {
|
||||
return nil, legacyTokenMethodRemoved()
|
||||
}
|
||||
|
||||
func tokenPageToPB(page tokencontracts.PageResult) *pb.PageResponse {
|
||||
return &pb.PageResponse{
|
||||
Page: int32(page.Page),
|
||||
PageSize: int32(page.PageSize),
|
||||
Total: int32(page.Total),
|
||||
HasMore: page.HasMore,
|
||||
}
|
||||
}
|
||||
|
||||
func tokenSummaryToPB(summary *tokencontracts.TokenSummary) *pb.TokenSummary {
|
||||
if summary == nil {
|
||||
return nil
|
||||
}
|
||||
return &pb.TokenSummary{
|
||||
RecordedTokenTotal: summary.RecordedTokenTotal,
|
||||
AppliedTokenTotal: summary.AppliedTokenTotal,
|
||||
PendingApplyTokenTotal: summary.PendingApplyTokenTotal,
|
||||
QuotaSyncStatus: summary.QuotaSyncStatus,
|
||||
Tip: summary.Tip,
|
||||
}
|
||||
}
|
||||
|
||||
func tokenProductToPB(product tokencontracts.TokenProductView) *pb.TokenProductView {
|
||||
return &pb.TokenProductView{
|
||||
ProductId: product.ProductID,
|
||||
Name: product.Name,
|
||||
Description: product.Description,
|
||||
TokenAmount: product.TokenAmount,
|
||||
PriceCent: product.PriceCent,
|
||||
PriceText: product.PriceText,
|
||||
Currency: product.Currency,
|
||||
Badge: product.Badge,
|
||||
Status: product.Status,
|
||||
SortOrder: int32(product.SortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
func tokenProductsToPB(items []tokencontracts.TokenProductView) []*pb.TokenProductView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.TokenProductView, 0, len(items))
|
||||
for i := range items {
|
||||
result = append(result, tokenProductToPB(items[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tokenGrantToPB(grant *tokencontracts.TokenGrantView) *pb.TokenGrantView {
|
||||
if grant == nil {
|
||||
return nil
|
||||
}
|
||||
return &pb.TokenGrantView{
|
||||
GrantId: grant.GrantID,
|
||||
EventId: grant.EventID,
|
||||
Source: grant.Source,
|
||||
SourceLabel: grant.SourceLabel,
|
||||
Amount: grant.Amount,
|
||||
Status: grant.Status,
|
||||
QuotaApplied: grant.QuotaApplied,
|
||||
Description: grant.Description,
|
||||
CreatedAt: grant.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func tokenGrantsToPB(items []tokencontracts.TokenGrantView) []*pb.TokenGrantView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.TokenGrantView, 0, len(items))
|
||||
for i := range items {
|
||||
item := items[i]
|
||||
result = append(result, tokenGrantToPB(&item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tokenOrderToPB(order *tokencontracts.TokenOrderView) *pb.TokenOrderView {
|
||||
if order == nil {
|
||||
return nil
|
||||
}
|
||||
return &pb.TokenOrderView{
|
||||
OrderId: order.OrderID,
|
||||
OrderNo: order.OrderNo,
|
||||
Status: order.Status,
|
||||
TokenAmount: order.TokenAmount,
|
||||
AmountCent: order.AmountCent,
|
||||
PriceText: order.PriceText,
|
||||
Currency: order.Currency,
|
||||
PaymentMode: order.PaymentMode,
|
||||
Grant: tokenGrantToPB(order.Grant),
|
||||
CreatedAt: order.CreatedAt,
|
||||
PaidAt: tokenStringFromPtr(order.PaidAt),
|
||||
GrantedAt: tokenStringFromPtr(order.GrantedAt),
|
||||
ProductSnapshot: order.ProductSnapshot,
|
||||
ProductName: order.ProductName,
|
||||
Quantity: int32(order.Quantity),
|
||||
}
|
||||
}
|
||||
|
||||
func tokenOrdersToPB(items []tokencontracts.TokenOrderView) []*pb.TokenOrderView {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]*pb.TokenOrderView, 0, len(items))
|
||||
for i := range items {
|
||||
item := items[i]
|
||||
result = append(result, tokenOrderToPB(&item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tokenStringFromPtr(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
func legacyTokenMethodRemoved() error {
|
||||
return status.Error(codes.Unimplemented, "legacy token API has been removed; use credit APIs instead")
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -9,14 +9,24 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary"
|
||||
TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts"
|
||||
TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder"
|
||||
TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders"
|
||||
TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder"
|
||||
TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder"
|
||||
TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants"
|
||||
TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant"
|
||||
TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary"
|
||||
TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts"
|
||||
TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder"
|
||||
TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders"
|
||||
TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder"
|
||||
TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder"
|
||||
TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants"
|
||||
TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant"
|
||||
TokenStoreService_GetCreditBalanceSnapshot_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetCreditBalanceSnapshot"
|
||||
TokenStoreService_GetCreditConsumptionDashboard_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetCreditConsumptionDashboard"
|
||||
TokenStoreService_ListCreditProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditProducts"
|
||||
TokenStoreService_CreateCreditOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateCreditOrder"
|
||||
TokenStoreService_ListCreditOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditOrders"
|
||||
TokenStoreService_GetCreditOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetCreditOrder"
|
||||
TokenStoreService_MockPaidCreditOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidCreditOrder"
|
||||
TokenStoreService_ListCreditTransactions_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditTransactions"
|
||||
TokenStoreService_ListCreditPriceRules_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditPriceRules"
|
||||
TokenStoreService_ListCreditRewardRules_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListCreditRewardRules"
|
||||
)
|
||||
|
||||
type TokenStoreServiceClient interface {
|
||||
@@ -28,6 +38,16 @@ type TokenStoreServiceClient interface {
|
||||
MockPaidOrder(ctx context.Context, in *MockPaidOrderRequest, opts ...grpc.CallOption) (*MockPaidOrderResponse, error)
|
||||
ListGrants(ctx context.Context, in *ListTokenGrantsRequest, opts ...grpc.CallOption) (*ListTokenGrantsResponse, error)
|
||||
RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error)
|
||||
GetCreditBalanceSnapshot(ctx context.Context, in *GetCreditBalanceSnapshotRequest, opts ...grpc.CallOption) (*GetCreditBalanceSnapshotResponse, error)
|
||||
GetCreditConsumptionDashboard(ctx context.Context, in *GetCreditConsumptionDashboardRequest, opts ...grpc.CallOption) (*GetCreditConsumptionDashboardResponse, error)
|
||||
ListCreditProducts(ctx context.Context, in *ListCreditProductsRequest, opts ...grpc.CallOption) (*ListCreditProductsResponse, error)
|
||||
CreateCreditOrder(ctx context.Context, in *CreateCreditOrderRequest, opts ...grpc.CallOption) (*CreateCreditOrderResponse, error)
|
||||
ListCreditOrders(ctx context.Context, in *ListCreditOrdersRequest, opts ...grpc.CallOption) (*ListCreditOrdersResponse, error)
|
||||
GetCreditOrder(ctx context.Context, in *GetCreditOrderRequest, opts ...grpc.CallOption) (*GetCreditOrderResponse, error)
|
||||
MockPaidCreditOrder(ctx context.Context, in *MockPaidCreditOrderRequest, opts ...grpc.CallOption) (*MockPaidCreditOrderResponse, error)
|
||||
ListCreditTransactions(ctx context.Context, in *ListCreditTransactionsRequest, opts ...grpc.CallOption) (*ListCreditTransactionsResponse, error)
|
||||
ListCreditPriceRules(ctx context.Context, in *ListCreditPriceRulesRequest, opts ...grpc.CallOption) (*ListCreditPriceRulesResponse, error)
|
||||
ListCreditRewardRules(ctx context.Context, in *ListCreditRewardRulesRequest, opts ...grpc.CallOption) (*ListCreditRewardRulesResponse, error)
|
||||
}
|
||||
|
||||
type tokenStoreServiceClient struct {
|
||||
@@ -70,6 +90,46 @@ func (c *tokenStoreServiceClient) RecordForumRewardGrant(ctx context.Context, in
|
||||
return invokeTokenStore[RecordForumRewardGrantResponse](ctx, c.cc, TokenStoreService_RecordForumRewardGrant_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) GetCreditBalanceSnapshot(ctx context.Context, in *GetCreditBalanceSnapshotRequest, opts ...grpc.CallOption) (*GetCreditBalanceSnapshotResponse, error) {
|
||||
return invokeTokenStore[GetCreditBalanceSnapshotResponse](ctx, c.cc, TokenStoreService_GetCreditBalanceSnapshot_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) GetCreditConsumptionDashboard(ctx context.Context, in *GetCreditConsumptionDashboardRequest, opts ...grpc.CallOption) (*GetCreditConsumptionDashboardResponse, error) {
|
||||
return invokeTokenStore[GetCreditConsumptionDashboardResponse](ctx, c.cc, TokenStoreService_GetCreditConsumptionDashboard_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) ListCreditProducts(ctx context.Context, in *ListCreditProductsRequest, opts ...grpc.CallOption) (*ListCreditProductsResponse, error) {
|
||||
return invokeTokenStore[ListCreditProductsResponse](ctx, c.cc, TokenStoreService_ListCreditProducts_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) CreateCreditOrder(ctx context.Context, in *CreateCreditOrderRequest, opts ...grpc.CallOption) (*CreateCreditOrderResponse, error) {
|
||||
return invokeTokenStore[CreateCreditOrderResponse](ctx, c.cc, TokenStoreService_CreateCreditOrder_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) ListCreditOrders(ctx context.Context, in *ListCreditOrdersRequest, opts ...grpc.CallOption) (*ListCreditOrdersResponse, error) {
|
||||
return invokeTokenStore[ListCreditOrdersResponse](ctx, c.cc, TokenStoreService_ListCreditOrders_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) GetCreditOrder(ctx context.Context, in *GetCreditOrderRequest, opts ...grpc.CallOption) (*GetCreditOrderResponse, error) {
|
||||
return invokeTokenStore[GetCreditOrderResponse](ctx, c.cc, TokenStoreService_GetCreditOrder_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) MockPaidCreditOrder(ctx context.Context, in *MockPaidCreditOrderRequest, opts ...grpc.CallOption) (*MockPaidCreditOrderResponse, error) {
|
||||
return invokeTokenStore[MockPaidCreditOrderResponse](ctx, c.cc, TokenStoreService_MockPaidCreditOrder_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) ListCreditTransactions(ctx context.Context, in *ListCreditTransactionsRequest, opts ...grpc.CallOption) (*ListCreditTransactionsResponse, error) {
|
||||
return invokeTokenStore[ListCreditTransactionsResponse](ctx, c.cc, TokenStoreService_ListCreditTransactions_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) ListCreditPriceRules(ctx context.Context, in *ListCreditPriceRulesRequest, opts ...grpc.CallOption) (*ListCreditPriceRulesResponse, error) {
|
||||
return invokeTokenStore[ListCreditPriceRulesResponse](ctx, c.cc, TokenStoreService_ListCreditPriceRules_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func (c *tokenStoreServiceClient) ListCreditRewardRules(ctx context.Context, in *ListCreditRewardRulesRequest, opts ...grpc.CallOption) (*ListCreditRewardRulesResponse, error) {
|
||||
return invokeTokenStore[ListCreditRewardRulesResponse](ctx, c.cc, TokenStoreService_ListCreditRewardRules_FullMethodName, in, opts...)
|
||||
}
|
||||
|
||||
func invokeTokenStore[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) {
|
||||
out := new(Resp)
|
||||
err := cc.Invoke(ctx, fullMethod, in, out, opts...)
|
||||
@@ -88,6 +148,16 @@ type TokenStoreServiceServer interface {
|
||||
MockPaidOrder(context.Context, *MockPaidOrderRequest) (*MockPaidOrderResponse, error)
|
||||
ListGrants(context.Context, *ListTokenGrantsRequest) (*ListTokenGrantsResponse, error)
|
||||
RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error)
|
||||
GetCreditBalanceSnapshot(context.Context, *GetCreditBalanceSnapshotRequest) (*GetCreditBalanceSnapshotResponse, error)
|
||||
GetCreditConsumptionDashboard(context.Context, *GetCreditConsumptionDashboardRequest) (*GetCreditConsumptionDashboardResponse, error)
|
||||
ListCreditProducts(context.Context, *ListCreditProductsRequest) (*ListCreditProductsResponse, error)
|
||||
CreateCreditOrder(context.Context, *CreateCreditOrderRequest) (*CreateCreditOrderResponse, error)
|
||||
ListCreditOrders(context.Context, *ListCreditOrdersRequest) (*ListCreditOrdersResponse, error)
|
||||
GetCreditOrder(context.Context, *GetCreditOrderRequest) (*GetCreditOrderResponse, error)
|
||||
MockPaidCreditOrder(context.Context, *MockPaidCreditOrderRequest) (*MockPaidCreditOrderResponse, error)
|
||||
ListCreditTransactions(context.Context, *ListCreditTransactionsRequest) (*ListCreditTransactionsResponse, error)
|
||||
ListCreditPriceRules(context.Context, *ListCreditPriceRulesRequest) (*ListCreditPriceRulesResponse, error)
|
||||
ListCreditRewardRules(context.Context, *ListCreditRewardRulesRequest) (*ListCreditRewardRulesResponse, error)
|
||||
}
|
||||
|
||||
type UnimplementedTokenStoreServiceServer struct{}
|
||||
@@ -124,6 +194,46 @@ func (UnimplementedTokenStoreServiceServer) RecordForumRewardGrant(context.Conte
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RecordForumRewardGrant not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) GetCreditBalanceSnapshot(context.Context, *GetCreditBalanceSnapshotRequest) (*GetCreditBalanceSnapshotResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetCreditBalanceSnapshot not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) GetCreditConsumptionDashboard(context.Context, *GetCreditConsumptionDashboardRequest) (*GetCreditConsumptionDashboardResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetCreditConsumptionDashboard not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) ListCreditProducts(context.Context, *ListCreditProductsRequest) (*ListCreditProductsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListCreditProducts not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) CreateCreditOrder(context.Context, *CreateCreditOrderRequest) (*CreateCreditOrderResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CreateCreditOrder not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) ListCreditOrders(context.Context, *ListCreditOrdersRequest) (*ListCreditOrdersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListCreditOrders not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) GetCreditOrder(context.Context, *GetCreditOrderRequest) (*GetCreditOrderResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetCreditOrder not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) MockPaidCreditOrder(context.Context, *MockPaidCreditOrderRequest) (*MockPaidCreditOrderResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method MockPaidCreditOrder not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) ListCreditTransactions(context.Context, *ListCreditTransactionsRequest) (*ListCreditTransactionsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListCreditTransactions not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) ListCreditPriceRules(context.Context, *ListCreditPriceRulesRequest) (*ListCreditPriceRulesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListCreditPriceRules not implemented")
|
||||
}
|
||||
|
||||
func (UnimplementedTokenStoreServiceServer) ListCreditRewardRules(context.Context, *ListCreditRewardRulesRequest) (*ListCreditRewardRulesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListCreditRewardRules not implemented")
|
||||
}
|
||||
|
||||
func RegisterTokenStoreServiceServer(s grpc.ServiceRegistrar, srv TokenStoreServiceServer) {
|
||||
s.RegisterService(&TokenStoreService_ServiceDesc, srv)
|
||||
}
|
||||
@@ -179,6 +289,36 @@ var TokenStoreService_ServiceDesc = grpc.ServiceDesc{
|
||||
tokenStoreUnaryHandler[RecordForumRewardGrantRequest]("RecordForumRewardGrant", TokenStoreService_RecordForumRewardGrant_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *RecordForumRewardGrantRequest) (interface{}, error) {
|
||||
return s.RecordForumRewardGrant(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[GetCreditBalanceSnapshotRequest]("GetCreditBalanceSnapshot", TokenStoreService_GetCreditBalanceSnapshot_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetCreditBalanceSnapshotRequest) (interface{}, error) {
|
||||
return s.GetCreditBalanceSnapshot(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[GetCreditConsumptionDashboardRequest]("GetCreditConsumptionDashboard", TokenStoreService_GetCreditConsumptionDashboard_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetCreditConsumptionDashboardRequest) (interface{}, error) {
|
||||
return s.GetCreditConsumptionDashboard(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[ListCreditProductsRequest]("ListCreditProducts", TokenStoreService_ListCreditProducts_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditProductsRequest) (interface{}, error) {
|
||||
return s.ListCreditProducts(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[CreateCreditOrderRequest]("CreateCreditOrder", TokenStoreService_CreateCreditOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *CreateCreditOrderRequest) (interface{}, error) {
|
||||
return s.CreateCreditOrder(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[ListCreditOrdersRequest]("ListCreditOrders", TokenStoreService_ListCreditOrders_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditOrdersRequest) (interface{}, error) {
|
||||
return s.ListCreditOrders(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[GetCreditOrderRequest]("GetCreditOrder", TokenStoreService_GetCreditOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *GetCreditOrderRequest) (interface{}, error) {
|
||||
return s.GetCreditOrder(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[MockPaidCreditOrderRequest]("MockPaidCreditOrder", TokenStoreService_MockPaidCreditOrder_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *MockPaidCreditOrderRequest) (interface{}, error) {
|
||||
return s.MockPaidCreditOrder(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[ListCreditTransactionsRequest]("ListCreditTransactions", TokenStoreService_ListCreditTransactions_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditTransactionsRequest) (interface{}, error) {
|
||||
return s.ListCreditTransactions(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[ListCreditPriceRulesRequest]("ListCreditPriceRules", TokenStoreService_ListCreditPriceRules_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditPriceRulesRequest) (interface{}, error) {
|
||||
return s.ListCreditPriceRules(ctx, req)
|
||||
}),
|
||||
tokenStoreUnaryHandler[ListCreditRewardRulesRequest]("ListCreditRewardRules", TokenStoreService_ListCreditRewardRules_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListCreditRewardRulesRequest) (interface{}, error) {
|
||||
return s.ListCreditRewardRules(ctx, req)
|
||||
}),
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "tokenstore.proto",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user