feat: add token store p0 backend flow

This commit is contained in:
Losita
2026-05-04 21:49:29 +08:00
parent 46874f0806
commit 4fc6c0cac3
18 changed files with 1921 additions and 97 deletions

View File

@@ -0,0 +1,260 @@
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
}
func (dao *TokenStoreDAO) FindActiveProductByID(ctx context.Context, productID uint64) (*tokenmodel.TokenProduct, error) {
var product tokenmodel.TokenProduct
err := dao.db.WithContext(ctx).
Where("id = ? AND status = ?", productID, tokenmodel.TokenProductStatusActive).
First(&product).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &product, nil
}
func (dao *TokenStoreDAO) FindOrderByUserIdempotencyKey(ctx context.Context, userID uint64, key string) (*tokenmodel.TokenOrder, error) {
var order tokenmodel.TokenOrder
err := dao.db.WithContext(ctx).
Where("user_id = ? AND idempotency_key = ?", userID, key).
First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &order, nil
}
func (dao *TokenStoreDAO) CreateOrder(ctx context.Context, order *tokenmodel.TokenOrder) error {
return dao.db.WithContext(ctx).Create(order).Error
}
func (dao *TokenStoreDAO) CountOrders(ctx context.Context, query ListTokenOrdersQuery) (int64, error) {
db := dao.db.WithContext(ctx).
Model(&tokenmodel.TokenOrder{}).
Where("user_id = ?", query.UserID)
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var total int64
err := db.Count(&total).Error
return total, err
}
func (dao *TokenStoreDAO) ListOrders(ctx context.Context, query ListTokenOrdersQuery) ([]tokenmodel.TokenOrder, error) {
db := dao.db.WithContext(ctx).
Where("user_id = ?", query.UserID)
if status := strings.TrimSpace(query.Status); status != "" {
db = db.Where("status = ?", status)
}
var orders []tokenmodel.TokenOrder
err := db.Order("created_at DESC, id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&orders).Error
return orders, err
}
func (dao *TokenStoreDAO) FindOrderByID(ctx context.Context, orderID uint64) (*tokenmodel.TokenOrder, error) {
var order tokenmodel.TokenOrder
err := dao.db.WithContext(ctx).Where("id = ?", orderID).First(&order).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &order, nil
}
func (dao *TokenStoreDAO) LockOrderByID(ctx context.Context, orderID uint64) (*tokenmodel.TokenOrder, error) {
var order tokenmodel.TokenOrder
err := dao.db.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", orderID).
First(&order).Error
if err != nil {
return nil, err
}
return &order, nil
}
// UpdateOrderState 只负责把订单持久化到最新状态。
//
// 职责边界:
// 1. 调用方必须先完成状态机判断,并决定最终 status/paid_at/granted_at。
// 2. 这里不做“是否允许从 A -> B”校验避免 DAO 层承载业务规则。
// 3. payment_mode 允许调用方显式回填,保证 mock paid 后订单快照完整。
func (dao *TokenStoreDAO) UpdateOrderState(ctx context.Context, orderID uint64, status string, paidAt *time.Time, grantedAt *time.Time, paymentMode string) error {
updates := map[string]any{
"status": status,
"paid_at": paidAt,
"granted_at": grantedAt,
"payment_mode": paymentMode,
"updated_at": time.Now(),
}
return dao.db.WithContext(ctx).
Model(&tokenmodel.TokenOrder{}).
Where("id = ?", orderID).
Updates(updates).Error
}
func (dao *TokenStoreDAO) FindGrantByEventID(ctx context.Context, eventID string) (*tokenmodel.TokenGrant, error) {
var grant tokenmodel.TokenGrant
err := dao.db.WithContext(ctx).
Where("event_id = ?", eventID).
First(&grant).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &grant, nil
}
func (dao *TokenStoreDAO) FindGrantByOrderID(ctx context.Context, orderID uint64) (*tokenmodel.TokenGrant, error) {
var grant tokenmodel.TokenGrant
err := dao.db.WithContext(ctx).
Where("order_id = ?", orderID).
Order("created_at DESC, id DESC").
First(&grant).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &grant, nil
}
func (dao *TokenStoreDAO) ListGrantsByOrderIDs(ctx context.Context, orderIDs []uint64) ([]tokenmodel.TokenGrant, error) {
if len(orderIDs) == 0 {
return []tokenmodel.TokenGrant{}, nil
}
var grants []tokenmodel.TokenGrant
err := dao.db.WithContext(ctx).
Where("order_id IN ?", orderIDs).
Order("created_at DESC, id DESC").
Find(&grants).Error
return grants, err
}
func (dao *TokenStoreDAO) CreateGrant(ctx context.Context, grant *tokenmodel.TokenGrant) error {
return dao.db.WithContext(ctx).Create(grant).Error
}
func (dao *TokenStoreDAO) CountGrants(ctx context.Context, query ListTokenGrantsQuery) (int64, error) {
db := dao.db.WithContext(ctx).
Model(&tokenmodel.TokenGrant{}).
Where("user_id = ?", query.UserID)
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
var total int64
err := db.Count(&total).Error
return total, err
}
func (dao *TokenStoreDAO) ListGrants(ctx context.Context, query ListTokenGrantsQuery) ([]tokenmodel.TokenGrant, error) {
db := dao.db.WithContext(ctx).
Where("user_id = ?", query.UserID)
if source := strings.TrimSpace(query.Source); source != "" {
db = db.Where("source = ?", source)
}
var grants []tokenmodel.TokenGrant
err := db.Order("created_at DESC, id DESC").
Offset((query.Page - 1) * query.PageSize).
Limit(query.PageSize).
Find(&grants).Error
return grants, err
}
func (dao *TokenStoreDAO) SummarizePositiveGrants(ctx context.Context, userID uint64) (TokenGrantSummary, error) {
var summary TokenGrantSummary
err := dao.db.WithContext(ctx).
Model(&tokenmodel.TokenGrant{}).
Select(
`COALESCE(SUM(CASE WHEN amount > 0 AND status IN (?, ?) THEN amount ELSE 0 END), 0) AS recorded_token_total,
COALESCE(SUM(CASE WHEN amount > 0 AND (quota_applied = ? OR status = ?) THEN amount ELSE 0 END), 0) AS applied_token_total`,
tokenmodel.TokenGrantStatusRecorded,
tokenmodel.TokenGrantStatusApplied,
true,
tokenmodel.TokenGrantStatusApplied,
).
Where("user_id = ?", userID).
Scan(&summary).Error
return summary, err
}

View File

@@ -253,18 +253,21 @@ func tokenOrderToPB(order *tokencontracts.TokenOrderView) *pb.TokenOrderView {
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),
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),
}
}

