Version: 0.9.80.dev.260506

后端:
1. LLM 独立服务与统一计费出口落地:新增 `cmd/llm`、`client/llm` 与 `services/llm/rpc`,补齐 BillingContext、CreditBalanceGuard、价格规则解析、stream usage 归集与 `credit.charge.requested` outbox 发布,active-scheduler / agent / course / memory / gateway fallback 全部改走 llm zrpc,不再各自本地初始化模型。
2. TokenStore 收口为 Credit 权威账本:新增 credit account / ledger / product / order / price-rule / reward-rule 能力与 Redis 快照缓存,扩展 tokenstore rpc/client 支撑余额快照、消耗看板、商品、订单、流水、价格规则和奖励规则,并接入 LLM charge 事件消费完成 Credit 扣费落账。
3. 计费旧链路下线与网关切口切换:`/token-store` 语义整体切到 `/credit-store`,agent chat 移除旧 TokenQuotaGuard,userauth 的 CheckTokenQuota / AdjustTokenUsage 改为废弃,聊天历史落库不再同步旧 token 额度账本,course 图片解析请求补 user_id 进入新计费口径。

前端:
4. 计划广场从 mock 数据切到真实接口:新增 forum api/types,首页支持真实列表、标签、搜索、防抖、点赞、导入和发布计划,详情页补齐帖子详情、评论树、回复和删除评论链路,同时补上“至少一个标签”的前后端约束与默认标签兜底。
5. 商店页切到 Credit 体系并重做展示:顶部改为余额 + Credit/Token 消耗看板,支持 24h/7d/30d/all 周期切换;套餐区展示原价与当前价;历史区改为当前用户 Credit 流水并支持查看更多,整体视觉和交互同步收口。

仓库:
6. 配置与本地启动体系补齐 llm / outbox 编排:`config.example.yaml` 增加 llm rpc 和统一 outbox service 配置,`dev-common.ps1` 把 llm 纳入多服务依赖并自动建 Kafka topic,`docker-compose.yml` 同步初始化 agent/task/memory/active-scheduler/notification/taskclass-forum/llm/token-store 全量 outbox topic。
This commit is contained in:
Losita
2026-05-06 20:16:53 +08:00
parent 7d324b77aa
commit 61db646805
104 changed files with 9527 additions and 3925 deletions

View File

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

View File

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

View File

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

View File

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

View File

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