313 lines
10 KiB
Go
313 lines
10 KiB
Go
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
|
||
}
|