跳至主要内容

SpendService 設計文件

Issue: #186
日期: 2026-04-11
狀態: 已完成(feat/spend-service → PR #187)


背景

用戶花費 $TACHItachi_balances)換折價券的消費路徑。呼叫合約 TachiToken.burn(address, amount) 銷毀鏈上代幣,再扣除 DB 中的 tachi_balances.balance

依賴:

  • #166 合約 burn() ABI 已就位
  • #174 合約部署到 Sepolia
  • #178 後端 env var 已設定

架構

檔案異動

檔案操作
backend/internal/contract/tachi_token.go新增 Burn() 方法
backend/internal/services/spend_service.go新檔:SpendService
backend/internal/handlers/spend_handler.go新檔:SpendHandler
backend/internal/router/router.go注入 SpendService / SpendHandler,掛 route
backend/cmd/server/main.gowire SpendService
backend/internal/services/spend_service_test.go新檔:單元測試

介面規格

BurnCaller interface

type BurnCaller interface {
BurnOnChain(ctx context.Context, fromAddr string, amount int64) (txHash string, err error)
}

SpendService

type SpendService struct {
db *gorm.DB
contractCfg config.ContractConfig
tachiToken *contractpkg.TachiToken
burnCaller BurnCaller
}

func NewSpendService(db *gorm.DB, contractCfg config.ContractConfig, ethClient *ethclient.Client) *SpendService

func (s *SpendService) Redeem(ctx context.Context, userID uuid.UUID, amount int64) (newBalance int64, err error)

// SetBurnCallerForTest replaces the burn caller; use only in tests.
func (s *SpendService) SetBurnCallerForTest(bc BurnCaller)

TachiToken.Burn(新增)

func (t *TachiToken) Burn(ctx context.Context, fromAddr common.Address, amount *big.Int, signerKey *ecdsa.PrivateKey) (string, error)

API

POST /api/v1/spend/redeem
Authorization: Bearer <jwt>
Content-Type: application/json

Body: { "amount": 100 } // 必填,> 0
200: { "balance": 900 }
400: 餘額不足 / 錢包未綁定 / amount <= 0
500: 合約呼叫失敗

Transaction 流程(方案 A:reserve-then-burn)

1. DB txn (SELECT FOR UPDATE):
a. 取得 tachi_balances WHERE user_id(lock row)
→ 不存在或 balance < amount → ErrSpendInsufficientBalance (400)
b. resolveWalletAddress(auth_providers, provider=web3)
→ 找不到 → ErrSpendWalletNotLinked (400)
c. UPDATE tachi_balances SET balance = balance - amount(reservation)

2. BurnOnChain(walletAddr, amount) — 30s timeout,回傳 (txHash, err)
→ err != nil AND txHash == "":tx 未送出 → rollback DB → 500
→ err != nil AND txHash != "":tx 已廣播但收據未知 → 不 rollback,回傳 error → 500
→ err == nil:burn 成功

3. 回傳 newBalance(= reservation 後的值)

關鍵設計決策

  • wallet 解析在 DB txn 內執行。若 wallet 找不到,txn 直接 rollback,不需要額外還原 balance。
  • BurnOnChainSendTransaction 成功、WaitMined 失敗時回傳 (txHash, err),Redeem 以 txHash 是否為空判斷是否 rollback,避免鏈上已扣款、DB 卻恢復的一致性問題。

錯誤定義

var (
ErrSpendAmountInvalid = errors.New("spend amount must be greater than zero")
ErrSpendInsufficientBalance = errors.New("insufficient tachi balance")
ErrSpendWalletNotLinked = errors.New("web3 wallet not linked")
ErrSpendContractConfig = errors.New("spend contract config is incomplete")
)

測試計畫

測試名稱情境驗證點
TestRedeem_Success正常流程newBalance 正確、BurnCaller 被呼叫 1 次、fromAddr 正確
TestRedeem_InsufficientBalancetachi_balances.balance < amount回傳 ErrSpendInsufficientBalance,balance 不變
TestRedeem_WalletNotLinked無 web3 auth_provider回傳 ErrSpendWalletNotLinked,balance 不變
TestRedeem_BurnFailureRollbackBurnCaller 回傳 errorbalance 還原為扣除前的值

使用 SQLite in-memory DB + mock BurnCaller,與 claim_service_test.go 相同模式。


本票明確不做

  • 不實作折價券系統本身(coupon 發放、驗證邏輯)
  • 不動 ClaimService 或既有記帳架構
  • 不做 SIWE 錢包綁定
  • 不部署到 mainnet
  • 不引入 Gas 補貼機制