Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0x0B-a Internal Transfer Architecture (Strict FSM)

🇺🇸 English    |    🇨🇳 中文

🇺🇸 English

📦 Code Changes: View Diff


1. Problem Statement

1.1 System Topology

SystemRoleSource of TruthPersistence
PostgreSQLFunding Accountbalances_tbACID, Durable
UBSCoreTrading AccountRAMWAL + Volatile

1.2 The Core Constraint

These two systems cannot share a transaction. There is no XA/2PC database protocol. Therefore: We must build our own 2-Phase Commit using an external FSM Coordinator.


1.5 Security Pre-Validation (MANDATORY)

Caution

Defense-in-Depth All checks below MUST be performed at every independent module, not just API layer.

  • API Layer: First line of defense, reject obviously invalid requests
  • Coordinator: Re-validate, prevent internal calls bypassing API
  • Adapters: Final defense, each adapter must independently validate parameters
  • UBSCore: Last check before in-memory operations

Safety > Performance. The cost of redundant checks is acceptable; security vulnerabilities are not.

1.5.1 Identity & Authorization Checks

CheckAttack VectorValidation LogicError Code
User AuthenticationForged requestJWT/Session must be validUNAUTHORIZED
User ID ConsistencyCross-user transfer attackrequest.user_id == auth.user_idFORBIDDEN
Account OwnershipSteal others’ fundsSource/Target accounts belong to same user_idFORBIDDEN

1.5.2 Account Type Checks

CheckAttack VectorValidation LogicError Code
from != toInfinite wash trading/resource wasterequest.from != request.toSAME_ACCOUNT
Account Type ValidInject invalid typefrom, to ∈ {FUNDING, SPOT}INVALID_ACCOUNT_TYPE
Account Type SupportedRequest unlaunched featurefrom, to both in supported listUNSUPPORTED_ACCOUNT_TYPE

1.5.3 Amount Checks

CheckAttack VectorValidation LogicError Code
amount > 0Zero/negative transferamount > 0INVALID_AMOUNT
Precision CheckPrecision overflowdecimal_places(amount) <= asset.precisionPRECISION_OVERFLOW
Minimum AmountDust attackamount >= asset.min_transfer_amountAMOUNT_TOO_SMALL
Maximum Single AmountRisk control bypassamount <= asset.max_transfer_amountAMOUNT_TOO_LARGE
Integer Overflowu64 overflow attackamount <= u64::MAX / safety_factorOVERFLOW

1.5.4 Asset Checks

CheckAttack VectorValidation LogicError Code
Asset ExistsFake asset_idasset_id exists in systemINVALID_ASSET
Asset StatusDelisted assetasset.status == ACTIVEASSET_SUSPENDED
Transfer PermissionSome assets forbid internal transferasset.internal_transfer_enabled == trueTRANSFER_NOT_ALLOWED

1.5.5 Account Status Checks

Account Initialization Rules (Overview)

Account TypeInit TimingNotes
FUNDINGCreated on first deposit requestTriggered by external deposit flow
SPOTCreated on first internal transferLazy Init
FUTURECreated on first internal transfer [P2]Lazy Init
MARGINCreated on first internal transfer [P2]Lazy Init

Note

  • Specific initialization behaviors and business rules for each account type are defined in their dedicated documents.
  • Each account has its own state definitions (e.g., whether transfer is allowed); not detailed here.
  • Default State: On account initialization, transfer is allowed by default.

Account Status Check Table

CheckAttack VectorValidation LogicError Code
Source Account ExistsNon-existent accountSource account record must existSOURCE_ACCOUNT_NOT_FOUND
Target Account Exists/CreateNon-existent targetFUNDING must exist; SPOT/FUTURE/MARGIN can createTARGET_ACCOUNT_NOT_FOUND (FUNDING only)
Source Not FrozenFrozen account transfer outsource.status != FROZENACCOUNT_FROZEN
Source Not DisabledDisabled account operationsource.status != DISABLEDACCOUNT_DISABLED
Sufficient BalanceInsufficient balance direct rejectsource.available >= amountINSUFFICIENT_BALANCE

1.5.6 Rate Limiting - [P2 Future Optimization]

Note

This is a V2 optimization. V1 may skip this.

CheckAttack VectorValidation LogicError Code
Requests Per SecondDoS attackuser_requests_per_second <= 10RATE_LIMIT_EXCEEDED
Daily Transfer CountAbuseuser_daily_transfers <= 100DAILY_LIMIT_EXCEEDED
Daily Transfer AmountLarge amount risk controluser_daily_amount <= daily_limitDAILY_AMOUNT_EXCEEDED

1.5.7 Idempotency Check

CheckAttack VectorValidation LogicError Code
cid UniqueDuplicate submissionIf cid provided, check if existsDUPLICATE_REQUEST (return original result)
1. Authentication (JWT valid?)
2. Authorization (user_id match?)
3. Request Format (from/to/amount valid?)
4. Account Type (from != to, type supported?)
5. Asset Check (exists? enabled? transferable?)
6. Amount Check (range? precision? overflow?)
7. Rate Limiting (exceeded?)
8. Idempotency (duplicate?)
9. Balance Check (sufficient?) ← Check last, avoid unnecessary queries

2. FSM Design (The State Machine)

2.0 Library Choice: rust-fsm

We use the rust-fsm library, providing:

  • Compile-time validation - Illegal state transitions cause compile errors.
  • Declarative DSL - Clearly defined states and transitions.
  • Type Safety - Prevents missing match arms.

Cargo.toml:

[dependencies]
rust-fsm = "0.7"

DSL Definition:

#![allow(unused)]
fn main() {
use rust_fsm::*;

state_machine! {
    derive(Debug, Clone, Copy, PartialEq, Eq)
    
    TransferFsm(Init)  // Initial State
    
    // State Definitions
    Init => {
        SourceWithdrawOk => SourceDone,
        SourceWithdrawFail => Failed,
    },
    SourceDone => {
        TargetDepositOk => Committed,
        TargetDepositFail => Compensating,
        TargetDepositUnknown => SourceDone [loop],  // Stay, Infinite Retry
    },
    Compensating => {
        RefundOk => RolledBack,
        RefundFail => Compensating [loop],  // Stay, Infinite Retry
    },
    // Terminal States
    Committed,
    Failed,
    RolledBack,
}
}

Note

The DSL above is used for compile-time validation of state transition validity. Actual runtime state is stored in PostgreSQL and updated via CAS.

2.0.1 Core State Flow (Top Level)

                               ┌─────────────────────────────────────────────────────────┐
                               │              INTERNAL TRANSFER FSM                       │
                               └─────────────────────────────────────────────────────────┘

    ┌─────────────────────────────── Happy Path ────────────────────────────────────────────┐
    │                                                                                       │
    │    ┌─────────┐                    ┌─────────────┐                    ┌───────────────┐  │
    │    │  INIT   │   Source Deduct ✓  │ SOURCE_DONE │   Target Credit ✓  │               │  │
    │    │(Request)│ ─────────────────▶ │ (In-Flight) │ ─────────────────▶ │   COMMITTED   │  │
    │    └─────────┘                    └─────────────┘                    │               │  │
    │         │                               │                            └───────────────┘  │
    │         │                               │                                   ✅          │
    └─────────│───────────────────────────────│───────────────────────────────────────────────┘
              │                               │
              │                               │
              │                               ▼
              │                     ╔══════════════════════════════════════════════════╗
              │                     ║  🔒 ATOMIC COMMIT                               ║
              │                     ║                                                  ║
              │                     ║  IF AND ONLY IF:                                 ║
              │                     ║    FROM.withdraw = SUCCESS  ✓                   ║
              │                     ║    TO.deposit    = SUCCESS  ✓                   ║
              │                     ║                                                  ║
              │                     ║  EXECUTE: CAS(SOURCE_DONE → COMMITTED)           ║
              │                     ║  Must be atomic and non-interruptible.           ║
              │                     ╚══════════════════════════════════════════════════╝
              │                               │
              │ Source Deduction Fail         │ Target Credit Fail (EXPLICIT_FAIL)
              ▼                               ▼
        ┌──────────┐                   ┌──────────────┐
        │  FAILED  │                   │ COMPENSATING │◀───────────┐
        │ (Source) │                   │  (Refunding) │            │ Refund Fail (Infinite Retry)
        └──────────┘                   └──────────────┘────────────┘
             ❌                               │ Refund Success
                                              ▼
                                       ┌─────────────┐
                                       │ ROLLED_BACK │
                                       │ (Restored)  │
                                       └─────────────┘
                                             ↩️

    ╔════════════════════════════════════════════════════════════════════════════════════════╗
    ║  ⚠️ Target Unknown (TIMEOUT/UNKNOWN) → Stay SOURCE_DONE, Infinite Retry, NEVER rollback. ║
    ╚════════════════════════════════════════════════════════════════════════════════════════╝

Core State Description:

StateFund LocationDescription
INITSource AccountUser request accepted, funds haven’t moved yet.
SOURCE_DONEIn-FlightCRITICAL! Funds have left source, haven’t reached target.
COMMITTEDTarget AccountTerminal state, transfer succeeded.
FAILEDSource AccountTerminal state, source deduction failed, no funds moved.
COMPENSATINGIn-FlightTarget credit failed, refunding to source.
ROLLED_BACKSource AccountTerminal state, refund succeeded.

Important

SOURCE_DONE is the most critical state - funds have left the source account but have not yet reached the target. At this point, the state MUST NOT be lost; it must eventually reach COMMITTED or ROLLED_BACK.

