diff --git a/xy_sh/Dockerfile b/xy_sh/Dockerfile index 83d831c..82dc0b2 100644 --- a/xy_sh/Dockerfile +++ b/xy_sh/Dockerfile @@ -44,6 +44,4 @@ ENV TZ=Asia/Shanghai CMD ["./server"] -# 不被他人看法左右 -# 纸上谈兵是免费的 -# 《拿来》 + diff --git a/xy_sh/internal/test/example_test.go b/xy_sh/cmd/server/example_test.go similarity index 79% rename from xy_sh/internal/test/example_test.go rename to xy_sh/cmd/server/example_test.go index 975ca2f..1bacb0b 100644 --- a/xy_sh/internal/test/example_test.go +++ b/xy_sh/cmd/server/example_test.go @@ -1,29 +1,56 @@ -package test +package main import ( "bytes" "encoding/json" + "flag" "io" "net/http" "net/http/httptest" "strconv" "testing" "time" - + "xy_sh/internal/biz" + "xy_sh/internal/config" + "xy_sh/internal/data/impl" "xy_sh/internal/entities" "xy_sh/internal/router" + service2 "xy_sh/internal/service" "xy_sh/pkg/crypto" + "xy_sh/utils" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/log" ) +const URL = "http://127.0.0.1:8085" + func init() { crypto.InitCrypto("test_salt_12345", "test_key_16bytes") } func setupTestApp() *fiber.App { app := fiber.New() - router.SetupRoutes(app) + cfgPath := flag.String("config", "../../config/config_test.yaml", "Path to configuration file") + cfg, err := config.LoadConfig(*cfgPath) + db, _ := utils.NewGormDb(cfg) + orderImpl := impl.NewOrderImpl(db) + logImpl := impl.NewLogImpl(db) + ymt, err := ymtClient(cfg.Ymt) + + if err != nil { + panic("初始化YMT客户端失败: " + err.Error()) + } + bizs := biz.NewOrderStore(orderImpl, ymt, cfg) + service := service2.NewOrder(bizs, logImpl) + + log.Infof("开始注册路由...") // 🔍 添加日志 + router.SetupRoutes(app, service) + log.Infof("路由注册完成") // 🔍 添加日志 + // 🔍 打印所有路由 + for _, route := range app.GetRoutes() { + log.Infof("Route: %s %s", route.Method, route.Path) + } return app } @@ -54,7 +81,7 @@ func makeEncryptedRequest(t *testing.T, app *fiber.App, path string, bizData int req.Header.Set("timestamp", timestamp) req.Header.Set("sign", sign) - resp, err := app.Test(req) + resp, err := app.Test(req, 50000) if err != nil { t.Fatalf("请求失败: %v", err) } @@ -65,11 +92,24 @@ func makeEncryptedRequest(t *testing.T, app *fiber.App, path string, bizData int return resp, body } +func TestTestRoute(t *testing.T) { + app := setupTestApp() + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("请求失败: %v", err) + } + + body, _ := io.ReadAll(resp.Body) + t.Logf("状态码: %d, 响应: %s", resp.StatusCode, string(body)) +} + func TestCreateOrder(t *testing.T) { app := setupTestApp() bizData := entities.OrderRequest{ - ActCode: "ACT001", + ActCode: "ACT007", GoodsCode: "123456", ActOrderNum: "TEST_ORDER_001", Account: "19912345678", @@ -97,38 +137,9 @@ func TestCreateOrder(t *testing.T) { func TestQueryOrder(t *testing.T) { app := setupTestApp() - createBizData := entities.OrderRequest{ - ActCode: "ACT001", - GoodsCode: "123456", - ActOrderNum: "TEST_ORDER_002", - Account: "19912345678", - } - - _, createBody := makeEncryptedRequest(t, app, "/api/v1/order/create", createBizData) - - var createResp entities.CommonResponse - if err := json.Unmarshal(createBody, &createResp); err != nil { - t.Fatalf("创建订单响应解析失败: %v", err) - } - - if createResp.Code != entities.CodeSuccess { - t.Fatalf("创建订单失败: %s", createResp.Msg) - } - - encryptedData := createResp.Data.(string) - decryptedData, err := crypto.SM4CBCDecrypt(encryptedData) - if err != nil { - t.Fatalf("解密创建订单响应失败: %v", err) - } - - var orderResp entities.OrderResponseData - if err := json.Unmarshal(decryptedData, &orderResp); err != nil { - t.Fatalf("解析创建订单业务数据失败: %v", err) - } - queryBizData := entities.QueryOrderRequest{ - ActCode: "ACT001", - OrderNo: orderResp.OrderNo, + //ActCode: "ACT007", + OrderNo: "936312532119846913", } resp, body := makeEncryptedRequest(t, app, "/api/v1/order/query", queryBizData) @@ -238,11 +249,11 @@ func TestHealthCheck(t *testing.T) { app := setupTestApp() req := httptest.NewRequest(http.MethodGet, "/health", nil) - resp, body := app.Test(req) + resp, err := app.Test(req) if resp.StatusCode != http.StatusOK { t.Errorf("期望状态码200,实际: %d", resp.StatusCode) } - t.Logf("健康检查接口测试成功,响应: %s", string(body)) -} \ No newline at end of file + t.Logf("健康检查接口测试成功,响应: %s", err) +} diff --git a/xy_sh/cmd/server/main.go b/xy_sh/cmd/server/main.go index eae693b..c779352 100644 --- a/xy_sh/cmd/server/main.go +++ b/xy_sh/cmd/server/main.go @@ -1,18 +1,29 @@ package main import ( + "flag" + "fmt" "log" + "xy_sh/internal/biz" + "xy_sh/internal/data/impl" + service2 "xy_sh/internal/service" + "xy_sh/utils" + "xy_sh/ymt_v3" "xy_sh/internal/config" "xy_sh/internal/router" "xy_sh/pkg/crypto" + "github.com/go-viper/mapstructure/v2" "github.com/gofiber/fiber/v2" ) func main() { - cfg := config.LoadConfig() - + cfgPath := flag.String("config", "./config/config_test.yaml", "Path to configuration file") + cfg, err := config.LoadConfig(*cfgPath) + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } crypto.InitCrypto(cfg.SM3Salt, cfg.SM4Key) app := fiber.New(fiber.Config{ @@ -21,10 +32,31 @@ func main() { WriteTimeout: cfg.WriteTimeout, }) - router.SetupRoutes(app) + db, cleanup := utils.NewGormDb(cfg) + defer cleanup() + orderImpl := impl.NewOrderImpl(db) + logImpl := impl.NewLogImpl(db) + ymt, err := ymtClient(cfg.Ymt) + if err != nil { + log.Fatal(err) + } + bizs := biz.NewOrderStore(orderImpl, ymt, cfg) + service := service2.NewOrder(bizs, logImpl) + router.SetupRoutes(app, service) log.Printf("服务启动,监听端口: %s", cfg.ServerPort) if err := app.Listen(":" + cfg.ServerPort); err != nil { log.Fatalf("服务启动失败: %v", err) } -} \ No newline at end of file +} + +func ymtClient(cfg config.Ymt) (*ymt_v3.Client, error) { + return ymt_v3.NewClient(&ymt_v3.ClientConfig{ + AppID: cfg.AppID, + PrivateKey: cfg.PrivateKey, + PublicKey: cfg.PublicKey, + Key: cfg.Key, + EncryptType: ymt_v3.EncryptType(cfg.EncryptType), + Env: ymt_v3.Env(cfg.Env), + }) +} diff --git a/xy_sh/config/config.yaml b/xy_sh/config/config.yaml new file mode 100644 index 0000000..c57e203 --- /dev/null +++ b/xy_sh/config/config.yaml @@ -0,0 +1,202 @@ +# 服务器配置 +server: + port: 8090 + host: "0.0.0.0" + +ollama: + base_url: "http://192.168.6.115:11434" + model: "qwen3:8b" + generate_model: "qwen3:8b" + mapping_model: "qwen3:8b" +# model: "qwen3-coder:480b-cloud" +# generate_model: "qwen3-coder:480b-cloud" +# mapping_model: "deepseek-v3.2:cloud" + vl_model: "qwen2.5vl:3b" + timeout: "120s" + level: "info" + format: "json" + +vllm: + vl_model: + base_url: "http://192.168.6.115:8001/v1" + model: "qwen2.5-vl-3b-awq" + timeout: "120s" + level: "info" + text_model: + base_url: "http://192.168.6.115:8002/v1" + model: "qwen3-8b-fp8" + timeout: "120s" + level: "info" + +coze: + base_url: "https://api.coze.cn" + +lsxd: + # 统一登录 + login_url: "https://api.user.1688sup.com/v1/login/phone" + phone: "ORlviZN7N06W2+WKLe76xg==" + password: "V5Uh8C4bamEM6UQZh4TCeQ==" + code: "456789" + check_token_url: "https://api.user.1688sup.com/v1/user/welcome" + + +sys: + session_len: 6 + channel_pool_len: 100 + channel_pool_size: 32 + llm_pool_len: 5 + heartbeat_interval: 300 + key: report-api + pollSize: 5 #连接池大小,不配置,或配置为0表示不启用连接池 + minIdleConns: 2 #最小空闲连接数 + maxIdleTime: 30 #每个连接最大空闲时间,如果超过了这个时间会被关闭 + tls: 30 + db: +redis: + host: 47.97.27.195:6379 + type: node + pass: lansexiongdi@666 + key: ai_scheduler_prov + pollSize: 5 #连接池大小,不配置,或配置为0表示不启用连接池 + minIdleConns: 2 #最小空闲连接数 + maxIdleTime: 30 #每个连接最大空闲时间,如果超过了这个时间会被关闭 + tls: 30 + db: +db: + driver: mysql + source: root:SD###sdf323r343@tcp(121.199.38.107:3306)/sys_ai?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai +mongo: + source: mongodb://root:lsxd2026123@192.168.6.115:27017 + dataBase: ai_scheduler + maxPoolSize: 100 + minPoolSize: 10 + maxConnIdleTime: 30 + connectTimeout: 10 + socketTimeout: 30 +oss: + access_key: "LTAI5tGGZzjf3tvqWk8SQj2G" + secret_key: "S0NKOAUaYWoK4EGSxrMFmYDzllhvpq" + bucket: "attachment-public" + domain: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com" + endpoint: "https://oss-cn-hangzhou.aliyuncs.com" + +tools: + zltxOrderDetail: + enabled: true + base_url: "https://revcl.1688sup.com/api/admin/direct/ai/%s" + add_url: "https://revcl.1688sup.com/api/admin/direct/log/%s/%s" + api_key: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ1c2VyQ2VudGVyIiwiZXhwIjoxNzU4MDkxOTU4LCJuYmYiOjE3NTgwOTAxNTgsImp0aSI6IjEiLCJQaG9uZSI6IjE4MDAwMDAwMDAwIiwiVXNlck5hbWUiOiJsc3hkIiwiUmVhbE5hbWUiOiLotoXnuqfnrqHnkIblkZgiLCJBY2NvdW50VHlwZSI6MSwiR3JvdXBDb2RlcyI6IlZDTF9DQVNISUVSLFZDTF9PUEVSQVRFLFZDTF9BRE1JTixWQ0xfQUFBLFZDTF9WQ0xfT1BFUkFULFZDTF9JTlZPSUNFLENSTV9BRE1JTixMSUFOTElBTl9BRE1JTixNQVJLRVRNQUcyX0FETUlOLFBIT05FQklMTF9BRE1JTixRSUFOWkhVX1NVUFBFUl9BRE0sTUFSS0VUSU5HU0FBU19TVVBFUkFETUlOLENBUkRfQ09ERSxDQVJEX1BST0NVUkVNRU5ULE1BUktFVElOR1NZU1RFTV9TVVBFUixTVEFUSVNUSUNBTFNZU1RFTV9BRE1JTixaTFRYX0FETUlOLFpMVFhfT1BFUkFURSIsIkRpbmdVc2VySWQiOiIxNjIwMjYxMjMwMjg5MzM4MzQifQ.Bjsx9f8yfcrV9EWxb0n6POwnXVOq9XPRD78JFZnnf1_VAVMN78W4W570SZL27PWuDnkD7E4oUg6RzeZwZgl7BZrNpNr-a-QpNC5qCptqrqXeNfVStmX7pxWA8GqnzI8ybkZgbhQ58Gje7DzdJtBq_8zte_LDaYhTYXdIc5EAG0AbCzAk22nPTl47nkMeHtmisXQVLEsdibl1hW3ViFJlXwfXvUrOENItmL1_mRYkggUB0MaTu2nHJOYM6PaOVGLHx-74eepnmK2rm6konFEb6ed-Ukc6gVR-nM9yWZaYLYNGNKJLwZoCX3tRuerq74n4kzQgWmUEJeaVI1yIGSw1zw" + zltxProduct: + enabled: true + base_url: "https://revcl.1688sup.com/api/admin/oursProduct" + add_url: "https://revcl.1688sup.com/api/admin/platformProduct/getProductsByOfficialProductId" + api_key: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ1c2VyQ2VudGVyIiwiZXhwIjoxNzU2MTgyNTM1LCJuYmYiOjE3NTYxODA3MzUsImp0aSI6IjEiLCJQaG9uZSI6IjE4MDAwMDAwMDAwIiwiVXNlck5hbWUiOiJsc3hkIiwiUmVhbE5hbWUiOiLotoXnuqfnrqHnkIblkZgiLCJBY2NvdW50VHlwZSI6MSwiR3JvdXBDb2RlcyI6IlZDTF9DQVNISUVSLFZDTF9PUEVSQVRFLFZDTF9BRE1JTixWQ0xfQUFBLFZDTF9WQ0xfT1BFUkFULFZDTF9JTlZPSUNFLENSTV9BRE1JTixMSUFOTElBTl9BRE1JTixNQVJLRVRNQUcyX0FETUlOLFBIT05FQklMTF9BRE1JTixRSUFOWkhVX1NVUFBFUl9BRE0sTUFSS0VUSU5HU0FBU19TVVBFUkFETUlOLENBUkRfQ09ERSxDQVJEX1BST0NVUkVNRU5ULE1BUktFVElOR1NZU1RFTV9TVVBFUixTVEFUSVNUSUNBTFNZU1RFTV9BRE1JTixaTFRYX0FETUlOLFpMVFhfT1BFUkFURSIsIkRpbmdVc2VySWQiOiIxNjIwMjYxMjMwMjg5MzM4MzQifQ.N1xv1PYbcO8_jR5adaczc16YzGsr4z101gwEZdulkRaREBJNYTOnFrvRxTFx3RJTooXsqTqroE1MR84v_1WPX6BS6kKonA-kC1Jgot6yrt5rFWhGNGb2Cpr9rKIFCCQYmiGd3AUgDazEeaQ0_sodv3E-EXg9VfE1SX8nMcck9Yjnc8NCy7RTWaBIaSeOdZcEl-JfCD0S6GSx3oErp_hk-U9FKGwf60wAuDGTY1R0BP4BYpcEqS-C2LSnsSGyURi54Cuk5xH8r1WuF0Dm5bwAj5d7Hvs77-N_sUF-C5ONqyZJRAEhYLgcmN9RX_WQZfizdQJxizlTczdpzYfy-v-1eQ" + zltxOrderStatistics: + base_url: "https://revcl.1688sup.com/api/admin/direct/ai/search/" + enabled: true + api_key: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ1c2VyQ2VudGVyIiwiZXhwIjoxNzU2MTgyNTM1LCJuYmYiOjE3NTYxODA3MzUsImp0aSI6IjEiLCJQaG9uZSI6IjE4MDAwMDAwMDAwIiwiVXNlck5hbWUiOiJsc3hkIiwiUmVhbE5hbWUiOiLotoXnuqfnrqHnkIblkZgiLCJBY2NvdW50VHlwZSI6MSwiR3JvdXBDb2RlcyI6IlZDTF9DQVNISUVSLFZDTF9PUEVSQVRFLFZDTF9BRE1JTixWQ0xfQUFBLFZDTF9WQ0xfT1BFUkFULFZDTF9JTlZPSUNFLENSTV9BRE1JTixMSUFOTElBTl9BRE1JTixNQVJLRVRNQUcyX0FETUlOLFBIT05FQklMTF9BRE1JTixRSUFOWkhVX1NVUFBFUl9BRE0sTUFSS0VUSU5HU0FBU19TVVBFUkFETUlOLENBUkRfQ09ERSxDQVJEX1BST0NVUkVNRU5ULE1BUktFVElOR1NZU1RFTV9TVVBFUixTVEFUSVNUSUNBTFNZU1RFTV9BRE1JTixaTFRYX0FETUlOLFpMVFhfT1BFUkFURSIsIkRpbmdVc2VySWQiOiIxNjIwMjYxMjMwMjg5MzM4MzQifQ.N1xv1PYbcO8_jR5adaczc16YzGsr4z101gwEZdulkRaREBJNYTOnFrvRxTFx3RJTooXsqTqroE1MR84v_1WPX6BS6kKonA-kC1Jgot6yrt5rFWhGNGb2Cpr9rKIFCCQYmiGd3AUgDazEeaQ0_sodv3E-EXg9VfE1SX8nMcck9Yjnc8NCy7RTWaBIaSeOdZcEl-JfCD0S6GSx3oErp_hk-U9FKGwf60wAuDGTY1R0BP4BYpcEqS-C2LSnsSGyURi54Cuk5xH8r1WuF0Dm5bwAj5d7Hvs77-N_sUF-C5ONqyZJRAEhYLgcmN9RX_WQZfizdQJxizlTczdpzYfy-v-1eQ" + knowledge: + base_url: "http://117.175.169.61:10000" + enabled: true + DingTalkBot: + enabled: true + api_key: "dingsbbntrkeiyazcfdg" + api_secret: "ObqxwyR20r9rVNhju0sCPQyQA98_FZSc32W4vgxnGFH_b02HZr1BPCJsOAF816nu" + zltxOrderAfterSaleSupplier: + enabled: true + base_url: "https://revcl.1688sup.com/api/admin/afterSales/directs" + zltxOrderAfterSaleReseller: + enabled: true + base_url: "https://revcl.1688sup.com/api/admin/afterSales/reseller_pre_ai" + zltxOrderAfterSaleResellerBatch: + enabled: true + base_url: "https://revcl.1688sup.com/api/admin/afterSales/reseller_pre_ai" + weather: + enabled: true + base_url: "https://restapi.amap.com/v3/weather/weatherInfo" + api_key: "12afbde5ab78cb7e575ff76bd0bdef2b" + cozeExpress: + enabled: true + base_url: "https://api.coze.cn" + api_key: "7582477438102552616" + api_secret: "pat_eEN0BdLNDughEtABjJJRYTW71olvDU0qUbfQUeaPc2NnYWO8HeyNoui5aR9z0sSZ" + cozeCompany: + enabled: true + base_url: "https://api.coze.cn" + api_key: "7583905168607100978" + api_secret: "pat_eEN0BdLNDughEtABjJJRYTW71olvDU0qUbfQUeaPc2NnYWO8HeyNoui5aR9z0sSZ" + zltxResellerAuthProductToManagerAndDefaultLossReason: + base_url: "https://revcl.1688sup.com/api/admin/reseller/resellerAuthProduct/getManagerAndDefaultLossReason" + +# eino tool 配置 +eino_tools: + # 货易通商品上传 + hytProductUpload: + base_url: "https://hyt.86698.cn/admin_upload/api/v1/goods/supplier/batch/add/complete" + add_url: "https://hyt.86698.cn/#/goods/goodsManage" + # 货易通供应商查询 + hytSupplierSearch: + base_url: "https://hyt.86698.cn/admin_upload/api/v1/supplier/list" + # 货易通仓库查询 + hytWarehouseSearch: + base_url: "https://hyt.86698.cn/admin_upload/api/v1/warehouse/list" + # 货易通商品添加 + hytGoodsAdd: + base_url: "https://hyt.86698.cn/admin_upload/api/v1/goods/add" + add_url: "https://hyt.86698.cn/#/goods/goodsManage" + # 货易通商品图片添加 + hytGoodsMediaAdd: + base_url: "https://hyt.86698.cn/admin_upload/api/v1/media/add/batch" + # 货易通商品分类添加 + hytGoodsCategoryAdd: + base_url: "https://hyt.86698.cn/admin_upload/api/v1/good/category/relation/add" + # 货易通商品分类查询 + hytGoodsCategorySearch: + base_url: "https://hyt.86698.cn/admin_upload/api/v1/goods/category/list" + # 货易通商品品牌查询 + hytGoodsBrandSearch: + base_url: "https://hyt.86698.cn/admin_upload/api/v1/goods/brand/list" + # == 电商充值系统 == + # 我们的商品统计 + rechargeStatisticsOursProduct: + base_url: "http://admin.lanseds.cn/admin/statistics/oursProduct" + # == 通用工具 == + # 表格转图片 + excel2pic: + base_url: "http://192.168.6.115:8010/api/v1/convert" + +dingtalk: + api_key: "dingsbbntrkeiyazcfdg" + api_secret: "ObqxwyR20r9rVNhju0sCPQyQA98_FZSc32W4vgxnGFH_b02HZr1BPCJsOAF816nu" + table_demand: + url: "https://alidocs.dingtalk.com/i/nodes/2Amq4vjg89RnYx9DTp66m2orW3kdP0wQ" + base_id: "2Amq4vjg89RnYx9DTp66m2orW3kdP0wQ" + sheet_id_or_name: "数据表" + # 机器人群组 + bot_group_id: + bbxt: 29 + +qywx: + corp_id: "ww48151f694fb8ec67" + app_secret: "uYqtdwdtdH4Uv_P4is2AChuGzBCoB6cQDyRvpbW0Vmk" + token: "zJdukry6" + aes_key: "4VLH47qRGUogc2d3QLWuUhvJlk8Y0YuRjXzeBquBq8B" + init_account: "les.,FuZhongYun" + chat_id_len: 16 + default_config_id: 1 + bot_group_id: + bbxt: 37 + +default_prompt: + img_recognize: + system_prompt: + '你是一个具备图像理解与用户意图分析能力的智能助手。当用户提供一张图片时,请完成以下任务: + 1. 关键信息提取: + 提取出图片中对用户可能有用的关键信息(例如金额、日期、标题、编号、联系信息、商品名称等)。 + 若图片为文档类(如合同、发票、收据),请结构化输出关键字段(如客户名称、金额、开票日期等)。 + ' + user_prompt: '识别图片内容' + +# 权限配置 +permissionConfig: + permission_url: "https://api.user.1688sup.com/v1/menu/myCodes?systemId=" \ No newline at end of file diff --git a/xy_sh/config/config_test.yaml b/xy_sh/config/config_test.yaml new file mode 100644 index 0000000..314cdf5 --- /dev/null +++ b/xy_sh/config/config_test.yaml @@ -0,0 +1,40 @@ +# config.yaml +server_port: "5002" + +# SM3 盐值 +s_m3_salt: "test_salt_12345" + +# SM4 密钥 +sm4_key: "test_key_16bytes" + +# 通知 URL +notify_url: "https://your-notify-url.com/callback" + +# 读写超时时间 +read_timeout: 30s +write_timeout: 30s + +# 亿美通(YMT)配置 +ymt: + app_id: "fzy-v3" + private_key: | + -----BEGIN RSA PRIVATE KEY----- + MIIEogIBAAKCAQEA+yK/Z/Vvxqfw4hGWRKO94Mo0Dt/65kEpDGgc51oDQ4N5V/LVtn7x+RCIQQn9tQp1apEx5dxGEI+AyxHRjwEXKAhejEG93rF3BmjnMG73L8T8ld3OGtm1WnzN6qeHnzhBDNTKz6IrqpQboqbJFB0zaC9nlPzKqDMBkldAcHbyzIhqF0uYUpVR2PqzAc/j/gVErsO4q37yiGKzUoOVP3HxNffQXxPmzD3/d9mAIKQZdCpf+0v/qsTzH/QvMz4G5duRAs59FiRUbZrb/9t1jsNmf8KtH3psDTRv/ESl8lgor/roSq+aTeqPZKgaSM6k3MypCUClZ/yh6lCz9ziqzSXZowIDAQABAoIBAAkLH0lnFTdaJNp/N6IRI21M2sMOXcKc5hWESkYqf6OWwG/iVr/TonMNnVp6OaCjV8cWo21bmUpPYJvvcFRt/Z97bawvUd8LFPYuIOUlmdEmjtH2ws1Eubsgc2nXzpJ1PCJyJzuC43+pBbW9u8/1nTxLOLeswr971rqmWbOzFNs4nBZUyVrUUz2LMBW+p0RTtMfnQRf92nGOWADl/y4/Ly1Q3OjNF1rHox5ou9xITmIx8Y+1aEbT4NAqGyfufOrLvodaLYF/spI+Oe3Gz6GVVvhRPoQ0J6C9/mfuqZcqVWQ4QcnMOmy9KAGOgZGZnkcdU/31MJQpGUuv4ECqwWtkX8ECgYEA+5SvmfCtis8d8hDPcn9fZWoVCA/dJLx03JG/iEQsUPqtJRxU7sMFAzK6ADHFx+ztBJi3X2e2fGWhw+1dXGFBT2pIv507TG3saTrLjlrA8iPKIcWfjupbJUK4tbTvLPaCpZNpKBnCgq0vpaq0SI3fjoHrMERIZDdKT179fI9I1F0CgYEA/4wPcddPQTHzktjXHP7CY6ZOQZydM2STazp5aWAdHnEXOkRDuZGTdtdc3fqQy53EU2VLu5Jxbnqn+IC1njrEonP/ko0sRyMssa9JdXqhM4dWZJO1IgdWWNWiVgH5aTlKe8SB3bSiwxf1FEfYa9lh60+qqQ+QEcpUETouzh3+hf8CgYBVDDes18MjGM9rxKkMcOjD9O+1MP+2aosrAY55N2qv2X5s/D3uFTl9kkl0xV6yLnMVyba75ui29viPro+QKkSU3z5GoJWqScLQ9BJaRm3Rra2oaxF8k9dKKlsc+lSco50Y8lNrPgIWgQuJesLFgEih+WOThpHeZx6U5GzXDe019QKBgHS6lZN6tkkheBFr21bzR/gcz0JJN8Vx+6TPYQYxURvGrMWAyS7KwIFYfqMnAV0BA9zUOHPFwOqmPHPW1x8f2RIbynI26jLUbmX7m6J+EYRoHZ5zmhmhIGATtcNzw2m9Mbi3WlrbWD2lg91vs/wPoBrMmTgAU97MfPohiZ+9M7YhAoGAMeJMoAW586q4u0G+0GsNTNfkHfR+1JI0gej9pL+Xw3iWu/RM0EpLM3LsgNq22D7AjyelqOPx7ICjg0vGpGp7ECrpM8QjEPtD9sXp02E6qiPU50L+hlIAaUyGWlWYI/lF6aFpvrPtTtbDx/3dK8miN/0fTtUcix3tQ0KLNdDYvaY= + -----END RSA PRIVATE KEY----- + public_key: | + -----BEGIN PUBLIC KEY----- + MIIBCgKCAQEA+yK/Z/Vvxqfw4hGWRKO94Mo0Dt/65kEpDGgc51oDQ4N5V/LVtn7x+RCIQQn9tQp1apEx5dxGEI+AyxHRjwEXKAhejEG93rF3BmjnMG73L8T8ld3OGtm1WnzN6qeHnzhBDNTKz6IrqpQboqbJFB0zaC9nlPzKqDMBkldAcHbyzIhqF0uYUpVR2PqzAc/j/gVErsO4q37yiGKzUoOVP3HxNffQXxPmzD3/d9mAIKQZdCpf+0v/qsTzH/QvMz4G5duRAs59FiRUbZrb/9t1jsNmf8KtH3psDTRv/ESl8lgor/roSq+aTeqPZKgaSM6k3MypCUClZ/yh6lCz9ziqzSXZowIDAQA= + -----END PUBLIC KEY----- + key: "40ec4208893105e82f9aff5f59639296" + activity_no: "FZY001" + encrypt_type: "aes" + env: "test" + +# 数据库配置 +db: + driver: "mysql" + source: "root:lcNHzXpGN0yoX^N@tcp(47.97.27.195:3306)/transfer?charset=utf8mb4&parseTime=True&loc=Local" + maxIdle: 10 + maxOpen: 100 + maxLifetime: 3600 + isDebug: true \ No newline at end of file diff --git a/xy_sh/gen.sh b/xy_sh/gen.sh new file mode 100755 index 0000000..208cf6c --- /dev/null +++ b/xy_sh/gen.sh @@ -0,0 +1,19 @@ +#!/bin/bash + + # 使用方法: + # ./genModel.sh usercenter user + # ./genModel.sh usercenter user_auth + # 再将./genModel下的文件剪切到对应服务的model目录里面,记得改package + + + #生成的表名 + tables=$1 + #表生成的genmodel目录 + modeldir=./internal/data/model + + # 数据库配置 + prefix=xy_sh_ + + + +gentool --dsn "root:lcNHzXpGN0yoX^N@tcp(47.97.27.195:3306)/transfer?charset=utf8mb4&parseTime=true" -outPath ${modeldir} -onlyModel -modelPkgName "model" -tables ${prefix}${tables} diff --git a/xy_sh/go.mod b/xy_sh/go.mod index fcbfa19..2489a2c 100644 --- a/xy_sh/go.mod +++ b/xy_sh/go.mod @@ -3,6 +3,42 @@ module xy_sh go 1.26 require ( + gitea.cdlsxd.cn/self-tools/l_request v1.0.8 + github.com/go-kratos/kratos/v2 v2.9.2 github.com/gofiber/fiber/v2 v2.52.0 + github.com/google/uuid v1.5.0 + github.com/google/wire v0.7.0 + github.com/spf13/viper v1.21.0 github.com/tjfoc/gmsm v1.4.1 -) \ No newline at end of file + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.2 + xorm.io/builder v0.3.13 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/xy_sh/internal/biz/biz.go b/xy_sh/internal/biz/biz.go index 409be57..5ef28d1 100644 --- a/xy_sh/internal/biz/biz.go +++ b/xy_sh/internal/biz/biz.go @@ -1,186 +1,199 @@ package biz import ( - "crypto/rand" - "encoding/hex" - "errors" - "io" + "context" + "encoding/json" + "fmt" + "net/http" "strconv" - "sync" + "strings" "time" + "xy_sh/internal/config" + "xy_sh/internal/data/impl" + "xy_sh/internal/data/model" + "xy_sh/pkg" + "xy_sh/pkg/crypto" + "xy_sh/ymt_v3" + + "gitea.cdlsxd.cn/self-tools/l_request" "xy_sh/internal/entities" ) type OrderStore struct { - mu sync.RWMutex - orders map[string]*OrderInfo + orders *impl.OrderImpl + ymtClient *ymt_v3.Client + cfg *config.Config } -type OrderInfo struct { - OrderNo string - ActOrderNum string - GoodsCode string - ActCode string - Account string - Status int - CouponNo string - CouponCode string - ExpireTime string - CouponId string - AppId string - OpenId string - CreateTime time.Time - UpdateTime time.Time -} - -var store *OrderStore - -func init() { - store = &OrderStore{ - orders: make(map[string]*OrderInfo), +func NewOrderStore(orders *impl.OrderImpl, ymtClient *ymt_v3.Client, cfg *config.Config) *OrderStore { + return &OrderStore{ + orders: orders, + ymtClient: ymtClient, + cfg: cfg, } } -func CreateOrder(req *entities.OrderRequest) (*entities.OrderResponseData, error) { - store.mu.Lock() - defer store.mu.Unlock() - - orderNo := generateOrderNo() - - orderInfo := &OrderInfo{ - OrderNo: orderNo, - ActOrderNum: req.ActOrderNum, - GoodsCode: req.GoodsCode, - ActCode: req.ActCode, - Account: req.Account, - Status: entities.StatusSuccess, - CreateTime: time.Now(), - UpdateTime: time.Now(), +func (o *OrderStore) CreateOrder(ctx context.Context, req *entities.OrderRequest) (*entities.OrderResponseData, error) { + var orderInfo model.XyShOrder + err := o.orders.GetByKey(ctx, "act_order_num", req.ActOrderNum, &orderInfo) + if err != nil { + return nil, err + } + if orderInfo.ID == 0 { + orderInfo = model.XyShOrder{ + ActOrderNum: req.ActOrderNum, + GoodsCode: req.GoodsCode, + ActCode: req.ActCode, + Account: req.Account, + Status: entities.StatusSuccess, + CallBackURL: req.CallbackUrl, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } } - orderInfo.CouponNo = generateCouponNo() - orderInfo.CouponCode = orderInfo.CouponNo - orderInfo.ExpireTime = time.Now().AddDate(1, 0, 0).Format("2006-01-02 15:04:05") - - store.orders[orderNo] = orderInfo + selfPublicIP, err := pkg.GetPublicIP() + if err != nil { + selfPublicIP = "120.55.12.245" + } + result, err := o.ymtClient.KeyOrder(&ymt_v3.KeyOrderRequest{ + OutBizNo: req.ActOrderNum, + ActivityNo: o.cfg.Ymt.ActivityNo, + Account: req.Account, + NotifyURL: selfPublicIP + ":" + o.cfg.ServerPort + "/callback/notify", + }) + if err != nil { + return nil, err + } + orderInfo.CouponNo = result.URL + orderInfo.CouponCode = result.Key + orderInfo.OrderNo = result.TradeNo + orderInfo.Status = int32(result.Status) + var validTime time.Time + if len(result.ValidEndTime) != 0 { + validTime, err = time.Parse(time.DateTime, result.ValidEndTime) + if err != nil { + return nil, fmt.Errorf("解析失败: %v\n", err) + } + } + orderInfo.ExpireTime = validTime + if orderInfo.ID == 0 { + err = o.orders.Add(ctx, &orderInfo) + } + if err != nil { + return nil, err + } return &entities.OrderResponseData{ - OrderNo: orderNo, - CouponNo: orderInfo.CouponNo, - CouponCode: orderInfo.CouponCode, - Status: orderInfo.Status, - ExpireTime: orderInfo.ExpireTime, + OrderNo: result.TradeNo, + CouponNo: result.URL, + CouponCode: result.Key, + Status: o.getStatus(result.Status, result.ValidEndTime, err), + ExpireTime: result.ValidEndTime, }, nil } -func QueryOrder(req *entities.QueryOrderRequest) (*entities.QueryOrderResponseData, error) { - store.mu.RLock() - defer store.mu.RUnlock() - - orderInfo, exists := store.orders[req.OrderNo] - if !exists { - return nil, errors.New("订单不存在") +func (o *OrderStore) getStatus(ymtStatus uint32, ymtValidTime string, err error) int { + if err != nil { + return entities.StatusFailed } + switch ymtStatus { + case 2: + return entities.StatusRedeemed + case 3: + return entities.StatusFailed + default: + t, err := time.Parse(time.DateTime, ymtValidTime) + if err != nil { + fmt.Printf("解析失败: %v\n", err) + return entities.StatusFailed + } + if t.Unix() <= time.Now().Unix() { + return entities.StatusExpired + } + return entities.StatusSuccess + } +} +func (o *OrderStore) QueryOrder(req *entities.QueryOrderRequest) (*entities.QueryOrderResponseData, error) { + result, err := o.ymtClient.KeyQuery(&ymt_v3.KeyQueryRequest{ + TradeNo: req.OrderNo, + }) + if err != nil { + return nil, err + } resp := &entities.QueryOrderResponseData{ - OrderNo: orderInfo.OrderNo, - Status: orderInfo.Status, - Account: orderInfo.Account, - CouponId: orderInfo.CouponId, + OrderNo: result.TradeNo, + Status: o.getStatus(result.Status, result.ValidEndTime, err), + Account: result.Account, } - if orderInfo.CouponNo != "" { + if result.URL != "" { resp.CardInfo = &entities.CardInfo{ - CouponNo: orderInfo.CouponNo, - CouponCode: orderInfo.CouponCode, - ExpireTime: orderInfo.ExpireTime, + CouponNo: result.URL, + CouponCode: result.Key, + ExpireTime: result.ValidEndTime, } } return resp, nil } -func WxRecharge(req *entities.WxRechargeRequest) (*entities.WxRechargeResponseData, error) { - store.mu.Lock() - defer store.mu.Unlock() - - if orderInfo, exists := store.orders[req.OrderNo]; exists { - return &entities.WxRechargeResponseData{ - OrderNo: orderInfo.OrderNo, - Status: orderInfo.Status, - CouponId: orderInfo.CouponId, - }, nil +func (o *OrderStore) HandleCallback(req *entities.CallBack) error { + callBackData := &entities.CallbackRequest{ + OrderNo: req.Data.TradeNo, + ActOrderNum: req.Data.OutBizNo, + Status: o.getStatus(req.Data.Status, req.Data.ValidEndTime, nil), + Account: req.Data.Account, + CardInfo: &entities.CardInfo{ + CouponNo: req.Data.Url, + CouponCode: req.Data.Key, + ExpireTime: req.Data.ValidEndTime, + }, } - - orderNo := req.OrderNo - - orderInfo := &OrderInfo{ - OrderNo: orderNo, - ActOrderNum: req.ActOrderNum, - GoodsCode: req.GoodsCode, - ActCode: req.ActCode, - AppId: req.AppId, - OpenId: req.OpenId, - Status: entities.StatusSuccess, - CouponId: generateCouponId(), - CreateTime: time.Now(), - UpdateTime: time.Now(), + sign, jsonData, err := makeEncryptedRequest(callBackData) + if err != nil { + return err } - - store.orders[orderNo] = orderInfo - - return &entities.WxRechargeResponseData{ - OrderNo: orderNo, - Status: orderInfo.Status, - CouponId: orderInfo.CouponId, - }, nil -} - -func HandleCallback(req *entities.CallbackRequest) error { - store.mu.Lock() - defer store.mu.Unlock() - - orderInfo, exists := store.orders[req.OrderNo] - if !exists { - orderInfo = &OrderInfo{ - OrderNo: req.OrderNo, - ActOrderNum: req.ActOrderNum, - CreateTime: time.Now(), - } - store.orders[req.OrderNo] = orderInfo + requset := &l_request.Request{ + Method: http.MethodPost, + Json: jsonData, + Url: o.cfg.NotifyUrl, + Headers: map[string]string{ + "Content-Type": "application/json", + "timestamp": fmt.Sprintf("%d", time.Now().UnixMilli()), + "sign": sign, + }, } - - orderInfo.Status = req.Status - orderInfo.Account = req.Account - orderInfo.CouponId = req.CouponId - orderInfo.UpdateTime = time.Now() - - if req.CardInfo != nil { - orderInfo.CouponNo = req.CardInfo.CouponNo - orderInfo.CouponCode = req.CardInfo.CouponCode - orderInfo.ExpireTime = req.CardInfo.ExpireTime + res, err := requset.Send() + if err != nil { + return err } + if strings.ToLower(res.Text) != "ok" { + } return nil } -func generateOrderNo() string { - return "HM" + strconv.FormatInt(time.Now().UnixMilli(), 10) + randomString(16) -} - -func generateCouponNo() string { - return randomString(16) -} - -func generateCouponId() string { - return randomString(8) -} - -func randomString(n int) string { - b := make([]byte, (n+1)/2) - if _, err := io.ReadFull(rand.Reader, b); err != nil { - panic(err) +func makeEncryptedRequest(bizData interface{}) (string, map[string]interface{}, error) { + bizJSON, err := json.Marshal(bizData) + if err != nil { + return "", nil, fmt.Errorf("业务数据序列化失败: %v", err) } - return hex.EncodeToString(b)[:n] -} \ No newline at end of file + + encryptedData, err := crypto.SM4CBCEncrypt(bizJSON) + if err != nil { + return "", nil, fmt.Errorf("加密失败: %v", err) + } + + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + + sign := crypto.GenerateSign(timestamp, encryptedData) + + reqBody := entities.EncryptedRequest{ + EncryptedData: encryptedData, + } + reqJSON, _ := pkg.StructToMap(reqBody) + return sign, reqJSON, err +} diff --git a/xy_sh/internal/biz/provider_set.go b/xy_sh/internal/biz/provider_set.go new file mode 100644 index 0000000..82d9000 --- /dev/null +++ b/xy_sh/internal/biz/provider_set.go @@ -0,0 +1,9 @@ +package biz + +import ( + "github.com/google/wire" +) + +var ProviderSetBiz = wire.NewSet( + NewOrderStore, +) diff --git a/xy_sh/internal/config/config.go b/xy_sh/internal/config/config.go index 7008161..a920837 100644 --- a/xy_sh/internal/config/config.go +++ b/xy_sh/internal/config/config.go @@ -1,35 +1,57 @@ package config import ( - "os" + "fmt" + + "github.com/spf13/viper" + "time" ) type Config struct { - ServerPort string - SM3Salt string - SM4Key string + ServerPort string `mapstructure:"server_port"` + SM3Salt string `mapstructure:"s_m3_salt"` + SM4Key string `mapstructure:"sm4_key"` + NotifyUrl string `mapstructure:"notify_url"` + Ymt Ymt `mapstructure:"ymt"` + DB DB `mapstructure:"db"` ReadTimeout time.Duration WriteTimeout time.Duration } -func LoadConfig() *Config { - port := getEnv("SERVER_PORT", "8080") - sm3Salt := getEnv("SM3_SALT", "default_salt_change_me") - sm4Key := getEnv("SM4_KEY", "default_key_16by") - - return &Config{ - ServerPort: port, - SM3Salt: sm3Salt, - SM4Key: sm4Key, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, - } +type Ymt struct { + AppID string `mapstructure:"app_id"` + PrivateKey string `mapstructure:"private_key"` + PublicKey string `mapstructure:"public_key"` + Key string `mapstructure:"key"` + ActivityNo string `mapstructure:"activity_no"` + EncryptType string `mapstructure:"encrypt_type"` + Env string `mapstructure:"env"` } -func getEnv(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value +type DB struct { + Driver string `mapstructure:"driver"` + Source string `mapstructure:"source"` + MaxIdle int32 `mapstructure:"maxIdle"` + MaxOpen int32 `mapstructure:"maxOpen"` + MaxLifetime int32 `mapstructure:"maxLifetime"` + IsDebug bool `mapstructure:"isDebug"` +} + +func LoadConfig(configPath string) (*Config, error) { + viper.SetConfigFile(configPath) + viper.SetConfigType("yaml") + + // 读取配置文件 + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) } - return defaultValue -} \ No newline at end of file + + // 解析配置 + var bc Config + if err := viper.Unmarshal(&bc); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return &bc, nil +} diff --git a/xy_sh/internal/data/impl/log.go b/xy_sh/internal/data/impl/log.go new file mode 100644 index 0000000..224a37b --- /dev/null +++ b/xy_sh/internal/data/impl/log.go @@ -0,0 +1,27 @@ +package impl + +import ( + "xy_sh/internal/data/model" + "xy_sh/tmpl/dataTemp" + "xy_sh/utils" +) + +type LogImpl struct { + dataTemp.DataTemp + db *utils.Db +} + +func NewLogImpl(db *utils.Db) *LogImpl { + return &LogImpl{ + DataTemp: *dataTemp.NewDataTemp(db, new(model.XyShLog)), + db: db, + } +} + +func (m *LogImpl) PrimaryKey() string { + return "id" +} + +func (m *LogImpl) GetTemp() *dataTemp.DataTemp { + return &m.DataTemp +} diff --git a/xy_sh/internal/data/impl/order.go b/xy_sh/internal/data/impl/order.go new file mode 100644 index 0000000..ae874bd --- /dev/null +++ b/xy_sh/internal/data/impl/order.go @@ -0,0 +1,27 @@ +package impl + +import ( + "xy_sh/internal/data/model" + "xy_sh/tmpl/dataTemp" + "xy_sh/utils" +) + +type OrderImpl struct { + dataTemp.DataTemp + db *utils.Db +} + +func NewOrderImpl(db *utils.Db) *OrderImpl { + return &OrderImpl{ + DataTemp: *dataTemp.NewDataTemp(db, new(model.XyShOrder)), + db: db, + } +} + +func (m *OrderImpl) PrimaryKey() string { + return "id" +} + +func (m *OrderImpl) GetTemp() *dataTemp.DataTemp { + return &m.DataTemp +} diff --git a/xy_sh/internal/data/model/xy_sh_log.gen.go b/xy_sh/internal/data/model/xy_sh_log.gen.go new file mode 100644 index 0000000..e80a3fb --- /dev/null +++ b/xy_sh/internal/data/model/xy_sh_log.gen.go @@ -0,0 +1,25 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameXyShLog = "xy_sh_log" + +// XyShLog mapped from table +type XyShLog struct { + ID int64 `gorm:"column:id;primaryKey" json:"id"` + Req string `gorm:"column:req;not null" json:"req"` + Decrypt string `gorm:"column:decrypt;not null" json:"decrypt"` + Resp string `gorm:"column:resp;not null" json:"resp"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` +} + +// TableName XyShLog's table name +func (*XyShLog) TableName() string { + return TableNameXyShLog +} diff --git a/xy_sh/internal/data/model/xy_sh_order.gen.go b/xy_sh/internal/data/model/xy_sh_order.gen.go new file mode 100644 index 0000000..45f4c28 --- /dev/null +++ b/xy_sh/internal/data/model/xy_sh_order.gen.go @@ -0,0 +1,33 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNameXyShOrder = "xy_sh_order" + +// XyShOrder mapped from table +type XyShOrder struct { + ID int64 `gorm:"column:id;primaryKey" json:"id"` + OrderNo string `gorm:"column:order_no;not null;comment:订单编号" json:"order_no"` // 订单编号 + ActOrderNum string `gorm:"column:act_order_num;not null;comment:活动方订单号,唯一,行内活动订单号" json:"act_order_num"` // 活动方订单号,唯一,行内活动订单号 + GoodsCode string `gorm:"column:goods_code;not null;comment:供应商商品编号" json:"goods_code"` // 供应商商品编号 + ActCode string `gorm:"column:act_code;not null;comment:活动code,可约定为各供应商的项目编号和密钥的拼接加密字符串" json:"act_code"` // 活动code,可约定为各供应商的项目编号和密钥的拼接加密字符串 + Account string `gorm:"column:account;not null;comment:充值账号" json:"account"` // 充值账号 + CallBackURL string `gorm:"column:call_back_url;not null;comment:回调地址" json:"call_back_url"` // 回调地址 + CouponNo string `gorm:"column:coupon_no;not null;comment:卡号,卡券/短链类商品返回" json:"coupon_no"` // 卡号,卡券/短链类商品返回 + CouponCode string `gorm:"column:coupon_code;not null;comment:卡密,卡券类商品返回" json:"coupon_code"` // 卡密,卡券类商品返回 + Status int32 `gorm:"column:status;not null;comment:状态,-1发放中 0成功 1失败 2已核销 3已过期" json:"status"` // 状态,-1发放中 0成功 1失败 2已核销 3已过期 + ExpireTime time.Time `gorm:"column:expire_time" json:"expire_time"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +// TableName XyShOrder's table name +func (*XyShOrder) TableName() string { + return TableNameXyShOrder +} diff --git a/xy_sh/internal/entities/request.go b/xy_sh/internal/entities/request.go index 68df311..9415394 100644 --- a/xy_sh/internal/entities/request.go +++ b/xy_sh/internal/entities/request.go @@ -40,4 +40,17 @@ type CardInfo struct { CouponNo string `json:"couponNo"` CouponCode string `json:"couponCode"` ExpireTime string `json:"expireTime"` -} \ No newline at end of file +} + +type CallBack struct { + Data struct { + NotifyId string `json:"notify_id"` + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status uint32 `json:"status"` + Account string `json:"account"` + ValidEndTime string `json:"valid_end_time"` + Key string `json:"key"` + Url string `json:"url"` + } `json:"data"` +} diff --git a/xy_sh/internal/middleware/middleware.go b/xy_sh/internal/middleware/middleware.go index 595df51..3db53cc 100644 --- a/xy_sh/internal/middleware/middleware.go +++ b/xy_sh/internal/middleware/middleware.go @@ -2,14 +2,54 @@ package middleware import ( "encoding/json" + "fmt" + "xy_sh/tmpl/errcode" "xy_sh/internal/entities" "xy_sh/pkg/crypto" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/log" ) func SignVerifyMiddleware() fiber.Handler { + return func(c *fiber.Ctx) error { + c.Locals("decryptedData", "") + timestamp := c.Get("timestamp") + sign := c.Get("sign") + + if timestamp == "" || sign == "" { + return fmt.Errorf("缺少timestamp或sign请求头") + } + + body := c.Body() + + var req entities.EncryptedRequest + if err := json.Unmarshal(body, &req); err != nil { + return fmt.Errorf("请求体格式错误") + } + + if req.EncryptedData == "" { + return fmt.Errorf("缺少encryptedData参数") + } + + if !crypto.VerifySign(timestamp, req.EncryptedData, sign) { + return fmt.Errorf("签名验证失败") + } + + decryptedData, err := crypto.SM4CBCDecrypt(req.EncryptedData) + if err != nil { + return fmt.Errorf("数据解密失败: " + err.Error()) + } + + c.Locals("decryptedData", string(decryptedData)) + c.Locals("timestamp", timestamp) + + return c.Next() + } +} + +func SignVerifyMiddleware2() fiber.Handler { return func(c *fiber.Ctx) error { timestamp := c.Get("timestamp") sign := c.Get("sign") @@ -65,23 +105,44 @@ func SignVerifyMiddleware() fiber.Handler { } } +type Res struct { + Code int `json:"code"` + Data interface{} `json:"data"` + Msg string `json:"msg"` +} + func EncryptResponseMiddleware() fiber.Handler { return func(c *fiber.Ctx) error { err := c.Next() + if c.Path() == "/api/v1/callback/notify" { + return nil + } + var resp *Res + // 如果有错误发生 if err != nil { - return err - } - - body := c.Response().Body() - if len(body) == 0 { - return nil - } - - var resp entities.CommonResponse - if err := json.Unmarshal(body, &resp); err != nil { - return nil + // 返回自定义错误响应 + resp = &Res{ + Msg: err.Error(), + Code: entities.CodeFailed, + Data: nil, + } + } else { + body := c.Response().Body() + if len(body) == 0 { + return nil + } + resp = &Res{ + Code: entities.CodeSuccess, + Data: body, + Msg: errcode.Success.Error(), + } } + //var resp entities.CommonResponse + //if err := json.Unmarshal(res, &resp); err != nil { + // return nil + //} + var newBody []byte if resp.Data != nil { dataBytes, err := json.Marshal(resp.Data) if err != nil { @@ -103,7 +164,7 @@ func EncryptResponseMiddleware() fiber.Handler { resp.Data = encryptedData - newBody, err := json.Marshal(resp) + newBody, err = json.Marshal(resp) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(entities.CommonResponse{ Code: entities.CodeFailed, @@ -112,9 +173,20 @@ func EncryptResponseMiddleware() fiber.Handler { }) } - c.Response().SetBody(newBody) } + return c.JSON(newBody) + } +} + +func SetLogReq() fiber.Handler { + return func(c *fiber.Ctx) error { + err := c.Next() + if err != nil { + return err + } + + log.Info("Request: ", string(c.Request().Header.Header()), " Body: ", string(c.Request().Body())) return nil } -} \ No newline at end of file +} diff --git a/xy_sh/internal/router/router.go b/xy_sh/internal/router/router.go index 0cd66cf..37c1147 100644 --- a/xy_sh/internal/router/router.go +++ b/xy_sh/internal/router/router.go @@ -1,38 +1,81 @@ package router import ( + "xy_sh/internal/entities" "xy_sh/internal/middleware" "xy_sh/internal/service" + "xy_sh/pkg/crypto" + "xy_sh/tmpl/errcode" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/log" ) -func SetupRoutes(app *fiber.App) { - app.Get("/health", service.HealthHandler) - app.Get("/", service.HealthHandler) - - v1 := app.Group("/api/v1") +func SetupRoutes( + app *fiber.App, + service *service.Order, +) { + v1 := app.Group("/api/v1", middleware.SetLogReq()) + app.Get("/test", func(c *fiber.Ctx) error { + return c.SendString("test ok") + }) + registerResponse(v1) v1.Post("/order/create", middleware.SignVerifyMiddleware(), - middleware.EncryptResponseMiddleware(), service.CreateOrderHandler, ) v1.Post("/order/query", middleware.SignVerifyMiddleware(), - middleware.EncryptResponseMiddleware(), service.QueryOrderHandler, ) - v1.Post("/wx/recharge", - middleware.SignVerifyMiddleware(), - middleware.EncryptResponseMiddleware(), - service.WxRechargeHandler, - ) - v1.Post("/callback/notify", - middleware.SignVerifyMiddleware(), service.CallbackHandler, ) -} \ No newline at end of file +} + +func registerResponse(router fiber.Router) { + // 自定义返回 + router.Use(func(c *fiber.Ctx) error { + err := c.Next() + return registerCommon(c, err) + }) + +} + +func registerCommon(c *fiber.Ctx, err error) error { + + log.Info("Response: ", string(c.Response().Body()), " Err: ", err) + if c.Path() == "/api/v1/callback/notify" { + return nil + } + // 如果有错误发生 + if err != nil { + // 返回自定义错误响应 + return c.JSON(fiber.Map{ + "msg": err.Error(), + "code": entities.CodeFailed, + "data": nil, + }) + } + + var data string + if len(c.Response().Body()) > 0 { + data, err = crypto.SM4CBCEncrypt(c.Response().Body()) + if err != nil { + return c.JSON(fiber.Map{ + "msg": err.Error(), + "code": entities.CodeFailed, + "data": nil, + }) + } + } + + return c.JSON(fiber.Map{ + "data": data, + "message": errcode.Success.Error(), + "code": errcode.Success.Code(), + }) +} diff --git a/xy_sh/internal/service/provider_set.go b/xy_sh/internal/service/provider_set.go new file mode 100644 index 0000000..40386d0 --- /dev/null +++ b/xy_sh/internal/service/provider_set.go @@ -0,0 +1,9 @@ +package service + +import ( + "github.com/google/wire" +) + +var ProviderSetService = wire.NewSet( + NewOrder, +) diff --git a/xy_sh/internal/service/service.go b/xy_sh/internal/service/service.go index 2025a6e..23eeaed 100644 --- a/xy_sh/internal/service/service.go +++ b/xy_sh/internal/service/service.go @@ -2,6 +2,9 @@ package service import ( "encoding/json" + "fmt" + "xy_sh/internal/data/impl" + "xy_sh/pkg" "xy_sh/internal/biz" "xy_sh/internal/entities" @@ -9,63 +12,57 @@ import ( "github.com/gofiber/fiber/v2" ) -func CreateOrderHandler(c *fiber.Ctx) error { - decryptedData := c.Locals("decryptedData").([]byte) +type Order struct { + biz *biz.OrderStore + logImpl *impl.LogImpl +} +func NewOrder(biz *biz.OrderStore, logImpl *impl.LogImpl) *Order { + return &Order{ + biz: biz, + logImpl: logImpl, + } +} + +func (o *Order) CreateOrderHandler(c *fiber.Ctx) error { + + decryptedData := c.Locals("decryptedData").([]byte) var req entities.OrderRequest if err := json.Unmarshal(decryptedData, &req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(entities.CommonResponse{ - Code: entities.CodeFailed, - Msg: "业务参数解析失败: " + err.Error(), - Data: nil, - }) + return fmt.Errorf("业务参数解析失败: %w", err) } if req.ActCode == "" || req.GoodsCode == "" || req.ActOrderNum == "" { - return c.Status(fiber.StatusBadRequest).JSON(entities.CommonResponse{ - Code: entities.CodeFailed, - Msg: "缺少必填参数: actCode, goodsCode, actOrderNum", - Data: nil, - }) + return fmt.Errorf("缺少必填参数: actCode, goodsCode, actOrderNum") + } - result, err := biz.CreateOrder(&req) + result, err := o.biz.CreateOrder(c.UserContext(), &req) if err != nil { - return c.Status(fiber.StatusOK).JSON(entities.CommonResponse{ - Code: entities.CodeFailed, - Msg: err.Error(), - Data: nil, - }) + return err } - return c.Status(fiber.StatusOK).JSON(entities.CommonResponse{ - Code: entities.CodeSuccess, - Msg: "请求成功", - Data: result, - }) + return pkg.HandleResponse(c, result) } -func QueryOrderHandler(c *fiber.Ctx) error { - decryptedData := c.Locals("decryptedData").([]byte) +func (o *Order) QueryOrderHandler(c *fiber.Ctx) error { + decryptedData := c.Locals("decryptedData").(string) + if len(decryptedData) == 0 { + return nil + } var req entities.QueryOrderRequest - if err := json.Unmarshal(decryptedData, &req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(entities.CommonResponse{ - Code: entities.CodeFailed, - Msg: "业务参数解析失败: " + err.Error(), - Data: nil, - }) + if err := json.Unmarshal([]byte(decryptedData), &req); err != nil { + return fmt.Errorf("业务参数解析失败: " + err.Error()) + } - if req.ActCode == "" || req.OrderNo == "" { - return c.Status(fiber.StatusBadRequest).JSON(entities.CommonResponse{ - Code: entities.CodeFailed, - Msg: "缺少必填参数: actCode, orderNo", - Data: nil, - }) + if req.ActCode == "" && req.OrderNo == "" { + return fmt.Errorf("缺少必填参数: actCode, orderNo") + } - result, err := biz.QueryOrder(&req) + result, err := o.biz.QueryOrder(&req) if err != nil { return c.Status(fiber.StatusOK).JSON(entities.CommonResponse{ Code: entities.CodeFailed, @@ -73,73 +70,16 @@ func QueryOrderHandler(c *fiber.Ctx) error { Data: nil, }) } - - return c.Status(fiber.StatusOK).JSON(entities.CommonResponse{ - Code: entities.CodeSuccess, - Msg: "请求成功", - Data: result, - }) + return pkg.HandleResponse(c, result) } -func WxRechargeHandler(c *fiber.Ctx) error { - decryptedData := c.Locals("decryptedData").([]byte) +func (o *Order) CallbackHandler(c *fiber.Ctx) error { - var req entities.WxRechargeRequest - if err := json.Unmarshal(decryptedData, &req); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(entities.CommonResponse{ - Code: entities.CodeFailed, - Msg: "业务参数解析失败: " + err.Error(), - Data: nil, - }) - } + var req entities.CallBack - if req.ActCode == "" || req.OrderNo == "" || req.GoodsCode == "" || req.ActOrderNum == "" || - req.AppId == "" || req.OpenId == "" { - return c.Status(fiber.StatusBadRequest).JSON(entities.CommonResponse{ - Code: entities.CodeFailed, - Msg: "缺少必填参数: actCode, orderNo, goodsCode, actOrderNum, appId, openId", - Data: nil, - }) - } - - result, err := biz.WxRecharge(&req) - if err != nil { - return c.Status(fiber.StatusOK).JSON(entities.CommonResponse{ - Code: entities.CodeFailed, - Msg: err.Error(), - Data: nil, - }) - } - - return c.Status(fiber.StatusOK).JSON(entities.CommonResponse{ - Code: entities.CodeSuccess, - Msg: "请求成功", - Data: result, - }) -} - -func CallbackHandler(c *fiber.Ctx) error { - decryptedData := c.Locals("decryptedData").([]byte) - - var req entities.CallbackRequest - if err := json.Unmarshal(decryptedData, &req); err != nil { - return c.Status(fiber.StatusBadRequest).SendString("参数解析失败") - } - - if req.OrderNo == "" || req.ActOrderNum == "" { - return c.Status(fiber.StatusBadRequest).SendString("缺少必填参数") - } - - if err := biz.HandleCallback(&req); err != nil { + if err := o.biz.HandleCallback(&req); err != nil { return c.Status(fiber.StatusInternalServerError).SendString("处理失败") } return c.Status(fiber.StatusOK).SendString("ok") } - -func HealthHandler(c *fiber.Ctx) error { - return c.Status(fiber.StatusOK).JSON(fiber.Map{ - "status": "ok", - "message": "服务运行正常", - }) -} \ No newline at end of file diff --git a/xy_sh/pkg/func.go b/xy_sh/pkg/func.go new file mode 100644 index 0000000..a4713bc --- /dev/null +++ b/xy_sh/pkg/func.go @@ -0,0 +1,127 @@ +package pkg + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/log" +) + +// GetPublicIP 获取公网 IP +func GetPublicIP() (string, error) { + client := http.Client{ + Timeout: 5 * time.Second, + } + + // 多个备用服务,提高可用性 + services := []struct { + url string + isJSON bool + parseFn func([]byte) (string, error) + }{ + { + url: "https://api.ipify.org?format=json", + isJSON: true, + parseFn: func(body []byte) (string, error) { + var result map[string]string + if err := json.Unmarshal(body, &result); err != nil { + return "", err + } + if ip, ok := result["ip"]; ok { + return ip, nil + } + return "", fmt.Errorf("未找到 IP 字段") + }, + }, + { + url: "https://ipinfo.io/ip", + isJSON: false, + parseFn: func(body []byte) (string, error) { + return string(body), nil + }, + }, + { + url: "https://api.my-ip.io/ip", + isJSON: false, + parseFn: func(body []byte) (string, error) { + return string(body), nil + }, + }, + { + url: "https://httpbin.org/ip", + isJSON: true, + parseFn: func(body []byte) (string, error) { + var result map[string]string + if err := json.Unmarshal(body, &result); err != nil { + return "", err + } + if ip, ok := result["origin"]; ok { + return ip, nil + } + return "", fmt.Errorf("未找到 IP 字段") + }, + }, + } + + for _, service := range services { + resp, err := client.Get(service.url) + if err != nil { + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + continue + } + + ip, err := service.parseFn(body) + if err == nil && ip != "" { + return ip, nil + } + } + + return "", fmt.Errorf("所有服务都无法获取公网 IP") +} + +func HandleResponse(c *fiber.Ctx, data interface{}) (err error) { + if os.Getenv("env") == "unit_test" { + log.Debug(data) + } + + switch data.(type) { + case error: + err = data.(error) + case int, int32, int64, float32, float64, string, bool: + c.Response().SetBody([]byte(fmt.Sprintf("%s", data))) + case []byte: + c.Response().SetBody(data.([]byte)) + default: + dataByte, _ := json.Marshal(data) + c.Response().SetBody(dataByte) + } + return +} + +// StructToMap 将结构体转换为 map[string]any +func StructToMap(v any) (map[string]any, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + var m map[string]any + err = json.Unmarshal(b, &m) + return m, err +} + +func StructToMapWithOutErr(v any) map[string]any { + b, _ := json.Marshal(v) + var m map[string]any + _ = json.Unmarshal(b, &m) + return m +} diff --git a/xy_sh/tmpl/dataTemp/queryTempl.go b/xy_sh/tmpl/dataTemp/queryTempl.go new file mode 100644 index 0000000..a4f2132 --- /dev/null +++ b/xy_sh/tmpl/dataTemp/queryTempl.go @@ -0,0 +1,266 @@ +package dataTemp + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "xy_sh/tmpl/errcode" + "xy_sh/utils" + + "github.com/go-kratos/kratos/v2/log" + "gorm.io/gorm" + + "xorm.io/builder" +) + +type PrimaryKey struct { + Id int `json:"id"` +} + +type GormDb struct { + Client *gorm.DB +} +type contextTxKey struct{} + +func (d *Db) DB(ctx context.Context) *gorm.DB { + tx, ok := ctx.Value(contextTxKey{}).(*gorm.DB) + if ok { + return tx + } + return d.Db.Client +} + +func (t *Db) ExecTx(ctx context.Context, f func(ctx context.Context) error) error { + return t.Db.Client.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + ctx = context.WithValue(ctx, contextTxKey{}, tx) + return f(ctx) + }) +} + +type Db struct { + Db *GormDb + Log *log.Helper +} + +type DataTemp struct { + Db *gorm.DB + ModelType reflect.Type // 改为存储类型而不是实例 + modelName string // 可选的表名缓存 +} + +func NewDataTemp(db *utils.Db, model interface{}) *DataTemp { + // 获取模型的类型 + t := reflect.TypeOf(model) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + return &DataTemp{ + Db: db.Client, + ModelType: t, + } +} + +func (k DataTemp) modelInstance() interface{} { + return reflect.New(k.ModelType).Interface() +} + +func (k DataTemp) GetById(id int32) (data map[string]interface{}, err error) { + err = k.Db.Model(k.modelInstance()).Where("id = ?", id).Find(&data).Error + if data == nil { + err = sql.ErrNoRows + } + return +} + +func (k DataTemp) GetByStruct(ctx context.Context, search interface{}, data interface{}, orderBy string) (err error) { + + err = k.Db.Model(k.modelInstance()).WithContext(ctx).Where(search).Find(&data).Error + + if data == nil { + err = sql.ErrNoRows + } + return +} + +func (k DataTemp) SaveByStruct(search interface{}, data interface{}) (err error) { + err = k.Db.Model(k.modelInstance()).Where(search).Save(&data).Error + if data == nil { + err = sql.ErrNoRows + } + return +} + +func (k DataTemp) Add(ctx context.Context, data interface{}) (err error) { + m := k.modelInstance() + if err = k.Db.Model(m).WithContext(ctx).Create(data).Error; err != nil { + return errcode.SqlErr(err) + } + return +} + +func (k DataTemp) AddWithData(data interface{}) (interface{}, error) { + result := k.Db.Model(k.modelInstance()).Create(data) + if result.Error != nil { + return data, result.Error + } + return data, nil +} + +func (k DataTemp) GetList(cond *builder.Cond, pageBoIn *ReqPageBo) (list []map[string]interface{}, pageBoOut *RespPageBo, err error) { + var ( + query, _ = builder.ToBoundSQL(*cond) + model = k.Db.Model(k.modelInstance()).Where(query) + total int64 + ) + model.Count(&total) + pageBoOut = pageBoOut.SetDataByReq(total, pageBoIn) + model.Limit(pageBoIn.GetSize()).Offset(pageBoIn.GetOffset()).Order("updated_at desc").Find(&list) + return +} + +func (k DataTemp) GetRange(ctx context.Context, cond *builder.Cond) (list []map[string]interface{}, err error) { + var ( + query, _ = builder.ToBoundSQL(*cond) + model = k.Db.Model(k.modelInstance()).Where(query) + ) + err = model.WithContext(ctx).Find(&list).Error + return list, err +} + +func (k DataTemp) GetRangeToMapStruct(ctx context.Context, cond *builder.Cond, data interface{}) (err error) { + var ( + query, _ = builder.ToBoundSQL(*cond) + model = k.Db.Model(k.modelInstance()).Where(query) + ) + err = model.WithContext(ctx).Find(data).Error + return err +} + +func (k DataTemp) GetOneBySearch(cond *builder.Cond) (data map[string]interface{}, err error) { + query, _ := builder.ToBoundSQL(*cond) + if err = k.Db.Model(k.modelInstance()).Where(query).Limit(1).Find(&data).Error; err != nil { + return nil, errcode.SqlErr(err) + } + + return +} + +func (k DataTemp) Exist(ctx context.Context, cond *builder.Cond) (bool, error) { + var data map[string]interface{} + query, _ := builder.ToBoundSQL(*cond) + err := k.Db.WithContext(ctx).Model(k.modelInstance()).Where(query).Limit(1).Find(&data).Error + if err != nil || data != nil { + return true, err + } + return false, nil +} + +func (k DataTemp) GetListToStruct(ctx context.Context, cond *builder.Cond, pageBoIn *ReqPageBo, result interface{}, orderBy string) (pageBoOut *RespPageBo, err error) { + // 参数验证 + if result == nil { + return nil, fmt.Errorf("result cannot be nil") + } + + val := reflect.ValueOf(result) + if val.Kind() != reflect.Ptr { + return nil, fmt.Errorf("result must be a pointer") + } + + elem := val.Elem() + if elem.Kind() != reflect.Slice { + return nil, fmt.Errorf("result must be a pointer to slice") + } + + // 构建基础查询 + query, _ := builder.ToBoundSQL(*cond) + + // 预编译 SQL 以提高性能 + // 使用 Table 指定表名,避免 GORM 的反射开销 + + db := k.Db.WithContext(ctx).Model(k.modelInstance()).Where(query) + + // 获取总数(使用单独的计数查询,避免缓存影响) + var total int64 + countDb := db + if pageBoIn != nil { + if err = countDb.Count(&total).Error; err != nil { + return nil, err + } + } + + // 初始化分页响应 + pageBoOut = &RespPageBo{} + pageBoOut = pageBoOut.SetDataByReq(total, pageBoIn) + + // 如果没有数据,直接返回空切片 + if total == 0 && pageBoIn != nil { + elem.Set(reflect.MakeSlice(elem.Type(), 0, 0)) + return pageBoOut, nil + } + + // 设置排序(使用索引字段提高性能) + if orderBy == "" { + orderBy = "updated_at desc" + } + + // 应用分页和排序,执行查询 + // 使用 Select 指定字段,避免查询所有字段(如果需要优化) + baseQuery := db + if pageBoIn != nil { + baseQuery = db.Limit(pageBoIn.GetSize()).Offset(pageBoIn.GetOffset()).Order(orderBy) + } + if err = baseQuery. + Order(orderBy). + Find(result).Error; err != nil { + return nil, err + } + + return pageBoOut, nil +} + +func (k DataTemp) UpdateByKey(ctx context.Context, key string, id interface{}, data interface{}) (err error) { + if err = k.Db.WithContext(ctx).Model(k.modelInstance()).Where(fmt.Sprintf("%s = ?", key), id).Updates(data).Error; err != nil { + return errcode.SqlErr(err) + } + return +} + +func (k DataTemp) UpdateByCond(ctx context.Context, cond *builder.Cond, data interface{}) (err error) { + var ( + query, _ = builder.ToBoundSQL(*cond) + model = k.Db.Model(k.modelInstance()).Where(query) + ) + err = model.WithContext(ctx).Updates(data).Error + return err +} + +func (k DataTemp) UpdateColumnByCond(ctx context.Context, cond *builder.Cond, column string, data interface{}) (err error) { + var ( + query, _ = builder.ToBoundSQL(*cond) + model = k.Db.Model(k.modelInstance()).Where(query) + ) + err = model.WithContext(ctx).Update(column, data).Error + return err +} + +func (k DataTemp) GetByKey(ctx context.Context, key string, value interface{}, data interface{}) (err error) { + if err = k.Db.WithContext(ctx).Model(k.modelInstance()).Where(fmt.Sprintf("%s = ?", key), value).Find(data).Error; err != nil { + return errcode.SqlErr(err) + } + return +} + +func (k DataTemp) DeleteByKey(ctx context.Context, key string, value interface{}) error { + result := k.Db.WithContext(ctx).Model(k.modelInstance()).Where(fmt.Sprintf("%s = ?", key), value). + Update("deleted_at", gorm.Expr("CURRENT_TIMESTAMP")) + + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errcode.NotFound("不存在或已被删除") + } + return nil +} diff --git a/xy_sh/tmpl/dataTemp/req_page.go b/xy_sh/tmpl/dataTemp/req_page.go new file mode 100644 index 0000000..87f17a2 --- /dev/null +++ b/xy_sh/tmpl/dataTemp/req_page.go @@ -0,0 +1,33 @@ +package dataTemp + +// ReqPageBo 分页请求实体 +type ReqPageBo struct { + Page int //页码,从第1页开始 + Limit int //分页大小 +} + +// GetOffset 获取便宜量 +// 确保 dataTemp/page.go 中有这些方法 +func (p *ReqPageBo) GetSize() int { + if p == nil { + return 10 // 默认每页10条 + } + if p.Limit <= 0 { + return 10 + } + return p.Limit +} + +func (p *ReqPageBo) GetOffset() int { + if p == nil { + return 0 + } + return (p.GetPage() - 1) * p.GetSize() +} + +func (p *ReqPageBo) GetPage() int { + if p == nil || p.Page <= 0 { + return 1 + } + return p.Page +} diff --git a/xy_sh/tmpl/dataTemp/resp_page.go b/xy_sh/tmpl/dataTemp/resp_page.go new file mode 100644 index 0000000..e707c8c --- /dev/null +++ b/xy_sh/tmpl/dataTemp/resp_page.go @@ -0,0 +1,22 @@ +package dataTemp + +// RespPageBo 分页响应实体 +type RespPageBo struct { + Page int //页码 + Limit int //每页大小 + Total int64 //总数 +} + +// SetDataByReq 通过req 设置响应参数 +func (r *RespPageBo) SetDataByReq(total int64, reqPage *ReqPageBo) *RespPageBo { + resp := r + if r == nil { + resp = &RespPageBo{} + } + resp.Total = total + if reqPage != nil { + resp.Page = reqPage.Page + resp.Limit = reqPage.Limit + } + return resp +} diff --git a/xy_sh/tmpl/errcode/common.go b/xy_sh/tmpl/errcode/common.go new file mode 100644 index 0000000..9b770b3 --- /dev/null +++ b/xy_sh/tmpl/errcode/common.go @@ -0,0 +1,113 @@ +package errcode + +import "fmt" + +var ( + AuthNotFound = &BusinessErr{code: AuthErr, message: "账号不存在"} + AuthStatusFreeze = &BusinessErr{code: AuthErr, message: "账号冻结"} + AuthStatusDel = &BusinessErr{code: AuthErr, message: "身份验证失败"} + AuthStatusPwdFail = &BusinessErr{code: AuthErr, message: "密码错误"} + AuthTokenCreateFail = &BusinessErr{code: AuthErr, message: "token生成失败"} + AuthTokenDelFail = &BusinessErr{code: AuthErr, message: "删除token失败"} + AuthWxLoginFail = &BusinessErr{code: AuthErr, message: "微信登录失败,请稍后重试"} + AuthInfoFail = &BusinessErr{code: AuthErr, message: "登录异常,请重新登录"} + + TokenNotFound = &BusinessErr{code: TokenErr, message: "缺少 Authorization Header"} + TokenFormatErr = &BusinessErr{code: TokenErr, message: "无效的token格式"} + TokenInfoNotFound = &BusinessErr{code: TokenErr, message: "未找到用户信息"} + TokenInvalid = &BusinessErr{code: TokenErr, message: "token过期"} + + PlatsNotFound = &BusinessErr{code: NotFoundErr, message: "信息未找到"} + BadRequest = &BusinessErr{code: BadReqErr, message: "操作失败"} + + ForbiddenError = &BusinessErr{code: ForbiddenErr, message: "权限不足"} + Success = &BusinessErr{code: 0, message: "成功"} + ParamError = &BusinessErr{code: ParamsErr, message: "参数错误"} + + SystemError = &BusinessErr{code: 405, message: "系统错误"} + + ClientNotFound = &BusinessErr{code: 406, message: "未找到client_id"} + SessionNotFound = &BusinessErr{code: 407, message: "未找到会话信息"} + UserNotFound = &BusinessErr{code: NotFoundErr, message: "不存在的用户"} + + KeyNotFound = &BusinessErr{code: 409, message: "身份验证失败"} + SysNotFound = &BusinessErr{code: 410, message: "未找到系统信息"} + SysCodeNotFound = &BusinessErr{code: 411, message: "未找到系统编码"} + InvalidParam = &BusinessErr{code: InvalidParamCode, message: "无效参数"} + WorkflowError = &BusinessErr{code: 501, message: "工作流过程错误"} + ClientInfoNotFound = &BusinessErr{code: NotFoundErr, message: "用户信息未找到"} +) + +const ( + InvalidParamCode = 408 + AuthErr = 403 + TokenErr = 401 + ParamsErr = 422 + BadReqErr = 400 + NotFoundErr = 404 + ForbiddenErr = 403 + BalanceNotEnoughCode = 402 +) + +type BusinessErr struct { + code int + message string +} + +func (e *BusinessErr) Error() string { + return e.message +} +func (e *BusinessErr) Code() int { + return e.code +} + +func NotFound(message string) *BusinessErr { + return &BusinessErr{code: NotFoundErr, message: PlatsNotFound.message + ":" + message} +} + +func (e *BusinessErr) Is(target error) bool { + _, ok := target.(*BusinessErr) + return ok +} + +// CustomErr 自定义错误 +func NewBusinessErr(code int, message string) *BusinessErr { + return &BusinessErr{code: code, message: message} +} + +func SysErrf(message string, arg ...any) *BusinessErr { + return &BusinessErr{code: SystemError.code, message: fmt.Sprintf(message, arg)} +} + +func SysErr(message string) *BusinessErr { + return &BusinessErr{code: SystemError.code, message: message} +} + +func ParamErrf(message string, arg ...any) *BusinessErr { + return &BusinessErr{code: ParamError.code, message: fmt.Sprintf(message, arg)} +} + +func ParamErr(message string) *BusinessErr { + return &BusinessErr{code: ParamError.code, message: ParamError.message + ":" + message} +} + +func SqlErr(err error) *BusinessErr { + + return &BusinessErr{code: ParamError.code, message: "数据操作失败,请联系管理员处理:" + err.Error()} +} + +func BadReq(message string) *BusinessErr { + return &BusinessErr{code: BadReqErr, message: BadRequest.message + ":" + message} +} + +func Forbidden(message string) *BusinessErr { + return &BusinessErr{code: ForbiddenErr, message: ForbiddenError.message + ":" + message} +} + +func (e *BusinessErr) Wrap(err error) *BusinessErr { + return NewBusinessErr(e.code, err.Error()) +} + +func BalanceNotEnoughErr(message string) *BusinessErr { + return NewBusinessErr(BalanceNotEnoughCode, message) +} diff --git a/xy_sh/utils/gorm.go b/xy_sh/utils/gorm.go new file mode 100644 index 0000000..17406dc --- /dev/null +++ b/xy_sh/utils/gorm.go @@ -0,0 +1,25 @@ +package utils + +import ( + "xy_sh/internal/config" + "xy_sh/utils/utils_gorm" + + "gorm.io/gorm" +) + +type Db struct { + Client *gorm.DB +} + +func NewGormDb(c *config.Config) (*Db, func()) { + transDBClient, mf := utils_gorm.DBConn(&c.DB) + //directDBClient, df := directDB(c, hLog) + cleanup := func() { + mf() + //df() + } + return &Db{ + Client: transDBClient, + //DirectDBClient: directDBClient, + }, cleanup +} diff --git a/xy_sh/utils/provider_set.go b/xy_sh/utils/provider_set.go new file mode 100644 index 0000000..142fa1a --- /dev/null +++ b/xy_sh/utils/provider_set.go @@ -0,0 +1,10 @@ +package utils + +import ( + "github.com/google/wire" +) + +var ProviderUtils = wire.NewSet( + + NewGormDb, +) diff --git a/xy_sh/utils/utils_gorm/gorm.go b/xy_sh/utils/utils_gorm/gorm.go new file mode 100644 index 0000000..24be468 --- /dev/null +++ b/xy_sh/utils/utils_gorm/gorm.go @@ -0,0 +1,42 @@ +package utils_gorm + +import ( + "database/sql" + "fmt" + "time" + "xy_sh/internal/config" + + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func DBConn(c *config.DB) (*gorm.DB, func()) { + mysqlConn, err := sql.Open(c.Driver, c.Source) + gormDB, err := gorm.Open( + mysql.New(mysql.Config{Conn: mysqlConn}), + ) + + gormDB.Logger = NewCustomLogger(gormDB) + if err != nil { + panic("failed to connect database") + } + sqlDB, err := gormDB.DB() + + // SetMaxIdleConns sets the maximum number of connections in the idle connection pool. + sqlDB.SetMaxIdleConns(int(c.MaxIdle)) + + // SetMaxOpenConns sets the maximum number of open connections to the database. + sqlDB.SetMaxOpenConns(int(c.MaxLifetime)) + + // SetConnMaxLifetime sets the maximum amount of time a connection may be reused. + sqlDB.SetConnMaxLifetime(time.Hour) + + return gormDB, func() { + if mysqlConn != nil { + fmt.Println("关闭 physicalGoodsDB") + if err := mysqlConn.Close(); err != nil { + fmt.Println("关闭 physicalGoodsDB 失败:", err) + } + } + } +} diff --git a/xy_sh/utils/utils_gorm/sql_log.go b/xy_sh/utils/utils_gorm/sql_log.go new file mode 100644 index 0000000..b34a29d --- /dev/null +++ b/xy_sh/utils/utils_gorm/sql_log.go @@ -0,0 +1,96 @@ +package utils_gorm + +import ( + "context" + "fmt" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "regexp" + "strings" + "time" +) + +type CustomLogger struct { + gormLogger logger.Interface + db *gorm.DB +} + +func NewCustomLogger(db *gorm.DB) *CustomLogger { + return &CustomLogger{ + gormLogger: logger.Default.LogMode(logger.Info), + db: db, + } +} + +func (l *CustomLogger) LogMode(level logger.LogLevel) logger.Interface { + newlogger := *l + newlogger.gormLogger = l.gormLogger.LogMode(level) + return &newlogger +} + +func (l *CustomLogger) Info(ctx context.Context, msg string, data ...interface{}) { + l.gormLogger.Info(ctx, msg, data...) +} + +func (l *CustomLogger) Warn(ctx context.Context, msg string, data ...interface{}) { + l.gormLogger.Warn(ctx, msg, data...) +} + +func (l *CustomLogger) Error(ctx context.Context, msg string, data ...interface{}) { + l.gormLogger.Error(ctx, msg, data...) +} + +func (l *CustomLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + elapsed := time.Since(begin) + sql, _ := fc() + l.gormLogger.Trace(ctx, begin, fc, err) + operation := extractOperation(sql) + tableName := extractTableName(sql) + fmt.Println(tableName) + //// 将SQL语句保存到数据库 + if operation == 0 || tableName == "sql_log" { + return + } + //go l.db.Model(&SqlLog{}).Create(&SqlLog{ + // OperatorID: 1, + // OperatorName: "test", + // SqlInfo: sql, + // TableNames: tableName, + // Type: operation, + //}) + + // 如果有需要,也可以根据执行时间(elapsed)等条件过滤或处理日志记录 + if elapsed > time.Second { + //l.gormLogger.Warn(ctx, "Slow SQL (> 1s): %s", sql) + } +} + +// extractTableName extracts the table name from a SQL query, supporting quoted table names. +func extractTableName(sql string) string { + // 使用非捕获组匹配多种SQL操作关键词 + re := regexp.MustCompile(`(?i)\b(?:from|update|into|delete\s+from)\b\s+[\` + "`" + `"]?(\w+)[\` + "`" + `"]?`) + match := re.FindStringSubmatch(sql) + + // 检查是否匹配成功 + if len(match) > 1 { + return match[1] + } + + return "" +} + +// extractOperation extracts the operation type from a SQL query. +func extractOperation(sql string) int32 { + sql = strings.TrimSpace(strings.ToLower(sql)) + var operation int32 + if strings.HasPrefix(sql, "select") { + operation = 0 + } else if strings.HasPrefix(sql, "insert") { + operation = 1 + } else if strings.HasPrefix(sql, "update") { + operation = 3 + } else if strings.HasPrefix(sql, "delete") { + operation = 2 + } + return operation +} diff --git a/xy_sh/ymt_v3/README.md b/xy_sh/ymt_v3/README.md new file mode 100644 index 0000000..5066924 --- /dev/null +++ b/xy_sh/ymt_v3/README.md @@ -0,0 +1,311 @@ +## 文档概述 +- 接口总数:5个 +- 环境配置: + - 测试环境地址:https://gateway.dev.cdlsxd.cn + - 正式环境地址:https://market.api.86698.cn +- 测试参数: + - app_id: "xxx" + - private_key: "xxx"(应用客户私钥,用于请求签名) + - public_key: "xxx"(应用平台公钥,用于平台响应或回调验签) + - key: "xxxx"(业务参数加密key) + - activity_no: "xxxx"(活动编号) + - sign_type: "RSA" + +## 认证与安全 +### 公共Header请求参数 +| 字段名称 | 类型 | 描述 | 示例值 | +|--------|------|------|--------| +| Appid | string | 分配给开发者的应用 ID | 123456 | +| Timestamp | string | 发送请求的时间,格式 yyyy-MM-dd HH:mm:ss | 2026-06-22 15:30:00 | +| Sign | string | 请求签名串 | 详见 SDK 示例 | +| Content-Type | string | 请求数据格式 | application/json | + +### 公共请求参数 +| 字段名称 | 类型 | 描述 | 示例值 | +|--------|------|------|--------| +| ciphertext | string | 请求业务参数加密串 | 详见 SDK 示例 | + +### 公共响应参数 +| 字段名称 | 类型 | 描述 | +|--------|------|------| +| code | int32 | 200 成功 | +| message | string | 请求描述 | +| reason | string | 错误原因,错误时返回 | +| data.ciphertext | string | 业务响应加密串 | + +### 业务参数加密规则 +1. 将业务参数去掉“零”值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +2. 使用应用key将plaintext字符串加密,支持两种模式:aes(ECB模式)/sm4(CBC模式),得到加密业务参数ciphertext + +### 签名规则 +1. 拼接签名字符串:分配给开发者的应用ID + 发送请求的时间 + 加密业务参数 +2. 使用应用私钥将拼接待签名字符串生成签名字符串 + +### 回调验签规则 +1. 获取header头里面的签名信息 +2. 获取body里面的业务参数data +3. 将业务参数data去掉“零”值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +4. 使用应用key将plaintext字符串加密[aes/sm4]得到加密得到ciphertext +5. 拼接签名字符串:分配给开发者的应用ID + 发送请求的时间 + ciphertext +6. 使用应用公钥验签 + +### 时间戳规则 +时间格式:yyyy-MM-dd HH:mm:ss,请求时间与服务端时间误差不能超过3分钟 + +## 接口列表 +### 接口 1:获取券码 +- 路径:/openapi/v1/key/order +- 方法:POST +- 描述:申请单个券码/权益,支持幂等 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号,幂等 | +| activity_no | string | 是 | 活动编号 | +| account | string | 否 | 账号,按活动类型透传 | +| notify_url | string | 否 | 回调通知地址 | + +#### 响应参数(解密后) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号 | +| trade_no | string | 是 | 交易号 | +| key | string | 否 | 卡密 | +| url | string | 否 | 链接型活动返回短链接,key/url 不会同时为空 | +| valid_begin_time | string | 否 | 生效时间 | +| valid_end_time | string | 否 | 失效时间 | +| usable_num | uint32 | 是 | 可用次数 | +| usage_num | uint32 | 是 | 已使用次数 | +| status | uint32 | 是 | 状态:1 正常,2 已核销,3 已作废 | +| settlement_price | float | 否 | 结算价 | +| account | string | 否 | 上报账号 | + +#### 示例 +- 明文业务参数: +```json +{ + "out_biz_no": "order_001", + "activity_no": "ACT20260622001", + "account": "18666666666", + "notify_url": "https://notify.example.com/openapi" +} +``` +- 请求体示例: +```json +{ + "ciphertext": "加密后的业务报文" +} +``` +- 异常响应示例: +```json +{ + "code": 401, + "message": "Signature verification failed", + "reason": "INVALID_SIGNATURE" +} +``` +- 成功响应示例: +```json +{ + "code": 200, + "data": { + "ciphertext": "加密后的响应报文" + }, + "message": "成功" +} +``` +- 解密后成功示例: +```json +{ + "out_biz_no": "order_001", + "trade_no": "7251449503000383488", + "key": "aZKdU9BymzR6qGRzJM", + "url": "", + "valid_begin_time": "2026-06-22 15:30:00", + "valid_end_time": "2026-12-31 23:59:59", + "usable_num": 1, + "usage_num": 0, + "status": 1, + "settlement_price": 9.9, + "account": "18666666666" +} +``` + +### 接口 2:券码查询 +- 路径:/openapi/v1/key/query +- 方法:POST +- 描述:查询已申请的券码详情 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 否 | 外部业务号,与 trade_no 二选一 | +| trade_no | string | 否 | 交易号,与 out_biz_no 二选一 | + +#### 响应参数(解密后) +与“获取券码”响应参数完全一致。 + +#### 示例 +```json +{ + "code": 200, + "data": { + "out_biz_no": "order_001", + "trade_no": "7251449503000383488", + "key": "aZKdU9BymzR6qGRzJM", + "url": "", + "valid_begin_time": "2026-06-22 15:30:00", + "valid_end_time": "2026-12-31 23:59:59", + "usable_num": 1, + "usage_num": 0, + "status": 1, + "settlement_price": 9.9, + "account": "18666666666", + "ciphertext": "" + }, + "message": "成功" +} +``` + +### 接口 3:券码作废 +- 路径:/openapi/v1/key/discard +- 方法:POST +- 描述:作废已申请的券码 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 否 | 外部业务号,与 trade_no 二选一 | +| trade_no | string | 否 | 交易号,与 out_biz_no 二选一 | + +#### 响应参数(解密后) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号 | +| trade_no | string | 是 | 交易号 | +| status | uint32 | 是 | 3 表示已作废 | + +#### 示例 +```json +{ + "code": 200, + "data": { + "out_biz_no": "order_001", + "trade_no": "7251449503000383488", + "status": 3, + "ciphertext": "" + }, + "message": "成功" +} +``` + +### 接口 4:批量发卡 +- 路径:/openapi/v1/key/batch_order +- 方法:POST +- 描述:批量申请多个券码,返回异步任务状态 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号,幂等 | +| activity_no | string | 是 | 活动编号 | +| number | int32 | 是 | 发卡数量 | +| notify_url | string | 否 | 回调通知地址 | + +#### 响应参数(解密后) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号 | +| trade_no | string | 是 | 交易号 | +| status | string | 是 | 任务状态,初始返回 processing | + +#### 示例 +- 成功响应示例: +```json +{ + "code": 200, + "data": { + "ciphertext": "加密后的响应报文" + }, + "message": "成功" +} +``` +- 解密后示例: +```json +{ + "out_biz_no": "batch_001", + "trade_no": "7251449503000383499", + "status": "processing" +} +``` + +### 接口 5:批量查询 +- 路径:/openapi/v1/key/batch_query +- 方法:POST +- 描述:查询批量发卡任务的状态和结果 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 否 | 外部业务号,与 trade_no 二选一 | +| trade_no | string | 否 | 交易号,与 out_biz_no 二选一 | + +#### 响应参数(解密后) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号 | +| trade_no | string | 是 | 交易号 | +| status | string | 是 | processing / success / failed | +| download_url | string | 否 | 批量任务成功后返回下载地址 | +| zip_password | string | 否 | 批量任务成功后返回压缩包密码 | + +#### 示例 +- 成功响应示例: +```json +{ + "code": 200, + "data": { + "ciphertext": "加密后的响应报文" + }, + "message": "成功" +} +``` +- 解密后示例: +```json +{ + "out_biz_no": "batch_001", + "trade_no": "7251449503000383499", + "status": "success", + "download_url": "https://oss.example.com/openapi\_7251449503000383499.zip", + "zip_password": "123456" +} +``` + +## 错误码 +| 错误码 | 说明 | 处理建议 | +|--------|------|----------| +| 500 | PANIC/其它 系统错误 | 联系平台处理 | +| 400 | INVALID_PAYLOAD 请求外壳格式错误 | 请检查请求 JSON 结构 | +| 400 | MISSING_PARAM 缺少必要参数 | 请检查 app_id、timestamp、sign、ciphertext | +| 400 | INVALID_TIMESTAMP 时间格式错误 | 请检查时间格式 | +| 400 | DECRYPT_FAILED 业务参数解密失败 | 请检查加密方式与密钥 | +| 400 | PARAM_FAIL 参数错误 | 请检查业务参数 | +| 400 | PARAM_DECRYPT_FAIL 明文参数格式错误 | 请检查密文解密后的业务报文 | +| 401 | APP_NOT_FOUND 应用不存在 | 请检查应用 ID | +| 401 | INVALID_SIGNATURE 签名错误 | 请检查签名串与私钥 | +| 401 | EXPIRED_TIMESTAMP 请求已过期 | 请检查客户端时间 | +| 401 | ACTIVITY_NOT_AUTH 活动未授权 | 请检查活动授权状态 | +| 401 | MERCHANT_NOT_EXIST 客户不存在 | 请检查客户是否存在 | +| 401 | MERCHANT_NOT_AUTH 客户冻结 | 请检查客户授权状态 | +| 401 | MERCHANT_APP_INCOMPLETE 客户应用配置未完善 | 请检查客户应用配置 | +| 401 | MERCHANT_APP_NOT_AUTH 应用不存在或未授权 | 请检查客户应用授权状态 | +| 403 | ACTIVITY_EXPIRE 活动已结束 | 请检查活动是否有效 | +| 403 | ACTIVITY_OUT_OF_STOCK 活动剩余量不足 | 请检查活动库存 | +| 404 | ACTIVITY_NOT_EXIST 活动不存在 | 请检查活动编号 | +| 404 | MERCHANT_ORDER_NOT_EXIST 订单不存在 | 请检查交易号或外部业务号 | +| 404 | KEY_NOT_EXIST key码不存在 | 请检查订单信息 | +| 429 | DUPLICATE_REQUEST 重复请求,请稍后重试 | 请避免短时间内重复提交同一业务号 | + +## 回调通知 +平台会将异步任务结果推送到请求中指定的notify_url地址,回调验签规则参考上述认证与安全章节的回调验签流程。 \ No newline at end of file diff --git a/xy_sh/ymt_v3/client.go b/xy_sh/ymt_v3/client.go new file mode 100644 index 0000000..dff4ed4 --- /dev/null +++ b/xy_sh/ymt_v3/client.go @@ -0,0 +1,240 @@ +package ymt_v3 + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Env 环境 +type Env string + +const ( + EnvTest Env = "test" + EnvProduct Env = "product" +) + +// 环境地址 +const ( + testBaseURL = "https://gateway.dev.cdlsxd.cn" + productBaseURL = "https://market.api.86698.cn" +) + +// ClientConfig 客户端配置 +type ClientConfig struct { + AppID string // 应用ID + PrivateKey string // 应用私钥(PEM格式) + PublicKey string // 平台公钥(PEM格式) + Key string // 业务参数加密key + EncryptType EncryptType // 加密类型:aes 或 sm4 + Env Env // 环境:test 或 product + HTTPClient *http.Client // HTTP客户端,可选 +} + +// Client SDK客户端 +type Client struct { + config *ClientConfig + httpClient *http.Client + baseURL string +} + +// NewClient 创建新的SDK客户端 +func NewClient(config *ClientConfig) (*Client, error) { + if config.AppID == "" { + return nil, fmt.Errorf("AppID不能为空") + } + if config.PrivateKey == "" { + return nil, fmt.Errorf("PrivateKey不能为空") + } + if config.PublicKey == "" { + return nil, fmt.Errorf("PublicKey不能为空") + } + if config.Key == "" { + return nil, fmt.Errorf("Key不能为空") + } + if config.EncryptType == "" { + config.EncryptType = EncryptTypeAES + } + if config.Env == "" { + config.Env = EnvTest + } + + var baseURL string + switch config.Env { + case EnvTest: + baseURL = testBaseURL + case EnvProduct: + baseURL = productBaseURL + default: + return nil, fmt.Errorf("不支持的环境: %s", config.Env) + } + + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = &http.Client{} + } + + return &Client{ + config: config, + httpClient: httpClient, + baseURL: baseURL, + }, nil +} + +// doRequest 发送请求并处理响应 +func (c *Client) doRequest(path string, bizParams interface{}, result interface{}) error { + // 1. 加密业务参数 + ciphertext, err := EncryptBizParams(bizParams, []byte(c.config.Key), c.config.EncryptType) + if err != nil { + return NewSDKError("加密业务参数失败", err) + } + + // 2. 生成时间戳 + timestamp := GenerateTimestamp() + + // 3. 构建签名字符串并签名 + signStr := BuildSignStr(c.config.AppID, timestamp, ciphertext) + sign, err := SignWithRSA(signStr, c.config.PrivateKey) + if err != nil { + return NewSDKError("签名失败", err) + } + + // 4. 构建请求体 + reqBody := map[string]string{ + "ciphertext": ciphertext, + } + reqBodyBytes, err := json.Marshal(reqBody) + if err != nil { + return NewSDKError("序列化请求体失败", err) + } + + // 5. 创建HTTP请求 + url := c.baseURL + path + req, err := http.NewRequest("POST", url, bytes.NewReader(reqBodyBytes)) + if err != nil { + return NewSDKError("创建HTTP请求失败", err) + } + + // 6. 设置Header + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Appid", c.config.AppID) + req.Header.Set("Timestamp", timestamp) + req.Header.Set("Sign", sign) + + // 7. 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return NewSDKError("发送HTTP请求失败", err) + } + defer resp.Body.Close() + + // 8. 读取响应 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return NewSDKError("读取响应失败", err) + } + + // 9. 解析公共响应 + var commonResp CommonResponse + if err := json.Unmarshal(respBody, &commonResp); err != nil { + return NewSDKError("解析响应JSON失败", err) + } + + // 10. 检查业务错误 + if commonResp.Code != 200 { + return NewAPIError(commonResp.Code, commonResp.Message, commonResp.Reason) + } + + // 11. 检查是否有加密数据 + if commonResp.Data == nil || commonResp.Data.Ciphertext == "" { + return NewSDKError("响应中没有加密数据", nil) + } + + // 12. 解密响应数据 + decrypted, err := DecryptBizParams(commonResp.Data.Ciphertext, []byte(c.config.Key), c.config.EncryptType) + if err != nil { + return NewSDKError("解密响应数据失败", err) + } + + // 13. 解析解密后的数据到结果 + if err := json.Unmarshal(decrypted, result); err != nil { + return NewSDKError("解析解密后的响应数据失败", err) + } + + return nil +} + +// ==================== API方法 ==================== + +// KeyOrder 获取券码 +// POST /openapi/v1/key/order +func (c *Client) KeyOrder(req *KeyOrderRequest) (*KeyOrderResponse, error) { + result := &KeyOrderResponse{} + if err := c.doRequest("/openapi/v1/key/order", req, result); err != nil { + return nil, err + } + return result, nil +} + +// KeyQuery 券码查询 +// POST /openapi/v1/key/query +func (c *Client) KeyQuery(req *KeyQueryRequest) (*KeyOrderResponse, error) { + result := &KeyOrderResponse{} + if err := c.doRequest("/openapi/v1/key/query", req, result); err != nil { + return nil, err + } + return result, nil +} + +// KeyDiscard 券码作废 +// POST /openapi/v1/key/discard +func (c *Client) KeyDiscard(req *KeyDiscardRequest) (*KeyDiscardResponse, error) { + result := &KeyDiscardResponse{} + if err := c.doRequest("/openapi/v1/key/discard", req, result); err != nil { + return nil, err + } + return result, nil +} + +// BatchOrder 批量发卡 +// POST /openapi/v1/key/batch_order +func (c *Client) BatchOrder(req *BatchOrderRequest) (*BatchOrderResponse, error) { + result := &BatchOrderResponse{} + if err := c.doRequest("/openapi/v1/key/batch_order", req, result); err != nil { + return nil, err + } + return result, nil +} + +// BatchQuery 批量查询 +// POST /openapi/v1/key/batch_query +func (c *Client) BatchQuery(req *BatchQueryRequest) (*BatchQueryResponse, error) { + result := &BatchQueryResponse{} + if err := c.doRequest("/openapi/v1/key/batch_query", req, result); err != nil { + return nil, err + } + return result, nil +} + +// VerifyNotify 验证回调通知 +func (c *Client) VerifyNotify(header *NotifyHeader, data interface{}) error { + return VerifyNotifySign(header, data, []byte(c.config.Key), c.config.PublicKey, c.config.EncryptType) +} + +// GetBaseURL 获取当前环境的基础URL +func (c *Client) GetBaseURL() string { + return c.baseURL +} + +// GetAppID 获取应用ID +func (c *Client) GetAppID() string { + return c.config.AppID +} + +// String 返回客户端配置的字符串表示(隐藏敏感信息) +func (c *Client) String() string { + return fmt.Sprintf("Client{AppID: %s, Env: %s, BaseURL: %s, EncryptType: %s}", + c.config.AppID, c.config.Env, c.baseURL, c.config.EncryptType) +} diff --git a/xy_sh/ymt_v3/crypto.go b/xy_sh/ymt_v3/crypto.go new file mode 100644 index 0000000..94cb87a --- /dev/null +++ b/xy_sh/ymt_v3/crypto.go @@ -0,0 +1,409 @@ +package ymt_v3 + +import ( + "bytes" + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "reflect" + "sort" + "strings" + "time" + + "github.com/tjfoc/gmsm/sm4" +) + +// EncryptType 加密类型 +type EncryptType string + +const ( + // EncryptTypeAES AES-ECB加密 + EncryptTypeAES EncryptType = "aes" + // EncryptTypeSM4 SM4-CBC加密 + EncryptTypeSM4 EncryptType = "sm4" +) + +// ==================== RSA签名 ==================== + +// SignWithRSA 使用RSA私钥对数据进行签名 +func SignWithRSA(signStr string, privateKeyPEM string) (string, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", fmt.Errorf("failed to decode PEM private key") + } + + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %v", err) + } + } + + rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("not a RSA private key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + signature, err := rsa.SignPKCS1v15(rand.Reader, rsaPrivateKey, crypto.SHA256, hashed) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(signature), nil +} + +// VerifyWithRSA 使用RSA公钥验证签名 +func VerifyWithRSA(signStr, signature string, publicKeyPEM string) error { + block, _ := pem.Decode([]byte(publicKeyPEM)) + if block == nil { + return fmt.Errorf("failed to decode PEM public key") + } + + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse public key: %v", err) + } + + rsaPublicKey, ok := publicKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("not a RSA public key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + sigBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("failed to decode signature: %v", err) + } + + return rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, hashed, sigBytes) +} + +// ==================== AES ECB加密 ==================== + +// AESECBEncrypt AES-ECB模式加密 +func AESECBEncrypt(plaintext []byte, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建AES cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, aes.BlockSize) + + ciphertext := make([]byte, len(padded)) + for i := 0; i < len(padded); i += aes.BlockSize { + block.Encrypt(ciphertext[i:i+aes.BlockSize], padded[i:i+aes.BlockSize]) + } + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// AESECBDecrypt AES-ECB模式解密 +func AESECBDecrypt(encryptedData string, key []byte) ([]byte, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建AES cipher失败: %v", err) + } + + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("密文长度不是块大小的整数倍") + } + + plaintext := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += aes.BlockSize { + block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) + } + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== SM4 CBC加密 ==================== + +// SM4CBCEncrypt SM4-CBC模式加密 +// IV会前置到密文中,解密时自动提取 +func SM4CBCEncrypt(plaintext []byte, key []byte) (string, error) { + if len(key) != 16 { + return "", fmt.Errorf("SM4密钥长度必须为16字节") + } + + block, err := sm4.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, block.BlockSize()) + + iv := make([]byte, block.BlockSize()) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", fmt.Errorf("生成IV失败: %v", err) + } + + mode := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(padded)) + mode.CryptBlocks(ciphertext, padded) + + // IV前置到密文 + result := append(iv, ciphertext...) + return base64.StdEncoding.EncodeToString(result), nil +} + +// SM4CBCDecrypt SM4-CBC模式解密 +// 从密文中提取前置的IV进行解密 +func SM4CBCDecrypt(encryptedData string, key []byte) ([]byte, error) { + if len(key) != 16 { + return nil, fmt.Errorf("SM4密钥长度必须为16字节") + } + + data, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := sm4.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + blockSize := block.BlockSize() + if len(data) < blockSize*2 { + return nil, fmt.Errorf("数据长度不足") + } + iv := data[:blockSize] + ciphertext := data[blockSize:] + + mode := cipher.NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(ciphertext)) + mode.CryptBlocks(plaintext, ciphertext) + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== PKCS7填充 ==================== + +// pkcs7Padding PKCS7填充 +func pkcs7Padding(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +// pkcs7UnPadding 去除PKCS7填充 +func pkcs7UnPadding(data []byte) ([]byte, error) { + length := len(data) + if length == 0 { + return nil, fmt.Errorf("数据为空") + } + padding := int(data[length-1]) + if padding > length || padding == 0 { + return nil, fmt.Errorf("无效的填充") + } + for i := length - padding; i < length; i++ { + if data[i] != byte(padding) { + return nil, fmt.Errorf("无效的填充") + } + } + return data[:length-padding], nil +} + +// ==================== 业务参数加密/解密 ==================== + +// EncryptBizParams 加密业务参数 +// 1. 将业务参数去掉"零"值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +// 2. 使用应用key将plaintext字符串加密,得到ciphertext +func EncryptBizParams(params interface{}, key []byte, encType EncryptType) (string, error) { + plaintext, err := marshalWithoutZero(params) + if err != nil { + return "", fmt.Errorf("序列化业务参数失败: %v", err) + } + + switch encType { + case EncryptTypeAES: + return AESECBEncrypt([]byte(plaintext), key) + case EncryptTypeSM4: + return SM4CBCEncrypt([]byte(plaintext), key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// DecryptBizParams 解密业务参数 +func DecryptBizParams(ciphertext string, key []byte, encType EncryptType) ([]byte, error) { + switch encType { + case EncryptTypeAES: + return AESECBDecrypt(ciphertext, key) + case EncryptTypeSM4: + return SM4CBCDecrypt(ciphertext, key) + default: + return nil, fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// EncryptBizParamsRaw 直接加密字节数据(用于回调验签) +func EncryptBizParamsRaw(plaintext []byte, key []byte, encType EncryptType) (string, error) { + switch encType { + case EncryptTypeAES: + return AESECBEncrypt(plaintext, key) + case EncryptTypeSM4: + return SM4CBCEncrypt(plaintext, key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// marshalWithoutZero 将结构体序列化为JSON,去掉零值字段并按字母排序 +func marshalWithoutZero(v interface{}) (string, error) { + val := reflect.ValueOf(v) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + if val.Kind() == reflect.Map { + // 类型断言为 map[string]interface{} + m, ok := v.(map[string]interface{}) + if !ok { + // 尝试从 reflect 转换 + m = make(map[string]interface{}) + for _, key := range val.MapKeys() { + m[fmt.Sprintf("%v", key.Interface())] = val.MapIndex(key).Interface() + } + } + return marshalMapSorted(m) + } + data, err := json.Marshal(v) + return string(data), err + } + + result := make(map[string]interface{}) + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + jsonTag := fieldType.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + continue + } + name := strings.Split(jsonTag, ",")[0] + + if isZeroValue(field) { + continue + } + + result[name] = field.Interface() + } + + return marshalMapSorted(result) +} + +// marshalMapSorted 将map按键排序后序列化为JSON +func marshalMapSorted(m map[string]interface{}) (string, error) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyBytes, _ := json.Marshal(k) + buf.Write(keyBytes) + buf.WriteByte(':') + valBytes, err := json.Marshal(m[k]) + if err != nil { + return "", err + } + buf.Write(valBytes) + } + buf.WriteByte('}') + + return buf.String(), nil +} + +// isZeroValue 判断reflect.Value是否为零值 +func isZeroValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Slice, reflect.Map, reflect.Ptr: + return v.IsNil() + default: + return false + } +} + +// ==================== 签名相关 ==================== + +// BuildSignStr 构建签名字符串 +// 规则:app_id + timestamp + ciphertext +func BuildSignStr(appID, timestamp, ciphertext string) string { + return appID + timestamp + ciphertext +} + +// GenerateTimestamp 生成时间戳,格式 yyyy-MM-dd HH:mm:ss +func GenerateTimestamp() string { + return time.Now().Format("2006-01-02 15:04:05") +} + +// ==================== 回调验签 ==================== + +// VerifyNotifySign 验证回调通知签名 +func VerifyNotifySign(header *NotifyHeader, data interface{}, key []byte, publicKeyPEM string, encType EncryptType) error { + plaintext, err := marshalWithoutZero(data) + if err != nil { + return fmt.Errorf("序列化回调数据失败: %v", err) + } + + ciphertext, err := EncryptBizParamsRaw([]byte(plaintext), key, encType) + if err != nil { + return fmt.Errorf("加密回调数据失败: %v", err) + } + + signStr := BuildSignStr(header.Appid, header.Timestamp, ciphertext) + + return VerifyWithRSA(signStr, header.Sign, publicKeyPEM) +} diff --git a/xy_sh/ymt_v3/errors.go b/xy_sh/ymt_v3/errors.go new file mode 100644 index 0000000..0e5bee4 --- /dev/null +++ b/xy_sh/ymt_v3/errors.go @@ -0,0 +1,50 @@ +package ymt_v3 + +import "fmt" + +// APIError 表示API返回的业务错误 +type APIError struct { + Code int32 `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` +} + +func (e *APIError) Error() string { + return fmt.Sprintf("API error: code=%d, message=%s, reason=%s", e.Code, e.Message, e.Reason) +} + +// IsSuccess 判断是否成功 +func (e *APIError) IsSuccess() bool { + return e.Code == 200 +} + +// NewAPIError 创建API错误 +func NewAPIError(code int32, message, reason string) *APIError { + return &APIError{ + Code: code, + Message: message, + Reason: reason, + } +} + +// SDKError SDK内部错误 +type SDKError struct { + Message string + Err error +} + +func (e *SDKError) Error() string { + if e.Err != nil { + return fmt.Sprintf("SDK error: %s: %v", e.Message, e.Err) + } + return fmt.Sprintf("SDK error: %s", e.Message) +} + +func (e *SDKError) Unwrap() error { + return e.Err +} + +// NewSDKError 创建SDK内部错误 +func NewSDKError(msg string, err error) *SDKError { + return &SDKError{Message: msg, Err: err} +} diff --git a/xy_sh/ymt_v3/example_test.go b/xy_sh/ymt_v3/example_test.go new file mode 100644 index 0000000..73c63da --- /dev/null +++ b/xy_sh/ymt_v3/example_test.go @@ -0,0 +1,324 @@ +package ymt_v3 + +import ( + "encoding/json" + "fmt" + "testing" +) + +// 测试参数(请替换为实际值) +const ( + testAppID = "your_app_id" + testPrivateKey = `-----BEGIN PRIVATE KEY----- +your_private_key_here +-----END PRIVATE KEY-----` + testPublicKey = `-----BEGIN PUBLIC KEY----- +your_public_key_here +-----END PUBLIC KEY-----` + testKey = "your_encryption_key_16" + testActivityNo = "your_activity_no" +) + +func ExampleClient_KeyOrder() { + // 创建客户端 + client, err := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + if err != nil { + fmt.Printf("创建客户端失败: %v\n", err) + return + } + + // 获取券码 + resp, err := client.KeyOrder(&KeyOrderRequest{ + OutBizNo: "order_001", + ActivityNo: testActivityNo, + Account: "18666666666", + NotifyURL: "https://notify.example.com/openapi", + }) + if err != nil { + fmt.Printf("获取券码失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("卡密: %s\n", resp.Key) + fmt.Printf("状态: %d\n", resp.Status) + + // Output: + // 交易号: 7251449503000383488 + // 卡密: aZKdU9BymzR6qGRzJM + // 状态: 1 +} + +func ExampleClient_KeyQuery() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 按外部业务号查询 + resp, err := client.KeyQuery(&KeyQueryRequest{ + OutBizNo: "order_001", + }) + if err != nil { + fmt.Printf("查询失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("状态: %d\n", resp.Status) +} + +func ExampleClient_KeyDiscard() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 按交易号作废 + resp, err := client.KeyDiscard(&KeyDiscardRequest{ + TradeNo: "7251449503000383488", + }) + if err != nil { + fmt.Printf("作废失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("状态: %d\n", resp.Status) +} + +func ExampleClient_BatchOrder() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 批量发卡 + resp, err := client.BatchOrder(&BatchOrderRequest{ + OutBizNo: "batch_001", + ActivityNo: testActivityNo, + Number: 100, + NotifyURL: "https://notify.example.com/openapi", + }) + if err != nil { + fmt.Printf("批量发卡失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("状态: %s\n", resp.Status) +} + +func ExampleClient_BatchQuery() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 查询批量任务 + resp, err := client.BatchQuery(&BatchQueryRequest{ + TradeNo: "7251449503000383499", + }) + if err != nil { + fmt.Printf("查询失败: %v\n", err) + return + } + + fmt.Printf("状态: %s\n", resp.Status) + if resp.DownloadURL != "" { + fmt.Printf("下载地址: %s\n", resp.DownloadURL) + } +} + +func TestMarshalWithoutZero(t *testing.T) { + req := KeyOrderRequest{ + OutBizNo: "order_001", + ActivityNo: "ACT20260622001", + // Account和NotifyURL为空,应该被去掉 + } + + result, err := marshalWithoutZero(req) + if err != nil { + t.Fatalf("marshalWithoutZero失败: %v", err) + } + + expected := `{"activity_no":"ACT20260622001","out_biz_no":"order_001"}` + if result != expected { + t.Errorf("期望 %s, 得到 %s", expected, result) + } +} + +func TestBuildSignStr(t *testing.T) { + appID := "123456" + timestamp := "2026-06-22 15:30:00" + ciphertext := "encrypted_data" + + signStr := BuildSignStr(appID, timestamp, ciphertext) + expected := "1234562026-06-22 15:30:00encrypted_data" + if signStr != expected { + t.Errorf("期望 %s, 得到 %s", expected, signStr) + } +} + +func TestGenerateTimestamp(t *testing.T) { + ts := GenerateTimestamp() + // 验证格式 yyyy-MM-dd HH:mm:ss + if len(ts) != 19 { + t.Errorf("时间戳格式错误: %s", ts) + } +} + +func TestEncryptDecryptAES(t *testing.T) { + key := []byte("1234567890123456") // 16字节 + plaintext := `{"out_biz_no":"order_001","activity_no":"ACT001"}` + + ciphertext, err := AESECBEncrypt([]byte(plaintext), key) + if err != nil { + t.Fatalf("加密失败: %v", err) + } + + decrypted, err := AESECBDecrypt(ciphertext, key) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + + if string(decrypted) != plaintext { + t.Errorf("解密结果不匹配: 期望 %s, 得到 %s", plaintext, string(decrypted)) + } +} + +func TestEncryptDecryptSM4(t *testing.T) { + key := []byte("1234567890123456") // 16字节 + plaintext := `{"out_biz_no":"order_001","activity_no":"ACT001"}` + + ciphertext, err := SM4CBCEncrypt([]byte(plaintext), key) + if err != nil { + t.Fatalf("SM4加密失败: %v", err) + } + + decrypted, err := SM4CBCDecrypt(ciphertext, key) + if err != nil { + t.Fatalf("SM4解密失败: %v", err) + } + + if string(decrypted) != plaintext { + t.Errorf("SM4解密结果不匹配: 期望 %s, 得到 %s", plaintext, string(decrypted)) + } +} + +func TestEncryptBizParams(t *testing.T) { + key := []byte("1234567890123456") + req := KeyOrderRequest{ + OutBizNo: "order_001", + ActivityNo: "ACT20260622001", + } + + ciphertext, err := EncryptBizParams(req, key, EncryptTypeAES) + if err != nil { + t.Fatalf("加密业务参数失败: %v", err) + } + + if ciphertext == "" { + t.Fatal("加密结果为空") + } + + // 验证可以解密 + decrypted, err := DecryptBizParams(ciphertext, key, EncryptTypeAES) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + + var result KeyOrderRequest + if err := json.Unmarshal(decrypted, &result); err != nil { + t.Fatalf("解析失败: %v", err) + } + + if result.OutBizNo != "order_001" { + t.Errorf("OutBizNo不匹配: 期望 order_001, 得到 %s", result.OutBizNo) + } +} + +func TestNewClient(t *testing.T) { + _, err := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + if err != nil { + t.Fatalf("创建客户端失败: %v", err) + } +} + +func TestNewClientMissingFields(t *testing.T) { + tests := []struct { + name string + config *ClientConfig + wantErr bool + }{ + {"空AppID", &ClientConfig{PrivateKey: "pk", PublicKey: "pub", Key: "key"}, true}, + {"空PrivateKey", &ClientConfig{AppID: "app", PublicKey: "pub", Key: "key"}, true}, + {"空PublicKey", &ClientConfig{AppID: "app", PrivateKey: "pk", Key: "key"}, true}, + {"空Key", &ClientConfig{AppID: "app", PrivateKey: "pk", PublicKey: "pub"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewClient(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("NewClient() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestAPIError(t *testing.T) { + err := NewAPIError(401, "Signature verification failed", "INVALID_SIGNATURE") + if err.Code != 401 { + t.Errorf("Code不匹配: 期望 401, 得到 %d", err.Code) + } + if err.Message != "Signature verification failed" { + t.Errorf("Message不匹配") + } + if err.Reason != "INVALID_SIGNATURE" { + t.Errorf("Reason不匹配") + } + if err.Error() != "API error: code=401, message=Signature verification failed, reason=INVALID_SIGNATURE" { + t.Errorf("Error()不匹配: %s", err.Error()) + } + if err.IsSuccess() { + t.Error("401应该不是成功") + } +} + +func TestSDKError(t *testing.T) { + err := NewSDKError("测试错误", nil) + if err.Error() != "SDK error: 测试错误" { + t.Errorf("Error()不匹配: %s", err.Error()) + } +} diff --git a/xy_sh/ymt_v3/generate.md b/xy_sh/ymt_v3/generate.md new file mode 100644 index 0000000..81ae245 --- /dev/null +++ b/xy_sh/ymt_v3/generate.md @@ -0,0 +1,2092 @@ +。 + +现在我来生成所有 6 个文件。根据文档,签名规则是拼接:app_id + timestamp + ciphertext,然后使用 RSA 私钥签名。业务参数加密使用 AES ECB 或 SM4 CBC。 + +让我生成完整的 SDK 代码。 + +// File: ymt_v3/go.mod +```go +module ymt_v3 + +go 1.21 + +require github.com/tjfoc/gmsm v1.4.1 +``` + +// File: ymt_v3/types.go +```go +package ymt_v3 + +// CommonResponse 公共响应结构 +type CommonResponse struct { + Code int32 `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` + Data *CommonData `json:"data,omitempty"` +} + +// CommonData 公共响应数据(包含加密的ciphertext) +type CommonData struct { + Ciphertext string `json:"ciphertext"` +} + +// KeyOrderRequest 获取券码请求业务参数 +type KeyOrderRequest struct { + OutBizNo string `json:"out_biz_no"` + ActivityNo string `json:"activity_no"` + Account string `json:"account,omitempty"` + NotifyURL string `json:"notify_url,omitempty"` +} + +// KeyOrderResponse 获取券码/券码查询响应业务参数 +type KeyOrderResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Key string `json:"key,omitempty"` + URL string `json:"url,omitempty"` + ValidBeginTime string `json:"valid_begin_time,omitempty"` + ValidEndTime string `json:"valid_end_time,omitempty"` + UsableNum uint32 `json:"usable_num"` + UsageNum uint32 `json:"usage_num"` + Status uint32 `json:"status"` + SettlementPrice float64 `json:"settlement_price,omitempty"` + Account string `json:"account,omitempty"` +} + +// KeyQueryRequest 券码查询请求业务参数 +type KeyQueryRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +// KeyDiscardRequest 券码作废请求业务参数 +type KeyDiscardRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +// KeyDiscardResponse 券码作废响应业务参数 +type KeyDiscardResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status uint32 `json:"status"` +} + +// BatchOrderRequest 批量发卡请求业务参数 +type BatchOrderRequest struct { + OutBizNo string `json:"out_biz_no"` + ActivityNo string `json:"activity_no"` + Number int32 `json:"number"` + NotifyURL string `json:"notify_url,omitempty"` +} + +// BatchOrderResponse 批量发卡响应业务参数 +type BatchOrderResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status string `json:"status"` +} + +// BatchQueryRequest 批量查询请求业务参数 +type BatchQueryRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +// BatchQueryResponse 批量查询响应业务参数 +type BatchQueryResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status string `json:"status"` + DownloadURL string `json:"download_url,omitempty"` + ZipPassword string `json:"zip_password,omitempty"` +} + +// NotifyPayload 回调通知的body结构 +type NotifyPayload struct { + Data *CommonData `json:"data"` +} + +// NotifyHeader 回调通知的header +type NotifyHeader struct { + Appid string `json:"Appid"` + Timestamp string `json:"Timestamp"` + Sign string `json:"Sign"` +} +``` + +// File: ymt_v3/errors.go +```go +package ymt_v3 + +import "fmt" + +// APIError 表示API返回的业务错误 +type APIError struct { + Code int32 `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` +} + +func (e *APIError) Error() string { + return fmt.Sprintf("API error: code=%d, message=%s, reason=%s", e.Code, e.Message, e.Reason) +} + +// IsSuccess 判断是否成功 +func (e *APIError) IsSuccess() bool { + return e.Code == 200 +} + +// NewAPIError 创建API错误 +func NewAPIError(code int32, message, reason string) *APIError { + return &APIError{ + Code: code, + Message: message, + Reason: reason, + } +} + +// SDKError SDK内部错误 +type SDKError struct { + Message string + Err error +} + +func (e *SDKError) Error() string { + if e.Err != nil { + return fmt.Sprintf("SDK error: %s: %v", e.Message, e.Err) + } + return fmt.Sprintf("SDK error: %s", e.Message) +} + +func (e *SDKError) Unwrap() error { + return e.Err +} + +// NewSDKError 创建SDK内部错误 +func NewSDKError(msg string, err error) *SDKError { + return &SDKError{Message: msg, Err: err} +} +``` + +// File: ymt_v3/crypto.go +```go +package ymt_v3 + +import ( + "bytes" + "crypto" + "crypto/aes" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/sha512" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "reflect" + "sort" + "strings" + "time" + + "github.com/tjfoc/gmsm/sm4" +) + +// EncryptType 加密类型 +type EncryptType string + +const ( + EncryptTypeAES EncryptType = "aes" + EncryptTypeSM4 EncryptType = "sm4" +) + +// ==================== RSA签名 ==================== + +// SignWithRSA 使用RSA私钥对数据进行签名 +// 签名串拼接规则:app_id + timestamp + ciphertext +func SignWithRSA(signStr string, privateKeyPEM string) (string, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", fmt.Errorf("failed to decode PEM private key") + } + + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + // 尝试PKCS1格式 + privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %v", err) + } + } + + rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("not a RSA private key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + signature, err := rsa.SignPKCS1v15(rand.Reader, rsaPrivateKey, crypto.SHA256, hashed) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(signature), nil +} + +// VerifyWithRSA 使用RSA公钥验证签名 +// 验签串拼接规则:app_id + timestamp + ciphertext +func VerifyWithRSA(signStr, signature string, publicKeyPEM string) error { + block, _ := pem.Decode([]byte(publicKeyPEM)) + if block == nil { + return fmt.Errorf("failed to decode PEM public key") + } + + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse public key: %v", err) + } + + rsaPublicKey, ok := publicKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("not a RSA public key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + sigBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("failed to decode signature: %v", err) + } + + return rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, hashed, sigBytes) +} + +// ==================== AES ECB加密 ==================== + +// AESECBEncrypt AES-ECB模式加密 +func AESECBEncrypt(plaintext []byte, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建AES cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, aes.BlockSize) + + ciphertext := make([]byte, len(padded)) + for i := 0; i < len(padded); i += aes.BlockSize { + block.Encrypt(ciphertext[i:i+aes.BlockSize], padded[i:i+aes.BlockSize]) + } + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// AESECBDecrypt AES-ECB模式解密 +func AESECBDecrypt(encryptedData string, key []byte) ([]byte, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建AES cipher失败: %v", err) + } + + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("密文长度不是块大小的整数倍") + } + + plaintext := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += aes.BlockSize { + block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) + } + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== SM4 CBC加密 ==================== + +// SM4CBCEncrypt SM4-CBC模式加密 +func SM4CBCEncrypt(plaintext []byte, key []byte) (string, error) { + if len(key) != 16 { + return "", fmt.Errorf("SM4密钥长度必须为16字节") + } + + block, err := sm4.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, block.BlockSize()) + + iv := make([]byte, block.BlockSize()) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", fmt.Errorf("生成IV失败: %v", err) + } + + mode := NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(padded)) + mode.CryptBlocks(ciphertext, padded) + + result := append(iv, ciphertext...) + return base64.StdEncoding.EncodeToString(result), nil +} + +// SM4CBCDecrypt SM4-CBC模式解密 +func SM4CBCDecrypt(encryptedData string, key []byte) ([]byte, error) { + if len(key) != 16 { + return nil, fmt.Errorf("SM4密钥长度必须为16字节") + } + + data, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := sm4.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + blockSize := block.BlockSize() + if len(data) < blockSize { + return nil, fmt.Errorf("数据长度不足") + } + iv := data[:blockSize] + ciphertext := data[blockSize:] + + mode := NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(ciphertext)) + mode.CryptBlocks(plaintext, ciphertext) + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== PKCS7填充 ==================== + +// pkcs7Padding PKCS7填充 +func pkcs7Padding(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +// pkcs7UnPadding 去除PKCS7填充 +func pkcs7UnPadding(data []byte) ([]byte, error) { + length := len(data) + if length == 0 { + return nil, fmt.Errorf("数据为空") + } + padding := int(data[length-1]) + if padding > length || padding == 0 { + return nil, fmt.Errorf("无效的填充") + } + for i := length - padding; i < length; i++ { + if data[i] != byte(padding) { + return nil, fmt.Errorf("无效的填充") + } + } + return data[:length-padding], nil +} + +// ==================== CBC模式实现(用于SM4) ==================== + +// cbc 实现了CBC模式加密/解密 +type cbc struct { + b cipherBlock + blockSize int + iv []byte +} + +type cipherBlock interface { + BlockSize() int + Encrypt(dst, src []byte) + Decrypt(dst, src []byte) +} + +// NewCBCEncrypter 创建CBC加密器 +func NewCBCEncrypter(b cipherBlock, iv []byte) *cbc { + return &cbc{ + b: b, + blockSize: b.BlockSize(), + iv: copyBytes(iv), + } +} + +// NewCBCDecrypter 创建CBC解密器 +func NewCBCDecrypter(b cipherBlock, iv []byte) *cbc { + return &cbc{ + b: b, + blockSize: b.BlockSize(), + iv: copyBytes(iv), + } +} + +func copyBytes(src []byte) []byte { + dst := make([]byte, len(src)) + copy(dst, src) + return dst +} + +// CryptBlocks 加密/解密数据块 +func (x *cbc) CryptBlocks(dst, src []byte) { + if len(src)%x.blockSize != 0 { + panic("crypto/cipher: input not full blocks") + } + if len(dst) < len(src) { + panic("crypto/cipher: output smaller than input") + } + + iv := x.iv + + for i := 0; i < len(src); i += x.blockSize { + // 对于加密:先 XOR IV,再加密 + // 对于解密:先解密,再 XOR IV + // 这里我们通过判断是加密还是解密来区分 + // 简单起见,我们使用一个标志 + } + + // 重新实现:使用内部类型区分 + panic("use Encrypt or Decrypt specific methods") +} + +// 使用标准库的cipher.BlockMode +// 由于sm4.NewCipher返回的是cipher.Block接口,我们可以直接使用cipher.NewCBCEncrypter +// 但为了兼容性,我们使用标准库 + +// ==================== 业务参数加密/解密 ==================== + +// EncryptBizParams 加密业务参数 +// 1. 将业务参数去掉"零"值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +// 2. 使用应用key将plaintext字符串加密,得到ciphertext +func EncryptBizParams(params interface{}, key []byte, encType EncryptType) (string, error) { + // 去掉零值参数并按字母排序 + plaintext, err := marshalWithoutZero(params) + if err != nil { + return "", fmt.Errorf("序列化业务参数失败: %v", err) + } + + switch encType { + case EncryptTypeAES: + return AESECBEncrypt([]byte(plaintext), key) + case EncryptTypeSM4: + return SM4CBCEncrypt([]byte(plaintext), key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// DecryptBizParams 解密业务参数 +func DecryptBizParams(ciphertext string, key []byte, encType EncryptType) ([]byte, error) { + switch encType { + case EncryptTypeAES: + return AESECBDecrypt(ciphertext, key) + case EncryptTypeSM4: + return SM4CBCDecrypt(ciphertext, key) + default: + return nil, fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// marshalWithoutZero 将结构体序列化为JSON,去掉零值字段并按字母排序 +func marshalWithoutZero(v interface{}) (string, error) { + val := reflect.ValueOf(v) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + // 如果是map,直接排序后转json + if val.Kind() == reflect.Map { + return marshalMapSorted(v) + } + // 否则直接json序列化 + data, err := json.Marshal(v) + return string(data), err + } + + result := make(map[string]interface{}) + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + // 获取json tag + jsonTag := fieldType.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + continue + } + // 处理omitempty + name := strings.Split(jsonTag, ",")[0] + + // 检查是否是零值 + if isZeroValue(field) { + continue + } + + result[name] = field.Interface() + } + + return marshalMapSorted(result) +} + +// marshalMapSorted 将map按键排序后序列化为JSON +func marshalMapSorted(m map[string]interface{}) (string, error) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + // 写入key + keyBytes, _ := json.Marshal(k) + buf.Write(keyBytes) + buf.WriteByte(':') + // 写入value + valBytes, err := json.Marshal(m[k]) + if err != nil { + return "", err + } + buf.Write(valBytes) + } + buf.WriteByte('}') + + return buf.String(), nil +} + +// isZeroValue 判断reflect.Value是否为零值 +func isZeroValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Slice, reflect.Map, reflect.Ptr: + return v.IsNil() + default: + return false + } +} + +// ==================== 签名相关 ==================== + +// BuildSignStr 构建签名字符串 +// 规则:app_id + timestamp + ciphertext +func BuildSignStr(appID, timestamp, ciphertext string) string { + return appID + timestamp + ciphertext +} + +// GenerateTimestamp 生成时间戳,格式 yyyy-MM-dd HH:mm:ss +func GenerateTimestamp() string { + return time.Now().Format("2006-01-02 15:04:05") +} + +// ==================== 回调验签 ==================== + +// VerifyNotifySign 验证回调通知签名 +// 1. 获取header头里面的签名信息 +// 2. 获取body里面的业务参数data +// 3. 将业务参数data去掉"零"值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +// 4. 使用应用key将plaintext字符串加密得到ciphertext +// 5. 拼接签名字符串:app_id + timestamp + ciphertext +// 6. 使用应用公钥验签 +func VerifyNotifySign(header *NotifyHeader, data interface{}, key []byte, publicKeyPEM string, encType EncryptType) error { + // 3. 将业务参数data去掉零值参数,排序,转json + plaintext, err := marshalWithoutZero(data) + if err != nil { + return fmt.Errorf("序列化回调数据失败: %v", err) + } + + // 4. 加密得到ciphertext + ciphertext, err := EncryptBizParamsRaw([]byte(plaintext), key, encType) + if err != nil { + return fmt.Errorf("加密回调数据失败: %v", err) + } + + // 5. 拼接签名字符串 + signStr := BuildSignStr(header.Appid, header.Timestamp, ciphertext) + + // 6. 使用应用公钥验签 + return VerifyWithRSA(signStr, header.Sign, publicKeyPEM) +} + +// EncryptBizParamsRaw 直接加密字节数据 +func EncryptBizParamsRaw(plaintext []byte, key []byte, encType EncryptType) (string, error) { + switch encType { + case EncryptTypeAES: + return AESECBEncrypt(plaintext, key) + case EncryptTypeSM4: + return SM4CBCEncrypt(plaintext, key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} +``` + +// File: ymt_v3/client.go +```go +package ymt_v3 + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// Env 环境 +type Env string + +const ( + EnvTest Env = "test" + EnvProduct Env = "product" +) + +// 环境地址 +const ( + testBaseURL = "https://gateway.dev.cdlsxd.cn" + productBaseURL = "https://market.api.86698.cn" +) + +// ClientConfig 客户端配置 +type ClientConfig struct { + AppID string // 应用ID + PrivateKey string // 应用私钥(PEM格式) + PublicKey string // 平台公钥(PEM格式) + Key string // 业务参数加密key + EncryptType EncryptType // 加密类型:aes 或 sm4 + Env Env // 环境:test 或 product + HTTPClient *http.Client // HTTP客户端,可选 +} + +// Client SDK客户端 +type Client struct { + config *ClientConfig + httpClient *http.Client + baseURL string +} + +// NewClient 创建新的SDK客户端 +func NewClient(config *ClientConfig) (*Client, error) { + if config.AppID == "" { + return nil, fmt.Errorf("AppID不能为空") + } + if config.PrivateKey == "" { + return nil, fmt.Errorf("PrivateKey不能为空") + } + if config.PublicKey == "" { + return nil, fmt.Errorf("PublicKey不能为空") + } + if config.Key == "" { + return nil, fmt.Errorf("Key不能为空") + } + if config.EncryptType == "" { + config.EncryptType = EncryptTypeAES + } + if config.Env == "" { + config.Env = EnvTest + } + + var baseURL string + switch config.Env { + case EnvTest: + baseURL = testBaseURL + case EnvProduct: + baseURL = productBaseURL + default: + return nil, fmt.Errorf("不支持的环境: %s", config.Env) + } + + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = &http.Client{} + } + + return &Client{ + config: config, + httpClient: httpClient, + baseURL: baseURL, + }, nil +} + +// doRequest 发送请求并处理响应 +func (c *Client) doRequest(path string, bizParams interface{}, result interface{}) error { + // 1. 加密业务参数 + ciphertext, err := EncryptBizParams(bizParams, []byte(c.config.Key), c.config.EncryptType) + if err != nil { + return NewSDKError("加密业务参数失败", err) + } + + // 2. 生成时间戳 + timestamp := GenerateTimestamp() + + // 3. 构建签名字符串并签名 + signStr := BuildSignStr(c.config.AppID, timestamp, ciphertext) + sign, err := SignWithRSA(signStr, c.config.PrivateKey) + if err != nil { + return NewSDKError("签名失败", err) + } + + // 4. 构建请求体 + reqBody := map[string]string{ + "ciphertext": ciphertext, + } + reqBodyBytes, err := json.Marshal(reqBody) + if err != nil { + return NewSDKError("序列化请求体失败", err) + } + + // 5. 创建HTTP请求 + url := c.baseURL + path + req, err := http.NewRequest("POST", url, bytes.NewReader(reqBodyBytes)) + if err != nil { + return NewSDKError("创建HTTP请求失败", err) + } + + // 6. 设置Header + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Appid", c.config.AppID) + req.Header.Set("Timestamp", timestamp) + req.Header.Set("Sign", sign) + + // 7. 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return NewSDKError("发送HTTP请求失败", err) + } + defer resp.Body.Close() + + // 8. 读取响应 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return NewSDKError("读取响应失败", err) + } + + // 9. 解析公共响应 + var commonResp CommonResponse + if err := json.Unmarshal(respBody, &commonResp); err != nil { + return NewSDKError("解析响应JSON失败", err) + } + + // 10. 检查业务错误 + if commonResp.Code != 200 { + return NewAPIError(commonResp.Code, commonResp.Message, commonResp.Reason) + } + + // 11. 检查是否有加密数据 + if commonResp.Data == nil || commonResp.Data.Ciphertext == "" { + return NewSDKError("响应中没有加密数据", nil) + } + + // 12. 解密响应数据 + decrypted, err := DecryptBizParams(commonResp.Data.Ciphertext, []byte(c.config.Key), c.config.EncryptType) + if err != nil { + return NewSDKError("解密响应数据失败", err) + } + + // 13. 解析解密后的数据到结果 + if err := json.Unmarshal(decrypted, result); err != nil { + return NewSDKError("解析解密后的响应数据失败", err) + } + + return nil +} + +// ==================== API方法 ==================== + +// KeyOrder 获取券码 +// POST /openapi/v1/key/order +func (c *Client) KeyOrder(req *KeyOrderRequest) (*KeyOrderResponse, error) { + result := &KeyOrderResponse{} + if err := c.doRequest("/openapi/v1/key/order", req, result); err != nil { + return nil, err + } + return result, nil +} + +// KeyQuery 券码查询 +// POST /openapi/v1/key/query +func (c *Client) KeyQuery(req *KeyQueryRequest) (*KeyOrderResponse, error) { + result := &KeyOrderResponse{} + if err := c.doRequest("/openapi/v1/key/query", req, result); err != nil { + return nil, err + } + return result, nil +} + +// KeyDiscard 券码作废 +// POST /openapi/v1/key/discard +func (c *Client) KeyDiscard(req *KeyDiscardRequest) (*KeyDiscardResponse, error) { + result := &KeyDiscardResponse{} + if err := c.doRequest("/openapi/v1/key/discard", req, result); err != nil { + return nil, err + } + return result, nil +} + +// BatchOrder 批量发卡 +// POST /openapi/v1/key/batch_order +func (c *Client) BatchOrder(req *BatchOrderRequest) (*BatchOrderResponse, error) { + result := &BatchOrderResponse{} + if err := c.doRequest("/openapi/v1/key/batch_order", req, result); err != nil { + return nil, err + } + return result, nil +} + +// BatchQuery 批量查询 +// POST /openapi/v1/key/batch_query +func (c *Client) BatchQuery(req *BatchQueryRequest) (*BatchQueryResponse, error) { + result := &BatchQueryResponse{} + if err := c.doRequest("/openapi/v1/key/batch_query", req, result); err != nil { + return nil, err + } + return result, nil +} + +// VerifyNotify 验证回调通知 +func (c *Client) VerifyNotify(header *NotifyHeader, data interface{}) error { + return VerifyNotifySign(header, data, []byte(c.config.Key), c.config.PublicKey, c.config.EncryptType) +} + +// GetBaseURL 获取当前环境的基础URL +func (c *Client) GetBaseURL() string { + return c.baseURL +} + +// GetAppID 获取应用ID +func (c *Client) GetAppID() string { + return c.config.AppID +} + +// String 返回客户端配置的字符串表示(隐藏敏感信息) +func (c *Client) String() string { + return fmt.Sprintf("Client{AppID: %s, Env: %s, BaseURL: %s, EncryptType: %s}", + c.config.AppID, c.config.Env, c.baseURL, c.config.EncryptType) +} +``` + +// File: ymt_v3/example_test.go +```go +package ymt_v3 + +import ( + "encoding/json" + "fmt" + "testing" +) + +// 测试参数(请替换为实际值) +const ( + testAppID = "your_app_id" + testPrivateKey = `-----BEGIN PRIVATE KEY----- +your_private_key_here +-----END PRIVATE KEY-----` + testPublicKey = `-----BEGIN PUBLIC KEY----- +your_public_key_here +-----END PUBLIC KEY-----` + testKey = "your_encryption_key_16" + testActivityNo = "your_activity_no" +) + +func ExampleClient_KeyOrder() { + // 创建客户端 + client, err := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + if err != nil { + fmt.Printf("创建客户端失败: %v\n", err) + return + } + + // 获取券码 + resp, err := client.KeyOrder(&KeyOrderRequest{ + OutBizNo: "order_001", + ActivityNo: testActivityNo, + Account: "18666666666", + NotifyURL: "https://notify.example.com/openapi", + }) + if err != nil { + fmt.Printf("获取券码失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("卡密: %s\n", resp.Key) + fmt.Printf("状态: %d\n", resp.Status) + + // Output: + // 交易号: 7251449503000383488 + // 卡密: aZKdU9BymzR6qGRzJM + // 状态: 1 +} + +func ExampleClient_KeyQuery() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 按外部业务号查询 + resp, err := client.KeyQuery(&KeyQueryRequest{ + OutBizNo: "order_001", + }) + if err != nil { + fmt.Printf("查询失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("状态: %d\n", resp.Status) +} + +func ExampleClient_KeyDiscard() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 按交易号作废 + resp, err := client.KeyDiscard(&KeyDiscardRequest{ + TradeNo: "7251449503000383488", + }) + if err != nil { + fmt.Printf("作废失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("状态: %d\n", resp.Status) +} + +func ExampleClient_BatchOrder() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 批量发卡 + resp, err := client.BatchOrder(&BatchOrderRequest{ + OutBizNo: "batch_001", + ActivityNo: testActivityNo, + Number: 100, + NotifyURL: "https://notify.example.com/openapi", + }) + if err != nil { + fmt.Printf("批量发卡失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("状态: %s\n", resp.Status) +} + +func ExampleClient_BatchQuery() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 查询批量任务 + resp, err := client.BatchQuery(&BatchQueryRequest{ + TradeNo: "7251449503000383499", + }) + if err != nil { + fmt.Printf("查询失败: %v\n", err) + return + } + + fmt.Printf("状态: %s\n", resp.Status) + if resp.DownloadURL != "" { + fmt.Printf("下载地址: %s\n", resp.DownloadURL) + } +} + +func TestMarshalWithoutZero(t *testing.T) { + req := KeyOrderRequest{ + OutBizNo: "order_001", + ActivityNo: "ACT20260622001", + // Account和NotifyURL为空,应该被去掉 + } + + result, err := marshalWithoutZero(req) + if err != nil { + t.Fatalf("marshalWithoutZero失败: %v", err) + } + + expected := `{"activity_no":"ACT20260622001","out_biz_no":"order_001"}` + if result != expected { + t.Errorf("期望 %s, 得到 %s", expected, result) + } +} + +func TestBuildSignStr(t *testing.T) { + appID := "123456" + timestamp := "2026-06-22 15:30:00" + ciphertext := "encrypted_data" + + signStr := BuildSignStr(appID, timestamp, ciphertext) + expected := "1234562026-06-22 15:30:00encrypted_data" + if signStr != expected { + t.Errorf("期望 %s, 得到 %s", expected, signStr) + } +} + +func TestGenerateTimestamp(t *testing.T) { + ts := GenerateTimestamp() + // 验证格式 yyyy-MM-dd HH:mm:ss + if len(ts) != 19 { + t.Errorf("时间戳格式错误: %s", ts) + } +} + +func TestEncryptDecryptAES(t *testing.T) { + key := []byte("1234567890123456") // 16字节 + plaintext := `{"out_biz_no":"order_001","activity_no":"ACT001"}` + + ciphertext, err := AESECBEncrypt([]byte(plaintext), key) + if err != nil { + t.Fatalf("加密失败: %v", err) + } + + decrypted, err := AESECBDecrypt(ciphertext, key) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + + if string(decrypted) != plaintext { + t.Errorf("解密结果不匹配: 期望 %s, 得到 %s", plaintext, string(decrypted)) + } +} + +func TestEncryptDecryptSM4(t *testing.T) { + key := []byte("1234567890123456") // 16字节 + plaintext := `{"out_biz_no":"order_001","activity_no":"ACT001"}` + + ciphertext, err := SM4CBCEncrypt([]byte(plaintext), key) + if err != nil { + t.Fatalf("SM4加密失败: %v", err) + } + + decrypted, err := SM4CBCDecrypt(ciphertext, key) + if err != nil { + t.Fatalf("SM4解密失败: %v", err) + } + + if string(decrypted) != plaintext { + t.Errorf("SM4解密结果不匹配: 期望 %s, 得到 %s", plaintext, string(decrypted)) + } +} + +func TestEncryptBizParams(t *testing.T) { + key := []byte("1234567890123456") + req := KeyOrderRequest{ + OutBizNo: "order_001", + ActivityNo: "ACT20260622001", + } + + ciphertext, err := EncryptBizParams(req, key, EncryptTypeAES) + if err != nil { + t.Fatalf("加密业务参数失败: %v", err) + } + + if ciphertext == "" { + t.Fatal("加密结果为空") + } + + // 验证可以解密 + decrypted, err := DecryptBizParams(ciphertext, key, EncryptTypeAES) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + + var result KeyOrderRequest + if err := json.Unmarshal(decrypted, &result); err != nil { + t.Fatalf("解析失败: %v", err) + } + + if result.OutBizNo != "order_001" { + t.Errorf("OutBizNo不匹配: 期望 order_001, 得到 %s", result.OutBizNo) + } +} + +func TestNewClient(t *testing.T) { + _, err := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + if err != nil { + t.Fatalf("创建客户端失败: %v", err) + } +} + +func TestNewClientMissingFields(t *testing.T) { + tests := []struct { + name string + config *ClientConfig + wantErr bool + }{ + {"空AppID", &ClientConfig{PrivateKey: "pk", PublicKey: "pub", Key: "key"}, true}, + {"空PrivateKey", &ClientConfig{AppID: "app", PublicKey: "pub", Key: "key"}, true}, + {"空PublicKey", &ClientConfig{AppID: "app", PrivateKey: "pk", Key: "key"}, true}, + {"空Key", &ClientConfig{AppID: "app", PrivateKey: "pk", PublicKey: "pub"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewClient(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("NewClient() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestAPIError(t *testing.T) { + err := NewAPIError(401, "Signature verification failed", "INVALID_SIGNATURE") + if err.Code != 401 { + t.Errorf("Code不匹配: 期望 401, 得到 %d", err.Code) + } + if err.Message != "Signature verification failed" { + t.Errorf("Message不匹配") + } + if err.Reason != "INVALID_SIGNATURE" { + t.Errorf("Reason不匹配") + } + if err.Error() != "API error: code=401, message=Signature verification failed, reason=INVALID_SIGNATURE" { + t.Errorf("Error()不匹配: %s", err.Error()) + } + if err.IsSuccess() { + t.Error("401应该不是成功") + } +} + +func TestSDKError(t *testing.T) { + err := NewSDKError("测试错误", nil) + if err.Error() != "SDK error: 测试错误" { + t.Errorf("Error()不匹配: %s", err.Error()) + } +} +``` + +现在让我检查一下 crypto.go 中的 CBC 实现,我需要修复它,因为之前的实现有 panic。让我重新生成 crypto.go 文件,使用标准库的 cipher.BlockMode 接口。 + +实际上,SM4 的 `sm4.NewCipher` 返回的是 `cipher.Block` 接口,所以我们可以直接使用 `cipher.NewCBCEncrypter` 和 `cipher.NewCBCDecrypter`。让我修复 crypto.go。 + +让我重新生成 crypto.go 文件: + +// File: ymt_v3/crypto.go +```go +package ymt_v3 + +import ( + "bytes" + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "reflect" + "sort" + "strings" + "time" + + "github.com/tjfoc/gmsm/sm4" +) + +// EncryptType 加密类型 +type EncryptType string + +const ( + EncryptTypeAES EncryptType = "aes" + EncryptTypeSM4 EncryptType = "sm4" +) + +// ==================== RSA签名 ==================== + +// SignWithRSA 使用RSA私钥对数据进行签名 +func SignWithRSA(signStr string, privateKeyPEM string) (string, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", fmt.Errorf("failed to decode PEM private key") + } + + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %v", err) + } + } + + rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("not a RSA private key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + signature, err := rsa.SignPKCS1v15(rand.Reader, rsaPrivateKey, crypto.SHA256, hashed) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(signature), nil +} + +// VerifyWithRSA 使用RSA公钥验证签名 +func VerifyWithRSA(signStr, signature string, publicKeyPEM string) error { + block, _ := pem.Decode([]byte(publicKeyPEM)) + if block == nil { + return fmt.Errorf("failed to decode PEM public key") + } + + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse public key: %v", err) + } + + rsaPublicKey, ok := publicKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("not a RSA public key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + sigBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("failed to decode signature: %v", err) + } + + return rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, hashed, sigBytes) +} + +// ==================== AES ECB加密 ==================== + +// AESECBEncrypt AES-ECB模式加密 +func AESECBEncrypt(plaintext []byte, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建AES cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, aes.BlockSize) + + ciphertext := make([]byte, len(padded)) + for i := 0; i < len(padded); i += aes.BlockSize { + block.Encrypt(ciphertext[i:i+aes.BlockSize], padded[i:i+aes.BlockSize]) + } + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// AESECBDecrypt AES-ECB模式解密 +func AESECBDecrypt(encryptedData string, key []byte) ([]byte, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建AES cipher失败: %v", err) + } + + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("密文长度不是块大小的整数倍") + } + + plaintext := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += aes.BlockSize { + block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) + } + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== SM4 CBC加密 ==================== + +// SM4CBCEncrypt SM4-CBC模式加密 +func SM4CBCEncrypt(plaintext []byte, key []byte) (string, error) { + if len(key) != 16 { + return "", fmt.Errorf("SM4密钥长度必须为16字节") + } + + block, err := sm4.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, block.BlockSize()) + + iv := make([]byte, block.BlockSize()) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", fmt.Errorf("生成IV失败: %v", err) + } + + mode := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(padded)) + mode.CryptBlocks(ciphertext, padded) + + result := append(iv, ciphertext...) + return base64.StdEncoding.EncodeToString(result), nil +} + +// SM4CBCDecrypt SM4-CBC模式解密 +func SM4CBCDecrypt(encryptedData string, key []byte) ([]byte, error) { + if len(key) != 16 { + return nil, fmt.Errorf("SM4密钥长度必须为16字节") + } + + data, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := sm4.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + blockSize := block.BlockSize() + if len(data) < blockSize { + return nil, fmt.Errorf("数据长度不足") + } + iv := data[:blockSize] + ciphertext := data[blockSize:] + + mode := cipher.NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(ciphertext)) + mode.CryptBlocks(plaintext, ciphertext) + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== PKCS7填充 ==================== + +// pkcs7Padding PKCS7填充 +func pkcs7Padding(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +// pkcs7UnPadding 去除PKCS7填充 +func pkcs7UnPadding(data []byte) ([]byte, error) { + length := len(data) + if length == 0 { + return nil, fmt.Errorf("数据为空") + } + padding := int(data[length-1]) + if padding > length || padding == 0 { + return nil, fmt.Errorf("无效的填充") + } + for i := length - padding; i < length; i++ { + if data[i] != byte(padding) { + return nil, fmt.Errorf("无效的填充") + } + } + return data[:length-padding], nil +} + +// ==================== 业务参数加密/解密 ==================== + +// EncryptBizParams 加密业务参数 +// 1. 将业务参数去掉"零"值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +// 2. 使用应用key将plaintext字符串加密,得到ciphertext +func EncryptBizParams(params interface{}, key []byte, encType EncryptType) (string, error) { + plaintext, err := marshalWithoutZero(params) + if err != nil { + return "", fmt.Errorf("序列化业务参数失败: %v", err) + } + + switch encType { + case EncryptTypeAES: + return AESECBEncrypt([]byte(plaintext), key) + case EncryptTypeSM4: + return SM4CBCEncrypt([]byte(plaintext), key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// DecryptBizParams 解密业务参数 +func DecryptBizParams(ciphertext string, key []byte, encType EncryptType) ([]byte, error) { + switch encType { + case EncryptTypeAES: + return AESECBDecrypt(ciphertext, key) + case EncryptTypeSM4: + return SM4CBCDecrypt(ciphertext, key) + default: + return nil, fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// EncryptBizParamsRaw 直接加密字节数据 +func EncryptBizParamsRaw(plaintext []byte, key []byte, encType EncryptType) (string, error) { + switch encType { + case EncryptTypeAES: + return AESECBEncrypt(plaintext, key) + case EncryptTypeSM4: + return SM4CBCEncrypt(plaintext, key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// marshalWithoutZero 将结构体序列化为JSON,去掉零值字段并按字母排序 +func marshalWithoutZero(v interface{}) (string, error) { + val := reflect.ValueOf(v) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + if val.Kind() == reflect.Map { + return marshalMapSorted(v) + } + data, err := json.Marshal(v) + return string(data), err + } + + result := make(map[string]interface{}) + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + jsonTag := fieldType.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + continue + } + name := strings.Split(jsonTag, ",")[0] + + if isZeroValue(field) { + continue + } + + result[name] = field.Interface() + } + + return marshalMapSorted(result) +} + +// marshalMapSorted 将map按键排序后序列化为JSON +func marshalMapSorted(m map[string]interface{}) (string, error) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyBytes, _ := json.Marshal(k) + buf.Write(keyBytes) + buf.WriteByte(':') + valBytes, err := json.Marshal(m[k]) + if err != nil { + return "", err + } + buf.Write(valBytes) + } + buf.WriteByte('}') + + return buf.String(), nil +} + +// isZeroValue 判断reflect.Value是否为零值 +func isZeroValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Slice, reflect.Map, reflect.Ptr: + return v.IsNil() + default: + return false + } +} + +// ==================== 签名相关 ==================== + +// BuildSignStr 构建签名字符串 +// 规则:app_id + timestamp + ciphertext +func BuildSignStr(appID, timestamp, ciphertext string) string { + return appID + timestamp + ciphertext +} + +// GenerateTimestamp 生成时间戳,格式 yyyy-MM-dd HH:mm:ss +func GenerateTimestamp() string { + return time.Now().Format("2006-01-02 15:04:05") +} + +// ==================== 回调验签 ==================== + +// VerifyNotifySign 验证回调通知签名 +func VerifyNotifySign(header *NotifyHeader, data interface{}, key []byte, publicKeyPEM string, encType EncryptType) error { + plaintext, err := marshalWithoutZero(data) + if err != nil { + return fmt.Errorf("序列化回调数据失败: %v", err) + } + + ciphertext, err := EncryptBizParamsRaw([]byte(plaintext), key, encType) + if err != nil { + return fmt.Errorf("加密回调数据失败: %v", err) + } + + signStr := BuildSignStr(header.Appid, header.Timestamp, ciphertext) + + return VerifyWithRSA(signStr, header.Sign, publicKeyPEM) +} +``` + +现在让我重新生成完整的 client.go 文件,确保 crypto.go 中的函数签名与 client.go 中的调用一致。 + +实际上,我注意到 crypto.go 中我使用了 `cipher.NewCBCEncrypter` 和 `cipher.NewCBCDecrypter`,这些是标准库 `crypto/cipher` 包中的函数,它们接受 `cipher.Block` 接口。而 `sm4.NewCipher` 返回的正是 `cipher.Block` 接口,所以可以直接使用。 + +让我检查一下所有文件的一致性,然后输出最终版本。 + +好的,所有文件已经生成完毕。让我输出最终版本。 + +实际上,让我重新审视一下 crypto.go 中的 CBC 实现。标准库的 `cipher.NewCBCEncrypter` 和 `cipher.NewCBCDecrypter` 接受 `cipher.Block` 接口,而 `sm4.NewCipher` 返回 `*sm4.SM4Cipher` 实现了 `cipher.Block` 接口,所以可以直接使用。 + +但是,SM4 CBC 加密时,IV 需要前置到密文中,解密时从密文中提取 IV。这个逻辑已经在代码中实现了。 + +好的,所有文件都准备好了,让我输出最终版本。 + +等等,让我再检查一下 go.mod 中的依赖。`github.com/tjfoc/gmsm` 的版本,我写的是 `v1.4.1`,但实际最新版本可能不同。不过对于 SDK 来说,指定一个合理的版本即可。 + +好的,现在输出所有文件。现在让我重新生成完整的 crypto.go 文件,修复 CBC 实现问题: + +// File: ymt_v3/crypto.go +```go +package ymt_v3 + +import ( + "bytes" + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "reflect" + "sort" + "strings" + "time" + + "github.com/tjfoc/gmsm/sm4" +) + +// EncryptType 加密类型 +type EncryptType string + +const ( + // EncryptTypeAES AES-ECB加密 + EncryptTypeAES EncryptType = "aes" + // EncryptTypeSM4 SM4-CBC加密 + EncryptTypeSM4 EncryptType = "sm4" +) + +// ==================== RSA签名 ==================== + +// SignWithRSA 使用RSA私钥对数据进行签名 +func SignWithRSA(signStr string, privateKeyPEM string) (string, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", fmt.Errorf("failed to decode PEM private key") + } + + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %v", err) + } + } + + rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("not a RSA private key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + signature, err := rsa.SignPKCS1v15(rand.Reader, rsaPrivateKey, crypto.SHA256, hashed) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(signature), nil +} + +// VerifyWithRSA 使用RSA公钥验证签名 +func VerifyWithRSA(signStr, signature string, publicKeyPEM string) error { + block, _ := pem.Decode([]byte(publicKeyPEM)) + if block == nil { + return fmt.Errorf("failed to decode PEM public key") + } + + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse public key: %v", err) + } + + rsaPublicKey, ok := publicKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("not a RSA public key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + sigBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("failed to decode signature: %v", err) + } + + return rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, hashed, sigBytes) +} + +// ==================== AES ECB加密 ==================== + +// AESECBEncrypt AES-ECB模式加密 +func AESECBEncrypt(plaintext []byte, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建AES cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, aes.BlockSize) + + ciphertext := make([]byte, len(padded)) + for i := 0; i < len(padded); i += aes.BlockSize { + block.Encrypt(ciphertext[i:i+aes.BlockSize], padded[i:i+aes.BlockSize]) + } + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// AESECBDecrypt AES-ECB模式解密 +func AESECBDecrypt(encryptedData string, key []byte) ([]byte, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建AES cipher失败: %v", err) + } + + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("密文长度不是块大小的整数倍") + } + + plaintext := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += aes.BlockSize { + block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) + } + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== SM4 CBC加密 ==================== + +// SM4CBCEncrypt SM4-CBC模式加密 +// IV会前置到密文中,解密时自动提取 +func SM4CBCEncrypt(plaintext []byte, key []byte) (string, error) { + if len(key) != 16 { + return "", fmt.Errorf("SM4密钥长度必须为16字节") + } + + block, err := sm4.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, block.BlockSize()) + + iv := make([]byte, block.BlockSize()) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", fmt.Errorf("生成IV失败: %v", err) + } + + mode := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(padded)) + mode.CryptBlocks(ciphertext, padded) + + // IV前置到密文 + result := append(iv, ciphertext...) + return base64.StdEncoding.EncodeToString(result), nil +} + +// SM4CBCDecrypt SM4-CBC模式解密 +// 从密文中提取前置的IV进行解密 +func SM4CBCDecrypt(encryptedData string, key []byte) ([]byte, error) { + if len(key) != 16 { + return nil, fmt.Errorf("SM4密钥长度必须为16字节") + } + + data, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := sm4.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + blockSize := block.BlockSize() + if len(data) < blockSize*2 { + return nil, fmt.Errorf("数据长度不足") + } + iv := data[:blockSize] + ciphertext := data[blockSize:] + + mode := cipher.NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(ciphertext)) + mode.CryptBlocks(plaintext, ciphertext) + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== PKCS7填充 ==================== + +// pkcs7Padding PKCS7填充 +func pkcs7Padding(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +// pkcs7UnPadding 去除PKCS7填充 +func pkcs7UnPadding(data []byte) ([]byte, error) { + length := len(data) + if length == 0 { + return nil, fmt.Errorf("数据为空") + } + padding := int(data[length-1]) + if padding > length || padding == 0 { + return nil, fmt.Errorf("无效的填充") + } + for i := length - padding; i < length; i++ { + if data[i] != byte(padding) { + return nil, fmt.Errorf("无效的填充") + } + } + return data[:length-padding], nil +} + +// ==================== 业务参数加密/解密 ==================== + +// EncryptBizParams 加密业务参数 +// 1. 将业务参数去掉"零"值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +// 2. 使用应用key将plaintext字符串加密,得到ciphertext +func EncryptBizParams(params interface{}, key []byte, encType EncryptType) (string, error) { + plaintext, err := marshalWithoutZero(params) + if err != nil { + return "", fmt.Errorf("序列化业务参数失败: %v", err) + } + + switch encType { + case EncryptTypeAES: + return AESECBEncrypt([]byte(plaintext), key) + case EncryptTypeSM4: + return SM4CBCEncrypt([]byte(plaintext), key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// DecryptBizParams 解密业务参数 +func DecryptBizParams(ciphertext string, key []byte, encType EncryptType) ([]byte, error) { + switch encType { + case EncryptTypeAES: + return AESECBDecrypt(ciphertext, key) + case EncryptTypeSM4: + return SM4CBCDecrypt(ciphertext, key) + default: + return nil, fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// EncryptBizParamsRaw 直接加密字节数据(用于回调验签) +func EncryptBizParamsRaw(plaintext []byte, key []byte, encType EncryptType) (string, error) { + switch encType { + case EncryptTypeAES: + return AESECBEncrypt(plaintext, key) + case EncryptTypeSM4: + return SM4CBCEncrypt(plaintext, key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// marshalWithoutZero 将结构体序列化为JSON,去掉零值字段并按字母排序 +func marshalWithoutZero(v interface{}) (string, error) { + val := reflect.ValueOf(v) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + if val.Kind() == reflect.Map { + return marshalMapSorted(v) + } + data, err := json.Marshal(v) + return string(data), err + } + + result := make(map[string]interface{}) + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + jsonTag := fieldType.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + continue + } + name := strings.Split(jsonTag, ",")[0] + + if isZeroValue(field) { + continue + } + + result[name] = field.Interface() + } + + return marshalMapSorted(result) +} + +// marshalMapSorted 将map按键排序后序列化为JSON +func marshalMapSorted(m map[string]interface{}) (string, error) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyBytes, _ := json.Marshal(k) + buf.Write(keyBytes) + buf.WriteByte(':') + valBytes, err := json.Marshal(m[k]) + if err != nil { + return "", err + } + buf.Write(valBytes) + } + buf.WriteByte('}') + + return buf.String(), nil +} + +// isZeroValue 判断reflect.Value是否为零值 +func isZeroValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Slice, reflect.Map, reflect.Ptr: + return v.IsNil() + default: + return false + } +} + +// ==================== 签名相关 ==================== + +// BuildSignStr 构建签名字符串 +// 规则:app_id + timestamp + ciphertext +func BuildSignStr(appID, timestamp, ciphertext string) string { + return appID + timestamp + ciphertext +} + +// GenerateTimestamp 生成时间戳,格式 yyyy-MM-dd HH:mm:ss +func GenerateTimestamp() string { + return time.Now().Format("2006-01-02 15:04:05") +} + +// ==================== 回调验签 ==================== + +// VerifyNotifySign 验证回调通知签名 +// 1. 获取header头里面的签名信息 +// 2. 获取body里面的业务参数data +// 3. 将业务参数data去掉"零"值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +// 4. 使用应用key将plaintext字符串加密得到ciphertext +// 5. 拼接签名字符串:app_id + timestamp + ciphertext +// 6. 使用应用公钥验签 +func VerifyNotifySign(header *NotifyHeader, data interface{}, key []byte, publicKeyPEM string, encType Encrypt + +## 加密实现 + +以下是从加密工具获取的完整实现: + +### 加密实现 1 + +{ + "success": true, + "data": "\n### 时间戳和随机数生成规范\n\n**代码模板**:\n\n```go\npackage crypto\n\nimport (\n \"crypto/rand\"\n \"fmt\"\n \"math/big\"\n \"time\"\n)\n\n// GenerateTimestamp 生成秒级时间戳\nfunc GenerateTimestamp() string {\n return fmt.Sprintf(\"%d\", time.Now().Unix())\n}\n\n// GenerateTimestampMillis 生成毫秒级时间戳\nfunc GenerateTimestampMillis() string {\n return fmt.Sprintf(\"%d\", time.Now().UnixMilli())\n}\n\n// GenerateNonce 生成指定长度的随机字符串(加密安全)\nfunc GenerateNonce(length int) (string, error) {\n const charset = \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\"\n b := make([]byte, length)\n for i := range b {\n num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))\n if err != nil {\n return \"\", err\n }\n b[i] = charset[num.Int64()]\n }\n return string(b), nil\n}\n```\n\n**注意事项**:\n- 确认文档要求的是秒级还是毫秒级时间戳\n- 确认nonce的长度要求(通常16-32位)\n- 生产环境建议使用加密安全的随机数生成器\n", + "tool": "nonce_timestamp" +} + +### 加密实现 2 + +{ + "success": true, + "data": "\n### AES-ECB 加密完整实现指南\n\n**配置参数**:\n- 密钥长度: 16 字节\n- 编码方式: base64\n\n**完整代码模板**:\n\n```go\npackage crypto\n\nimport (\n \"crypto/aes\"\n \"encoding/base64\"\n \"encoding/hex\"\n \"fmt\"\n \"bytes\"\n)\n\n// AESECBEncrypt AES-ECB模式加密\nfunc AESECBEncrypt(plaintext []byte, key []byte, encoding string) (string, error) {\n block, err := aes.NewCipher(key)\n if err != nil {\n return \"\", fmt.Errorf(\"创建AES cipher失败: %v\", err)\n }\n\n padded := pkcs7Padding(plaintext, aes.BlockSize)\n\n ciphertext := make([]byte, len(padded))\n for i := 0; i \u003c len(padded); i += aes.BlockSize {\n block.Encrypt(ciphertext[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])\n }\n\n if encoding == \"hex\" {\n return hex.EncodeToString(ciphertext), nil\n }\n return base64.StdEncoding.EncodeToString(ciphertext), nil\n}\n\n// AESECBDecrypt AES-ECB模式解密\nfunc AESECBDecrypt(encryptedData string, key []byte, encoding string) ([]byte, error) {\n var ciphertext []byte\n var err error\n \n if encoding == \"hex\" {\n ciphertext, err = hex.DecodeString(encryptedData)\n } else {\n ciphertext, err = base64.StdEncoding.DecodeString(encryptedData)\n }\n if err != nil {\n return nil, fmt.Errorf(\"解码失败: %v\", err)\n }\n\n block, err := aes.NewCipher(key)\n if err != nil {\n return nil, fmt.Errorf(\"创建AES cipher失败: %v\", err)\n }\n\n if len(ciphertext)%aes.BlockSize != 0 {\n return nil, fmt.Errorf(\"密文长度不是块大小的整数倍\")\n }\n\n plaintext := make([]byte, len(ciphertext))\n for i := 0; i \u003c len(ciphertext); i += aes.BlockSize {\n block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])\n }\n\n plaintext, err = pkcs7UnPadding(plaintext)\n if err != nil {\n return nil, fmt.Errorf(\"去除填充失败: %v\", err)\n }\n\n return plaintext, nil\n}\n```\n\n%!(EXTRA string=16)", + "tool": "aes_ecb_encrypt" +} + +### 加密实现 3 + +{ + "success": true, + "data": "\n### RSA签名完整实现指南\n\n**适用场景**:文档要求使用RSA私钥对请求参数进行签名\n\n**配置参数**:\n- 哈希算法: SHA256\n- 编码方式: base64\n\n**完整代码模板**:\n```go\npackage crypto\n\nimport (\n \"crypto\"\n \"crypto/rand\"\n \"crypto/rsa\"\n \"crypto/sha256\"\n \"crypto/sha512\"\n \"crypto/x509\"\n \"encoding/base64\"\n \"encoding/hex\"\n \"encoding/pem\"\n \"fmt\"\n \"sort\"\n \"strings\"\n)\n\n// RSAConfig RSA签名配置\ntype RSAConfig struct {\n PrivateKeyPEM string // PEM格式的私钥\n KeyID string // 密钥ID(如文档要求携带)\n}\n\n// SignWithRSA 使用RSA私钥对请求进行签名\nfunc SignWithRSA(params map[string]string, config *RSAConfig) (string, error) {\n // 1. 拼接参数(按字典序排序)\n keys := make([]string, 0, len(params))\n for k := range params {\n if k != \"sign\" \u0026\u0026 k != \"signature\" {\n keys = append(keys, k)\n }\n }\n sort.Strings(keys)\n \n var sb strings.Builder\n for _, k := range keys {\n if sb.Len() \u003e 0 {\n sb.WriteString(\"\u0026\")\n }\n sb.WriteString(k)\n sb.WriteString(\"=\")\n sb.WriteString(params[k])\n }\n signStr := sb.String()\n \n // 2. 加载私钥\n block, _ := pem.Decode([]byte(config.PrivateKeyPEM))\n if block == nil {\n return \"\", fmt.Errorf(\"failed to decode PEM private key\")\n }\n \n privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)\n if err != nil {\n // 尝试PKCS1格式\n privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)\n if err != nil {\n return \"\", fmt.Errorf(\"failed to parse private key: %v\", err)\n }\n }\n \n rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey)\n if !ok {\n return \"\", fmt.Errorf(\"not a RSA private key\")\n }\n \n // 3. 计算哈希\n var hashed []byte\n var hashFunc crypto.Hash\n \n switch \"SHA256\" {\n case \"SHA384\":\n h := sha512.New384()\n h.Write([]byte(signStr))\n hashed = h.Sum(nil)\n hashFunc = crypto.SHA384\n case \"SHA512\":\n h := sha512.New()\n h.Write([]byte(signStr))\n hashed = h.Sum(nil)\n hashFunc = crypto.SHA512\n default: // SHA256\n h := sha256.New()\n h.Write([]byte(signStr))\n hashed = h.Sum(nil)\n hashFunc = crypto.SHA256\n }\n \n // 4. RSA签名\n signature, err := rsa.SignPKCS1v15(rand.Reader, rsaPrivateKey, hashFunc, hashed)\n if err != nil {\n return \"\", err\n }\n \n // 5. 编码\n if \"base64\" == \"hex\" {\n return hex.EncodeToString(signature), nil\n }\n return base64.StdEncoding.EncodeToString(signature), nil\n}\n```\n\n**注意事项**:\n- 确认文档使用的是 PKCS1 还是 PKCS8 格式\n- 确认是否需要添加额外的固定盐值\n- 确认编码是 Base64 还是 Hex\n- 注意排除签名字段本身(sign/signature)\n", + "tool": "rsa_sign" +} + +### 加密实现 4 + +{ + "success": true, + "data": "\n### SM4-CBC 国密对称加密完整实现指南\n\n**⚠️ 重要:必须使用第三方库,不要自己实现 SM4 算法!**\n**推荐使用:github.com/tjfoc/gmsm**\n\n**前置要求**:\n```bash\ngo get github.com/tjfoc/gmsm\n```\n\n**配置参数**:\n- 编码方式: base64\n\n**完整代码模板(请直接复制使用)**:\n\n```go\npackage crypto\n\nimport (\n \"crypto/cipher\"\n \"crypto/rand\"\n \"encoding/base64\"\n \"encoding/hex\"\n \"fmt\"\n \"io\"\n \"bytes\"\n\n \"github.com/tjfoc/gmsm/sm4\" // ⚠️ 必须使用此库,不要自己实现SM4\n)\n\n// SM4CBCEncrypt SM4-CBC模式加密\n// 使用 github.com/tjfoc/gmsm/sm4 实现\nfunc SM4CBCEncrypt(plaintext []byte, key []byte) (string, error) {\n if len(key) != 16 {\n return \"\", fmt.Errorf(\"SM4密钥长度必须为16字节\")\n }\n\n // 使用 gmsm 库创建 SM4 cipher\n block, err := sm4.NewCipher(key)\n if err != nil {\n return \"\", fmt.Errorf(\"创建SM4 cipher失败: %v\", err)\n }\n\n padded := pkcs7Padding(plaintext, block.BlockSize())\n\n iv := make([]byte, block.BlockSize())\n if _, err := io.ReadFull(rand.Reader, iv); err != nil {\n return \"\", fmt.Errorf(\"生成IV失败: %v\", err)\n }\n\n mode := cipher.NewCBCEncrypter(block, iv)\n ciphertext := make([]byte, len(padded))\n mode.CryptBlocks(ciphertext, padded)\n\n result := append(iv, ciphertext...)\n if \"base64\" == \"hex\" {\n return hex.EncodeToString(result), nil\n }\n return base64.StdEncoding.EncodeToString(result), nil\n}\n\n// SM4CBCDecrypt SM4-CBC模式解密\n// 使用 github.com/tjfoc/gmsm/sm4 实现\nfunc SM4CBCDecrypt(encryptedData string, key []byte) ([]byte, error) {\n if len(key) != 16 {\n return nil, fmt.Errorf(\"SM4密钥长度必须为16字节\")\n }\n\n var data []byte\n var err error\n if \"base64\" == \"hex\" {\n data, err = hex.DecodeString(encryptedData)\n } else {\n data, err = base64.StdEncoding.DecodeString(encryptedData)\n }\n if err != nil {\n return nil, fmt.Errorf(\"解码失败: %v\", err)\n }\n\n block, err := sm4.NewCipher(key)\n if err != nil {\n return nil, fmt.Errorf(\"创建SM4 cipher失败: %v\", err)\n }\n\n blockSize := block.BlockSize()\n if len(data) \u003c blockSize {\n return nil, fmt.Errorf(\"数据长度不足\")\n }\n iv := data[:blockSize]\n ciphertext := data[blockSize:]\n\n mode := cipher.NewCBCDecrypter(block, iv)\n plaintext := make([]byte, len(ciphertext))\n mode.CryptBlocks(plaintext, ciphertext)\n\n plaintext, err = pkcs7UnPadding(plaintext)\n if err != nil {\n return nil, fmt.Errorf(\"去除填充失败: %v\", err)\n }\n\n return plaintext, nil\n}\n\n// pkcs7Padding PKCS7填充\nfunc pkcs7Padding(data []byte, blockSize int) []byte {\n padding := blockSize - len(data)%blockSize\n padText := bytes.Repeat([]byte{byte(padding)}, padding)\n return append(data, padText...)\n}\n\n// pkcs7UnPadding 去除PKCS7填充\nfunc pkcs7UnPadding(data []byte) ([]byte, error) {\n length := len(data)\n if length == 0 {\n return nil, fmt.Errorf(\"数据为空\")\n }\n padding := int(data[length-1])\n if padding \u003e length || padding == 0 {\n return nil, fmt.Errorf(\"无效的填充\")\n }\n for i := length - padding; i \u003c length; i++ {\n if data[i] != byte(padding) {\n return nil, fmt.Errorf(\"无效的填充\")\n }\n }\n return data[:length-padding], nil\n}\n```\n\n**⚠️ 重要提醒**:\n1. **不要自己实现 SM4 算法**,请直接使用 github.com/tjfoc/gmsm\n2. 这个库是国密官方推荐的 Go 实现,安全可靠\n3. 在 go.mod 中添加依赖:`require github.com/tjfoc/gmsm v1.4.1`\n\n**注意事项**:\n- SM4 密钥固定为 16 字节\n- IV 必须随机生成且每次不同\n- CBC 模式需要 IV,ECB 模式不需要\n", + "tool": "sm4_cbc_encrypt" +} + +### 加密实现 5 + +{ + "success": true, + "data": "\n### 参数拼接规范\n\n**常见拼接方式**:\n\n1. **字典序排序**:按key字典序排序后拼接 `key=value\u0026key2=value2`\n2. **固定顺序**:按文档指定顺序拼接\n3. **JSON字符串**:整个请求体作为签名串\n\n**代码模板**:\n\n```go\npackage crypto\n\nimport (\n \"fmt\"\n \"sort\"\n \"strings\"\n)\n\n// BuildSignString 方式1:字典序排序拼接\nfunc BuildSignString(params map[string]string) string {\n keys := make([]string, 0, len(params))\n for k, v := range params {\n if v != \"\" \u0026\u0026 k != \"sign\" \u0026\u0026 k != \"signature\" {\n keys = append(keys, k)\n }\n }\n sort.Strings(keys)\n \n var parts []string\n for _, k := range keys {\n parts = append(parts, fmt.Sprintf(\"%s=%s\", k, params[k]))\n }\n return strings.Join(parts, \"\u0026\")\n}\n\n// BuildSignStringOrdered 方式2:固定顺序拼接\nfunc BuildSignStringOrdered(params map[string]string, orderedKeys []string) string {\n var parts []string\n for _, k := range orderedKeys {\n if v, ok := params[k]; ok \u0026\u0026 v != \"\" {\n parts = append(parts, fmt.Sprintf(\"%s=%s\", k, v))\n }\n }\n return strings.Join(parts, \"\u0026\")\n}\n```\n\n**注意事项**:\n- 确认文档指定的排序规则(字典序/固定顺序)\n- 确认空值是否要包含(通常排除空值)\n- 确认是否需要URL编码\n- 注意排除签名字段本身\n", + "tool": "param_concat" +} + diff --git a/xy_sh/ymt_v3/refine.md b/xy_sh/ymt_v3/refine.md new file mode 100644 index 0000000..5066924 --- /dev/null +++ b/xy_sh/ymt_v3/refine.md @@ -0,0 +1,311 @@ +## 文档概述 +- 接口总数:5个 +- 环境配置: + - 测试环境地址:https://gateway.dev.cdlsxd.cn + - 正式环境地址:https://market.api.86698.cn +- 测试参数: + - app_id: "xxx" + - private_key: "xxx"(应用客户私钥,用于请求签名) + - public_key: "xxx"(应用平台公钥,用于平台响应或回调验签) + - key: "xxxx"(业务参数加密key) + - activity_no: "xxxx"(活动编号) + - sign_type: "RSA" + +## 认证与安全 +### 公共Header请求参数 +| 字段名称 | 类型 | 描述 | 示例值 | +|--------|------|------|--------| +| Appid | string | 分配给开发者的应用 ID | 123456 | +| Timestamp | string | 发送请求的时间,格式 yyyy-MM-dd HH:mm:ss | 2026-06-22 15:30:00 | +| Sign | string | 请求签名串 | 详见 SDK 示例 | +| Content-Type | string | 请求数据格式 | application/json | + +### 公共请求参数 +| 字段名称 | 类型 | 描述 | 示例值 | +|--------|------|------|--------| +| ciphertext | string | 请求业务参数加密串 | 详见 SDK 示例 | + +### 公共响应参数 +| 字段名称 | 类型 | 描述 | +|--------|------|------| +| code | int32 | 200 成功 | +| message | string | 请求描述 | +| reason | string | 错误原因,错误时返回 | +| data.ciphertext | string | 业务响应加密串 | + +### 业务参数加密规则 +1. 将业务参数去掉“零”值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +2. 使用应用key将plaintext字符串加密,支持两种模式:aes(ECB模式)/sm4(CBC模式),得到加密业务参数ciphertext + +### 签名规则 +1. 拼接签名字符串:分配给开发者的应用ID + 发送请求的时间 + 加密业务参数 +2. 使用应用私钥将拼接待签名字符串生成签名字符串 + +### 回调验签规则 +1. 获取header头里面的签名信息 +2. 获取body里面的业务参数data +3. 将业务参数data去掉“零”值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +4. 使用应用key将plaintext字符串加密[aes/sm4]得到加密得到ciphertext +5. 拼接签名字符串:分配给开发者的应用ID + 发送请求的时间 + ciphertext +6. 使用应用公钥验签 + +### 时间戳规则 +时间格式:yyyy-MM-dd HH:mm:ss,请求时间与服务端时间误差不能超过3分钟 + +## 接口列表 +### 接口 1:获取券码 +- 路径:/openapi/v1/key/order +- 方法:POST +- 描述:申请单个券码/权益,支持幂等 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号,幂等 | +| activity_no | string | 是 | 活动编号 | +| account | string | 否 | 账号,按活动类型透传 | +| notify_url | string | 否 | 回调通知地址 | + +#### 响应参数(解密后) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号 | +| trade_no | string | 是 | 交易号 | +| key | string | 否 | 卡密 | +| url | string | 否 | 链接型活动返回短链接,key/url 不会同时为空 | +| valid_begin_time | string | 否 | 生效时间 | +| valid_end_time | string | 否 | 失效时间 | +| usable_num | uint32 | 是 | 可用次数 | +| usage_num | uint32 | 是 | 已使用次数 | +| status | uint32 | 是 | 状态:1 正常,2 已核销,3 已作废 | +| settlement_price | float | 否 | 结算价 | +| account | string | 否 | 上报账号 | + +#### 示例 +- 明文业务参数: +```json +{ + "out_biz_no": "order_001", + "activity_no": "ACT20260622001", + "account": "18666666666", + "notify_url": "https://notify.example.com/openapi" +} +``` +- 请求体示例: +```json +{ + "ciphertext": "加密后的业务报文" +} +``` +- 异常响应示例: +```json +{ + "code": 401, + "message": "Signature verification failed", + "reason": "INVALID_SIGNATURE" +} +``` +- 成功响应示例: +```json +{ + "code": 200, + "data": { + "ciphertext": "加密后的响应报文" + }, + "message": "成功" +} +``` +- 解密后成功示例: +```json +{ + "out_biz_no": "order_001", + "trade_no": "7251449503000383488", + "key": "aZKdU9BymzR6qGRzJM", + "url": "", + "valid_begin_time": "2026-06-22 15:30:00", + "valid_end_time": "2026-12-31 23:59:59", + "usable_num": 1, + "usage_num": 0, + "status": 1, + "settlement_price": 9.9, + "account": "18666666666" +} +``` + +### 接口 2:券码查询 +- 路径:/openapi/v1/key/query +- 方法:POST +- 描述:查询已申请的券码详情 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 否 | 外部业务号,与 trade_no 二选一 | +| trade_no | string | 否 | 交易号,与 out_biz_no 二选一 | + +#### 响应参数(解密后) +与“获取券码”响应参数完全一致。 + +#### 示例 +```json +{ + "code": 200, + "data": { + "out_biz_no": "order_001", + "trade_no": "7251449503000383488", + "key": "aZKdU9BymzR6qGRzJM", + "url": "", + "valid_begin_time": "2026-06-22 15:30:00", + "valid_end_time": "2026-12-31 23:59:59", + "usable_num": 1, + "usage_num": 0, + "status": 1, + "settlement_price": 9.9, + "account": "18666666666", + "ciphertext": "" + }, + "message": "成功" +} +``` + +### 接口 3:券码作废 +- 路径:/openapi/v1/key/discard +- 方法:POST +- 描述:作废已申请的券码 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 否 | 外部业务号,与 trade_no 二选一 | +| trade_no | string | 否 | 交易号,与 out_biz_no 二选一 | + +#### 响应参数(解密后) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号 | +| trade_no | string | 是 | 交易号 | +| status | uint32 | 是 | 3 表示已作废 | + +#### 示例 +```json +{ + "code": 200, + "data": { + "out_biz_no": "order_001", + "trade_no": "7251449503000383488", + "status": 3, + "ciphertext": "" + }, + "message": "成功" +} +``` + +### 接口 4:批量发卡 +- 路径:/openapi/v1/key/batch_order +- 方法:POST +- 描述:批量申请多个券码,返回异步任务状态 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号,幂等 | +| activity_no | string | 是 | 活动编号 | +| number | int32 | 是 | 发卡数量 | +| notify_url | string | 否 | 回调通知地址 | + +#### 响应参数(解密后) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号 | +| trade_no | string | 是 | 交易号 | +| status | string | 是 | 任务状态,初始返回 processing | + +#### 示例 +- 成功响应示例: +```json +{ + "code": 200, + "data": { + "ciphertext": "加密后的响应报文" + }, + "message": "成功" +} +``` +- 解密后示例: +```json +{ + "out_biz_no": "batch_001", + "trade_no": "7251449503000383499", + "status": "processing" +} +``` + +### 接口 5:批量查询 +- 路径:/openapi/v1/key/batch_query +- 方法:POST +- 描述:查询批量发卡任务的状态和结果 + +#### 请求参数(业务明文) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 否 | 外部业务号,与 trade_no 二选一 | +| trade_no | string | 否 | 交易号,与 out_biz_no 二选一 | + +#### 响应参数(解密后) +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| out_biz_no | string | 是 | 外部业务号 | +| trade_no | string | 是 | 交易号 | +| status | string | 是 | processing / success / failed | +| download_url | string | 否 | 批量任务成功后返回下载地址 | +| zip_password | string | 否 | 批量任务成功后返回压缩包密码 | + +#### 示例 +- 成功响应示例: +```json +{ + "code": 200, + "data": { + "ciphertext": "加密后的响应报文" + }, + "message": "成功" +} +``` +- 解密后示例: +```json +{ + "out_biz_no": "batch_001", + "trade_no": "7251449503000383499", + "status": "success", + "download_url": "https://oss.example.com/openapi\_7251449503000383499.zip", + "zip_password": "123456" +} +``` + +## 错误码 +| 错误码 | 说明 | 处理建议 | +|--------|------|----------| +| 500 | PANIC/其它 系统错误 | 联系平台处理 | +| 400 | INVALID_PAYLOAD 请求外壳格式错误 | 请检查请求 JSON 结构 | +| 400 | MISSING_PARAM 缺少必要参数 | 请检查 app_id、timestamp、sign、ciphertext | +| 400 | INVALID_TIMESTAMP 时间格式错误 | 请检查时间格式 | +| 400 | DECRYPT_FAILED 业务参数解密失败 | 请检查加密方式与密钥 | +| 400 | PARAM_FAIL 参数错误 | 请检查业务参数 | +| 400 | PARAM_DECRYPT_FAIL 明文参数格式错误 | 请检查密文解密后的业务报文 | +| 401 | APP_NOT_FOUND 应用不存在 | 请检查应用 ID | +| 401 | INVALID_SIGNATURE 签名错误 | 请检查签名串与私钥 | +| 401 | EXPIRED_TIMESTAMP 请求已过期 | 请检查客户端时间 | +| 401 | ACTIVITY_NOT_AUTH 活动未授权 | 请检查活动授权状态 | +| 401 | MERCHANT_NOT_EXIST 客户不存在 | 请检查客户是否存在 | +| 401 | MERCHANT_NOT_AUTH 客户冻结 | 请检查客户授权状态 | +| 401 | MERCHANT_APP_INCOMPLETE 客户应用配置未完善 | 请检查客户应用配置 | +| 401 | MERCHANT_APP_NOT_AUTH 应用不存在或未授权 | 请检查客户应用授权状态 | +| 403 | ACTIVITY_EXPIRE 活动已结束 | 请检查活动是否有效 | +| 403 | ACTIVITY_OUT_OF_STOCK 活动剩余量不足 | 请检查活动库存 | +| 404 | ACTIVITY_NOT_EXIST 活动不存在 | 请检查活动编号 | +| 404 | MERCHANT_ORDER_NOT_EXIST 订单不存在 | 请检查交易号或外部业务号 | +| 404 | KEY_NOT_EXIST key码不存在 | 请检查订单信息 | +| 429 | DUPLICATE_REQUEST 重复请求,请稍后重试 | 请避免短时间内重复提交同一业务号 | + +## 回调通知 +平台会将异步任务结果推送到请求中指定的notify_url地址,回调验签规则参考上述认证与安全章节的回调验签流程。 \ No newline at end of file diff --git a/xy_sh/ymt_v3/types.go b/xy_sh/ymt_v3/types.go new file mode 100644 index 0000000..09f89e3 --- /dev/null +++ b/xy_sh/ymt_v3/types.go @@ -0,0 +1,98 @@ +package ymt_v3 + +// CommonResponse 公共响应结构 +type CommonResponse struct { + Code int32 `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` + Data *CommonData `json:"data,omitempty"` +} + +// CommonData 公共响应数据(包含加密的ciphertext) +type CommonData struct { + Ciphertext string `json:"ciphertext"` +} + +// KeyOrderRequest 获取券码请求业务参数 +type KeyOrderRequest struct { + OutBizNo string `json:"out_biz_no"` + ActivityNo string `json:"activity_no"` + Account string `json:"account,omitempty"` + NotifyURL string `json:"notify_url,omitempty"` +} + +// KeyOrderResponse 获取券码/券码查询响应业务参数 +type KeyOrderResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Key string `json:"key,omitempty"` + URL string `json:"url,omitempty"` + ValidBeginTime string `json:"valid_begin_time,omitempty"` + ValidEndTime string `json:"valid_end_time,omitempty"` + UsableNum uint32 `json:"usable_num"` + UsageNum uint32 `json:"usage_num"` + Status uint32 `json:"status"` + SettlementPrice float64 `json:"settlement_price,omitempty"` + Account string `json:"account,omitempty"` +} + +// KeyQueryRequest 券码查询请求业务参数 +type KeyQueryRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +// KeyDiscardRequest 券码作废请求业务参数 +type KeyDiscardRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +// KeyDiscardResponse 券码作废响应业务参数 +type KeyDiscardResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status uint32 `json:"status"` +} + +// BatchOrderRequest 批量发卡请求业务参数 +type BatchOrderRequest struct { + OutBizNo string `json:"out_biz_no"` + ActivityNo string `json:"activity_no"` + Number int32 `json:"number"` + NotifyURL string `json:"notify_url,omitempty"` +} + +// BatchOrderResponse 批量发卡响应业务参数 +type BatchOrderResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status string `json:"status"` +} + +// BatchQueryRequest 批量查询请求业务参数 +type BatchQueryRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +// BatchQueryResponse 批量查询响应业务参数 +type BatchQueryResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status string `json:"status"` + DownloadURL string `json:"download_url,omitempty"` + ZipPassword string `json:"zip_password,omitempty"` +} + +// NotifyPayload 回调通知的body结构 +type NotifyPayload struct { + Data *CommonData `json:"data"` +} + +// NotifyHeader 回调通知的header +type NotifyHeader struct { + Appid string `json:"Appid"` + Timestamp string `json:"Timestamp"` + Sign string `json:"Sign"` +} diff --git a/xy_sh/ymt_v3/valid.md b/xy_sh/ymt_v3/valid.md new file mode 100644 index 0000000..7b5058f --- /dev/null +++ b/xy_sh/ymt_v3/valid.md @@ -0,0 +1,1149 @@ +// File: ymt_v3/go.mod +```go +module ymt_v3 + +go 1.21 + +require github.com/tjfoc/gmsm v1.4.1 +``` + +// File: ymt_v3/types.go +```go +package ymt_v3 + +// CommonResponse 公共响应结构 +type CommonResponse struct { + Code int32 `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` + Data *CommonData `json:"data,omitempty"` +} + +// CommonData 公共响应数据(包含加密的ciphertext) +type CommonData struct { + Ciphertext string `json:"ciphertext"` +} + +// KeyOrderRequest 获取券码请求业务参数 +type KeyOrderRequest struct { + OutBizNo string `json:"out_biz_no"` + ActivityNo string `json:"activity_no"` + Account string `json:"account,omitempty"` + NotifyURL string `json:"notify_url,omitempty"` +} + +// KeyOrderResponse 获取券码/券码查询响应业务参数 +type KeyOrderResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Key string `json:"key,omitempty"` + URL string `json:"url,omitempty"` + ValidBeginTime string `json:"valid_begin_time,omitempty"` + ValidEndTime string `json:"valid_end_time,omitempty"` + UsableNum uint32 `json:"usable_num"` + UsageNum uint32 `json:"usage_num"` + Status uint32 `json:"status"` + SettlementPrice float64 `json:"settlement_price,omitempty"` + Account string `json:"account,omitempty"` +} + +// KeyQueryRequest 券码查询请求业务参数 +type KeyQueryRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +// KeyDiscardRequest 券码作废请求业务参数 +type KeyDiscardRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +// KeyDiscardResponse 券码作废响应业务参数 +type KeyDiscardResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status uint32 `json:"status"` +} + +// BatchOrderRequest 批量发卡请求业务参数 +type BatchOrderRequest struct { + OutBizNo string `json:"out_biz_no"` + ActivityNo string `json:"activity_no"` + Number int32 `json:"number"` + NotifyURL string `json:"notify_url,omitempty"` +} + +// BatchOrderResponse 批量发卡响应业务参数 +type BatchOrderResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status string `json:"status"` +} + +// BatchQueryRequest 批量查询请求业务参数 +type BatchQueryRequest struct { + OutBizNo string `json:"out_biz_no,omitempty"` + TradeNo string `json:"trade_no,omitempty"` +} + +// BatchQueryResponse 批量查询响应业务参数 +type BatchQueryResponse struct { + OutBizNo string `json:"out_biz_no"` + TradeNo string `json:"trade_no"` + Status string `json:"status"` + DownloadURL string `json:"download_url,omitempty"` + ZipPassword string `json:"zip_password,omitempty"` +} + +// NotifyPayload 回调通知的body结构 +type NotifyPayload struct { + Data *CommonData `json:"data"` +} + +// NotifyHeader 回调通知的header +type NotifyHeader struct { + Appid string `json:"Appid"` + Timestamp string `json:"Timestamp"` + Sign string `json:"Sign"` +} +``` + +// File: ymt_v3/errors.go +```go +package ymt_v3 + +import "fmt" + +// APIError 表示API返回的业务错误 +type APIError struct { + Code int32 `json:"code"` + Message string `json:"message"` + Reason string `json:"reason,omitempty"` +} + +func (e *APIError) Error() string { + return fmt.Sprintf("API error: code=%d, message=%s, reason=%s", e.Code, e.Message, e.Reason) +} + +// IsSuccess 判断是否成功 +func (e *APIError) IsSuccess() bool { + return e.Code == 200 +} + +// NewAPIError 创建API错误 +func NewAPIError(code int32, message, reason string) *APIError { + return &APIError{ + Code: code, + Message: message, + Reason: reason, + } +} + +// SDKError SDK内部错误 +type SDKError struct { + Message string + Err error +} + +func (e *SDKError) Error() string { + if e.Err != nil { + return fmt.Sprintf("SDK error: %s: %v", e.Message, e.Err) + } + return fmt.Sprintf("SDK error: %s", e.Message) +} + +func (e *SDKError) Unwrap() error { + return e.Err +} + +// NewSDKError 创建SDK内部错误 +func NewSDKError(msg string, err error) *SDKError { + return &SDKError{Message: msg, Err: err} +} +``` + +// File: ymt_v3/crypto.go +```go +package ymt_v3 + +import ( + "bytes" + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "reflect" + "sort" + "strings" + "time" + + "github.com/tjfoc/gmsm/sm4" +) + +// EncryptType 加密类型 +type EncryptType string + +const ( + // EncryptTypeAES AES-ECB加密 + EncryptTypeAES EncryptType = "aes" + // EncryptTypeSM4 SM4-CBC加密 + EncryptTypeSM4 EncryptType = "sm4" +) + +// ==================== RSA签名 ==================== + +// SignWithRSA 使用RSA私钥对数据进行签名 +func SignWithRSA(signStr string, privateKeyPEM string) (string, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return "", fmt.Errorf("failed to decode PEM private key") + } + + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %v", err) + } + } + + rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("not a RSA private key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + signature, err := rsa.SignPKCS1v15(rand.Reader, rsaPrivateKey, crypto.SHA256, hashed) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(signature), nil +} + +// VerifyWithRSA 使用RSA公钥验证签名 +func VerifyWithRSA(signStr, signature string, publicKeyPEM string) error { + block, _ := pem.Decode([]byte(publicKeyPEM)) + if block == nil { + return fmt.Errorf("failed to decode PEM public key") + } + + publicKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse public key: %v", err) + } + + rsaPublicKey, ok := publicKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("not a RSA public key") + } + + h := sha256.New() + h.Write([]byte(signStr)) + hashed := h.Sum(nil) + + sigBytes, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return fmt.Errorf("failed to decode signature: %v", err) + } + + return rsa.VerifyPKCS1v15(rsaPublicKey, crypto.SHA256, hashed, sigBytes) +} + +// ==================== AES ECB加密 ==================== + +// AESECBEncrypt AES-ECB模式加密 +func AESECBEncrypt(plaintext []byte, key []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建AES cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, aes.BlockSize) + + ciphertext := make([]byte, len(padded)) + for i := 0; i < len(padded); i += aes.BlockSize { + block.Encrypt(ciphertext[i:i+aes.BlockSize], padded[i:i+aes.BlockSize]) + } + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// AESECBDecrypt AES-ECB模式解密 +func AESECBDecrypt(encryptedData string, key []byte) ([]byte, error) { + ciphertext, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建AES cipher失败: %v", err) + } + + if len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("密文长度不是块大小的整数倍") + } + + plaintext := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += aes.BlockSize { + block.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) + } + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== SM4 CBC加密 ==================== + +// SM4CBCEncrypt SM4-CBC模式加密 +// IV会前置到密文中,解密时自动提取 +func SM4CBCEncrypt(plaintext []byte, key []byte) (string, error) { + if len(key) != 16 { + return "", fmt.Errorf("SM4密钥长度必须为16字节") + } + + block, err := sm4.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + padded := pkcs7Padding(plaintext, block.BlockSize()) + + iv := make([]byte, block.BlockSize()) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", fmt.Errorf("生成IV失败: %v", err) + } + + mode := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, len(padded)) + mode.CryptBlocks(ciphertext, padded) + + // IV前置到密文 + result := append(iv, ciphertext...) + return base64.StdEncoding.EncodeToString(result), nil +} + +// SM4CBCDecrypt SM4-CBC模式解密 +// 从密文中提取前置的IV进行解密 +func SM4CBCDecrypt(encryptedData string, key []byte) ([]byte, error) { + if len(key) != 16 { + return nil, fmt.Errorf("SM4密钥长度必须为16字节") + } + + data, err := base64.StdEncoding.DecodeString(encryptedData) + if err != nil { + return nil, fmt.Errorf("解码失败: %v", err) + } + + block, err := sm4.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("创建SM4 cipher失败: %v", err) + } + + blockSize := block.BlockSize() + if len(data) < blockSize*2 { + return nil, fmt.Errorf("数据长度不足") + } + iv := data[:blockSize] + ciphertext := data[blockSize:] + + mode := cipher.NewCBCDecrypter(block, iv) + plaintext := make([]byte, len(ciphertext)) + mode.CryptBlocks(plaintext, ciphertext) + + plaintext, err = pkcs7UnPadding(plaintext) + if err != nil { + return nil, fmt.Errorf("去除填充失败: %v", err) + } + + return plaintext, nil +} + +// ==================== PKCS7填充 ==================== + +// pkcs7Padding PKCS7填充 +func pkcs7Padding(data []byte, blockSize int) []byte { + padding := blockSize - len(data)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(data, padText...) +} + +// pkcs7UnPadding 去除PKCS7填充 +func pkcs7UnPadding(data []byte) ([]byte, error) { + length := len(data) + if length == 0 { + return nil, fmt.Errorf("数据为空") + } + padding := int(data[length-1]) + if padding > length || padding == 0 { + return nil, fmt.Errorf("无效的填充") + } + for i := length - padding; i < length; i++ { + if data[i] != byte(padding) { + return nil, fmt.Errorf("无效的填充") + } + } + return data[:length-padding], nil +} + +// ==================== 业务参数加密/解密 ==================== + +// EncryptBizParams 加密业务参数 +// 1. 将业务参数去掉"零"值的参数,再由小到大按照字母排序,转成json字符串得到plaintext +// 2. 使用应用key将plaintext字符串加密,得到ciphertext +func EncryptBizParams(params interface{}, key []byte, encType EncryptType) (string, error) { + plaintext, err := marshalWithoutZero(params) + if err != nil { + return "", fmt.Errorf("序列化业务参数失败: %v", err) + } + + switch encType { + case EncryptTypeAES: + return AESECBEncrypt([]byte(plaintext), key) + case EncryptTypeSM4: + return SM4CBCEncrypt([]byte(plaintext), key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// DecryptBizParams 解密业务参数 +func DecryptBizParams(ciphertext string, key []byte, encType EncryptType) ([]byte, error) { + switch encType { + case EncryptTypeAES: + return AESECBDecrypt(ciphertext, key) + case EncryptTypeSM4: + return SM4CBCDecrypt(ciphertext, key) + default: + return nil, fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// EncryptBizParamsRaw 直接加密字节数据(用于回调验签) +func EncryptBizParamsRaw(plaintext []byte, key []byte, encType EncryptType) (string, error) { + switch encType { + case EncryptTypeAES: + return AESECBEncrypt(plaintext, key) + case EncryptTypeSM4: + return SM4CBCEncrypt(plaintext, key) + default: + return "", fmt.Errorf("不支持的加密类型: %s", encType) + } +} + +// marshalWithoutZero 将结构体序列化为JSON,去掉零值字段并按字母排序 +func marshalWithoutZero(v interface{}) (string, error) { + val := reflect.ValueOf(v) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + if val.Kind() == reflect.Map { + // 类型断言为 map[string]interface{} + m, ok := v.(map[string]interface{}) + if !ok { + // 尝试从 reflect 转换 + m = make(map[string]interface{}) + for _, key := range val.MapKeys() { + m[fmt.Sprintf("%v", key.Interface())] = val.MapIndex(key).Interface() + } + } + return marshalMapSorted(m) + } + data, err := json.Marshal(v) + return string(data), err + } + + result := make(map[string]interface{}) + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + jsonTag := fieldType.Tag.Get("json") + if jsonTag == "" || jsonTag == "-" { + continue + } + name := strings.Split(jsonTag, ",")[0] + + if isZeroValue(field) { + continue + } + + result[name] = field.Interface() + } + + return marshalMapSorted(result) +} + +// marshalMapSorted 将map按键排序后序列化为JSON +func marshalMapSorted(m map[string]interface{}) (string, error) { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + keyBytes, _ := json.Marshal(k) + buf.Write(keyBytes) + buf.WriteByte(':') + valBytes, err := json.Marshal(m[k]) + if err != nil { + return "", err + } + buf.Write(valBytes) + } + buf.WriteByte('}') + + return buf.String(), nil +} + +// isZeroValue 判断reflect.Value是否为零值 +func isZeroValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Slice, reflect.Map, reflect.Ptr: + return v.IsNil() + default: + return false + } +} + +// ==================== 签名相关 ==================== + +// BuildSignStr 构建签名字符串 +// 规则:app_id + timestamp + ciphertext +func BuildSignStr(appID, timestamp, ciphertext string) string { + return appID + timestamp + ciphertext +} + +// GenerateTimestamp 生成时间戳,格式 yyyy-MM-dd HH:mm:ss +func GenerateTimestamp() string { + return time.Now().Format("2006-01-02 15:04:05") +} + +// ==================== 回调验签 ==================== + +// VerifyNotifySign 验证回调通知签名 +func VerifyNotifySign(header *NotifyHeader, data interface{}, key []byte, publicKeyPEM string, encType EncryptType) error { + plaintext, err := marshalWithoutZero(data) + if err != nil { + return fmt.Errorf("序列化回调数据失败: %v", err) + } + + ciphertext, err := EncryptBizParamsRaw([]byte(plaintext), key, encType) + if err != nil { + return fmt.Errorf("加密回调数据失败: %v", err) + } + + signStr := BuildSignStr(header.Appid, header.Timestamp, ciphertext) + + return VerifyWithRSA(signStr, header.Sign, publicKeyPEM) +} +``` + +// File: ymt_v3/client.go +```go +package ymt_v3 + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// Env 环境 +type Env string + +const ( + EnvTest Env = "test" + EnvProduct Env = "product" +) + +// 环境地址 +const ( + testBaseURL = "https://gateway.dev.cdlsxd.cn" + productBaseURL = "https://market.api.86698.cn" +) + +// ClientConfig 客户端配置 +type ClientConfig struct { + AppID string // 应用ID + PrivateKey string // 应用私钥(PEM格式) + PublicKey string // 平台公钥(PEM格式) + Key string // 业务参数加密key + EncryptType EncryptType // 加密类型:aes 或 sm4 + Env Env // 环境:test 或 product + HTTPClient *http.Client // HTTP客户端,可选 +} + +// Client SDK客户端 +type Client struct { + config *ClientConfig + httpClient *http.Client + baseURL string +} + +// NewClient 创建新的SDK客户端 +func NewClient(config *ClientConfig) (*Client, error) { + if config.AppID == "" { + return nil, fmt.Errorf("AppID不能为空") + } + if config.PrivateKey == "" { + return nil, fmt.Errorf("PrivateKey不能为空") + } + if config.PublicKey == "" { + return nil, fmt.Errorf("PublicKey不能为空") + } + if config.Key == "" { + return nil, fmt.Errorf("Key不能为空") + } + if config.EncryptType == "" { + config.EncryptType = EncryptTypeAES + } + if config.Env == "" { + config.Env = EnvTest + } + + var baseURL string + switch config.Env { + case EnvTest: + baseURL = testBaseURL + case EnvProduct: + baseURL = productBaseURL + default: + return nil, fmt.Errorf("不支持的环境: %s", config.Env) + } + + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = &http.Client{} + } + + return &Client{ + config: config, + httpClient: httpClient, + baseURL: baseURL, + }, nil +} + +// doRequest 发送请求并处理响应 +func (c *Client) doRequest(path string, bizParams interface{}, result interface{}) error { + // 1. 加密业务参数 + ciphertext, err := EncryptBizParams(bizParams, []byte(c.config.Key), c.config.EncryptType) + if err != nil { + return NewSDKError("加密业务参数失败", err) + } + + // 2. 生成时间戳 + timestamp := GenerateTimestamp() + + // 3. 构建签名字符串并签名 + signStr := BuildSignStr(c.config.AppID, timestamp, ciphertext) + sign, err := SignWithRSA(signStr, c.config.PrivateKey) + if err != nil { + return NewSDKError("签名失败", err) + } + + // 4. 构建请求体 + reqBody := map[string]string{ + "ciphertext": ciphertext, + } + reqBodyBytes, err := json.Marshal(reqBody) + if err != nil { + return NewSDKError("序列化请求体失败", err) + } + + // 5. 创建HTTP请求 + url := c.baseURL + path + req, err := http.NewRequest("POST", url, bytes.NewReader(reqBodyBytes)) + if err != nil { + return NewSDKError("创建HTTP请求失败", err) + } + + // 6. 设置Header + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Appid", c.config.AppID) + req.Header.Set("Timestamp", timestamp) + req.Header.Set("Sign", sign) + + // 7. 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return NewSDKError("发送HTTP请求失败", err) + } + defer resp.Body.Close() + + // 8. 读取响应 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return NewSDKError("读取响应失败", err) + } + + // 9. 解析公共响应 + var commonResp CommonResponse + if err := json.Unmarshal(respBody, &commonResp); err != nil { + return NewSDKError("解析响应JSON失败", err) + } + + // 10. 检查业务错误 + if commonResp.Code != 200 { + return NewAPIError(commonResp.Code, commonResp.Message, commonResp.Reason) + } + + // 11. 检查是否有加密数据 + if commonResp.Data == nil || commonResp.Data.Ciphertext == "" { + return NewSDKError("响应中没有加密数据", nil) + } + + // 12. 解密响应数据 + decrypted, err := DecryptBizParams(commonResp.Data.Ciphertext, []byte(c.config.Key), c.config.EncryptType) + if err != nil { + return NewSDKError("解密响应数据失败", err) + } + + // 13. 解析解密后的数据到结果 + if err := json.Unmarshal(decrypted, result); err != nil { + return NewSDKError("解析解密后的响应数据失败", err) + } + + return nil +} + +// ==================== API方法 ==================== + +// KeyOrder 获取券码 +// POST /openapi/v1/key/order +func (c *Client) KeyOrder(req *KeyOrderRequest) (*KeyOrderResponse, error) { + result := &KeyOrderResponse{} + if err := c.doRequest("/openapi/v1/key/order", req, result); err != nil { + return nil, err + } + return result, nil +} + +// KeyQuery 券码查询 +// POST /openapi/v1/key/query +func (c *Client) KeyQuery(req *KeyQueryRequest) (*KeyOrderResponse, error) { + result := &KeyOrderResponse{} + if err := c.doRequest("/openapi/v1/key/query", req, result); err != nil { + return nil, err + } + return result, nil +} + +// KeyDiscard 券码作废 +// POST /openapi/v1/key/discard +func (c *Client) KeyDiscard(req *KeyDiscardRequest) (*KeyDiscardResponse, error) { + result := &KeyDiscardResponse{} + if err := c.doRequest("/openapi/v1/key/discard", req, result); err != nil { + return nil, err + } + return result, nil +} + +// BatchOrder 批量发卡 +// POST /openapi/v1/key/batch_order +func (c *Client) BatchOrder(req *BatchOrderRequest) (*BatchOrderResponse, error) { + result := &BatchOrderResponse{} + if err := c.doRequest("/openapi/v1/key/batch_order", req, result); err != nil { + return nil, err + } + return result, nil +} + +// BatchQuery 批量查询 +// POST /openapi/v1/key/batch_query +func (c *Client) BatchQuery(req *BatchQueryRequest) (*BatchQueryResponse, error) { + result := &BatchQueryResponse{} + if err := c.doRequest("/openapi/v1/key/batch_query", req, result); err != nil { + return nil, err + } + return result, nil +} + +// VerifyNotify 验证回调通知 +func (c *Client) VerifyNotify(header *NotifyHeader, data interface{}) error { + return VerifyNotifySign(header, data, []byte(c.config.Key), c.config.PublicKey, c.config.EncryptType) +} + +// GetBaseURL 获取当前环境的基础URL +func (c *Client) GetBaseURL() string { + return c.baseURL +} + +// GetAppID 获取应用ID +func (c *Client) GetAppID() string { + return c.config.AppID +} + +// String 返回客户端配置的字符串表示(隐藏敏感信息) +func (c *Client) String() string { + return fmt.Sprintf("Client{AppID: %s, Env: %s, BaseURL: %s, EncryptType: %s}", + c.config.AppID, c.config.Env, c.baseURL, c.config.EncryptType) +} +``` + +// File: ymt_v3/example_test.go +```go +package ymt_v3 + +import ( + "encoding/json" + "fmt" + "testing" +) + +// 测试参数(请替换为实际值) +const ( + testAppID = "your_app_id" + testPrivateKey = `-----BEGIN PRIVATE KEY----- +your_private_key_here +-----END PRIVATE KEY-----` + testPublicKey = `-----BEGIN PUBLIC KEY----- +your_public_key_here +-----END PUBLIC KEY-----` + testKey = "your_encryption_key_16" + testActivityNo = "your_activity_no" +) + +func ExampleClient_KeyOrder() { + // 创建客户端 + client, err := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + if err != nil { + fmt.Printf("创建客户端失败: %v\n", err) + return + } + + // 获取券码 + resp, err := client.KeyOrder(&KeyOrderRequest{ + OutBizNo: "order_001", + ActivityNo: testActivityNo, + Account: "18666666666", + NotifyURL: "https://notify.example.com/openapi", + }) + if err != nil { + fmt.Printf("获取券码失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("卡密: %s\n", resp.Key) + fmt.Printf("状态: %d\n", resp.Status) + + // Output: + // 交易号: 7251449503000383488 + // 卡密: aZKdU9BymzR6qGRzJM + // 状态: 1 +} + +func ExampleClient_KeyQuery() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 按外部业务号查询 + resp, err := client.KeyQuery(&KeyQueryRequest{ + OutBizNo: "order_001", + }) + if err != nil { + fmt.Printf("查询失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("状态: %d\n", resp.Status) +} + +func ExampleClient_KeyDiscard() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 按交易号作废 + resp, err := client.KeyDiscard(&KeyDiscardRequest{ + TradeNo: "7251449503000383488", + }) + if err != nil { + fmt.Printf("作废失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("状态: %d\n", resp.Status) +} + +func ExampleClient_BatchOrder() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 批量发卡 + resp, err := client.BatchOrder(&BatchOrderRequest{ + OutBizNo: "batch_001", + ActivityNo: testActivityNo, + Number: 100, + NotifyURL: "https://notify.example.com/openapi", + }) + if err != nil { + fmt.Printf("批量发卡失败: %v\n", err) + return + } + + fmt.Printf("交易号: %s\n", resp.TradeNo) + fmt.Printf("状态: %s\n", resp.Status) +} + +func ExampleClient_BatchQuery() { + client, _ := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + + // 查询批量任务 + resp, err := client.BatchQuery(&BatchQueryRequest{ + TradeNo: "7251449503000383499", + }) + if err != nil { + fmt.Printf("查询失败: %v\n", err) + return + } + + fmt.Printf("状态: %s\n", resp.Status) + if resp.DownloadURL != "" { + fmt.Printf("下载地址: %s\n", resp.DownloadURL) + } +} + +func TestMarshalWithoutZero(t *testing.T) { + req := KeyOrderRequest{ + OutBizNo: "order_001", + ActivityNo: "ACT20260622001", + // Account和NotifyURL为空,应该被去掉 + } + + result, err := marshalWithoutZero(req) + if err != nil { + t.Fatalf("marshalWithoutZero失败: %v", err) + } + + expected := `{"activity_no":"ACT20260622001","out_biz_no":"order_001"}` + if result != expected { + t.Errorf("期望 %s, 得到 %s", expected, result) + } +} + +func TestBuildSignStr(t *testing.T) { + appID := "123456" + timestamp := "2026-06-22 15:30:00" + ciphertext := "encrypted_data" + + signStr := BuildSignStr(appID, timestamp, ciphertext) + expected := "1234562026-06-22 15:30:00encrypted_data" + if signStr != expected { + t.Errorf("期望 %s, 得到 %s", expected, signStr) + } +} + +func TestGenerateTimestamp(t *testing.T) { + ts := GenerateTimestamp() + // 验证格式 yyyy-MM-dd HH:mm:ss + if len(ts) != 19 { + t.Errorf("时间戳格式错误: %s", ts) + } +} + +func TestEncryptDecryptAES(t *testing.T) { + key := []byte("1234567890123456") // 16字节 + plaintext := `{"out_biz_no":"order_001","activity_no":"ACT001"}` + + ciphertext, err := AESECBEncrypt([]byte(plaintext), key) + if err != nil { + t.Fatalf("加密失败: %v", err) + } + + decrypted, err := AESECBDecrypt(ciphertext, key) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + + if string(decrypted) != plaintext { + t.Errorf("解密结果不匹配: 期望 %s, 得到 %s", plaintext, string(decrypted)) + } +} + +func TestEncryptDecryptSM4(t *testing.T) { + key := []byte("1234567890123456") // 16字节 + plaintext := `{"out_biz_no":"order_001","activity_no":"ACT001"}` + + ciphertext, err := SM4CBCEncrypt([]byte(plaintext), key) + if err != nil { + t.Fatalf("SM4加密失败: %v", err) + } + + decrypted, err := SM4CBCDecrypt(ciphertext, key) + if err != nil { + t.Fatalf("SM4解密失败: %v", err) + } + + if string(decrypted) != plaintext { + t.Errorf("SM4解密结果不匹配: 期望 %s, 得到 %s", plaintext, string(decrypted)) + } +} + +func TestEncryptBizParams(t *testing.T) { + key := []byte("1234567890123456") + req := KeyOrderRequest{ + OutBizNo: "order_001", + ActivityNo: "ACT20260622001", + } + + ciphertext, err := EncryptBizParams(req, key, EncryptTypeAES) + if err != nil { + t.Fatalf("加密业务参数失败: %v", err) + } + + if ciphertext == "" { + t.Fatal("加密结果为空") + } + + // 验证可以解密 + decrypted, err := DecryptBizParams(ciphertext, key, EncryptTypeAES) + if err != nil { + t.Fatalf("解密失败: %v", err) + } + + var result KeyOrderRequest + if err := json.Unmarshal(decrypted, &result); err != nil { + t.Fatalf("解析失败: %v", err) + } + + if result.OutBizNo != "order_001" { + t.Errorf("OutBizNo不匹配: 期望 order_001, 得到 %s", result.OutBizNo) + } +} + +func TestNewClient(t *testing.T) { + _, err := NewClient(&ClientConfig{ + AppID: testAppID, + PrivateKey: testPrivateKey, + PublicKey: testPublicKey, + Key: testKey, + EncryptType: EncryptTypeAES, + Env: EnvTest, + }) + if err != nil { + t.Fatalf("创建客户端失败: %v", err) + } +} + +func TestNewClientMissingFields(t *testing.T) { + tests := []struct { + name string + config *ClientConfig + wantErr bool + }{ + {"空AppID", &ClientConfig{PrivateKey: "pk", PublicKey: "pub", Key: "key"}, true}, + {"空PrivateKey", &ClientConfig{AppID: "app", PublicKey: "pub", Key: "key"}, true}, + {"空PublicKey", &ClientConfig{AppID: "app", PrivateKey: "pk", Key: "key"}, true}, + {"空Key", &ClientConfig{AppID: "app", PrivateKey: "pk", PublicKey: "pub"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewClient(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("NewClient() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestAPIError(t *testing.T) { + err := NewAPIError(401, "Signature verification failed", "INVALID_SIGNATURE") + if err.Code != 401 { + t.Errorf("Code不匹配: 期望 401, 得到 %d", err.Code) + } + if err.Message != "Signature verification failed" { + t.Errorf("Message不匹配") + } + if err.Reason != "INVALID_SIGNATURE" { + t.Errorf("Reason不匹配") + } + if err.Error() != "API error: code=401, message=Signature verification failed, reason=INVALID_SIGNATURE" { + t.Errorf("Error()不匹配: %s", err.Error()) + } + if err.IsSuccess() { + t.Error("401应该不是成功") + } +} + +func TestSDKError(t *testing.T) { + err := NewSDKError("测试错误", nil) + if err.Error() != "SDK error: 测试错误" { + t.Errorf("Error()不匹配: %s", err.Error()) + } +} +``` \ No newline at end of file