feat: add token store p0 backend flow
This commit is contained in:
312
backend/services/tokenstore/sv/order.go
Normal file
312
backend/services/tokenstore/sv/order.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user