View File

@@ -63,18 +63,21 @@ func (m *TokenGrantView) String() string { return proto.CompactTextString(m) }
func (*TokenGrantView) ProtoMessage() {}
type TokenOrderView 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"`
TokenAmount int64 `protobuf:"varint,4,opt,name=token_amount,json=tokenAmount,proto3" json:"token_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"`
Grant *TokenGrantView `protobuf:"bytes,9,opt,name=grant,proto3" json:"grant,omitempty"`
CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
PaidAt string `protobuf:"bytes,11,opt,name=paid_at,json=paidAt,proto3" json:"paid_at,omitempty"`
GrantedAt string `protobuf:"bytes,12,opt,name=granted_at,json=grantedAt,proto3" json:"granted_at,omitempty"`
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"`
TokenAmount int64 `protobuf:"varint,4,opt,name=token_amount,json=tokenAmount,proto3" json:"token_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"`
Grant *TokenGrantView `protobuf:"bytes,9,opt,name=grant,proto3" json:"grant,omitempty"`
CreatedAt string `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
PaidAt string `protobuf:"bytes,11,opt,name=paid_at,json=paidAt,proto3" json:"paid_at,omitempty"`
GrantedAt string `protobuf:"bytes,12,opt,name=granted_at,json=grantedAt,proto3" json:"granted_at,omitempty"`
ProductSnapshot string `protobuf:"bytes,13,opt,name=product_snapshot,json=productSnapshot,proto3" json:"product_snapshot,omitempty"`
ProductName string `protobuf:"bytes,14,opt,name=product_name,json=productName,proto3" json:"product_name,omitempty"`
Quantity int32 `protobuf:"varint,15,opt,name=quantity,proto3" json:"quantity,omitempty"`
}
func (m *TokenOrderView) Reset() { *m = TokenOrderView{} }

View File

@@ -67,6 +67,9 @@ message TokenOrderView {
string created_at = 10;
string paid_at = 11;
string granted_at = 12;
string product_snapshot = 13;
string product_name = 14;
int32 quantity = 15;
}
message GetTokenSummaryRequest {

View File

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

View File

@@ -0,0 +1,238 @@
package sv
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/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"
)
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
}
if pageSize <= 0 {
pageSize = defaultPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
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 ""
}
return value.Format(time.RFC3339)
}
func formatTimePtr(value *time.Time) *string {
if value == nil || value.IsZero() {
return nil
}
formatted := value.Format(time.RFC3339)
return &formatted
}
func formatPriceText(currency string, amountCent int64) string {
if strings.EqualFold(strings.TrimSpace(currency), "CNY") {
return fmt.Sprintf("¥%.2f", float64(amountCent)/100)
}
return fmt.Sprintf("%s %.2f", strings.ToUpper(strings.TrimSpace(currency)), float64(amountCent)/100)
}
func stringPtrFromNonEmpty(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
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
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "duplicate entry") ||
strings.Contains(lower, "duplicate key") ||
strings.Contains(lower, "unique constraint") ||
strings.Contains(lower, "unique violation") ||
strings.Contains(lower, "error 1062")
}
func normalizeRecordNotFound(err error, fallback error) error {
if errorsIsRecordNotFound(err) {
return fallback
}
return err
}
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 {
return respond.Response{
Status: tokenStoreBadRequestStatus,
Info: strings.TrimSpace(message),
}
}

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
tokenstoredao "github.com/LoveLosita/smartflow/backend/services/tokenstore/dao"
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
"gorm.io/gorm"
)
@@ -35,12 +36,14 @@ type Options struct {
// 3. 不负责真实第三方支付回调P0 只处理 mock paid。
type Service struct {
db *gorm.DB
tokenDAO *tokenstoredao.TokenStoreDAO
grantOutlet TokenGrantOutlet
}
func New(opts Options) *Service {
return &Service{
db: opts.DB,
tokenDAO: tokenstoredao.NewTokenStoreDAO(opts.DB),
grantOutlet: opts.GrantOutlet,
}
}
@@ -55,53 +58,3 @@ func (s *Service) Ready() error {
}
return nil
}
// ListProducts 是商品列表用例占位,第四步实现真实查询。
func (s *Service) ListProducts(ctx context.Context, actorUserID uint64) ([]tokencontracts.TokenProductView, error) {
_ = ctx
_ = actorUserID
return nil, ErrNotImplemented
}
// GetSummary 是 Token 概览用例占位,第四步实现 grant 账本聚合。
func (s *Service) GetSummary(ctx context.Context, actorUserID uint64) (*tokencontracts.TokenSummary, error) {
_ = ctx
_ = actorUserID
return nil, ErrNotImplemented
}
// CreateOrder 是创建订单用例占位,第四步实现商品读取、订单幂等和金额快照。
func (s *Service) CreateOrder(ctx context.Context, req tokencontracts.CreateTokenOrderRequest) (*tokencontracts.TokenOrderView, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}
// ListOrders 是订单列表用例占位,第四步实现用户维度分页查询。
func (s *Service) ListOrders(ctx context.Context, req tokencontracts.ListTokenOrdersRequest) ([]tokencontracts.TokenOrderView, tokencontracts.PageResult, error) {
_ = ctx
_ = req
return nil, tokencontracts.PageResult{}, ErrNotImplemented
}
// GetOrder 是订单详情用例占位,第四步实现订单归属校验。
func (s *Service) GetOrder(ctx context.Context, actorUserID uint64, orderID uint64) (*tokencontracts.TokenOrderView, error) {
_ = ctx
_ = actorUserID
_ = orderID
return nil, ErrNotImplemented
}
// MockPaidOrder 是 P0 mock paid 用例占位,第四步实现支付态流转和 grant 账本。
func (s *Service) MockPaidOrder(ctx context.Context, req tokencontracts.MockPaidOrderRequest) (*tokencontracts.TokenOrderView, error) {
_ = ctx
_ = req
return nil, ErrNotImplemented
}
// ListGrants 是 Token 获取记录用例占位,第四步实现账本分页查询。
func (s *Service) ListGrants(ctx context.Context, req tokencontracts.ListTokenGrantsRequest) ([]tokencontracts.TokenGrantView, tokencontracts.PageResult, error) {
_ = ctx
_ = req
return nil, tokencontracts.PageResult{}, ErrNotImplemented
}