f
This commit is contained in:
123
internal/infrastructure/external/README.md
vendored
Normal file
123
internal/infrastructure/external/README.md
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
# 外部服务错误处理修复说明
|
||||
|
||||
## 问题描述
|
||||
|
||||
在外部服务(WestDex、Yushan、Zhicha)中,使用 `fmt.Errorf("%w: %s", ErrXXX, err)` 包装错误后,外层的 `errors.Is(err, ErrXXX)` 无法正确识别错误类型。
|
||||
|
||||
## 问题原因
|
||||
|
||||
`fmt.Errorf` 创建的包装错误虽然实现了 `Unwrap()` 接口,但没有实现 `Is()` 接口,因此 `errors.Is` 无法正确判断错误类型。
|
||||
|
||||
## 修复方案
|
||||
|
||||
统一使用 `errors.Join` 来组合错误,这是 Go 1.20+ 的标准做法,天然支持 `errors.Is` 判断。
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. WestDex 服务 (`westdex_service.go`)
|
||||
|
||||
#### 修复前:
|
||||
```go
|
||||
// 无法被 errors.Is 识别的错误包装
|
||||
err = fmt.Errorf("%w: %s", ErrSystem, marshalErr.Error())
|
||||
err = fmt.Errorf("%w: %s", ErrDatasource, westDexResp.Message)
|
||||
```
|
||||
|
||||
#### 修复后:
|
||||
```go
|
||||
// 可以被 errors.Is 正确识别的错误组合
|
||||
err = errors.Join(ErrSystem, marshalErr)
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf(westDexResp.Message))
|
||||
```
|
||||
|
||||
### 2. Yushan 服务 (`yushan_service.go`)
|
||||
|
||||
#### 修复前:
|
||||
```go
|
||||
// 无法被 errors.Is 识别的错误包装
|
||||
err = fmt.Errorf("%w: %s", ErrSystem, err.Error())
|
||||
err = fmt.Errorf("%w: %s", ErrDatasource, "羽山请求retdata为空")
|
||||
```
|
||||
|
||||
#### 修复后:
|
||||
```go
|
||||
// 可以被 errors.Is 正确识别的错误组合
|
||||
err = errors.Join(ErrSystem, err)
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("羽山请求retdata为空"))
|
||||
```
|
||||
|
||||
### 3. Zhicha 服务 (`zhicha_service.go`)
|
||||
|
||||
#### 修复前:
|
||||
```go
|
||||
// 无法被 errors.Is 识别的错误包装
|
||||
err = fmt.Errorf("%w: %s", ErrSystem, marshalErr.Error())
|
||||
err = fmt.Errorf("%w: %s", ErrDatasource, "HTTP状态码 %d", response.StatusCode)
|
||||
```
|
||||
|
||||
#### 修复后:
|
||||
```go
|
||||
// 可以被 errors.Is 正确识别的错误组合
|
||||
err = errors.Join(ErrSystem, marshalErr)
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", response.StatusCode))
|
||||
```
|
||||
|
||||
## 修复效果
|
||||
|
||||
### 修复前的问题:
|
||||
```go
|
||||
// 在应用服务层
|
||||
if errors.Is(err, westdex.ErrDatasource) {
|
||||
// 这里无法正确识别,因为 fmt.Errorf 包装的错误
|
||||
// 没有实现 Is() 接口
|
||||
return ErrDatasource
|
||||
}
|
||||
```
|
||||
|
||||
### 修复后的效果:
|
||||
```go
|
||||
// 在应用服务层
|
||||
if errors.Is(err, westdex.ErrDatasource) {
|
||||
// 现在可以正确识别了!
|
||||
return ErrDatasource
|
||||
}
|
||||
|
||||
if errors.Is(err, westdex.ErrSystem) {
|
||||
// 系统错误也能正确识别
|
||||
return ErrSystem
|
||||
}
|
||||
```
|
||||
|
||||
## 优势
|
||||
|
||||
1. **完全兼容**:`errors.Is` 现在可以正确识别所有错误类型
|
||||
2. **标准做法**:使用 Go 1.20+ 的 `errors.Join` 标准库功能
|
||||
3. **性能优秀**:标准库实现,性能优于自定义解决方案
|
||||
4. **维护简单**:无需自定义错误类型,代码更简洁
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Go版本要求**:需要 Go 1.20 或更高版本(项目使用 Go 1.23.4,完全满足)
|
||||
2. **错误消息格式**:`errors.Join` 使用换行符分隔多个错误
|
||||
3. **向后兼容**:现有的错误处理代码无需修改
|
||||
|
||||
## 测试验证
|
||||
|
||||
所有修复后的外部服务都能正确编译:
|
||||
```bash
|
||||
go build ./internal/infrastructure/external/westdex/...
|
||||
go build ./internal/infrastructure/external/yushan/...
|
||||
go build ./internal/infrastructure/external/zhicha/...
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
通过统一使用 `errors.Join` 修复外部服务的错误处理,现在:
|
||||
|
||||
- ✅ `errors.Is(err, ErrDatasource)` 可以正确识别数据源异常
|
||||
- ✅ `errors.Is(err, ErrSystem)` 可以正确识别系统异常
|
||||
- ✅ `errors.Is(err, ErrNotFound)` 可以正确识别查询为空
|
||||
- ✅ 错误处理逻辑更加清晰和可靠
|
||||
- ✅ 符合 Go 1.20+ 的最佳实践
|
||||
|
||||
这个修复确保了整个系统的错误处理链路都能正确工作,提高了系统的可靠性和可维护性。
|
||||
194
internal/infrastructure/external/alicloud/README.md
vendored
Normal file
194
internal/infrastructure/external/alicloud/README.md
vendored
Normal file
@@ -0,0 +1,194 @@
|
||||
# 阿里云二要素验证服务
|
||||
|
||||
这个服务提供了调用阿里云身份证二要素验证API的功能,用于验证姓名和身份证号码是否匹配。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 身份证二要素验证(姓名 + 身份证号)
|
||||
- 支持详细验证结果返回
|
||||
- 支持简单布尔值判断
|
||||
- 错误处理和中文错误信息
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 必需配置
|
||||
|
||||
- `Host`: 阿里云API的域名地址
|
||||
- `AppCode`: 阿里云市场应用的AppCode
|
||||
|
||||
### 配置示例
|
||||
|
||||
```go
|
||||
host := "https://kzidcardv1.market.alicloudapi.com"
|
||||
appCode := "您的AppCode"
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 创建服务实例
|
||||
|
||||
```go
|
||||
service := NewAlicloudService(host, appCode)
|
||||
```
|
||||
|
||||
### 2. 调用API
|
||||
|
||||
#### 身份证二要素验证示例
|
||||
|
||||
```go
|
||||
// 构建请求参数
|
||||
params := map[string]interface{}{
|
||||
"name": "张三",
|
||||
"idcard": "110101199001011234",
|
||||
}
|
||||
|
||||
// 调用API
|
||||
responseBody, err := service.CallAPI("api-mall/api/id_card/check", params)
|
||||
if err != nil {
|
||||
log.Printf("验证失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析完整响应结构
|
||||
var response struct {
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Birthday string `json:"birthday"`
|
||||
Result int `json:"result"`
|
||||
Address string `json:"address"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
Sex string `json:"sex"`
|
||||
Desc string `json:"desc"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
log.Printf("响应解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if response.Code != 200 {
|
||||
log.Printf("API返回错误: code=%d, msg=%s", response.Code, response.Msg)
|
||||
return
|
||||
}
|
||||
|
||||
idCardData := response.Data
|
||||
|
||||
// 判断验证结果
|
||||
if idCardData.Result == 1 {
|
||||
fmt.Println("身份证信息验证通过")
|
||||
} else {
|
||||
fmt.Println("身份证信息验证失败")
|
||||
}
|
||||
```
|
||||
|
||||
#### 通用API调用
|
||||
|
||||
```go
|
||||
// 调用其他阿里云API
|
||||
params := map[string]interface{}{
|
||||
"param1": "value1",
|
||||
"param2": "value2",
|
||||
}
|
||||
|
||||
responseBody, err := service.CallAPI("your/api/path", params)
|
||||
if err != nil {
|
||||
log.Printf("API调用失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据具体API的响应结构进行解析
|
||||
// 每个API的响应结构可能不同,需要根据API文档定义相应的结构体
|
||||
var response struct {
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
log.Printf("响应解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 处理响应数据
|
||||
fmt.Printf("响应数据: %s\n", string(responseBody))
|
||||
```
|
||||
|
||||
## 响应格式
|
||||
|
||||
### 通用响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"msg": "成功",
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"data": {
|
||||
// 具体的业务数据
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 身份证验证响应示例
|
||||
|
||||
#### 成功响应 (code: 200)
|
||||
|
||||
```json
|
||||
{
|
||||
"msg": "成功",
|
||||
"success": true,
|
||||
"code": 200,
|
||||
"data": {
|
||||
"birthday": "19840816",
|
||||
"result": 1,
|
||||
"address": "浙江省杭州市淳安县",
|
||||
"orderNo": "202406271440416095174",
|
||||
"sex": "男",
|
||||
"desc": "不一致"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 参数错误响应 (code: 400)
|
||||
|
||||
```json
|
||||
{
|
||||
"msg": "请输入有效的身份证号码",
|
||||
"code": 400,
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"msg": "AppCode无效",
|
||||
"success": false,
|
||||
"code": 400
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
服务定义了以下错误类型:
|
||||
|
||||
- `ErrDatasource`: 数据源异常
|
||||
- `ErrSystem`: 系统异常
|
||||
- `ErrInvalid`: 身份证信息不匹配
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 请确保您的AppCode有效且有足够的调用额度
|
||||
2. 身份证号码必须是18位有效格式
|
||||
3. 姓名必须是真实有效的姓名
|
||||
4. 建议在生产环境中添加适当的重试机制和超时设置
|
||||
5. 请遵守阿里云API的使用规范和频率限制
|
||||
|
||||
## 依赖
|
||||
|
||||
- Go 1.16+
|
||||
- 标准库:`net/http`, `encoding/json`, `net/url`
|
||||
48
internal/infrastructure/external/alicloud/alicloud_factory.go
vendored
Normal file
48
internal/infrastructure/external/alicloud/alicloud_factory.go
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
package alicloud
|
||||
|
||||
import (
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewAlicloudServiceWithConfig 使用配置创建阿里云服务,并启用外部服务调用日志
|
||||
func NewAlicloudServiceWithConfig(cfg *config.Config) (*AlicloudService, error) {
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: true,
|
||||
LogDir: "./logs/external_services",
|
||||
ServiceName: "alicloud",
|
||||
UseDaily: false,
|
||||
EnableLevelSeparation: true,
|
||||
LevelConfigs: map[string]external_logger.ExternalServiceLevelFileConfig{
|
||||
"info": {
|
||||
MaxSize: 100,
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28,
|
||||
Compress: true,
|
||||
},
|
||||
"error": {
|
||||
MaxSize: 100,
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28,
|
||||
Compress: true,
|
||||
},
|
||||
"warn": {
|
||||
MaxSize: 100,
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28,
|
||||
Compress: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewAlicloudService(
|
||||
cfg.Alicloud.Host,
|
||||
cfg.Alicloud.AppCode,
|
||||
logger,
|
||||
), nil
|
||||
}
|
||||
142
internal/infrastructure/external/alicloud/alicloud_service.go
vendored
Normal file
142
internal/infrastructure/external/alicloud/alicloud_service.go
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
package alicloud
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
)
|
||||
|
||||
// AlicloudConfig 阿里云配置
|
||||
type AlicloudConfig struct {
|
||||
Host string
|
||||
AppCode string
|
||||
}
|
||||
|
||||
// AlicloudService 阿里云服务
|
||||
type AlicloudService struct {
|
||||
config AlicloudConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
}
|
||||
|
||||
// NewAlicloudService 创建阿里云服务实例
|
||||
func NewAlicloudService(host, appCode string, logger ...*external_logger.ExternalServiceLogger) *AlicloudService {
|
||||
var serviceLogger *external_logger.ExternalServiceLogger
|
||||
if len(logger) > 0 {
|
||||
serviceLogger = logger[0]
|
||||
}
|
||||
return &AlicloudService{
|
||||
config: AlicloudConfig{
|
||||
Host: host,
|
||||
AppCode: appCode,
|
||||
},
|
||||
logger: serviceLogger,
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID 生成请求ID
|
||||
func (a *AlicloudService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, a.config.Host)))
|
||||
return fmt.Sprintf("alicloud_%x", hash[:8])
|
||||
}
|
||||
|
||||
// CallAPI 调用阿里云API的通用方法
|
||||
// path: API路径(如 "api-mall/api/id_card/check")
|
||||
// params: 请求参数
|
||||
func (a *AlicloudService) CallAPI(path string, params map[string]interface{}) (respBytes []byte, err error) {
|
||||
startTime := time.Now()
|
||||
requestID := a.generateRequestID()
|
||||
transactionID := ""
|
||||
|
||||
// 构建请求URL
|
||||
reqURL := a.config.Host + "/" + path
|
||||
|
||||
// 记录请求日志
|
||||
if a.logger != nil {
|
||||
a.logger.LogRequest(requestID, transactionID, path, reqURL)
|
||||
}
|
||||
|
||||
// 构建请求参数
|
||||
formData := url.Values{}
|
||||
for key, value := range params {
|
||||
formData.Set(key, fmt.Sprintf("%v", value))
|
||||
}
|
||||
|
||||
// 创建HTTP请求
|
||||
req, err := http.NewRequest("POST", reqURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
if a.logger != nil {
|
||||
a.logger.LogError(requestID, transactionID, path, errors.Join(ErrSystem, err), params)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrSystem, err.Error())
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("Authorization", "APPCODE "+a.config.AppCode)
|
||||
|
||||
// 发送请求,超时时间设置为60秒
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
if isTimeout {
|
||||
if a.logger != nil {
|
||||
a.logger.LogError(requestID, transactionID, path, errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %s", err.Error())), params)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: API请求超时: %s", ErrDatasource, err.Error())
|
||||
}
|
||||
if a.logger != nil {
|
||||
a.logger.LogError(requestID, transactionID, path, errors.Join(ErrSystem, err), params)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrSystem, err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应体
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
if a.logger != nil {
|
||||
a.logger.LogError(requestID, transactionID, path, errors.Join(ErrSystem, err), params)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrSystem, err.Error())
|
||||
}
|
||||
|
||||
// 记录响应日志(不记录具体响应数据)
|
||||
if a.logger != nil {
|
||||
duration := time.Since(startTime)
|
||||
a.logger.LogResponse(requestID, transactionID, path, resp.StatusCode, duration)
|
||||
}
|
||||
|
||||
// 直接返回原始响应body,让调用方自己处理
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// GetConfig 获取配置信息
|
||||
func (a *AlicloudService) GetConfig() AlicloudConfig {
|
||||
return a.config
|
||||
}
|
||||
143
internal/infrastructure/external/alicloud/alicloud_service_test.go
vendored
Normal file
143
internal/infrastructure/external/alicloud/alicloud_service_test.go
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
package alicloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRealAlicloudAPI(t *testing.T) {
|
||||
// 使用真实的阿里云API配置
|
||||
host := "https://kzidcardv1.market.alicloudapi.com"
|
||||
appCode := "d55b58829efb41c8aa8e86769cba4844"
|
||||
|
||||
service := NewAlicloudService(host, appCode)
|
||||
|
||||
// 测试真实的身份证验证
|
||||
name := "张荣宏"
|
||||
idCard := "45212220000827423X"
|
||||
|
||||
fmt.Printf("开始测试阿里云二要素验证API...\n")
|
||||
fmt.Printf("姓名: %s\n", name)
|
||||
fmt.Printf("身份证: %s\n", idCard)
|
||||
|
||||
// 构建请求参数
|
||||
params := map[string]interface{}{
|
||||
"name": name,
|
||||
"idcard": idCard,
|
||||
}
|
||||
|
||||
// 调用真实API
|
||||
responseBody, err := service.CallAPI("api-mall/api/id_card/check", params)
|
||||
if err != nil {
|
||||
t.Logf("API调用失败: %v", err)
|
||||
fmt.Printf("错误详情: %v\n", err)
|
||||
t.Fail()
|
||||
return
|
||||
}
|
||||
|
||||
// 打印原始响应数据
|
||||
fmt.Printf("API响应成功!\n")
|
||||
fmt.Printf("原始响应数据: %s\n", string(responseBody))
|
||||
|
||||
// 解析完整响应结构
|
||||
var response struct {
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Birthday string `json:"birthday"`
|
||||
Result int `json:"result"`
|
||||
Address string `json:"address"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
Sex string `json:"sex"`
|
||||
Desc string `json:"desc"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
t.Logf("响应数据解析失败: %v", err)
|
||||
t.Fail()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if response.Code != 200 {
|
||||
t.Logf("API返回错误: code=%d, msg=%s", response.Code, response.Msg)
|
||||
t.Fail()
|
||||
return
|
||||
}
|
||||
|
||||
idCardData := response.Data
|
||||
|
||||
// 打印详细响应结果
|
||||
fmt.Printf("验证结果: %d\n", idCardData.Result)
|
||||
fmt.Printf("描述: %s\n", idCardData.Desc)
|
||||
fmt.Printf("生日: %s\n", idCardData.Birthday)
|
||||
fmt.Printf("性别: %s\n", idCardData.Sex)
|
||||
fmt.Printf("地址: %s\n", idCardData.Address)
|
||||
fmt.Printf("订单号: %s\n", idCardData.OrderNo)
|
||||
|
||||
// 将完整响应转换为JSON并打印
|
||||
jsonResponse, _ := json.MarshalIndent(idCardData, "", " ")
|
||||
fmt.Printf("完整响应JSON:\n%s\n", string(jsonResponse))
|
||||
|
||||
// 判断验证结果
|
||||
if idCardData.Result == 1 {
|
||||
fmt.Printf("验证结果: 通过\n")
|
||||
} else {
|
||||
fmt.Printf("验证结果: 失败\n")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlicloudAPIError 测试错误响应
|
||||
func TestAlicloudAPIError(t *testing.T) {
|
||||
// 使用真实的阿里云API配置
|
||||
host := "https://kzidcardv1.market.alicloudapi.com"
|
||||
appCode := "d55b58829efb41c8aa8e86769cba4844"
|
||||
|
||||
service := NewAlicloudService(host, appCode)
|
||||
|
||||
// 测试无效的身份证号码
|
||||
name := "张三"
|
||||
invalidIdCard := "123456789"
|
||||
|
||||
fmt.Printf("测试错误响应 - 无效身份证号\n")
|
||||
fmt.Printf("姓名: %s\n", name)
|
||||
fmt.Printf("身份证: %s\n", invalidIdCard)
|
||||
|
||||
// 构建请求参数
|
||||
params := map[string]interface{}{
|
||||
"name": name,
|
||||
"idcard": invalidIdCard,
|
||||
}
|
||||
|
||||
// 调用真实API
|
||||
responseBody, err := service.CallAPI("api-mall/api/id_card/check", params)
|
||||
if err != nil {
|
||||
fmt.Printf("网络请求错误: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var response struct {
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
fmt.Printf("响应解析失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否为错误响应
|
||||
if response.Code != 200 {
|
||||
fmt.Printf("预期的错误响应: code=%d, msg=%s\n", response.Code, response.Msg)
|
||||
fmt.Printf("错误处理正确: API返回错误状态\n")
|
||||
} else {
|
||||
t.Error("期望返回错误,但实际成功")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
76
internal/infrastructure/external/alicloud/example.go
vendored
Normal file
76
internal/infrastructure/external/alicloud/example.go
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
package alicloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ExampleUsage 使用示例
|
||||
func ExampleUsage() {
|
||||
// 创建阿里云服务实例
|
||||
// 请替换为您的实际配置
|
||||
host := "https://kzidcardv1.market.alicloudapi.com"
|
||||
appCode := "您的AppCode"
|
||||
|
||||
service := NewAlicloudService(host, appCode)
|
||||
|
||||
// 示例:验证身份证信息
|
||||
name := "张三"
|
||||
idCard := "110101199001011234"
|
||||
|
||||
// 构建请求参数
|
||||
params := map[string]interface{}{
|
||||
"name": name,
|
||||
"idcard": idCard,
|
||||
}
|
||||
|
||||
// 调用API
|
||||
responseBody, err := service.CallAPI("api-mall/api/id_card/check", params)
|
||||
if err != nil {
|
||||
log.Printf("验证失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析完整响应结构
|
||||
var response struct {
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Birthday string `json:"birthday"`
|
||||
Result int `json:"result"`
|
||||
Address string `json:"address"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
Sex string `json:"sex"`
|
||||
Desc string `json:"desc"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
log.Printf("响应解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if response.Code != 200 {
|
||||
log.Printf("API返回错误: code=%d, msg=%s", response.Code, response.Msg)
|
||||
return
|
||||
}
|
||||
|
||||
idCardData := response.Data
|
||||
|
||||
fmt.Printf("验证结果: %d\n", idCardData.Result)
|
||||
fmt.Printf("描述: %s\n", idCardData.Desc)
|
||||
fmt.Printf("生日: %s\n", idCardData.Birthday)
|
||||
fmt.Printf("性别: %s\n", idCardData.Sex)
|
||||
fmt.Printf("地址: %s\n", idCardData.Address)
|
||||
fmt.Printf("订单号: %s\n", idCardData.OrderNo)
|
||||
|
||||
// 判断验证结果
|
||||
if idCardData.Result == 1 {
|
||||
fmt.Println("身份证信息验证通过")
|
||||
} else {
|
||||
fmt.Println("身份证信息验证失败")
|
||||
}
|
||||
}
|
||||
162
internal/infrastructure/external/alicloud/example_advanced.go
vendored
Normal file
162
internal/infrastructure/external/alicloud/example_advanced.go
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
package alicloud
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ExampleAdvancedUsage 高级使用示例
|
||||
func ExampleAdvancedUsage() {
|
||||
// 创建阿里云服务实例
|
||||
host := "https://kzidcardv1.market.alicloudapi.com"
|
||||
appCode := "您的AppCode"
|
||||
|
||||
service := NewAlicloudService(host, appCode)
|
||||
|
||||
// 示例1: 身份证二要素验证
|
||||
fmt.Println("=== 示例1: 身份证二要素验证 ===")
|
||||
exampleIdCardCheck(service)
|
||||
|
||||
// 示例2: 其他API调用(假设)
|
||||
fmt.Println("\n=== 示例2: 其他API调用 ===")
|
||||
exampleOtherAPI(service)
|
||||
}
|
||||
|
||||
// exampleIdCardCheck 身份证验证示例
|
||||
func exampleIdCardCheck(service *AlicloudService) {
|
||||
// 构建请求参数
|
||||
params := map[string]interface{}{
|
||||
"name": "张三",
|
||||
"idcard": "110101199001011234",
|
||||
}
|
||||
|
||||
// 调用API
|
||||
responseBody, err := service.CallAPI("api-mall/api/id_card/check", params)
|
||||
if err != nil {
|
||||
log.Printf("身份证验证失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析完整响应结构
|
||||
var response struct {
|
||||
Msg string `json:"msg"`
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Birthday string `json:"birthday"`
|
||||
Result int `json:"result"`
|
||||
Address string `json:"address"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
Sex string `json:"sex"`
|
||||
Desc string `json:"desc"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
log.Printf("响应解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if response.Code != 200 {
|
||||
log.Printf("API返回错误: code=%d, msg=%s", response.Code, response.Msg)
|
||||
return
|
||||
}
|
||||
|
||||
idCardData := response.Data
|
||||
|
||||
// 处理验证结果
|
||||
fmt.Printf("验证结果: %d (%s)\n", idCardData.Result, idCardData.Desc)
|
||||
fmt.Printf("生日: %s\n", idCardData.Birthday)
|
||||
fmt.Printf("性别: %s\n", idCardData.Sex)
|
||||
fmt.Printf("地址: %s\n", idCardData.Address)
|
||||
fmt.Printf("订单号: %s\n", idCardData.OrderNo)
|
||||
|
||||
if idCardData.Result == 1 {
|
||||
fmt.Println("✅ 身份证信息验证通过")
|
||||
} else {
|
||||
fmt.Println("❌ 身份证信息验证失败")
|
||||
}
|
||||
}
|
||||
|
||||
// exampleOtherAPI 其他API调用示例
|
||||
func exampleOtherAPI(service *AlicloudService) {
|
||||
// 假设调用其他API
|
||||
params := map[string]interface{}{
|
||||
"param1": "value1",
|
||||
"param2": "value2",
|
||||
}
|
||||
|
||||
// 调用API
|
||||
responseBody, err := service.CallAPI("other/api/path", params)
|
||||
if err != nil {
|
||||
log.Printf("API调用失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据具体API的响应结构进行解析
|
||||
// 这里只是示例,实际使用时需要根据API文档定义相应的结构体
|
||||
fmt.Printf("API响应数据: %s\n", string(responseBody))
|
||||
|
||||
// 示例:解析通用响应结构
|
||||
var genericData map[string]interface{}
|
||||
if err := json.Unmarshal(responseBody, &genericData); err != nil {
|
||||
log.Printf("响应解析失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("解析后的数据: %+v\n", genericData)
|
||||
}
|
||||
|
||||
// ExampleErrorHandling 错误处理示例
|
||||
func ExampleErrorHandling() {
|
||||
host := "https://kzidcardv1.market.alicloudapi.com"
|
||||
appCode := "您的AppCode"
|
||||
|
||||
service := NewAlicloudService(host, appCode)
|
||||
|
||||
// 测试各种错误情况
|
||||
testCases := []struct {
|
||||
name string
|
||||
idCard string
|
||||
desc string
|
||||
}{
|
||||
{"张三", "123456789", "无效身份证号"},
|
||||
{"", "110101199001011234", "空姓名"},
|
||||
{"张三", "", "空身份证号"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
fmt.Printf("\n测试: %s\n", tc.desc)
|
||||
|
||||
params := map[string]interface{}{
|
||||
"name": tc.name,
|
||||
"idcard": tc.idCard,
|
||||
}
|
||||
|
||||
responseBody, err := service.CallAPI("api-mall/api/id_card/check", params)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 网络请求错误: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var response struct {
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
fmt.Printf("❌ 响应解析失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if response.Code != 200 {
|
||||
fmt.Printf("❌ 预期错误: code=%d, msg=%s\n", response.Code, response.Msg)
|
||||
} else {
|
||||
fmt.Printf("⚠️ 意外成功\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
134
internal/infrastructure/external/captcha/captcha_service.go
vendored
Normal file
134
internal/infrastructure/external/captcha/captcha_service.go
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
package captcha
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
captcha20230305 "github.com/alibabacloud-go/captcha-20230305/client"
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCaptchaVerifyFailed = errors.New("图形验证码校验失败")
|
||||
ErrCaptchaConfig = errors.New("验证码配置错误")
|
||||
ErrCaptchaEncryptMissing = errors.New("加密模式需要配置 EncryptKey(控制台 ekey)")
|
||||
)
|
||||
|
||||
// CaptchaConfig 阿里云验证码配置
|
||||
type CaptchaConfig struct {
|
||||
AccessKeyID string
|
||||
AccessKeySecret string
|
||||
EndpointURL string
|
||||
SceneID string
|
||||
// EncryptKey 加密模式使用的密钥(控制台 ekey,Base64 编码的 32 字节),用于生成 EncryptedSceneId
|
||||
EncryptKey string
|
||||
}
|
||||
|
||||
// CaptchaService 阿里云验证码服务
|
||||
type CaptchaService struct {
|
||||
config CaptchaConfig
|
||||
}
|
||||
|
||||
// NewCaptchaService 创建验证码服务实例
|
||||
func NewCaptchaService(config CaptchaConfig) *CaptchaService {
|
||||
return &CaptchaService{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify 验证滑块验证码
|
||||
func (s *CaptchaService) Verify(captchaVerifyParam string) error {
|
||||
if captchaVerifyParam == "" {
|
||||
return ErrCaptchaVerifyFailed
|
||||
}
|
||||
|
||||
if s.config.AccessKeyID == "" || s.config.AccessKeySecret == "" {
|
||||
return ErrCaptchaConfig
|
||||
}
|
||||
|
||||
clientCfg := &openapi.Config{
|
||||
AccessKeyId: tea.String(s.config.AccessKeyID),
|
||||
AccessKeySecret: tea.String(s.config.AccessKeySecret),
|
||||
}
|
||||
clientCfg.Endpoint = tea.String(s.config.EndpointURL)
|
||||
|
||||
client, err := captcha20230305.NewClient(clientCfg)
|
||||
if err != nil {
|
||||
return errors.Join(ErrCaptchaConfig, err)
|
||||
}
|
||||
|
||||
req := &captcha20230305.VerifyIntelligentCaptchaRequest{
|
||||
SceneId: tea.String(s.config.SceneID),
|
||||
CaptchaVerifyParam: tea.String(captchaVerifyParam),
|
||||
}
|
||||
|
||||
resp, err := client.VerifyIntelligentCaptcha(req)
|
||||
if err != nil {
|
||||
return errors.Join(ErrCaptchaVerifyFailed, err)
|
||||
}
|
||||
|
||||
if resp.Body == nil || !tea.BoolValue(resp.Body.Result.VerifyResult) {
|
||||
return ErrCaptchaVerifyFailed
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEncryptedSceneId 生成加密场景 ID(EncryptedSceneId),供前端加密模式初始化验证码使用。
|
||||
// 算法:AES-256-CBC,明文 sceneId×tamp&expireTime,密钥为控制台 ekey(Base64 解码后 32 字节)。
|
||||
// expireTimeSec 有效期为 1~86400 秒。
|
||||
func (s *CaptchaService) GetEncryptedSceneId(expireTimeSec int) (string, error) {
|
||||
if expireTimeSec <= 0 || expireTimeSec > 86400 {
|
||||
return "", fmt.Errorf("expireTimeSec 必须在 1~86400 之间")
|
||||
}
|
||||
if s.config.EncryptKey == "" {
|
||||
return "", ErrCaptchaEncryptMissing
|
||||
}
|
||||
if s.config.SceneID == "" {
|
||||
return "", ErrCaptchaConfig
|
||||
}
|
||||
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(s.config.EncryptKey)
|
||||
if err != nil || len(keyBytes) != 32 {
|
||||
return "", errors.Join(ErrCaptchaConfig, fmt.Errorf("EncryptKey 必须为 Base64 编码的 32 字节"))
|
||||
}
|
||||
|
||||
plaintext := fmt.Sprintf("%s&%d&%d", s.config.SceneID, time.Now().Unix(), expireTimeSec)
|
||||
plainBytes := []byte(plaintext)
|
||||
plainBytes = pkcs7Pad(plainBytes, aes.BlockSize)
|
||||
|
||||
block, err := aes.NewCipher(keyBytes)
|
||||
if err != nil {
|
||||
return "", errors.Join(ErrCaptchaConfig, err)
|
||||
}
|
||||
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
ciphertext := make([]byte, len(plainBytes))
|
||||
mode.CryptBlocks(ciphertext, plainBytes)
|
||||
|
||||
result := make([]byte, len(iv)+len(ciphertext))
|
||||
copy(result, iv)
|
||||
copy(result[len(iv):], ciphertext)
|
||||
return base64.StdEncoding.EncodeToString(result), nil
|
||||
}
|
||||
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
n := blockSize - (len(data) % blockSize)
|
||||
pad := make([]byte, n)
|
||||
for i := range pad {
|
||||
pad[i] = byte(n)
|
||||
}
|
||||
return append(data, pad...)
|
||||
}
|
||||
712
internal/infrastructure/external/email/qq_email_service.go
vendored
Normal file
712
internal/infrastructure/external/email/qq_email_service.go
vendored
Normal file
@@ -0,0 +1,712 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"hyapi-server/internal/config"
|
||||
)
|
||||
|
||||
// QQEmailService QQ邮箱服务
|
||||
type QQEmailService struct {
|
||||
config config.EmailConfig
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// EmailData 邮件数据
|
||||
type EmailData struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Content string `json:"content"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// InvoiceEmailData 发票邮件数据
|
||||
type InvoiceEmailData struct {
|
||||
CompanyName string `json:"company_name"`
|
||||
Amount string `json:"amount"`
|
||||
InvoiceType string `json:"invoice_type"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileName string `json:"file_name"`
|
||||
ReceivingEmail string `json:"receiving_email"`
|
||||
ApprovedAt string `json:"approved_at"`
|
||||
}
|
||||
|
||||
// NewQQEmailService 创建QQ邮箱服务
|
||||
func NewQQEmailService(config config.EmailConfig, logger *zap.Logger) *QQEmailService {
|
||||
return &QQEmailService{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SendEmail 发送邮件
|
||||
func (s *QQEmailService) SendEmail(ctx context.Context, data *EmailData) error {
|
||||
s.logger.Info("开始发送邮件",
|
||||
zap.String("to", data.To),
|
||||
zap.String("subject", data.Subject),
|
||||
)
|
||||
|
||||
// 构建邮件内容
|
||||
message := s.buildEmailMessage(data)
|
||||
|
||||
// 发送邮件
|
||||
err := s.sendSMTP(data.To, data.Subject, message)
|
||||
if err != nil {
|
||||
s.logger.Error("发送邮件失败",
|
||||
zap.String("to", data.To),
|
||||
zap.String("subject", data.Subject),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("发送邮件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("邮件发送成功",
|
||||
zap.String("to", data.To),
|
||||
zap.String("subject", data.Subject),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendInvoiceEmail 发送发票邮件
|
||||
func (s *QQEmailService) SendInvoiceEmail(ctx context.Context, data *InvoiceEmailData) error {
|
||||
s.logger.Info("开始发送发票邮件",
|
||||
zap.String("to", data.ReceivingEmail),
|
||||
zap.String("company_name", data.CompanyName),
|
||||
zap.String("amount", data.Amount),
|
||||
)
|
||||
|
||||
// 构建邮件内容
|
||||
subject := "您的发票已开具成功"
|
||||
content := s.buildInvoiceEmailContent(data)
|
||||
|
||||
emailData := &EmailData{
|
||||
To: data.ReceivingEmail,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Data: map[string]interface{}{
|
||||
"company_name": data.CompanyName,
|
||||
"amount": data.Amount,
|
||||
"invoice_type": data.InvoiceType,
|
||||
"file_url": data.FileURL,
|
||||
"file_name": data.FileName,
|
||||
"approved_at": data.ApprovedAt,
|
||||
},
|
||||
}
|
||||
|
||||
return s.SendEmail(ctx, emailData)
|
||||
}
|
||||
|
||||
// buildEmailMessage 构建邮件消息
|
||||
func (s *QQEmailService) buildEmailMessage(data *EmailData) string {
|
||||
headers := make(map[string]string)
|
||||
headers["From"] = s.config.FromEmail
|
||||
headers["To"] = data.To
|
||||
headers["Subject"] = data.Subject
|
||||
headers["MIME-Version"] = "1.0"
|
||||
headers["Content-Type"] = "text/html; charset=UTF-8"
|
||||
|
||||
var message strings.Builder
|
||||
for key, value := range headers {
|
||||
message.WriteString(fmt.Sprintf("%s: %s\r\n", key, value))
|
||||
}
|
||||
message.WriteString("\r\n")
|
||||
message.WriteString(data.Content)
|
||||
|
||||
return message.String()
|
||||
}
|
||||
|
||||
// buildInvoiceEmailContent 构建发票邮件内容
|
||||
func (s *QQEmailService) buildInvoiceEmailContent(data *InvoiceEmailData) string {
|
||||
htmlTemplate := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>发票开具成功通知</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', 'Microsoft YaHei', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #2d3748;
|
||||
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 650px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 50px 40px 40px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
50% { transform: translateY(-20px) rotate(180deg); }
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
padding: 40px 40px 20px;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.greeting p {
|
||||
font-size: 16px;
|
||||
color: #4a5568;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.access-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0 20px 30px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.access-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: shimmer 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { transform: translateX(-100%) translateY(-100%) rotate(0deg); }
|
||||
50% { transform: translateX(100%) translateY(100%) rotate(180deg); }
|
||||
}
|
||||
|
||||
.access-section h3 {
|
||||
color: white;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.access-section p {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 25px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.access-btn {
|
||||
display: inline-block;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
text-decoration: none;
|
||||
border-radius: 50px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(10px);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.access-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 0 40px 40px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.info-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 25px -5px rgba(102, 126, 234, 0.1);
|
||||
border-color: #cbd5e0;
|
||||
}
|
||||
|
||||
.info-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: #718096;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #2d3748;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.notes-section {
|
||||
background: linear-gradient(135deg, #f0fff4 0%, #ffffff 100%);
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
margin: 30px 0;
|
||||
border: 1px solid #c6f6d5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notes-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.notes-section h4 {
|
||||
color: #2f855a;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notes-section h4::before {
|
||||
content: '📋';
|
||||
margin-right: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.notes-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notes-section li {
|
||||
color: #4a5568;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 24px;
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notes-section li::before {
|
||||
content: '✓';
|
||||
color: #48bb78;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 35px 40px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-divider {
|
||||
width: 60px;
|
||||
height: 2px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
margin: 20px auto;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.container {
|
||||
margin: 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 40px 30px 30px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
padding: 30px 30px 20px;
|
||||
}
|
||||
|
||||
.access-section {
|
||||
margin: 0 15px 25px;
|
||||
padding: 30px 25px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 0 30px 30px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 30px 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="success-icon">✓</div>
|
||||
<h1>发票已开具完成</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
<p>尊敬的用户,您好!</p>
|
||||
<p>您的发票申请已审核通过,发票已成功开具。</p>
|
||||
</div>
|
||||
|
||||
<div class="access-section">
|
||||
<h3>📄 发票访问链接</h3>
|
||||
<p>您的发票已准备就绪,请点击下方按钮访问查看页面</p>
|
||||
<a href="{{.FileURL}}" class="access-btn" target="_blank">
|
||||
🔗 访问发票页面
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">公司名称</span>
|
||||
<span class="info-value">{{.CompanyName}}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">发票金额</span>
|
||||
<span class="info-value">¥{{.Amount}}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">发票类型</span>
|
||||
<span class="info-value">{{.InvoiceType}}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<span class="info-label">开具时间</span>
|
||||
<span class="info-value">{{.ApprovedAt}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="notes-section">
|
||||
<h4>注意事项</h4>
|
||||
<ul>
|
||||
<li>访问页面后可在页面内下载发票文件</li>
|
||||
<li>请妥善保管发票文件,建议打印存档</li>
|
||||
<li>如有疑问,请回到我们平台进行下载</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿回复</p>
|
||||
<div class="footer-divider"></div>
|
||||
<p>海宇数据 API 服务平台</p>
|
||||
<p>发送时间:{{.CurrentTime}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// 解析模板
|
||||
tmpl, err := template.New("invoice_email").Parse(htmlTemplate)
|
||||
if err != nil {
|
||||
s.logger.Error("解析邮件模板失败", zap.Error(err))
|
||||
return s.buildSimpleInvoiceEmail(data)
|
||||
}
|
||||
|
||||
// 准备模板数据
|
||||
templateData := struct {
|
||||
CompanyName string
|
||||
Amount string
|
||||
InvoiceType string
|
||||
FileURL string
|
||||
FileName string
|
||||
ApprovedAt string
|
||||
CurrentTime string
|
||||
Domain string
|
||||
}{
|
||||
CompanyName: data.CompanyName,
|
||||
Amount: data.Amount,
|
||||
InvoiceType: data.InvoiceType,
|
||||
FileURL: data.FileURL,
|
||||
FileName: data.FileName,
|
||||
ApprovedAt: data.ApprovedAt,
|
||||
CurrentTime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
Domain: s.config.Domain,
|
||||
}
|
||||
|
||||
// 执行模板
|
||||
var content strings.Builder
|
||||
err = tmpl.Execute(&content, templateData)
|
||||
if err != nil {
|
||||
s.logger.Error("执行邮件模板失败", zap.Error(err))
|
||||
return s.buildSimpleInvoiceEmail(data)
|
||||
}
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
// buildSimpleInvoiceEmail 构建简单的发票邮件内容(备用方案)
|
||||
func (s *QQEmailService) buildSimpleInvoiceEmail(data *InvoiceEmailData) string {
|
||||
return fmt.Sprintf(`
|
||||
发票开具成功通知
|
||||
|
||||
尊敬的用户,您好!
|
||||
|
||||
您的发票申请已审核通过,发票已成功开具。
|
||||
|
||||
发票信息:
|
||||
- 公司名称:%s
|
||||
- 发票金额:¥%s
|
||||
- 发票类型:%s
|
||||
- 开具时间:%s
|
||||
|
||||
发票文件下载链接:%s
|
||||
文件名:%s
|
||||
|
||||
如有疑问,请访问控制台查看详细信息:https://%s
|
||||
|
||||
海宇数据 API 服务平台
|
||||
%s
|
||||
`, data.CompanyName, data.Amount, data.InvoiceType, data.ApprovedAt, data.FileURL, data.FileName, s.config.Domain, time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// sendSMTP 通过SMTP发送邮件
|
||||
func (s *QQEmailService) sendSMTP(to, subject, message string) error {
|
||||
// 构建认证信息
|
||||
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
|
||||
|
||||
// 构建收件人列表
|
||||
toList := []string{to}
|
||||
|
||||
// 发送邮件
|
||||
if s.config.UseSSL {
|
||||
// QQ邮箱587端口使用STARTTLS,465端口使用直接SSL
|
||||
if s.config.Port == 587 {
|
||||
// 使用STARTTLS (587端口)
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", s.config.Host, s.config.Port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接SMTP服务器失败: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, s.config.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建SMTP客户端失败: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 启用STARTTLS
|
||||
if err = client.StartTLS(&tls.Config{
|
||||
ServerName: s.config.Host,
|
||||
InsecureSkipVerify: false,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("启用STARTTLS失败: %w", err)
|
||||
}
|
||||
|
||||
// 认证
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP认证失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置发件人
|
||||
if err = client.Mail(s.config.FromEmail); err != nil {
|
||||
return fmt.Errorf("设置发件人失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置收件人
|
||||
for _, recipient := range toList {
|
||||
if err = client.Rcpt(recipient); err != nil {
|
||||
return fmt.Errorf("设置收件人失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送邮件内容
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("准备发送邮件内容失败: %w", err)
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
_, err = writer.Write([]byte(message))
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送邮件内容失败: %w", err)
|
||||
}
|
||||
} else {
|
||||
// 使用直接SSL连接 (465端口)
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: s.config.Host,
|
||||
InsecureSkipVerify: false,
|
||||
}
|
||||
|
||||
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", s.config.Host, s.config.Port), tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接SMTP服务器失败: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, s.config.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建SMTP客户端失败: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// 认证
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP认证失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置发件人
|
||||
if err = client.Mail(s.config.FromEmail); err != nil {
|
||||
return fmt.Errorf("设置发件人失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置收件人
|
||||
for _, recipient := range toList {
|
||||
if err = client.Rcpt(recipient); err != nil {
|
||||
return fmt.Errorf("设置收件人失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送邮件内容
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("准备发送邮件内容失败: %w", err)
|
||||
}
|
||||
defer writer.Close()
|
||||
|
||||
_, err = writer.Write([]byte(message))
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送邮件内容失败: %w", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 使用普通连接
|
||||
err := smtp.SendMail(
|
||||
fmt.Sprintf("%s:%d", s.config.Host, s.config.Port),
|
||||
auth,
|
||||
s.config.FromEmail,
|
||||
toList,
|
||||
[]byte(message),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送邮件失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
301
internal/infrastructure/external/esign/certification_esign_service.go
vendored
Normal file
301
internal/infrastructure/external/esign/certification_esign_service.go
vendored
Normal file
@@ -0,0 +1,301 @@
|
||||
package esign
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"hyapi-server/internal/domains/certification/entities/value_objects"
|
||||
"hyapi-server/internal/domains/certification/enums"
|
||||
"hyapi-server/internal/domains/certification/repositories"
|
||||
"hyapi-server/internal/shared/esign"
|
||||
)
|
||||
|
||||
// ================ 常量定义 ================
|
||||
|
||||
const (
|
||||
// 企业认证超时时间
|
||||
EnterpriseAuthTimeout = 30 * time.Minute
|
||||
|
||||
// 合同签署超时时间
|
||||
ContractSignTimeout = 7 * 24 * time.Hour // 7天
|
||||
|
||||
// 回调重试次数
|
||||
MaxCallbackRetries = 3
|
||||
)
|
||||
|
||||
// ================ 服务实现 ================
|
||||
|
||||
// CertificationEsignService 认证e签宝服务实现
|
||||
//
|
||||
// 业务职责:
|
||||
// - 处理企业认证流程
|
||||
// - 处理合同生成和签署
|
||||
// - 处理e签宝回调
|
||||
// - 管理认证状态更新
|
||||
type CertificationEsignService struct {
|
||||
esignClient *esign.Client
|
||||
commandRepo repositories.CertificationCommandRepository
|
||||
queryRepo repositories.CertificationQueryRepository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewCertificationEsignService 创建认证e签宝服务
|
||||
func NewCertificationEsignService(
|
||||
esignClient *esign.Client,
|
||||
commandRepo repositories.CertificationCommandRepository,
|
||||
queryRepo repositories.CertificationQueryRepository,
|
||||
logger *zap.Logger,
|
||||
) *CertificationEsignService {
|
||||
return &CertificationEsignService{
|
||||
esignClient: esignClient,
|
||||
commandRepo: commandRepo,
|
||||
queryRepo: queryRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ================ 企业认证流程 ================
|
||||
|
||||
// StartEnterpriseAuth 开始企业认证
|
||||
//
|
||||
// 业务流程:
|
||||
// 1. 调用e签宝企业认证API
|
||||
// 2. 更新认证记录的auth_flow_id
|
||||
// 3. 更新状态为企业认证中
|
||||
//
|
||||
// 参数:
|
||||
// - ctx: 上下文
|
||||
// - certificationID: 认证ID
|
||||
// - enterpriseInfo: 企业信息
|
||||
//
|
||||
// 返回:
|
||||
// - authURL: 认证URL
|
||||
// - error: 错误信息
|
||||
func (s *CertificationEsignService) StartEnterpriseAuth(
|
||||
ctx context.Context,
|
||||
certificationID string,
|
||||
enterpriseInfo *value_objects.EnterpriseInfo,
|
||||
) (string, error) {
|
||||
s.logger.Info("开始企业认证",
|
||||
zap.String("certification_id", certificationID),
|
||||
zap.String("company_name", enterpriseInfo.CompanyName))
|
||||
|
||||
// TODO: 实现e签宝企业认证API调用
|
||||
// 暂时使用模拟响应
|
||||
authFlowID := fmt.Sprintf("auth_%s_%d", certificationID, time.Now().Unix())
|
||||
authURL := fmt.Sprintf("https://esign.example.com/auth/%s", authFlowID)
|
||||
|
||||
s.logger.Info("模拟调用e签宝企业认证API",
|
||||
zap.String("auth_flow_id", authFlowID),
|
||||
zap.String("auth_url", authURL))
|
||||
|
||||
// 更新认证记录
|
||||
if err := s.commandRepo.UpdateAuthFlowID(ctx, certificationID, authFlowID); err != nil {
|
||||
s.logger.Error("更新认证流程ID失败", zap.Error(err))
|
||||
return "", fmt.Errorf("更新认证流程ID失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("企业认证启动成功",
|
||||
zap.String("certification_id", certificationID),
|
||||
zap.String("auth_flow_id", authFlowID))
|
||||
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// HandleEnterpriseAuthCallback 处理企业认证回调
|
||||
//
|
||||
// 业务流程:
|
||||
// 1. 根据回调信息查找认证记录
|
||||
// 2. 根据回调状态更新认证状态
|
||||
// 3. 如果成功,继续合同生成流程
|
||||
//
|
||||
// 参数:
|
||||
// - ctx: 上下文
|
||||
// - authFlowID: 认证流程ID
|
||||
// - success: 是否成功
|
||||
// - message: 回调消息
|
||||
//
|
||||
// 返回:
|
||||
// - error: 错误信息
|
||||
func (s *CertificationEsignService) HandleEnterpriseAuthCallback(
|
||||
ctx context.Context,
|
||||
authFlowID string,
|
||||
success bool,
|
||||
message string,
|
||||
) error {
|
||||
s.logger.Info("处理企业认证回调",
|
||||
zap.String("auth_flow_id", authFlowID),
|
||||
zap.Bool("success", success))
|
||||
|
||||
// 查找认证记录
|
||||
cert, err := s.queryRepo.FindByAuthFlowID(ctx, authFlowID)
|
||||
if err != nil {
|
||||
s.logger.Error("根据认证流程ID查找认证记录失败", zap.Error(err))
|
||||
return fmt.Errorf("查找认证记录失败: %w", err)
|
||||
}
|
||||
|
||||
if success {
|
||||
// 企业认证成功,更新状态
|
||||
if err := s.commandRepo.UpdateStatus(ctx, cert.ID, enums.StatusEnterpriseVerified); err != nil {
|
||||
s.logger.Error("更新认证状态失败", zap.Error(err))
|
||||
return fmt.Errorf("更新认证状态失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("企业认证成功", zap.String("certification_id", cert.ID))
|
||||
} else {
|
||||
// 企业认证失败,更新状态
|
||||
if err := s.commandRepo.UpdateStatus(ctx, cert.ID, enums.StatusInfoRejected); err != nil {
|
||||
s.logger.Error("更新认证状态失败", zap.Error(err))
|
||||
return fmt.Errorf("更新认证状态失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("企业认证失败", zap.String("certification_id", cert.ID), zap.String("reason", message))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ================ 合同管理流程 ================
|
||||
|
||||
// GenerateContract 生成认证合同
|
||||
//
|
||||
// 业务流程:
|
||||
// 1. 调用e签宝合同生成API
|
||||
// 2. 更新认证记录的合同信息
|
||||
// 3. 更新状态为合同已生成
|
||||
//
|
||||
// 参数:
|
||||
// - ctx: 上下文
|
||||
// - certificationID: 认证ID
|
||||
//
|
||||
// 返回:
|
||||
// - contractSignURL: 合同签署URL
|
||||
// - error: 错误信息
|
||||
func (s *CertificationEsignService) GenerateContract(
|
||||
ctx context.Context,
|
||||
certificationID string,
|
||||
) (string, error) {
|
||||
s.logger.Info("生成认证合同", zap.String("certification_id", certificationID))
|
||||
|
||||
// TODO: 实现e签宝合同生成API调用
|
||||
// 暂时使用模拟响应
|
||||
contractFileID := fmt.Sprintf("contract_%s_%d", certificationID, time.Now().Unix())
|
||||
esignFlowID := fmt.Sprintf("flow_%s_%d", certificationID, time.Now().Unix())
|
||||
contractURL := fmt.Sprintf("https://esign.example.com/contract/%s", contractFileID)
|
||||
contractSignURL := fmt.Sprintf("https://esign.example.com/sign/%s", esignFlowID)
|
||||
|
||||
s.logger.Info("模拟调用e签宝合同生成API",
|
||||
zap.String("contract_file_id", contractFileID),
|
||||
zap.String("esign_flow_id", esignFlowID))
|
||||
|
||||
// 更新认证记录
|
||||
if err := s.commandRepo.UpdateContractInfo(
|
||||
ctx,
|
||||
certificationID,
|
||||
contractFileID,
|
||||
esignFlowID,
|
||||
contractURL,
|
||||
contractSignURL,
|
||||
); err != nil {
|
||||
s.logger.Error("更新合同信息失败", zap.Error(err))
|
||||
return "", fmt.Errorf("更新合同信息失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
if err := s.commandRepo.UpdateStatus(ctx, certificationID, enums.StatusContractApplied); err != nil {
|
||||
s.logger.Error("更新认证状态失败", zap.Error(err))
|
||||
return "", fmt.Errorf("更新认证状态失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("认证合同生成成功",
|
||||
zap.String("certification_id", certificationID),
|
||||
zap.String("contract_file_id", contractFileID))
|
||||
|
||||
return contractSignURL, nil
|
||||
}
|
||||
|
||||
// HandleContractSignCallback 处理合同签署回调
|
||||
//
|
||||
// 业务流程:
|
||||
// 1. 根据回调信息查找认证记录
|
||||
// 2. 根据回调状态更新认证状态
|
||||
// 3. 如果成功,认证流程完成
|
||||
//
|
||||
// 参数:
|
||||
// - ctx: 上下文
|
||||
// - esignFlowID: e签宝流程ID
|
||||
// - success: 是否成功
|
||||
// - signedFileURL: 已签署文件URL
|
||||
//
|
||||
// 返回:
|
||||
// - error: 错误信息
|
||||
func (s *CertificationEsignService) HandleContractSignCallback(
|
||||
ctx context.Context,
|
||||
esignFlowID string,
|
||||
success bool,
|
||||
signedFileURL string,
|
||||
) error {
|
||||
s.logger.Info("处理合同签署回调",
|
||||
zap.String("esign_flow_id", esignFlowID),
|
||||
zap.Bool("success", success))
|
||||
|
||||
// 查找认证记录
|
||||
cert, err := s.queryRepo.FindByEsignFlowID(ctx, esignFlowID)
|
||||
if err != nil {
|
||||
s.logger.Error("根据e签宝流程ID查找认证记录失败", zap.Error(err))
|
||||
return fmt.Errorf("查找认证记录失败: %w", err)
|
||||
}
|
||||
|
||||
if success {
|
||||
// 合同签署成功,更新合同URL
|
||||
if err := s.commandRepo.UpdateContractInfo(ctx, cert.ID, cert.ContractFileID, cert.EsignFlowID, signedFileURL, cert.ContractSignURL); err != nil {
|
||||
s.logger.Error("更新合同URL失败", zap.Error(err))
|
||||
return fmt.Errorf("更新合同URL失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新状态到合同已签署
|
||||
if err := s.commandRepo.UpdateStatus(ctx, cert.ID, enums.StatusContractSigned); err != nil {
|
||||
s.logger.Error("更新认证状态失败", zap.Error(err))
|
||||
return fmt.Errorf("更新认证状态失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("合同签署成功", zap.String("certification_id", cert.ID))
|
||||
} else {
|
||||
// 合同签署失败
|
||||
if err := s.commandRepo.UpdateStatus(ctx, cert.ID, enums.StatusContractRejected); err != nil {
|
||||
s.logger.Error("更新认证状态失败", zap.Error(err))
|
||||
return fmt.Errorf("更新认证状态失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("合同签署失败", zap.String("certification_id", cert.ID))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ================ 辅助方法 ================
|
||||
|
||||
// GetContractSignURL 获取合同签署URL
|
||||
//
|
||||
// 参数:
|
||||
// - ctx: 上下文
|
||||
// - certificationID: 认证ID
|
||||
//
|
||||
// 返回:
|
||||
// - signURL: 签署URL
|
||||
// - error: 错误信息
|
||||
func (s *CertificationEsignService) GetContractSignURL(ctx context.Context, certificationID string) (string, error) {
|
||||
cert, err := s.queryRepo.GetByID(ctx, certificationID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取认证信息失败: %w", err)
|
||||
}
|
||||
|
||||
if cert.ContractSignURL == "" {
|
||||
return "", fmt.Errorf("合同签署URL尚未生成")
|
||||
}
|
||||
|
||||
return cert.ContractSignURL, nil
|
||||
}
|
||||
48
internal/infrastructure/external/jiguang/crypto.go
vendored
Normal file
48
internal/infrastructure/external/jiguang/crypto.go
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
package jiguang
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SignMethod 签名方法类型
|
||||
type SignMethod string
|
||||
|
||||
const (
|
||||
SignMethodMD5 SignMethod = "md5"
|
||||
SignMethodHMACMD5 SignMethod = "hmac"
|
||||
)
|
||||
|
||||
// GenerateSign 生成签名
|
||||
// 根据 signMethod 参数选择使用 MD5 或 HMAC-MD5 算法
|
||||
// MD5: md5(timestamp + "&appSecret=" + appSecret),然后转大写十六进制
|
||||
// HMAC-MD5: hmac_md5(timestamp, appSecret),然后转大写十六进制
|
||||
func GenerateSign(timestamp string, appSecret string, signMethod SignMethod) (string, error) {
|
||||
var hashBytes []byte
|
||||
|
||||
switch signMethod {
|
||||
case SignMethodMD5:
|
||||
// MD5算法:在待签名字符串后面加上 &appSecret=xxx 再进行计算
|
||||
signStr := timestamp + "&appSecret=" + appSecret
|
||||
hash := md5.Sum([]byte(signStr))
|
||||
hashBytes = hash[:]
|
||||
case SignMethodHMACMD5:
|
||||
// HMAC-MD5算法:使用 appSecret 初始化摘要算法再进行计算
|
||||
mac := hmac.New(md5.New, []byte(appSecret))
|
||||
mac.Write([]byte(timestamp))
|
||||
hashBytes = mac.Sum(nil)
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的签名方法: %s", signMethod)
|
||||
}
|
||||
|
||||
// 将二进制转化为大写的十六进制(正确签名应该为32大写字符串)
|
||||
return strings.ToUpper(hex.EncodeToString(hashBytes)), nil
|
||||
}
|
||||
|
||||
// GenerateSignWithDefault 使用默认的 HMAC-MD5 方法生成签名
|
||||
func GenerateSignWithDefault(timestamp string, appSecret string) (string, error) {
|
||||
return GenerateSign(timestamp, appSecret, SignMethodHMACMD5)
|
||||
}
|
||||
149
internal/infrastructure/external/jiguang/jiguang_errors.go
vendored
Normal file
149
internal/infrastructure/external/jiguang/jiguang_errors.go
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
package jiguang
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// JiguangError 极光服务错误
|
||||
type JiguangError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error 实现error接口
|
||||
func (e *JiguangError) Error() string {
|
||||
return fmt.Sprintf("极光错误 [%d]: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// IsSuccess 检查是否成功
|
||||
func (e *JiguangError) IsSuccess() bool {
|
||||
return e.Code == 0
|
||||
}
|
||||
|
||||
// IsQueryFailed 检查是否查询失败
|
||||
func (e *JiguangError) IsQueryFailed() bool {
|
||||
return e.Code == 922
|
||||
}
|
||||
|
||||
// IsNoRecord 检查是否查无记录
|
||||
func (e *JiguangError) IsNoRecord() bool {
|
||||
return e.Code == 921
|
||||
}
|
||||
|
||||
// IsParamError 检查是否是参数相关错误
|
||||
func (e *JiguangError) IsParamError() bool {
|
||||
return e.Code == 400 || e.Code == 906 || e.Code == 914 || e.Code == 918
|
||||
}
|
||||
|
||||
// IsAuthError 检查是否是认证相关错误
|
||||
func (e *JiguangError) IsAuthError() bool {
|
||||
return e.Code == 902 || e.Code == 903 || e.Code == 904 || e.Code == 905
|
||||
}
|
||||
|
||||
// IsSystemError 检查是否是系统错误
|
||||
func (e *JiguangError) IsSystemError() bool {
|
||||
return e.Code == 405 || e.Code == 911 || e.Code == 912 || e.Code == 915 || e.Code == 916 || e.Code == 917 || e.Code == 919 || e.Code == 923
|
||||
}
|
||||
|
||||
// 预定义错误常量
|
||||
var (
|
||||
// 成功状态
|
||||
ErrSuccess = &JiguangError{Code: 0, Message: "请求成功"}
|
||||
|
||||
// 参数错误
|
||||
ErrParamInvalid = &JiguangError{Code: 400, Message: "请求参数不正确,QCXGGB2Q查询为空"}
|
||||
ErrMethodInvalid = &JiguangError{Code: 405, Message: "请求方法不正确"}
|
||||
ErrParamFormInvalid = &JiguangError{Code: 906, Message: "请求参数形式不正确"}
|
||||
ErrBodyIncomplete = &JiguangError{Code: 914, Message: "Body 请求参数不完整"}
|
||||
ErrBodyNotSupported = &JiguangError{Code: 918, Message: "Body 请求参数不支持"}
|
||||
|
||||
// 认证错误
|
||||
ErrAppIDInvalid = &JiguangError{Code: 902, Message: "错误的 appId/账户已删除"}
|
||||
ErrTimestampInvalid = &JiguangError{Code: 903, Message: "错误的时间戳/时间误差大于 10 分钟"}
|
||||
ErrSignMethodInvalid = &JiguangError{Code: 904, Message: "无法识别的签名方法"}
|
||||
ErrSignInvalid = &JiguangError{Code: 905, Message: "签名不合法"}
|
||||
|
||||
// 系统错误
|
||||
ErrAccountStatusError = &JiguangError{Code: 911, Message: "账户状态异常"}
|
||||
ErrInterfaceDisabled = &JiguangError{Code: 912, Message: "接口状态不可用"}
|
||||
ErrAPICallError = &JiguangError{Code: 915, Message: "API 接口调用有误"}
|
||||
ErrInternalError = &JiguangError{Code: 916, Message: "内部接口调用错误,请联系相关人员"}
|
||||
ErrTimeout = &JiguangError{Code: 917, Message: "请求超时"}
|
||||
ErrBusinessDisabled = &JiguangError{Code: 919, Message: "业务状态不可用"}
|
||||
ErrInterfaceException = &JiguangError{Code: 923, Message: "接口异常"}
|
||||
|
||||
// 业务错误
|
||||
ErrNoRecord = &JiguangError{Code: 921, Message: "查无记录"}
|
||||
ErrQueryFailed = &JiguangError{Code: 922, Message: "查询失败"}
|
||||
)
|
||||
|
||||
// NewJiguangError 创建新的极光错误
|
||||
func NewJiguangError(code int, message string) *JiguangError {
|
||||
return &JiguangError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// NewJiguangErrorFromCode 根据状态码创建错误
|
||||
func NewJiguangErrorFromCode(code int) *JiguangError {
|
||||
switch code {
|
||||
case 0:
|
||||
return ErrSuccess
|
||||
case 400:
|
||||
return ErrParamInvalid
|
||||
case 405:
|
||||
return ErrMethodInvalid
|
||||
case 902:
|
||||
return ErrAppIDInvalid
|
||||
case 903:
|
||||
return ErrTimestampInvalid
|
||||
case 904:
|
||||
return ErrSignMethodInvalid
|
||||
case 905:
|
||||
return ErrSignInvalid
|
||||
case 906:
|
||||
return ErrParamFormInvalid
|
||||
case 911:
|
||||
return ErrAccountStatusError
|
||||
case 912:
|
||||
return ErrInterfaceDisabled
|
||||
case 914:
|
||||
return ErrBodyIncomplete
|
||||
case 915:
|
||||
return ErrAPICallError
|
||||
case 916:
|
||||
return ErrInternalError
|
||||
case 917:
|
||||
return ErrTimeout
|
||||
case 918:
|
||||
return ErrBodyNotSupported
|
||||
case 919:
|
||||
return ErrBusinessDisabled
|
||||
case 921:
|
||||
return ErrNoRecord
|
||||
case 922:
|
||||
return ErrQueryFailed
|
||||
case 923:
|
||||
return ErrInterfaceException
|
||||
default:
|
||||
return &JiguangError{
|
||||
Code: code,
|
||||
Message: fmt.Sprintf("未知错误码: %d", code),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsJiguangError 检查是否是极光错误
|
||||
func IsJiguangError(err error) bool {
|
||||
_, ok := err.(*JiguangError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetJiguangError 获取极光错误
|
||||
func GetJiguangError(err error) *JiguangError {
|
||||
if jiguangErr, ok := err.(*JiguangError); ok {
|
||||
return jiguangErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
85
internal/infrastructure/external/jiguang/jiguang_factory.go
vendored
Normal file
85
internal/infrastructure/external/jiguang/jiguang_factory.go
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
package jiguang
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewJiguangServiceWithConfig 使用配置创建极光服务
|
||||
func NewJiguangServiceWithConfig(cfg *config.Config) (*JiguangService, error) {
|
||||
// 将配置类型转换为通用外部服务日志配置
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.Jiguang.Logging.Enabled,
|
||||
LogDir: cfg.Jiguang.Logging.LogDir,
|
||||
ServiceName: "jiguang",
|
||||
UseDaily: cfg.Jiguang.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.Jiguang.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
|
||||
// 转换级别配置
|
||||
for key, value := range cfg.Jiguang.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: value.MaxSize,
|
||||
MaxBackups: value.MaxBackups,
|
||||
MaxAge: value.MaxAge,
|
||||
Compress: value.Compress,
|
||||
}
|
||||
}
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析签名方法
|
||||
var signMethod SignMethod
|
||||
if cfg.Jiguang.SignMethod == "md5" {
|
||||
signMethod = SignMethodMD5
|
||||
} else {
|
||||
signMethod = SignMethodHMACMD5 // 默认使用 HMAC-MD5
|
||||
}
|
||||
|
||||
// 解析超时时间
|
||||
timeout := 60 * time.Second
|
||||
if cfg.Jiguang.Timeout > 0 {
|
||||
timeout = cfg.Jiguang.Timeout
|
||||
}
|
||||
|
||||
// 创建极光服务
|
||||
service := NewJiguangService(
|
||||
cfg.Jiguang.URL,
|
||||
cfg.Jiguang.AppID,
|
||||
cfg.Jiguang.AppSecret,
|
||||
signMethod,
|
||||
timeout,
|
||||
logger,
|
||||
)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewJiguangServiceWithLogging 使用自定义日志配置创建极光服务
|
||||
func NewJiguangServiceWithLogging(url, appID, appSecret string, signMethod SignMethod, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig) (*JiguangService, error) {
|
||||
// 设置服务名称
|
||||
loggingConfig.ServiceName = "jiguang"
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建极光服务
|
||||
service := NewJiguangService(url, appID, appSecret, signMethod, timeout, logger)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewJiguangServiceSimple 创建简单的极光服务(无日志)
|
||||
func NewJiguangServiceSimple(url, appID, appSecret string, signMethod SignMethod, timeout time.Duration) *JiguangService {
|
||||
return NewJiguangService(url, appID, appSecret, signMethod, timeout, nil)
|
||||
}
|
||||
316
internal/infrastructure/external/jiguang/jiguang_service.go
vendored
Normal file
316
internal/infrastructure/external/jiguang/jiguang_service.go
vendored
Normal file
@@ -0,0 +1,316 @@
|
||||
package jiguang
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
ErrNotFound = errors.New("查询为空")
|
||||
)
|
||||
|
||||
// JiguangResponse 极光API响应结构(兼容两套字段命名)
|
||||
//
|
||||
// 格式一:ordernum、message、result(定位/查询类接口常见)
|
||||
// 格式二:order_id、msg、data(文档中的 code/msg/order_id/data)
|
||||
type JiguangResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Message string `json:"message"`
|
||||
OrderID string `json:"order_id"`
|
||||
OrderNum string `json:"ordernum"`
|
||||
Data interface{} `json:"data"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
|
||||
// normalize 将异名字段合并到 OrderID、Msg,便于后续统一分支使用
|
||||
func (r *JiguangResponse) normalize() {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
if r.OrderID == "" && r.OrderNum != "" {
|
||||
r.OrderID = r.OrderNum
|
||||
}
|
||||
if r.Msg == "" && r.Message != "" {
|
||||
r.Msg = r.Message
|
||||
}
|
||||
}
|
||||
|
||||
// JiguangConfig 极光服务配置
|
||||
type JiguangConfig struct {
|
||||
URL string
|
||||
AppID string
|
||||
AppSecret string
|
||||
SignMethod SignMethod // 签名方法:md5 或 hmac
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// JiguangService 极光服务
|
||||
type JiguangService struct {
|
||||
config JiguangConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
}
|
||||
|
||||
// NewJiguangService 创建一个新的极光服务实例
|
||||
func NewJiguangService(url, appID, appSecret string, signMethod SignMethod, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *JiguangService {
|
||||
// 如果没有指定签名方法,默认使用 HMAC-MD5
|
||||
if signMethod == "" {
|
||||
signMethod = SignMethodHMACMD5
|
||||
}
|
||||
|
||||
// 如果没有指定超时时间,默认使用 60 秒
|
||||
if timeout == 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
|
||||
return &JiguangService{
|
||||
config: JiguangConfig{
|
||||
URL: url,
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
SignMethod: signMethod,
|
||||
Timeout: timeout,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID 生成请求ID
|
||||
func (j *JiguangService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, j.config.AppID)))
|
||||
return fmt.Sprintf("jiguang_%x", hash[:8])
|
||||
}
|
||||
|
||||
// CallAPI 调用极光API
|
||||
// apiCode: API服务编码(如 marriage-single-v2),用于请求头
|
||||
// apiPath: API路径(如 marriage/single-v2),用于URL路径
|
||||
// params: 请求参数(会作为JSON body发送)
|
||||
func (j *JiguangService) CallAPI(ctx context.Context, apiCode string, apiPath string, params map[string]interface{}) (resp []byte, err error) {
|
||||
startTime := time.Now()
|
||||
requestID := j.generateRequestID()
|
||||
|
||||
// 生成时间戳(毫秒)
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
|
||||
// 从ctx中获取transactionId
|
||||
var transactionID string
|
||||
if ctxTransactionID, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = ctxTransactionID
|
||||
}
|
||||
|
||||
// 生成签名
|
||||
sign, signErr := GenerateSign(timestamp, j.config.AppSecret, j.config.SignMethod)
|
||||
if signErr != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("生成签名失败: %w", signErr))
|
||||
if j.logger != nil {
|
||||
j.logger.LogError(requestID, transactionID, apiCode, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建完整的请求URL,使用apiPath作为路径
|
||||
requestURL := strings.TrimSuffix(j.config.URL, "/") + "/" + strings.TrimPrefix(apiPath, "/")
|
||||
|
||||
// 记录请求日志
|
||||
if j.logger != nil {
|
||||
j.logger.LogRequest(requestID, transactionID, apiCode, requestURL)
|
||||
}
|
||||
|
||||
// 将请求参数转换为JSON
|
||||
jsonData, marshalErr := json.Marshal(params)
|
||||
if marshalErr != nil {
|
||||
err = errors.Join(ErrSystem, marshalErr)
|
||||
if j.logger != nil {
|
||||
j.logger.LogError(requestID, transactionID, apiCode, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建HTTP POST请求
|
||||
req, newRequestErr := http.NewRequestWithContext(ctx, "POST", requestURL, bytes.NewBuffer(jsonData))
|
||||
if newRequestErr != nil {
|
||||
err = errors.Join(ErrSystem, newRequestErr)
|
||||
if j.logger != nil {
|
||||
j.logger.LogError(requestID, transactionID, apiCode, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("appId", j.config.AppID)
|
||||
req.Header.Set("apiCode", apiCode)
|
||||
req.Header.Set("timestamp", timestamp)
|
||||
req.Header.Set("signMethod", string(j.config.SignMethod))
|
||||
req.Header.Set("sign", sign)
|
||||
|
||||
// 创建HTTP客户端
|
||||
client := &http.Client{
|
||||
Timeout: j.config.Timeout,
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
httpResp, clientDoErr := client.Do(req)
|
||||
if clientDoErr != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := clientDoErr.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := clientDoErr.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
if isTimeout {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", clientDoErr))
|
||||
} else {
|
||||
err = errors.Join(ErrSystem, clientDoErr)
|
||||
}
|
||||
if j.logger != nil {
|
||||
j.logger.LogError(requestID, transactionID, apiCode, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
closeErr := Body.Close()
|
||||
if closeErr != nil {
|
||||
// 记录关闭错误
|
||||
if j.logger != nil {
|
||||
j.logger.LogError(requestID, transactionID, apiCode, errors.Join(ErrSystem, fmt.Errorf("关闭响应体失败: %w", closeErr)), params)
|
||||
}
|
||||
}
|
||||
}(httpResp.Body)
|
||||
|
||||
// 计算请求耗时
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// 读取响应体
|
||||
bodyBytes, readErr := io.ReadAll(httpResp.Body)
|
||||
if readErr != nil {
|
||||
err = errors.Join(ErrSystem, readErr)
|
||||
if j.logger != nil {
|
||||
j.logger.LogError(requestID, transactionID, apiCode, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查HTTP状态码
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("极光请求失败,状态码: %d", httpResp.StatusCode))
|
||||
if j.logger != nil {
|
||||
j.logger.LogError(requestID, transactionID, apiCode, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析响应结构
|
||||
var jiguangResp JiguangResponse
|
||||
if err := json.Unmarshal(bodyBytes, &jiguangResp); err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
|
||||
if j.logger != nil {
|
||||
j.logger.LogError(requestID, transactionID, apiCode, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
jiguangResp.normalize()
|
||||
|
||||
// 记录响应日志(不记录具体响应数据)
|
||||
if j.logger != nil {
|
||||
if jiguangResp.OrderID != "" {
|
||||
j.logger.LogResponseWithID(requestID, transactionID, apiCode, httpResp.StatusCode, duration, jiguangResp.OrderID)
|
||||
} else {
|
||||
j.logger.LogResponse(requestID, transactionID, apiCode, httpResp.StatusCode, duration)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查业务状态码
|
||||
if jiguangResp.Code != 0 && jiguangResp.Code != 200 {
|
||||
// 创建极光错误
|
||||
jiguangErr := NewJiguangErrorFromCode(jiguangResp.Code)
|
||||
if jiguangErr.Message == fmt.Sprintf("未知错误码: %d", jiguangResp.Code) {
|
||||
if jiguangResp.Msg != "" {
|
||||
jiguangErr.Message = jiguangResp.Msg
|
||||
} else if jiguangResp.Message != "" {
|
||||
jiguangErr.Message = jiguangResp.Message
|
||||
}
|
||||
}
|
||||
// 根据错误类型返回不同的错误
|
||||
if jiguangErr.IsNoRecord() {
|
||||
// 从context中获取apiCode,判断是否需要抛出异常
|
||||
var processorCode string
|
||||
if ctxProcessorCode, ok := ctx.Value("api_code").(string); ok {
|
||||
processorCode = ctxProcessorCode
|
||||
}
|
||||
// 定义不需要抛出异常的处理器列表(默认情况下查无记录时抛出异常)
|
||||
processorsNotToThrowError := map[string]bool{
|
||||
// 在这个列表中的处理器,查无记录时返回空数组,不抛出异常
|
||||
// 示例:如果需要添加某个处理器,取消下面的注释
|
||||
// "QCXG9P1C": true,
|
||||
}
|
||||
// 如果是不需要抛出异常的处理器,返回空数组;否则(默认)抛出异常
|
||||
if processorsNotToThrowError[processorCode] {
|
||||
// 查无记录时,返回空数组,API调用记录为成功
|
||||
return []byte("[]"), nil
|
||||
}
|
||||
// 记录错误日志
|
||||
if j.logger != nil {
|
||||
j.logger.LogErrorWithResponseID(requestID, transactionID, apiCode, jiguangErr, params, jiguangResp.OrderID)
|
||||
}
|
||||
return nil, errors.Join(ErrNotFound, jiguangErr)
|
||||
}
|
||||
// 记录错误日志(查无记录的情况不记录错误日志)
|
||||
if j.logger != nil {
|
||||
j.logger.LogErrorWithResponseID(requestID, transactionID, apiCode, jiguangErr, params, jiguangResp.OrderID)
|
||||
}
|
||||
if jiguangErr.IsQueryFailed() {
|
||||
return nil, errors.Join(ErrDatasource, jiguangErr)
|
||||
} else if jiguangErr.IsSystemError() {
|
||||
return nil, errors.Join(ErrSystem, jiguangErr)
|
||||
} else {
|
||||
return nil, errors.Join(ErrDatasource, jiguangErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 成功时业务体在 data 或 result
|
||||
payload := jiguangResp.Data
|
||||
if payload == nil {
|
||||
payload = jiguangResp.Result
|
||||
}
|
||||
if payload == nil {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("业务数据序列化失败: %w", err))
|
||||
if j.logger != nil {
|
||||
j.logger.LogErrorWithResponseID(requestID, transactionID, apiCode, err, params, jiguangResp.OrderID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dataBytes, nil
|
||||
}
|
||||
|
||||
// GetConfig 获取配置信息
|
||||
func (j *JiguangService) GetConfig() JiguangConfig {
|
||||
return j.config
|
||||
}
|
||||
25
internal/infrastructure/external/muzi/muzi_errors.go
vendored
Normal file
25
internal/infrastructure/external/muzi/muzi_errors.go
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
package muzi
|
||||
|
||||
import "fmt"
|
||||
|
||||
// MuziError 木子数据业务错误
|
||||
type MuziError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
// Error implements error interface.
|
||||
func (e *MuziError) Error() string {
|
||||
return fmt.Sprintf("木子数据返回错误,代码: %d,信息: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// NewMuziError 根据错误码创建业务错误
|
||||
func NewMuziError(code int, message string) *MuziError {
|
||||
if message == "" {
|
||||
message = "木子数据返回未知错误"
|
||||
}
|
||||
return &MuziError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
61
internal/infrastructure/external/muzi/muzi_factory.go
vendored
Normal file
61
internal/infrastructure/external/muzi/muzi_factory.go
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
package muzi
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewMuziServiceWithConfig 使用配置创建木子数据服务
|
||||
func NewMuziServiceWithConfig(cfg *config.Config) (*MuziService, error) {
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.Muzi.Logging.Enabled,
|
||||
LogDir: cfg.Muzi.Logging.LogDir,
|
||||
ServiceName: "muzi",
|
||||
UseDaily: cfg.Muzi.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.Muzi.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
|
||||
for level, levelCfg := range cfg.Muzi.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[level] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: levelCfg.MaxSize,
|
||||
MaxBackups: levelCfg.MaxBackups,
|
||||
MaxAge: levelCfg.MaxAge,
|
||||
Compress: levelCfg.Compress,
|
||||
}
|
||||
}
|
||||
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
service := NewMuziService(
|
||||
cfg.Muzi.URL,
|
||||
cfg.Muzi.AppID,
|
||||
cfg.Muzi.AppSecret,
|
||||
cfg.Muzi.Timeout,
|
||||
logger,
|
||||
)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewMuziServiceWithLogging 使用自定义日志配置创建木子数据服务
|
||||
func NewMuziServiceWithLogging(url, appID, appSecret string, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig) (*MuziService, error) {
|
||||
loggingConfig.ServiceName = "muzi"
|
||||
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewMuziService(url, appID, appSecret, timeout, logger), nil
|
||||
}
|
||||
|
||||
// NewMuziServiceSimple 创建无日志的木子数据服务
|
||||
func NewMuziServiceSimple(url, appID, appSecret string, timeout time.Duration) *MuziService {
|
||||
return NewMuziService(url, appID, appSecret, timeout, nil)
|
||||
}
|
||||
406
internal/infrastructure/external/muzi/muzi_service.go
vendored
Normal file
406
internal/infrastructure/external/muzi/muzi_service.go
vendored
Normal file
@@ -0,0 +1,406 @@
|
||||
package muzi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
const defaultRequestTimeout = 60 * time.Second
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
)
|
||||
|
||||
// Muzi状态码常量
|
||||
const (
|
||||
CodeSuccess = 0 // 成功查询
|
||||
CodeSystemError = 500 // 系统异常
|
||||
CodeParamMissing = 601 // 参数不全
|
||||
CodeInterfaceExpired = 602 // 接口已过期
|
||||
CodeVerifyFailed = 603 // 接口校验失败
|
||||
CodeIPNotInWhitelist = 604 // IP不在白名单中
|
||||
CodeProductNotFound = 701 // 产品编号不存在
|
||||
CodeUserNotFound = 702 // 用户名不存在
|
||||
CodeUnauthorizedAPI = 703 // 接口未授权
|
||||
CodeInsufficientFund = 704 // 商户余额不足
|
||||
)
|
||||
|
||||
// MuziResponse 木子数据接口通用响应
|
||||
type MuziResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ExecuteTime int64 `json:"executeTime"`
|
||||
}
|
||||
|
||||
// MuziConfig 木子数据接口配置
|
||||
type MuziConfig struct {
|
||||
URL string
|
||||
AppID string
|
||||
AppSecret string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// MuziService 木子数据接口服务封装
|
||||
type MuziService struct {
|
||||
config MuziConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
}
|
||||
|
||||
// NewMuziService 创建木子数据服务实例
|
||||
func NewMuziService(url, appID, appSecret string, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *MuziService {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultRequestTimeout
|
||||
}
|
||||
|
||||
return &MuziService{
|
||||
config: MuziConfig{
|
||||
URL: url,
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
Timeout: timeout,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID 生成请求ID
|
||||
func (m *MuziService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
raw := fmt.Sprintf("%d_%s", timestamp, m.config.AppID)
|
||||
sum := md5.Sum([]byte(raw))
|
||||
return fmt.Sprintf("muzi_%x", sum[:8])
|
||||
}
|
||||
|
||||
// CallAPI 调用木子数据接口
|
||||
func (m *MuziService) CallAPI(ctx context.Context, prodCode string, path string, params map[string]interface{},paramSign map[string]interface{}) (json.RawMessage, error) {
|
||||
requestID := m.generateRequestID()
|
||||
now := time.Now()
|
||||
timestamp := strconv.FormatInt(now.UnixMilli(), 10)
|
||||
|
||||
flatParams := flattenParams(params)
|
||||
|
||||
signParts := collectSignatureValues(paramSign)
|
||||
signature := m.GenerateSignature(prodCode, timestamp, signParts...)
|
||||
|
||||
// 从上下文获取链路ID
|
||||
var transactionID string
|
||||
if ctxTransactionID, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = ctxTransactionID
|
||||
}
|
||||
|
||||
requestBody := map[string]interface{}{
|
||||
"appId": m.config.AppID,
|
||||
"prodCode": prodCode,
|
||||
"timestamp": timestamp,
|
||||
"signature": signature,
|
||||
}
|
||||
for key, value := range flatParams {
|
||||
requestBody[key] = value
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogRequest(requestID, transactionID, prodCode, m.config.URL)
|
||||
}
|
||||
|
||||
bodyBytes, marshalErr := json.Marshal(requestBody)
|
||||
if marshalErr != nil {
|
||||
err := errors.Join(ErrSystem, marshalErr)
|
||||
if m.logger != nil {
|
||||
m.logger.LogError(requestID, transactionID, prodCode, err, requestBody)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 构建完整的URL,拼接路径参数
|
||||
fullURL := m.config.URL
|
||||
if path != "" {
|
||||
// 确保路径以/开头
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
// 确保URL不以/结尾,避免双斜杠
|
||||
if strings.HasSuffix(fullURL, "/") {
|
||||
fullURL = fullURL[:len(fullURL)-1]
|
||||
}
|
||||
fullURL += path
|
||||
}
|
||||
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewBuffer(bodyBytes))
|
||||
if reqErr != nil {
|
||||
err := errors.Join(ErrSystem, reqErr)
|
||||
if m.logger != nil {
|
||||
m.logger.LogError(requestID, transactionID, prodCode, err, requestBody)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: m.config.Timeout,
|
||||
}
|
||||
|
||||
resp, httpErr := client.Do(req)
|
||||
if httpErr != nil {
|
||||
err := wrapHTTPError(httpErr)
|
||||
if errors.Is(err, ErrDatasource) {
|
||||
err = errors.Join(err, fmt.Errorf("API请求超时: %v", httpErr))
|
||||
}
|
||||
if m.logger != nil {
|
||||
m.logger.LogError(requestID, transactionID, prodCode, err, requestBody)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func(body io.ReadCloser) {
|
||||
closeErr := body.Close()
|
||||
if closeErr != nil && m.logger != nil {
|
||||
m.logger.LogError(requestID, transactionID, prodCode, errors.Join(ErrSystem, fmt.Errorf("关闭响应体失败: %w", closeErr)), requestBody)
|
||||
}
|
||||
}(resp.Body)
|
||||
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
err := errors.Join(ErrSystem, readErr)
|
||||
if m.logger != nil {
|
||||
m.logger.LogError(requestID, transactionID, prodCode, err, requestBody)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
// 记录响应日志(不记录具体响应数据)
|
||||
m.logger.LogResponse(requestID, transactionID, prodCode, resp.StatusCode, time.Since(now))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err := errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", resp.StatusCode))
|
||||
if m.logger != nil {
|
||||
m.logger.LogError(requestID, transactionID, prodCode, err, requestBody)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var muziResp MuziResponse
|
||||
if err := json.Unmarshal(respBody, &muziResp); err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %v", err))
|
||||
if m.logger != nil {
|
||||
m.logger.LogError(requestID, transactionID, prodCode, err, requestBody)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if muziResp.Code != CodeSuccess {
|
||||
muziErr := NewMuziError(muziResp.Code, muziResp.Msg)
|
||||
var resultErr error
|
||||
|
||||
switch muziResp.Code {
|
||||
case CodeSystemError:
|
||||
resultErr = errors.Join(ErrDatasource, muziErr)
|
||||
default:
|
||||
resultErr = errors.Join(ErrSystem, muziErr)
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
m.logger.LogError(requestID, transactionID, prodCode, muziErr, requestBody)
|
||||
}
|
||||
return nil, resultErr
|
||||
}
|
||||
|
||||
return muziResp.Data, nil
|
||||
}
|
||||
|
||||
func wrapHTTPError(err error) error {
|
||||
var timeout bool
|
||||
if err == context.DeadlineExceeded {
|
||||
timeout = true
|
||||
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
timeout = true
|
||||
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
timeout = true
|
||||
}
|
||||
|
||||
if timeout {
|
||||
return errors.Join(ErrDatasource, err)
|
||||
}
|
||||
return errors.Join(ErrSystem, err)
|
||||
}
|
||||
|
||||
func pkcs5Padding(src []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(src)%blockSize
|
||||
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(src, padtext...)
|
||||
}
|
||||
|
||||
func flattenParams(params map[string]interface{}) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
if params == nil {
|
||||
return result
|
||||
}
|
||||
for key, value := range params {
|
||||
flattenValue(key, value, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func flattenValue(prefix string, value interface{}, out map[string]interface{}) {
|
||||
switch val := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, v := range val {
|
||||
flattenValue(combinePrefix(prefix, k), v, out)
|
||||
}
|
||||
case map[interface{}]interface{}:
|
||||
for k, v := range val {
|
||||
keyStr := fmt.Sprint(k)
|
||||
flattenValue(combinePrefix(prefix, keyStr), v, out)
|
||||
}
|
||||
case []interface{}:
|
||||
for i, item := range val {
|
||||
nextPrefix := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
flattenValue(nextPrefix, item, out)
|
||||
}
|
||||
case []string:
|
||||
for i, item := range val {
|
||||
nextPrefix := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
flattenValue(nextPrefix, item, out)
|
||||
}
|
||||
case []int:
|
||||
for i, item := range val {
|
||||
nextPrefix := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
flattenValue(nextPrefix, item, out)
|
||||
}
|
||||
case []float64:
|
||||
for i, item := range val {
|
||||
nextPrefix := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
flattenValue(nextPrefix, item, out)
|
||||
}
|
||||
case []bool:
|
||||
for i, item := range val {
|
||||
nextPrefix := fmt.Sprintf("%s[%d]", prefix, i)
|
||||
flattenValue(nextPrefix, item, out)
|
||||
}
|
||||
default:
|
||||
out[prefix] = val
|
||||
}
|
||||
}
|
||||
|
||||
func combinePrefix(prefix, key string) string {
|
||||
if prefix == "" {
|
||||
return key
|
||||
}
|
||||
return prefix + "." + key
|
||||
}
|
||||
|
||||
// Encrypt 使用 AES/ECB/PKCS5Padding 对单个字符串进行加密并返回 Base64 结果
|
||||
func (m *MuziService) Encrypt(value string) (string, error) {
|
||||
if len(m.config.AppSecret) != 32 {
|
||||
return "", fmt.Errorf("AppSecret长度必须为32位")
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher([]byte(m.config.AppSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("初始化加密器失败: %w", err)
|
||||
}
|
||||
|
||||
padded := pkcs5Padding([]byte(value), block.BlockSize())
|
||||
encrypted := make([]byte, len(padded))
|
||||
|
||||
for bs, be := 0, block.BlockSize(); bs < len(padded); bs, be = bs+block.BlockSize(), be+block.BlockSize() {
|
||||
block.Encrypt(encrypted[bs:be], padded[bs:be])
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
// GenerateSignature 根据协议生成签名,extraValues 会按顺序追加在待签名字符串之后
|
||||
func (m *MuziService) GenerateSignature(prodCode, timestamp string, extraValues ...string) string {
|
||||
signStr := m.config.AppID + prodCode + timestamp
|
||||
for _, extra := range extraValues {
|
||||
signStr += extra
|
||||
}
|
||||
hash := md5.Sum([]byte(signStr))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// GenerateTimestamp 生成当前毫秒级时间戳字符串
|
||||
func (m *MuziService) GenerateTimestamp() string {
|
||||
return strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
}
|
||||
|
||||
// FlattenParams 将嵌套参数展平为一维键值对
|
||||
func (m *MuziService) FlattenParams(params map[string]interface{}) map[string]interface{} {
|
||||
return flattenParams(params)
|
||||
}
|
||||
|
||||
func collectSignatureValues(data interface{}) []string {
|
||||
var result []string
|
||||
collectSignatureValuesRecursive(reflect.ValueOf(data), &result)
|
||||
return result
|
||||
}
|
||||
|
||||
func collectSignatureValuesRecursive(value reflect.Value, result *[]string) {
|
||||
if !value.IsValid() {
|
||||
*result = append(*result, "")
|
||||
return
|
||||
}
|
||||
|
||||
switch value.Kind() {
|
||||
case reflect.Pointer, reflect.Interface:
|
||||
if value.IsNil() {
|
||||
*result = append(*result, "")
|
||||
return
|
||||
}
|
||||
collectSignatureValuesRecursive(value.Elem(), result)
|
||||
case reflect.Map:
|
||||
keys := value.MapKeys()
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
return fmt.Sprint(keys[i].Interface()) < fmt.Sprint(keys[j].Interface())
|
||||
})
|
||||
for _, key := range keys {
|
||||
collectSignatureValuesRecursive(value.MapIndex(key), result)
|
||||
}
|
||||
case reflect.Slice, reflect.Array:
|
||||
for i := 0; i < value.Len(); i++ {
|
||||
collectSignatureValuesRecursive(value.Index(i), result)
|
||||
}
|
||||
case reflect.Struct:
|
||||
typeInfo := value.Type()
|
||||
fieldNames := make([]string, 0, value.NumField())
|
||||
fieldIndices := make(map[string]int, value.NumField())
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
field := typeInfo.Field(i)
|
||||
if field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
fieldNames = append(fieldNames, field.Name)
|
||||
fieldIndices[field.Name] = i
|
||||
}
|
||||
sort.Strings(fieldNames)
|
||||
for _, name := range fieldNames {
|
||||
collectSignatureValuesRecursive(value.Field(fieldIndices[name]), result)
|
||||
}
|
||||
default:
|
||||
*result = append(*result, fmt.Sprint(value.Interface()))
|
||||
}
|
||||
}
|
||||
573
internal/infrastructure/external/notification/wechat_work_service.go
vendored
Normal file
573
internal/infrastructure/external/notification/wechat_work_service.go
vendored
Normal file
@@ -0,0 +1,573 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WeChatWorkService 企业微信通知服务
|
||||
type WeChatWorkService struct {
|
||||
webhookURL string
|
||||
secret string
|
||||
timeout time.Duration
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// WechatWorkConfig 企业微信配置
|
||||
type WechatWorkConfig struct {
|
||||
WebhookURL string `yaml:"webhook_url"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
}
|
||||
|
||||
// WechatWorkMessage 企业微信消息
|
||||
type WechatWorkMessage struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Text *WechatWorkText `json:"text,omitempty"`
|
||||
Markdown *WechatWorkMarkdown `json:"markdown,omitempty"`
|
||||
}
|
||||
|
||||
// WechatWorkText 文本消息
|
||||
type WechatWorkText struct {
|
||||
Content string `json:"content"`
|
||||
MentionedList []string `json:"mentioned_list,omitempty"`
|
||||
MentionedMobileList []string `json:"mentioned_mobile_list,omitempty"`
|
||||
}
|
||||
|
||||
// WechatWorkMarkdown Markdown消息
|
||||
type WechatWorkMarkdown struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// NewWeChatWorkService 创建企业微信通知服务
|
||||
func NewWeChatWorkService(webhookURL, secret string, logger *zap.Logger) *WeChatWorkService {
|
||||
return &WeChatWorkService{
|
||||
webhookURL: webhookURL,
|
||||
secret: secret,
|
||||
timeout: 60 * time.Second,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SendTextMessage 发送文本消息
|
||||
func (s *WeChatWorkService) SendTextMessage(ctx context.Context, content string, mentionedList []string, mentionedMobileList []string) error {
|
||||
s.logger.Info("发送企业微信文本消息",
|
||||
zap.String("content", content),
|
||||
zap.Strings("mentioned_list", mentionedList),
|
||||
)
|
||||
|
||||
message := map[string]interface{}{
|
||||
"msgtype": "text",
|
||||
"text": map[string]interface{}{
|
||||
"content": content,
|
||||
"mentioned_list": mentionedList,
|
||||
"mentioned_mobile_list": mentionedMobileList,
|
||||
},
|
||||
}
|
||||
|
||||
return s.sendMessage(ctx, message)
|
||||
}
|
||||
|
||||
// SendMarkdownMessage 发送Markdown消息
|
||||
func (s *WeChatWorkService) SendMarkdownMessage(ctx context.Context, content string) error {
|
||||
s.logger.Info("发送企业微信Markdown消息", zap.String("content", content))
|
||||
|
||||
message := map[string]interface{}{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]interface{}{
|
||||
"content": content,
|
||||
},
|
||||
}
|
||||
|
||||
return s.sendMessage(ctx, message)
|
||||
}
|
||||
|
||||
// SendCardMessage 发送卡片消息
|
||||
func (s *WeChatWorkService) SendCardMessage(ctx context.Context, title, description, url string, btnText string) error {
|
||||
s.logger.Info("发送企业微信卡片消息",
|
||||
zap.String("title", title),
|
||||
zap.String("description", description),
|
||||
)
|
||||
|
||||
message := map[string]interface{}{
|
||||
"msgtype": "template_card",
|
||||
"template_card": map[string]interface{}{
|
||||
"card_type": "text_notice",
|
||||
"source": map[string]interface{}{
|
||||
"icon_url": "https://example.com/icon.png",
|
||||
"desc": "企业认证系统",
|
||||
},
|
||||
"main_title": map[string]interface{}{
|
||||
"title": title,
|
||||
},
|
||||
"horizontal_content_list": []map[string]interface{}{
|
||||
{
|
||||
"keyname": "描述",
|
||||
"value": description,
|
||||
},
|
||||
},
|
||||
"jump_list": []map[string]interface{}{
|
||||
{
|
||||
"type": "1",
|
||||
"title": btnText,
|
||||
"url": url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return s.sendMessage(ctx, message)
|
||||
}
|
||||
|
||||
// SendCertificationNotification 发送认证相关通知
|
||||
func (s *WeChatWorkService) SendCertificationNotification(ctx context.Context, notificationType string, data map[string]interface{}) error {
|
||||
s.logger.Info("发送认证通知", zap.String("type", notificationType))
|
||||
|
||||
switch notificationType {
|
||||
case "new_application":
|
||||
return s.sendNewApplicationNotification(ctx, data)
|
||||
case "pending_manual_review":
|
||||
return s.sendPendingManualReviewNotification(ctx, data)
|
||||
case "ocr_success":
|
||||
return s.sendOCRSuccessNotification(ctx, data)
|
||||
case "ocr_failed":
|
||||
return s.sendOCRFailedNotification(ctx, data)
|
||||
case "face_verify_success":
|
||||
return s.sendFaceVerifySuccessNotification(ctx, data)
|
||||
case "face_verify_failed":
|
||||
return s.sendFaceVerifyFailedNotification(ctx, data)
|
||||
case "admin_approved":
|
||||
return s.sendAdminApprovedNotification(ctx, data)
|
||||
case "admin_rejected":
|
||||
return s.sendAdminRejectedNotification(ctx, data)
|
||||
case "contract_signed":
|
||||
return s.sendContractSignedNotification(ctx, data)
|
||||
case "certification_completed":
|
||||
return s.sendCertificationCompletedNotification(ctx, data)
|
||||
default:
|
||||
return fmt.Errorf("不支持的通知类型: %s", notificationType)
|
||||
}
|
||||
}
|
||||
|
||||
// sendNewApplicationNotification 发送新申请通知
|
||||
func (s *WeChatWorkService) sendNewApplicationNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
companyName := data["company_name"].(string)
|
||||
applicantName := data["applicant_name"].(string)
|
||||
applicationID := data["application_id"].(string)
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】🆕 新的企业认证申请
|
||||
|
||||
**企业名称**: %s
|
||||
**申请人**: %s
|
||||
**申请ID**: %s
|
||||
**申请时间**: %s
|
||||
|
||||
请管理员及时审核处理。`,
|
||||
companyName,
|
||||
applicantName,
|
||||
applicationID,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendPendingManualReviewNotification 用户已提交企业信息,待管理员人工审核(三真审核前序步骤)
|
||||
func (s *WeChatWorkService) sendPendingManualReviewNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
companyName := fmt.Sprint(data["company_name"])
|
||||
legalPersonName := fmt.Sprint(data["legal_person_name"])
|
||||
authorizedRepName := fmt.Sprint(data["authorized_rep_name"])
|
||||
contactPhone := fmt.Sprint(data["contact_phone"])
|
||||
apiUsage := fmt.Sprint(data["api_usage"])
|
||||
submitAt := fmt.Sprint(data["submit_at"])
|
||||
|
||||
if authorizedRepName == "" || authorizedRepName == "<nil>" {
|
||||
authorizedRepName = "—"
|
||||
}
|
||||
if apiUsage == "" || apiUsage == "<nil>" {
|
||||
apiUsage = "—"
|
||||
}
|
||||
if contactPhone == "" || contactPhone == "<nil>" {
|
||||
contactPhone = "—"
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】📋 企业信息待人工审核
|
||||
|
||||
**企业名称**: %s
|
||||
**法人**: %s
|
||||
**授权申请人**: %s
|
||||
**联系电话**: %s
|
||||
**应用场景说明**: %s
|
||||
**提交时间**: %s
|
||||
|
||||
> 请管理员登录后台 **企业审核** 通过审核后,用户方可进行 e签宝企业认证。`,
|
||||
companyName,
|
||||
legalPersonName,
|
||||
authorizedRepName,
|
||||
contactPhone,
|
||||
apiUsage,
|
||||
submitAt)
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendOCRSuccessNotification 发送OCR识别成功通知
|
||||
func (s *WeChatWorkService) sendOCRSuccessNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
companyName := data["company_name"].(string)
|
||||
confidence := data["confidence"].(float64)
|
||||
applicationID := data["application_id"].(string)
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】✅ OCR识别成功
|
||||
|
||||
**企业名称**: %s
|
||||
**识别置信度**: %.2f%%
|
||||
**申请ID**: %s
|
||||
**识别时间**: %s
|
||||
|
||||
营业执照信息已自动提取,请用户确认信息。`,
|
||||
companyName,
|
||||
confidence*100,
|
||||
applicationID,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendOCRFailedNotification 发送OCR识别失败通知
|
||||
func (s *WeChatWorkService) sendOCRFailedNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
applicationID := data["application_id"].(string)
|
||||
errorMsg := data["error_message"].(string)
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】❌ OCR识别失败
|
||||
|
||||
**申请ID**: %s
|
||||
**错误信息**: %s
|
||||
**失败时间**: %s
|
||||
|
||||
请检查营业执照图片质量或联系技术支持。`,
|
||||
applicationID,
|
||||
errorMsg,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendFaceVerifySuccessNotification 发送人脸识别成功通知
|
||||
func (s *WeChatWorkService) sendFaceVerifySuccessNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
applicantName := data["applicant_name"].(string)
|
||||
applicationID := data["application_id"].(string)
|
||||
confidence := data["confidence"].(float64)
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】✅ 人脸识别成功
|
||||
|
||||
**申请人**: %s
|
||||
**申请ID**: %s
|
||||
**识别置信度**: %.2f%%
|
||||
**识别时间**: %s
|
||||
|
||||
身份验证通过,可以进行下一步操作。`,
|
||||
applicantName,
|
||||
applicationID,
|
||||
confidence*100,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendFaceVerifyFailedNotification 发送人脸识别失败通知
|
||||
func (s *WeChatWorkService) sendFaceVerifyFailedNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
applicantName := data["applicant_name"].(string)
|
||||
applicationID := data["application_id"].(string)
|
||||
errorMsg := data["error_message"].(string)
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】❌ 人脸识别失败
|
||||
|
||||
**申请人**: %s
|
||||
**申请ID**: %s
|
||||
**错误信息**: %s
|
||||
**失败时间**: %s
|
||||
|
||||
请重新进行人脸识别或联系技术支持。`,
|
||||
applicantName,
|
||||
applicationID,
|
||||
errorMsg,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendAdminApprovedNotification 发送管理员审核通过通知
|
||||
func (s *WeChatWorkService) sendAdminApprovedNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
companyName := data["company_name"].(string)
|
||||
applicationID := data["application_id"].(string)
|
||||
adminName := data["admin_name"].(string)
|
||||
comment := data["comment"].(string)
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】✅ 管理员审核通过
|
||||
|
||||
**企业名称**: %s
|
||||
**申请ID**: %s
|
||||
**审核人**: %s
|
||||
**审核意见**: %s
|
||||
**审核时间**: %s
|
||||
|
||||
认证申请已通过审核,请用户签署电子合同。`,
|
||||
companyName,
|
||||
applicationID,
|
||||
adminName,
|
||||
comment,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendAdminRejectedNotification 发送管理员审核拒绝通知
|
||||
func (s *WeChatWorkService) sendAdminRejectedNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
companyName := data["company_name"].(string)
|
||||
applicationID := data["application_id"].(string)
|
||||
adminName := data["admin_name"].(string)
|
||||
reason := data["reason"].(string)
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】❌ 管理员审核拒绝
|
||||
|
||||
**企业名称**: %s
|
||||
**申请ID**: %s
|
||||
**审核人**: %s
|
||||
**拒绝原因**: %s
|
||||
**审核时间**: %s
|
||||
|
||||
认证申请被拒绝,请根据反馈意见重新提交。`,
|
||||
companyName,
|
||||
applicationID,
|
||||
adminName,
|
||||
reason,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendContractSignedNotification 发送合同签署通知
|
||||
func (s *WeChatWorkService) sendContractSignedNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
companyName := data["company_name"].(string)
|
||||
applicationID := data["application_id"].(string)
|
||||
signerName := data["signer_name"].(string)
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】📝 电子合同已签署
|
||||
|
||||
**企业名称**: %s
|
||||
**申请ID**: %s
|
||||
**签署人**: %s
|
||||
**签署时间**: %s
|
||||
|
||||
电子合同签署完成,系统将自动生成钱包和Access Key。`,
|
||||
companyName,
|
||||
applicationID,
|
||||
signerName,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendCertificationCompletedNotification 发送认证完成通知
|
||||
func (s *WeChatWorkService) sendCertificationCompletedNotification(ctx context.Context, data map[string]interface{}) error {
|
||||
companyName := data["company_name"].(string)
|
||||
applicationID := data["application_id"].(string)
|
||||
walletAddress := data["wallet_address"].(string)
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】🎉 企业认证完成
|
||||
|
||||
**企业名称**: %s
|
||||
**申请ID**: %s
|
||||
**钱包地址**: %s
|
||||
**完成时间**: %s
|
||||
|
||||
恭喜!企业认证流程已完成,钱包和Access Key已生成。`,
|
||||
companyName,
|
||||
applicationID,
|
||||
walletAddress,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// sendMessage 发送消息到企业微信
|
||||
func (s *WeChatWorkService) sendMessage(ctx context.Context, message map[string]interface{}) error {
|
||||
// 生成签名URL
|
||||
signedURL := s.generateSignedURL()
|
||||
|
||||
// 序列化消息
|
||||
messageBytes, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化消息失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建HTTP客户端
|
||||
client := &http.Client{
|
||||
Timeout: s.timeout,
|
||||
}
|
||||
|
||||
// 创建请求
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", signedURL, bytes.NewBuffer(messageBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "hyapi-server/1.0")
|
||||
|
||||
// 发送请求
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
errorMsg := "发送请求失败"
|
||||
if isTimeout {
|
||||
errorMsg = "发送请求超时"
|
||||
}
|
||||
return fmt.Errorf("%s: %w", errorMsg, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("请求失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var response map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||
return fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查错误码
|
||||
if errCode, ok := response["errcode"].(float64); ok && errCode != 0 {
|
||||
errmsg := response["errmsg"].(string)
|
||||
return fmt.Errorf("企业微信API错误: %d - %s", int(errCode), errmsg)
|
||||
}
|
||||
|
||||
s.logger.Info("企业微信消息发送成功", zap.Any("response", response))
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSignedURL 生成带签名的URL
|
||||
func (s *WeChatWorkService) generateSignedURL() string {
|
||||
if s.secret == "" {
|
||||
return s.webhookURL
|
||||
}
|
||||
|
||||
// 生成时间戳
|
||||
timestamp := time.Now().Unix()
|
||||
|
||||
// 生成随机字符串(这里简化处理,实际应该使用随机字符串)
|
||||
nonce := fmt.Sprintf("%d", timestamp)
|
||||
|
||||
// 构建签名字符串
|
||||
signStr := fmt.Sprintf("%d\n%s", timestamp, s.secret)
|
||||
|
||||
// 计算签名
|
||||
h := hmac.New(sha256.New, []byte(s.secret))
|
||||
h.Write([]byte(signStr))
|
||||
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
|
||||
// 构建签名URL
|
||||
return fmt.Sprintf("%s×tamp=%d&nonce=%s&sign=%s",
|
||||
s.webhookURL, timestamp, nonce, signature)
|
||||
}
|
||||
|
||||
// SendSystemAlert 发送系统告警
|
||||
func (s *WeChatWorkService) SendSystemAlert(ctx context.Context, level, title, message string) error {
|
||||
s.logger.Info("发送系统告警",
|
||||
zap.String("level", level),
|
||||
zap.String("title", title),
|
||||
)
|
||||
|
||||
// 根据告警级别选择图标
|
||||
var icon string
|
||||
switch level {
|
||||
case "info":
|
||||
icon = "ℹ️"
|
||||
case "warning":
|
||||
icon = "⚠️"
|
||||
case "error":
|
||||
icon = "🚨"
|
||||
case "critical":
|
||||
icon = "💥"
|
||||
default:
|
||||
icon = "📢"
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】%s 系统告警
|
||||
|
||||
**级别**: %s
|
||||
**标题**: %s
|
||||
**消息**: %s
|
||||
**时间**: %s
|
||||
|
||||
请相关人员及时处理。`,
|
||||
icon,
|
||||
level,
|
||||
title,
|
||||
message,
|
||||
time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
|
||||
// SendDailyReport 发送每日报告
|
||||
func (s *WeChatWorkService) SendDailyReport(ctx context.Context, reportData map[string]interface{}) error {
|
||||
s.logger.Info("发送每日报告")
|
||||
|
||||
content := fmt.Sprintf(`## 【海宇数据】📊 企业认证系统每日报告
|
||||
|
||||
**报告日期**: %s
|
||||
|
||||
### 统计数据
|
||||
- **新增申请**: %d
|
||||
- **OCR识别成功**: %d
|
||||
- **OCR识别失败**: %d
|
||||
- **人脸识别成功**: %d
|
||||
- **人脸识别失败**: %d
|
||||
- **审核通过**: %d
|
||||
- **审核拒绝**: %d
|
||||
- **认证完成**: %d
|
||||
|
||||
### 系统状态
|
||||
- **系统运行时间**: %s
|
||||
- **API调用次数**: %d
|
||||
- **错误次数**: %d
|
||||
|
||||
祝您工作愉快!`,
|
||||
time.Now().Format("2006-01-02"),
|
||||
reportData["new_applications"],
|
||||
reportData["ocr_success"],
|
||||
reportData["ocr_failed"],
|
||||
reportData["face_verify_success"],
|
||||
reportData["face_verify_failed"],
|
||||
reportData["admin_approved"],
|
||||
reportData["admin_rejected"],
|
||||
reportData["certification_completed"],
|
||||
reportData["uptime"],
|
||||
reportData["api_calls"],
|
||||
reportData["errors"])
|
||||
|
||||
return s.SendMarkdownMessage(ctx, content)
|
||||
}
|
||||
147
internal/infrastructure/external/notification/wechat_work_service_test.go
vendored
Normal file
147
internal/infrastructure/external/notification/wechat_work_service_test.go
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
package notification_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"hyapi-server/internal/infrastructure/external/notification"
|
||||
)
|
||||
|
||||
// newTestWeChatWorkService 创建用于测试的企业微信服务实例
|
||||
// 默认使用环境变量 WECOM_WEBHOOK,若未设置则使用项目配置中的 webhook。
|
||||
func newTestWeChatWorkService(t *testing.T) *notification.WeChatWorkService {
|
||||
t.Helper()
|
||||
|
||||
webhook := os.Getenv("WECOM_WEBHOOK")
|
||||
if webhook == "" {
|
||||
// 使用你提供的 webhook 地址
|
||||
webhook = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=649bf737-28ca-4f30-ad5f-cfb65b2af113"
|
||||
}
|
||||
|
||||
logger, _ := zap.NewDevelopment()
|
||||
return notification.NewWeChatWorkService(webhook, "", logger)
|
||||
}
|
||||
|
||||
// TestWeChatWork_SendAllBusinessNotifications
|
||||
// 手动运行该用例,将依次向企业微信群推送 5 种业务场景的通知:
|
||||
// 1. 用户充值成功
|
||||
// 2. 用户申请开发票
|
||||
// 3. 用户企业认证成功
|
||||
// 4. 用户余额低于阈值
|
||||
// 5. 用户余额欠费
|
||||
//
|
||||
// 注意:
|
||||
// - 通知中只使用企业名称和手机号码,不展示用户ID
|
||||
// - 默认使用示例企业名称和手机号,实际使用时请根据需要修改
|
||||
func TestWeChatWork_SendAllBusinessNotifications(t *testing.T) {
|
||||
svc := newTestWeChatWorkService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 示例企业信息(实际可按需修改)
|
||||
enterpriseName := "测试企业有限公司"
|
||||
phone := "13800000000"
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{
|
||||
name: "recharge_success",
|
||||
content: fmt.Sprintf(
|
||||
"### 【海宇数据】用户充值成功通知\n"+
|
||||
"> 企业名称:%s\n"+
|
||||
"> 联系手机:%s\n"+
|
||||
"> 充值金额:%s 元\n"+
|
||||
"> 入账总额:%s 元(含赠送)\n"+
|
||||
"> 时间:%s\n",
|
||||
enterpriseName,
|
||||
phone,
|
||||
"1000.00",
|
||||
"1050.00",
|
||||
now,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "invoice_applied",
|
||||
content: fmt.Sprintf(
|
||||
"### 【海宇数据】用户申请开发票\n"+
|
||||
"> 企业名称:%s\n"+
|
||||
"> 联系手机:%s\n"+
|
||||
"> 申请开票金额:%s 元\n"+
|
||||
"> 发票类型:%s\n"+
|
||||
"> 申请时间:%s\n"+
|
||||
"\n请财务尽快审核并开具发票。",
|
||||
enterpriseName,
|
||||
phone,
|
||||
"500.00",
|
||||
"增值税专用发票",
|
||||
now,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "certification_completed",
|
||||
content: fmt.Sprintf(
|
||||
"### 【海宇数据】企业认证成功\n"+
|
||||
"> 企业名称:%s\n"+
|
||||
"> 联系手机:%s\n"+
|
||||
"> 完成时间:%s\n"+
|
||||
"\n该企业已完成认证,请相关同事同步更新内部系统并关注后续接入情况。",
|
||||
enterpriseName,
|
||||
phone,
|
||||
now,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "low_balance_alert",
|
||||
content: fmt.Sprintf(
|
||||
"### 【海宇数据】用户余额预警\n"+
|
||||
"<font color=\"warning\">用户余额已低于预警阈值,请及时跟进。</font>\n"+
|
||||
"> 企业名称:%s\n"+
|
||||
"> 联系手机:%s\n"+
|
||||
"> 当前余额:%s 元\n"+
|
||||
"> 预警阈值:%s 元\n"+
|
||||
"> 时间:%s\n",
|
||||
enterpriseName,
|
||||
phone,
|
||||
"180.00",
|
||||
"200.00",
|
||||
now,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "arrears_alert",
|
||||
content: fmt.Sprintf(
|
||||
"### 【海宇数据】用户余额欠费告警\n"+
|
||||
"<font color=\"warning\">该企业已发生欠费,请及时联系并处理。</font>\n"+
|
||||
"> 企业名称:%s\n"+
|
||||
"> 联系手机:%s\n"+
|
||||
"> 当前余额:%s 元\n"+
|
||||
"> 欠费金额:%s 元\n"+
|
||||
"> 时间:%s\n",
|
||||
enterpriseName,
|
||||
phone,
|
||||
"-50.00",
|
||||
"50.00",
|
||||
now,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := svc.SendMarkdownMessage(ctx, tc.content); err != nil {
|
||||
t.Fatalf("发送场景[%s]通知失败: %v", tc.name, err)
|
||||
}
|
||||
// 简单间隔,避免瞬时发送过多消息
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
})
|
||||
}
|
||||
}
|
||||
531
internal/infrastructure/external/ocr/baidu_ocr_service.go
vendored
Normal file
531
internal/infrastructure/external/ocr/baidu_ocr_service.go
vendored
Normal file
@@ -0,0 +1,531 @@
|
||||
package ocr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"hyapi-server/internal/application/certification/dto/responses"
|
||||
)
|
||||
|
||||
// BaiduOCRService 百度OCR服务
|
||||
type BaiduOCRService struct {
|
||||
apiKey string
|
||||
secretKey string
|
||||
endpoint string
|
||||
timeout time.Duration
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewBaiduOCRService 创建百度OCR服务
|
||||
func NewBaiduOCRService(apiKey, secretKey string, logger *zap.Logger) *BaiduOCRService {
|
||||
return &BaiduOCRService{
|
||||
apiKey: apiKey,
|
||||
secretKey: secretKey,
|
||||
endpoint: "https://aip.baidubce.com",
|
||||
timeout: 60 * time.Second,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RecognizeBusinessLicense 识别营业执照
|
||||
func (s *BaiduOCRService) RecognizeBusinessLicense(ctx context.Context, imageBytes []byte) (*responses.BusinessLicenseResult, error) {
|
||||
s.logger.Info("开始识别营业执照", zap.Int("image_size", len(imageBytes)))
|
||||
|
||||
// 获取访问令牌
|
||||
accessToken, err := s.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取访问令牌失败: %w", err)
|
||||
}
|
||||
|
||||
// 将图片转换为base64并进行URL编码
|
||||
imageBase64 := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
imageBase64UrlEncoded := url.QueryEscape(imageBase64)
|
||||
|
||||
// 构建请求URL(只包含access_token)
|
||||
apiURL := fmt.Sprintf("%s/rest/2.0/ocr/v1/business_license?access_token=%s", s.endpoint, accessToken)
|
||||
|
||||
// 构建POST请求体
|
||||
payload := strings.NewReader(fmt.Sprintf("image=%s", imageBase64UrlEncoded))
|
||||
resp, err := s.sendRequest(ctx, "POST", apiURL, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("营业执照识别请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查错误
|
||||
if errCode, ok := result["error_code"].(float64); ok && errCode != 0 {
|
||||
errorMsg := result["error_msg"].(string)
|
||||
return nil, fmt.Errorf("OCR识别失败: %s", errorMsg)
|
||||
}
|
||||
|
||||
// 解析识别结果
|
||||
licenseResult := s.parseBusinessLicenseResult(result)
|
||||
|
||||
s.logger.Info("营业执照识别成功",
|
||||
zap.String("company_name", licenseResult.CompanyName),
|
||||
zap.String("legal_representative", licenseResult.LegalPersonName),
|
||||
zap.String("registered_capital", licenseResult.RegisteredCapital),
|
||||
)
|
||||
|
||||
return licenseResult, nil
|
||||
}
|
||||
|
||||
// RecognizeIDCard 识别身份证
|
||||
func (s *BaiduOCRService) RecognizeIDCard(ctx context.Context, imageBytes []byte, side string) (*responses.IDCardResult, error) {
|
||||
s.logger.Info("开始识别身份证", zap.String("side", side), zap.Int("image_size", len(imageBytes)))
|
||||
|
||||
// 获取访问令牌
|
||||
accessToken, err := s.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取访问令牌失败: %w", err)
|
||||
}
|
||||
|
||||
// 将图片转换为base64并进行URL编码
|
||||
imageBase64 := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
imageBase64UrlEncoded := url.QueryEscape(imageBase64)
|
||||
|
||||
// 构建请求URL(只包含access_token)
|
||||
apiURL := fmt.Sprintf("%s/rest/2.0/ocr/v1/idcard?access_token=%s", s.endpoint, accessToken)
|
||||
|
||||
// 构建POST请求体
|
||||
payload := strings.NewReader(fmt.Sprintf("image=%s&side=%s", imageBase64UrlEncoded, side))
|
||||
resp, err := s.sendRequest(ctx, "POST", apiURL, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("身份证识别请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查错误
|
||||
if errCode, ok := result["error_code"].(float64); ok && errCode != 0 {
|
||||
errorMsg := result["error_msg"].(string)
|
||||
return nil, fmt.Errorf("OCR识别失败: %s", errorMsg)
|
||||
}
|
||||
|
||||
// 解析识别结果
|
||||
idCardResult := s.parseIDCardResult(result, side)
|
||||
|
||||
s.logger.Info("身份证识别成功",
|
||||
zap.String("name", idCardResult.Name),
|
||||
zap.String("id_number", idCardResult.IDCardNumber),
|
||||
zap.String("side", side),
|
||||
)
|
||||
|
||||
return idCardResult, nil
|
||||
}
|
||||
|
||||
// RecognizeGeneralText 通用文字识别
|
||||
func (s *BaiduOCRService) RecognizeGeneralText(ctx context.Context, imageBytes []byte) (*responses.GeneralTextResult, error) {
|
||||
s.logger.Info("开始通用文字识别", zap.Int("image_size", len(imageBytes)))
|
||||
|
||||
// 获取访问令牌
|
||||
accessToken, err := s.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取访问令牌失败: %w", err)
|
||||
}
|
||||
|
||||
// 将图片转换为base64并进行URL编码
|
||||
imageBase64 := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
imageBase64UrlEncoded := url.QueryEscape(imageBase64)
|
||||
|
||||
// 构建请求URL(只包含access_token)
|
||||
apiURL := fmt.Sprintf("%s/rest/2.0/ocr/v1/general_basic?access_token=%s", s.endpoint, accessToken)
|
||||
|
||||
// 构建POST请求体
|
||||
payload := strings.NewReader(fmt.Sprintf("image=%s", imageBase64UrlEncoded))
|
||||
resp, err := s.sendRequest(ctx, "POST", apiURL, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("通用文字识别请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查错误
|
||||
if errCode, ok := result["error_code"].(float64); ok && errCode != 0 {
|
||||
errorMsg := result["error_msg"].(string)
|
||||
return nil, fmt.Errorf("OCR识别失败: %s", errorMsg)
|
||||
}
|
||||
|
||||
// 解析识别结果
|
||||
textResult := s.parseGeneralTextResult(result)
|
||||
|
||||
s.logger.Info("通用文字识别成功",
|
||||
zap.Int("word_count", len(textResult.Words)),
|
||||
zap.Float64("confidence", textResult.Confidence),
|
||||
)
|
||||
|
||||
return textResult, nil
|
||||
}
|
||||
|
||||
// RecognizeFromURL 从URL识别图片
|
||||
func (s *BaiduOCRService) RecognizeFromURL(ctx context.Context, imageURL string, ocrType string) (interface{}, error) {
|
||||
s.logger.Info("从URL识别图片", zap.String("url", imageURL), zap.String("type", ocrType))
|
||||
|
||||
// 下载图片
|
||||
imageBytes, err := s.downloadImage(ctx, imageURL)
|
||||
if err != nil {
|
||||
s.logger.Error("下载图片失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("下载图片失败: %w", err)
|
||||
}
|
||||
|
||||
// 根据类型调用相应的识别方法
|
||||
switch ocrType {
|
||||
case "business_license":
|
||||
return s.RecognizeBusinessLicense(ctx, imageBytes)
|
||||
case "idcard_front":
|
||||
return s.RecognizeIDCard(ctx, imageBytes, "front")
|
||||
case "idcard_back":
|
||||
return s.RecognizeIDCard(ctx, imageBytes, "back")
|
||||
case "general_text":
|
||||
return s.RecognizeGeneralText(ctx, imageBytes)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的OCR类型: %s", ocrType)
|
||||
}
|
||||
}
|
||||
|
||||
// getAccessToken 获取百度API访问令牌
|
||||
func (s *BaiduOCRService) getAccessToken(ctx context.Context) (string, error) {
|
||||
// 构建获取访问令牌的URL
|
||||
tokenURL := fmt.Sprintf("%s/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s",
|
||||
s.endpoint, s.apiKey, s.secretKey)
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.sendRequest(ctx, "POST", tokenURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取访问令牌请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(resp, &result); err != nil {
|
||||
return "", fmt.Errorf("解析访问令牌响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查错误
|
||||
if errCode, ok := result["error"].(string); ok && errCode != "" {
|
||||
errorDesc := result["error_description"].(string)
|
||||
return "", fmt.Errorf("获取访问令牌失败: %s - %s", errCode, errorDesc)
|
||||
}
|
||||
|
||||
// 提取访问令牌
|
||||
accessToken, ok := result["access_token"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("响应中未找到访问令牌")
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
// sendRequest 发送HTTP请求
|
||||
func (s *BaiduOCRService) sendRequest(ctx context.Context, method, url string, body io.Reader) ([]byte, error) {
|
||||
// 创建HTTP客户端
|
||||
client := &http.Client{
|
||||
Timeout: s.timeout,
|
||||
}
|
||||
|
||||
// 创建请求
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "hyapi-server/1.0")
|
||||
|
||||
// 发送请求
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := err.Error();
|
||||
errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
if isTimeout {
|
||||
return nil, fmt.Errorf("API请求超时: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("发送请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("请求失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应内容失败: %w", err)
|
||||
}
|
||||
|
||||
return responseBody, nil
|
||||
}
|
||||
|
||||
// parseBusinessLicenseResult 解析营业执照识别结果
|
||||
func (s *BaiduOCRService) parseBusinessLicenseResult(result map[string]interface{}) *responses.BusinessLicenseResult {
|
||||
wordsResult := result["words_result"].(map[string]interface{})
|
||||
|
||||
// 提取企业信息
|
||||
companyName := ""
|
||||
if companyNameObj, ok := wordsResult["单位名称"].(map[string]interface{}); ok {
|
||||
companyName = companyNameObj["words"].(string)
|
||||
}
|
||||
|
||||
unifiedSocialCode := ""
|
||||
if socialCreditCodeObj, ok := wordsResult["社会信用代码"].(map[string]interface{}); ok {
|
||||
unifiedSocialCode = socialCreditCodeObj["words"].(string)
|
||||
}
|
||||
|
||||
legalPersonName := ""
|
||||
if legalPersonObj, ok := wordsResult["法人"].(map[string]interface{}); ok {
|
||||
legalPersonName = legalPersonObj["words"].(string)
|
||||
}
|
||||
|
||||
// 提取注册资本等其他信息
|
||||
registeredCapital := ""
|
||||
if registeredCapitalObj, ok := wordsResult["注册资本"].(map[string]interface{}); ok {
|
||||
registeredCapital = registeredCapitalObj["words"].(string)
|
||||
}
|
||||
|
||||
// 提取企业地址
|
||||
address := ""
|
||||
if addressObj, ok := wordsResult["地址"].(map[string]interface{}); ok {
|
||||
address = addressObj["words"].(string)
|
||||
}
|
||||
|
||||
// 计算置信度(这里简化处理,实际应该从OCR结果中获取)
|
||||
confidence := 0.9 // 默认置信度
|
||||
|
||||
return &responses.BusinessLicenseResult{
|
||||
CompanyName: companyName,
|
||||
UnifiedSocialCode: unifiedSocialCode,
|
||||
LegalPersonName: legalPersonName,
|
||||
LegalPersonID: "", // 营业执照上没有法人身份证号
|
||||
RegisteredCapital: registeredCapital,
|
||||
Address: address,
|
||||
Confidence: confidence,
|
||||
ProcessedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// parseIDCardResult 解析身份证识别结果
|
||||
func (s *BaiduOCRService) parseIDCardResult(result map[string]interface{}, side string) *responses.IDCardResult {
|
||||
wordsResult := result["words_result"].(map[string]interface{})
|
||||
|
||||
idCardResult := &responses.IDCardResult{
|
||||
Side: side,
|
||||
Confidence: s.extractConfidence(result),
|
||||
}
|
||||
|
||||
if side == "front" {
|
||||
if name, ok := wordsResult["姓名"]; ok {
|
||||
if word, ok := name.(map[string]interface{}); ok {
|
||||
idCardResult.Name = word["words"].(string)
|
||||
}
|
||||
}
|
||||
if gender, ok := wordsResult["性别"]; ok {
|
||||
if word, ok := gender.(map[string]interface{}); ok {
|
||||
idCardResult.Gender = word["words"].(string)
|
||||
}
|
||||
}
|
||||
if nation, ok := wordsResult["民族"]; ok {
|
||||
if word, ok := nation.(map[string]interface{}); ok {
|
||||
idCardResult.Nation = word["words"].(string)
|
||||
}
|
||||
}
|
||||
if birthday, ok := wordsResult["出生"]; ok {
|
||||
if word, ok := birthday.(map[string]interface{}); ok {
|
||||
idCardResult.Birthday = word["words"].(string)
|
||||
}
|
||||
}
|
||||
if address, ok := wordsResult["住址"]; ok {
|
||||
if word, ok := address.(map[string]interface{}); ok {
|
||||
idCardResult.Address = word["words"].(string)
|
||||
}
|
||||
}
|
||||
if idNumber, ok := wordsResult["公民身份号码"]; ok {
|
||||
if word, ok := idNumber.(map[string]interface{}); ok {
|
||||
idCardResult.IDCardNumber = word["words"].(string)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if issuingAgency, ok := wordsResult["签发机关"]; ok {
|
||||
if word, ok := issuingAgency.(map[string]interface{}); ok {
|
||||
idCardResult.IssuingAgency = word["words"].(string)
|
||||
}
|
||||
}
|
||||
if validPeriod, ok := wordsResult["有效期限"]; ok {
|
||||
if word, ok := validPeriod.(map[string]interface{}); ok {
|
||||
idCardResult.ValidPeriod = word["words"].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return idCardResult
|
||||
}
|
||||
|
||||
// parseGeneralTextResult 解析通用文字识别结果
|
||||
func (s *BaiduOCRService) parseGeneralTextResult(result map[string]interface{}) *responses.GeneralTextResult {
|
||||
wordsResult := result["words_result"].([]interface{})
|
||||
|
||||
textResult := &responses.GeneralTextResult{
|
||||
Confidence: s.extractConfidence(result),
|
||||
Words: make([]responses.TextLine, 0, len(wordsResult)),
|
||||
}
|
||||
|
||||
for _, word := range wordsResult {
|
||||
if wordMap, ok := word.(map[string]interface{}); ok {
|
||||
line := responses.TextLine{
|
||||
Text: wordMap["words"].(string),
|
||||
Confidence: 1.0, // 百度返回的通用文字识别没有单独置信度
|
||||
}
|
||||
textResult.Words = append(textResult.Words, line)
|
||||
}
|
||||
}
|
||||
|
||||
return textResult
|
||||
}
|
||||
|
||||
// extractConfidence 提取置信度
|
||||
func (s *BaiduOCRService) extractConfidence(result map[string]interface{}) float64 {
|
||||
if confidence, ok := result["confidence"].(float64); ok {
|
||||
return confidence
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// extractWords 提取识别的文字
|
||||
func (s *BaiduOCRService) extractWords(result map[string]interface{}) []string {
|
||||
words := make([]string, 0)
|
||||
|
||||
if wordsResult, ok := result["words_result"]; ok {
|
||||
switch v := wordsResult.(type) {
|
||||
case map[string]interface{}:
|
||||
// 营业执照等结构化文档
|
||||
for _, word := range v {
|
||||
if wordMap, ok := word.(map[string]interface{}); ok {
|
||||
if wordsStr, ok := wordMap["words"].(string); ok {
|
||||
words = append(words, wordsStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
// 通用文字识别
|
||||
for _, word := range v {
|
||||
if wordMap, ok := word.(map[string]interface{}); ok {
|
||||
if wordsStr, ok := wordMap["words"].(string); ok {
|
||||
words = append(words, wordsStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
|
||||
// downloadImage 下载图片
|
||||
func (s *BaiduOCRService) downloadImage(ctx context.Context, imageURL string) ([]byte, error) {
|
||||
// 创建HTTP客户端
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// 创建请求
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载图片失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("下载图片失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 读取响应内容
|
||||
imageBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取图片内容失败: %w", err)
|
||||
}
|
||||
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// ValidateBusinessLicense 验证营业执照识别结果
|
||||
func (s *BaiduOCRService) ValidateBusinessLicense(result *responses.BusinessLicenseResult) error {
|
||||
if result.Confidence < 0.8 {
|
||||
return fmt.Errorf("识别置信度过低: %.2f", result.Confidence)
|
||||
}
|
||||
if result.CompanyName == "" {
|
||||
return fmt.Errorf("未能识别公司名称")
|
||||
}
|
||||
if result.LegalPersonName == "" {
|
||||
return fmt.Errorf("未能识别法定代表人")
|
||||
}
|
||||
if result.UnifiedSocialCode == "" {
|
||||
return fmt.Errorf("未能识别统一社会信用代码")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateIDCard 验证身份证识别结果
|
||||
func (s *BaiduOCRService) ValidateIDCard(result *responses.IDCardResult) error {
|
||||
if result.Confidence < 0.8 {
|
||||
return fmt.Errorf("识别置信度过低: %.2f", result.Confidence)
|
||||
}
|
||||
if result.Side == "front" {
|
||||
if result.Name == "" {
|
||||
return fmt.Errorf("未能识别姓名")
|
||||
}
|
||||
if result.IDCardNumber == "" {
|
||||
return fmt.Errorf("未能识别身份证号码")
|
||||
}
|
||||
} else {
|
||||
if result.IssuingAgency == "" {
|
||||
return fmt.Errorf("未能识别签发机关")
|
||||
}
|
||||
if result.ValidPeriod == "" {
|
||||
return fmt.Errorf("未能识别有效期限")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
164
internal/infrastructure/external/pdfgen/pdfgen_service.go
vendored
Normal file
164
internal/infrastructure/external/pdfgen/pdfgen_service.go
vendored
Normal file
@@ -0,0 +1,164 @@
|
||||
package pdfgen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PDFGenService PDF生成服务客户端
|
||||
type PDFGenService struct {
|
||||
baseURL string
|
||||
apiPath string
|
||||
logger *zap.Logger
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewPDFGenService 创建PDF生成服务客户端
|
||||
func NewPDFGenService(cfg *config.Config, logger *zap.Logger) *PDFGenService {
|
||||
// 根据环境选择服务地址
|
||||
var baseURL string
|
||||
if cfg.App.IsProduction() {
|
||||
baseURL = cfg.PDFGen.ProductionURL
|
||||
} else {
|
||||
baseURL = cfg.PDFGen.DevelopmentURL
|
||||
}
|
||||
|
||||
// 如果配置为空,使用默认值
|
||||
if baseURL == "" {
|
||||
if cfg.App.IsProduction() {
|
||||
baseURL = "http://localhost:15990"
|
||||
} else {
|
||||
baseURL = "http://101.43.41.217:15990"
|
||||
}
|
||||
}
|
||||
|
||||
// 获取API路径,如果为空使用默认值
|
||||
apiPath := cfg.PDFGen.APIPath
|
||||
if apiPath == "" {
|
||||
apiPath = "/api/v1/generate/guangzhou"
|
||||
}
|
||||
|
||||
// 获取超时时间,如果为0使用默认值
|
||||
timeout := cfg.PDFGen.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 120 * time.Second
|
||||
}
|
||||
|
||||
logger.Info("PDF生成服务已初始化",
|
||||
zap.String("base_url", baseURL),
|
||||
zap.String("api_path", apiPath),
|
||||
zap.Duration("timeout", timeout),
|
||||
)
|
||||
|
||||
return &PDFGenService{
|
||||
baseURL: baseURL,
|
||||
apiPath: apiPath,
|
||||
logger: logger,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil, // 不使用任何代理
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GeneratePDFRequest PDF生成请求
|
||||
type GeneratePDFRequest struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
ReportNumber string `json:"report_number,omitempty"`
|
||||
GenerateTime string `json:"generate_time,omitempty"`
|
||||
}
|
||||
|
||||
// GeneratePDFResponse PDF生成响应
|
||||
type GeneratePDFResponse struct {
|
||||
PDFBytes []byte
|
||||
FileName string
|
||||
}
|
||||
|
||||
// GenerateGuangzhouPDF 生成广州大数据租赁风险PDF报告
|
||||
func (s *PDFGenService) GenerateGuangzhouPDF(ctx context.Context, req *GeneratePDFRequest) (*GeneratePDFResponse, error) {
|
||||
// 构建请求体
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建请求URL
|
||||
url := fmt.Sprintf("%s%s", s.baseURL, s.apiPath)
|
||||
|
||||
// 创建HTTP请求
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// 发送请求
|
||||
s.logger.Info("开始调用PDF生成服务",
|
||||
zap.String("url", url),
|
||||
zap.Int("data_count", len(req.Data)),
|
||||
zap.ByteString("reqBody", reqBody),
|
||||
)
|
||||
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
s.logger.Error("调用PDF生成服务失败",
|
||||
zap.String("url", url),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("调用PDF生成服务失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查HTTP状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 尝试读取错误信息
|
||||
errorBody, _ := io.ReadAll(resp.Body)
|
||||
s.logger.Error("PDF生成服务返回错误",
|
||||
zap.String("url", url),
|
||||
zap.Int("status_code", resp.StatusCode),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.String("error_body", string(errorBody)),
|
||||
)
|
||||
return nil, fmt.Errorf("PDF生成失败,状态码: %d, 错误: %s", resp.StatusCode, string(errorBody))
|
||||
}
|
||||
|
||||
// 读取PDF文件
|
||||
pdfBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取PDF文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 生成文件名
|
||||
fileName := "大数据租赁风险报告.pdf"
|
||||
if req.ReportNumber != "" {
|
||||
fileName = fmt.Sprintf("%s.pdf", req.ReportNumber)
|
||||
}
|
||||
|
||||
s.logger.Info("PDF生成成功",
|
||||
zap.String("url", url),
|
||||
zap.String("file_name", fileName),
|
||||
zap.Int("file_size", len(pdfBytes)),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
)
|
||||
|
||||
return &GeneratePDFResponse{
|
||||
PDFBytes: pdfBytes,
|
||||
FileName: fileName,
|
||||
}, nil
|
||||
}
|
||||
47
internal/infrastructure/external/shujubao/crypto.go
vendored
Normal file
47
internal/infrastructure/external/shujubao/crypto.go
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
package shujubao
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SignMethod 签名方法
|
||||
type SignMethod string
|
||||
|
||||
const (
|
||||
SignMethodMD5 SignMethod = "md5"
|
||||
SignMethodHMACMD5 SignMethod = "hmac"
|
||||
)
|
||||
|
||||
// GenerateSignMD5 使用 MD5 生成签名:md5(app_secret + timestamp),32 位小写
|
||||
func GenerateSignMD5(appSecret, timestamp string) string {
|
||||
h := md5.Sum([]byte(appSecret + timestamp))
|
||||
sign := strings.ToLower(hex.EncodeToString(h[:]))
|
||||
return sign
|
||||
}
|
||||
|
||||
// GenerateSignHMAC 使用 HMAC-MD5 生成签名(仅 timestamp,兼容旧逻辑)
|
||||
func GenerateSignHMAC(appSecret, timestamp string) string {
|
||||
mac := hmac.New(md5.New, []byte(appSecret))
|
||||
mac.Write([]byte(timestamp))
|
||||
sign := strings.ToLower(hex.EncodeToString(mac.Sum(nil)))
|
||||
return sign
|
||||
}
|
||||
|
||||
// GenerateSignFromParamsMD5 根据入参生成签名:入参按 ASCII 排序组合后与 app_secret 做 MD5。
|
||||
// sortedParamStr 格式为 key1=value1&key2=value2&...(key 按字母序)。
|
||||
func GenerateSignFromParamsMD5(appSecret, sortedParamStr string) string {
|
||||
h := md5.Sum([]byte(appSecret + sortedParamStr))
|
||||
sign := strings.ToLower(hex.EncodeToString(h[:]))
|
||||
return sign
|
||||
}
|
||||
|
||||
// GenerateSignFromParamsHMAC 根据入参生成签名:入参按 ASCII 排序组合后与 app_secret 做 HMAC-MD5。
|
||||
func GenerateSignFromParamsHMAC(appSecret, sortedParamStr string) string {
|
||||
mac := hmac.New(md5.New, []byte(appSecret))
|
||||
mac.Write([]byte(sortedParamStr))
|
||||
sign := strings.ToLower(hex.EncodeToString(mac.Sum(nil)))
|
||||
return sign
|
||||
}
|
||||
135
internal/infrastructure/external/shujubao/shujubao_errors.go
vendored
Normal file
135
internal/infrastructure/external/shujubao/shujubao_errors.go
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
package shujubao
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GetQueryEmptyErrByCode 将数据宝错误码归类为“查询为空/不扣费”错误。
|
||||
// 说明:上游通常依赖 errors.Is(err, ErrQueryEmpty) 来决定是否扣费。
|
||||
func GetQueryEmptyErrByCode(code string) error {
|
||||
switch code {
|
||||
case "10001", "10006":
|
||||
return ErrQueryEmpty
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ShujubaoError 数据宝服务错误
|
||||
type ShujubaoError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error 实现 error 接口
|
||||
func (e *ShujubaoError) Error() string {
|
||||
return fmt.Sprintf("数据宝错误 [%s]: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// IsSuccess 检查是否成功
|
||||
func (e *ShujubaoError) IsSuccess() bool {
|
||||
return e.Code == "200" || e.Code == "0" || e.Code == "10000"
|
||||
}
|
||||
|
||||
// NewShujubaoError 创建新的数据宝错误
|
||||
func NewShujubaoError(code, message string) *ShujubaoError {
|
||||
return &ShujubaoError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// 数据宝全系统错误码与描述映射(Code -> Desc)
|
||||
var systemErrorCodeDesc = map[string]string{
|
||||
"10000": "成功",
|
||||
"10001": "查空",
|
||||
"10002": "查询失败",
|
||||
"10003": "系统处理异常",
|
||||
"10004": "系统处理超时",
|
||||
"10005": "服务异常",
|
||||
"10006": "查无",
|
||||
"10017": "查询失败",
|
||||
"10018": "参数错误",
|
||||
"10019": "系统异常",
|
||||
"10020": "同一参数请求次数超限",
|
||||
"99999": "其他错误",
|
||||
"999": "接口处理异常",
|
||||
"000": "key参数不能为空",
|
||||
"001": "找不到这个key",
|
||||
"002": "调用次数已用完",
|
||||
"003": "用户该接口状态不可用",
|
||||
"004": "接口信息不存在",
|
||||
"005": "你没有认证信息",
|
||||
"008": "当前接口只允许“企业认证”通过的账户进行调用,请在数据宝官网个人中心进行企业认证后再进行调用,谢谢!",
|
||||
"009": "触发风控",
|
||||
"011": "接口缺少参数",
|
||||
"012": "没有ip访问权限",
|
||||
"013": "接口模板不存在",
|
||||
"015": "该接口已下架",
|
||||
"020": "调用第三方产生异常",
|
||||
"022": "调用第三方返回的数据格式错误",
|
||||
"025": "你没有购买此接口",
|
||||
"026": "用户信息不存在",
|
||||
"027": "请求第三方地址超时,请稍后再试",
|
||||
"028": "请求第三方地址被拒绝,请稍后再试",
|
||||
"034": "签名不合法",
|
||||
"035": "请求参数加密有误",
|
||||
"036": "验签失败",
|
||||
"037": "timestamp不能为空",
|
||||
"038": "请求繁忙,请稍后联系管理员再试",
|
||||
"039": "请在个人中心接口设置加密状态",
|
||||
"040": "timestamp不合法",
|
||||
"041": "timestamp已过期",
|
||||
"042": "身份证手机号姓名银行卡等不符合规则",
|
||||
"043": "该号段不支持验证",
|
||||
"047": "请在个人中心获取密钥",
|
||||
"048": "找不到这个secretKey",
|
||||
"049": "用户还未申购该产品",
|
||||
"050": "请联系客服开启验签",
|
||||
"051": "超过当日调用次数",
|
||||
"052": "机房限制调用,请联系客服切换其他机房",
|
||||
"053": "系统错误",
|
||||
"054": "token无效",
|
||||
"055": "配置信息未完善,请联系数据宝工作人员",
|
||||
"056": "apiName参数不能为空",
|
||||
"057": "并发量超过限制,请联系客服",
|
||||
"058": "撞库风控预警,请联系客服",
|
||||
}
|
||||
|
||||
// GetSystemErrorDesc 根据错误码获取系统错误描述(支持带 SYSTEM_ 前缀或纯数字)
|
||||
func GetSystemErrorDesc(code string) string {
|
||||
// 去掉 SYSTEM_ 前缀
|
||||
key := code
|
||||
if len(code) > 7 && code[:7] == "SYSTEM_" {
|
||||
key = code[7:]
|
||||
}
|
||||
if desc, ok := systemErrorCodeDesc[key]; ok {
|
||||
return desc
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// NewShujubaoErrorFromCode 根据状态码创建错误
|
||||
func NewShujubaoErrorFromCode(code, message string) *ShujubaoError {
|
||||
if message != "" {
|
||||
return NewShujubaoError(code, message)
|
||||
}
|
||||
if desc := GetSystemErrorDesc(code); desc != "" {
|
||||
return NewShujubaoError(code, desc)
|
||||
}
|
||||
return NewShujubaoError(code, "未知错误")
|
||||
}
|
||||
|
||||
// IsShujubaoError 检查是否是数据宝错误
|
||||
func IsShujubaoError(err error) bool {
|
||||
_, ok := err.(*ShujubaoError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetShujubaoError 获取数据宝错误
|
||||
func GetShujubaoError(err error) *ShujubaoError {
|
||||
if shujubaoErr, ok := err.(*ShujubaoError); ok {
|
||||
return shujubaoErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
66
internal/infrastructure/external/shujubao/shujubao_factory.go
vendored
Normal file
66
internal/infrastructure/external/shujubao/shujubao_factory.go
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
package shujubao
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewShujubaoServiceWithConfig 使用配置创建数据宝服务
|
||||
func NewShujubaoServiceWithConfig(cfg *config.Config) (*ShujubaoService, error) {
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.Shujubao.Logging.Enabled,
|
||||
LogDir: cfg.Shujubao.Logging.LogDir,
|
||||
ServiceName: "shujubao",
|
||||
UseDaily: cfg.Shujubao.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.Shujubao.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
for k, v := range cfg.Shujubao.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[k] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: v.MaxSize,
|
||||
MaxBackups: v.MaxBackups,
|
||||
MaxAge: v.MaxAge,
|
||||
Compress: v.Compress,
|
||||
}
|
||||
}
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var signMethod SignMethod
|
||||
if cfg.Shujubao.SignMethod == "md5" {
|
||||
signMethod = SignMethodMD5
|
||||
} else {
|
||||
signMethod = SignMethodHMACMD5
|
||||
}
|
||||
timeout := 60 * time.Second
|
||||
if cfg.Shujubao.Timeout > 0 {
|
||||
timeout = cfg.Shujubao.Timeout
|
||||
}
|
||||
|
||||
return NewShujubaoService(
|
||||
cfg.Shujubao.URL,
|
||||
cfg.Shujubao.AppSecret,
|
||||
signMethod,
|
||||
timeout,
|
||||
logger,
|
||||
), nil
|
||||
}
|
||||
|
||||
// NewShujubaoServiceWithLogging 使用自定义日志配置创建数据宝服务
|
||||
func NewShujubaoServiceWithLogging(url, appSecret string, signMethod SignMethod, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig) (*ShujubaoService, error) {
|
||||
loggingConfig.ServiceName = "shujubao"
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewShujubaoService(url, appSecret, signMethod, timeout, logger), nil
|
||||
}
|
||||
|
||||
// NewShujubaoServiceSimple 创建无日志的数据宝服务
|
||||
func NewShujubaoServiceSimple(url, appSecret string, signMethod SignMethod, timeout time.Duration) *ShujubaoService {
|
||||
return NewShujubaoService(url, appSecret, signMethod, timeout, nil)
|
||||
}
|
||||
313
internal/infrastructure/external/shujubao/shujubao_service.go
vendored
Normal file
313
internal/infrastructure/external/shujubao/shujubao_service.go
vendored
Normal file
@@ -0,0 +1,313 @@
|
||||
package shujubao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// 错误日志中单条入参值的最大长度,避免 base64 等长内容打满日志
|
||||
maxLogParamValueLen = 300
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
ErrQueryEmpty = errors.New("查询为空")
|
||||
)
|
||||
|
||||
// truncateForLog 将字符串截断到指定长度,用于错误日志,避免 base64 等过长内容
|
||||
func truncateForLog(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return s
|
||||
}
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "...[truncated, total " + strconv.Itoa(len(s)) + " chars]"
|
||||
}
|
||||
|
||||
// paramsForLog 返回适合写入错误日志的入参副本(长字符串会被截断)
|
||||
func paramsForLog(params map[string]interface{}) map[string]interface{} {
|
||||
if params == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(params))
|
||||
for k, v := range params {
|
||||
if v == nil {
|
||||
out[k] = nil
|
||||
continue
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
out[k] = truncateForLog(val, maxLogParamValueLen)
|
||||
default:
|
||||
s := fmt.Sprint(v)
|
||||
out[k] = truncateForLog(s, maxLogParamValueLen)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ShujubaoResp 数据宝 API 通用响应(按实际文档调整)
|
||||
type ShujubaoResp struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// ShujubaoConfig 数据宝服务配置
|
||||
type ShujubaoConfig struct {
|
||||
URL string
|
||||
AppSecret string
|
||||
SignMethod SignMethod
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ShujubaoService 数据宝服务
|
||||
type ShujubaoService struct {
|
||||
config ShujubaoConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
}
|
||||
|
||||
// NewShujubaoService 创建数据宝服务实例
|
||||
func NewShujubaoService(url, appSecret string, signMethod SignMethod, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *ShujubaoService {
|
||||
if signMethod == "" {
|
||||
signMethod = SignMethodHMACMD5
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
return &ShujubaoService{
|
||||
config: ShujubaoConfig{
|
||||
URL: url,
|
||||
AppSecret: appSecret,
|
||||
SignMethod: signMethod,
|
||||
Timeout: timeout,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID 生成请求 ID
|
||||
func (s *ShujubaoService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, s.config.AppSecret)))
|
||||
return fmt.Sprintf("shujubao_%x", hash[:8])
|
||||
}
|
||||
|
||||
// buildSortedParamStr 将入参按 key 的 ASCII 排序组合为 key1=value1&key2=value2&...
|
||||
func buildSortedParamStr(params map[string]interface{}) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
v := params[k]
|
||||
var vs string
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
vs = val
|
||||
case nil:
|
||||
vs = ""
|
||||
default:
|
||||
vs = fmt.Sprint(val)
|
||||
}
|
||||
b.WriteString(k)
|
||||
b.WriteByte('=')
|
||||
b.WriteString(vs)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildFormUrlEncodedBody 按 key 的 ASCII 排序构建 application/x-www-form-urlencoded 请求体(键与值均已 URL 编码)
|
||||
func buildFormUrlEncodedBody(params map[string]interface{}) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte('&')
|
||||
}
|
||||
v := params[k]
|
||||
var vs string
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
vs = val
|
||||
case nil:
|
||||
vs = ""
|
||||
default:
|
||||
vs = fmt.Sprint(val)
|
||||
}
|
||||
b.WriteString(url.QueryEscape(k))
|
||||
b.WriteByte('=')
|
||||
b.WriteString(url.QueryEscape(vs))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// generateSign 根据入参与时间戳生成签名。入参按 ASCII 排序组合后与 app_secret 做 MD5/HMAC。
|
||||
// 对于开启了加密的接口需传 sign 与 timestamp;明文传输的接口则无需传这两个参数。
|
||||
func (s *ShujubaoService) generateSign(timestamp string, params map[string]interface{}) string {
|
||||
// 合并 timestamp 到入参后参与排序
|
||||
merged := make(map[string]interface{}, len(params)+1)
|
||||
for k, v := range params {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["timestamp"] = timestamp
|
||||
sortedStr := buildSortedParamStr(merged)
|
||||
switch s.config.SignMethod {
|
||||
case SignMethodMD5:
|
||||
return GenerateSignFromParamsMD5(s.config.AppSecret, sortedStr)
|
||||
default:
|
||||
return GenerateSignFromParamsHMAC(s.config.AppSecret, sortedStr)
|
||||
}
|
||||
}
|
||||
|
||||
// buildRequestURL 拼接接口地址得到最终请求 URL,如 https://api.chinadatapay.com/communication/personal/197
|
||||
func (s *ShujubaoService) buildRequestURL(apiPath string) string {
|
||||
base := strings.TrimSuffix(s.config.URL, "/")
|
||||
if apiPath == "" {
|
||||
return base
|
||||
}
|
||||
return base + "/" + strings.TrimPrefix(apiPath, "/")
|
||||
}
|
||||
|
||||
// CallAPI 调用数据宝 API(POST)。最终请求地址 = url + 拼接接口地址值;body 为业务参数;sign、timestamp 按原样传 header。
|
||||
func (s *ShujubaoService) CallAPI(ctx context.Context, apiPath string, params map[string]interface{}) (data interface{}, err error) {
|
||||
startTime := time.Now()
|
||||
requestID := s.generateRequestID()
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
|
||||
// 最终请求 URL = https://api.chinadatapay.com/communication + 拼接接口地址值,如 /personal/197
|
||||
requestURL := s.buildRequestURL(apiPath)
|
||||
|
||||
var transactionID string
|
||||
if id, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = id
|
||||
}
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.LogRequest(requestID, transactionID, apiPath, requestURL)
|
||||
}
|
||||
|
||||
// 使用 application/x-www-form-urlencoded,贵司接口暂不支持 JSON 入参
|
||||
formBody := buildFormUrlEncodedBody(params)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, strings.NewReader(formBody))
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, paramsForLog(params))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("timestamp", timestamp)
|
||||
req.Header.Set("sign", s.generateSign(timestamp, params))
|
||||
|
||||
client := &http.Client{Timeout: s.config.Timeout}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
if isTimeout {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err))
|
||||
} else {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, paramsForLog(params))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, paramsForLog(params))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.logger != nil {
|
||||
duration := time.Since(startTime)
|
||||
s.logger.LogResponse(requestID, transactionID, apiPath, response.StatusCode, duration)
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", response.StatusCode))
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, paramsForLog(params))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var shujubaoResp ShujubaoResp
|
||||
if err := json.Unmarshal(respBody, &shujubaoResp); err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, paramsForLog(params))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
code := shujubaoResp.Code
|
||||
|
||||
// 成功码只有这三类:其它 code 都走统一错误映射返回
|
||||
if code != "10000" && code != "200" && code != "0" {
|
||||
shujubaoErr := NewShujubaoErrorFromCode(code, shujubaoResp.Message)
|
||||
if queryEmptyErr := GetQueryEmptyErrByCode(code); queryEmptyErr != nil {
|
||||
err = errors.Join(queryEmptyErr, shujubaoErr)
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, paramsForLog(params))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, shujubaoErr, paramsForLog(params))
|
||||
}
|
||||
return nil, errors.Join(ErrDatasource, shujubaoErr)
|
||||
}
|
||||
|
||||
return shujubaoResp.Data, nil
|
||||
}
|
||||
199
internal/infrastructure/external/shumai/crypto.go
vendored
Normal file
199
internal/infrastructure/external/shumai/crypto.go
vendored
Normal file
@@ -0,0 +1,199 @@
|
||||
package shumai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SignMethod 签名方法
|
||||
type SignMethod string
|
||||
|
||||
const (
|
||||
SignMethodMD5 SignMethod = "md5"
|
||||
SignMethodHMACMD5 SignMethod = "hmac"
|
||||
)
|
||||
|
||||
// GenerateSignForm 生成表单接口签名(appid & timestamp & app_security)
|
||||
// 拼接规则:appid + "&" + timestamp + "&" + app_security,对拼接串做 MD5,32 位小写十六进制;
|
||||
// 不足 32 位左侧补 0。
|
||||
func GenerateSignForm(appid, timestamp, appSecret string) string {
|
||||
str := appid + "&" + timestamp + "&" + appSecret
|
||||
hash := md5.Sum([]byte(str))
|
||||
sign := strings.ToLower(hex.EncodeToString(hash[:]))
|
||||
if n := 32 - len(sign); n > 0 {
|
||||
sign = strings.Repeat("0", n) + sign
|
||||
}
|
||||
return sign
|
||||
}
|
||||
|
||||
// app_secret: "BnJWo61hUgNEa5fqBCueiT1IZ1e0DxPU"
|
||||
|
||||
// Encrypt 使用 AES/ECB/PKCS5Padding 加密数据
|
||||
// 加密算法:AES,工作模式:ECB(无初始向量),填充方式:PKCS5Padding
|
||||
// 加密 key 是服务商分配的 app_security,AES 加密之后再进行 base64 编码
|
||||
func Encrypt(data, appSecurity string) (string, error) {
|
||||
key := prepareAESKey([]byte(appSecurity))
|
||||
ciphertext, err := aesEncryptECB([]byte(data), key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt 解密 base64 编码的 AES/ECB/PKCS5Padding 加密数据
|
||||
func Decrypt(encodedData, appSecurity string) ([]byte, error) {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encodedData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := prepareAESKey([]byte(appSecurity))
|
||||
plaintext, err := aesDecryptECB(ciphertext, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// prepareAESKey 准备 AES 密钥,确保长度为 16/24/32 字节
|
||||
// 如果 key 长度不足,用 0 填充;如果过长,截取前 32 字节
|
||||
func prepareAESKey(key []byte) []byte {
|
||||
keyLen := len(key)
|
||||
if keyLen == 16 || keyLen == 24 || keyLen == 32 {
|
||||
return key
|
||||
}
|
||||
if keyLen < 16 {
|
||||
// 不足 16 字节,用 0 填充到 16 字节(AES-128)
|
||||
padded := make([]byte, 16)
|
||||
copy(padded, key)
|
||||
return padded
|
||||
}
|
||||
if keyLen < 24 {
|
||||
// 不足 24 字节,用 0 填充到 24 字节(AES-192)
|
||||
padded := make([]byte, 24)
|
||||
copy(padded, key)
|
||||
return padded
|
||||
}
|
||||
if keyLen < 32 {
|
||||
// 不足 32 字节,用 0 填充到 32 字节(AES-256)
|
||||
padded := make([]byte, 32)
|
||||
copy(padded, key)
|
||||
return padded
|
||||
}
|
||||
// 超过 32 字节,截取前 32 字节(AES-256)
|
||||
return key[:32]
|
||||
}
|
||||
|
||||
// aesEncryptECB 使用 AES ECB 模式加密,PKCS5 填充
|
||||
func aesEncryptECB(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paddedPlaintext := pkcs5Padding(plaintext, block.BlockSize())
|
||||
ciphertext := make([]byte, len(paddedPlaintext))
|
||||
mode := newECBEncrypter(block)
|
||||
mode.CryptBlocks(ciphertext, paddedPlaintext)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// aesDecryptECB 使用 AES ECB 模式解密,PKCS5 去填充
|
||||
func aesDecryptECB(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ciphertext)%block.BlockSize() != 0 {
|
||||
return nil, errors.New("ciphertext length is not a multiple of block size")
|
||||
}
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
mode := newECBDecrypter(block)
|
||||
mode.CryptBlocks(plaintext, ciphertext)
|
||||
return pkcs5Unpadding(plaintext), nil
|
||||
}
|
||||
|
||||
// pkcs5Padding PKCS5 填充
|
||||
func pkcs5Padding(src []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(src)%blockSize
|
||||
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(src, padtext...)
|
||||
}
|
||||
|
||||
// pkcs5Unpadding 去除 PKCS5 填充
|
||||
func pkcs5Unpadding(src []byte) []byte {
|
||||
length := len(src)
|
||||
if length == 0 {
|
||||
return src
|
||||
}
|
||||
unpadding := int(src[length-1])
|
||||
if unpadding > length {
|
||||
return src
|
||||
}
|
||||
return src[:length-unpadding]
|
||||
}
|
||||
|
||||
// ECB 模式加密/解密实现
|
||||
type ecb struct {
|
||||
b cipher.Block
|
||||
blockSize int
|
||||
}
|
||||
|
||||
func newECB(b cipher.Block) *ecb {
|
||||
return &ecb{
|
||||
b: b,
|
||||
blockSize: b.BlockSize(),
|
||||
}
|
||||
}
|
||||
|
||||
type ecbEncrypter ecb
|
||||
|
||||
func newECBEncrypter(b cipher.Block) cipher.BlockMode {
|
||||
return (*ecbEncrypter)(newECB(b))
|
||||
}
|
||||
|
||||
func (x *ecbEncrypter) BlockSize() int {
|
||||
return x.blockSize
|
||||
}
|
||||
|
||||
func (x *ecbEncrypter) CryptBlocks(dst, src []byte) {
|
||||
if len(src)%x.blockSize != 0 {
|
||||
panic("crypto/cipher: input not full blocks")
|
||||
}
|
||||
if len(dst) < len(src) {
|
||||
panic("crypto/cipher: output smaller than input")
|
||||
}
|
||||
for len(src) > 0 {
|
||||
x.b.Encrypt(dst, src[:x.blockSize])
|
||||
src = src[x.blockSize:]
|
||||
dst = dst[x.blockSize:]
|
||||
}
|
||||
}
|
||||
|
||||
type ecbDecrypter ecb
|
||||
|
||||
func newECBDecrypter(b cipher.Block) cipher.BlockMode {
|
||||
return (*ecbDecrypter)(newECB(b))
|
||||
}
|
||||
|
||||
func (x *ecbDecrypter) BlockSize() int {
|
||||
return x.blockSize
|
||||
}
|
||||
|
||||
func (x *ecbDecrypter) CryptBlocks(dst, src []byte) {
|
||||
if len(src)%x.blockSize != 0 {
|
||||
panic("crypto/cipher: input not full blocks")
|
||||
}
|
||||
if len(dst) < len(src) {
|
||||
panic("crypto/cipher: output smaller than input")
|
||||
}
|
||||
for len(src) > 0 {
|
||||
x.b.Decrypt(dst, src[:x.blockSize])
|
||||
src = src[x.blockSize:]
|
||||
dst = dst[x.blockSize:]
|
||||
}
|
||||
}
|
||||
108
internal/infrastructure/external/shumai/shumai_errors.go
vendored
Normal file
108
internal/infrastructure/external/shumai/shumai_errors.go
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
package shumai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ShumaiError 数脉服务错误
|
||||
type ShumaiError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error 实现 error 接口
|
||||
func (e *ShumaiError) Error() string {
|
||||
return fmt.Sprintf("数脉错误 [%s]: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// IsSuccess 是否成功
|
||||
func (e *ShumaiError) IsSuccess() bool {
|
||||
return e.Code == "0" || e.Code == "200"
|
||||
}
|
||||
|
||||
// IsNoRecord 是否查无记录
|
||||
func (e *ShumaiError) IsNoRecord() bool {
|
||||
return e.Code == "404"
|
||||
}
|
||||
|
||||
// IsParamError 是否参数错误
|
||||
func (e *ShumaiError) IsParamError() bool {
|
||||
return e.Code == "400"
|
||||
}
|
||||
|
||||
// IsAuthError 是否认证错误
|
||||
func (e *ShumaiError) IsAuthError() bool {
|
||||
return e.Code == "601" || e.Code == "602"
|
||||
}
|
||||
|
||||
// IsSystemError 是否系统错误
|
||||
func (e *ShumaiError) IsSystemError() bool {
|
||||
return e.Code == "500" || e.Code == "501"
|
||||
}
|
||||
|
||||
// 预定义错误
|
||||
var (
|
||||
ErrSuccess = &ShumaiError{Code: "200", Message: "成功"}
|
||||
ErrParamError = &ShumaiError{Code: "400", Message: "参数错误"}
|
||||
ErrNoRecord = &ShumaiError{Code: "404", Message: "请求资源不存在"}
|
||||
ErrSystemError = &ShumaiError{Code: "500", Message: "系统内部错误,请联系服务商"}
|
||||
ErrThirdPartyError = &ShumaiError{Code: "501", Message: "第三方服务异常"}
|
||||
ErrNoPermission = &ShumaiError{Code: "601", Message: "服务商未开通接口权限"}
|
||||
ErrAccountDisabled = &ShumaiError{Code: "602", Message: "账号停用"}
|
||||
ErrInsufficientBalance = &ShumaiError{Code: "603", Message: "余额不足请充值"}
|
||||
ErrInterfaceDisabled = &ShumaiError{Code: "604", Message: "接口停用"}
|
||||
ErrInsufficientQuota = &ShumaiError{Code: "605", Message: "次数不足,请购买套餐"}
|
||||
ErrRateLimitExceeded = &ShumaiError{Code: "606", Message: "调用超限,请联系服务商"}
|
||||
ErrOther = &ShumaiError{Code: "1001", Message: "其他,以实际返回为准"}
|
||||
)
|
||||
|
||||
// NewShumaiError 创建数脉错误
|
||||
func NewShumaiError(code, message string) *ShumaiError {
|
||||
return &ShumaiError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// NewShumaiErrorFromCode 根据状态码创建错误
|
||||
func NewShumaiErrorFromCode(code string) *ShumaiError {
|
||||
switch code {
|
||||
case "0", "200":
|
||||
return ErrSuccess
|
||||
case "400":
|
||||
return ErrParamError
|
||||
case "404":
|
||||
return ErrNoRecord
|
||||
case "500":
|
||||
return ErrSystemError
|
||||
case "501":
|
||||
return ErrThirdPartyError
|
||||
case "601":
|
||||
return ErrNoPermission
|
||||
case "602":
|
||||
return ErrAccountDisabled
|
||||
case "603":
|
||||
return ErrInsufficientBalance
|
||||
case "604":
|
||||
return ErrInterfaceDisabled
|
||||
case "605":
|
||||
return ErrInsufficientQuota
|
||||
case "606":
|
||||
return ErrRateLimitExceeded
|
||||
case "1001":
|
||||
return ErrOther
|
||||
default:
|
||||
return &ShumaiError{Code: code, Message: "未知错误"}
|
||||
}
|
||||
}
|
||||
|
||||
// IsShumaiError 是否为数脉错误
|
||||
func IsShumaiError(err error) bool {
|
||||
_, ok := err.(*ShumaiError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetShumaiError 获取数脉错误
|
||||
func GetShumaiError(err error) *ShumaiError {
|
||||
if e, ok := err.(*ShumaiError); ok {
|
||||
return e
|
||||
}
|
||||
return nil
|
||||
}
|
||||
69
internal/infrastructure/external/shumai/shumai_factory.go
vendored
Normal file
69
internal/infrastructure/external/shumai/shumai_factory.go
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
package shumai
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewShumaiServiceWithConfig 使用 config 创建数脉服务
|
||||
func NewShumaiServiceWithConfig(cfg *config.Config) (*ShumaiService, error) {
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.Shumai.Logging.Enabled,
|
||||
LogDir: cfg.Shumai.Logging.LogDir,
|
||||
ServiceName: "shumai",
|
||||
UseDaily: cfg.Shumai.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.Shumai.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
for k, v := range cfg.Shumai.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[k] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: v.MaxSize,
|
||||
MaxBackups: v.MaxBackups,
|
||||
MaxAge: v.MaxAge,
|
||||
Compress: v.Compress,
|
||||
}
|
||||
}
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var signMethod SignMethod
|
||||
if cfg.Shumai.SignMethod == "md5" {
|
||||
signMethod = SignMethodMD5
|
||||
} else {
|
||||
signMethod = SignMethodHMACMD5
|
||||
}
|
||||
timeout := 60 * time.Second
|
||||
if cfg.Shumai.Timeout > 0 {
|
||||
timeout = cfg.Shumai.Timeout
|
||||
}
|
||||
|
||||
return NewShumaiService(
|
||||
cfg.Shumai.URL,
|
||||
cfg.Shumai.AppID,
|
||||
cfg.Shumai.AppSecret,
|
||||
signMethod,
|
||||
timeout,
|
||||
logger,
|
||||
cfg.Shumai.AppID2, // 走政务接口使用这个
|
||||
cfg.Shumai.AppSecret2, // 走政务接口使用这个
|
||||
), nil
|
||||
}
|
||||
|
||||
// NewShumaiServiceWithLogging 使用自定义日志配置创建数脉服务
|
||||
func NewShumaiServiceWithLogging(url, appID, appSecret string, signMethod SignMethod, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig, appID2, appSecret2 string) (*ShumaiService, error) {
|
||||
loggingConfig.ServiceName = "shumai"
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewShumaiService(url, appID, appSecret, signMethod, timeout, logger, appID2, appSecret2), nil
|
||||
}
|
||||
|
||||
// NewShumaiServiceSimple 创建无数脉日志的数脉服务
|
||||
func NewShumaiServiceSimple(url, appID, appSecret string, signMethod SignMethod, timeout time.Duration, appID2, appSecret2 string) *ShumaiService {
|
||||
return NewShumaiService(url, appID, appSecret, signMethod, timeout, nil, appID2, appSecret2)
|
||||
}
|
||||
360
internal/infrastructure/external/shumai/shumai_service.go
vendored
Normal file
360
internal/infrastructure/external/shumai/shumai_service.go
vendored
Normal file
@@ -0,0 +1,360 @@
|
||||
package shumai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// 错误日志中单条入参值的最大长度,避免 base64 等长内容打满日志
|
||||
maxLogParamValueLen = 300
|
||||
// 错误日志中 response_body 的最大长度
|
||||
maxLogResponseBodyLen = 500
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
ErrNotFound = errors.New("查询为空")
|
||||
)
|
||||
|
||||
// truncateForLog 将字符串截断到指定长度,用于错误日志,避免 base64 等过长内容
|
||||
func truncateForLog(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return s
|
||||
}
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "...[truncated, total " + strconv.Itoa(len(s)) + " chars]"
|
||||
}
|
||||
|
||||
// requestParamsForLog 返回适合写入错误日志的入参副本(长字符串会被截断)
|
||||
func requestParamsForLog(reqFormData map[string]interface{}) map[string]interface{} {
|
||||
if reqFormData == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]interface{}, len(reqFormData))
|
||||
for k, v := range reqFormData {
|
||||
if v == nil {
|
||||
out[k] = nil
|
||||
continue
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
out[k] = truncateForLog(val, maxLogParamValueLen)
|
||||
default:
|
||||
s := fmt.Sprint(v)
|
||||
out[k] = truncateForLog(s, maxLogParamValueLen)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ShumaiResponse 数脉 API 通用响应(占位,按实际文档调整)
|
||||
type ShumaiResponse struct {
|
||||
Code int `json:"code"` // 状态码
|
||||
Msg string `json:"msg"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// ShumaiConfig 数脉服务配置
|
||||
type ShumaiConfig struct {
|
||||
URL string
|
||||
AppID string
|
||||
AppSecret string
|
||||
AppID2 string // 走政务接口使用这个
|
||||
AppSecret2 string // 走政务接口使用这个
|
||||
SignMethod SignMethod
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ShumaiService 数脉服务
|
||||
type ShumaiService struct {
|
||||
config ShumaiConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
useGovernment bool // 是否使用政务接口(app_id2)
|
||||
}
|
||||
|
||||
// NewShumaiService 创建数脉服务实例
|
||||
// appID2 和 appSecret2 用于政务接口,如果为空则只使用普通接口
|
||||
func NewShumaiService(url, appID, appSecret string, signMethod SignMethod, timeout time.Duration, logger *external_logger.ExternalServiceLogger, appID2, appSecret2 string) *ShumaiService {
|
||||
if signMethod == "" {
|
||||
signMethod = SignMethodHMACMD5
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
return &ShumaiService{
|
||||
config: ShumaiConfig{
|
||||
URL: url,
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
AppID2: appID2, // 走政务接口使用这个
|
||||
AppSecret2: appSecret2, // 走政务接口使用这个
|
||||
SignMethod: signMethod,
|
||||
Timeout: timeout,
|
||||
},
|
||||
logger: logger,
|
||||
useGovernment: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ShumaiService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
appID := s.getCurrentAppID()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, appID)))
|
||||
return fmt.Sprintf("shumai_%x", hash[:8])
|
||||
}
|
||||
|
||||
// generateRequestIDWithAppID 根据指定的 AppID 生成请求ID(用于不依赖全局状态的情况)
|
||||
func (s *ShumaiService) generateRequestIDWithAppID(appID string) string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, appID)))
|
||||
return fmt.Sprintf("shumai_%x", hash[:8])
|
||||
}
|
||||
|
||||
// getCurrentAppID 获取当前使用的 AppID
|
||||
func (s *ShumaiService) getCurrentAppID() string {
|
||||
if s.useGovernment && s.config.AppID2 != "" {
|
||||
return s.config.AppID2
|
||||
}
|
||||
return s.config.AppID
|
||||
}
|
||||
|
||||
// getCurrentAppSecret 获取当前使用的 AppSecret
|
||||
func (s *ShumaiService) getCurrentAppSecret() string {
|
||||
if s.useGovernment && s.config.AppSecret2 != "" {
|
||||
return s.config.AppSecret2
|
||||
}
|
||||
return s.config.AppSecret
|
||||
}
|
||||
|
||||
// UseGovernment 切换到政务接口(使用 app_id2 和 app_secret2)
|
||||
func (s *ShumaiService) UseGovernment() {
|
||||
s.useGovernment = true
|
||||
}
|
||||
|
||||
// UseNormal 切换到普通接口(使用 app_id 和 app_secret)
|
||||
func (s *ShumaiService) UseNormal() {
|
||||
s.useGovernment = false
|
||||
}
|
||||
|
||||
// IsUsingGovernment 检查是否正在使用政务接口
|
||||
func (s *ShumaiService) IsUsingGovernment() bool {
|
||||
return s.useGovernment
|
||||
}
|
||||
|
||||
// GetConfig 返回当前配置
|
||||
func (s *ShumaiService) GetConfig() ShumaiConfig {
|
||||
return s.config
|
||||
}
|
||||
|
||||
// CallAPIForm 以表单方式调用数脉 API(application/x-www-form-urlencoded)
|
||||
// 在方法内部将 reqFormData 转为表单:先写入业务参数,再追加 appid、timestamp、sign。
|
||||
// 签名算法:md5(appid×tamp&app_security),32 位小写,不足补 0。
|
||||
// useGovernment 可选参数:true 表示使用政务接口(app_id2),false 表示使用实时接口(app_id)
|
||||
// 如果未提供参数,则使用全局状态(通过 UseGovernment()/UseNormal() 设置)
|
||||
func (s *ShumaiService) CallAPIForm(ctx context.Context, apiPath string, reqFormData map[string]interface{}, useGovernment ...bool) ([]byte, error) {
|
||||
// 确定是否使用政务接口:如果提供了参数则使用参数值,否则使用全局状态
|
||||
var useGov bool
|
||||
if len(useGovernment) > 0 {
|
||||
useGov = useGovernment[0]
|
||||
} else {
|
||||
// 未提供参数时,使用全局状态以保持向后兼容
|
||||
useGov = s.useGovernment
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
|
||||
// 根据参数选择使用的 AppID 和 AppSecret,而不是依赖全局状态
|
||||
var appID, appSecret string
|
||||
if useGov && s.config.AppID2 != "" {
|
||||
appID = s.config.AppID2
|
||||
appSecret = s.config.AppSecret2
|
||||
} else {
|
||||
appID = s.config.AppID
|
||||
appSecret = s.config.AppSecret
|
||||
}
|
||||
|
||||
// 使用指定的 AppID 生成请求ID
|
||||
requestID := s.generateRequestIDWithAppID(appID)
|
||||
sign := GenerateSignForm(appID, timestamp, appSecret)
|
||||
|
||||
var transactionID string
|
||||
if id, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = id
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("appid", appID)
|
||||
form.Set("timestamp", timestamp)
|
||||
form.Set("sign", sign)
|
||||
for k, v := range reqFormData {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
form.Set(k, fmt.Sprint(v))
|
||||
}
|
||||
body := form.Encode()
|
||||
|
||||
baseURL := strings.TrimSuffix(s.config.URL, "/")
|
||||
|
||||
reqURL := baseURL
|
||||
if apiPath != "" {
|
||||
reqURL = baseURL + "/" + strings.TrimPrefix(apiPath, "/")
|
||||
}
|
||||
if apiPath == "" {
|
||||
apiPath = "shumai_form"
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.LogRequest(requestID, transactionID, apiPath, reqURL)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, map[string]interface{}{"request_params": requestParamsForLog(reqFormData)})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: s.config.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
isTimeout := ctx.Err() == context.DeadlineExceeded
|
||||
if !isTimeout {
|
||||
if te, ok := err.(interface{ Timeout() bool }); ok && te.Timeout() {
|
||||
isTimeout = true
|
||||
}
|
||||
}
|
||||
if !isTimeout {
|
||||
es := err.Error()
|
||||
if strings.Contains(es, "deadline exceeded") || strings.Contains(es, "timeout") || strings.Contains(es, "canceled") {
|
||||
isTimeout = true
|
||||
}
|
||||
}
|
||||
if isTimeout {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err))
|
||||
} else {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, map[string]interface{}{"request_params": requestParamsForLog(reqFormData)})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
duration := time.Since(startTime)
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, map[string]interface{}{"request_params": requestParamsForLog(reqFormData)})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP %d", resp.StatusCode))
|
||||
if s.logger != nil {
|
||||
errorPayload := map[string]interface{}{
|
||||
"request_params": requestParamsForLog(reqFormData),
|
||||
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
|
||||
}
|
||||
s.logger.LogError(requestID, transactionID, apiPath, err, errorPayload)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.LogResponse(requestID, transactionID, apiPath, resp.StatusCode, duration)
|
||||
}
|
||||
|
||||
var shumaiResp ShumaiResponse
|
||||
if err := json.Unmarshal(raw, &shumaiResp); err != nil {
|
||||
parseErr := errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, parseErr, map[string]interface{}{
|
||||
"request_params": requestParamsForLog(reqFormData),
|
||||
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
|
||||
})
|
||||
}
|
||||
return nil, parseErr
|
||||
}
|
||||
|
||||
codeStr := strconv.Itoa(shumaiResp.Code)
|
||||
msg := shumaiResp.Msg
|
||||
if msg == "" {
|
||||
msg = shumaiResp.Message
|
||||
}
|
||||
|
||||
shumaiErr := NewShumaiErrorFromCode(codeStr)
|
||||
if !shumaiErr.IsSuccess() {
|
||||
if shumaiErr.Message == "未知错误" && msg != "" {
|
||||
shumaiErr = NewShumaiError(codeStr, msg)
|
||||
}
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, shumaiErr, map[string]interface{}{
|
||||
"request_params": requestParamsForLog(reqFormData),
|
||||
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
|
||||
})
|
||||
}
|
||||
if shumaiErr.IsNoRecord() {
|
||||
return nil, errors.Join(ErrNotFound, shumaiErr)
|
||||
}
|
||||
return nil, errors.Join(ErrDatasource, shumaiErr)
|
||||
}
|
||||
|
||||
if shumaiResp.Data == nil {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
|
||||
dataBytes, err := json.Marshal(shumaiResp.Data)
|
||||
if err != nil {
|
||||
marshalErr := errors.Join(ErrSystem, fmt.Errorf("data 序列化失败: %w", err))
|
||||
if s.logger != nil {
|
||||
s.logger.LogError(requestID, transactionID, apiPath, marshalErr, map[string]interface{}{
|
||||
"request_params": requestParamsForLog(reqFormData),
|
||||
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
|
||||
})
|
||||
}
|
||||
return nil, marshalErr
|
||||
}
|
||||
return dataBytes, nil
|
||||
}
|
||||
|
||||
func (s *ShumaiService) Encrypt(data string) (string, error) {
|
||||
appSecret := s.getCurrentAppSecret()
|
||||
encryptedValue, err := Encrypt(data, appSecret)
|
||||
if err != nil {
|
||||
return "", ErrSystem
|
||||
}
|
||||
return encryptedValue, nil
|
||||
}
|
||||
|
||||
func (s *ShumaiService) Decrypt(encodedData string) ([]byte, error) {
|
||||
appSecret := s.getCurrentAppSecret()
|
||||
decryptedValue, err := Decrypt(encodedData, appSecret)
|
||||
if err != nil {
|
||||
return nil, ErrSystem
|
||||
}
|
||||
return decryptedValue, nil
|
||||
}
|
||||
148
internal/infrastructure/external/sms/aliyun_sms.go
vendored
Normal file
148
internal/infrastructure/external/sms/aliyun_sms.go
vendored
Normal file
@@ -0,0 +1,148 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/aliyun/alibaba-cloud-sdk-go/services/dysmsapi"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"hyapi-server/internal/config"
|
||||
)
|
||||
|
||||
// AliSMSService 阿里云短信服务
|
||||
type AliSMSService struct {
|
||||
client *dysmsapi.Client
|
||||
config config.SMSConfig
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAliSMSService 创建阿里云短信服务
|
||||
func NewAliSMSService(cfg config.SMSConfig, logger *zap.Logger) (*AliSMSService, error) {
|
||||
client, err := dysmsapi.NewClientWithAccessKey("cn-hangzhou", cfg.AccessKeyID, cfg.AccessKeySecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建短信客户端失败: %w", err)
|
||||
}
|
||||
return &AliSMSService{
|
||||
client: client,
|
||||
config: cfg,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SendVerificationCode 发送验证码
|
||||
func (s *AliSMSService) SendVerificationCode(ctx context.Context, phone string, code string) error {
|
||||
request := dysmsapi.CreateSendSmsRequest()
|
||||
request.Scheme = "https"
|
||||
request.PhoneNumbers = phone
|
||||
request.SignName = s.config.SignName
|
||||
request.TemplateCode = s.config.TemplateCode
|
||||
request.TemplateParam = fmt.Sprintf(`{"code":"%s"}`, code)
|
||||
|
||||
response, err := s.client.SendSms(request)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to send SMS",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("短信发送失败: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != "OK" {
|
||||
s.logger.Error("SMS send failed",
|
||||
zap.String("phone", phone),
|
||||
zap.String("code", response.Code),
|
||||
zap.String("message", response.Message))
|
||||
return fmt.Errorf("短信发送失败: %s - %s", response.Code, response.Message)
|
||||
}
|
||||
|
||||
s.logger.Info("SMS sent successfully",
|
||||
zap.String("phone", phone),
|
||||
zap.String("bizId", response.BizId))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendBalanceAlert 发送余额预警短信(低余额与欠费共用 balance_alert_template_code;模板需包含 name、time、money)
|
||||
func (s *AliSMSService) SendBalanceAlert(ctx context.Context, phone string, balance float64, threshold float64, alertType string, enterpriseName ...string) error {
|
||||
request := dysmsapi.CreateSendSmsRequest()
|
||||
request.Scheme = "https"
|
||||
request.PhoneNumbers = phone
|
||||
request.SignName = s.config.SignName
|
||||
|
||||
name := "海宇数据用户"
|
||||
if len(enterpriseName) > 0 && enterpriseName[0] != "" {
|
||||
name = enterpriseName[0]
|
||||
}
|
||||
t := time.Now().Format("2006-01-02 15:04:05")
|
||||
var money float64
|
||||
if alertType == "low_balance" {
|
||||
money = threshold
|
||||
} else {
|
||||
money = balance
|
||||
}
|
||||
|
||||
templateCode := s.config.BalanceAlertTemplateCode
|
||||
if templateCode == "" {
|
||||
templateCode = "SMS_500565339"
|
||||
}
|
||||
tp, err := json.Marshal(struct {
|
||||
Name string `json:"name"`
|
||||
Time string `json:"time"`
|
||||
Money string `json:"money"`
|
||||
}{Name: name, Time: t, Money: fmt.Sprintf("%.2f", money)})
|
||||
if err != nil {
|
||||
return fmt.Errorf("构建短信模板参数失败: %w", err)
|
||||
}
|
||||
request.TemplateCode = templateCode
|
||||
request.TemplateParam = string(tp)
|
||||
|
||||
response, err := s.client.SendSms(request)
|
||||
if err != nil {
|
||||
s.logger.Error("发送余额预警短信失败",
|
||||
zap.String("phone", phone),
|
||||
zap.String("alert_type", alertType),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("短信发送失败: %w", err)
|
||||
}
|
||||
|
||||
if response.Code != "OK" {
|
||||
s.logger.Error("余额预警短信发送失败",
|
||||
zap.String("phone", phone),
|
||||
zap.String("alert_type", alertType),
|
||||
zap.String("code", response.Code),
|
||||
zap.String("message", response.Message))
|
||||
return fmt.Errorf("短信发送失败: %s - %s", response.Code, response.Message)
|
||||
}
|
||||
|
||||
s.logger.Info("余额预警短信发送成功",
|
||||
zap.String("phone", phone),
|
||||
zap.String("alert_type", alertType),
|
||||
zap.String("bizId", response.BizId))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateCode 生成验证码
|
||||
func (s *AliSMSService) GenerateCode(length int) string {
|
||||
if length <= 0 {
|
||||
length = 6
|
||||
}
|
||||
|
||||
max := big.NewInt(int64(pow10(length)))
|
||||
n, _ := rand.Int(rand.Reader, max)
|
||||
|
||||
format := fmt.Sprintf("%%0%dd", length)
|
||||
return fmt.Sprintf(format, n.Int64())
|
||||
}
|
||||
|
||||
func pow10(n int) int {
|
||||
result := 1
|
||||
for i := 0; i < n; i++ {
|
||||
result *= 10
|
||||
}
|
||||
return result
|
||||
}
|
||||
48
internal/infrastructure/external/sms/mock_sms.go
vendored
Normal file
48
internal/infrastructure/external/sms/mock_sms.go
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// MockSMSService 模拟短信服务(用于开发和测试)
|
||||
type MockSMSService struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewMockSMSService 创建模拟短信服务
|
||||
func NewMockSMSService(logger *zap.Logger) *MockSMSService {
|
||||
return &MockSMSService{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SendVerificationCode 模拟发送验证码
|
||||
func (s *MockSMSService) SendVerificationCode(ctx context.Context, phone string, code string) error {
|
||||
s.logger.Info("Mock SMS sent",
|
||||
zap.String("phone", phone),
|
||||
zap.String("code", code))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendBalanceAlert 模拟余额预警
|
||||
func (s *MockSMSService) SendBalanceAlert(ctx context.Context, phone string, balance float64, threshold float64, alertType string, enterpriseName ...string) error {
|
||||
s.logger.Info("Mock balance alert SMS",
|
||||
zap.String("phone", phone),
|
||||
zap.Float64("balance", balance),
|
||||
zap.String("alert_type", alertType))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateCode 生成验证码
|
||||
func (s *MockSMSService) GenerateCode(length int) string {
|
||||
if length <= 0 {
|
||||
length = 6
|
||||
}
|
||||
result := ""
|
||||
for i := 0; i < length; i++ {
|
||||
result += "1"
|
||||
}
|
||||
return result
|
||||
}
|
||||
38
internal/infrastructure/external/sms/sender.go
vendored
Normal file
38
internal/infrastructure/external/sms/sender.go
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"hyapi-server/internal/config"
|
||||
)
|
||||
|
||||
// SMSSender 短信发送抽象(验证码 + 余额预警),支持阿里云与腾讯云等实现。
|
||||
type SMSSender interface {
|
||||
SendVerificationCode(ctx context.Context, phone, code string) error
|
||||
SendBalanceAlert(ctx context.Context, phone string, balance, threshold float64, alertType string, enterpriseName ...string) error
|
||||
GenerateCode(length int) string
|
||||
}
|
||||
|
||||
// NewSMSSender 根据 sms.provider 创建实现;mock_enabled 时返回模拟发送器。
|
||||
// provider 为空时默认 tencent。
|
||||
func NewSMSSender(cfg config.SMSConfig, logger *zap.Logger) (SMSSender, error) {
|
||||
if cfg.MockEnabled {
|
||||
return NewMockSMSService(logger), nil
|
||||
}
|
||||
p := strings.ToLower(strings.TrimSpace(cfg.Provider))
|
||||
if p == "" {
|
||||
p = "tencent"
|
||||
}
|
||||
switch p {
|
||||
case "tencent":
|
||||
return NewTencentSMSService(cfg, logger)
|
||||
case "aliyun", "alicloud", "ali":
|
||||
return NewAliSMSService(cfg, logger)
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的短信服务商: %s(支持 aliyun、tencent)", cfg.Provider)
|
||||
}
|
||||
}
|
||||
187
internal/infrastructure/external/sms/tencent_sms.go
vendored
Normal file
187
internal/infrastructure/external/sms/tencent_sms.go
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
sms "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"hyapi-server/internal/config"
|
||||
)
|
||||
|
||||
// TencentSMSService 腾讯云短信(与 bdqr-server 接入方式一致)
|
||||
type TencentSMSService struct {
|
||||
client *sms.Client
|
||||
cfg config.SMSConfig
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewTencentSMSService 创建腾讯云短信客户端
|
||||
func NewTencentSMSService(cfg config.SMSConfig, logger *zap.Logger) (*TencentSMSService, error) {
|
||||
tc := cfg.TencentCloud
|
||||
if tc.SecretId == "" || tc.SecretKey == "" {
|
||||
return nil, fmt.Errorf("腾讯云短信未配置 secret_id / secret_key")
|
||||
}
|
||||
credential := common.NewCredential(tc.SecretId, tc.SecretKey)
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.ReqMethod = "POST"
|
||||
cpf.HttpProfile.ReqTimeout = 10
|
||||
cpf.HttpProfile.Endpoint = "sms.tencentcloudapi.com"
|
||||
if tc.Endpoint != "" {
|
||||
cpf.HttpProfile.Endpoint = tc.Endpoint
|
||||
}
|
||||
|
||||
region := tc.Region
|
||||
if region == "" {
|
||||
region = "ap-guangzhou"
|
||||
}
|
||||
|
||||
client, err := sms.NewClient(credential, region, cpf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建腾讯云短信客户端失败: %w", err)
|
||||
}
|
||||
|
||||
return &TencentSMSService{
|
||||
client: client,
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeTencentPhone(phone string) string {
|
||||
if strings.HasPrefix(phone, "+86") {
|
||||
return phone
|
||||
}
|
||||
return "+86" + phone
|
||||
}
|
||||
|
||||
// SendVerificationCode 发送验证码(模板参数为单个验证码,与 bdqr 一致)
|
||||
func (s *TencentSMSService) SendVerificationCode(ctx context.Context, phone string, code string) error {
|
||||
tc := s.cfg.TencentCloud
|
||||
request := &sms.SendSmsRequest{}
|
||||
request.SmsSdkAppId = common.StringPtr(tc.SmsSdkAppId)
|
||||
request.SignName = common.StringPtr(tc.SignName)
|
||||
request.TemplateId = common.StringPtr(tc.TemplateID)
|
||||
request.TemplateParamSet = common.StringPtrs([]string{code})
|
||||
request.PhoneNumberSet = common.StringPtrs([]string{normalizeTencentPhone(phone)})
|
||||
|
||||
response, err := s.client.SendSms(request)
|
||||
if err != nil {
|
||||
s.logger.Error("腾讯云短信发送失败",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("短信发送失败: %w", err)
|
||||
}
|
||||
|
||||
if response.Response == nil || len(response.Response.SendStatusSet) == 0 {
|
||||
return fmt.Errorf("腾讯云短信返回空响应")
|
||||
}
|
||||
|
||||
st := response.Response.SendStatusSet[0]
|
||||
if st.Code == nil || *st.Code != "Ok" {
|
||||
msg := ""
|
||||
if st.Message != nil {
|
||||
msg = *st.Message
|
||||
}
|
||||
s.logger.Error("腾讯云短信业务失败",
|
||||
zap.String("phone", phone),
|
||||
zap.String("message", msg))
|
||||
return fmt.Errorf("短信发送失败: %s", msg)
|
||||
}
|
||||
|
||||
s.logger.Info("腾讯云短信发送成功",
|
||||
zap.String("phone", phone),
|
||||
zap.String("serial_no", safeStrPtr(st.SerialNo)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendBalanceAlert 发送余额类预警。低余额与欠费使用不同模板(见 low_balance_template_id / arrears_template_id),
|
||||
// 若未分别配置则回退 balance_alert_template_id。除验证码外,腾讯云短信按无变量模板发送。
|
||||
func (s *TencentSMSService) SendBalanceAlert(ctx context.Context, phone string, balance float64, threshold float64, alertType string, enterpriseName ...string) error {
|
||||
tc := s.cfg.TencentCloud
|
||||
tplID := resolveTencentBalanceTemplateID(tc, alertType)
|
||||
if tplID == "" {
|
||||
return fmt.Errorf("腾讯云余额类短信模板未配置(请设置 sms.tencent_cloud.low_balance_template_id 与 arrears_template_id,或回退 balance_alert_template_id)")
|
||||
}
|
||||
|
||||
request := &sms.SendSmsRequest{}
|
||||
request.SmsSdkAppId = common.StringPtr(tc.SmsSdkAppId)
|
||||
request.SignName = common.StringPtr(tc.SignName)
|
||||
request.TemplateId = common.StringPtr(tplID)
|
||||
request.PhoneNumberSet = common.StringPtrs([]string{normalizeTencentPhone(phone)})
|
||||
|
||||
response, err := s.client.SendSms(request)
|
||||
if err != nil {
|
||||
s.logger.Error("腾讯云余额预警短信失败",
|
||||
zap.String("phone", phone),
|
||||
zap.String("alert_type", alertType),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("短信发送失败: %w", err)
|
||||
}
|
||||
|
||||
if response.Response == nil || len(response.Response.SendStatusSet) == 0 {
|
||||
return fmt.Errorf("腾讯云短信返回空响应")
|
||||
}
|
||||
|
||||
st := response.Response.SendStatusSet[0]
|
||||
if st.Code == nil || *st.Code != "Ok" {
|
||||
msg := ""
|
||||
if st.Message != nil {
|
||||
msg = *st.Message
|
||||
}
|
||||
return fmt.Errorf("短信发送失败: %s", msg)
|
||||
}
|
||||
|
||||
s.logger.Info("腾讯云余额预警短信发送成功",
|
||||
zap.String("phone", phone),
|
||||
zap.String("alert_type", alertType))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateCode 生成数字验证码
|
||||
func (s *TencentSMSService) GenerateCode(length int) string {
|
||||
if length <= 0 {
|
||||
length = 6
|
||||
}
|
||||
max := big.NewInt(int64(pow10Tencent(length)))
|
||||
n, _ := rand.Int(rand.Reader, max)
|
||||
format := fmt.Sprintf("%%0%dd", length)
|
||||
return fmt.Sprintf(format, n.Int64())
|
||||
}
|
||||
|
||||
func safeStrPtr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func pow10Tencent(n int) int {
|
||||
result := 1
|
||||
for i := 0; i < n; i++ {
|
||||
result *= 10
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveTencentBalanceTemplateID(tc config.TencentSMSConfig, alertType string) string {
|
||||
switch alertType {
|
||||
case "low_balance":
|
||||
if tc.LowBalanceTemplateID != "" {
|
||||
return tc.LowBalanceTemplateID
|
||||
}
|
||||
case "arrears":
|
||||
if tc.ArrearsTemplateID != "" {
|
||||
return tc.ArrearsTemplateID
|
||||
}
|
||||
}
|
||||
return tc.BalanceAlertTemplateID
|
||||
}
|
||||
115
internal/infrastructure/external/storage/local_file_storage_service.go
vendored
Normal file
115
internal/infrastructure/external/storage/local_file_storage_service.go
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LocalFileStorageService 本地文件存储服务
|
||||
type LocalFileStorageService struct {
|
||||
basePath string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// LocalFileStorageConfig 本地文件存储配置
|
||||
type LocalFileStorageConfig struct {
|
||||
BasePath string `yaml:"base_path"`
|
||||
}
|
||||
|
||||
// NewLocalFileStorageService 创建本地文件存储服务
|
||||
func NewLocalFileStorageService(basePath string, logger *zap.Logger) *LocalFileStorageService {
|
||||
// 确保基础路径存在
|
||||
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||||
logger.Error("创建基础存储目录失败", zap.Error(err), zap.String("path", basePath))
|
||||
}
|
||||
|
||||
return &LocalFileStorageService{
|
||||
basePath: basePath,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// StoreFile 存储文件
|
||||
func (s *LocalFileStorageService) StoreFile(ctx context.Context, file io.Reader, filename string) (string, error) {
|
||||
// 构建完整文件路径
|
||||
fullPath := filepath.Join(s.basePath, filename)
|
||||
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
s.logger.Error("创建目录失败", zap.Error(err), zap.String("dir", dir))
|
||||
return "", fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
dst, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
s.logger.Error("创建文件失败", zap.Error(err), zap.String("path", fullPath))
|
||||
return "", fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// 复制文件内容
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
s.logger.Error("写入文件失败", zap.Error(err), zap.String("path", fullPath))
|
||||
// 删除部分写入的文件
|
||||
_ = os.Remove(fullPath)
|
||||
return "", fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("文件存储成功", zap.String("path", fullPath))
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
// StoreMultipartFile 存储multipart文件
|
||||
func (s *LocalFileStorageService) StoreMultipartFile(ctx context.Context, file *multipart.FileHeader, filename string) (string, error) {
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开上传文件失败: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
return s.StoreFile(ctx, src, filename)
|
||||
}
|
||||
|
||||
// GetFileURL 获取文件URL
|
||||
func (s *LocalFileStorageService) GetFileURL(ctx context.Context, filePath string) (string, error) {
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("文件不存在: %s", filePath)
|
||||
}
|
||||
|
||||
// 返回文件路径(在实际应用中,这里应该返回可访问的URL)
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件
|
||||
func (s *LocalFileStorageService) DeleteFile(ctx context.Context, filePath string) error {
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// 文件不存在,不视为错误
|
||||
return nil
|
||||
}
|
||||
s.logger.Error("删除文件失败", zap.Error(err), zap.String("path", filePath))
|
||||
return fmt.Errorf("删除文件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("文件删除成功", zap.String("path", filePath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileReader 获取文件读取器
|
||||
func (s *LocalFileStorageService) GetFileReader(ctx context.Context, filePath string) (io.ReadCloser, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开文件失败: %w", err)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
110
internal/infrastructure/external/storage/local_file_storage_service_impl.go
vendored
Normal file
110
internal/infrastructure/external/storage/local_file_storage_service_impl.go
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LocalFileStorageServiceImpl 本地文件存储服务实现
|
||||
type LocalFileStorageServiceImpl struct {
|
||||
basePath string
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLocalFileStorageServiceImpl 创建本地文件存储服务实现
|
||||
func NewLocalFileStorageServiceImpl(basePath string, logger *zap.Logger) *LocalFileStorageServiceImpl {
|
||||
// 确保基础路径存在
|
||||
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||||
logger.Error("创建基础存储目录失败", zap.Error(err), zap.String("path", basePath))
|
||||
}
|
||||
|
||||
return &LocalFileStorageServiceImpl{
|
||||
basePath: basePath,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// StoreFile 存储文件
|
||||
func (s *LocalFileStorageServiceImpl) StoreFile(ctx context.Context, file io.Reader, filename string) (string, error) {
|
||||
// 构建完整文件路径
|
||||
fullPath := filepath.Join(s.basePath, filename)
|
||||
|
||||
// 确保目录存在
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
s.logger.Error("创建目录失败", zap.Error(err), zap.String("dir", dir))
|
||||
return "", fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建文件
|
||||
dst, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
s.logger.Error("创建文件失败", zap.Error(err), zap.String("path", fullPath))
|
||||
return "", fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// 复制文件内容
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
s.logger.Error("写入文件失败", zap.Error(err), zap.String("path", fullPath))
|
||||
// 删除部分写入的文件
|
||||
_ = os.Remove(fullPath)
|
||||
return "", fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("文件存储成功", zap.String("path", fullPath))
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
// StoreMultipartFile 存储multipart文件
|
||||
func (s *LocalFileStorageServiceImpl) StoreMultipartFile(ctx context.Context, file *multipart.FileHeader, filename string) (string, error) {
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开上传文件失败: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
return s.StoreFile(ctx, src, filename)
|
||||
}
|
||||
|
||||
// GetFileURL 获取文件URL
|
||||
func (s *LocalFileStorageServiceImpl) GetFileURL(ctx context.Context, filePath string) (string, error) {
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("文件不存在: %s", filePath)
|
||||
}
|
||||
|
||||
// 返回文件路径(在实际应用中,这里应该返回可访问的URL)
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件
|
||||
func (s *LocalFileStorageServiceImpl) DeleteFile(ctx context.Context, filePath string) error {
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// 文件不存在,不视为错误
|
||||
return nil
|
||||
}
|
||||
s.logger.Error("删除文件失败", zap.Error(err), zap.String("path", filePath))
|
||||
return fmt.Errorf("删除文件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("文件删除成功", zap.String("path", filePath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileReader 获取文件读取器
|
||||
func (s *LocalFileStorageServiceImpl) GetFileReader(ctx context.Context, filePath string) (io.ReadCloser, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开文件失败: %w", err)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
353
internal/infrastructure/external/storage/qiniu_storage_service.go
vendored
Normal file
353
internal/infrastructure/external/storage/qiniu_storage_service.go
vendored
Normal file
@@ -0,0 +1,353 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/qiniu/go-sdk/v7/auth/qbox"
|
||||
"github.com/qiniu/go-sdk/v7/storage"
|
||||
"go.uber.org/zap"
|
||||
|
||||
sharedStorage "hyapi-server/internal/shared/storage"
|
||||
)
|
||||
|
||||
// QiNiuStorageService 七牛云存储服务
|
||||
type QiNiuStorageService struct {
|
||||
accessKey string
|
||||
secretKey string
|
||||
bucket string
|
||||
domain string
|
||||
logger *zap.Logger
|
||||
mac *qbox.Mac
|
||||
bucketManager *storage.BucketManager
|
||||
}
|
||||
|
||||
// QiNiuStorageConfig 七牛云存储配置
|
||||
type QiNiuStorageConfig struct {
|
||||
AccessKey string `yaml:"access_key"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
Bucket string `yaml:"bucket"`
|
||||
Domain string `yaml:"domain"`
|
||||
}
|
||||
|
||||
// NewQiNiuStorageService 创建七牛云存储服务
|
||||
func NewQiNiuStorageService(accessKey, secretKey, bucket, domain string, logger *zap.Logger) *QiNiuStorageService {
|
||||
mac := qbox.NewMac(accessKey, secretKey)
|
||||
// 使用默认配置,不需要指定region
|
||||
cfg := storage.Config{}
|
||||
bucketManager := storage.NewBucketManager(mac, &cfg)
|
||||
|
||||
return &QiNiuStorageService{
|
||||
accessKey: accessKey,
|
||||
secretKey: secretKey,
|
||||
bucket: bucket,
|
||||
domain: domain,
|
||||
logger: logger,
|
||||
mac: mac,
|
||||
bucketManager: bucketManager,
|
||||
}
|
||||
}
|
||||
|
||||
// UploadFile 上传文件到七牛云
|
||||
func (s *QiNiuStorageService) UploadFile(ctx context.Context, fileBytes []byte, fileName string) (*sharedStorage.UploadResult, error) {
|
||||
s.logger.Info("开始上传文件到七牛云",
|
||||
zap.String("file_name", fileName),
|
||||
zap.Int("file_size", len(fileBytes)),
|
||||
)
|
||||
|
||||
// 生成唯一的文件key
|
||||
key := s.generateFileKey(fileName)
|
||||
|
||||
// 创建上传凭证
|
||||
putPolicy := storage.PutPolicy{
|
||||
Scope: s.bucket,
|
||||
}
|
||||
upToken := putPolicy.UploadToken(s.mac)
|
||||
|
||||
// 配置上传参数
|
||||
cfg := storage.Config{}
|
||||
formUploader := storage.NewFormUploader(&cfg)
|
||||
ret := storage.PutRet{}
|
||||
|
||||
// 上传文件
|
||||
err := formUploader.Put(ctx, &ret, upToken, key, strings.NewReader(string(fileBytes)), int64(len(fileBytes)), &storage.PutExtra{})
|
||||
if err != nil {
|
||||
s.logger.Error("文件上传失败",
|
||||
zap.String("file_name", fileName),
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("文件上传失败: %w", err)
|
||||
}
|
||||
|
||||
// 构建文件URL
|
||||
fileURL := s.GetFileURL(ctx, key)
|
||||
|
||||
s.logger.Info("文件上传成功",
|
||||
zap.String("file_name", fileName),
|
||||
zap.String("key", key),
|
||||
zap.String("url", fileURL),
|
||||
)
|
||||
|
||||
return &sharedStorage.UploadResult{
|
||||
Key: key,
|
||||
URL: fileURL,
|
||||
MimeType: s.getMimeType(fileName),
|
||||
Size: int64(len(fileBytes)),
|
||||
Hash: ret.Hash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateUploadToken 生成上传凭证
|
||||
func (s *QiNiuStorageService) GenerateUploadToken(ctx context.Context, key string) (string, error) {
|
||||
putPolicy := storage.PutPolicy{
|
||||
Scope: s.bucket,
|
||||
// 设置过期时间(1小时)
|
||||
Expires: uint64(time.Now().Add(time.Hour).Unix()),
|
||||
}
|
||||
|
||||
token := putPolicy.UploadToken(s.mac)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GetFileURL 获取文件访问URL
|
||||
func (s *QiNiuStorageService) GetFileURL(ctx context.Context, key string) string {
|
||||
// 如果是私有空间,需要生成带签名的URL
|
||||
if s.isPrivateBucket() {
|
||||
deadline := time.Now().Add(time.Hour).Unix() // 1小时过期
|
||||
privateAccessURL := storage.MakePrivateURL(s.mac, s.domain, key, deadline)
|
||||
return privateAccessURL
|
||||
}
|
||||
|
||||
// 公开空间直接返回URL
|
||||
return fmt.Sprintf("%s/%s", s.domain, key)
|
||||
}
|
||||
|
||||
// GetPrivateFileURL 获取私有文件访问URL
|
||||
func (s *QiNiuStorageService) GetPrivateFileURL(ctx context.Context, key string, expires int64) (string, error) {
|
||||
baseURL := s.GetFileURL(ctx, key)
|
||||
|
||||
// TODO: 实际集成七牛云SDK生成私有URL
|
||||
s.logger.Info("生成七牛云私有文件URL",
|
||||
zap.String("key", key),
|
||||
zap.Int64("expires", expires),
|
||||
)
|
||||
|
||||
// 模拟返回私有URL
|
||||
return fmt.Sprintf("%s?token=mock_private_token&expires=%d", baseURL, expires), nil
|
||||
}
|
||||
|
||||
// DeleteFile 删除文件
|
||||
func (s *QiNiuStorageService) DeleteFile(ctx context.Context, key string) error {
|
||||
s.logger.Info("删除七牛云文件", zap.String("key", key))
|
||||
|
||||
err := s.bucketManager.Delete(s.bucket, key)
|
||||
if err != nil {
|
||||
s.logger.Error("删除文件失败",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("删除文件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("文件删除成功", zap.String("key", key))
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileExists 检查文件是否存在
|
||||
func (s *QiNiuStorageService) FileExists(ctx context.Context, key string) (bool, error) {
|
||||
// TODO: 实际集成七牛云SDK检查文件存在性
|
||||
s.logger.Info("检查七牛云文件存在性", zap.String("key", key))
|
||||
|
||||
// 模拟文件存在
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetFileInfo 获取文件信息
|
||||
func (s *QiNiuStorageService) GetFileInfo(ctx context.Context, key string) (*sharedStorage.FileInfo, error) {
|
||||
fileInfo, err := s.bucketManager.Stat(s.bucket, key)
|
||||
if err != nil {
|
||||
s.logger.Error("获取文件信息失败",
|
||||
zap.String("key", key),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("获取文件信息失败: %w", err)
|
||||
}
|
||||
|
||||
return &sharedStorage.FileInfo{
|
||||
Key: key,
|
||||
Size: fileInfo.Fsize,
|
||||
MimeType: fileInfo.MimeType,
|
||||
Hash: fileInfo.Hash,
|
||||
PutTime: fileInfo.PutTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListFiles 列出文件
|
||||
func (s *QiNiuStorageService) ListFiles(ctx context.Context, prefix string, limit int) ([]*sharedStorage.FileInfo, error) {
|
||||
entries, _, _, hasMore, err := s.bucketManager.ListFiles(s.bucket, prefix, "", "", limit)
|
||||
if err != nil {
|
||||
s.logger.Error("列出文件失败",
|
||||
zap.String("prefix", prefix),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("列出文件失败: %w", err)
|
||||
}
|
||||
|
||||
var fileInfos []*sharedStorage.FileInfo
|
||||
for _, entry := range entries {
|
||||
fileInfo := &sharedStorage.FileInfo{
|
||||
Key: entry.Key,
|
||||
Size: entry.Fsize,
|
||||
MimeType: entry.MimeType,
|
||||
Hash: entry.Hash,
|
||||
PutTime: entry.PutTime,
|
||||
}
|
||||
fileInfos = append(fileInfos, fileInfo)
|
||||
}
|
||||
|
||||
_ = hasMore // 暂时忽略hasMore
|
||||
return fileInfos, nil
|
||||
}
|
||||
|
||||
// generateFileKey 生成文件key
|
||||
func (s *QiNiuStorageService) generateFileKey(fileName string) string {
|
||||
// 生成时间戳
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
// 生成随机字符串
|
||||
randomStr := fmt.Sprintf("%d", time.Now().UnixNano()%1000000)
|
||||
// 获取文件扩展名
|
||||
ext := filepath.Ext(fileName)
|
||||
// 构建key: 日期/时间戳_随机数.扩展名
|
||||
key := fmt.Sprintf("certification/%s/%s_%s%s",
|
||||
time.Now().Format("20060102"), timestamp, randomStr, ext)
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
// getMimeType 根据文件名获取MIME类型
|
||||
func (s *QiNiuStorageService) getMimeType(fileName string) string {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".bmp":
|
||||
return "image/bmp"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// isPrivateBucket 判断是否为私有空间
|
||||
func (s *QiNiuStorageService) isPrivateBucket() bool {
|
||||
// 这里可以根据配置或域名特征判断
|
||||
// 私有空间的域名通常包含特定标识
|
||||
return strings.Contains(s.domain, "private") ||
|
||||
strings.Contains(s.domain, "auth") ||
|
||||
strings.Contains(s.domain, "secure")
|
||||
}
|
||||
|
||||
// generateSignature 生成签名(用于私有空间访问)
|
||||
func (s *QiNiuStorageService) generateSignature(data string) string {
|
||||
h := hmac.New(sha1.New, []byte(s.secretKey))
|
||||
h.Write([]byte(data))
|
||||
return base64.URLEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// UploadFromReader 从Reader上传文件
|
||||
func (s *QiNiuStorageService) UploadFromReader(ctx context.Context, reader io.Reader, fileName string, fileSize int64) (*sharedStorage.UploadResult, error) {
|
||||
// 读取文件内容
|
||||
fileBytes, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取文件失败: %w", err)
|
||||
}
|
||||
|
||||
return s.UploadFile(ctx, fileBytes, fileName)
|
||||
}
|
||||
|
||||
// DownloadFile 从七牛云下载文件
|
||||
func (s *QiNiuStorageService) DownloadFile(ctx context.Context, fileURL string) ([]byte, error) {
|
||||
s.logger.Info("开始从七牛云下载文件", zap.String("file_url", fileURL))
|
||||
|
||||
// 创建HTTP客户端,超时时间设置为60秒
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// 创建请求
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fileURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := err.Error();
|
||||
errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
errorMsg := "下载文件失败"
|
||||
if isTimeout {
|
||||
errorMsg = "下载文件超时"
|
||||
}
|
||||
s.logger.Error(errorMsg,
|
||||
zap.String("file_url", fileURL),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("%s: %w", errorMsg, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
s.logger.Error("下载文件失败,状态码异常",
|
||||
zap.String("file_url", fileURL),
|
||||
zap.Int("status_code", resp.StatusCode),
|
||||
)
|
||||
return nil, fmt.Errorf("下载文件失败,状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
fileContent, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
s.logger.Error("读取文件内容失败",
|
||||
zap.String("file_url", fileURL),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("读取文件内容失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("文件下载成功",
|
||||
zap.String("file_url", fileURL),
|
||||
zap.Int("file_size", len(fileContent)),
|
||||
)
|
||||
|
||||
return fileContent, nil
|
||||
}
|
||||
183
internal/infrastructure/external/tianyancha/tianyancha_service.go
vendored
Normal file
183
internal/infrastructure/external/tianyancha/tianyancha_service.go
vendored
Normal file
@@ -0,0 +1,183 @@
|
||||
package tianyancha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrNotFound = errors.New("查询为空")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
ErrInvalidParam = errors.New("参数错误")
|
||||
)
|
||||
|
||||
// APIEndpoints 天眼查 API 端点映射
|
||||
var APIEndpoints = map[string]string{
|
||||
"VerifyThreeElements": "/open/ic/verify/2.0", // 企业三要素验证
|
||||
"InvestHistory": "/open/hi/invest/2.0", // 对外投资历史
|
||||
"FinancingHistory": "/open/cd/findHistoryRongzi/2.0", // 融资历史
|
||||
"PunishmentInfo": "/open/mr/punishmentInfo/3.0", // 行政处罚
|
||||
"AbnormalInfo": "/open/mr/abnormal/2.0", // 经营异常
|
||||
"OwnTax": "/open/mr/ownTax/2.0", // 欠税公告
|
||||
"TaxContravention": "/open/mr/taxContravention/2.0", // 税收违法
|
||||
"holderChange": "/open/ic/holderChange/2.0", // 股权变更
|
||||
"baseinfo": "/open/ic/baseinfo/normal", // 企业基本信息
|
||||
"investtree": "/v3/open/investtree", // 股权穿透
|
||||
}
|
||||
|
||||
// TianYanChaConfig 天眼查配置
|
||||
type TianYanChaConfig struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// TianYanChaService 天眼查服务
|
||||
type TianYanChaService struct {
|
||||
config TianYanChaConfig
|
||||
}
|
||||
|
||||
// APIResponse 标准API响应结构
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// TianYanChaResponse 天眼查原始响应结构
|
||||
type TianYanChaResponse struct {
|
||||
ErrorCode int `json:"error_code"`
|
||||
Reason string `json:"reason"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
|
||||
// NewTianYanChaService 创建天眼查服务实例
|
||||
func NewTianYanChaService(baseURL, token string, timeout time.Duration) *TianYanChaService {
|
||||
if timeout == 0 {
|
||||
timeout = 60 * time.Second
|
||||
}
|
||||
|
||||
return &TianYanChaService{
|
||||
config: TianYanChaConfig{
|
||||
BaseURL: baseURL,
|
||||
Token: token,
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CallAPI 调用天眼查API - 通用方法,由外部处理器传入具体参数
|
||||
func (t *TianYanChaService) CallAPI(ctx context.Context, apiCode string, params map[string]string) (*APIResponse, error) {
|
||||
// 从映射中获取 API 端点
|
||||
endpoint, exists := APIEndpoints[apiCode]
|
||||
if !exists {
|
||||
return nil, errors.Join(ErrInvalidParam, fmt.Errorf("未找到 API 代码对应的端点: %s", apiCode))
|
||||
}
|
||||
|
||||
// 构建完整 URL
|
||||
fullURL := strings.TrimRight(t.config.BaseURL, "/") + "/" + strings.TrimLeft(endpoint, "/")
|
||||
|
||||
// 检查 Token 是否配置
|
||||
if t.config.Token == "" {
|
||||
return nil, errors.Join(ErrSystem, fmt.Errorf("天眼查 API Token 未配置"))
|
||||
}
|
||||
|
||||
// 构建查询参数
|
||||
queryParams := url.Values{}
|
||||
for key, value := range params {
|
||||
queryParams.Set(key, value)
|
||||
}
|
||||
|
||||
// 构建完整URL
|
||||
requestURL := fullURL
|
||||
if len(queryParams) > 0 {
|
||||
requestURL += "?" + queryParams.Encode()
|
||||
}
|
||||
|
||||
// 创建请求
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrSystem, fmt.Errorf("创建请求失败: %v", err))
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Authorization", t.config.Token)
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{Timeout: t.config.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
if isTimeout {
|
||||
return nil, errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err))
|
||||
}
|
||||
return nil, errors.Join(ErrDatasource, fmt.Errorf("API 请求异常: %v", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查 HTTP 状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.Join(ErrDatasource, fmt.Errorf("API 请求失败,状态码: %d", resp.StatusCode))
|
||||
}
|
||||
|
||||
// 读取响应体
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrSystem, fmt.Errorf("读取响应体失败: %v", err))
|
||||
}
|
||||
|
||||
// 解析 JSON 响应
|
||||
var tianYanChaResp TianYanChaResponse
|
||||
if err := json.Unmarshal(body, &tianYanChaResp); err != nil {
|
||||
return nil, errors.Join(ErrSystem, fmt.Errorf("解析响应 JSON 失败: %v", err))
|
||||
}
|
||||
|
||||
// 检查天眼查业务状态码
|
||||
if tianYanChaResp.ErrorCode != 0 {
|
||||
// 特殊处理:ErrorCode 300000 表示查询为空,返回成功但数据为空数组
|
||||
if tianYanChaResp.ErrorCode == 300000 {
|
||||
return &APIResponse{
|
||||
Success: true,
|
||||
Code: 0,
|
||||
Message: "",
|
||||
Data: []interface{}{}, // 返回空数组而不是nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &APIResponse{
|
||||
Success: false,
|
||||
Code: tianYanChaResp.ErrorCode,
|
||||
Message: tianYanChaResp.Reason,
|
||||
Data: tianYanChaResp.Result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 成功情况
|
||||
return &APIResponse{
|
||||
Success: true,
|
||||
Code: 0,
|
||||
Message: tianYanChaResp.Reason,
|
||||
Data: tianYanChaResp.Result,
|
||||
}, nil
|
||||
}
|
||||
160
internal/infrastructure/external/westdex/crypto.go
vendored
Normal file
160
internal/infrastructure/external/westdex/crypto.go
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
package westdex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
const (
|
||||
KEY_SIZE = 16 // AES-128, 16 bytes
|
||||
)
|
||||
|
||||
// Encrypt encrypts the given data using AES encryption in ECB mode with PKCS5 padding
|
||||
func Encrypt(data, secretKey string) (string, error) {
|
||||
key := generateAESKey(KEY_SIZE*8, []byte(secretKey))
|
||||
ciphertext, err := aesEncrypt([]byte(data), key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts the given base64-encoded string using AES encryption in ECB mode with PKCS5 padding
|
||||
func Decrypt(encodedData, secretKey string) ([]byte, error) {
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encodedData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := generateAESKey(KEY_SIZE*8, []byte(secretKey))
|
||||
plaintext, err := aesDecrypt(ciphertext, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// generateAESKey generates a key for AES encryption using a SHA-1 based PRNG
|
||||
func generateAESKey(length int, password []byte) []byte {
|
||||
h := sha1.New()
|
||||
h.Write(password)
|
||||
state := h.Sum(nil)
|
||||
|
||||
keyBytes := make([]byte, 0, length/8)
|
||||
for len(keyBytes) < length/8 {
|
||||
h := sha1.New()
|
||||
h.Write(state)
|
||||
state = h.Sum(nil)
|
||||
keyBytes = append(keyBytes, state...)
|
||||
}
|
||||
|
||||
return keyBytes[:length/8]
|
||||
}
|
||||
|
||||
// aesEncrypt encrypts plaintext using AES in ECB mode with PKCS5 padding
|
||||
func aesEncrypt(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paddedPlaintext := pkcs5Padding(plaintext, block.BlockSize())
|
||||
ciphertext := make([]byte, len(paddedPlaintext))
|
||||
mode := newECBEncrypter(block)
|
||||
mode.CryptBlocks(ciphertext, paddedPlaintext)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// aesDecrypt decrypts ciphertext using AES in ECB mode with PKCS5 padding
|
||||
func aesDecrypt(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
mode := newECBDecrypter(block)
|
||||
mode.CryptBlocks(plaintext, ciphertext)
|
||||
return pkcs5Unpadding(plaintext), nil
|
||||
}
|
||||
|
||||
// pkcs5Padding pads the input to a multiple of the block size using PKCS5 padding
|
||||
func pkcs5Padding(src []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(src)%blockSize
|
||||
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(src, padtext...)
|
||||
}
|
||||
|
||||
// pkcs5Unpadding removes PKCS5 padding from the input
|
||||
func pkcs5Unpadding(src []byte) []byte {
|
||||
length := len(src)
|
||||
unpadding := int(src[length-1])
|
||||
return src[:(length - unpadding)]
|
||||
}
|
||||
|
||||
// ECB mode encryption/decryption
|
||||
type ecb struct {
|
||||
b cipher.Block
|
||||
blockSize int
|
||||
}
|
||||
|
||||
func newECB(b cipher.Block) *ecb {
|
||||
return &ecb{
|
||||
b: b,
|
||||
blockSize: b.BlockSize(),
|
||||
}
|
||||
}
|
||||
|
||||
type ecbEncrypter ecb
|
||||
|
||||
func newECBEncrypter(b cipher.Block) cipher.BlockMode {
|
||||
return (*ecbEncrypter)(newECB(b))
|
||||
}
|
||||
|
||||
func (x *ecbEncrypter) BlockSize() int { return x.blockSize }
|
||||
|
||||
func (x *ecbEncrypter) CryptBlocks(dst, src []byte) {
|
||||
if len(src)%x.blockSize != 0 {
|
||||
panic("crypto/cipher: input not full blocks")
|
||||
}
|
||||
if len(dst) < len(src) {
|
||||
panic("crypto/cipher: output smaller than input")
|
||||
}
|
||||
for len(src) > 0 {
|
||||
x.b.Encrypt(dst, src[:x.blockSize])
|
||||
src = src[x.blockSize:]
|
||||
dst = dst[x.blockSize:]
|
||||
}
|
||||
}
|
||||
|
||||
type ecbDecrypter ecb
|
||||
|
||||
func newECBDecrypter(b cipher.Block) cipher.BlockMode {
|
||||
return (*ecbDecrypter)(newECB(b))
|
||||
}
|
||||
|
||||
func (x *ecbDecrypter) BlockSize() int { return x.blockSize }
|
||||
|
||||
func (x *ecbDecrypter) CryptBlocks(dst, src []byte) {
|
||||
if len(src)%x.blockSize != 0 {
|
||||
panic("crypto/cipher: input not full blocks")
|
||||
}
|
||||
if len(dst) < len(src) {
|
||||
panic("crypto/cipher: output smaller than input")
|
||||
}
|
||||
for len(src) > 0 {
|
||||
x.b.Decrypt(dst, src[:x.blockSize])
|
||||
src = src[x.blockSize:]
|
||||
dst = dst[x.blockSize:]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Md5Encrypt 用于对传入的message进行MD5加密
|
||||
func Md5Encrypt(message string) string {
|
||||
hash := md5.New()
|
||||
hash.Write([]byte(message)) // 将字符串转换为字节切片并写入
|
||||
return hex.EncodeToString(hash.Sum(nil)) // 将哈希值转换为16进制字符串并返回
|
||||
}
|
||||
63
internal/infrastructure/external/westdex/westdex_factory.go
vendored
Normal file
63
internal/infrastructure/external/westdex/westdex_factory.go
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
package westdex
|
||||
|
||||
import (
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewWestDexServiceWithConfig 使用配置创建西部数据服务
|
||||
func NewWestDexServiceWithConfig(cfg *config.Config) (*WestDexService, error) {
|
||||
// 将配置类型转换为通用外部服务日志配置
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.WestDex.Logging.Enabled,
|
||||
LogDir: cfg.WestDex.Logging.LogDir,
|
||||
ServiceName: "westdex",
|
||||
UseDaily: cfg.WestDex.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.WestDex.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
|
||||
// 转换级别配置
|
||||
for key, value := range cfg.WestDex.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: value.MaxSize,
|
||||
MaxBackups: value.MaxBackups,
|
||||
MaxAge: value.MaxAge,
|
||||
Compress: value.Compress,
|
||||
}
|
||||
}
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建西部数据服务
|
||||
service := NewWestDexService(
|
||||
cfg.WestDex.URL,
|
||||
cfg.WestDex.Key,
|
||||
cfg.WestDex.SecretID,
|
||||
cfg.WestDex.SecretSecondID,
|
||||
logger,
|
||||
)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewWestDexServiceWithLogging 使用自定义日志配置创建西部数据服务
|
||||
func NewWestDexServiceWithLogging(url, key, secretID, secretSecondID string, loggingConfig external_logger.ExternalServiceLoggingConfig) (*WestDexService, error) {
|
||||
// 设置服务名称
|
||||
loggingConfig.ServiceName = "westdex"
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建西部数据服务
|
||||
service := NewWestDexService(url, key, secretID, secretSecondID, logger)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
418
internal/infrastructure/external/westdex/westdex_service.go
vendored
Normal file
418
internal/infrastructure/external/westdex/westdex_service.go
vendored
Normal file
@@ -0,0 +1,418 @@
|
||||
package westdex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/crypto"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
ErrNotFound = errors.New("查询为空")
|
||||
)
|
||||
|
||||
type WestResp struct {
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
Data string `json:"data"`
|
||||
ID string `json:"id"`
|
||||
ErrorCode *int `json:"error_code"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type G05HZ01WestResp struct {
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
ID string `json:"id"`
|
||||
ErrorCode *int `json:"error_code"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type WestConfig struct {
|
||||
Url string
|
||||
Key string
|
||||
SecretID string
|
||||
SecretSecondID string
|
||||
}
|
||||
|
||||
type WestDexService struct {
|
||||
config WestConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
}
|
||||
|
||||
// NewWestDexService 是一个构造函数,用于初始化 WestDexService
|
||||
func NewWestDexService(url, key, secretID, secretSecondID string, logger *external_logger.ExternalServiceLogger) *WestDexService {
|
||||
return &WestDexService{
|
||||
config: WestConfig{
|
||||
Url: url,
|
||||
Key: key,
|
||||
SecretID: secretID,
|
||||
SecretSecondID: secretSecondID,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID 生成请求ID
|
||||
func (w *WestDexService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, w.config.Key)))
|
||||
return fmt.Sprintf("westdex_%x", hash[:8])
|
||||
}
|
||||
|
||||
// buildRequestURL 构建请求URL
|
||||
func (w *WestDexService) buildRequestURL(code string) string {
|
||||
timestamp := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
|
||||
return fmt.Sprintf("%s/%s/%s?timestamp=%s", w.config.Url, w.config.SecretID, code, timestamp)
|
||||
}
|
||||
|
||||
// CallAPI 调用西部数据的 API
|
||||
func (w *WestDexService) CallAPI(ctx context.Context, code string, reqData map[string]interface{}) (resp []byte, err error) {
|
||||
startTime := time.Now()
|
||||
requestID := w.generateRequestID()
|
||||
|
||||
// 从ctx中获取transactionId
|
||||
var transactionID string
|
||||
if ctxTransactionID, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = ctxTransactionID
|
||||
}
|
||||
|
||||
// 构建请求URL
|
||||
reqUrl := w.buildRequestURL(code)
|
||||
|
||||
// 记录请求日志
|
||||
if w.logger != nil {
|
||||
w.logger.LogRequest(requestID, transactionID, code, reqUrl)
|
||||
}
|
||||
|
||||
jsonData, marshalErr := json.Marshal(reqData)
|
||||
if marshalErr != nil {
|
||||
err = errors.Join(ErrSystem, marshalErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建HTTP POST请求
|
||||
req, newRequestErr := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBuffer(jsonData))
|
||||
if newRequestErr != nil {
|
||||
err = errors.Join(ErrSystem, newRequestErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 发送请求,超时时间设置为60秒
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
httpResp, clientDoErr := client.Do(req)
|
||||
if clientDoErr != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := clientDoErr.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := clientDoErr.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
if isTimeout {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", clientDoErr))
|
||||
} else {
|
||||
err = errors.Join(ErrSystem, clientDoErr)
|
||||
}
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
closeErr := Body.Close()
|
||||
if closeErr != nil {
|
||||
// 记录关闭错误
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, errors.Join(ErrSystem, fmt.Errorf("关闭响应体失败: %w", closeErr)), reqData)
|
||||
}
|
||||
}
|
||||
}(httpResp.Body)
|
||||
|
||||
// 计算请求耗时
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// 检查请求是否成功
|
||||
if httpResp.StatusCode == 200 {
|
||||
// 读取响应体
|
||||
bodyBytes, ReadErr := io.ReadAll(httpResp.Body)
|
||||
if ReadErr != nil {
|
||||
err = errors.Join(ErrSystem, ReadErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 手动调用 json.Unmarshal 触发自定义的 UnmarshalJSON 方法
|
||||
var westDexResp WestResp
|
||||
UnmarshalErr := json.Unmarshal(bodyBytes, &westDexResp)
|
||||
if UnmarshalErr != nil {
|
||||
err = errors.Join(ErrSystem, UnmarshalErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// 记录响应日志(不记录具体响应数据)
|
||||
if w.logger != nil {
|
||||
w.logger.LogResponseWithID(requestID, transactionID, code, httpResp.StatusCode, duration, westDexResp.ID)
|
||||
}
|
||||
|
||||
if westDexResp.Code != "00000" && westDexResp.Code != "200" && westDexResp.Code != "0" {
|
||||
if westDexResp.Data == "" {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf(westDexResp.Message))
|
||||
if w.logger != nil {
|
||||
w.logger.LogErrorWithResponseID(requestID, transactionID, code, err, reqData, westDexResp.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
decryptedData, DecryptErr := crypto.WestDexDecrypt(westDexResp.Data, w.config.Key)
|
||||
if DecryptErr != nil {
|
||||
err = errors.Join(ErrSystem, DecryptErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogErrorWithResponseID(requestID, transactionID, code, err, reqData, westDexResp.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 记录业务错误日志,包含响应ID
|
||||
if w.logger != nil {
|
||||
w.logger.LogErrorWithResponseID(requestID, transactionID, code, errors.Join(ErrDatasource, fmt.Errorf(westDexResp.Message)), reqData, westDexResp.ID)
|
||||
}
|
||||
|
||||
// 记录性能日志(失败)
|
||||
// 注意:通用日志系统不包含性能日志功能
|
||||
|
||||
return decryptedData, errors.Join(ErrDatasource, fmt.Errorf(westDexResp.Message))
|
||||
}
|
||||
|
||||
if westDexResp.Data == "" {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf(westDexResp.Message))
|
||||
if w.logger != nil {
|
||||
w.logger.LogErrorWithResponseID(requestID, transactionID, code, err, reqData, westDexResp.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decryptedData, DecryptErr := crypto.WestDexDecrypt(westDexResp.Data, w.config.Key)
|
||||
if DecryptErr != nil {
|
||||
err = errors.Join(ErrSystem, DecryptErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogErrorWithResponseID(requestID, transactionID, code, err, reqData, westDexResp.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 记录性能日志(成功)
|
||||
// 注意:通用日志系统不包含性能日志功能
|
||||
|
||||
return decryptedData, nil
|
||||
}
|
||||
|
||||
// 记录HTTP错误
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("西部请求失败Code: %d", httpResp.StatusCode))
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
// 注意:通用日志系统不包含性能日志功能
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// G05HZ01CallAPI 调用西部数据的 G05HZ01 API
|
||||
func (w *WestDexService) G05HZ01CallAPI(ctx context.Context, code string, reqData map[string]interface{}) (resp []byte, err error) {
|
||||
startTime := time.Now()
|
||||
requestID := w.generateRequestID()
|
||||
|
||||
// 从ctx中获取transactionId
|
||||
var transactionID string
|
||||
if ctxTransactionID, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = ctxTransactionID
|
||||
}
|
||||
|
||||
// 构建请求URL
|
||||
reqUrl := fmt.Sprintf("%s/%s/%s?timestamp=%d", w.config.Url, w.config.SecretSecondID, code, time.Now().UnixNano()/int64(time.Millisecond))
|
||||
|
||||
// 记录请求日志
|
||||
if w.logger != nil {
|
||||
w.logger.LogRequest(requestID, transactionID, code, reqUrl)
|
||||
}
|
||||
|
||||
jsonData, marshalErr := json.Marshal(reqData)
|
||||
if marshalErr != nil {
|
||||
err = errors.Join(ErrSystem, marshalErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建HTTP POST请求
|
||||
req, newRequestErr := http.NewRequestWithContext(ctx, "POST", reqUrl, bytes.NewBuffer(jsonData))
|
||||
if newRequestErr != nil {
|
||||
err = errors.Join(ErrSystem, newRequestErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 发送请求,超时时间设置为60秒
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
httpResp, clientDoErr := client.Do(req)
|
||||
if clientDoErr != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := clientDoErr.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := clientDoErr.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
if isTimeout {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", clientDoErr))
|
||||
} else {
|
||||
err = errors.Join(ErrSystem, clientDoErr)
|
||||
}
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
closeErr := Body.Close()
|
||||
if closeErr != nil {
|
||||
// 记录关闭错误
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, errors.Join(ErrSystem, fmt.Errorf("关闭响应体失败: %w", closeErr)), reqData)
|
||||
}
|
||||
}
|
||||
}(httpResp.Body)
|
||||
|
||||
// 计算请求耗时
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if httpResp.StatusCode == 200 {
|
||||
bodyBytes, ReadErr := io.ReadAll(httpResp.Body)
|
||||
if ReadErr != nil {
|
||||
err = errors.Join(ErrSystem, ReadErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var westDexResp G05HZ01WestResp
|
||||
UnmarshalErr := json.Unmarshal(bodyBytes, &westDexResp)
|
||||
if UnmarshalErr != nil {
|
||||
err = errors.Join(ErrSystem, UnmarshalErr)
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 记录响应日志(不记录具体响应数据)
|
||||
if w.logger != nil {
|
||||
w.logger.LogResponseWithID(requestID, transactionID, code, httpResp.StatusCode, duration, westDexResp.ID)
|
||||
}
|
||||
|
||||
if westDexResp.Code != "0000" {
|
||||
if westDexResp.Data == nil || westDexResp.Code == "1404" {
|
||||
err = errors.Join(ErrNotFound, fmt.Errorf(westDexResp.Message))
|
||||
if w.logger != nil {
|
||||
w.logger.LogErrorWithResponseID(requestID, transactionID, code, err, reqData, westDexResp.ID)
|
||||
}
|
||||
return nil, err
|
||||
} else {
|
||||
// 记录业务错误日志,包含响应ID
|
||||
if w.logger != nil {
|
||||
w.logger.LogErrorWithResponseID(requestID, transactionID, code, errors.Join(ErrSystem, fmt.Errorf(string(westDexResp.Data))), reqData, westDexResp.ID)
|
||||
}
|
||||
|
||||
// 记录性能日志(失败)
|
||||
// 注意:通用日志系统不包含性能日志功能
|
||||
|
||||
return westDexResp.Data, errors.Join(ErrSystem, fmt.Errorf(string(westDexResp.Data)))
|
||||
}
|
||||
}
|
||||
|
||||
if westDexResp.Data == nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf(westDexResp.Message))
|
||||
if w.logger != nil {
|
||||
w.logger.LogErrorWithResponseID(requestID, transactionID, code, err, reqData, westDexResp.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 记录性能日志(成功)
|
||||
// 注意:通用日志系统不包含性能日志功能
|
||||
|
||||
return westDexResp.Data, nil
|
||||
} else {
|
||||
// 记录HTTP错误
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("西部请求失败Code: %d", httpResp.StatusCode))
|
||||
if w.logger != nil {
|
||||
w.logger.LogError(requestID, transactionID, code, err, reqData)
|
||||
// 注意:通用日志系统不包含性能日志功能
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WestDexService) Encrypt(data string) (string, error) {
|
||||
encryptedValue, err := crypto.WestDexEncrypt(data, w.config.Key)
|
||||
if err != nil {
|
||||
return "", ErrSystem
|
||||
}
|
||||
|
||||
return encryptedValue, nil
|
||||
}
|
||||
func (w *WestDexService) Md5Encrypt(data string) string {
|
||||
result := Md5Encrypt(data)
|
||||
return result
|
||||
}
|
||||
|
||||
func (w *WestDexService) GetConfig() WestConfig {
|
||||
return w.config
|
||||
}
|
||||
62
internal/infrastructure/external/xingwei/xingwei_factory.go
vendored
Normal file
62
internal/infrastructure/external/xingwei/xingwei_factory.go
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
package xingwei
|
||||
|
||||
import (
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewXingweiServiceWithConfig 使用配置创建行为数据服务
|
||||
func NewXingweiServiceWithConfig(cfg *config.Config) (*XingweiService, error) {
|
||||
// 将配置类型转换为通用外部服务日志配置
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.Xingwei.Logging.Enabled,
|
||||
LogDir: cfg.Xingwei.Logging.LogDir,
|
||||
ServiceName: "xingwei",
|
||||
UseDaily: cfg.Xingwei.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.Xingwei.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
|
||||
// 转换级别配置
|
||||
for key, value := range cfg.Xingwei.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: value.MaxSize,
|
||||
MaxBackups: value.MaxBackups,
|
||||
MaxAge: value.MaxAge,
|
||||
Compress: value.Compress,
|
||||
}
|
||||
}
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建行为数据服务
|
||||
service := NewXingweiService(
|
||||
cfg.Xingwei.URL,
|
||||
cfg.Xingwei.ApiID,
|
||||
cfg.Xingwei.ApiKey,
|
||||
logger,
|
||||
)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewXingweiServiceWithLogging 使用自定义日志配置创建行为数据服务
|
||||
func NewXingweiServiceWithLogging(url, apiID, apiKey string, loggingConfig external_logger.ExternalServiceLoggingConfig) (*XingweiService, error) {
|
||||
// 设置服务名称
|
||||
loggingConfig.ServiceName = "xingwei"
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建行为数据服务
|
||||
service := NewXingweiService(url, apiID, apiKey, logger)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
296
internal/infrastructure/external/xingwei/xingwei_service.go
vendored
Normal file
296
internal/infrastructure/external/xingwei/xingwei_service.go
vendored
Normal file
@@ -0,0 +1,296 @@
|
||||
package xingwei
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// 行为数据API状态码常量
|
||||
const (
|
||||
CodeSuccess = 200 // 操作成功
|
||||
CodeSystemError = 500 // 系统内部错误
|
||||
CodeMerchantError = 3001 // 商家相关报错(商家不存在、商家被禁用、商家余额不足)
|
||||
CodeAccountExpired = 3002 // 账户已过期
|
||||
CodeIPWhitelistMissing = 3003 // 未添加ip白名单
|
||||
CodeUnauthorized = 3004 // 未授权调用该接口
|
||||
CodeProductIDError = 4001 // 产品id错误
|
||||
CodeInterfaceDisabled = 4002 // 接口被停用
|
||||
CodeQueryException = 5001 // 接口查询异常,请联系技术人员
|
||||
CodeNotFound = 6000 // 未查询到结果
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
ErrNotFound = errors.New("未查询到结果")
|
||||
|
||||
// 请求ID计数器,确保唯一性
|
||||
requestIDCounter int64
|
||||
)
|
||||
|
||||
// XingweiResponse 行为数据API响应结构
|
||||
type XingweiResponse struct {
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// XingweiErrorCode 行为数据错误码定义
|
||||
type XingweiErrorCode struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
// 行为数据错误码映射
|
||||
var XingweiErrorCodes = map[int]XingweiErrorCode{
|
||||
CodeSuccess: {Code: CodeSuccess, Message: "操作成功"},
|
||||
CodeSystemError: {Code: CodeSystemError, Message: "系统内部错误"},
|
||||
CodeMerchantError: {Code: CodeMerchantError, Message: "商家相关报错(商家不存在、商家被禁用、商家余额不足)"},
|
||||
CodeAccountExpired: {Code: CodeAccountExpired, Message: "账户已过期"},
|
||||
CodeIPWhitelistMissing: {Code: CodeIPWhitelistMissing, Message: "未添加ip白名单"},
|
||||
CodeUnauthorized: {Code: CodeUnauthorized, Message: "未授权调用该接口"},
|
||||
CodeProductIDError: {Code: CodeProductIDError, Message: "产品id错误"},
|
||||
CodeInterfaceDisabled: {Code: CodeInterfaceDisabled, Message: "接口被停用"},
|
||||
CodeQueryException: {Code: CodeQueryException, Message: "接口查询异常,请联系技术人员"},
|
||||
CodeNotFound: {Code: CodeNotFound, Message: "未查询到结果"},
|
||||
}
|
||||
|
||||
// GetXingweiErrorMessage 根据错误码获取错误消息
|
||||
func GetXingweiErrorMessage(code int) string {
|
||||
if errorCode, exists := XingweiErrorCodes[code]; exists {
|
||||
return errorCode.Message
|
||||
}
|
||||
return fmt.Sprintf("未知错误码: %d", code)
|
||||
}
|
||||
|
||||
type XingweiConfig struct {
|
||||
URL string
|
||||
ApiID string
|
||||
ApiKey string
|
||||
}
|
||||
|
||||
type XingweiService struct {
|
||||
config XingweiConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
}
|
||||
|
||||
// NewXingweiService 是一个构造函数,用于初始化 XingweiService
|
||||
func NewXingweiService(url, apiID, apiKey string, logger *external_logger.ExternalServiceLogger) *XingweiService {
|
||||
return &XingweiService{
|
||||
config: XingweiConfig{
|
||||
URL: url,
|
||||
ApiID: apiID,
|
||||
ApiKey: apiKey,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID 生成请求ID
|
||||
func (x *XingweiService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
// 使用原子计数器确保唯一性
|
||||
counter := atomic.AddInt64(&requestIDCounter, 1)
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%d_%s", timestamp, counter, x.config.ApiID)))
|
||||
return fmt.Sprintf("xingwei_%x", hash[:8])
|
||||
}
|
||||
|
||||
// createSign 创建签名:使用MD5算法将apiId、timestamp、apiKey字符串拼接生成sign
|
||||
// 参考Java示例:DigestUtils.md5Hex(apiId + timestamp + apiKey)
|
||||
func (x *XingweiService) createSign(timestamp int64) string {
|
||||
signStr := x.config.ApiID + strconv.FormatInt(timestamp, 10) + x.config.ApiKey
|
||||
hash := md5.Sum([]byte(signStr))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// CallAPI 调用行为数据的 API
|
||||
func (x *XingweiService) CallAPI(ctx context.Context, projectID string, params map[string]interface{}) (resp []byte, err error) {
|
||||
startTime := time.Now()
|
||||
requestID := x.generateRequestID()
|
||||
timestamp := time.Now().UnixMilli()
|
||||
|
||||
// 从ctx中获取transactionId
|
||||
var transactionID string
|
||||
if ctxTransactionID, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = ctxTransactionID
|
||||
}
|
||||
|
||||
// 记录请求日志
|
||||
if x.logger != nil {
|
||||
x.logger.LogRequest(requestID, transactionID, "xingwei_api", x.config.URL)
|
||||
}
|
||||
|
||||
// 将请求参数转换为JSON
|
||||
jsonData, marshalErr := json.Marshal(params)
|
||||
if marshalErr != nil {
|
||||
err = errors.Join(ErrSystem, marshalErr)
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api", err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建HTTP POST请求
|
||||
req, newRequestErr := http.NewRequestWithContext(ctx, "POST", x.config.URL, bytes.NewBuffer(jsonData))
|
||||
if newRequestErr != nil {
|
||||
err = errors.Join(ErrSystem, newRequestErr)
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api", err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("timestamp", strconv.FormatInt(timestamp, 10))
|
||||
req.Header.Set("sign", x.createSign(timestamp))
|
||||
req.Header.Set("API-ID", x.config.ApiID)
|
||||
req.Header.Set("project_id", projectID)
|
||||
|
||||
// 创建HTTP客户端,超时时间设置为60秒
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
httpResp, clientDoErr := client.Do(req)
|
||||
if clientDoErr != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := clientDoErr.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := clientDoErr.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
if isTimeout {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", clientDoErr))
|
||||
} else {
|
||||
err = errors.Join(ErrSystem, clientDoErr)
|
||||
}
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api", err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
closeErr := Body.Close()
|
||||
if closeErr != nil {
|
||||
// 记录关闭错误
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api", errors.Join(ErrSystem, fmt.Errorf("关闭响应体失败: %w", closeErr)), params)
|
||||
}
|
||||
}
|
||||
}(httpResp.Body)
|
||||
|
||||
// 计算请求耗时
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// 读取响应体
|
||||
bodyBytes, ReadErr := io.ReadAll(httpResp.Body)
|
||||
if ReadErr != nil {
|
||||
err = errors.Join(ErrSystem, ReadErr)
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api", err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 记录响应日志(不记录具体响应数据)
|
||||
if x.logger != nil {
|
||||
x.logger.LogResponse(requestID, transactionID, "xingwei_api", httpResp.StatusCode, duration)
|
||||
}
|
||||
|
||||
// 检查HTTP状态码
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("行为数据请求失败,状态码: %d", httpResp.StatusCode))
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api", err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析响应结构
|
||||
var xingweiResp XingweiResponse
|
||||
if err := json.Unmarshal(bodyBytes, &xingweiResp); err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api", err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查业务状态码
|
||||
switch xingweiResp.Code {
|
||||
case CodeSuccess:
|
||||
// 成功响应,返回data字段
|
||||
if xingweiResp.Data == nil {
|
||||
return []byte("{}"), nil
|
||||
}
|
||||
|
||||
// 将data转换为JSON字节
|
||||
dataBytes, err := json.Marshal(xingweiResp.Data)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("data字段序列化失败: %w", err))
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api", err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dataBytes, nil
|
||||
|
||||
case CodeNotFound:
|
||||
// 未查询到结果,返回空数组
|
||||
if x.logger != nil {
|
||||
// 这里只记录有响应,不记录具体返回内容
|
||||
x.logger.LogResponse(requestID, transactionID, "xingwei_api", httpResp.StatusCode, duration)
|
||||
}
|
||||
return []byte("[]"), nil
|
||||
|
||||
case CodeSystemError:
|
||||
// 系统内部错误
|
||||
errorMsg := GetXingweiErrorMessage(xingweiResp.Code)
|
||||
systemErr := fmt.Errorf("行为数据系统错误[%d]: %s", xingweiResp.Code, errorMsg)
|
||||
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api",
|
||||
errors.Join(ErrSystem, systemErr), params)
|
||||
}
|
||||
|
||||
return nil, errors.Join(ErrSystem, systemErr)
|
||||
|
||||
default:
|
||||
// 其他业务错误
|
||||
errorMsg := GetXingweiErrorMessage(xingweiResp.Code)
|
||||
businessErr := fmt.Errorf("行为数据业务错误[%d]: %s", xingweiResp.Code, errorMsg)
|
||||
|
||||
if x.logger != nil {
|
||||
x.logger.LogError(requestID, transactionID, "xingwei_api",
|
||||
errors.Join(ErrDatasource, businessErr), params)
|
||||
}
|
||||
|
||||
return nil, errors.Join(ErrDatasource, businessErr)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfig 获取配置信息
|
||||
func (x *XingweiService) GetConfig() XingweiConfig {
|
||||
return x.config
|
||||
}
|
||||
241
internal/infrastructure/external/xingwei/xingwei_test.go
vendored
Normal file
241
internal/infrastructure/external/xingwei/xingwei_test.go
vendored
Normal file
@@ -0,0 +1,241 @@
|
||||
package xingwei
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestXingweiService_CreateSign(t *testing.T) {
|
||||
// 创建测试配置 - 使用nil logger来避免日志问题
|
||||
service := NewXingweiService(
|
||||
"https://sjztyh.chengdaoji.cn/dataCenterManageApi/manage/interface/doc/api/handle",
|
||||
"test_api_id",
|
||||
"test_api_key",
|
||||
nil, // 使用nil logger
|
||||
)
|
||||
|
||||
// 测试签名生成
|
||||
timestamp := int64(1743474772049)
|
||||
sign := service.createSign(timestamp)
|
||||
|
||||
// 验证签名不为空
|
||||
if sign == "" {
|
||||
t.Error("签名不能为空")
|
||||
}
|
||||
|
||||
// 验证签名长度(MD5应该是32位十六进制字符串)
|
||||
if len(sign) != 32 {
|
||||
t.Errorf("签名长度应该是32位,实际是%d位", len(sign))
|
||||
}
|
||||
|
||||
t.Logf("生成的签名: %s", sign)
|
||||
}
|
||||
|
||||
func TestXingweiService_CallAPI(t *testing.T) {
|
||||
// 创建测试配置 - 使用nil logger来避免日志问题
|
||||
service := NewXingweiService(
|
||||
"https://sjztyh.chengdaoji.cn/dataCenterManageApi/manage/interface/doc/api/handle",
|
||||
"test_api_id",
|
||||
"test_api_key",
|
||||
nil, // 使用nil logger
|
||||
)
|
||||
|
||||
// 创建测试上下文
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试参数
|
||||
projectID := "test_project_id"
|
||||
params := map[string]interface{}{
|
||||
"test_param": "test_value",
|
||||
}
|
||||
|
||||
// 注意:这个测试会实际发送HTTP请求,所以可能会失败
|
||||
// 在实际使用中,应该使用mock或者测试服务器
|
||||
resp, err := service.CallAPI(ctx, projectID, params)
|
||||
|
||||
// 由于这是真实的外部API调用,我们主要测试错误处理
|
||||
if err != nil {
|
||||
t.Logf("预期的错误(真实API调用): %v", err)
|
||||
} else {
|
||||
t.Logf("API调用成功,响应长度: %d", len(resp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestXingweiService_GenerateRequestID(t *testing.T) {
|
||||
// 创建测试配置 - 使用nil logger来避免日志问题
|
||||
service := NewXingweiService(
|
||||
"https://sjztyh.chengdaoji.cn/dataCenterManageApi/manage/interface/doc/api/handle",
|
||||
"test_api_id",
|
||||
"test_api_key",
|
||||
nil, // 使用nil logger
|
||||
)
|
||||
|
||||
// 测试请求ID生成
|
||||
requestID1 := service.generateRequestID()
|
||||
requestID2 := service.generateRequestID()
|
||||
|
||||
// 验证请求ID不为空
|
||||
if requestID1 == "" || requestID2 == "" {
|
||||
t.Error("请求ID不能为空")
|
||||
}
|
||||
|
||||
// 验证请求ID应该以xingwei_开头
|
||||
if len(requestID1) < 8 || requestID1[:8] != "xingwei_" {
|
||||
t.Error("请求ID应该以xingwei_开头")
|
||||
}
|
||||
|
||||
// 验证两次生成的请求ID应该不同
|
||||
if requestID1 == requestID2 {
|
||||
t.Error("两次生成的请求ID应该不同")
|
||||
}
|
||||
|
||||
t.Logf("请求ID1: %s", requestID1)
|
||||
t.Logf("请求ID2: %s", requestID2)
|
||||
}
|
||||
|
||||
func TestGetXingweiErrorMessage(t *testing.T) {
|
||||
// 测试已知错误码(使用常量)
|
||||
testCases := []struct {
|
||||
code int
|
||||
expected string
|
||||
}{
|
||||
{CodeSuccess, "操作成功"},
|
||||
{CodeSystemError, "系统内部错误"},
|
||||
{CodeMerchantError, "商家相关报错(商家不存在、商家被禁用、商家余额不足)"},
|
||||
{CodeAccountExpired, "账户已过期"},
|
||||
{CodeIPWhitelistMissing, "未添加ip白名单"},
|
||||
{CodeUnauthorized, "未授权调用该接口"},
|
||||
{CodeProductIDError, "产品id错误"},
|
||||
{CodeInterfaceDisabled, "接口被停用"},
|
||||
{CodeQueryException, "接口查询异常,请联系技术人员"},
|
||||
{CodeNotFound, "未查询到结果"},
|
||||
{9999, "未知错误码: 9999"}, // 测试未知错误码
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
result := GetXingweiErrorMessage(tc.code)
|
||||
if result != tc.expected {
|
||||
t.Errorf("错误码 %d 的消息不正确,期望: %s, 实际: %s", tc.code, tc.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXingweiResponseParsing(t *testing.T) {
|
||||
// 测试响应结构解析
|
||||
testCases := []struct {
|
||||
name string
|
||||
response string
|
||||
expectedCode int
|
||||
}{
|
||||
{
|
||||
name: "成功响应",
|
||||
response: `{"msg": "操作成功", "code": 200, "data": {"result": "test"}}`,
|
||||
expectedCode: CodeSuccess,
|
||||
},
|
||||
{
|
||||
name: "商家错误",
|
||||
response: `{"msg": "商家相关报错", "code": 3001, "data": null}`,
|
||||
expectedCode: CodeMerchantError,
|
||||
},
|
||||
{
|
||||
name: "未查询到结果",
|
||||
response: `{"msg": "未查询到结果", "code": 6000, "data": null}`,
|
||||
expectedCode: CodeNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var resp XingweiResponse
|
||||
err := json.Unmarshal([]byte(tc.response), &resp)
|
||||
if err != nil {
|
||||
t.Errorf("解析响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.Code != tc.expectedCode {
|
||||
t.Errorf("错误码不匹配,期望: %d, 实际: %d", tc.expectedCode, resp.Code)
|
||||
}
|
||||
|
||||
// 测试错误消息获取
|
||||
errorMsg := GetXingweiErrorMessage(resp.Code)
|
||||
if errorMsg == "" {
|
||||
t.Errorf("无法获取错误码 %d 的消息", resp.Code)
|
||||
}
|
||||
|
||||
t.Logf("响应: %+v, 错误消息: %s", resp, errorMsg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXingweiErrorHandling 测试错误处理逻辑
|
||||
func TestXingweiErrorHandling(t *testing.T) {
|
||||
// 注意:这个测试主要验证常量定义和错误消息,不需要实际的服务实例
|
||||
|
||||
// 测试查空错误
|
||||
t.Run("NotFound错误", func(t *testing.T) {
|
||||
// 模拟返回查空响应
|
||||
response := `{"msg": "未查询到结果", "code": 6000, "data": null}`
|
||||
var xingweiResp XingweiResponse
|
||||
err := json.Unmarshal([]byte(response), &xingweiResp)
|
||||
if err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证状态码
|
||||
if xingweiResp.Code != CodeNotFound {
|
||||
t.Errorf("期望状态码 %d, 实际 %d", CodeNotFound, xingweiResp.Code)
|
||||
}
|
||||
|
||||
// 验证错误消息
|
||||
errorMsg := GetXingweiErrorMessage(xingweiResp.Code)
|
||||
if errorMsg != "未查询到结果" {
|
||||
t.Errorf("期望错误消息 '未查询到结果', 实际 '%s'", errorMsg)
|
||||
}
|
||||
|
||||
t.Logf("查空错误测试通过: 状态码=%d, 消息=%s", xingweiResp.Code, errorMsg)
|
||||
})
|
||||
|
||||
// 测试系统错误
|
||||
t.Run("SystemError错误", func(t *testing.T) {
|
||||
response := `{"msg": "系统内部错误", "code": 500, "data": null}`
|
||||
var xingweiResp XingweiResponse
|
||||
err := json.Unmarshal([]byte(response), &xingweiResp)
|
||||
if err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if xingweiResp.Code != CodeSystemError {
|
||||
t.Errorf("期望状态码 %d, 实际 %d", CodeSystemError, xingweiResp.Code)
|
||||
}
|
||||
|
||||
errorMsg := GetXingweiErrorMessage(xingweiResp.Code)
|
||||
if errorMsg != "系统内部错误" {
|
||||
t.Errorf("期望错误消息 '系统内部错误', 实际 '%s'", errorMsg)
|
||||
}
|
||||
|
||||
t.Logf("系统错误测试通过: 状态码=%d, 消息=%s", xingweiResp.Code, errorMsg)
|
||||
})
|
||||
|
||||
// 测试成功响应
|
||||
t.Run("Success响应", func(t *testing.T) {
|
||||
response := `{"msg": "操作成功", "code": 200, "data": {"result": "test"}}`
|
||||
var xingweiResp XingweiResponse
|
||||
err := json.Unmarshal([]byte(response), &xingweiResp)
|
||||
if err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
if xingweiResp.Code != CodeSuccess {
|
||||
t.Errorf("期望状态码 %d, 实际 %d", CodeSuccess, xingweiResp.Code)
|
||||
}
|
||||
|
||||
errorMsg := GetXingweiErrorMessage(xingweiResp.Code)
|
||||
if errorMsg != "操作成功" {
|
||||
t.Errorf("期望错误消息 '操作成功', 实际 '%s'", errorMsg)
|
||||
}
|
||||
|
||||
t.Logf("成功响应测试通过: 状态码=%d, 消息=%s", xingweiResp.Code, errorMsg)
|
||||
})
|
||||
}
|
||||
67
internal/infrastructure/external/yushan/yushan_factory.go
vendored
Normal file
67
internal/infrastructure/external/yushan/yushan_factory.go
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
package yushan
|
||||
|
||||
import (
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewYushanServiceWithConfig 使用配置创建羽山服务
|
||||
func NewYushanServiceWithConfig(cfg *config.Config) (*YushanService, error) {
|
||||
// 将配置类型转换为通用外部服务日志配置
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.Yushan.Logging.Enabled,
|
||||
LogDir: cfg.Yushan.Logging.LogDir,
|
||||
ServiceName: "yushan",
|
||||
UseDaily: cfg.Yushan.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.Yushan.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
|
||||
// 转换级别配置
|
||||
for key, value := range cfg.Yushan.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: value.MaxSize,
|
||||
MaxBackups: value.MaxBackups,
|
||||
MaxAge: value.MaxAge,
|
||||
Compress: value.Compress,
|
||||
}
|
||||
}
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建羽山服务
|
||||
service := NewYushanService(
|
||||
cfg.Yushan.URL,
|
||||
cfg.Yushan.APIKey,
|
||||
cfg.Yushan.AcctID,
|
||||
logger,
|
||||
)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewYushanServiceWithLogging 使用自定义日志配置创建羽山服务
|
||||
func NewYushanServiceWithLogging(url, apiKey, acctID string, loggingConfig external_logger.ExternalServiceLoggingConfig) (*YushanService, error) {
|
||||
// 设置服务名称
|
||||
loggingConfig.ServiceName = "yushan"
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建羽山服务
|
||||
service := NewYushanService(url, apiKey, acctID, logger)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewYushanServiceSimple 创建简单的羽山服务(无日志)
|
||||
func NewYushanServiceSimple(url, apiKey, acctID string) *YushanService {
|
||||
return NewYushanService(url, apiKey, acctID, nil)
|
||||
}
|
||||
287
internal/infrastructure/external/yushan/yushan_service.go
vendored
Normal file
287
internal/infrastructure/external/yushan/yushan_service.go
vendored
Normal file
@@ -0,0 +1,287 @@
|
||||
package yushan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrNotFound = errors.New("查询为空")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
)
|
||||
|
||||
type YushanConfig struct {
|
||||
URL string
|
||||
ApiKey string
|
||||
AcctID string
|
||||
}
|
||||
|
||||
type YushanService struct {
|
||||
config YushanConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
}
|
||||
|
||||
// NewYushanService 是一个构造函数,用于初始化 YushanService
|
||||
func NewYushanService(url, apiKey, acctID string, logger *external_logger.ExternalServiceLogger) *YushanService {
|
||||
return &YushanService{
|
||||
config: YushanConfig{
|
||||
URL: url,
|
||||
ApiKey: apiKey,
|
||||
AcctID: acctID,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CallAPI 调用羽山数据的 API
|
||||
func (y *YushanService) CallAPI(ctx context.Context, code string, params map[string]interface{}) (respBytes []byte, err error) {
|
||||
startTime := time.Now()
|
||||
requestID := y.generateRequestID()
|
||||
|
||||
// 从ctx中获取transactionId
|
||||
var transactionID string
|
||||
if ctxTransactionID, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = ctxTransactionID
|
||||
}
|
||||
|
||||
// 记录请求日志
|
||||
if y.logger != nil {
|
||||
y.logger.LogRequest(requestID, transactionID, code, y.config.URL)
|
||||
}
|
||||
|
||||
// 获取当前时间戳
|
||||
unixMilliseconds := time.Now().UnixNano() / int64(time.Millisecond)
|
||||
|
||||
// 生成请求序列号
|
||||
requestSN, _ := y.GenerateRandomString()
|
||||
|
||||
// 构建请求数据
|
||||
reqData := map[string]interface{}{
|
||||
"prod_id": code,
|
||||
"req_time": unixMilliseconds,
|
||||
"request_sn": requestSN,
|
||||
"req_data": params,
|
||||
}
|
||||
|
||||
// 将请求数据转换为 JSON 字节数组
|
||||
messageBytes, err := json.Marshal(reqData)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if y.logger != nil {
|
||||
y.logger.LogError(requestID, transactionID, code, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取 API 密钥
|
||||
key, err := hex.DecodeString(y.config.ApiKey)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if y.logger != nil {
|
||||
y.logger.LogError(requestID, transactionID, code, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 使用 AES CBC 加密请求数据
|
||||
cipherText := y.AES_CBC_Encrypt(messageBytes, key)
|
||||
|
||||
// 将加密后的数据编码为 Base64 字符串
|
||||
content := base64.StdEncoding.EncodeToString(cipherText)
|
||||
|
||||
// 发起 HTTP 请求,超时时间设置为60秒
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", y.config.URL, strings.NewReader(content))
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if y.logger != nil {
|
||||
y.logger.LogError(requestID, transactionID, code, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("ACCT_ID", y.config.AcctID)
|
||||
|
||||
// 执行请求
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
isTimeout = true
|
||||
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
if isTimeout {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err))
|
||||
} else {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
}
|
||||
if y.logger != nil {
|
||||
y.logger.LogError(requestID, transactionID, code, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应体
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
if y.logger != nil {
|
||||
y.logger.LogError(requestID, transactionID, code, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var respData []byte
|
||||
|
||||
if IsJSON(string(body)) {
|
||||
respData = body
|
||||
} else {
|
||||
sDec, err := base64.StdEncoding.DecodeString(string(body))
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if y.logger != nil {
|
||||
y.logger.LogError(requestID, transactionID, code, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
respData = y.AES_CBC_Decrypt(sDec, key)
|
||||
}
|
||||
retCode := gjson.GetBytes(respData, "retcode").String()
|
||||
|
||||
// 记录响应日志(不记录具体响应数据)
|
||||
if y.logger != nil {
|
||||
duration := time.Since(startTime)
|
||||
y.logger.LogResponse(requestID, transactionID, code, resp.StatusCode, duration)
|
||||
}
|
||||
|
||||
if retCode == "100000" {
|
||||
// retcode 为 100000,表示查询为空
|
||||
return nil, ErrNotFound
|
||||
} else if retCode == "000000" {
|
||||
// retcode 为 000000,表示有数据,返回 retdata
|
||||
retData := gjson.GetBytes(respData, "retdata")
|
||||
if !retData.Exists() {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("羽山请求retdata为空"))
|
||||
if y.logger != nil {
|
||||
y.logger.LogError(requestID, transactionID, code, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return []byte(retData.Raw), nil
|
||||
} else {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("羽山请求未知的状态码"))
|
||||
if y.logger != nil {
|
||||
y.logger.LogError(requestID, transactionID, code, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID 生成请求ID
|
||||
func (y *YushanService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, y.config.ApiKey)))
|
||||
return fmt.Sprintf("yushan_%x", hash[:8])
|
||||
}
|
||||
|
||||
// GenerateRandomString 生成一个32位的随机字符串订单号
|
||||
func (y *YushanService) GenerateRandomString() (string, error) {
|
||||
// 创建一个16字节的数组
|
||||
bytes := make([]byte, 16)
|
||||
// 读取随机字节到数组中
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 将字节数组编码为16进制字符串
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// AEC加密(CBC模式)
|
||||
func (y *YushanService) AES_CBC_Encrypt(plainText []byte, key []byte) []byte {
|
||||
//指定加密算法,返回一个AES算法的Block接口对象
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
//进行填充
|
||||
plainText = Padding(plainText, block.BlockSize())
|
||||
//指定初始向量vi,长度和block的块尺寸一致
|
||||
iv := []byte("0000000000000000")
|
||||
//指定分组模式,返回一个BlockMode接口对象
|
||||
blockMode := cipher.NewCBCEncrypter(block, iv)
|
||||
//加密连续数据库
|
||||
cipherText := make([]byte, len(plainText))
|
||||
blockMode.CryptBlocks(cipherText, plainText)
|
||||
//返回base64密文
|
||||
return cipherText
|
||||
}
|
||||
|
||||
// AEC解密(CBC模式)
|
||||
func (y *YushanService) AES_CBC_Decrypt(cipherText []byte, key []byte) []byte {
|
||||
//指定解密算法,返回一个AES算法的Block接口对象
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
//指定初始化向量IV,和加密的一致
|
||||
iv := []byte("0000000000000000")
|
||||
//指定分组模式,返回一个BlockMode接口对象
|
||||
blockMode := cipher.NewCBCDecrypter(block, iv)
|
||||
//解密
|
||||
plainText := make([]byte, len(cipherText))
|
||||
blockMode.CryptBlocks(plainText, cipherText)
|
||||
//删除填充
|
||||
plainText = UnPadding(plainText)
|
||||
return plainText
|
||||
} // 对明文进行填充
|
||||
func Padding(plainText []byte, blockSize int) []byte {
|
||||
//计算要填充的长度
|
||||
n := blockSize - len(plainText)%blockSize
|
||||
//对原来的明文填充n个n
|
||||
temp := bytes.Repeat([]byte{byte(n)}, n)
|
||||
plainText = append(plainText, temp...)
|
||||
return plainText
|
||||
}
|
||||
|
||||
// 对密文删除填充
|
||||
func UnPadding(cipherText []byte) []byte {
|
||||
//取出密文最后一个字节end
|
||||
end := cipherText[len(cipherText)-1]
|
||||
//删除填充
|
||||
cipherText = cipherText[:len(cipherText)-int(end)]
|
||||
return cipherText
|
||||
}
|
||||
|
||||
// 判断字符串是否为 JSON 格式
|
||||
func IsJSON(s string) bool {
|
||||
var js interface{}
|
||||
return json.Unmarshal([]byte(s), &js) == nil
|
||||
}
|
||||
83
internal/infrastructure/external/yushan/yushan_test.go
vendored
Normal file
83
internal/infrastructure/external/yushan/yushan_test.go
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
package yushan
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateRequestID(t *testing.T) {
|
||||
service := &YushanService{
|
||||
config: YushanConfig{
|
||||
ApiKey: "test_api_key_123",
|
||||
},
|
||||
}
|
||||
|
||||
id1 := service.generateRequestID()
|
||||
|
||||
// 等待一小段时间确保时间戳不同
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
id2 := service.generateRequestID()
|
||||
|
||||
if id1 == "" || id2 == "" {
|
||||
t.Error("请求ID生成失败")
|
||||
}
|
||||
|
||||
if id1 == id2 {
|
||||
t.Error("不同时间生成的请求ID应该不同")
|
||||
}
|
||||
|
||||
// 验证ID格式
|
||||
if len(id1) < 20 { // yushan_ + 8位十六进制 + 其他
|
||||
t.Errorf("请求ID长度不足,实际: %s", id1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRandomString(t *testing.T) {
|
||||
service := &YushanService{}
|
||||
|
||||
str1, err := service.GenerateRandomString()
|
||||
if err != nil {
|
||||
t.Fatalf("生成随机字符串失败: %v", err)
|
||||
}
|
||||
|
||||
str2, err := service.GenerateRandomString()
|
||||
if err != nil {
|
||||
t.Fatalf("生成随机字符串失败: %v", err)
|
||||
}
|
||||
|
||||
if str1 == "" || str2 == "" {
|
||||
t.Error("随机字符串为空")
|
||||
}
|
||||
|
||||
if str1 == str2 {
|
||||
t.Error("两次生成的随机字符串应该不同")
|
||||
}
|
||||
|
||||
// 验证长度(16字节 = 32位十六进制字符)
|
||||
if len(str1) != 32 || len(str2) != 32 {
|
||||
t.Error("随机字符串长度应该是32位")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsJSON(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"{}", true},
|
||||
{"[]", true},
|
||||
{"{\"key\": \"value\"}", true},
|
||||
{"[1, 2, 3]", true},
|
||||
{"invalid json", false},
|
||||
{"", false},
|
||||
{"{invalid}", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
result := IsJSON(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("输入: %s, 期望: %v, 实际: %v", tc.input, tc.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
121
internal/infrastructure/external/zhicha/crypto.go
vendored
Normal file
121
internal/infrastructure/external/zhicha/crypto.go
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
package zhicha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
KEY_SIZE = 16 // AES-128, 16 bytes
|
||||
)
|
||||
|
||||
// Encrypt 使用AES-128-CBC加密数据
|
||||
// 对应Python示例中的encrypt函数
|
||||
func Encrypt(data, key string) (string, error) {
|
||||
// 将十六进制密钥转换为字节
|
||||
binKey, err := hex.DecodeString(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("密钥格式错误: %w", err)
|
||||
}
|
||||
|
||||
if len(binKey) < KEY_SIZE {
|
||||
return "", fmt.Errorf("密钥长度不足,需要至少%d字节", KEY_SIZE)
|
||||
}
|
||||
|
||||
// 从密钥前16个字符生成IV
|
||||
iv := []byte(key[:KEY_SIZE])
|
||||
|
||||
// 创建AES加密器
|
||||
block, err := aes.NewCipher(binKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建AES加密器失败: %w", err)
|
||||
}
|
||||
|
||||
// 对数据进行PKCS7填充
|
||||
paddedData := pkcs7Padding([]byte(data), aes.BlockSize)
|
||||
|
||||
// 创建CBC模式加密器
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
|
||||
// 加密
|
||||
ciphertext := make([]byte, len(paddedData))
|
||||
mode.CryptBlocks(ciphertext, paddedData)
|
||||
|
||||
// 返回Base64编码结果
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt 使用AES-128-CBC解密数据
|
||||
// 对应Python示例中的decrypt函数
|
||||
func Decrypt(encryptedData, key string) (string, error) {
|
||||
// 将十六进制密钥转换为字节
|
||||
binKey, err := hex.DecodeString(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("密钥格式错误: %w", err)
|
||||
}
|
||||
|
||||
if len(binKey) < KEY_SIZE {
|
||||
return "", fmt.Errorf("密钥长度不足,需要至少%d字节", KEY_SIZE)
|
||||
}
|
||||
|
||||
// 从密钥前16个字符生成IV
|
||||
iv := []byte(key[:KEY_SIZE])
|
||||
|
||||
// 解码Base64数据
|
||||
decodedData, err := base64.StdEncoding.DecodeString(encryptedData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Base64解码失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查数据长度是否为AES块大小的倍数
|
||||
if len(decodedData) == 0 || len(decodedData)%aes.BlockSize != 0 {
|
||||
return "", fmt.Errorf("加密数据长度无效,必须是%d字节的倍数", aes.BlockSize)
|
||||
}
|
||||
|
||||
// 创建AES解密器
|
||||
block, err := aes.NewCipher(binKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建AES解密器失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建CBC模式解密器
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
|
||||
// 解密
|
||||
plaintext := make([]byte, len(decodedData))
|
||||
mode.CryptBlocks(plaintext, decodedData)
|
||||
|
||||
// 移除PKCS7填充
|
||||
unpadded, err := pkcs7Unpadding(plaintext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("移除填充失败: %w", err)
|
||||
}
|
||||
|
||||
return string(unpadded), nil
|
||||
}
|
||||
|
||||
// pkcs7Padding 使用PKCS7填充数据
|
||||
func pkcs7Padding(src []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(src)%blockSize
|
||||
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(src, padtext...)
|
||||
}
|
||||
|
||||
// pkcs7Unpadding 移除PKCS7填充
|
||||
func pkcs7Unpadding(src []byte) ([]byte, error) {
|
||||
length := len(src)
|
||||
if length == 0 {
|
||||
return nil, fmt.Errorf("数据为空")
|
||||
}
|
||||
|
||||
unpadding := int(src[length-1])
|
||||
if unpadding > length {
|
||||
return nil, fmt.Errorf("填充长度无效")
|
||||
}
|
||||
|
||||
return src[:length-unpadding], nil
|
||||
}
|
||||
170
internal/infrastructure/external/zhicha/zhicha_errors.go
vendored
Normal file
170
internal/infrastructure/external/zhicha/zhicha_errors.go
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
package zhicha
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ZhichaError 智查金控服务错误
|
||||
type ZhichaError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Error 实现error接口
|
||||
func (e *ZhichaError) Error() string {
|
||||
return fmt.Sprintf("智查金控错误 [%s]: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// IsSuccess 检查是否成功
|
||||
func (e *ZhichaError) IsSuccess() bool {
|
||||
return e.Code == "200"
|
||||
}
|
||||
|
||||
// IsNoRecord 检查是否查询无记录
|
||||
func (e *ZhichaError) IsNoRecord() bool {
|
||||
return e.Code == "201"
|
||||
}
|
||||
|
||||
// IsBusinessError 检查是否是业务错误(非系统错误)
|
||||
func (e *ZhichaError) IsBusinessError() bool {
|
||||
return e.Code >= "302" && e.Code <= "320"
|
||||
}
|
||||
|
||||
// IsSystemError 检查是否是系统错误
|
||||
func (e *ZhichaError) IsSystemError() bool {
|
||||
return e.Code == "500"
|
||||
}
|
||||
|
||||
// IsAuthError 检查是否是认证相关错误
|
||||
func (e *ZhichaError) IsAuthError() bool {
|
||||
return e.Code == "304" || e.Code == "318" || e.Code == "319" || e.Code == "320"
|
||||
}
|
||||
|
||||
// IsParamError 检查是否是参数相关错误
|
||||
func (e *ZhichaError) IsParamError() bool {
|
||||
return e.Code == "302" || e.Code == "303" || e.Code == "305" || e.Code == "306" || e.Code == "307" || e.Code == "316" || e.Code == "317"
|
||||
}
|
||||
|
||||
// IsServiceError 检查是否是服务相关错误
|
||||
func (e *ZhichaError) IsServiceError() bool {
|
||||
return e.Code == "308" || e.Code == "309" || e.Code == "310" || e.Code == "311"
|
||||
}
|
||||
|
||||
// IsUserError 检查是否是用户相关错误
|
||||
func (e *ZhichaError) IsUserError() bool {
|
||||
return e.Code == "312" || e.Code == "313" || e.Code == "314" || e.Code == "315"
|
||||
}
|
||||
|
||||
// 预定义错误常量
|
||||
var (
|
||||
// 成功状态
|
||||
ErrSuccess = &ZhichaError{Code: "200", Message: "请求成功"}
|
||||
ErrNoRecord = &ZhichaError{Code: "201", Message: "查询无记录"}
|
||||
|
||||
// 业务参数错误
|
||||
ErrBusinessParamMissing = &ZhichaError{Code: "302", Message: "业务参数缺失"}
|
||||
ErrParamError = &ZhichaError{Code: "303", Message: "参数错误"}
|
||||
ErrHeaderParamMissing = &ZhichaError{Code: "304", Message: "请求头参数缺失"}
|
||||
ErrNameError = &ZhichaError{Code: "305", Message: "姓名错误"}
|
||||
ErrPhoneError = &ZhichaError{Code: "306", Message: "手机号错误"}
|
||||
ErrIDCardError = &ZhichaError{Code: "307", Message: "身份证号错误"}
|
||||
|
||||
// 服务相关错误
|
||||
ErrServiceNotExist = &ZhichaError{Code: "308", Message: "服务不存在"}
|
||||
ErrServiceNotEnabled = &ZhichaError{Code: "309", Message: "服务未开通"}
|
||||
ErrInsufficientBalance = &ZhichaError{Code: "310", Message: "余额不足"}
|
||||
ErrRemoteDataError = &ZhichaError{Code: "311", Message: "调用远程数据异常"}
|
||||
|
||||
// 用户相关错误
|
||||
ErrUserNotExist = &ZhichaError{Code: "312", Message: "用户不存在"}
|
||||
ErrUserStatusError = &ZhichaError{Code: "313", Message: "用户状态异常"}
|
||||
ErrUserUnauthorized = &ZhichaError{Code: "314", Message: "用户未授权"}
|
||||
ErrWhitelistError = &ZhichaError{Code: "315", Message: "白名单错误"}
|
||||
|
||||
// 时间戳和认证错误
|
||||
ErrTimestampInvalid = &ZhichaError{Code: "316", Message: "timestamp不合法"}
|
||||
ErrTimestampExpired = &ZhichaError{Code: "317", Message: "timestamp已过期"}
|
||||
ErrSignVerifyFailed = &ZhichaError{Code: "318", Message: "验签失败"}
|
||||
ErrDecryptFailed = &ZhichaError{Code: "319", Message: "解密失败"}
|
||||
ErrUnauthorized = &ZhichaError{Code: "320", Message: "未授权"}
|
||||
|
||||
// 系统错误
|
||||
ErrSystemError = &ZhichaError{Code: "500", Message: "系统异常,请联系管理员"}
|
||||
)
|
||||
|
||||
// NewZhichaError 创建新的智查金控错误
|
||||
func NewZhichaError(code, message string) *ZhichaError {
|
||||
return &ZhichaError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// NewZhichaErrorFromCode 根据状态码创建错误
|
||||
func NewZhichaErrorFromCode(code string) *ZhichaError {
|
||||
switch code {
|
||||
case "200":
|
||||
return ErrSuccess
|
||||
case "201":
|
||||
return ErrNoRecord
|
||||
case "302":
|
||||
return ErrBusinessParamMissing
|
||||
case "303":
|
||||
return ErrParamError
|
||||
case "304":
|
||||
return ErrHeaderParamMissing
|
||||
case "305":
|
||||
return ErrNameError
|
||||
case "306":
|
||||
return ErrPhoneError
|
||||
case "307":
|
||||
return ErrIDCardError
|
||||
case "308":
|
||||
return ErrServiceNotExist
|
||||
case "309":
|
||||
return ErrServiceNotEnabled
|
||||
case "310":
|
||||
return ErrInsufficientBalance
|
||||
case "311":
|
||||
return ErrRemoteDataError
|
||||
case "312":
|
||||
return ErrUserNotExist
|
||||
case "313":
|
||||
return ErrUserStatusError
|
||||
case "314":
|
||||
return ErrUserUnauthorized
|
||||
case "315":
|
||||
return ErrWhitelistError
|
||||
case "316":
|
||||
return ErrTimestampInvalid
|
||||
case "317":
|
||||
return ErrTimestampExpired
|
||||
case "318":
|
||||
return ErrSignVerifyFailed
|
||||
case "319":
|
||||
return ErrDecryptFailed
|
||||
case "320":
|
||||
return ErrUnauthorized
|
||||
case "500":
|
||||
return ErrSystemError
|
||||
default:
|
||||
return &ZhichaError{
|
||||
Code: code,
|
||||
Message: "未知错误",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsZhichaError 检查是否是智查金控错误
|
||||
func IsZhichaError(err error) bool {
|
||||
_, ok := err.(*ZhichaError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetZhichaError 获取智查金控错误
|
||||
func GetZhichaError(err error) *ZhichaError {
|
||||
if zhichaErr, ok := err.(*ZhichaError); ok {
|
||||
return zhichaErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
68
internal/infrastructure/external/zhicha/zhicha_factory.go
vendored
Normal file
68
internal/infrastructure/external/zhicha/zhicha_factory.go
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
package zhicha
|
||||
|
||||
import (
|
||||
"hyapi-server/internal/config"
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
// NewZhichaServiceWithConfig 使用配置创建智查金控服务
|
||||
func NewZhichaServiceWithConfig(cfg *config.Config) (*ZhichaService, error) {
|
||||
// 将配置类型转换为通用外部服务日志配置
|
||||
loggingConfig := external_logger.ExternalServiceLoggingConfig{
|
||||
Enabled: cfg.Zhicha.Logging.Enabled,
|
||||
LogDir: cfg.Zhicha.Logging.LogDir,
|
||||
ServiceName: "zhicha",
|
||||
UseDaily: cfg.Zhicha.Logging.UseDaily,
|
||||
EnableLevelSeparation: cfg.Zhicha.Logging.EnableLevelSeparation,
|
||||
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
|
||||
}
|
||||
|
||||
// 转换级别配置
|
||||
for key, value := range cfg.Zhicha.Logging.LevelConfigs {
|
||||
loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{
|
||||
MaxSize: value.MaxSize,
|
||||
MaxBackups: value.MaxBackups,
|
||||
MaxAge: value.MaxAge,
|
||||
Compress: value.Compress,
|
||||
}
|
||||
}
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建智查金控服务
|
||||
service := NewZhichaService(
|
||||
cfg.Zhicha.URL,
|
||||
cfg.Zhicha.AppID,
|
||||
cfg.Zhicha.AppSecret,
|
||||
cfg.Zhicha.EncryptKey,
|
||||
logger,
|
||||
)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewZhichaServiceWithLogging 使用自定义日志配置创建智查金控服务
|
||||
func NewZhichaServiceWithLogging(url, appID, appSecret, encryptKey string, loggingConfig external_logger.ExternalServiceLoggingConfig) (*ZhichaService, error) {
|
||||
// 设置服务名称
|
||||
loggingConfig.ServiceName = "zhicha"
|
||||
|
||||
// 创建通用外部服务日志器
|
||||
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建智查金控服务
|
||||
service := NewZhichaService(url, appID, appSecret, encryptKey, logger)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// NewZhichaServiceSimple 创建简单的智查金控服务(无日志)
|
||||
func NewZhichaServiceSimple(url, appID, appSecret, encryptKey string) *ZhichaService {
|
||||
return NewZhichaService(url, appID, appSecret, encryptKey, nil)
|
||||
}
|
||||
338
internal/infrastructure/external/zhicha/zhicha_service.go
vendored
Normal file
338
internal/infrastructure/external/zhicha/zhicha_service.go
vendored
Normal file
@@ -0,0 +1,338 @@
|
||||
package zhicha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/shared/external_logger"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDatasource = errors.New("数据源异常")
|
||||
ErrSystem = errors.New("系统异常")
|
||||
)
|
||||
|
||||
// contextKey 用于在 context 中存储不跳过 201 错误检查的标志
|
||||
type contextKey string
|
||||
|
||||
const dontSkipCode201CheckKey contextKey = "dont_skip_code_201_check"
|
||||
|
||||
type ZhichaResp struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type ZhichaConfig struct {
|
||||
URL string
|
||||
AppID string
|
||||
AppSecret string
|
||||
EncryptKey string
|
||||
}
|
||||
|
||||
type ZhichaService struct {
|
||||
config ZhichaConfig
|
||||
logger *external_logger.ExternalServiceLogger
|
||||
}
|
||||
|
||||
// NewZhichaService 是一个构造函数,用于初始化 ZhichaService
|
||||
func NewZhichaService(url, appID, appSecret, encryptKey string, logger *external_logger.ExternalServiceLogger) *ZhichaService {
|
||||
return &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
URL: url,
|
||||
AppID: appID,
|
||||
AppSecret: appSecret,
|
||||
EncryptKey: encryptKey,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// generateRequestID 生成请求ID
|
||||
func (z *ZhichaService) generateRequestID() string {
|
||||
timestamp := time.Now().UnixNano()
|
||||
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, z.config.AppID)))
|
||||
return fmt.Sprintf("zhicha_%x", hash[:8])
|
||||
}
|
||||
|
||||
// generateSign 生成签名
|
||||
func (z *ZhichaService) generateSign(timestamp int64) string {
|
||||
// 第一步:对app_secret进行MD5加密
|
||||
encryptedSecret := fmt.Sprintf("%x", md5.Sum([]byte(z.config.AppSecret)))
|
||||
|
||||
// 第二步:将加密后的密钥和时间戳拼接,再次MD5加密
|
||||
signStr := encryptedSecret + strconv.FormatInt(timestamp, 10)
|
||||
sign := fmt.Sprintf("%x", md5.Sum([]byte(signStr)))
|
||||
|
||||
return sign
|
||||
}
|
||||
|
||||
// CallAPI 调用智查金控的 API
|
||||
func (z *ZhichaService) CallAPI(ctx context.Context, proID string, params map[string]interface{}) (data interface{}, err error) {
|
||||
startTime := time.Now()
|
||||
requestID := z.generateRequestID()
|
||||
timestamp := time.Now().Unix()
|
||||
|
||||
// 从ctx中获取transactionId
|
||||
var transactionID string
|
||||
if ctxTransactionID, ok := ctx.Value("transaction_id").(string); ok {
|
||||
transactionID = ctxTransactionID
|
||||
}
|
||||
|
||||
// 记录请求日志
|
||||
if z.logger != nil {
|
||||
z.logger.LogRequest(requestID, transactionID, proID, z.config.URL)
|
||||
}
|
||||
|
||||
jsonData, marshalErr := json.Marshal(params)
|
||||
if marshalErr != nil {
|
||||
err = errors.Join(ErrSystem, marshalErr)
|
||||
if z.logger != nil {
|
||||
z.logger.LogError(requestID, transactionID, proID, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建HTTP POST请求
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", z.config.URL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if z.logger != nil {
|
||||
z.logger.LogError(requestID, transactionID, proID, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("appId", z.config.AppID)
|
||||
req.Header.Set("proId", proID)
|
||||
req.Header.Set("timestamp", strconv.FormatInt(timestamp, 10))
|
||||
req.Header.Set("sign", z.generateSign(timestamp))
|
||||
|
||||
// 创建HTTP客户端,超时时间设置为60秒
|
||||
client := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 检查是否是超时错误
|
||||
isTimeout := false
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
isTimeout = true
|
||||
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
|
||||
// 检查是否是网络超时错误
|
||||
isTimeout = true
|
||||
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
|
||||
errStr == "timeout" ||
|
||||
errStr == "Client.Timeout exceeded" ||
|
||||
errStr == "net/http: request canceled" {
|
||||
isTimeout = true
|
||||
}
|
||||
|
||||
if isTimeout {
|
||||
// 超时错误应该返回数据源异常,而不是系统异常
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err))
|
||||
} else {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
}
|
||||
if z.logger != nil {
|
||||
z.logger.LogError(requestID, transactionID, proID, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
respBody, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
err = errors.Join(ErrSystem, err)
|
||||
if z.logger != nil {
|
||||
z.logger.LogError(requestID, transactionID, proID, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 记录响应日志(不记录具体响应数据)
|
||||
if z.logger != nil {
|
||||
duration := time.Since(startTime)
|
||||
z.logger.LogResponse(requestID, transactionID, proID, response.StatusCode, duration)
|
||||
}
|
||||
|
||||
// 检查HTTP状态码
|
||||
if response.StatusCode != http.StatusOK {
|
||||
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", response.StatusCode))
|
||||
if z.logger != nil {
|
||||
z.logger.LogError(requestID, transactionID, proID, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var zhichaResp ZhichaResp
|
||||
if err := json.Unmarshal(respBody, &zhichaResp); err != nil {
|
||||
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %s", err.Error()))
|
||||
if z.logger != nil {
|
||||
z.logger.LogError(requestID, transactionID, proID, err, params)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查业务状态码
|
||||
if zhichaResp.Code != "200" && zhichaResp.Code != "201" {
|
||||
// 创建智查金控错误用于日志记录
|
||||
zhichaErr := NewZhichaErrorFromCode(zhichaResp.Code)
|
||||
if zhichaErr.Code == "未知错误" {
|
||||
zhichaErr.Message = zhichaResp.Message
|
||||
}
|
||||
|
||||
// 记录智查金控的详细错误信息到日志
|
||||
if z.logger != nil {
|
||||
z.logger.LogError(requestID, transactionID, proID, zhichaErr, params)
|
||||
}
|
||||
|
||||
// 对外统一返回数据源异常错误
|
||||
return nil, ErrDatasource
|
||||
}
|
||||
|
||||
// 201 表示查询为空,兼容其它情况如果data也为空,则返回空对象
|
||||
if zhichaResp.Code == "201" {
|
||||
// 先做类型断言
|
||||
dataMap, ok := zhichaResp.Data.(map[string]interface{})
|
||||
if ok && len(dataMap) > 0 {
|
||||
return dataMap, nil
|
||||
}
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
// 返回data字段
|
||||
return zhichaResp.Data, nil
|
||||
}
|
||||
|
||||
// Encrypt 使用配置的加密密钥对数据进行AES-128-CBC加密
|
||||
func (z *ZhichaService) Encrypt(data string) (string, error) {
|
||||
if z.config.EncryptKey == "" {
|
||||
return "", fmt.Errorf("加密密钥未配置")
|
||||
}
|
||||
|
||||
// 将十六进制密钥转换为字节
|
||||
binKey, err := hex.DecodeString(z.config.EncryptKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("密钥格式错误: %w", err)
|
||||
}
|
||||
|
||||
if len(binKey) < 16 { // AES-128, 16 bytes
|
||||
return "", fmt.Errorf("密钥长度不足,需要至少16字节")
|
||||
}
|
||||
|
||||
// 从密钥前16个字符生成IV
|
||||
iv := []byte(z.config.EncryptKey[:16])
|
||||
|
||||
// 创建AES加密器
|
||||
block, err := aes.NewCipher(binKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建AES加密器失败: %w", err)
|
||||
}
|
||||
|
||||
// 对数据进行PKCS7填充
|
||||
paddedData := z.pkcs7Padding([]byte(data), aes.BlockSize)
|
||||
|
||||
// 创建CBC模式加密器
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
|
||||
// 加密
|
||||
ciphertext := make([]byte, len(paddedData))
|
||||
mode.CryptBlocks(ciphertext, paddedData)
|
||||
|
||||
// 返回Base64编码结果
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt 使用配置的加密密钥对数据进行AES-128-CBC解密
|
||||
func (z *ZhichaService) Decrypt(encryptedData string) (string, error) {
|
||||
if z.config.EncryptKey == "" {
|
||||
return "", fmt.Errorf("加密密钥未配置")
|
||||
}
|
||||
|
||||
// 将十六进制密钥转换为字节
|
||||
binKey, err := hex.DecodeString(z.config.EncryptKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("密钥格式错误: %w", err)
|
||||
}
|
||||
|
||||
if len(binKey) < 16 { // AES-128, 16 bytes
|
||||
return "", fmt.Errorf("密钥长度不足,需要至少16字节")
|
||||
}
|
||||
|
||||
// 从密钥前16个字符生成IV
|
||||
iv := []byte(z.config.EncryptKey[:16])
|
||||
|
||||
// 解码Base64数据
|
||||
decodedData, err := base64.StdEncoding.DecodeString(encryptedData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Base64解码失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查数据长度是否为AES块大小的倍数
|
||||
if len(decodedData) == 0 || len(decodedData)%aes.BlockSize != 0 {
|
||||
return "", fmt.Errorf("加密数据长度无效,必须是%d字节的倍数", aes.BlockSize)
|
||||
}
|
||||
|
||||
// 创建AES解密器
|
||||
block, err := aes.NewCipher(binKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建AES解密器失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建CBC模式解密器
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
|
||||
// 解密
|
||||
plaintext := make([]byte, len(decodedData))
|
||||
mode.CryptBlocks(plaintext, decodedData)
|
||||
|
||||
// 移除PKCS7填充
|
||||
unpadded, err := z.pkcs7Unpadding(plaintext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("移除填充失败: %w", err)
|
||||
}
|
||||
|
||||
return string(unpadded), nil
|
||||
}
|
||||
|
||||
// pkcs7Padding 使用PKCS7填充数据
|
||||
func (z *ZhichaService) pkcs7Padding(src []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(src)%blockSize
|
||||
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(src, padtext...)
|
||||
}
|
||||
|
||||
// pkcs7Unpadding 移除PKCS7填充
|
||||
func (z *ZhichaService) pkcs7Unpadding(src []byte) ([]byte, error) {
|
||||
length := len(src)
|
||||
if length == 0 {
|
||||
return nil, fmt.Errorf("数据为空")
|
||||
}
|
||||
|
||||
unpadding := int(src[length-1])
|
||||
if unpadding > length {
|
||||
return nil, fmt.Errorf("填充长度无效")
|
||||
}
|
||||
|
||||
return src[:length-unpadding], nil
|
||||
}
|
||||
703
internal/infrastructure/external/zhicha/zhicha_test.go
vendored
Normal file
703
internal/infrastructure/external/zhicha/zhicha_test.go
vendored
Normal file
@@ -0,0 +1,703 @@
|
||||
package zhicha
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateSign(t *testing.T) {
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
AppSecret: "test_secret_123",
|
||||
},
|
||||
}
|
||||
|
||||
timestamp := int64(1640995200) // 2022-01-01 00:00:00
|
||||
sign := service.generateSign(timestamp)
|
||||
|
||||
if sign == "" {
|
||||
t.Error("签名生成失败,签名为空")
|
||||
}
|
||||
|
||||
// 验证签名长度(MD5是32位十六进制)
|
||||
if len(sign) != 32 {
|
||||
t.Errorf("签名长度错误,期望32位,实际%d位", len(sign))
|
||||
}
|
||||
|
||||
// 验证相同参数生成相同签名
|
||||
sign2 := service.generateSign(timestamp)
|
||||
if sign != sign2 {
|
||||
t.Error("相同参数生成的签名不一致")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
// 测试密钥(32位十六进制)
|
||||
key := "1234567890abcdef1234567890abcdef"
|
||||
|
||||
// 测试数据
|
||||
testData := "这是一个测试数据,包含中文和English"
|
||||
|
||||
// 加密
|
||||
encrypted, err := Encrypt(testData, key)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
|
||||
if encrypted == "" {
|
||||
t.Error("加密结果为空")
|
||||
}
|
||||
|
||||
// 解密
|
||||
decrypted, err := Decrypt(encrypted, key)
|
||||
if err != nil {
|
||||
t.Fatalf("解密失败: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != testData {
|
||||
t.Errorf("解密结果不匹配,期望: %s, 实际: %s", testData, decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptWithInvalidKey(t *testing.T) {
|
||||
// 测试无效密钥
|
||||
invalidKeys := []string{
|
||||
"", // 空密钥
|
||||
"123", // 太短
|
||||
"invalid_key_string", // 非十六进制
|
||||
"1234567890abcdef", // 16位,不足32位
|
||||
}
|
||||
|
||||
testData := "test data"
|
||||
|
||||
for _, key := range invalidKeys {
|
||||
_, err := Encrypt(testData, key)
|
||||
if err == nil {
|
||||
t.Errorf("使用无效密钥 %s 应该返回错误", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithInvalidData(t *testing.T) {
|
||||
key := "af4ca0098e6a202a5c08c413ebd9fd62"
|
||||
|
||||
// 测试无效的加密数据
|
||||
invalidData := []string{
|
||||
"", // 空数据
|
||||
"invalid_base64", // 无效的Base64
|
||||
"dGVzdA==", // 有效的Base64但不是AES加密数据
|
||||
"i96w+SDjwENjuvsokMFbLw==",
|
||||
"oaihmICgEcszWMk0gXoB12E/ygF4g78x0/sC3/KHnBk=",
|
||||
"5bx+WvXvdNRVVOp9UuNFHg==",
|
||||
}
|
||||
|
||||
for _, data := range invalidData {
|
||||
decrypted, err := Decrypt(data, key)
|
||||
if err == nil {
|
||||
t.Errorf("使用无效数据 %s 应该返回错误", data)
|
||||
}
|
||||
fmt.Println("data: ", data)
|
||||
fmt.Println("decrypted: ", decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCS7Padding(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
blockSize int
|
||||
expected int
|
||||
}{
|
||||
{"", 16, 16},
|
||||
{"a", 16, 16},
|
||||
{"ab", 16, 16},
|
||||
{"abc", 16, 16},
|
||||
{"abcd", 16, 16},
|
||||
{"abcde", 16, 16},
|
||||
{"abcdef", 16, 16},
|
||||
{"abcdefg", 16, 16},
|
||||
{"abcdefgh", 16, 16},
|
||||
{"abcdefghi", 16, 16},
|
||||
{"abcdefghij", 16, 16},
|
||||
{"abcdefghijk", 16, 16},
|
||||
{"abcdefghijkl", 16, 16},
|
||||
{"abcdefghijklm", 16, 16},
|
||||
{"abcdefghijklmn", 16, 16},
|
||||
{"abcdefghijklmno", 16, 16},
|
||||
{"abcdefghijklmnop", 16, 16},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
padded := pkcs7Padding([]byte(tc.input), tc.blockSize)
|
||||
if len(padded)%tc.blockSize != 0 {
|
||||
t.Errorf("输入: %s, 期望块大小倍数,实际: %d", tc.input, len(padded))
|
||||
}
|
||||
|
||||
// 测试移除填充
|
||||
unpadded, err := pkcs7Unpadding(padded)
|
||||
if err != nil {
|
||||
t.Errorf("移除填充失败: %v", err)
|
||||
}
|
||||
|
||||
if string(unpadded) != tc.input {
|
||||
t.Errorf("输入: %s, 期望: %s, 实际: %s", tc.input, tc.input, string(unpadded))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRequestID(t *testing.T) {
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
AppID: "test_app_id",
|
||||
},
|
||||
}
|
||||
|
||||
id1 := service.generateRequestID()
|
||||
|
||||
// 等待一小段时间确保时间戳不同
|
||||
time.Sleep(time.Millisecond)
|
||||
|
||||
id2 := service.generateRequestID()
|
||||
|
||||
if id1 == "" || id2 == "" {
|
||||
t.Error("请求ID生成失败")
|
||||
}
|
||||
|
||||
if id1 == id2 {
|
||||
t.Error("不同时间生成的请求ID应该不同")
|
||||
}
|
||||
|
||||
// 验证ID格式
|
||||
if len(id1) < 20 { // zhicha_ + 8位十六进制 + 其他
|
||||
t.Errorf("请求ID长度不足,实际: %s", id1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallAPISuccess(t *testing.T) {
|
||||
// 创建测试服务
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
URL: "http://proxy.haiyudata.com/dataMiddle/api/handle",
|
||||
AppID: "4b78fff61ab8426f",
|
||||
AppSecret: "1128f01b94124ae899c2e9f2b1f37681",
|
||||
EncryptKey: "af4ca0098e6a202a5c08c413ebd9fd62",
|
||||
},
|
||||
logger: nil, // 测试时不使用日志
|
||||
}
|
||||
|
||||
// 测试参数
|
||||
idCardEncrypted, err := service.Encrypt("45212220000827423X")
|
||||
if err != nil {
|
||||
t.Fatalf("加密身份证号失败: %v", err)
|
||||
}
|
||||
nameEncrypted, err := service.Encrypt("张荣宏")
|
||||
if err != nil {
|
||||
t.Fatalf("加密姓名失败: %v", err)
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"idCard": idCardEncrypted,
|
||||
"name": nameEncrypted,
|
||||
"authorized": "1",
|
||||
}
|
||||
|
||||
// 创建带超时的context
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 调用API
|
||||
data, err := service.CallAPI(ctx, "ZCI001", params)
|
||||
|
||||
// 注意:这是真实API调用,可能会因为网络、认证等原因失败
|
||||
// 我们主要测试方法调用是否正常,不强制要求API返回成功
|
||||
if err != nil {
|
||||
// 如果是网络错误或认证错误,这是正常的
|
||||
t.Logf("API调用返回错误: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果成功,验证响应
|
||||
if data == nil {
|
||||
t.Error("响应数据为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 将data转换为字符串进行显示
|
||||
var dataStr string
|
||||
if str, ok := data.(string); ok {
|
||||
dataStr = str
|
||||
} else {
|
||||
// 如果不是字符串,尝试JSON序列化
|
||||
if dataBytes, err := json.Marshal(data); err == nil {
|
||||
dataStr = string(dataBytes)
|
||||
} else {
|
||||
dataStr = fmt.Sprintf("%v", data)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("API调用成功,响应内容: %s", dataStr)
|
||||
}
|
||||
|
||||
func TestCallAPIWithInvalidURL(t *testing.T) {
|
||||
// 创建使用无效URL的服务
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
URL: "https://invalid-url-that-does-not-exist.com/api",
|
||||
AppID: "test_app_id",
|
||||
AppSecret: "test_app_secret",
|
||||
EncryptKey: "test_encrypt_key",
|
||||
},
|
||||
logger: nil,
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"test": "data",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 应该返回错误
|
||||
_, err := service.CallAPI(ctx, "test_pro_id", params)
|
||||
if err == nil {
|
||||
t.Error("使用无效URL应该返回错误")
|
||||
}
|
||||
|
||||
t.Logf("预期的错误: %v", err)
|
||||
}
|
||||
|
||||
func TestCallAPIWithContextCancellation(t *testing.T) {
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
URL: "https://www.zhichajinkong.com/dataMiddle/api/handle",
|
||||
AppID: "4b78fff61ab8426f",
|
||||
AppSecret: "1128f01b94124ae899c2e9f2b1f37681",
|
||||
EncryptKey: "af4ca0098e6a202a5c08c413ebd9fd62",
|
||||
},
|
||||
logger: nil,
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"test": "data",
|
||||
}
|
||||
|
||||
// 创建可取消的context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// 立即取消
|
||||
cancel()
|
||||
|
||||
// 应该返回context取消错误
|
||||
_, err := service.CallAPI(ctx, "test_pro_id", params)
|
||||
if err == nil {
|
||||
t.Error("context取消后应该返回错误")
|
||||
}
|
||||
|
||||
// 检查是否是context取消错误
|
||||
if err != context.Canceled && !strings.Contains(err.Error(), "context") {
|
||||
t.Errorf("期望context相关错误,实际: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Context取消错误: %v", err)
|
||||
}
|
||||
|
||||
func TestCallAPIWithTimeout(t *testing.T) {
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
URL: "https://www.zhichajinkong.com/dataMiddle/api/handle",
|
||||
AppID: "4b78fff61ab8426f",
|
||||
AppSecret: "1128f01b94124ae899c2e9f2b1f37681",
|
||||
EncryptKey: "af4ca0098e6a202a5c08c413ebd9fd62",
|
||||
},
|
||||
logger: nil,
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"test": "data",
|
||||
}
|
||||
|
||||
// 创建很短的超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// 应该因为超时而失败
|
||||
_, err := service.CallAPI(ctx, "test_pro_id", params)
|
||||
if err == nil {
|
||||
t.Error("超时后应该返回错误")
|
||||
}
|
||||
|
||||
// 检查是否是超时错误
|
||||
if err != context.DeadlineExceeded && !strings.Contains(err.Error(), "timeout") && !strings.Contains(err.Error(), "deadline") {
|
||||
t.Errorf("期望超时相关错误,实际: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("超时错误: %v", err)
|
||||
}
|
||||
|
||||
func TestCallAPIRequestHeaders(t *testing.T) {
|
||||
// 这个测试验证请求头是否正确设置
|
||||
// 由于我们不能直接访问HTTP请求,我们通过日志或其他方式来验证
|
||||
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
URL: "https://www.zhichajinkong.com/dataMiddle/api/handle",
|
||||
AppID: "test_app_id",
|
||||
AppSecret: "test_app_secret",
|
||||
EncryptKey: "test_encrypt_key",
|
||||
},
|
||||
logger: nil,
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"test": "headers",
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 调用API(可能会失败,但我们主要测试请求头设置)
|
||||
_, err := service.CallAPI(ctx, "test_pro_id", params)
|
||||
|
||||
// 验证签名生成是否正确
|
||||
timestamp := time.Now().Unix()
|
||||
sign := service.generateSign(timestamp)
|
||||
|
||||
if sign == "" {
|
||||
t.Error("签名生成失败")
|
||||
}
|
||||
|
||||
if len(sign) != 32 {
|
||||
t.Errorf("签名长度错误,期望32位,实际%d位", len(sign))
|
||||
}
|
||||
|
||||
t.Logf("签名生成成功: %s", sign)
|
||||
t.Logf("API调用结果: %v", err)
|
||||
}
|
||||
|
||||
func TestZhichaErrorHandling(t *testing.T) {
|
||||
// 测试核心错误类型
|
||||
testCases := []struct {
|
||||
name string
|
||||
code string
|
||||
message string
|
||||
expectedErr *ZhichaError
|
||||
}{
|
||||
{
|
||||
name: "成功状态",
|
||||
code: "200",
|
||||
message: "请求成功",
|
||||
expectedErr: ErrSuccess,
|
||||
},
|
||||
{
|
||||
name: "查询无记录",
|
||||
code: "201",
|
||||
message: "查询无记录",
|
||||
expectedErr: ErrNoRecord,
|
||||
},
|
||||
{
|
||||
name: "手机号错误",
|
||||
code: "306",
|
||||
message: "手机号错误",
|
||||
expectedErr: ErrPhoneError,
|
||||
},
|
||||
{
|
||||
name: "姓名错误",
|
||||
code: "305",
|
||||
message: "姓名错误",
|
||||
expectedErr: ErrNameError,
|
||||
},
|
||||
{
|
||||
name: "身份证号错误",
|
||||
code: "307",
|
||||
message: "身份证号错误",
|
||||
expectedErr: ErrIDCardError,
|
||||
},
|
||||
{
|
||||
name: "余额不足",
|
||||
code: "310",
|
||||
message: "余额不足",
|
||||
expectedErr: ErrInsufficientBalance,
|
||||
},
|
||||
{
|
||||
name: "用户不存在",
|
||||
code: "312",
|
||||
message: "用户不存在",
|
||||
expectedErr: ErrUserNotExist,
|
||||
},
|
||||
{
|
||||
name: "系统异常",
|
||||
code: "500",
|
||||
message: "系统异常,请联系管理员",
|
||||
expectedErr: ErrSystemError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 测试从状态码创建错误
|
||||
err := NewZhichaErrorFromCode(tc.code)
|
||||
|
||||
if err.Code != tc.expectedErr.Code {
|
||||
t.Errorf("期望错误码 %s,实际 %s", tc.expectedErr.Code, err.Code)
|
||||
}
|
||||
|
||||
if err.Message != tc.expectedErr.Message {
|
||||
t.Errorf("期望错误消息 %s,实际 %s", tc.expectedErr.Message, err.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestZhichaErrorHelpers(t *testing.T) {
|
||||
// 测试错误类型判断函数
|
||||
err := NewZhichaError("302", "业务参数缺失")
|
||||
|
||||
// 测试IsZhichaError
|
||||
if !IsZhichaError(err) {
|
||||
t.Error("IsZhichaError应该返回true")
|
||||
}
|
||||
|
||||
// 测试GetZhichaError
|
||||
zhichaErr := GetZhichaError(err)
|
||||
if zhichaErr == nil {
|
||||
t.Error("GetZhichaError应该返回非nil值")
|
||||
}
|
||||
|
||||
if zhichaErr.Code != "302" {
|
||||
t.Errorf("期望错误码302,实际%s", zhichaErr.Code)
|
||||
}
|
||||
|
||||
// 测试普通错误
|
||||
normalErr := fmt.Errorf("普通错误")
|
||||
if IsZhichaError(normalErr) {
|
||||
t.Error("普通错误不应该被识别为智查金控错误")
|
||||
}
|
||||
|
||||
if GetZhichaError(normalErr) != nil {
|
||||
t.Error("普通错误的GetZhichaError应该返回nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZhichaErrorString(t *testing.T) {
|
||||
// 测试错误字符串格式
|
||||
err := NewZhichaError("304", "请求头参数缺失")
|
||||
expectedStr := "智查金控错误 [304]: 请求头参数缺失"
|
||||
|
||||
if err.Error() != expectedStr {
|
||||
t.Errorf("期望错误字符串 %s,实际 %s", expectedStr, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsIsFunctionality(t *testing.T) {
|
||||
// 测试 errors.Is() 功能是否正常工作
|
||||
|
||||
// 创建各种错误
|
||||
testCases := []struct {
|
||||
name string
|
||||
err error
|
||||
expected error
|
||||
shouldMatch bool
|
||||
}{
|
||||
{
|
||||
name: "手机号错误匹配",
|
||||
err: ErrPhoneError,
|
||||
expected: ErrPhoneError,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "姓名错误匹配",
|
||||
err: ErrNameError,
|
||||
expected: ErrNameError,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "身份证号错误匹配",
|
||||
err: ErrIDCardError,
|
||||
expected: ErrIDCardError,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "余额不足错误匹配",
|
||||
err: ErrInsufficientBalance,
|
||||
expected: ErrInsufficientBalance,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "用户不存在错误匹配",
|
||||
err: ErrUserNotExist,
|
||||
expected: ErrUserNotExist,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "系统错误匹配",
|
||||
err: ErrSystemError,
|
||||
expected: ErrSystemError,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "不同错误不匹配",
|
||||
err: ErrPhoneError,
|
||||
expected: ErrNameError,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "手机号错误与身份证号错误不匹配",
|
||||
err: ErrPhoneError,
|
||||
expected: ErrIDCardError,
|
||||
shouldMatch: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 使用 errors.Is() 进行判断
|
||||
if errors.Is(tc.err, tc.expected) != tc.shouldMatch {
|
||||
if tc.shouldMatch {
|
||||
t.Errorf("期望 errors.Is(%v, %v) 返回 true", tc.err, tc.expected)
|
||||
} else {
|
||||
t.Errorf("期望 errors.Is(%v, %v) 返回 false", tc.err, tc.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorsIsInSwitch(t *testing.T) {
|
||||
// 测试在 switch 语句中使用 errors.Is()
|
||||
|
||||
// 模拟API调用返回手机号错误
|
||||
err := ErrPhoneError
|
||||
|
||||
// 使用 switch 语句进行错误判断
|
||||
var result string
|
||||
switch {
|
||||
case errors.Is(err, ErrSuccess):
|
||||
result = "请求成功"
|
||||
case errors.Is(err, ErrNoRecord):
|
||||
result = "查询无记录"
|
||||
case errors.Is(err, ErrPhoneError):
|
||||
result = "手机号格式错误"
|
||||
case errors.Is(err, ErrNameError):
|
||||
result = "姓名格式错误"
|
||||
case errors.Is(err, ErrIDCardError):
|
||||
result = "身份证号格式错误"
|
||||
case errors.Is(err, ErrHeaderParamMissing):
|
||||
result = "请求头参数缺失"
|
||||
case errors.Is(err, ErrInsufficientBalance):
|
||||
result = "余额不足"
|
||||
case errors.Is(err, ErrUserNotExist):
|
||||
result = "用户不存在"
|
||||
case errors.Is(err, ErrUserUnauthorized):
|
||||
result = "用户未授权"
|
||||
case errors.Is(err, ErrSystemError):
|
||||
result = "系统异常"
|
||||
default:
|
||||
result = "未知错误"
|
||||
}
|
||||
|
||||
// 验证结果
|
||||
expected := "手机号格式错误"
|
||||
if result != expected {
|
||||
t.Errorf("期望结果 %s,实际 %s", expected, result)
|
||||
}
|
||||
|
||||
t.Logf("Switch语句错误判断结果: %s", result)
|
||||
}
|
||||
|
||||
func TestServiceEncryptDecrypt(t *testing.T) {
|
||||
// 创建测试服务
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
URL: "https://test.com",
|
||||
AppID: "test_app_id",
|
||||
AppSecret: "test_app_secret",
|
||||
EncryptKey: "af4ca0098e6a202a5c08c413ebd9fd62",
|
||||
},
|
||||
logger: nil,
|
||||
}
|
||||
|
||||
// 测试数据
|
||||
testData := "Hello, 智查金控!"
|
||||
|
||||
// 测试加密
|
||||
encrypted, err := service.Encrypt(testData)
|
||||
if err != nil {
|
||||
t.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
|
||||
if encrypted == "" {
|
||||
t.Error("加密结果为空")
|
||||
}
|
||||
|
||||
if encrypted == testData {
|
||||
t.Error("加密结果与原文相同")
|
||||
}
|
||||
|
||||
t.Logf("原文: %s", testData)
|
||||
t.Logf("加密后: %s", encrypted)
|
||||
|
||||
// 测试解密
|
||||
decrypted, err := service.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("解密失败: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != testData {
|
||||
t.Errorf("解密结果不匹配,期望: %s,实际: %s", testData, decrypted)
|
||||
}
|
||||
|
||||
t.Logf("解密后: %s", decrypted)
|
||||
}
|
||||
|
||||
func TestEncryptWithoutKey(t *testing.T) {
|
||||
// 创建没有加密密钥的服务
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
URL: "https://test.com",
|
||||
AppID: "test_app_id",
|
||||
AppSecret: "test_app_secret",
|
||||
// 没有设置 EncryptKey
|
||||
},
|
||||
logger: nil,
|
||||
}
|
||||
|
||||
// 应该返回错误
|
||||
_, err := service.Encrypt("test data")
|
||||
if err == nil {
|
||||
t.Error("没有加密密钥时应该返回错误")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "加密密钥未配置") {
|
||||
t.Errorf("期望错误包含'加密密钥未配置',实际: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("预期的错误: %v", err)
|
||||
}
|
||||
|
||||
func TestDecryptWithoutKey(t *testing.T) {
|
||||
// 创建没有加密密钥的服务
|
||||
service := &ZhichaService{
|
||||
config: ZhichaConfig{
|
||||
URL: "https://test.com",
|
||||
AppID: "test_app_id",
|
||||
AppSecret: "test_app_secret",
|
||||
// 没有设置 EncryptKey
|
||||
},
|
||||
logger: nil,
|
||||
}
|
||||
|
||||
// 应该返回错误
|
||||
_, err := service.Decrypt("test encrypted data")
|
||||
if err == nil {
|
||||
t.Error("没有加密密钥时应该返回错误")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "加密密钥未配置") {
|
||||
t.Errorf("期望错误包含'加密密钥未配置',实际: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("预期的错误: %v", err)
|
||||
}
|
||||
Reference in New Issue
Block a user