2.1 States (Exhaustive)

IDState NameEntry ConditionTerminal?Funds Location
0INITUser request accepted.NoSource
10SOURCE_PENDINGCAS success, Adapter call initiated.NoSource (Deducting)
20SOURCE_DONESource Adapter returned OK.NoIn-Flight
30TARGET_PENDINGCAS success, Target Adapter call initiated.NoIn-Flight (Crediting)
40COMMITTEDTarget Adapter returned OK.YESTarget
-10FAILEDSource Adapter returned FAIL.YESSource (Unchanged)
-20COMPENSATINGTarget Adapter FAIL AND Source is Reversible.NoIn-Flight (Refunding)
-30ROLLED_BACKSource Refund OK.YESSource (Restored)

2.2 State Transition Rules (Exhaustive)

┌───────────────────────────────────────────────────────────────────────────────┐
│                         CANONICAL STATE TRANSITIONS                           │
├───────────────────────────────────────────────────────────────────────────────┤
│                                                                               │
│  INIT ──────[CAS OK]───────► SOURCE_PENDING                                   │
│    │                              │                                           │
│    │                              ├──[Adapter OK]────► SOURCE_DONE            │
│    │                              │                         │                 │
│    │                              └──[Adapter FAIL]──► FAILED (Terminal)      │
│    │                                                        │                 │
│    │                                                        │                 │
│    │                              SOURCE_DONE ──[CAS OK]──► TARGET_PENDING    │
│    │                                                             │            │
│    │                        ┌────────────────────────────────────┤            │
│    │                        │                                    │            │
│    │            [Adapter OK]│                       [Adapter FAIL]            │
│    │                        │                                    │            │
│    │                        ▼                                    ▼            │
│    │                   COMMITTED                     ┌───────────────────┐    │
│    │                   (Terminal)                    │ SOURCE REVERSIBLE?│    │
│    │                                                 └─────────┬─────────┘    │
│    │                                                   YES     │     NO       │
│    │                                                   ▼       │     ▼        │
│    │                                           COMPENSATING    │  INFINITE    │
│    │                                                 │         │   RETRY      │
│    │                                    [Refund OK]  │         │ (Stay in     │
│    │                                         ▼       │         │  TARGET_     │
│    │                                    ROLLED_BACK  │         │  PENDING)    │
│    │                                    (Terminal)   │         │              │
│    │                                                 │         │              │
│    └─────────────────────────────────────────────────┴─────────┴──────────────┘

2.3 Reversibility Rule (CRITICAL)

Core Principle: Only when an Adapter returns an explicitly defined failure can we safely rollback.

Response TypeMeaningCan Safely Rollback?Handling
SUCCESSOperation succeededN/AContinue to next step
EXPLICIT_FAILExplicit business failure (e.g., insufficient balance)YESCan enter COMPENSATING
TIMEOUTTimeout, state unknownNOInfinite Retry
PENDINGProcessing, state unknownNOInfinite Retry
NETWORK_ERRORNetwork error, state unknownNOInfinite Retry
UNKNOWNAny other situationNOInfinite Retry or Manual Intervention

Caution

Only EXPLICIT_FAIL allows safe rollback. Any unknown state (Timeout, Pending, Network Error) means funds are In-Flight. We cannot know whether the counterparty has processed the request. Rash rollback will cause Double Spend or Fund Loss. Only safe actions: Infinite Retry or Manual Intervention.


3. Transfer Scenarios (Step-by-Step)

3.1 Scenario A: Funding → Spot (Deposit to Trading)

Happy Path:

StepActorActionPre-StatePost-StateFunds
1APIValidate, Create Record-INITFunding
2CoordinatorCAS(INITSOURCE_PENDING)INITSOURCE_PENDINGFunding
3CoordinatorCall FundingAdapter.withdraw(req_id)---
4PGUPDATE balances SET amount = amount - X--Deducted
5CoordinatorOn OK: CAS(SOURCE_PENDINGSOURCE_DONE)SOURCE_PENDINGSOURCE_DONEIn-Flight
6CoordinatorCAS(SOURCE_DONETARGET_PENDING)SOURCE_DONETARGET_PENDINGIn-Flight
7CoordinatorCall TradingAdapter.deposit(req_id)---
8UBSCoreCredit RAM, Write WAL, Emit Event--Credited
9CoordinatorOn Event: CAS(TARGET_PENDINGCOMMITTED)TARGET_PENDINGCOMMITTEDTrading

Failure Path (Target Fails):

StepActorActionPre-StatePost-StateFunds
7’CoordinatorCall TradingAdapter.deposit(req_id)FAIL/TimeoutTARGET_PENDING-In-Flight
8’CoordinatorCheck: Source = Funding (Reversible)---
9’CoordinatorCAS(TARGET_PENDINGCOMPENSATING)TARGET_PENDINGCOMPENSATINGIn-Flight
10’CoordinatorCall FundingAdapter.refund(req_id)---
11’PGUPDATE balances SET amount = amount + X--Refunded
12’CoordinatorCAS(COMPENSATINGROLLED_BACK)COMPENSATINGROLLED_BACKFunding

3.2 Scenario B: Spot → Funding (Withdraw from Trading)

Happy Path:

StepActorActionPre-StatePost-StateFunds
1APIValidate, Create Record-INITTrading
2CoordinatorCAS(INITSOURCE_PENDING)INITSOURCE_PENDINGTrading
3CoordinatorCall TradingAdapter.withdraw(req_id)---
4UBSCoreCheck Balance, Deduct RAM, Write WAL, Emit Event--Deducted
5CoordinatorOn Event: CAS(SOURCE_PENDINGSOURCE_DONE)SOURCE_PENDINGSOURCE_DONEIn-Flight
6CoordinatorCAS(SOURCE_DONETARGET_PENDING)SOURCE_DONETARGET_PENDINGIn-Flight
7CoordinatorCall FundingAdapter.deposit(req_id)---
8PGINSERT ... ON CONFLICT UPDATE SET amount = amount + X--Credited
9CoordinatorOn OK: CAS(TARGET_PENDINGCOMMITTED)TARGET_PENDINGCOMMITTEDFunding

Failure Path (Target Fails):

StepActorActionPre-StatePost-StateFunds
7aCoordinatorCall FundingAdapter.deposit(req_id)EXPLICIT_FAIL (e.g., constraint)TARGET_PENDING-In-Flight
8aCoordinatorCheck response type = EXPLICIT_FAIL (can safely rollback)---
9aCoordinatorCAS(TARGET_PENDINGCOMPENSATING)TARGET_PENDINGCOMPENSATINGIn-Flight
10aCoordinatorCall TradingAdapter.refund(req_id) (refund to UBSCore)---
11aUBSCoreCredit RAM balance, write WAL--Refunded
12aCoordinatorCAS(COMPENSATINGROLLED_BACK)COMPENSATINGROLLED_BACKTrading
StepActorActionPre-StatePost-StateFunds
7bCoordinatorCall FundingAdapter.deposit(req_id)TIMEOUT/UNKNOWNTARGET_PENDING-In-Flight
8bCoordinatorCheck response type = UNKNOWN (cannot safely rollback)---
9bCoordinatorDO NOT TRANSITION. Stay TARGET_PENDING.TARGET_PENDINGTARGET_PENDINGIn-Flight
10bCoordinatorLog CRITICAL. Alert Ops. Schedule Retry.---
11bRecoveryRetry FundingAdapter.deposit(req_id) INFINITELY.---
12b(Eventually)On OK: CAS(TARGET_PENDINGCOMMITTED)TARGET_PENDINGCOMMITTEDFunding

Warning

Only enter COMPENSATING when Target returns EXPLICIT_FAIL. If Timeout or Unknown, funds are In-Flight. Must Infinite Retry or Manual Intervention.


4. Failure Mode and Effects Analysis (FMEA)

4.1 Phase 1 Failures (Source Operation)

FailureCauseCurrent StateFundsResolution
Adapter returns FAILInsufficient balance, DB constraintSOURCE_PENDINGSourceTransition to FAILED. User sees error.
Adapter returns PENDINGTimeout, network issueSOURCE_PENDINGUnknownRetry. Adapter MUST be idempotent.
Coordinator crashes after CAS, before callProcess killSOURCE_PENDINGSourceRecovery Worker retries call.
Coordinator crashes after call, before resultProcess killSOURCE_PENDINGUnknownRecovery Worker retries (idempotent).

4.2 Phase 2 Failures (Target Operation)

FailureCauseResponse TypeCurrent StateFundsResolution
Target explicit rejectBusiness ruleEXPLICIT_FAILTARGET_PENDINGIn-FlightCOMPENSATING → Refund.
TimeoutNetwork delayTIMEOUTTARGET_PENDINGUnknownInfinite Retry.
Network errorConnection lostNETWORK_ERRORTARGET_PENDINGUnknownInfinite Retry.
Unknown errorSystem exceptionUNKNOWNTARGET_PENDINGUnknownInfinite Retry or Manual Intervention.
Coordinator crashesProcess killN/ATARGET_PENDINGIn-FlightRecovery Worker retries.

4.3 Compensation Failures

FailureCauseCurrent StateFundsResolution
Refund FAILPG down, constraintCOMPENSATINGIn-FlightInfinite Retry. Funds stuck until PG up.
Refund PENDINGTimeoutCOMPENSATINGUnknownRetry.

5. Idempotency Requirements (MANDATORY)

5.1 Why Idempotency?

