This commit is contained in:
renzhiyuan 2026-07-29 10:28:21 +08:00
parent ceba8ce43d
commit 9d3bfd07ff
37 changed files with 6629 additions and 340 deletions

View File

@ -44,6 +44,4 @@ ENV TZ=Asia/Shanghai
CMD ["./server"]
# 不被他人看法左右
# 纸上谈兵是免费的
# 《拿来》

View File

@ -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))
t.Logf("健康检查接口测试成功,响应: %s", err)
}

View File

@ -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)
}
}
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),
})
}

202
xy_sh/config/config.yaml Normal file
View File

@ -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="

View File

@ -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

19
xy_sh/gen.sh Executable file
View File

@ -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}

View File

@ -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
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
)

View File

@ -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,
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,
CreateTime: time.Now(),
UpdateTime: time.Now(),
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
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,
},
}
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(),
res, err := requset.Send()
if err != nil {
return err
}
store.orders[req.OrderNo] = orderInfo
if strings.ToLower(res.Text) != "ok" {
}
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
}
return nil
}
func generateOrderNo() string {
return "HM" + strconv.FormatInt(time.Now().UnixMilli(), 10) + randomString(16)
func makeEncryptedRequest(bizData interface{}) (string, map[string]interface{}, error) {
bizJSON, err := json.Marshal(bizData)
if err != nil {
return "", nil, fmt.Errorf("业务数据序列化失败: %v", err)
}
func generateCouponNo() string {
return randomString(16)
encryptedData, err := crypto.SM4CBCEncrypt(bizJSON)
if err != nil {
return "", nil, fmt.Errorf("加密失败: %v", err)
}
func generateCouponId() string {
return randomString(8)
}
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
func randomString(n int) string {
b := make([]byte, (n+1)/2)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
panic(err)
sign := crypto.GenerateSign(timestamp, encryptedData)
reqBody := entities.EncryptedRequest{
EncryptedData: encryptedData,
}
return hex.EncodeToString(b)[:n]
reqJSON, _ := pkg.StructToMap(reqBody)
return sign, reqJSON, err
}

View File

@ -0,0 +1,9 @@
package biz
import (
"github.com/google/wire"
)
var ProviderSetBiz = wire.NewSet(
NewOrderStore,
)

View File

@ -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"`
}
return defaultValue
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)
}
// 解析配置
var bc Config
if err := viper.Unmarshal(&bc); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return &bc, nil
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 <xy_sh_log>
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
}

View File

@ -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 <xy_sh_order>
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
}

View File

@ -41,3 +41,16 @@ type CardInfo struct {
CouponCode string `json:"couponCode"`
ExpireTime string `json:"expireTime"`
}
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"`
}

View File

@ -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 err != nil {
return err
if c.Path() == "/api/v1/callback/notify" {
return nil
}
var resp *Res
// 如果有错误发生
if err != nil {
// 返回自定义错误响应
resp = &Res{
Msg: err.Error(),
Code: entities.CodeFailed,
Data: nil,
}
} else {
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{
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
}
}

View File

@ -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,
)
}
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(),
})
}

View File

@ -0,0 +1,9 @@
package service
import (
"github.com/google/wire"
)
var ProviderSetService = wire.NewSet(
NewOrder,
)

View File

@ -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": "服务运行正常",
})
}

127
xy_sh/pkg/func.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

25
xy_sh/utils/gorm.go Normal file
View File

@ -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
}

View File

@ -0,0 +1,10 @@
package utils
import (
"github.com/google/wire"
)
var ProviderUtils = wire.NewSet(
NewGormDb,
)

View File

@ -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)
}
}
}
}

View File

@ -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
}

311
xy_sh/ymt_v3/README.md Normal file
View File

@ -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地址回调验签规则参考上述认证与安全章节的回调验签流程。

240
xy_sh/ymt_v3/client.go Normal file
View File

@ -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)
}

409
xy_sh/ymt_v3/crypto.go Normal file
View File

@ -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)
}

50
xy_sh/ymt_v3/errors.go Normal file
View File

@ -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}
}

View File

@ -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())
}
}

2092
xy_sh/ymt_v3/generate.md Normal file

File diff suppressed because it is too large Load Diff

311
xy_sh/ymt_v3/refine.md Normal file
View File

@ -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地址回调验签规则参考上述认证与安全章节的回调验签流程。

98
xy_sh/ymt_v3/types.go Normal file
View File

@ -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"`
}

1149
xy_sh/ymt_v3/valid.md Normal file

File diff suppressed because it is too large Load Diff