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]


Reply via email to