This is an automated email from the ASF dual-hosted git repository.

thunguo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-seata-go-samples.git


The following commit(s) were added to refs/heads/main by this push:
     new c95a0a3  feat: add XA bank transfer sample with Docker Compose setup 
(#96)
c95a0a3 is described below

commit c95a0a3de11eb5a1e079d87f7422bf453bcd8e25
Author: Cheyne Chen <[email protected]>
AuthorDate: Sat Jul 4 16:25:42 2026 +0800

    feat: add XA bank transfer sample with Docker Compose setup (#96)
    
    * feat: add XA bank transfer sample with Docker Compose setup
    
    - Introduced a new Docker Compose configuration for running Seata and two 
MySQL instances.
    - Added bank-a and bank-b services to handle debit and credit operations 
respectively.
    - Implemented a transfer service to manage global transactions across the 
two banks.
    - Included SQL scripts for initializing the databases and accounts.
    - Updated README with instructions on running the sample and details on the 
transaction process.
    
    * chore: update Seata server image and improve context handling in services
    
    - Updated Seata server image version to 1.6.1 in Docker Compose 
configuration.
    - Enhanced context handling in bank-a and bank-b services by using request 
context in account retrieval.
    - Added comments to clarify middleware behavior for debit and credit 
operations in both services.
    - Improved error handling in transfer service's postJSON function for 
better debugging.
---
 xa/bank_transfer/README.md                | 142 +++++++++++++++++++++++++
 xa/bank_transfer/bank-a-service/main.go   | 160 ++++++++++++++++++++++++++++
 xa/bank_transfer/bank-b-service/main.go   | 168 ++++++++++++++++++++++++++++++
 xa/bank_transfer/docker-compose.yml       |  48 +++++++++
 xa/bank_transfer/sql/bank_a.sql           |  31 ++++++
 xa/bank_transfer/sql/bank_b.sql           |  34 ++++++
 xa/bank_transfer/transfer-service/main.go | 152 +++++++++++++++++++++++++++
 7 files changed, 735 insertions(+)

diff --git a/xa/bank_transfer/README.md b/xa/bank_transfer/README.md
new file mode 100644
index 0000000..07ccb6d
--- /dev/null
+++ b/xa/bank_transfer/README.md
@@ -0,0 +1,142 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+
+# XA Bank Transfer
+
+This sample demonstrates an XA global transaction across two independent MySQL
+databases.
+
+- `transfer-service` starts the global transaction and calls the two banks by 
HTTP.
+- `bank-a-service` uses the Seata XA MySQL driver to deduct money from 
`db_bank_a`.
+- `bank-b-service` uses the Seata XA MySQL driver to add money to `db_bank_b`.
+
+The global XID is sent in the HTTP header. Each bank service receives it 
through
+the Gin transaction middleware, registers an XA branch with Seata, and lets 
Seata
+coordinate the two-phase commit.
+
+## Run
+
+Start Seata and the two MySQL instances:
+
+```bash
+docker-compose -f xa/bank_transfer/docker-compose.yml up -d
+```
+
+Start the three services from the repository root:
+
+```bash
+go run ./xa/bank_transfer/bank-a-service
+go run ./xa/bank_transfer/bank-b-service
+go run ./xa/bank_transfer/transfer-service
+```
+
+Default ports:
+
+- `transfer-service`: `18080`
+- `bank-a-service`: `18081`
+- `bank-b-service`: `18082`
+- `mysql-bank-a`: `3307`
+- `mysql-bank-b`: `3308`
+- `seata-server`: `8091`
+
+## Successful transfer
+
+Check the initial balances:
+
+```bash
+curl http://127.0.0.1:18081/accounts/A-1001
+curl http://127.0.0.1:18082/accounts/B-2001
+```
+
+Run a successful transfer:
+
+```bash
+curl -X POST http://127.0.0.1:18080/transfer/success
+```
+
+Expected result:
+
+- `A-1001` in `db_bank_a` is debited by `100`.
+- `B-2001` in `db_bank_b` is credited by `100`.
+- Seata commits both XA branches.
+
+## Gin and XA context
+
+Bank services must enable `r.ContextWithFallback = true` on Gin 1.8.1+ so the
+global XID injected by `TransactionMiddleware` is visible to `db.ExecContext`.
+
+Database handlers should pass `c.Request.Context()` into SQL calls instead of
+the `*gin.Context` value itself.
+
+## Rollback transfer
+
+`B-FROZEN` is a frozen account in `db_bank_b`. It rejects credits to simulate a
+bank-b failure after bank-a has already executed the debit.
+
+```bash
+curl -X POST http://127.0.0.1:18080/transfer/fail
+```
+
+Expected result:
+
+- `bank-a-service` first deducts `100` from `A-1001`.
+- `bank-b-service` rejects the credit because `B-FROZEN` is frozen.
+- `transfer-service` returns an error from the global transaction.
+- Seata rolls back the bank-a XA branch, so `A-1001` keeps its original 
balance.
+
+Verify balances:
+
+```bash
+curl http://127.0.0.1:18081/accounts/A-1001
+curl http://127.0.0.1:18082/accounts/B-FROZEN
+```
+
+You can also send a custom request:
+
+```bash
+curl -X POST http://127.0.0.1:18080/transfer \
+  -H 'Content-Type: application/json' \
+  -d '{"from_account_no":"A-1001","to_account_no":"B-2001","amount":100}'
+```
+
+## Recover from a stuck XA state
+
+If repeated rollback failures leave MySQL with prepared XA branches or row
+locks, restart the three Go services and clean the databases:
+
+```bash
+docker exec mysql-bank-a mysql -uroot -p123456 -e "XA RECOVER;"
+docker exec mysql-bank-b mysql -uroot -p123456 -e "XA RECOVER;"
+```
+
+For every row returned by `XA RECOVER`, run `XA ROLLBACK '<data>';` on that
+database. Then verify no transaction is waiting:
+
+```bash
+docker exec mysql-bank-a mysql -uroot -p123456 -e "SELECT trx_id, trx_state 
FROM information_schema.innodb_trx;"
+docker exec mysql-bank-b mysql -uroot -p123456 -e "SELECT trx_id, trx_state 
FROM information_schema.innodb_trx;"
+```
+
+## Environment variables
+
+The sample defaults are ready for the compose file above. Override these when
+running services against another environment:
+
+- `SEATA_CONFIG`
+- `BANK_A_URL`, `BANK_B_URL`
+- `BANK_A_MYSQL_HOST`, `BANK_A_MYSQL_PORT`, `BANK_A_MYSQL_USERNAME`, 
`BANK_A_MYSQL_PASSWORD`, `BANK_A_MYSQL_DB`
+- `BANK_B_MYSQL_HOST`, `BANK_B_MYSQL_PORT`, `BANK_B_MYSQL_USERNAME`, 
`BANK_B_MYSQL_PASSWORD`, `BANK_B_MYSQL_DB`
diff --git a/xa/bank_transfer/bank-a-service/main.go 
b/xa/bank_transfer/bank-a-service/main.go
new file mode 100644
index 0000000..b2e796f
--- /dev/null
+++ b/xa/bank_transfer/bank-a-service/main.go
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package main
+
+import (
+       "context"
+       "database/sql"
+       "errors"
+       "fmt"
+       "net/http"
+       "os"
+
+       "github.com/gin-gonic/gin"
+       "seata.apache.org/seata-go/pkg/client"
+       sql2 "seata.apache.org/seata-go/pkg/datasource/sql"
+       ginmiddleware "seata.apache.org/seata-go/pkg/integration/gin"
+)
+
+const defaultListenAddr = ":18081"
+
+var db *sql.DB
+
+type debitRequest struct {
+       AccountNo string `json:"account_no" binding:"required"`
+       Amount    int64  `json:"amount" binding:"required,gt=0"`
+}
+
+type account struct {
+       AccountNo string `json:"account_no"`
+       Balance   int64  `json:"balance"`
+}
+
+func main() {
+       client.InitPath(resolveSeataConfig())
+       db = openXADB()
+       defer db.Close()
+
+       r := gin.Default()
+       r.ContextWithFallback = true
+       // Read-only routes registered before Use() skip TransactionMiddleware.
+       r.GET("/accounts/:accountNo", accountHandler)
+       // Debit participates in the global XA branch; middleware injects the 
global XID into request context.
+       r.Use(ginmiddleware.TransactionMiddleware())
+       r.POST("/debit", debitHandler)
+
+       addr := getenv("BANK_A_ADDR", defaultListenAddr)
+       if err := r.Run(addr); err != nil {
+               panic(fmt.Sprintf("start bank-a-service failed: %v", err))
+       }
+}
+
+func debitHandler(c *gin.Context) {
+       var req debitRequest
+       if err := c.ShouldBindJSON(&req); err != nil {
+               c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+               return
+       }
+
+       if err := debit(c.Request.Context(), req.AccountNo, req.Amount); err != 
nil {
+               c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+               return
+       }
+       c.JSON(http.StatusOK, gin.H{"status": "debited"})
+}
+
+func accountHandler(c *gin.Context) {
+       acc, err := getAccount(c.Request.Context(), c.Param("accountNo"))
+       if err != nil {
+               c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+               return
+       }
+       c.JSON(http.StatusOK, acc)
+}
+
+func debit(ctx context.Context, accountNo string, amount int64) error {
+       ret, err := db.ExecContext(ctx,
+               "update account_tbl set balance = balance - ? where account_no 
= ? and balance >= ?",
+               amount, accountNo, amount,
+       )
+       if err != nil {
+               return fmt.Errorf("debit account %s failed: %w", accountNo, err)
+       }
+
+       rows, err := ret.RowsAffected()
+       if err != nil {
+               return fmt.Errorf("read debit affected rows failed: %w", err)
+       }
+       if rows == 0 {
+               return fmt.Errorf("account %s has insufficient balance", 
accountNo)
+       }
+       return nil
+}
+
+func getAccount(ctx context.Context, accountNo string) (*account, error) {
+       var acc account
+       err := db.QueryRowContext(ctx, "select account_no, balance from 
account_tbl where account_no = ?", accountNo).
+               Scan(&acc.AccountNo, &acc.Balance)
+       if errors.Is(err, sql.ErrNoRows) {
+               return nil, fmt.Errorf("account %s not found", accountNo)
+       }
+       if err != nil {
+               return nil, err
+       }
+       return &acc, nil
+}
+
+func openXADB() *sql.DB {
+       dsn := 
fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?multiStatements=true&interpolateParams=true",
+               getenv("BANK_A_MYSQL_USERNAME", "root"),
+               getenv("BANK_A_MYSQL_PASSWORD", "123456"),
+               getenv("BANK_A_MYSQL_HOST", "127.0.0.1"),
+               getenv("BANK_A_MYSQL_PORT", "3307"),
+               getenv("BANK_A_MYSQL_DB", "db_bank_a"),
+       )
+       db, err := sql.Open(sql2.SeataXAMySQLDriver, dsn)
+       if err != nil {
+               panic(fmt.Sprintf("open bank-a XA datasource failed: %v", err))
+       }
+       if err := db.Ping(); err != nil {
+               panic(fmt.Sprintf("ping bank-a datasource failed: %v", err))
+       }
+       db.SetMaxOpenConns(20)
+       db.SetMaxIdleConns(5)
+       db.SetConnMaxLifetime(0)
+       return db
+}
+
+func resolveSeataConfig() string {
+       if path := os.Getenv("SEATA_CONFIG"); path != "" {
+               return path
+       }
+       for _, path := range []string{"./conf/seatago.yml", 
"../../../conf/seatago.yml"} {
+               if _, err := os.Stat(path); err == nil {
+                       return path
+               }
+       }
+       return "./conf/seatago.yml"
+}
+
+func getenv(key, fallback string) string {
+       if value := os.Getenv(key); value != "" {
+               return value
+       }
+       return fallback
+}
diff --git a/xa/bank_transfer/bank-b-service/main.go 
b/xa/bank_transfer/bank-b-service/main.go
new file mode 100644
index 0000000..9172e8c
--- /dev/null
+++ b/xa/bank_transfer/bank-b-service/main.go
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package main
+
+import (
+       "context"
+       "database/sql"
+       "errors"
+       "fmt"
+       "net/http"
+       "os"
+
+       "github.com/gin-gonic/gin"
+       "seata.apache.org/seata-go/pkg/client"
+       sql2 "seata.apache.org/seata-go/pkg/datasource/sql"
+       ginmiddleware "seata.apache.org/seata-go/pkg/integration/gin"
+)
+
+const defaultListenAddr = ":18082"
+
+var db *sql.DB
+
+type creditRequest struct {
+       AccountNo string `json:"account_no" binding:"required"`
+       Amount    int64  `json:"amount" binding:"required,gt=0"`
+}
+
+type account struct {
+       AccountNo string `json:"account_no"`
+       Balance   int64  `json:"balance"`
+       Frozen    bool   `json:"frozen"`
+}
+
+func main() {
+       client.InitPath(resolveSeataConfig())
+       db = openXADB()
+       defer db.Close()
+
+       r := gin.Default()
+       r.ContextWithFallback = true
+       // Read-only routes registered before Use() skip TransactionMiddleware.
+       r.GET("/accounts/:accountNo", accountHandler)
+       // Credit participates in the global XA branch; middleware injects the 
global XID into request context.
+       r.Use(ginmiddleware.TransactionMiddleware())
+       r.POST("/credit", creditHandler)
+
+       addr := getenv("BANK_B_ADDR", defaultListenAddr)
+       if err := r.Run(addr); err != nil {
+               panic(fmt.Sprintf("start bank-b-service failed: %v", err))
+       }
+}
+
+func creditHandler(c *gin.Context) {
+       var req creditRequest
+       if err := c.ShouldBindJSON(&req); err != nil {
+               c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+               return
+       }
+
+       if err := credit(c.Request.Context(), req.AccountNo, req.Amount); err 
!= nil {
+               c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+               return
+       }
+       c.JSON(http.StatusOK, gin.H{"status": "credited"})
+}
+
+func accountHandler(c *gin.Context) {
+       acc, err := getAccount(c.Request.Context(), c.Param("accountNo"))
+       if err != nil {
+               c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+               return
+       }
+       c.JSON(http.StatusOK, acc)
+}
+
+func credit(ctx context.Context, accountNo string, amount int64) error {
+       ret, err := db.ExecContext(ctx,
+               "update account_tbl set balance = balance + ? where account_no 
= ? and frozen = 0",
+               amount, accountNo,
+       )
+       if err != nil {
+               return fmt.Errorf("credit account %s failed: %w", accountNo, 
err)
+       }
+
+       rows, err := ret.RowsAffected()
+       if err != nil {
+               return fmt.Errorf("read credit affected rows failed: %w", err)
+       }
+       if rows == 0 {
+               acc, lookupErr := getAccount(context.Background(), accountNo)
+               if lookupErr != nil {
+                       return lookupErr
+               }
+               if acc.Frozen {
+                       return fmt.Errorf("account %s is frozen and rejects 
credit", accountNo)
+               }
+               return fmt.Errorf("account %s did not accept credit", accountNo)
+       }
+       return nil
+}
+
+func getAccount(ctx context.Context, accountNo string) (*account, error) {
+       var acc account
+       err := db.QueryRowContext(ctx, "select account_no, balance, frozen from 
account_tbl where account_no = ?", accountNo).
+               Scan(&acc.AccountNo, &acc.Balance, &acc.Frozen)
+       if errors.Is(err, sql.ErrNoRows) {
+               return nil, fmt.Errorf("account %s not found", accountNo)
+       }
+       if err != nil {
+               return nil, err
+       }
+       return &acc, nil
+}
+
+func openXADB() *sql.DB {
+       dsn := 
fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?multiStatements=true&interpolateParams=true",
+               getenv("BANK_B_MYSQL_USERNAME", "root"),
+               getenv("BANK_B_MYSQL_PASSWORD", "123456"),
+               getenv("BANK_B_MYSQL_HOST", "127.0.0.1"),
+               getenv("BANK_B_MYSQL_PORT", "3308"),
+               getenv("BANK_B_MYSQL_DB", "db_bank_b"),
+       )
+       db, err := sql.Open(sql2.SeataXAMySQLDriver, dsn)
+       if err != nil {
+               panic(fmt.Sprintf("open bank-b XA datasource failed: %v", err))
+       }
+       if err := db.Ping(); err != nil {
+               panic(fmt.Sprintf("ping bank-b datasource failed: %v", err))
+       }
+       db.SetMaxOpenConns(20)
+       db.SetMaxIdleConns(5)
+       db.SetConnMaxLifetime(0)
+       return db
+}
+
+func resolveSeataConfig() string {
+       if path := os.Getenv("SEATA_CONFIG"); path != "" {
+               return path
+       }
+       for _, path := range []string{"./conf/seatago.yml", 
"../../../conf/seatago.yml"} {
+               if _, err := os.Stat(path); err == nil {
+                       return path
+               }
+       }
+       return "./conf/seatago.yml"
+}
+
+func getenv(key, fallback string) string {
+       if value := os.Getenv(key); value != "" {
+               return value
+       }
+       return fallback
+}
diff --git a/xa/bank_transfer/docker-compose.yml 
b/xa/bank_transfer/docker-compose.yml
new file mode 100644
index 0000000..a2f797e
--- /dev/null
+++ b/xa/bank_transfer/docker-compose.yml
@@ -0,0 +1,48 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+services:
+  seata-server:
+    image: seataio/seata-server:1.6.1
+    ports:
+      - "8091:8091"
+      - "7091:7091"
+    environment:
+      - SEATA_PORT=8091
+      - STORE_MODE=file
+
+  mysql-bank-a:
+    image: mysql:8.0.32
+    container_name: mysql-bank-a
+    environment:
+      - MYSQL_ROOT_PASSWORD=123456
+    command: --default-authentication-plugin=mysql_native_password 
--default-time-zone='+08:00'
+    volumes:
+      - ./sql/bank_a.sql:/docker-entrypoint-initdb.d/bank_a.sql
+    ports:
+      - "3307:3306"
+
+  mysql-bank-b:
+    image: mysql:8.0.32
+    container_name: mysql-bank-b
+    environment:
+      - MYSQL_ROOT_PASSWORD=123456
+    command: --default-authentication-plugin=mysql_native_password 
--default-time-zone='+08:00'
+    volumes:
+      - ./sql/bank_b.sql:/docker-entrypoint-initdb.d/bank_b.sql
+    ports:
+      - "3308:3306"
diff --git a/xa/bank_transfer/sql/bank_a.sql b/xa/bank_transfer/sql/bank_a.sql
new file mode 100644
index 0000000..97b1c89
--- /dev/null
+++ b/xa/bank_transfer/sql/bank_a.sql
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+CREATE DATABASE IF NOT EXISTS db_bank_a DEFAULT CHARACTER SET utf8mb4 COLLATE 
utf8mb4_unicode_ci;
+USE db_bank_a;
+
+CREATE TABLE IF NOT EXISTS account_tbl (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  account_no VARCHAR(64) NOT NULL,
+  balance BIGINT NOT NULL DEFAULT 0,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_account_no (account_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO account_tbl (account_no, balance)
+VALUES ('A-1001', 1000)
+ON DUPLICATE KEY UPDATE balance = VALUES(balance);
diff --git a/xa/bank_transfer/sql/bank_b.sql b/xa/bank_transfer/sql/bank_b.sql
new file mode 100644
index 0000000..8640b50
--- /dev/null
+++ b/xa/bank_transfer/sql/bank_b.sql
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+CREATE DATABASE IF NOT EXISTS db_bank_b DEFAULT CHARACTER SET utf8mb4 COLLATE 
utf8mb4_unicode_ci;
+USE db_bank_b;
+
+CREATE TABLE IF NOT EXISTS account_tbl (
+  id BIGINT NOT NULL AUTO_INCREMENT,
+  account_no VARCHAR(64) NOT NULL,
+  balance BIGINT NOT NULL DEFAULT 0,
+  frozen TINYINT(1) NOT NULL DEFAULT 0,
+  PRIMARY KEY (id),
+  UNIQUE KEY uk_account_no (account_no)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO account_tbl (account_no, balance, frozen)
+VALUES
+  ('B-2001', 500, 0),
+  ('B-FROZEN', 500, 1)
+ON DUPLICATE KEY UPDATE balance = VALUES(balance), frozen = VALUES(frozen);
diff --git a/xa/bank_transfer/transfer-service/main.go 
b/xa/bank_transfer/transfer-service/main.go
new file mode 100644
index 0000000..b981b3d
--- /dev/null
+++ b/xa/bank_transfer/transfer-service/main.go
@@ -0,0 +1,152 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package main
+
+import (
+       "bytes"
+       "context"
+       "encoding/json"
+       "fmt"
+       "io"
+       "net/http"
+       "os"
+       "time"
+
+       "github.com/gin-gonic/gin"
+       "seata.apache.org/seata-go/pkg/client"
+       "seata.apache.org/seata-go/pkg/constant"
+       "seata.apache.org/seata-go/pkg/tm"
+)
+
+const defaultListenAddr = ":18080"
+
+var httpClient = &http.Client{Timeout: 10 * time.Second}
+
+type transferRequest struct {
+       FromAccountNo string `json:"from_account_no" binding:"required"`
+       ToAccountNo   string `json:"to_account_no" binding:"required"`
+       Amount        int64  `json:"amount" binding:"required,gt=0"`
+}
+
+func main() {
+       client.InitPath(resolveSeataConfig())
+
+       r := gin.Default()
+       r.POST("/transfer", func(c *gin.Context) {
+               var req transferRequest
+               if err := c.ShouldBindJSON(&req); err != nil {
+                       c.JSON(http.StatusBadRequest, gin.H{"error": 
err.Error()})
+                       return
+               }
+               executeTransfer(c, req)
+       })
+       r.POST("/transfer/success", func(c *gin.Context) {
+               executeTransfer(c, transferRequest{
+                       FromAccountNo: "A-1001",
+                       ToAccountNo:   "B-2001",
+                       Amount:        100,
+               })
+       })
+       r.POST("/transfer/fail", func(c *gin.Context) {
+               executeTransfer(c, transferRequest{
+                       FromAccountNo: "A-1001",
+                       ToAccountNo:   "B-FROZEN",
+                       Amount:        100,
+               })
+       })
+
+       addr := getenv("TRANSFER_ADDR", defaultListenAddr)
+       if err := r.Run(addr); err != nil {
+               panic(fmt.Sprintf("start transfer-service failed: %v", err))
+       }
+}
+
+func executeTransfer(c *gin.Context, req transferRequest) {
+       err := tm.WithGlobalTx(c.Request.Context(), &tm.GtxConfig{
+               Name:    "XABankTransfer",
+               Timeout: 30 * time.Second,
+       }, func(txCtx context.Context) error {
+               if err := postJSON(txCtx, getenv("BANK_A_URL", 
"http://127.0.0.1:18081";)+"/debit", map[string]any{
+                       "account_no": req.FromAccountNo,
+                       "amount":     req.Amount,
+               }); err != nil {
+                       return err
+               }
+
+               if err := postJSON(txCtx, getenv("BANK_B_URL", 
"http://127.0.0.1:18082";)+"/credit", map[string]any{
+                       "account_no": req.ToAccountNo,
+                       "amount":     req.Amount,
+               }); err != nil {
+                       return err
+               }
+               return nil
+       })
+       if err != nil {
+               c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+               return
+       }
+       c.JSON(http.StatusOK, gin.H{"status": "committed"})
+}
+
+func postJSON(ctx context.Context, url string, payload map[string]any) error {
+       body, err := json.Marshal(payload)
+       if err != nil {
+               return err
+       }
+
+       req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, 
bytes.NewReader(body))
+       if err != nil {
+               return err
+       }
+       req.Header.Set("Content-Type", "application/json")
+       req.Header.Set(constant.XidKey, tm.GetXID(ctx))
+
+       resp, err := httpClient.Do(req)
+       if err != nil {
+               return fmt.Errorf("post %s failed: %w", url, err)
+       }
+       defer resp.Body.Close()
+
+       respBody, err := io.ReadAll(resp.Body)
+       if err != nil {
+               return fmt.Errorf("post %s failed to read response body: %w", 
url, err)
+       }
+       if resp.StatusCode < http.StatusOK || resp.StatusCode >= 
http.StatusMultipleChoices {
+               return fmt.Errorf("post %s returned %s: %s", url, resp.Status, 
string(respBody))
+       }
+       return nil
+}
+
+func resolveSeataConfig() string {
+       if path := os.Getenv("SEATA_CONFIG"); path != "" {
+               return path
+       }
+       for _, path := range []string{"./conf/seatago.yml", 
"../../../conf/seatago.yml"} {
+               if _, err := os.Stat(path); err == nil {
+                       return path
+               }
+       }
+       return "./conf/seatago.yml"
+}
+
+func getenv(key, fallback string) string {
+       if value := os.Getenv(key); value != "" {
+               return value
+       }
+       return fallback
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to