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]