Retries are the foundation of crash recovery. Without idempotency, a retry will cause double execution (double deduction, double credit).

5.2 Implementation (Funding Adapter)

Requirement: Given the same req_id, calling withdraw() or deposit() multiple times MUST have the same effect as calling it once.

Mechanism:

  1. transfers_tb has UNIQUE(req_id).
  2. Atomic Transaction:
    BEGIN;
    -- Check if already processed
    SELECT state FROM transfers_tb WHERE req_id = $1;
    IF state >= expected_post_state THEN
        RETURN 'AlreadyProcessed';
    END IF;
    
    -- Perform balance update
    UPDATE balances_tb SET amount = amount - $2 WHERE user_id = $3 AND asset_id = $4 AND amount >= $2;
    IF NOT FOUND THEN
        RETURN 'InsufficientBalance';
    END IF;
    
    -- Update state
    UPDATE transfers_tb SET state = $new_state, updated_at = NOW() WHERE req_id = $1;
    COMMIT;
    RETURN 'Success';
    

5.3 Implementation (Trading Adapter)

Requirement: Same as above. UBSCore MUST reject duplicate req_id.

Mechanism:

  1. InternalOrder includes req_id field (or cid).
  2. UBSCore maintains a ProcessedTransferSet (HashSet in RAM, rebuilt from WAL on restart).
  3. On receiving Transfer Order:
    IF req_id IN ProcessedTransferSet THEN
        RETURN 'AlreadyProcessed' (Success, no-op)
    ELSE
        ProcessTransfer()
        ProcessedTransferSet.insert(req_id)
        WriteWAL(TransferEvent)
        RETURN 'Success'
    END IF
    

6. Recovery Worker (Zombie Handler)

6.1 Purpose

On Coordinator startup (or periodically), scan for “stuck” transfers and resume them.

6.2 Query

SELECT * FROM transfers_tb 
WHERE state IN (0, 10, 20, 30, -20) -- INIT, SOURCE_PENDING, SOURCE_DONE, TARGET_PENDING, COMPENSATING
  AND updated_at < NOW() - INTERVAL '1 minute'; -- Stale threshold

6.3 Recovery Logic

Current StateAction
INITCall step() (will transition to SOURCE_PENDING).
SOURCE_PENDINGRetry Source.withdraw().
SOURCE_DONECall step() (will transition to TARGET_PENDING).
TARGET_PENDINGRetry Target.deposit(). Apply Reversibility Rule.
COMPENSATINGRetry Source.refund().

7. Data Model

7.1 Table: transfers_tb

