Version: 0.9.78.dev.260506
This commit is contained in:
84
backend/services/tokenstore/sv/grant.go
Normal file
84
backend/services/tokenstore/sv/grant.go
Normal file
@@ -0,0 +1,84 @@
|
||||
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
|
||||
}
|
||||
238
backend/services/tokenstore/sv/helpers.go
Normal file
238
backend/services/tokenstore/sv/helpers.go
Normal file
@@ -0,0 +1,238 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
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/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
|
||||
}
|
||||
157
backend/services/tokenstore/sv/outbox.go
Normal file
157
backend/services/tokenstore/sv/outbox.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package sv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"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"
|
||||
)
|
||||
|
||||
// OutboxBus 是 token-store 注册论坛奖励 handler 需要的最小总线接口。
|
||||
type OutboxBus interface {
|
||||
RegisterEventHandler(eventType string, handler outboxinfra.MessageHandler) error
|
||||
}
|
||||
|
||||
// RegisterForumRewardRoutes 只登记 token-store 负责消费的论坛奖励事件归属。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责把事件类型路由到 token-store 的服务级 outbox;
|
||||
// 2. 不注册 handler,也不启动 consumer;
|
||||
// 3. 供发布方和消费方在不同进程内共享同一份事件归属映射。
|
||||
func RegisterForumRewardRoutes() error {
|
||||
if err := outboxinfra.RegisterEventService(sharedevents.ForumPostLikedEventType, outboxinfra.ServiceTokenStore); err != nil {
|
||||
return err
|
||||
}
|
||||
return outboxinfra.RegisterEventService(sharedevents.ForumPostImportedEventType, outboxinfra.ServiceTokenStore)
|
||||
}
|
||||
|
||||
// RegisterForumRewardHandlers 注册 token-store 对论坛奖励事件的消费处理器。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只消费 forum.post.liked / forum.post.imported 两类事件;
|
||||
// 2. 论坛奖励账本写入由 token-store Service 负责,handler 不自行计算金额;
|
||||
// 3. grant 写入成功后再标记 consumed;若标记失败,可依赖 event_id 幂等安全重试。
|
||||
func RegisterForumRewardHandlers(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 := RegisterForumRewardRoutes(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerForumRewardHandler(bus, outboxRepo, svc, sharedevents.ForumPostLikedEventType, sharedevents.ForumRewardSourceLike); err != nil {
|
||||
return err
|
||||
}
|
||||
return registerForumRewardHandler(bus, outboxRepo, svc, sharedevents.ForumPostImportedEventType, sharedevents.ForumRewardSourceImport)
|
||||
}
|
||||
|
||||
func registerForumRewardHandler(
|
||||
bus OutboxBus,
|
||||
outboxRepo *outboxinfra.Repository,
|
||||
svc *Service,
|
||||
eventType string,
|
||||
source string,
|
||||
) error {
|
||||
route, ok := outboxinfra.ResolveEventRoute(eventType)
|
||||
if !ok {
|
||||
return fmt.Errorf("forum reward outbox route is missing: eventType=%s", eventType)
|
||||
}
|
||||
eventOutboxRepo := outboxRepo.WithRoute(route)
|
||||
|
||||
handler := func(ctx context.Context, envelope kafkabus.Envelope) error {
|
||||
if !isAllowedForumRewardEventVersion(envelope.EventVersion) {
|
||||
if err := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("论坛奖励事件版本不受支持: %s", envelope.EventVersion)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload sharedevents.ForumPostRewardPayload
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "解析论坛奖励载荷失败: "+err.Error()); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := payload.Validate(); err != nil {
|
||||
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "论坛奖励载荷非法: "+err.Error()); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if payload.EventType() != eventType {
|
||||
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, fmt.Sprintf("论坛奖励事件类型不匹配: envelope=%s payload=%s", eventType, payload.EventType())); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
eventID := strings.TrimSpace(envelope.EventID)
|
||||
if eventID == "" {
|
||||
eventID = strings.TrimSpace(payload.EventID)
|
||||
}
|
||||
if eventID == "" {
|
||||
if markErr := eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "论坛奖励 event_id 为空"); markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{
|
||||
EventID: eventID,
|
||||
ReceiverUserID: payload.RewardReceiverUserID,
|
||||
Source: forumRewardSource(payload, source),
|
||||
SourceRefID: forumRewardSourceRefID(payload, source),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf(
|
||||
"forum reward event consumed by tokenstore: event_type=%s event_id=%s grant_id=%d outbox_id=%d",
|
||||
eventType,
|
||||
eventID,
|
||||
grant.GrantID,
|
||||
envelope.OutboxID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
return bus.RegisterEventHandler(eventType, handler)
|
||||
}
|
||||
|
||||
func isAllowedForumRewardEventVersion(version string) bool {
|
||||
version = strings.TrimSpace(version)
|
||||
return version == "" || version == sharedevents.ForumRewardEventVersion
|
||||
}
|
||||
|
||||
func forumRewardSource(payload sharedevents.ForumPostRewardPayload, fallback string) string {
|
||||
source := strings.TrimSpace(payload.Source)
|
||||
if source != "" {
|
||||
return source
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func forumRewardSourceRefID(payload sharedevents.ForumPostRewardPayload, source string) string {
|
||||
if source == sharedevents.ForumRewardSourceImport && payload.ImportID > 0 {
|
||||
return strconv.FormatUint(payload.ImportID, 10)
|
||||
}
|
||||
return strconv.FormatUint(payload.PostID, 10)
|
||||
}
|
||||
34
backend/services/tokenstore/sv/product.go
Normal file
34
backend/services/tokenstore/sv/product.go
Normal 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
|
||||
}
|
||||
234
backend/services/tokenstore/sv/reward.go
Normal file
234
backend/services/tokenstore/sv/reward.go
Normal file
@@ -0,0 +1,234 @@
|
||||
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"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
forumLikeRewardConfigKey = "tokenstore.reward.forumLikeAmount"
|
||||
forumImportRewardConfigKey = "tokenstore.reward.forumImportAmount"
|
||||
|
||||
defaultForumLikeRewardAmount int64 = 1
|
||||
defaultForumImportRewardAmount int64 = 5
|
||||
)
|
||||
|
||||
type forumRewardGrantRequest struct {
|
||||
EventID string
|
||||
ReceiverUserID uint64
|
||||
Source string
|
||||
SourceRefID uint64
|
||||
}
|
||||
|
||||
type forumRewardDecision struct {
|
||||
Amount int64
|
||||
Status string
|
||||
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) {
|
||||
normalized := forumRewardGrantRequest{
|
||||
EventID: strings.TrimSpace(req.EventID),
|
||||
ReceiverUserID: req.ReceiverUserID,
|
||||
Source: strings.ToLower(strings.TrimSpace(req.Source)),
|
||||
}
|
||||
|
||||
switch {
|
||||
case normalized.EventID == "":
|
||||
return forumRewardGrantRequest{}, tokenStoreBadRequest("event_id 不能为空")
|
||||
case normalized.ReceiverUserID == 0:
|
||||
return forumRewardGrantRequest{}, tokenStoreBadRequest("receiver_user_id 不能为空")
|
||||
}
|
||||
|
||||
sourceRefID, err := parseForumRewardSourceRefID(req.SourceRefID)
|
||||
if err != nil {
|
||||
return forumRewardGrantRequest{}, err
|
||||
}
|
||||
normalized.SourceRefID = sourceRefID
|
||||
|
||||
switch normalized.Source {
|
||||
case tokenmodel.TokenGrantSourceForumLike, tokenmodel.TokenGrantSourceForumImport:
|
||||
return normalized, nil
|
||||
default:
|
||||
return forumRewardGrantRequest{}, tokenStoreBadRequest("source 仅支持 forum_like 或 forum_import")
|
||||
}
|
||||
}
|
||||
|
||||
func parseForumRewardSourceRefID(raw string) (uint64, error) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return 0, tokenStoreBadRequest("source_ref_id 不能为空")
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseUint(trimmed, 10, 64)
|
||||
if err != nil || parsed == 0 {
|
||||
return 0, tokenStoreBadRequest("source_ref_id 必须是正整数")
|
||||
}
|
||||
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,
|
||||
Description: strings.TrimSpace(description),
|
||||
}
|
||||
}
|
||||
|
||||
func positiveConfigAmountOrDefault(configKey string, fallback int64) int64 {
|
||||
amount := viper.GetInt64(configKey)
|
||||
if amount <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
func forumRewardDescription(source string) string {
|
||||
switch strings.TrimSpace(source) {
|
||||
case tokenmodel.TokenGrantSourceForumLike:
|
||||
return "计划被点赞奖励"
|
||||
case tokenmodel.TokenGrantSourceForumImport:
|
||||
return "计划被导入奖励"
|
||||
default:
|
||||
return "论坛奖励入账"
|
||||
}
|
||||
}
|
||||
60
backend/services/tokenstore/sv/service.go
Normal file
60
backend/services/tokenstore/sv/service.go
Normal file
@@ -0,0 +1,60 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Service 承载 Token 商店服务内部业务编排。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责商品、订单、mock paid、grant 账本和奖励规则;
|
||||
// 2. 不负责登录鉴权,也不直接修改 user/auth 权威额度;
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// Ready 用于第二步骨架阶段的依赖检查。
|
||||
func (s *Service) Ready() error {
|
||||
if s == nil {
|
||||
return errors.New("tokenstore service is nil")
|
||||
}
|
||||
if s.db == nil {
|
||||
return errors.New("tokenstore db is nil")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user