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 8c80750 Feat/sample at ecommerce platform (#95)
8c80750 is described below
commit 8c8075004d7af662d9ad4913bca8271b3d8b0f90
Author: AsperforMias <[email protected]>
AuthorDate: Sat Jul 4 16:27:02 2026 +0800
Feat/sample at ecommerce platform (#95)
* feat: part of payment order tx
* feat: add inventoty for goods
* fix: flaw of path
* chore: rm unused test file
* fix: support rollback of tx
* feat: add account part for platform
* update: complete basic sql part
* feat: complete docker compose for startup
* docs: readme for E-commere sample
* docs: correct flaw of readme
* fix: complete the issue of copilot review
* fix(ecommerce): address copilot review findings
* fix(ecommerce): address latest copilot review
* fix(ecommerce): refine latest copilot review follow-ups
* fix(ecommerce): address newest copilot findings
* fix(ecommerce): address latest copilot comments
* fix(ecommerce): add env example license header
---
at/ecommerce/.env.example | 18 ++++
at/ecommerce/README.md | 132 +++++++++++++++++++++++++
at/ecommerce/account/deduct.go | 59 ++++++++++++
at/ecommerce/account/main.go | 58 +++++++++++
at/ecommerce/docker-compose.yml | 46 +++++++++
at/ecommerce/inventory/deduct.go | 59 ++++++++++++
at/ecommerce/inventory/main.go | 58 +++++++++++
at/ecommerce/order/create.go | 180 +++++++++++++++++++++++++++++++++++
at/ecommerce/order/main.go | 66 +++++++++++++
at/ecommerce/sql/mysql_ecommerce.sql | 100 +++++++++++++++++++
util/db.go | 39 +++++---
util/http.go | 113 ++++++++++++++++++++++
12 files changed, 915 insertions(+), 13 deletions(-)
diff --git a/at/ecommerce/.env.example b/at/ecommerce/.env.example
new file mode 100644
index 0000000..e4fdd66
--- /dev/null
+++ b/at/ecommerce/.env.example
@@ -0,0 +1,18 @@
+#
+# 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.
+#
+
+MYSQL_ROOT_PASSWORD=replace-with-a-strong-password
diff --git a/at/ecommerce/README.md b/at/ecommerce/README.md
new file mode 100644
index 0000000..5abc273
--- /dev/null
+++ b/at/ecommerce/README.md
@@ -0,0 +1,132 @@
+<!--
+ 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.
+-->
+
+# AT E-commerce Sample
+
+This sample demonstrates an e-commerce order flow in Seata Go AT mode.
+
+The scenario contains three independent Go services:
+
+1. `order-service` creates the order record and starts the global transaction
+2. `inventory-service` deducts stock
+3. `account-service` deducts the user balance
+
+All three operations must succeed or fail together. If the account balance is
insufficient, `account-service` rejects the request and Seata automatically
rolls back the order creation and inventory deduction through `undo_log`.
+
+## Directory Layout
+
+- `order/`: `order-service`
+- `inventory/`: `inventory-service`
+- `account/`: `account-service`
+- `sql/mysql_ecommerce.sql`: schema and seed data for the three MySQL databases
+- `docker-compose.yml`: MySQL and Seata Server
+
+## Start the Infrastructure
+
+Configure the MySQL root password first:
+
+```bash
+cd at/ecommerce
+cp .env.example .env
+```
+
+Edit `.env` and set a non-trivial `MYSQL_ROOT_PASSWORD` before starting the
sample. The compose file now requires this value explicitly.
+
+For the local Go services, export either `MYSQL_PASSWORD` or
`MYSQL_ROOT_PASSWORD` with the same value. If `MYSQL_PASSWORD` is unset, the
sample falls back to `MYSQL_ROOT_PASSWORD` automatically:
+
+```bash
+export MYSQL_ROOT_PASSWORD=your-password
+# optional:
+export MYSQL_PASSWORD="$MYSQL_ROOT_PASSWORD"
+```
+
+```bash
+cd at/ecommerce
+docker-compose up -d
+```
+
+Default ports:
+
+- MySQL: `3306`
+- Seata Server: `8091`
+- order-service: `18080`
+- inventory-service: `18081`
+- account-service: `18082`
+
+## Start the Three Services
+
+From the repository root, open three terminals and run:
+
+```bash
+go run ./at/ecommerce/order
+go run ./at/ecommerce/inventory
+go run ./at/ecommerce/account
+```
+
+The services use `conf/seatago.yml`, so run them from the repository root as
shown above.
+
+## Run the Success Scenario
+
+The account seed balance is `50`, so a `money` value below that limit commits
successfully:
+
+```bash
+curl -X POST http://127.0.0.1:18080/createOrder \
+ -H 'Content-Type: application/json' \
+ -d '{"userId":"U100001","commodityCode":"C100001","count":2,"money":30}'
+```
+
+Expected result:
+
+- a new row is inserted into `seata_ecommerce_order.order_tbl`
+- `seata_ecommerce_inventory.inventory_tbl.stock` decreases from `100` to `98`
+- `seata_ecommerce_account.account_tbl.balance` decreases from `50` to `20`
+
+## Run the Rollback Scenario
+
+This request exceeds the seed balance and makes `account-service` reject the
deduction:
+
+```bash
+curl -X POST http://127.0.0.1:18080/createOrder \
+ -H 'Content-Type: application/json' \
+ -d '{"userId":"U100001","commodityCode":"C100001","count":2,"money":100}'
+```
+
+Expected result:
+
+- `account-service` returns `insufficient balance for userId U100001`
+- the global transaction fails in `order-service`
+- `seata_ecommerce_order.order_tbl` does not gain a new committed row
+- `seata_ecommerce_inventory.inventory_tbl.stock` remains unchanged from its
value before this request
+- `seata_ecommerce_account.account_tbl.balance` remains unchanged from its
value before this request
+
+## Reset the Demo Data
+
+If you want to re-run the checks from the initial database state, recreate the
sample containers and volumes:
+
+```bash
+cd at/ecommerce
+docker-compose down -v
+docker-compose up -d
+```
+
+## Verify in MySQL
+
+```bash
+mysql -h127.0.0.1 -P3306 -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SELECT * FROM
seata_ecommerce_order.order_tbl;"
+mysql -h127.0.0.1 -P3306 -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SELECT * FROM
seata_ecommerce_inventory.inventory_tbl;"
+mysql -h127.0.0.1 -P3306 -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SELECT * FROM
seata_ecommerce_account.account_tbl;"
+```
diff --git a/at/ecommerce/account/deduct.go b/at/ecommerce/account/deduct.go
new file mode 100644
index 0000000..c4e2c1b
--- /dev/null
+++ b/at/ecommerce/account/deduct.go
@@ -0,0 +1,59 @@
+/*
+ * 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 (
+ "fmt"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "seata.apache.org/seata-go-samples/util"
+)
+
+type AccountRequest struct {
+ UserID string `json:"userId"`
+ Money int `json:"money"`
+}
+
+func deductAccount(c *gin.Context) error {
+ var req AccountRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ return util.NewValidationError(err.Error())
+ }
+ if strings.TrimSpace(req.UserID) == "" {
+ return util.NewValidationError("userId is required")
+ }
+ if req.Money <= 0 {
+ return util.NewValidationError("money must be greater than 0")
+ }
+
+ query := "update account_tbl set balance = balance - ? where user_id =
? and balance >= ?"
+ ret, err := db.ExecContext(c.Request.Context(), query, req.Money,
req.UserID, req.Money)
+ if err != nil {
+ return err
+ }
+
+ rows, err := ret.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return util.NewConflictError(fmt.Sprintf("insufficient balance
for userId %s", req.UserID))
+ }
+ return nil
+}
diff --git a/at/ecommerce/account/main.go b/at/ecommerce/account/main.go
new file mode 100644
index 0000000..8f6df35
--- /dev/null
+++ b/at/ecommerce/account/main.go
@@ -0,0 +1,58 @@
+/*
+ * 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 (
+ "database/sql"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "seata.apache.org/seata-go-samples/util"
+ "seata.apache.org/seata-go/pkg/client"
+ ginmiddleware "seata.apache.org/seata-go/pkg/integration/gin"
+ "seata.apache.org/seata-go/pkg/util/log"
+)
+
+var db *sql.DB
+
+func main() {
+ client.InitPath("conf/seatago.yml")
+ if err := util.SetDefaultEnv("MYSQL_DB", "seata_ecommerce_account");
err != nil {
+ log.Fatalf("set MYSQL_DB default error: %v", err)
+ }
+ db = util.GetAtMySqlDb()
+
+ r := gin.Default()
+ r.ContextWithFallback = true
+ r.Use(ginmiddleware.TransactionMiddleware())
+ r.POST("/deductAccount", deductAccountHandler)
+
+ if err := r.Run(":18082"); err != nil {
+ log.Fatalf("start account service fatal: %v", err)
+ }
+}
+
+func deductAccountHandler(c *gin.Context) {
+ log.Infof("receive deduct account request")
+ if err := deductAccount(c); err != nil {
+ log.Errorf("deduct account failed: %v", err)
+ c.JSON(util.StatusCodeForError(err), util.APIResponse{Error:
util.PublicErrorMessage(err)})
+ return
+ }
+ c.JSON(http.StatusOK, util.APIResponse{Message: "deduct account ok"})
+}
diff --git a/at/ecommerce/docker-compose.yml b/at/ecommerce/docker-compose.yml
new file mode 100644
index 0000000..8dd8743
--- /dev/null
+++ b/at/ecommerce/docker-compose.yml
@@ -0,0 +1,46 @@
+# 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:
+ mysql:
+ image: mysql:8.0.32
+ container_name: ecommerce_at_mysql
+ environment:
+ MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in
.env or environment}
+ command: --default-authentication-plugin=mysql_native_password
--default-time-zone='+08:00'
+ healthcheck:
+ test: ["CMD-SHELL", "mysqladmin ping -h127.0.0.1 -uroot
-p$${MYSQL_ROOT_PASSWORD} --silent"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+ start_period: 10s
+ volumes:
+ -
./sql/mysql_ecommerce.sql:/docker-entrypoint-initdb.d/1_ecommerce.sql:ro
+ -
../../dockercompose/mysql/mysqld.cnf:/etc/mysql/mysql.conf.d/mysqld.cnf:ro
+ ports:
+ - "127.0.0.1:3306:3306"
+
+ seata-server:
+ image: seataio/seata-server:1.6.1
+ container_name: ecommerce_at_seata_server
+ environment:
+ - SEATA_PORT=8091
+ - STORE_MODE=file
+ ports:
+ - "8091:8091"
+ - "7091:7091"
+ depends_on:
+ mysql:
+ condition: service_healthy
diff --git a/at/ecommerce/inventory/deduct.go b/at/ecommerce/inventory/deduct.go
new file mode 100644
index 0000000..23fe15b
--- /dev/null
+++ b/at/ecommerce/inventory/deduct.go
@@ -0,0 +1,59 @@
+/*
+ * 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 (
+ "fmt"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "seata.apache.org/seata-go-samples/util"
+)
+
+type InventoryRequest struct {
+ CommodityCode string `json:"commodityCode"`
+ Count int `json:"count"`
+}
+
+func deductInventory(c *gin.Context) error {
+ var req InventoryRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ return util.NewValidationError(err.Error())
+ }
+ if strings.TrimSpace(req.CommodityCode) == "" {
+ return util.NewValidationError("commodityCode is required")
+ }
+ if req.Count <= 0 {
+ return util.NewValidationError("count must be greater than 0")
+ }
+
+ query := "update inventory_tbl set stock = stock - ? where
commodity_code = ? and stock >= ?"
+ ret, err := db.ExecContext(c.Request.Context(), query, req.Count,
req.CommodityCode, req.Count)
+ if err != nil {
+ return err
+ }
+
+ rows, err := ret.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return util.NewConflictError(fmt.Sprintf("insufficient
inventory for commodityCode %s", req.CommodityCode))
+ }
+ return nil
+}
diff --git a/at/ecommerce/inventory/main.go b/at/ecommerce/inventory/main.go
new file mode 100644
index 0000000..2e7cb20
--- /dev/null
+++ b/at/ecommerce/inventory/main.go
@@ -0,0 +1,58 @@
+/*
+ * 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 (
+ "database/sql"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "seata.apache.org/seata-go-samples/util"
+ "seata.apache.org/seata-go/pkg/client"
+ ginmiddleware "seata.apache.org/seata-go/pkg/integration/gin"
+ "seata.apache.org/seata-go/pkg/util/log"
+)
+
+var db *sql.DB
+
+func main() {
+ client.InitPath("conf/seatago.yml")
+ if err := util.SetDefaultEnv("MYSQL_DB", "seata_ecommerce_inventory");
err != nil {
+ log.Fatalf("set MYSQL_DB default error: %v", err)
+ }
+ db = util.GetAtMySqlDb()
+
+ r := gin.Default()
+ r.ContextWithFallback = true
+ r.Use(ginmiddleware.TransactionMiddleware())
+ r.POST("/deductInventory", deductInventoryHandler)
+
+ if err := r.Run(":18081"); err != nil {
+ log.Fatalf("start inventory service fatal: %v", err)
+ }
+}
+
+func deductInventoryHandler(c *gin.Context) {
+ log.Infof("receive deduct inventory request")
+ if err := deductInventory(c); err != nil {
+ log.Errorf("deduct inventory failed: %v", err)
+ c.JSON(util.StatusCodeForError(err), util.APIResponse{Error:
util.PublicErrorMessage(err)})
+ return
+ }
+ c.JSON(http.StatusOK, util.APIResponse{Message: "deduct inventory ok"})
+}
diff --git a/at/ecommerce/order/create.go b/at/ecommerce/order/create.go
new file mode 100644
index 0000000..4e428ab
--- /dev/null
+++ b/at/ecommerce/order/create.go
@@ -0,0 +1,180 @@
+/*
+ * 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"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "seata.apache.org/seata-go-samples/util"
+ "seata.apache.org/seata-go/pkg/constant"
+ "seata.apache.org/seata-go/pkg/tm"
+ "seata.apache.org/seata-go/pkg/util/log"
+)
+
+type OrderRequest struct {
+ UserID string `json:"userId"`
+ CommodityCode string `json:"commodityCode"`
+ Count int `json:"count"`
+ Money int `json:"money"`
+}
+
+type InventoryRequest struct {
+ CommodityCode string `json:"commodityCode"`
+ Count int `json:"count"`
+}
+
+type AccountRequest struct {
+ UserID string `json:"userId"`
+ Money int `json:"money"`
+}
+
+func createOrder(c *gin.Context) error {
+ var req OrderRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ return util.NewValidationError(err.Error())
+ }
+ if strings.TrimSpace(req.UserID) == "" {
+ return util.NewValidationError("userId is required")
+ }
+ if strings.TrimSpace(req.CommodityCode) == "" {
+ return util.NewValidationError("commodityCode is required")
+ }
+ if req.Count <= 0 {
+ return util.NewValidationError("count must be greater than 0")
+ }
+ if req.Money <= 0 {
+ return util.NewValidationError("money must be greater than 0")
+ }
+
+ return tm.WithGlobalTx(c.Request.Context(), &tm.GtxConfig{
+ Name: "ATSampleEcommerceCreateOrder",
+ Timeout: time.Second * 30,
+ }, func(ctx context.Context) error {
+ if err := insertOrder(ctx, req); err != nil {
+ return err
+ }
+ if err := deductInventory(ctx, req); err != nil {
+ return err
+ }
+ if err := deductAccount(ctx, req); err != nil {
+ return err
+ }
+ return nil
+ })
+}
+
+func insertOrder(ctx context.Context, req OrderRequest) error {
+ query := "insert into order_tbl(user_id, commodity_code, count, money,
status) values (?, ?, ?, ?, ?)"
+ ret, err := db.ExecContext(ctx, query, req.UserID, req.CommodityCode,
req.Count, req.Money, "CREATED")
+ if err != nil {
+ return err
+ }
+
+ rows, err := ret.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return fmt.Errorf("create order affected unexpected rows: %d",
rows)
+ }
+ return nil
+}
+
+func deductInventory(ctx context.Context, req OrderRequest) error {
+ payload, err := json.Marshal(InventoryRequest{
+ CommodityCode: req.CommodityCode,
+ Count: req.Count,
+ })
+ if err != nil {
+ return err
+ }
+
+ log.Infof("call inventory service, xid=%s", tm.GetXID(ctx))
+ return postJSON(ctx, inventoryService+"/deductInventory", payload)
+}
+
+func deductAccount(ctx context.Context, req OrderRequest) error {
+ payload, err := json.Marshal(AccountRequest{
+ UserID: req.UserID,
+ Money: req.Money,
+ })
+ if err != nil {
+ return err
+ }
+
+ log.Infof("call account service, xid=%s", tm.GetXID(ctx))
+ return postJSON(ctx, accountService+"/deductAccount", payload)
+}
+
+func postJSON(ctx context.Context, url string, payload []byte) error {
+ requestCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+
+ httpReq, err := http.NewRequestWithContext(requestCtx, http.MethodPost,
url, bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ httpReq.Header.Set(constant.XidKey, tm.GetXID(ctx))
+ httpReq.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(httpReq)
+ if err != nil {
+ return util.NewDownstreamError(0, fmt.Sprintf("request %s
failed: %v", url, err))
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusOK {
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024))
+ return nil
+ }
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ message := parseAPIResponseMessage(body)
+ if resp.StatusCode == http.StatusConflict {
+ return util.NewConflictError(message)
+ }
+
+ return util.NewDownstreamError(resp.StatusCode, message)
+}
+
+func parseAPIResponseMessage(body []byte) string {
+ message := strings.TrimSpace(string(body))
+
+ var response util.APIResponse
+ if err := json.Unmarshal(body, &response); err != nil {
+ return message
+ }
+ if response.Error != "" {
+ return response.Error
+ }
+ if response.Message != "" {
+ return response.Message
+ }
+ return message
+}
diff --git a/at/ecommerce/order/main.go b/at/ecommerce/order/main.go
new file mode 100644
index 0000000..cd34730
--- /dev/null
+++ b/at/ecommerce/order/main.go
@@ -0,0 +1,66 @@
+/*
+ * 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 (
+ "database/sql"
+ "net/http"
+ "os"
+
+ "github.com/gin-gonic/gin"
+ "seata.apache.org/seata-go-samples/util"
+ "seata.apache.org/seata-go/pkg/client"
+ "seata.apache.org/seata-go/pkg/util/log"
+)
+
+var (
+ db *sql.DB
+ inventoryService = "http://127.0.0.1:18081"
+ accountService = "http://127.0.0.1:18082"
+)
+
+func main() {
+ client.InitPath("conf/seatago.yml")
+ if err := util.SetDefaultEnv("MYSQL_DB", "seata_ecommerce_order"); err
!= nil {
+ log.Fatalf("set MYSQL_DB default error: %v", err)
+ }
+ if value := os.Getenv("INVENTORY_SERVICE_URL"); value != "" {
+ inventoryService = value
+ }
+ if value := os.Getenv("ACCOUNT_SERVICE_URL"); value != "" {
+ accountService = value
+ }
+ db = util.GetAtMySqlDb()
+
+ r := gin.Default()
+ r.POST("/createOrder", createOrderHandler)
+
+ if err := r.Run(":18080"); err != nil {
+ log.Fatalf("start order service fatal: %v", err)
+ }
+}
+
+func createOrderHandler(c *gin.Context) {
+ log.Infof("receive create order request")
+ if err := createOrder(c); err != nil {
+ log.Errorf("create order failed: %v", err)
+ c.JSON(util.StatusCodeForError(err), util.APIResponse{Error:
util.PublicErrorMessage(err)})
+ return
+ }
+ c.JSON(http.StatusOK, util.APIResponse{Message: "create order ok"})
+}
diff --git a/at/ecommerce/sql/mysql_ecommerce.sql
b/at/ecommerce/sql/mysql_ecommerce.sql
new file mode 100644
index 0000000..5b1594d
--- /dev/null
+++ b/at/ecommerce/sql/mysql_ecommerce.sql
@@ -0,0 +1,100 @@
+/*
+ * 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 seata_ecommerce_order DEFAULT CHARACTER SET
utf8mb4 COLLATE utf8mb4_unicode_ci;
+CREATE DATABASE IF NOT EXISTS seata_ecommerce_inventory DEFAULT CHARACTER SET
utf8mb4 COLLATE utf8mb4_unicode_ci;
+CREATE DATABASE IF NOT EXISTS seata_ecommerce_account DEFAULT CHARACTER SET
utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+USE seata_ecommerce_order;
+
+CREATE TABLE IF NOT EXISTS order_tbl (
+ id INT NOT NULL AUTO_INCREMENT,
+ user_id VARCHAR(64) NOT NULL,
+ commodity_code VARCHAR(64) NOT NULL,
+ count INT NOT NULL,
+ money INT NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS undo_log (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ branch_id BIGINT NOT NULL,
+ xid VARCHAR(100) NOT NULL,
+ context VARCHAR(128) NOT NULL,
+ rollback_info LONGBLOB NOT NULL,
+ log_status INT NOT NULL,
+ log_created DATETIME NOT NULL,
+ log_modified DATETIME NOT NULL,
+ ext VARCHAR(100) DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY ux_undo_log (xid, branch_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+USE seata_ecommerce_inventory;
+
+CREATE TABLE IF NOT EXISTS inventory_tbl (
+ id INT NOT NULL AUTO_INCREMENT,
+ commodity_code VARCHAR(64) NOT NULL,
+ stock INT NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_commodity_code (commodity_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO inventory_tbl (commodity_code, stock) VALUES ('C100001', 100) AS
new
+ON DUPLICATE KEY UPDATE stock = new.stock;
+
+CREATE TABLE IF NOT EXISTS undo_log (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ branch_id BIGINT NOT NULL,
+ xid VARCHAR(100) NOT NULL,
+ context VARCHAR(128) NOT NULL,
+ rollback_info LONGBLOB NOT NULL,
+ log_status INT NOT NULL,
+ log_created DATETIME NOT NULL,
+ log_modified DATETIME NOT NULL,
+ ext VARCHAR(100) DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY ux_undo_log (xid, branch_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+USE seata_ecommerce_account;
+
+CREATE TABLE IF NOT EXISTS account_tbl (
+ id INT NOT NULL AUTO_INCREMENT,
+ user_id VARCHAR(64) NOT NULL,
+ balance INT NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uk_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO account_tbl (user_id, balance) VALUES ('U100001', 50) AS new
+ON DUPLICATE KEY UPDATE balance = new.balance;
+
+CREATE TABLE IF NOT EXISTS undo_log (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ branch_id BIGINT NOT NULL,
+ xid VARCHAR(100) NOT NULL,
+ context VARCHAR(128) NOT NULL,
+ rollback_info LONGBLOB NOT NULL,
+ log_status INT NOT NULL,
+ log_created DATETIME NOT NULL,
+ log_modified DATETIME NOT NULL,
+ ext VARCHAR(100) DEFAULT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY ux_undo_log (xid, branch_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
diff --git a/util/db.go b/util/db.go
index 4ef4a1b..54b0a75 100644
--- a/util/db.go
+++ b/util/db.go
@@ -19,6 +19,7 @@ package util
import (
"database/sql"
+ "fmt"
"os"
sql2 "seata.apache.org/seata-go/pkg/datasource/sql"
@@ -54,20 +55,32 @@ func GetTccMySqlDb() *sql.DB {
return dbTcc
}
-func defaultEnv() {
- if os.Getenv("MYSQL_HOST") == "" {
- _ = os.Setenv("MYSQL_HOST", "127.0.0.1")
- }
- if os.Getenv("MYSQL_PORT") == "" {
- _ = os.Setenv("MYSQL_PORT", "3306")
+func SetDefaultEnv(key string, value string) error {
+ currentValue, exists := os.LookupEnv(key)
+ if exists && currentValue != "" {
+ return nil
}
- if os.Getenv("MYSQL_USERNAME") == "" {
- _ = os.Setenv("MYSQL_USERNAME", "root")
- }
- if os.Getenv("MYSQL_PASSWORD") == "" {
- _ = os.Setenv("MYSQL_PASSWORD", "12345678")
+ return os.Setenv(key, value)
+}
+
+func defaultEnv() {
+ mustSetDefaultEnv("MYSQL_HOST", "127.0.0.1")
+ mustSetDefaultEnv("MYSQL_PORT", "3306")
+ mustSetDefaultEnv("MYSQL_USERNAME", "root")
+ if password := os.Getenv("MYSQL_PASSWORD"); password == "" {
+ if rootPassword := os.Getenv("MYSQL_ROOT_PASSWORD");
rootPassword != "" {
+ if err := os.Setenv("MYSQL_PASSWORD", rootPassword);
err != nil {
+ panic(fmt.Sprintf("set MYSQL_PASSWORD from
MYSQL_ROOT_PASSWORD error: %v", err))
+ }
+ } else {
+ mustSetDefaultEnv("MYSQL_PASSWORD", "12345678")
+ }
}
- if os.Getenv("MYSQL_DB") == "" {
- _ = os.Setenv("MYSQL_DB", "seata_client")
+ mustSetDefaultEnv("MYSQL_DB", "seata_client")
+}
+
+func mustSetDefaultEnv(key string, value string) {
+ if err := SetDefaultEnv(key, value); err != nil {
+ panic(fmt.Sprintf("set %s default error: %v", key, err))
}
}
diff --git a/util/http.go b/util/http.go
new file mode 100644
index 0000000..8e0d834
--- /dev/null
+++ b/util/http.go
@@ -0,0 +1,113 @@
+/*
+ * 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 util
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+)
+
+type APIResponse struct {
+ Message string `json:"message,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type ValidationError struct {
+ Message string
+}
+
+func (e *ValidationError) Error() string {
+ return e.Message
+}
+
+func NewValidationError(message string) error {
+ return &ValidationError{Message: message}
+}
+
+type ConflictError struct {
+ Message string
+}
+
+func (e *ConflictError) Error() string {
+ return e.Message
+}
+
+func NewConflictError(message string) error {
+ return &ConflictError{Message: message}
+}
+
+type DownstreamError struct {
+ StatusCode int
+ Message string
+}
+
+func (e *DownstreamError) Error() string {
+ if e.StatusCode > 0 {
+ return fmt.Sprintf("downstream returned status %d: %s",
e.StatusCode, e.Message)
+ }
+ return e.Message
+}
+
+func NewDownstreamError(statusCode int, message string) error {
+ return &DownstreamError{StatusCode: statusCode, Message: message}
+}
+
+func StatusCodeForError(err error) int {
+ var validationErr *ValidationError
+ if errors.As(err, &validationErr) {
+ return http.StatusBadRequest
+ }
+
+ var conflictErr *ConflictError
+ if errors.As(err, &conflictErr) {
+ return http.StatusConflict
+ }
+
+ var downstreamErr *DownstreamError
+ if errors.As(err, &downstreamErr) {
+ if downstreamErr.StatusCode >= 400 && downstreamErr.StatusCode
< 500 {
+ return downstreamErr.StatusCode
+ }
+ return http.StatusBadGateway
+ }
+
+ return http.StatusInternalServerError
+}
+
+func PublicErrorMessage(err error) string {
+ var validationErr *ValidationError
+ if errors.As(err, &validationErr) {
+ return validationErr.Error()
+ }
+
+ var conflictErr *ConflictError
+ if errors.As(err, &conflictErr) {
+ return conflictErr.Error()
+ }
+
+ var downstreamErr *DownstreamError
+ if errors.As(err, &downstreamErr) {
+ if downstreamErr.StatusCode >= 400 && downstreamErr.StatusCode
< 500 {
+ return "dependent service rejected the request"
+ }
+ return "dependent service is unavailable"
+ }
+
+ return "internal server error"
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]