CREATE TABLE transfers_tb (
    transfer_id   BIGSERIAL PRIMARY KEY,
    req_id        VARCHAR(26) UNIQUE NOT NULL,  -- Server-generated Unique ID (ULID)
    cid           VARCHAR(64) UNIQUE,           -- Client Idempotency Key (Optional)
    user_id       BIGINT NOT NULL,
    asset_id      INTEGER NOT NULL,
    amount        DECIMAL(30, 8) NOT NULL,
    transfer_type SMALLINT NOT NULL,            -- 1 = Funding->Spot, 2 = Spot->Funding
    source_type   SMALLINT NOT NULL,            -- 1 = Funding, 2 = Trading
    state         SMALLINT NOT NULL DEFAULT 0,  -- FSM State ID
    error_message TEXT,                         -- Last error (for debugging)
    retry_count   INTEGER NOT NULL DEFAULT 0,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_transfers_state ON transfers_tb(state) WHERE state NOT IN (40, -10, -30);

7.2 Invariant Check

Run periodically to detect data corruption:

-- Sum of Funding + Trading + In-Flight should be constant per user per asset
-- In-Flight = SUM(amount) WHERE state IN (SOURCE_DONE, TARGET_PENDING, COMPENSATING)

8. API Contract

8.1 Endpoint: POST /api/v1/internal_transfer

Request:

{
  "from": "SPOT",       // Source account type
  "to": "FUNDING",     // Target account type
  "asset": "USDT",
  "amount": "100.00"
}

Account Type Enum (AccountType):

ValueMeaningStatus
FUNDINGFunding Account (PostgreSQL)Supported
SPOTSpot Trading Account (UBSCore)Supported
FUTUREFutures AccountFuture Extension
MARGINMargin AccountFuture Extension

Response:

{
  "transfer_id": 12345,
  "req_id": "01JFVQ2X8Z0Y1M3N4P5R6S7T8U",  // Server-generated (ULID)
  "from": "SPOT",
  "to": "FUNDING",
  "state": "COMMITTED",  // or "PENDING" if async
  "message": "Transfer successful"
}

8.2 Query Endpoint: GET /api/v1/internal_transfer/:req_id

Response:

{
  "transfer_id": 12345,
  "req_id": "sr-1734912345678901234",
  "from": "SPOT",
  "to": "FUNDING",
  "asset": "USDT",
  "amount": "100.00",
  "state": "COMMITTED",
  "created_at": "2024-12-23T14:00:00Z",
  "updated_at": "2024-12-23T14:00:01Z"
}

Important

req_id is SERVER-GENERATED, not client. If client needs idempotency, use optional cid (client_order_id) field. Server will check for duplicates and return existing result.

Error Codes:

CodeMeaning
INSUFFICIENT_BALANCESource account balance < amount.
INVALID_ACCOUNT_TYPEfrom or to account type is invalid or unsupported.
SAME_ACCOUNTfrom and to are the same.
DUPLICATE_REQUESTcid already processed. Return original result.
INVALID_AMOUNTamount <= 0 or exceeds precision.
SYSTEM_ERRORInternal failure. Advise retry.

9. Implementation Pseudocode (Critical State Checks)

9.1 API Layer

function handle_transfer_request(request, auth_context):
    // ========== Defense-in-Depth Layer 1: API Layer ==========
    
    // 1. Identity Authentication
    if !auth_context.is_valid():
        return Error(UNAUTHORIZED)
    
    // 2. User ID Consistency (Prevent cross-user attacks)
    if request.user_id != auth_context.user_id:
        return Error(FORBIDDEN, "User ID mismatch")
    
    // 3. Account Type Check
    if request.from == request.to:
        return Error(SAME_ACCOUNT)
    
    if request.from NOT IN [FUNDING, SPOT]:
        return Error(INVALID_ACCOUNT_TYPE)
    
    if request.to NOT IN [FUNDING, SPOT]:
        return Error(INVALID_ACCOUNT_TYPE)
    
    // 4. Amount Check
    if request.amount <= 0:
        return Error(INVALID_AMOUNT)
    
    if decimal_places(request.amount) > asset.precision:
        return Error(PRECISION_OVERFLOW)
    
    // 5. Idempotency Check
    if request.cid:
        existing = db.find_by_cid(request.cid)
        if existing:
            return Success(existing)  // Return existing result
    
    // 6. Asset Check
    asset = db.get_asset(request.asset_id)
    if !asset or asset.status != ACTIVE:
        return Error(INVALID_ASSET)
    
    // 7. Call Coordinator
    result = coordinator.create_and_execute(request)
    return result

9.2 Coordinator Layer

function create_and_execute(request):
    // ========== Defense-in-Depth Layer 2: Coordinator ==========
    
    // Re-verify (Prevent internal calls bypassing API)
    ASSERT request.from != request.to
    ASSERT request.amount > 0
    ASSERT request.user_id > 0
    
    // Generate unique ID
    req_id = ulid.new()
    
    // Create transfer record (State = INIT)
    transfer = TransferRecord {
        req_id: req_id,
        user_id: request.user_id,
        from: request.from,
        to: request.to,
        asset_id: request.asset_id,
        amount: request.amount,
        state: INIT,
        created_at: now()
    }
    
    db.insert(transfer)
    log.info("Transfer created", req_id)
    
    // Execute FSM
    return execute_fsm(req_id)

function execute_fsm(req_id):
    loop:
        transfer = db.get(req_id)
        
        if transfer.state.is_terminal():
            return transfer
        
        new_state = step(transfer)
        
        if new_state == transfer.state:
            // No progress, wait for retry
            sleep(RETRY_INTERVAL)
            continue
    
function step(transfer):
    match transfer.state:
        INIT:
            return step_init(transfer)
        SOURCE_PENDING:
            return step_source_pending(transfer)
        SOURCE_DONE:
            return step_source_done(transfer)
        TARGET_PENDING:
            return step_target_pending(transfer)
        COMPENSATING:
            return step_compensating(transfer)
        _:
            return transfer.state  // Terminal, no processing
    
function step_init(transfer):
    // CAS: Persist state BEFORE calling adapter (Persist-Before-Call)
    success = db.cas_update(
        req_id = transfer.req_id,
        old_state = INIT,
        new_state = SOURCE_PENDING
    )
    
    if !success:
        return db.get(transfer.req_id).state
    
    // Get source adapter
    source_adapter = get_adapter(transfer.from)
    
    // ========== Defense-in-Depth Layer 3: Adapter ==========
    result = source_adapter.withdraw(
        req_id = transfer.req_id,
        user_id = transfer.user_id,
        asset_id = transfer.asset_id,
        amount = transfer.amount
    )
    
    match result:
        SUCCESS:
            db.cas_update(transfer.req_id, SOURCE_PENDING, SOURCE_DONE)
            return SOURCE_DONE
        
        EXPLICIT_FAIL(reason):
            db.update_with_error(transfer.req_id, SOURCE_PENDING, FAILED, reason)
            return FAILED
        
        TIMEOUT | PENDING | NETWORK_ERROR | UNKNOWN:
            log.warn("Source withdraw unknown state", transfer.req_id)
            return SOURCE_PENDING

function step_source_done(transfer):
    // ========== Enter SOURCE_DONE: Funds In-Flight, must reach terminal state ==========
    
    // CAS update to TARGET_PENDING
    success = db.cas_update(transfer.req_id, SOURCE_DONE, TARGET_PENDING)
    if !success:
        return db.get(transfer.req_id).state
    
    // Get target adapter
    target_adapter = get_adapter(transfer.to)
    
    // ========== Defense-in-Depth Layer 4: Target Adapter ==========
    result = target_adapter.deposit(
        req_id = transfer.req_id,
        user_id = transfer.user_id,
        asset_id = transfer.asset_id,
        amount = transfer.amount
    )
    
    match result:
        SUCCESS:
            // ╔════════════════════════════════════════════════════════════════╗
            // ║  🔒 ATOMIC COMMIT - CRITICAL STEP!                             ║
            // ║                                                                ║
            // ║  At this point:                                                ║
            // ║    FROM.withdraw = SUCCESS ✓ (already confirmed)               ║
            // ║    TO.deposit    = SUCCESS ✓ (just confirmed)                  ║
            // ║                                                                ║
            // ║  Execute Atomic CAS Commit:                                    ║
            // ║    CAS(TARGET_PENDING → COMMITTED)                            ║
            // ║                                                                ║
            // ║  Once this CAS succeeds, the transfer is irreversible!         ║
            // ╚════════════════════════════════════════════════════════════════╝
            
            commit_success = db.cas_update(transfer.req_id, TARGET_PENDING, COMMITTED)
            
            if !commit_success:
                return db.get(transfer.req_id).state
            
            log.info("🔒 ATOMIC COMMIT SUCCESS", transfer.req_id)
            return COMMITTED
        
        EXPLICIT_FAIL(reason):
            db.update_with_error(transfer.req_id, TARGET_PENDING, COMPENSATING, reason)
            return COMPENSATING
        
        TIMEOUT | PENDING | NETWORK_ERROR | UNKNOWN:
            // ========== CRITICAL: Unknown state, MUST NOT compensate! ==========
            log.critical("Target deposit unknown state - INFINITE RETRY", transfer.req_id)
            alert_ops("Transfer stuck in TARGET_PENDING", transfer.req_id)
            return TARGET_PENDING  // Stay and retry

function step_compensating(transfer):
    source_adapter = get_adapter(transfer.from)
    
    result = source_adapter.refund(
        req_id = transfer.req_id,
        user_id = transfer.user_id,
        asset_id = transfer.asset_id,
        amount = transfer.amount
    )
    
    match result:
        SUCCESS:
            db.cas_update(transfer.req_id, COMPENSATING, ROLLED_BACK)
            log.info("Transfer rolled back", transfer.req_id)
            return ROLLED_BACK
        
        _:
            log.critical("Refund failed - MUST RETRY", transfer.req_id)
            return COMPENSATING

9.3 Adapter Layer (Example: Funding Adapter)

function withdraw(req_id, user_id, asset_id, amount):
    // ========== Defense-in-Depth Layer 3: Adapter Internal Verification ==========
    
    // Re-verify parameters (Do not trust caller)
    ASSERT amount > 0
    ASSERT user_id > 0
    ASSERT asset_id > 0
    
    // Idempotency Check
    existing = db.find_transfer_operation(req_id, "WITHDRAW")
    if existing:
        return existing.result
    
    // Begin transaction
    tx = db.begin_transaction()
    try:
        // SELECT FOR UPDATE
        account = tx.select_for_update(
            "SELECT * FROM balances_tb WHERE user_id = ? AND asset_id = ? AND account_type = 'FUNDING'"
        )
        
        if !account:
            tx.rollback()
            return EXPLICIT_FAIL("SOURCE_ACCOUNT_NOT_FOUND")
        
        if account.status == FROZEN:
            tx.rollback()
            return EXPLICIT_FAIL("ACCOUNT_FROZEN")
        
        if account.available < amount:
            tx.rollback()
            return EXPLICIT_FAIL("INSUFFICIENT_BALANCE")
        
        // Execute deduction
        tx.update("UPDATE balances_tb SET available = available - ? WHERE id = ?", amount, account.id)
        
        // Record operation for idempotency
        tx.insert("INSERT INTO transfer_operations (req_id, op_type, result) VALUES (?, 'WITHDRAW', 'SUCCESS')")
        
        tx.commit()
        return SUCCESS
        
    catch Exception as e:
        tx.rollback()
        log.error("Withdraw failed", req_id, e)
        return UNKNOWN  // Uncertainty requires retry

10. Acceptance Test Plan (Security Critical)

Caution

ALL tests below must pass before going production. Any failure indicates potential fund theft, loss, or creation from thin air.

10.1 Fund Conservation Tests

Test IDScenarioExpected ResultVerification
INV-001After normal transferTotal funds = BeforeSUM(source) + SUM(target) = Constant
INV-002After failed transferTotal funds = BeforeSource balance unchanged
INV-003After rollbackTotal funds = BeforeSource balance fully restored
INV-004After crash recoveryTotal funds = BeforeVerify all account balances

10.2 External Attack Tests

Test IDAttack VectorStepsExpected Result
ATK-001Cross-user transferSubmits user B’s funds with user A’s tokenFORBIDDEN
ATK-002user_id TamperingModify user_id in request bodyFORBIDDEN
ATK-003Negative Amountamount = -100INVALID_AMOUNT
ATK-004Zero Amountamount = 0INVALID_AMOUNT
ATK-005Precision Overflowamount = 0.000000001 (>8 decimals)PRECISION_OVERFLOW
ATK-006Integer Overflowamount = u64::MAX + 1OVERFLOW or parse error
ATK-007Same Accountfrom = to = SPOTSAME_ACCOUNT
ATK-008Invalid Account Typefrom = “INVALID”INVALID_ACCOUNT_TYPE
ATK-009Non-existent Assetasset_id = 999999INVALID_ASSET
ATK-010Duplicate cidSubmit same ID twiceSecond returns first result
ATK-011No TokenMissing Authorization headerUNAUTHORIZED
ATK-012Expired TokenUse expired JWTUNAUTHORIZED
ATK-013Forged TokenInvalid signature JWTUNAUTHORIZED

10.3 Balance & Status Tests

Test IDScenarioExpected Result
BAL-001amount > availableINSUFFICIENT_BALANCE, no change
BAL-002amount = availableSuccess, balance becomes 0
BAL-003Concurrent: Total > balanceOne success, one INSUFFICIENT_BALANCE
BAL-004Transfer from frozen accountACCOUNT_FROZEN
BAL-005Transfer from disabled accountACCOUNT_DISABLED

10.4 FSM State Transition Tests

Test IDScenarioExpected State Flow
FSM-001Normal Funding→SpotINIT → SOURCE_PENDING → SOURCE_DONE → TARGET_PENDING → COMMITTED
FSM-002Normal Spot→FundingSame as above
FSM-003Source FailureINIT → SOURCE_PENDING → FAILED
FSM-004Target Failure (Explicit)… → TARGET_PENDING → COMPENSATING → ROLLED_BACK
FSM-005Target Timeout… → TARGET_PENDING (Stay, infinite retry)
FSM-006Compensation FailureCOMPENSATING (Stay, infinite retry)

10.5 Crash Recovery Tests

Test IDCrash PointExpected Recovery Behavior
CRA-001After INIT, before SOURCE_PENDINGRecovery reads INIT, restarts step_init
CRA-002During SOURCE_PENDING, before callRecovery retries withdraw (idempotent)
CRA-003During SOURCE_PENDING, after callRecovery retries withdraw (idempotent, returns handled)
CRA-004After SOURCE_DONE, before TARGET_PENDINGRecovery executes step_source_done
CRA-005During TARGET_PENDINGRecovery retries deposit (idempotent)
CRA-006During COMPENSATINGRecovery retries refund (idempotent)

10.6 Concurrency & Race Tests

Test IDScenarioExpected Result
CON-001Multiple Workers on same req_idOnly one successful CAS, others skip
CON-002Concurrent Same Amount TranserTwo separate req_ids, both execute
CON-003Transfer + External WithdrawSum cannot exceed balance
CON-004No-lock balance readNo double deduction (SELECT FOR UPDATE)

10.7 Idempotency Tests

Test IDScenarioExpected Result
IDP-001Call withdraw twiceSecond returns SUCCESS, balance deducted once
IDP-002Call deposit twiceSecond returns SUCCESS, balance credited once
IDP-003Call refund twiceSecond returns SUCCESS, balance credited once
IDP-004Recovery multiple retriesFinal state consistent, balance correct

10.8 Fund Anomaly Tests (Most Critical)

Test IDThreatMethodVerification
FND-001Double SpendSource deduct twiceOnly deduct once (idempotent)
FND-002Fund DisappearanceSource success, target fail, no compensationMust compensate or retry
FND-003Money from NothingTarget credit twiceOnly credit once (idempotent)
FND-004Lost in TransitCrash at any pointRecovery restores integrity
FND-005State InconsistencySOURCE_DONE but DB not updatedWAL + Idempotency parity
FND-006Partial CommitPG Transaction partial successAtomic transaction (all or none)

10.9 Monitoring & Alerting Tests

Test IDScenarioExpected Alert
MON-001Stuck in TARGET_PENDING > 1mCRITICAL Alert
MON-002Compensation fail 3 timesCRITICAL Alert
MON-003Fund conservation check failCRITICAL Alert + HALT Service
MON-004Abnormal freq per userWARNING Alert [P2]

🇨🇳 中文

📦 代码变更: 查看 Diff


1. 问题陈述

1.1 系统拓扑

系统角色数据源持久化
PostgreSQL资金账户 (Funding)balances_tbACID, 持久化
UBSCore交易账户 (Trading)RAMWAL + 易失性

1.2 核心约束

这两个系统 无法共享事务。没有 XA/2PC 数据库协议。 因此:我们必须使用外部 FSM 协调器构建自己的两阶段提交。


1.5 安全前置检查 (MANDATORY)

Caution

纵深防御 (Defense-in-Depth) 以下所有检查必须在 每一个独立模块 中执行,不仅仅是 API 层。

  • API 层: 第一道防线,拒绝明显非法请求
  • Coordinator: 再次验证,防止内部调用绕过 API
  • Adapters: 最终防线,每个适配器必须独立验证参数
  • UBSCore: 内存操作前最后一次检查

安全 > 性能。重复检查的开销可以接受,安全漏洞不可接受。

1.5.1 身份与授权检查

检查项攻击向量验证逻辑错误码
用户认证伪造请求JWT/Session 必须有效UNAUTHORIZED
用户 ID 一致性跨用户转账攻击request.user_id == auth.user_idFORBIDDEN
账户归属转走他人资金源/目标账户都属于同一 user_idFORBIDDEN

1.5.2 账户类型检查

检查项攻击向量验证逻辑错误码
from != to无限刷单/浪费资源request.from != request.toSAME_ACCOUNT
账户类型有效注入无效类型from, to ∈ {FUNDING, SPOT}INVALID_ACCOUNT_TYPE
账户类型支持请求未上线功能from, to 都在支持列表中UNSUPPORTED_ACCOUNT_TYPE

1.5.3 金额检查

检查项攻击向量验证逻辑错误码
amount > 0零/负数转账amount > 0INVALID_AMOUNT
精度检查精度溢出decimal_places(amount) <= asset.precisionPRECISION_OVERFLOW
最小金额微额攻击/粉尘攻击amount >= asset.min_transfer_amountAMOUNT_TOO_SMALL
最大单笔金额风控绕过amount <= asset.max_transfer_amountAMOUNT_TOO_LARGE
整数溢出u64 溢出攻击amount <= u64::MAX / safety_factorOVERFLOW

1.5.4 资产检查

检查项攻击向量验证逻辑错误码
资产存在伪造 asset_idasset_id 在系统中存在INVALID_ASSET
资产状态已下架资产asset.status == ACTIVEASSET_SUSPENDED
转账许可某些资产禁止内部转账asset.internal_transfer_enabled == trueTRANSFER_NOT_ALLOWED

1.5.5 账户状态检查

账户初始化规则(概述)

账户类型初始化时机备注
FUNDING首次申请充值时创建外部充值流程触发
SPOT首次内部转账时创建懒加载 (Lazy Init)
FUTURE首次内部转账时创建 [P2]懒加载
MARGIN首次内部转账时创建 [P2]懒加载

Note

  • 各账户类型的具体初始化行为和业务规则,请参见各账户类型的专用文档。
  • 每个账户都有自己的状态定义(如是否允许划转),当前不详细定义。
  • 默认状态:账户初始化时,默认允许划转。

账户状态检查表

检查项攻击向量验证逻辑错误码
源账户存在不存在的账户源账户记录必须存在SOURCE_ACCOUNT_NOT_FOUND
目标账户存在/创建不存在的目标FUNDING必须存在;SPOT/FUTURE/MARGIN可创建TARGET_ACCOUNT_NOT_FOUND (仅FUNDING)
源账户未冻结被冻结账户转出source.status != FROZENACCOUNT_FROZEN
源账户未禁用被禁用账户操作source.status != DISABLEDACCOUNT_DISABLED
余额充足余额不足直接拒绝source.available >= amountINSUFFICIENT_BALANCE

1.5.6 频率限制 (Rate Limiting) - [P2 未来优化]

Note

此部分为 V2 优化项,V1 可不实现。

检查项攻击向量验证逻辑错误码
每秒请求数DoS 攻击user_requests_per_second <= 10RATE_LIMIT_EXCEEDED
每日转账次数滥用user_daily_transfers <= 100DAILY_LIMIT_EXCEEDED
每日转账金额大额风控user_daily_amount <= daily_limitDAILY_AMOUNT_EXCEEDED

1.5.7 幂等性检查

检查项攻击向量验证逻辑错误码
cid 唯一重复提交如提供 cid,检查是否已存在DUPLICATE_REQUEST (返回原结果)

1.5.8 检查顺序 (推荐)

1. 身份认证 (JWT 有效?)
2. 授权检查 (user_id 匹配?)
3. 请求格式 (from/to/amount 有效?)
4. 账户类型 (from != to, 类型支持?)
5. 资产检查 (存在? 启用? 可转账?)
6. 金额检查 (范围? 精度? 溢出?)
7. 频率限制 (超限?)
8. 幂等性 (重复?)
9. 余额检查 (充足?) ← 最后检查,避免无谓查询

2. FSM 设计 (状态机)

2.0 库选择: rust-fsm

使用 rust-fsm,提供:

  • 编译时验证 - 非法状态转换在编译时报错
  • 声明式 DSL - 清晰定义状态和转换
  • 类型安全 - 防止遗漏分支

Cargo.toml:

[dependencies]
rust-fsm = "0.7"

DSL 定义:

#![allow(unused)]
fn main() {
use rust_fsm::*;

state_machine! {
    derive(Debug, Clone, Copy, PartialEq, Eq)
    
    TransferFsm(Init)  // 初始状态
    
    // 状态定义
    Init => {
        SourceWithdrawOk => SourceDone,
        SourceWithdrawFail => Failed,
    },
    SourceDone => {
        TargetDepositOk => Committed,
        TargetDepositFail => Compensating,
        TargetDepositUnknown => SourceDone [loop],  // 保持,无限重试
    },
    Compensating => {
        RefundOk => RolledBack,
        RefundFail => Compensating [loop],  // 保持,无限重试
    },
    // 终态
    Committed,
    Failed,
    RolledBack,
}
}

Note

上述 DSL 用于编译时验证状态转换的合法性。 实际运行时状态存储在 PostgreSQL,使用 CAS 更新。

2.0.1 核心状态流程图 (Top Level)

                              ┌─────────────────────────────────────────────────────────┐
                              │              INTERNAL TRANSFER FSM                       │
                              └─────────────────────────────────────────────────────────┘

   ┌─────────────────────────────── 正常路径 (Happy Path) ──────────────────────────────────┐
   │                                                                                        │
   │   ┌─────────┐                    ┌─────────────┐                    ┌───────────────┐  │
   │   │  INIT   │   源扣减成功 ✓     │ SOURCE_DONE │   目标入账成功 ✓   │               │  │
   │   │(用户请求)│ ─────────────────▶ │ (资金在途)  │ ─────────────────▶ │   COMMITTED   │  │
   │   └─────────┘                    └─────────────┘                    │               │  │
   │        │                               │                            └───────────────┘  │
   │        │                               │                                   ✅          │
   └────────│───────────────────────────────│───────────────────────────────────────────────┘
            │                               │
            │                               │
            │                               ▼
            │                     ╔══════════════════════════════════════════════════╗
            │                     ║  🔒 ATOMIC COMMIT (原子提交)                     ║
            │                     ║                                                  ║
            │                     ║  当且仅当:                                       ║
            │                     ║    FROM.withdraw = SUCCESS  ✓                   ║
            │                     ║    TO.deposit    = SUCCESS  ✓                   ║
            │                     ║                                                  ║
            │                     ║  执行: CAS(SOURCE_DONE → COMMITTED)             ║
            │                     ║  此操作必须原子,不可中断                         ║
            │                     ╚══════════════════════════════════════════════════╝
            │                               │
            │ 源扣减失败                     │ 目标入账失败 (明确 EXPLICIT_FAIL)
            ▼                               ▼
      ┌──────────┐                   ┌──────────────┐
      │  FAILED  │                   │ COMPENSATING │◀───────────┐
      │ (源失败)  │                   │  (退款中)    │            │ 退款失败 (无限重试)
      └──────────┘                   └──────────────┘────────────┘
           ❌                               │ 退款成功
                                            ▼
                                     ┌─────────────┐
                                     │ ROLLED_BACK │
                                     │  (已回滚)    │
                                     └─────────────┘
                                           ↩️

   ╔════════════════════════════════════════════════════════════════════════════════════════╗
   ║  ⚠️ 目标入账状态未知 (TIMEOUT/UNKNOWN) → 保持 SOURCE_DONE,无限重试,绝不进入 COMPENSATING║
   ╚════════════════════════════════════════════════════════════════════════════════════════╝

核心状态说明:

状态资金位置说明
INIT源账户用户发起请求,资金尚未移动
SOURCE_DONE在途关键点!资金已离开源,尚未到达目标
COMMITTED目标账户终态,转账成功
FAILED源账户终态,源扣减失败,无资金移动
COMPENSATING在途目标入账失败,正在退款
ROLLED_BACK源账户终态,退款成功

Important

SOURCE_DONE 是最关键的状态 - 资金已离开源账户但尚未到达目标。 此时绝不能丢失状态,必须确保最终到达 COMMITTEDROLLED_BACK


2.1 状态 (穷举)

ID状态名进入条件终态?资金位置
0INIT用户请求已接受源账户
10SOURCE_PENDINGCAS 成功,适配器调用已发起源账户 (扣减中)
20SOURCE_DONE源适配器返回 OK在途
30TARGET_PENDINGCAS 成功,目标适配器调用已发起在途 (入账中)
40COMMITTED目标适配器返回 OK目标账户
-10FAILED源适配器返回 FAIL源账户 (未变)
-20COMPENSATING目标适配器 FAIL 且源可逆在途 (退款中)
-30ROLLED_BACK源退款 OK源账户 (已恢复)

2.2 状态转换规则 (穷举)

┌───────────────────────────────────────────────────────────────────────────────┐
│                              规范状态转换                                       │
├───────────────────────────────────────────────────────────────────────────────┤
│                                                                               │
│  INIT ──────[CAS成功]───────► SOURCE_PENDING                                  │
│    │                              │                                           │
│    │                              ├──[适配器OK]────► SOURCE_DONE              │
│    │                              │                         │                 │
│    │                              └──[适配器FAIL]──► FAILED (终态)            │
│    │                                                        │                 │
│    │                                                        │                 │
│    │                              SOURCE_DONE ──[CAS成功]──► TARGET_PENDING   │
│    │                                                             │            │
│    │                        ┌────────────────────────────────────┤            │
│    │                        │                                    │            │
│    │            [适配器OK]  │                       [适配器FAIL]              │
│    │                        │                                    │            │
│    │                        ▼                                    ▼            │
│    │                   COMMITTED                     ┌───────────────────┐    │
│    │                   (终态)                        │   源可逆?          │    │
│    │                                                 └─────────┬─────────┘    │
│    │                                                   是      │     否       │
│    │                                                   ▼       │     ▼        │
│    │                                           COMPENSATING    │  无限重试    │
│    │                                                 │         │ (保持在      │
│    │                                    [退款OK]     │         │  TARGET_     │
│    │                                         ▼       │         │  PENDING)    │
│    │                                    ROLLED_BACK  │         │              │
│    │                                    (终态)       │         │              │
│    │                                                 │         │              │
│    └─────────────────────────────────────────────────┴─────────┴──────────────┘

2.3 可逆性规则 (关键)

核心原则: 只有当适配器返回 明确定义的失败 时,才能安全撤销。

响应类型含义可安全撤销?处理方式
SUCCESS操作成功N/A继续下一步
EXPLICIT_FAIL明确业务失败 (如余额不足)可进入 COMPENSATING
TIMEOUT超时,状态未知无限重试
PENDING处理中,状态未知无限重试
NETWORK_ERROR网络错误,状态未知无限重试
UNKNOWN任何其他情况无限重试或人工介入

Caution

只有 EXPLICIT_FAIL 可以安全撤销。 任何状态未知的情况(超时、Pending、网络错误),资金都处于 In-Flight 中。 我们无法知道对方是否已处理。贸然撤销将导致 双花资金丢失。 唯一安全操作:无限重试人工介入


3. 转账场景 (逐步)

3.1 场景 A: 资金 → 交易 (充值到交易账户)

正常路径:

步骤执行者操作前状态后状态资金
1API验证,创建记录-INIT资金账户
2协调器CAS(INITSOURCE_PENDING)INITSOURCE_PENDING资金账户
3协调器调用 FundingAdapter.withdraw(req_id)---
4PGUPDATE balances SET amount = amount - X--已扣减
5协调器收到 OK: CAS(SOURCE_PENDINGSOURCE_DONE)SOURCE_PENDINGSOURCE_DONE在途
6协调器CAS(SOURCE_DONETARGET_PENDING)SOURCE_DONETARGET_PENDING在途
7协调器调用 TradingAdapter.deposit(req_id)---
8UBSCore增加RAM余额,写WAL,发出事件--已入账
9协调器收到事件: CAS(TARGET_PENDINGCOMMITTED)TARGET_PENDINGCOMMITTED交易账户

失败路径 (目标失败):

步骤执行者操作前状态后状态资金
7’协调器调用 TradingAdapter.deposit(req_id)FAIL/超时TARGET_PENDING-在途
8’协调器检查: 源 = 资金账户 (可逆)---
9’协调器CAS(TARGET_PENDINGCOMPENSATING)TARGET_PENDINGCOMPENSATING在途
10’协调器调用 FundingAdapter.refund(req_id)---
11’PGUPDATE balances SET amount = amount + X--已退款
12’协调器CAS(COMPENSATINGROLLED_BACK)COMPENSATINGROLLED_BACK资金账户

3.2 场景 B: 交易 → 资金 (从交易账户提现)

正常路径:

步骤执行者操作前状态后状态资金
1API验证,创建记录-INIT交易账户
2协调器CAS(INITSOURCE_PENDING)INITSOURCE_PENDING交易账户
3协调器调用 TradingAdapter.withdraw(req_id)---
4UBSCore检查余额,扣减RAM,写WAL,发出事件--已扣减
5协调器收到事件: CAS(SOURCE_PENDINGSOURCE_DONE)SOURCE_PENDINGSOURCE_DONE在途
6协调器CAS(SOURCE_DONETARGET_PENDING)SOURCE_DONETARGET_PENDING在途
7协调器调用 FundingAdapter.deposit(req_id)---
8PGINSERT ... ON CONFLICT UPDATE SET amount = amount + X--已入账
9协调器收到 OK: CAS(TARGET_PENDINGCOMMITTED)TARGET_PENDINGCOMMITTED资金账户

失败路径 (目标失败):

步骤执行者操作前状态后状态资金
7a协调器调用 FundingAdapter.deposit(req_id)EXPLICIT_FAIL (如约束违反)TARGET_PENDING-在途
8a协调器检查响应类型 = EXPLICIT_FAIL (可安全撤销)---
9a协调器CAS(TARGET_PENDINGCOMPENSATING)TARGET_PENDINGCOMPENSATING在途
10a协调器调用 TradingAdapter.refund(req_id) (向UBSCore退款)---
11aUBSCore增加RAM余额,写WAL--已退款
12a协调器CAS(COMPENSATINGROLLED_BACK)COMPENSATINGROLLED_BACK交易账户
步骤执行者操作前状态后状态资金
7b协调器调用 FundingAdapter.deposit(req_id)TIMEOUT/UNKNOWNTARGET_PENDING-在途
8b协调器检查响应类型 = UNKNOWN (不可安全撤销)---
9b协调器不转换状态。保持 TARGET_PENDINGTARGET_PENDINGTARGET_PENDING在途
10b协调器记录 CRITICAL 日志。告警运维。安排重试。---
11b恢复器无限重试 FundingAdapter.deposit(req_id)---
12b(最终)收到 OK: CAS(TARGET_PENDINGCOMMITTED)TARGET_PENDINGCOMMITTED资金账户

Warning

只有当目标返回 EXPLICIT_FAIL 时才能进入 COMPENSATING 如果是超时或未知状态,资金处于 In-Flight,必须无限重试或人工介入。


4. 失效模式与影响分析 (FMEA)

4.1 阶段1失败 (源操作)

失败原因当前状态资金解决方案
适配器返回 FAIL余额不足,DB约束SOURCE_PENDING源账户转到 FAILED。用户看到错误。
适配器返回 PENDING超时,网络问题SOURCE_PENDING未知重试。适配器必须幂等。
协调器在CAS后、调用前崩溃进程终止SOURCE_PENDING源账户恢复工作器重试调用。
协调器在调用后、结果前崩溃进程终止SOURCE_PENDING未知恢复工作器重试(幂等)。

4.2 阶段2失败 (目标操作)

失败原因响应类型当前状态资金解决方案
目标明确拒绝业务规则EXPLICIT_FAILTARGET_PENDING在途COMPENSATING → 退款。
超时网络延迟TIMEOUTTARGET_PENDING未知无限重试
网络错误连接断开NETWORK_ERRORTARGET_PENDING未知无限重试
未知错误系统异常UNKNOWNTARGET_PENDING未知无限重试 或 人工介入。
协调器崩溃进程终止N/ATARGET_PENDING在途恢复工作器重试。

4.3 补偿失败

失败原因当前状态资金解决方案
退款 FAILPG宕机,约束COMPENSATING在途无限重试。资金卡住直到PG恢复。
退款 PENDING超时COMPENSATING未知重试

5. 幂等性要求 (强制)

5.1 为什么需要幂等性?

重试是崩溃恢复的基础。没有幂等性,重试将导致 双重执行(双重扣减、双重入账)。

5.2 实现 (资金适配器)

要求: 给定相同的 req_id,多次调用 withdraw()deposit() 必须与调用一次效果相同。

机制:

  1. transfers_tbUNIQUE(req_id)
  2. 原子事务:
    BEGIN;
    -- 检查是否已处理
    SELECT state FROM transfers_tb WHERE req_id = $1;
    IF state >= expected_post_state THEN
        RETURN 'AlreadyProcessed';
    END IF;
    
    -- 执行余额更新
    UPDATE balances_tb SET amount = amount - $2 WHERE user_id = $3 AND asset_id = $4 AND amount >= $2;
    IF NOT FOUND THEN
        RETURN 'InsufficientBalance';
    END IF;
    
    -- 更新状态
    UPDATE transfers_tb SET state = $new_state, updated_at = NOW() WHERE req_id = $1;
    COMMIT;
    RETURN 'Success';
    

5.3 实现 (交易适配器)

要求: 同上。UBSCore 必须拒绝重复的 req_id

机制:

  1. InternalOrder 包含 req_id 字段(或 cid)。
  2. UBSCore 维护一个 ProcessedTransferSet(RAM中的HashSet,重启时从WAL重建)。
  3. 收到转账订单时:
    IF req_id IN ProcessedTransferSet THEN
        RETURN 'AlreadyProcessed' (成功,无操作)
    ELSE
        ProcessTransfer()
        ProcessedTransferSet.insert(req_id)
        WriteWAL(TransferEvent)
        RETURN 'Success'
    END IF
    

6. 恢复工作器 (僵尸处理器)

6.1 目的

在协调器启动时(或定期),扫描“卡住“的转账并恢复它们。

6.2 查询

SELECT * FROM transfers_tb 
WHERE state IN (0, 10, 20, 30, -20) -- INIT, SOURCE_PENDING, SOURCE_DONE, TARGET_PENDING, COMPENSATING
  AND updated_at < NOW() - INTERVAL '1 minute'; -- 过期阈值

6.3 恢复逻辑

当前状态操作
INIT调用 step()(将转到 SOURCE_PENDING)。
SOURCE_PENDING重试 Source.withdraw()
SOURCE_DONE调用 step()(将转到 TARGET_PENDING)。
TARGET_PENDING重试 Target.deposit()。应用可逆性规则。
COMPENSATING重试 Source.refund()

7. 数据模型

7.1 表: transfers_tb

CREATE TABLE transfers_tb (
    transfer_id   BIGSERIAL PRIMARY KEY,
    req_id        VARCHAR(26) UNIQUE NOT NULL,  -- 服务端生成的唯一 ID (ULID)
    cid           VARCHAR(64) UNIQUE,           -- 客户端幂等键 (可选)
    user_id       BIGINT NOT NULL,
    asset_id      INTEGER NOT NULL,
    amount        DECIMAL(30, 8) NOT NULL,
    transfer_type SMALLINT NOT NULL,            -- 1 = 资金->交易, 2 = 交易->资金
    source_type   SMALLINT NOT NULL,            -- 1 = 资金, 2 = 交易
    state         SMALLINT NOT NULL DEFAULT 0,  -- FSM 状态 ID
    error_message TEXT,                         -- 最后错误(用于调试)
    retry_count   INTEGER NOT NULL DEFAULT 0,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_transfers_state ON transfers_tb(state) WHERE state NOT IN (40, -10, -30);

7.2 不变量检查

定期运行以检测数据损坏:

-- 每个用户每个资产的 资金 + 交易 + 在途 之和应该是常数
-- 在途 = SUM(amount) WHERE state IN (SOURCE_DONE, TARGET_PENDING, COMPENSATING)

8. API 契约

8.1 端点: POST /api/v1/internal_transfer

请求:

{
  "from": "SPOT",       // 源账户类型
  "to": "FUNDING",     // 目标账户类型
  "asset": "USDT",
  "amount": "100.00"
}

账户类型枚举 (AccountType):

含义状态
FUNDING资金账户 (PostgreSQL)已支持
SPOT现货交易账户 (UBSCore)已支持
FUTURE合约账户未来扩展
MARGIN杠杆账户未来扩展

响应:

{
  "transfer_id": 12345,
  "req_id": "01JFVQ2X8Z0Y1M3N4P5R6S7T8U",  // 服务端生成 (ULID)
  "from": "SPOT",
  "to": "FUNDING",
  "state": "COMMITTED",  // 或 "PENDING" 如果异步
  "message": "转账成功"
}

8.2 查询端点: GET /api/v1/internal_transfer/:req_id

响应:

{
  "transfer_id": 12345,
  "req_id": "sr-1734912345678901234",
  "from": "SPOT",
  "to": "FUNDING",
  "asset": "USDT",
  "amount": "100.00",
  "state": "COMMITTED",
  "created_at": "2024-12-23T14:00:00Z",
  "updated_at": "2024-12-23T14:00:01Z"
}

Important

req_id 由服务端生成,不是客户端。 客户端如果需要幂等性,应使用 cid (client_order_id) 字段(可选),服务端会检查重复并返回已有结果。

错误码:

代码含义
INSUFFICIENT_BALANCE源账户余额 < 金额。
INVALID_ACCOUNT_TYPEfromto 的账户类型无效或不支持。
SAME_ACCOUNTfromto 相同。
DUPLICATE_REQUESTcid 已处理。返回原始结果。
INVALID_AMOUNT金额 <= 0 或超过精度。
SYSTEM_ERROR内部失败。建议重试。

9. 实现伪代码 (关键状态检查)

9.1 API 层

function handle_transfer_request(request, auth_context):
    // ========== 纵深防御 Layer 1: API 层 ==========
    
    // 1. 身份认证
    if !auth_context.is_valid():
        return Error(UNAUTHORIZED)
    
    // 2. 用户 ID 一致性(防止跨用户攻击)
    if request.user_id != auth_context.user_id:
        return Error(FORBIDDEN, "User ID mismatch")
    
    // 3. 账户类型检查
    if request.from == request.to:
        return Error(SAME_ACCOUNT)
    
    if request.from NOT IN [FUNDING, SPOT]:
        return Error(INVALID_ACCOUNT_TYPE)
    
    if request.to NOT IN [FUNDING, SPOT]:
        return Error(INVALID_ACCOUNT_TYPE)
    
    // 4. 金额检查
    if request.amount <= 0:
        return Error(INVALID_AMOUNT)
    
    if decimal_places(request.amount) > asset.precision:
        return Error(PRECISION_OVERFLOW)
    
    // 5. 幂等性检查
    if request.cid:
        existing = db.find_by_cid(request.cid)
        if existing:
            return Success(existing)  // 返回已存在的结果
    
    // 6. 资产检查
    asset = db.get_asset(request.asset_id)
    if !asset or asset.status != ACTIVE:
        return Error(INVALID_ASSET)
    
    // 7. 调用 Coordinator
    result = coordinator.create_and_execute(request)
    return result

9.2 Coordinator 层

function create_and_execute(request):
    // ========== 纵深防御 Layer 2: Coordinator ==========
    
    // 再次验证(防止内部调用绕过 API)
    ASSERT request.from != request.to
    ASSERT request.amount > 0
    ASSERT request.user_id > 0
    
    // 生成唯一 ID
    req_id = ulid.new()
    
    // 创建转账记录 (State = INIT)
    transfer = TransferRecord {
        req_id: req_id,
        user_id: request.user_id,
        from: request.from,
        to: request.to,
        asset_id: request.asset_id,
        amount: request.amount,
        state: INIT,
        created_at: now()
    }
    
    db.insert(transfer)
    log.info("Transfer created", req_id)
    
    // 执行 FSM
    return execute_fsm(req_id)

function execute_fsm(req_id):
    loop:
        transfer = db.get(req_id)
        
        if transfer.state.is_terminal():
            return transfer
        
        new_state = step(transfer)
        
        if new_state == transfer.state:
            // 未进展,等待重试
            sleep(RETRY_INTERVAL)
            continue
    
function step(transfer):
    match transfer.state:
        INIT:
            return step_init(transfer)
        SOURCE_PENDING:
            return step_source_pending(transfer)
        SOURCE_DONE:
            return step_source_done(transfer)
        TARGET_PENDING:
            return step_target_pending(transfer)
        COMPENSATING:
            return step_compensating(transfer)
        _:
            return transfer.state  // 终态,不处理

function step_init(transfer):
    // CAS: 先更新状态,再调用适配器(Persist-Before-Call)
    success = db.cas_update(
        req_id = transfer.req_id,
        old_state = INIT,
        new_state = SOURCE_PENDING
    )
    
    if !success:
        // 并发冲突,重新读取
        return db.get(transfer.req_id).state
    
    // 获取源适配器
    source_adapter = get_adapter(transfer.from)
    
    // ========== 纵深防御 Layer 3: Adapter ==========
    result = source_adapter.withdraw(
        req_id = transfer.req_id,
        user_id = transfer.user_id,
        asset_id = transfer.asset_id,
        amount = transfer.amount
    )
    
    match result:
        SUCCESS:
            db.cas_update(transfer.req_id, SOURCE_PENDING, SOURCE_DONE)
            return SOURCE_DONE
        
        EXPLICIT_FAIL(reason):
            // 明确失败,可以安全终止
            db.update_with_error(transfer.req_id, SOURCE_PENDING, FAILED, reason)
            return FAILED
        
        TIMEOUT | PENDING | NETWORK_ERROR | UNKNOWN:
            // 状态未知,保持 SOURCE_PENDING,等待重试
            log.warn("Source withdraw unknown state", transfer.req_id)
            return SOURCE_PENDING

function step_source_done(transfer):
    // ========== 进入 SOURCE_DONE: 资金已在途,必须确保最终到达终态 ==========
    
    // CAS 更新到 TARGET_PENDING
    success = db.cas_update(transfer.req_id, SOURCE_DONE, TARGET_PENDING)
    if !success:
        return db.get(transfer.req_id).state
    
    // 获取目标适配器
    target_adapter = get_adapter(transfer.to)
    
    // ========== 纵深防御 Layer 4: Target Adapter ==========
    result = target_adapter.deposit(
        req_id = transfer.req_id,
        user_id = transfer.user_id,
        asset_id = transfer.asset_id,
        amount = transfer.amount
    )
    
    match result:
        SUCCESS:
            // ╔════════════════════════════════════════════════════════════════╗
            // ║  🔒 ATOMIC COMMIT - 最关键的一步!                             ║
            // ║                                                                ║
            // ║  此时:                                                         ║
            // ║    FROM.withdraw = SUCCESS ✓ (已确认)                         ║
            // ║    TO.deposit    = SUCCESS ✓ (刚确认)                         ║
            // ║                                                                ║
            // ║  执行原子 CAS 提交:                                            ║
            // ║    CAS(TARGET_PENDING → COMMITTED)                            ║
            // ║                                                                ║
            // ║  此 CAS 是最终确认,一旦成功,转账不可逆转!                    ║
            // ╚════════════════════════════════════════════════════════════════╝
            
            commit_success = db.cas_update(transfer.req_id, TARGET_PENDING, COMMITTED)
            
            if !commit_success:
                // 极少发生:另一个 Worker 已经提交,返回当前状态
                return db.get(transfer.req_id).state
            
            log.info("🔒 ATOMIC COMMIT SUCCESS", transfer.req_id)
            return COMMITTED
        
        EXPLICIT_FAIL(reason):
            // 明确失败,可以进入补偿
            db.update_with_error(transfer.req_id, TARGET_PENDING, COMPENSATING, reason)
            return COMPENSATING
        
        TIMEOUT | PENDING | NETWORK_ERROR | UNKNOWN:
            // ========== 关键:状态未知,不能补偿!==========
            log.critical("Target deposit unknown state - INFINITE RETRY", transfer.req_id)
            alert_ops("Transfer stuck in TARGET_PENDING", transfer.req_id)
            return TARGET_PENDING  // 保持状态,等待重试


function step_compensating(transfer):
    source_adapter = get_adapter(transfer.from)
    
    result = source_adapter.refund(
        req_id = transfer.req_id,
        user_id = transfer.user_id,
        asset_id = transfer.asset_id,
        amount = transfer.amount
    )
    
    match result:
        SUCCESS:
            db.cas_update(transfer.req_id, COMPENSATING, ROLLED_BACK)
            log.info("Transfer rolled back", transfer.req_id)
            return ROLLED_BACK
        
        _:
            // 退款失败,必须无限重试
            log.critical("Refund failed - MUST RETRY", transfer.req_id)
            return COMPENSATING

9.3 Adapter 层 (示例: Funding Adapter)

function withdraw(req_id, user_id, asset_id, amount):
    // ========== 纵深防御 Layer 3: Adapter 内部检查 ==========
    
    // 再次验证参数(不信任调用者)
    ASSERT amount > 0
    ASSERT user_id > 0
    ASSERT asset_id > 0
    
    // 幂等性检查
    existing = db.find_transfer_operation(req_id, "WITHDRAW")
    if existing:
        return existing.result  // 返回已处理的结果
    
    // 开始事务
    tx = db.begin_transaction()
    try:
        // 获取账户并锁定
        account = tx.select_for_update(
            "SELECT * FROM balances_tb WHERE user_id = ? AND asset_id = ? AND account_type = 'FUNDING'"
        )
        
        if !account:
            tx.rollback()
            return EXPLICIT_FAIL("SOURCE_ACCOUNT_NOT_FOUND")
        
        if account.status == FROZEN:
            tx.rollback()
            return EXPLICIT_FAIL("ACCOUNT_FROZEN")
        
        if account.available < amount:
            tx.rollback()
            return EXPLICIT_FAIL("INSUFFICIENT_BALANCE")
        
        // 执行扣减
        tx.update("UPDATE balances_tb SET available = available - ? WHERE id = ?", amount, account.id)
        
        // 记录操作(用于幂等性)
        tx.insert("INSERT INTO transfer_operations (req_id, op_type, result) VALUES (?, 'WITHDRAW', 'SUCCESS')")
        
        tx.commit()
        return SUCCESS
        
    catch Exception as e:
        tx.rollback()
        log.error("Withdraw failed", req_id, e)
        return UNKNOWN  // 不确定是否执行,必须重试

10. 验收测试计划 (安全关键)

Caution

以下测试必须全部通过才能上线。 任何失败都可能导致资金被盗、消失或无中生有。

10.1 资金守恒测试

测试 ID场景预期结果验证方法
INV-001正常转账后总资金 = 转账前SUM(source) + SUM(target) = 常数
INV-002失败转账后总资金 = 转账前源账户余额无变化
INV-003回滚后总资金 = 转账前源账户余额完全恢复
INV-004系统崩溃恢复后总资金 = 崩溃前遍历所有账户验证

10.2 外部攻击测试

测试 ID攻击向量测试步骤预期结果
ATK-001跨用户转账用 user_id=A 的 token 请求转 user_id=B 的资金FORBIDDEN
ATK-002user_id 篡改修改请求体中的 user_idFORBIDDEN
ATK-003负数金额amount = -100INVALID_AMOUNT
ATK-004零金额amount = 0INVALID_AMOUNT
ATK-005超精度金额amount = 0.000000001 (超过8位)PRECISION_OVERFLOW
ATK-006整数溢出amount = u64::MAX + 1OVERFLOW 或解析失败
ATK-007相同账户from = to = SPOTSAME_ACCOUNT
ATK-008无效账户类型from = “INVALID”INVALID_ACCOUNT_TYPE
ATK-009不存在的资产asset_id = 999999INVALID_ASSET
ATK-010重复 cid同一 cid 发两次第二次返回第一次结果
ATK-011无 Token不带 Authorization headerUNAUTHORIZED
ATK-012过期 Token使用过期的 JWTUNAUTHORIZED
ATK-013伪造 Token使用无效签名的 JWTUNAUTHORIZED

10.3 余额不足测试

测试 ID场景预期结果
BAL-001转账金额 > 可用余额INSUFFICIENT_BALANCE,余额无变化
BAL-002转账金额 = 可用余额成功,余额变为 0
BAL-003并发: 两次转账总额 > 余额一个成功,一个 INSUFFICIENT_BALANCE
BAL-004冻结账户转出ACCOUNT_FROZEN
BAL-005禁用账户转出ACCOUNT_DISABLED

10.4 FSM 状态转换测试

测试 ID场景预期状态流
FSM-001正常 Funding→SpotINIT → SOURCE_PENDING → SOURCE_DONE → TARGET_PENDING → COMMITTED
FSM-002正常 Spot→Funding同上
FSM-003源失败INIT → SOURCE_PENDING → FAILED
FSM-004目标失败 (明确)… → TARGET_PENDING → COMPENSATING → ROLLED_BACK
FSM-005目标超时… → TARGET_PENDING (保持,无限重试)
FSM-006补偿失败COMPENSATING (保持,无限重试)

10.5 崩溃恢复测试

测试 ID崩溃点预期恢复行为
CRA-001INIT 后,SOURCE_PENDING 前Recovery 读取 INIT,重新执行 step_init
CRA-002SOURCE_PENDING 中,适配器调用前Recovery 重试 withdraw (幂等)
CRA-003SOURCE_PENDING 中,适配器调用后Recovery 重试 withdraw (幂等,返回已处理)
CRA-004SOURCE_DONE 后,TARGET_PENDING 前Recovery 继续执行 step_source_done
CRA-005TARGET_PENDING 中Recovery 重试 deposit (幂等)
CRA-006COMPENSATING 中Recovery 重试 refund (幂等)

10.6 并发/竞态测试

测试 ID场景预期结果
CON-001多个 Worker 处理同一 req_id只有一个成功 CAS,其他跳过
CON-002同时两次相同金额转账两个独立 req_id,各自执行
CON-003转账 + 外部提现并发只有余额足够的操作成功
CON-004读取余额时无锁无重复扣减(SELECT FOR UPDATE)

10.7 幂等性测试

测试 ID场景预期结果
IDP-001同一 req_id 调用 withdraw 两次第二次返回 SUCCESS,余额只扣一次
IDP-002同一 req_id 调用 deposit 两次第二次返回 SUCCESS,余额只加一次
IDP-003同一 req_id 调用 refund 两次第二次返回 SUCCESS,余额只加一次
IDP-004Recovery 多次重试同一 transfer最终状态一致,余额正确

10.8 资金异常测试 (最关键)

测试 ID威胁测试方法验证
FND-001双花 (Double Spend)源扣减两次只扣一次(幂等)
FND-002资金消失源扣减成功,目标失败,不补偿必须补偿或无限重试
FND-003资金无中生有目标入账两次只入一次(幂等)
FND-004中途崩溃丢失任意点崩溃Recovery 恢复完整性
FND-005状态不一致SOURCE_DONE 但 DB 未更新WAL + 幂等保证一致
FND-006部分提交PG 事务部分成功原子事务,全成功或全失败

10.9 监控告警测试

测试 ID场景预期告警
MON-001转账卡在 TARGET_PENDING > 1 分钟CRITICAL 告警
MON-002补偿连续失败 3 次CRITICAL 告警
MON-003资金守恒检查失败CRITICAL 告警 + 暂停服务
MON-004单用户转账频率异常WARNING 告警 [P2]




📋 Implementation & Verification | 实现与验证

本章的完整实现细节、API 说明、E2E 测试脚本和验证结果请参阅:

For complete implementation details, API documentation, E2E test scripts, and verification results:

👉 Phase 0x0B-a: Implementation & Testing Guide

包含 / Includes:

  • 架构实现与核心模块 (Architecture & Core Modules)
  • 新增 API 端点 (New API Endpoints)
  • 可复用 E2E 测试脚本 (Reusable E2E Test Script)
  • 数据库验证方法 (Database Verification)
  • 已修复 Bug 清单 (Fixed Bugs)