This commit is contained in:
2025-07-11 21:05:58 +08:00
parent 5b4392894f
commit e3d64e7485
74 changed files with 14379 additions and 697 deletions

View File

@@ -43,6 +43,9 @@ func NewConnection(config Config) (*DB, error) {
SingularTable: true, // 使用单数表名
},
DisableForeignKeyConstraintWhenMigrating: true,
NowFunc: func() time.Time {
return time.Now().In(time.FixedZone("CST", 8*3600)) // 强制使用北京时间
},
}
// 连接数据库
@@ -76,7 +79,7 @@ func NewConnection(config Config) (*DB, error) {
// buildDSN 构建数据库连接字符串
func buildDSN(config Config) string {
return fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s",
"host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s options='-c timezone=%s'",
config.Host,
config.User,
config.Password,
@@ -84,6 +87,7 @@ func buildDSN(config Config) string {
config.Port,
config.SSLMode,
config.Timezone,
config.Timezone,
)
}

View File

@@ -1,303 +0,0 @@
package http
import (
"fmt"
"strings"
"tyapi-server/internal/shared/interfaces"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
// RequestValidator 请求验证器实现
type RequestValidator struct {
validator *validator.Validate
response interfaces.ResponseBuilder
}
// NewRequestValidator 创建请求验证器
func NewRequestValidator(response interfaces.ResponseBuilder) interfaces.RequestValidator {
v := validator.New()
// 注册自定义验证器
registerCustomValidators(v)
return &RequestValidator{
validator: v,
response: response,
}
}
// Validate 验证请求体
func (v *RequestValidator) Validate(c *gin.Context, dto interface{}) error {
if err := v.validator.Struct(dto); err != nil {
validationErrors := v.formatValidationErrors(err)
v.response.BadRequest(c, "Validation failed", validationErrors)
return err
}
return nil
}
// ValidateQuery 验证查询参数
func (v *RequestValidator) ValidateQuery(c *gin.Context, dto interface{}) error {
if err := c.ShouldBindQuery(dto); err != nil {
v.response.BadRequest(c, "查询参数格式错误", err.Error())
return err
}
if err := v.validator.Struct(dto); err != nil {
validationErrors := v.formatValidationErrors(err)
v.response.ValidationError(c, validationErrors)
return err
}
return nil
}
// ValidateParam 验证路径参数
func (v *RequestValidator) ValidateParam(c *gin.Context, dto interface{}) error {
if err := c.ShouldBindUri(dto); err != nil {
v.response.BadRequest(c, "路径参数格式错误", err.Error())
return err
}
if err := v.validator.Struct(dto); err != nil {
validationErrors := v.formatValidationErrors(err)
v.response.ValidationError(c, validationErrors)
return err
}
return nil
}
// BindAndValidate 绑定并验证请求
func (v *RequestValidator) BindAndValidate(c *gin.Context, dto interface{}) error {
// 绑定请求体
if err := c.ShouldBindJSON(dto); err != nil {
v.response.BadRequest(c, "请求体格式错误", err.Error())
return err
}
// 验证数据
return v.Validate(c, dto)
}
// formatValidationErrors 格式化验证错误
func (v *RequestValidator) formatValidationErrors(err error) map[string][]string {
errors := make(map[string][]string)
if validationErrors, ok := err.(validator.ValidationErrors); ok {
for _, fieldError := range validationErrors {
fieldName := v.getFieldName(fieldError)
errorMessage := v.getErrorMessage(fieldError)
if _, exists := errors[fieldName]; !exists {
errors[fieldName] = []string{}
}
errors[fieldName] = append(errors[fieldName], errorMessage)
}
}
return errors
}
// getFieldName 获取字段名JSON标签优先
func (v *RequestValidator) getFieldName(fieldError validator.FieldError) string {
// 可以通过反射获取JSON标签这里简化处理
fieldName := fieldError.Field()
// 转换为snake_case可选
return v.toSnakeCase(fieldName)
}
// getErrorMessage 获取错误消息
func (v *RequestValidator) getErrorMessage(fieldError validator.FieldError) string {
field := fieldError.Field()
tag := fieldError.Tag()
param := fieldError.Param()
fieldDisplayName := v.getFieldDisplayName(field)
switch tag {
case "required":
return fmt.Sprintf("%s 不能为空", fieldDisplayName)
case "email":
return fmt.Sprintf("%s 必须是有效的邮箱地址", fieldDisplayName)
case "min":
return fmt.Sprintf("%s 长度不能少于 %s 位", fieldDisplayName, param)
case "max":
return fmt.Sprintf("%s 长度不能超过 %s 位", fieldDisplayName, param)
case "len":
return fmt.Sprintf("%s 长度必须为 %s 位", fieldDisplayName, param)
case "gt":
return fmt.Sprintf("%s 必须大于 %s", fieldDisplayName, param)
case "gte":
return fmt.Sprintf("%s 必须大于等于 %s", fieldDisplayName, param)
case "lt":
return fmt.Sprintf("%s 必须小于 %s", fieldDisplayName, param)
case "lte":
return fmt.Sprintf("%s 必须小于等于 %s", fieldDisplayName, param)
case "oneof":
return fmt.Sprintf("%s 必须是以下值之一:[%s]", fieldDisplayName, param)
case "url":
return fmt.Sprintf("%s 必须是有效的URL地址", fieldDisplayName)
case "alpha":
return fmt.Sprintf("%s 只能包含字母", fieldDisplayName)
case "alphanum":
return fmt.Sprintf("%s 只能包含字母和数字", fieldDisplayName)
case "numeric":
return fmt.Sprintf("%s 必须是数字", fieldDisplayName)
case "phone":
return fmt.Sprintf("%s 必须是有效的手机号", fieldDisplayName)
case "username":
return fmt.Sprintf("%s 格式不正确,只能包含字母、数字、下划线,且不能以数字开头", fieldDisplayName)
case "strong_password":
return fmt.Sprintf("%s 强度不足必须包含大小写字母和数字且不少于8位", fieldDisplayName)
case "eqfield":
return fmt.Sprintf("%s 必须与 %s 一致", fieldDisplayName, v.getFieldDisplayName(param))
default:
return fmt.Sprintf("%s 格式不正确", fieldDisplayName)
}
}
// getFieldDisplayName 获取字段显示名称(中文)
func (v *RequestValidator) getFieldDisplayName(field string) string {
fieldNames := map[string]string{
"phone": "手机号",
"password": "密码",
"confirm_password": "确认密码",
"old_password": "原密码",
"new_password": "新密码",
"confirm_new_password": "确认新密码",
"code": "验证码",
"username": "用户名",
"email": "邮箱",
"display_name": "显示名称",
"scene": "使用场景",
"Password": "密码",
"NewPassword": "新密码",
}
if displayName, exists := fieldNames[field]; exists {
return displayName
}
return field
}
// toSnakeCase 转换为snake_case
func (v *RequestValidator) toSnakeCase(str string) string {
var result strings.Builder
for i, r := range str {
if i > 0 && (r >= 'A' && r <= 'Z') {
result.WriteRune('_')
}
result.WriteRune(r)
}
return strings.ToLower(result.String())
}
// registerCustomValidators 注册自定义验证器
func registerCustomValidators(v *validator.Validate) {
// 注册手机号验证器
v.RegisterValidation("phone", validatePhone)
// 注册用户名验证器
v.RegisterValidation("username", validateUsername)
// 注册密码强度验证器
v.RegisterValidation("strong_password", validateStrongPassword)
}
// validatePhone 验证手机号
func validatePhone(fl validator.FieldLevel) bool {
phone := fl.Field().String()
if phone == "" {
return true // 空值由required标签处理
}
// 简单的手机号验证(可根据需要完善)
if len(phone) < 10 || len(phone) > 15 {
return false
}
// 检查是否以+开头或全是数字
if strings.HasPrefix(phone, "+") {
phone = phone[1:]
}
for _, r := range phone {
if r < '0' || r > '9' {
if r != '-' && r != ' ' && r != '(' && r != ')' {
return false
}
}
}
return true
}
// validateUsername 验证用户名
func validateUsername(fl validator.FieldLevel) bool {
username := fl.Field().String()
if username == "" {
return true // 空值由required标签处理
}
// 用户名规则3-30个字符只能包含字母、数字、下划线不能以数字开头
if len(username) < 3 || len(username) > 30 {
return false
}
// 不能以数字开头
if username[0] >= '0' && username[0] <= '9' {
return false
}
// 只能包含字母、数字、下划线
for _, r := range username {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') {
return false
}
}
return true
}
// validateStrongPassword 验证密码强度
func validateStrongPassword(fl validator.FieldLevel) bool {
password := fl.Field().String()
if password == "" {
return true // 空值由required标签处理
}
// 密码强度规则至少8个字符包含大小写字母、数字
if len(password) < 8 {
return false
}
hasUpper := false
hasLower := false
hasDigit := false
for _, r := range password {
switch {
case r >= 'A' && r <= 'Z':
hasUpper = true
case r >= 'a' && r <= 'z':
hasLower = true
case r >= '0' && r <= '9':
hasDigit = true
}
}
return hasUpper && hasLower && hasDigit
}
// ValidateStruct 直接验证结构体不通过HTTP上下文
func (v *RequestValidator) ValidateStruct(dto interface{}) error {
return v.validator.Struct(dto)
}
// GetValidator 获取原始验证器(用于特殊情况)
func (v *RequestValidator) GetValidator() *validator.Validate {
return v.validator
}

View File

@@ -1,6 +1,7 @@
package http
import (
"fmt"
"strings"
"tyapi-server/internal/shared/interfaces"
@@ -14,16 +15,12 @@ import (
// RequestValidatorZh 中文验证器实现
type RequestValidatorZh struct {
validator *validator.Validate
translator ut.Translator
response interfaces.ResponseBuilder
translator ut.Translator
}
// NewRequestValidatorZh 创建支持中文翻译的请求验证器
func NewRequestValidatorZh(response interfaces.ResponseBuilder) interfaces.RequestValidator {
// 创建验证器实例
validate := validator.New()
// 创建中文locale
zhLocale := zh.New()
uni := ut.New(zhLocale, zhLocale)
@@ -31,39 +28,64 @@ func NewRequestValidatorZh(response interfaces.ResponseBuilder) interfaces.Reque
// 获取中文翻译器
trans, _ := uni.GetTranslator("zh")
// 注册中文翻译
zh_translations.RegisterDefaultTranslations(validate, trans)
// 注册官方中文翻译
zh_translations.RegisterDefaultTranslations(validator.New(), trans)
// 注册自定义验证器
registerCustomValidatorsZh(validate, trans)
// 注册自定义翻译
registerCustomTranslations(trans)
return &RequestValidatorZh{
validator: validate,
translator: trans,
response: response,
translator: trans,
}
}
// registerCustomTranslations 注册自定义翻译
func registerCustomTranslations(trans ut.Translator) {
// 自定义 eqfield 翻译(更友好的提示)
_ = trans.Add("eqfield", "{0}必须与{1}一致", true)
// 自定义 required 翻译
_ = trans.Add("required", "{0}不能为空", true)
// 自定义 min 翻译
_ = trans.Add("min", "{0}长度不能少于{1}位", true)
// 自定义 max 翻译
_ = trans.Add("max", "{0}长度不能超过{1}位", true)
// 自定义 len 翻译
_ = trans.Add("len", "{0}长度必须为{1}位", true)
// 自定义 email 翻译
_ = trans.Add("email", "{0}必须是有效的邮箱地址", true)
// 自定义手机号翻译
_ = trans.Add("phone", "{0}必须是有效的手机号", true)
// 自定义用户名翻译
_ = trans.Add("username", "{0}格式不正确,只能包含字母、数字、下划线,且不能以数字开头", true)
// 自定义强密码翻译
_ = trans.Add("strong_password", "{0}强度不足必须包含大小写字母和数字且不少于8位", true)
}
// Validate 验证请求体
func (v *RequestValidatorZh) Validate(c *gin.Context, dto interface{}) error {
if err := v.validator.Struct(dto); err != nil {
validationErrors := v.formatValidationErrorsZh(err)
v.response.ValidationError(c, validationErrors)
return err
}
return nil
// 直接使用 Gin 的绑定和验证
return v.BindAndValidate(c, dto)
}
// ValidateQuery 验证查询参数
func (v *RequestValidatorZh) ValidateQuery(c *gin.Context, dto interface{}) error {
if err := c.ShouldBindQuery(dto); err != nil {
v.response.BadRequest(c, "查询参数格式错误", err.Error())
return err
}
if err := v.validator.Struct(dto); err != nil {
validationErrors := v.formatValidationErrorsZh(err)
v.response.ValidationError(c, validationErrors)
// 处理查询参数验证错误
if _, ok := err.(validator.ValidationErrors); ok {
validationErrors := v.formatValidationErrorsZh(err)
v.response.ValidationError(c, validationErrors)
} else {
v.response.BadRequest(c, "查询参数格式错误", err.Error())
}
return err
}
return nil
@@ -72,13 +94,13 @@ func (v *RequestValidatorZh) ValidateQuery(c *gin.Context, dto interface{}) erro
// ValidateParam 验证路径参数
func (v *RequestValidatorZh) ValidateParam(c *gin.Context, dto interface{}) error {
if err := c.ShouldBindUri(dto); err != nil {
v.response.BadRequest(c, "路径参数格式错误", err.Error())
return err
}
if err := v.validator.Struct(dto); err != nil {
validationErrors := v.formatValidationErrorsZh(err)
v.response.ValidationError(c, validationErrors)
// 处理路径参数验证错误
if _, ok := err.(validator.ValidationErrors); ok {
validationErrors := v.formatValidationErrorsZh(err)
v.response.ValidationError(c, validationErrors)
} else {
v.response.BadRequest(c, "路径参数格式错误", err.Error())
}
return err
}
return nil
@@ -86,14 +108,20 @@ func (v *RequestValidatorZh) ValidateParam(c *gin.Context, dto interface{}) erro
// BindAndValidate 绑定并验证请求
func (v *RequestValidatorZh) BindAndValidate(c *gin.Context, dto interface{}) error {
// 绑定请求体
// 绑定请求体Gin 会自动进行 binding 标签验证)
if err := c.ShouldBindJSON(dto); err != nil {
v.response.BadRequest(c, "请求体格式错误", err.Error())
// 处理 Gin binding 验证错误
if _, ok := err.(validator.ValidationErrors); ok {
// 所有验证错误都使用 422 状态码
validationErrors := v.formatValidationErrorsZh(err)
v.response.ValidationError(c, validationErrors)
} else {
v.response.BadRequest(c, "请求体格式错误", err.Error())
}
return err
}
// 验证数据
return v.Validate(c, dto)
return nil
}
// formatValidationErrorsZh 格式化验证错误(中文翻译版)
@@ -104,15 +132,8 @@ func (v *RequestValidatorZh) formatValidationErrorsZh(err error) map[string][]st
for _, fieldError := range validationErrors {
fieldName := v.getFieldNameZh(fieldError)
// 首先尝试使用翻译器获取翻译后的错误消息
errorMessage := fieldError.Translate(v.translator)
// 如果翻译后的消息包含英文字段名,则替换为中文字段名
fieldDisplayName := v.getFieldDisplayName(fieldError.Field())
if fieldDisplayName != fieldError.Field() {
// 替换字段名为中文
errorMessage = strings.ReplaceAll(errorMessage, fieldError.Field(), fieldDisplayName)
}
// 获取友好的中文错误消息
errorMessage := v.getFriendlyErrorMessage(fieldError)
if _, exists := errors[fieldName]; !exists {
errors[fieldName] = []string{}
@@ -124,6 +145,53 @@ func (v *RequestValidatorZh) formatValidationErrorsZh(err error) map[string][]st
return errors
}
// getFriendlyErrorMessage 获取友好的中文错误消息
func (v *RequestValidatorZh) getFriendlyErrorMessage(fieldError validator.FieldError) string {
field := fieldError.Field()
tag := fieldError.Tag()
param := fieldError.Param()
fieldDisplayName := v.getFieldDisplayName(field)
// 优先使用官方翻译器
errorMessage := fieldError.Translate(v.translator)
// 如果官方翻译成功且不是英文,使用官方翻译
if errorMessage != fieldError.Error() {
// 替换字段名为中文
if fieldDisplayName != fieldError.Field() {
errorMessage = strings.ReplaceAll(errorMessage, fieldError.Field(), fieldDisplayName)
}
return errorMessage
}
// 回退到自定义翻译
switch tag {
case "required":
return fmt.Sprintf("%s不能为空", fieldDisplayName)
case "email":
return fmt.Sprintf("%s必须是有效的邮箱地址", fieldDisplayName)
case "min":
return fmt.Sprintf("%s长度不能少于%s位", fieldDisplayName, param)
case "max":
return fmt.Sprintf("%s长度不能超过%s位", fieldDisplayName, param)
case "len":
return fmt.Sprintf("%s长度必须为%s位", fieldDisplayName, param)
case "eqfield":
paramDisplayName := v.getFieldDisplayName(param)
return fmt.Sprintf("%s必须与%s一致", fieldDisplayName, paramDisplayName)
case "phone":
return fmt.Sprintf("%s必须是有效的手机号", fieldDisplayName)
case "username":
return fmt.Sprintf("%s格式不正确只能包含字母、数字、下划线且不能以数字开头", fieldDisplayName)
case "strong_password":
return fmt.Sprintf("%s强度不足必须包含大小写字母和数字且不少于8位", fieldDisplayName)
default:
// 默认错误消息
return fmt.Sprintf("%s格式不正确", fieldDisplayName)
}
}
// getFieldNameZh 获取字段名JSON标签优先
func (v *RequestValidatorZh) getFieldNameZh(fieldError validator.FieldError) string {
fieldName := fieldError.Field()
@@ -166,129 +234,3 @@ func (v *RequestValidatorZh) toSnakeCase(str string) string {
}
return strings.ToLower(result.String())
}
// registerCustomValidatorsZh 注册自定义验证器和中文翻译
func registerCustomValidatorsZh(v *validator.Validate, trans ut.Translator) {
// 注册手机号验证器
v.RegisterValidation("phone", validatePhoneZh)
v.RegisterTranslation("phone", trans, func(ut ut.Translator) error {
return ut.Add("phone", "{0}必须是有效的手机号", true)
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("phone", fe.Field())
return t
})
// 注册用户名验证器
v.RegisterValidation("username", validateUsernameZh)
v.RegisterTranslation("username", trans, func(ut ut.Translator) error {
return ut.Add("username", "{0}格式不正确,只能包含字母、数字、下划线,且不能以数字开头", true)
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("username", fe.Field())
return t
})
// 注册密码强度验证器
v.RegisterValidation("strong_password", validateStrongPasswordZh)
v.RegisterTranslation("strong_password", trans, func(ut ut.Translator) error {
return ut.Add("strong_password", "{0}强度不足必须包含大小写字母和数字且不少于8位", true)
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("strong_password", fe.Field())
return t
})
// 自定义eqfield翻译
v.RegisterTranslation("eqfield", trans, func(ut ut.Translator) error {
return ut.Add("eqfield", "{0}必须等于{1}", true)
}, func(ut ut.Translator, fe validator.FieldError) string {
t, _ := ut.T("eqfield", fe.Field(), fe.Param())
return t
})
}
// validatePhoneZh 验证手机号
func validatePhoneZh(fl validator.FieldLevel) bool {
phone := fl.Field().String()
if phone == "" {
return true // 空值由required标签处理
}
// 中国手机号验证11位以1开头
if len(phone) != 11 {
return false
}
if !strings.HasPrefix(phone, "1") {
return false
}
// 检查是否全是数字
for _, r := range phone {
if r < '0' || r > '9' {
return false
}
}
return true
}
// validateUsernameZh 验证用户名
func validateUsernameZh(fl validator.FieldLevel) bool {
username := fl.Field().String()
if username == "" {
return true // 空值由required标签处理
}
// 用户名规则3-30个字符只能包含字母、数字、下划线不能以数字开头
if len(username) < 3 || len(username) > 30 {
return false
}
// 不能以数字开头
if username[0] >= '0' && username[0] <= '9' {
return false
}
// 只能包含字母、数字、下划线
for _, r := range username {
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_') {
return false
}
}
return true
}
// validateStrongPasswordZh 验证密码强度
func validateStrongPasswordZh(fl validator.FieldLevel) bool {
password := fl.Field().String()
if password == "" {
return true // 空值由required标签处理
}
// 密码强度规则至少8个字符包含大小写字母、数字
if len(password) < 8 {
return false
}
hasUpper := false
hasLower := false
hasDigit := false
for _, r := range password {
switch {
case r >= 'A' && r <= 'Z':
hasUpper = true
case r >= 'a' && r <= 'z':
hasLower = true
case r >= '0' && r <= '9':
hasDigit = true
}
}
return hasUpper && hasLower && hasDigit
}
// ValidateStruct 直接验证结构体
func (v *RequestValidatorZh) ValidateStruct(dto interface{}) error {
return v.validator.Struct(dto)
}

View File

@@ -95,9 +95,6 @@ type RequestValidator interface {
// 绑定和验证
BindAndValidate(c *gin.Context, dto interface{}) error
// 直接验证结构体
ValidateStruct(dto interface{}) error
}
// PaginationMeta 分页元数据

View File

@@ -0,0 +1,515 @@
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: 30 * 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 "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)
}
// 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", "tyapi-server/1.0")
// 发送请求
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %w", 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&timestamp=%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)
}

View File

@@ -0,0 +1,548 @@
package ocr
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"go.uber.org/zap"
"tyapi-server/internal/domains/certification/dto"
)
// BaiduOCRService 百度OCR服务
type BaiduOCRService struct {
appID string
apiKey string
secretKey string
endpoint string
timeout time.Duration
logger *zap.Logger
}
// NewBaiduOCRService 创建百度OCR服务
func NewBaiduOCRService(appID, apiKey, secretKey string, logger *zap.Logger) *BaiduOCRService {
return &BaiduOCRService{
appID: appID,
apiKey: apiKey,
secretKey: secretKey,
endpoint: "https://aip.baidubce.com",
timeout: 30 * time.Second,
logger: logger,
}
}
// RecognizeBusinessLicense 识别营业执照
func (s *BaiduOCRService) RecognizeBusinessLicense(ctx context.Context, imageBytes []byte) (*dto.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
imageBase64 := base64.StdEncoding.EncodeToString(imageBytes)
// 构建请求参数
params := url.Values{}
params.Set("access_token", accessToken)
params.Set("image", imageBase64)
// 发送请求
apiURL := fmt.Sprintf("%s/rest/2.0/ocr/v1/business_license?%s", s.endpoint, params.Encode())
resp, err := s.sendRequest(ctx, "POST", apiURL, nil)
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.LegalRepresentative),
zap.String("registered_capital", licenseResult.RegisteredCapital),
)
return licenseResult, nil
}
// RecognizeIDCard 识别身份证
func (s *BaiduOCRService) RecognizeIDCard(ctx context.Context, imageBytes []byte, side string) (*dto.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
imageBase64 := base64.StdEncoding.EncodeToString(imageBytes)
// 构建请求参数
params := url.Values{}
params.Set("access_token", accessToken)
params.Set("image", imageBase64)
params.Set("side", side)
// 发送请求
apiURL := fmt.Sprintf("%s/rest/2.0/ocr/v1/idcard?%s", s.endpoint, params.Encode())
resp, err := s.sendRequest(ctx, "POST", apiURL, nil)
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.IDNumber),
zap.String("side", side),
)
return idCardResult, nil
}
// RecognizeGeneralText 通用文字识别
func (s *BaiduOCRService) RecognizeGeneralText(ctx context.Context, imageBytes []byte) (*dto.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
imageBase64 := base64.StdEncoding.EncodeToString(imageBytes)
// 构建请求参数
params := url.Values{}
params.Set("access_token", accessToken)
params.Set("image", imageBase64)
// 发送请求
apiURL := fmt.Sprintf("%s/rest/2.0/ocr/v1/general_basic?%s", s.endpoint, params.Encode())
resp, err := s.sendRequest(ctx, "POST", apiURL, nil)
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", "tyapi-server/1.0")
// 发送请求
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)
}
// 读取响应内容
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{}) *dto.BusinessLicenseResult {
// 解析百度OCR返回的结果
wordsResult := result["words_result"].(map[string]interface{})
licenseResult := &dto.BusinessLicenseResult{
Confidence: s.extractConfidence(result),
Words: s.extractWords(result),
}
// 提取关键字段
if companyName, ok := wordsResult["单位名称"]; ok {
if word, ok := companyName.(map[string]interface{}); ok {
licenseResult.CompanyName = word["words"].(string)
}
}
if legalRep, ok := wordsResult["法人"]; ok {
if word, ok := legalRep.(map[string]interface{}); ok {
licenseResult.LegalRepresentative = word["words"].(string)
}
}
if regCapital, ok := wordsResult["注册资本"]; ok {
if word, ok := regCapital.(map[string]interface{}); ok {
licenseResult.RegisteredCapital = word["words"].(string)
}
}
if regAddress, ok := wordsResult["地址"]; ok {
if word, ok := regAddress.(map[string]interface{}); ok {
licenseResult.RegisteredAddress = word["words"].(string)
}
}
if regNumber, ok := wordsResult["社会信用代码"]; ok {
if word, ok := regNumber.(map[string]interface{}); ok {
licenseResult.RegistrationNumber = word["words"].(string)
}
}
if businessScope, ok := wordsResult["经营范围"]; ok {
if word, ok := businessScope.(map[string]interface{}); ok {
licenseResult.BusinessScope = word["words"].(string)
}
}
if regDate, ok := wordsResult["成立日期"]; ok {
if word, ok := regDate.(map[string]interface{}); ok {
licenseResult.RegistrationDate = word["words"].(string)
}
}
if validDate, ok := wordsResult["营业期限"]; ok {
if word, ok := validDate.(map[string]interface{}); ok {
licenseResult.ValidDate = word["words"].(string)
}
}
return licenseResult
}
// parseIDCardResult 解析身份证识别结果
func (s *BaiduOCRService) parseIDCardResult(result map[string]interface{}, side string) *dto.IDCardResult {
wordsResult := result["words_result"].(map[string]interface{})
idCardResult := &dto.IDCardResult{
Side: side,
Confidence: s.extractConfidence(result),
Words: s.extractWords(result),
}
if side == "front" {
// 正面信息
if name, ok := wordsResult["姓名"]; ok {
if word, ok := name.(map[string]interface{}); ok {
idCardResult.Name = word["words"].(string)
}
}
if sex, ok := wordsResult["性别"]; ok {
if word, ok := sex.(map[string]interface{}); ok {
idCardResult.Sex = word["words"].(string)
}
}
if nation, ok := wordsResult["民族"]; ok {
if word, ok := nation.(map[string]interface{}); ok {
idCardResult.Nation = word["words"].(string)
}
}
if birth, ok := wordsResult["出生"]; ok {
if word, ok := birth.(map[string]interface{}); ok {
idCardResult.BirthDate = 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.IDNumber = word["words"].(string)
}
}
} else {
// 背面信息
if authority, ok := wordsResult["签发机关"]; ok {
if word, ok := authority.(map[string]interface{}); ok {
idCardResult.IssuingAuthority = word["words"].(string)
}
}
if validDate, ok := wordsResult["有效期限"]; ok {
if word, ok := validDate.(map[string]interface{}); ok {
idCardResult.ValidDate = word["words"].(string)
}
}
}
return idCardResult
}
// parseGeneralTextResult 解析通用文字识别结果
func (s *BaiduOCRService) parseGeneralTextResult(result map[string]interface{}) *dto.GeneralTextResult {
wordsResult := result["words_result"].([]interface{})
textResult := &dto.GeneralTextResult{
Confidence: s.extractConfidence(result),
Words: make([]string, 0, len(wordsResult)),
}
// 提取所有识别的文字
for _, word := range wordsResult {
if wordMap, ok := word.(map[string]interface{}); ok {
if words, ok := wordMap["words"].(string); ok {
textResult.Words = append(textResult.Words, words)
}
}
}
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: 30 * 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 *dto.BusinessLicenseResult) error {
if result.Confidence < 0.8 {
return fmt.Errorf("识别置信度过低: %.2f", result.Confidence)
}
if result.CompanyName == "" {
return fmt.Errorf("未能识别公司名称")
}
if result.LegalRepresentative == "" {
return fmt.Errorf("未能识别法定代表人")
}
if result.RegistrationNumber == "" {
return fmt.Errorf("未能识别统一社会信用代码")
}
return nil
}
// ValidateIDCard 验证身份证识别结果
func (s *BaiduOCRService) ValidateIDCard(result *dto.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.IDNumber == "" {
return fmt.Errorf("未能识别身份证号码")
}
} else {
if result.IssuingAuthority == "" {
return fmt.Errorf("未能识别签发机关")
}
if result.ValidDate == "" {
return fmt.Errorf("未能识别有效期限")
}
}
return nil
}

View File

@@ -0,0 +1,44 @@
package ocr
import (
"context"
"tyapi-server/internal/domains/certification/dto"
)
// OCRService OCR识别服务接口
type OCRService interface {
// 识别营业执照
RecognizeBusinessLicense(ctx context.Context, imageURL string) (*dto.OCREnterpriseInfo, error)
RecognizeBusinessLicenseFromBytes(ctx context.Context, imageBytes []byte) (*dto.OCREnterpriseInfo, error)
// 识别身份证
RecognizeIDCard(ctx context.Context, imageURL string, side string) (*IDCardInfo, error)
// 通用文字识别
RecognizeGeneralText(ctx context.Context, imageURL string) (*GeneralTextResult, error)
}
// IDCardInfo 身份证识别信息
type IDCardInfo struct {
Name string `json:"name"` // 姓名
IDCardNumber string `json:"id_card_number"` // 身份证号
Gender string `json:"gender"` // 性别
Nation string `json:"nation"` // 民族
Birthday string `json:"birthday"` // 出生日期
Address string `json:"address"` // 住址
IssuingAgency string `json:"issuing_agency"` // 签发机关
ValidPeriod string `json:"valid_period"` // 有效期限
Confidence float64 `json:"confidence"` // 识别置信度
}
// GeneralTextResult 通用文字识别结果
type GeneralTextResult struct {
Words []TextLine `json:"words"` // 识别的文字行
Confidence float64 `json:"confidence"` // 整体置信度
}
// TextLine 文字行
type TextLine struct {
Text string `json:"text"` // 文字内容
Confidence float64 `json:"confidence"` // 置信度
}

View File

@@ -31,7 +31,6 @@ func NewAliSMSService(cfg config.SMSConfig, logger *zap.Logger) (*AliSMSService,
if err != nil {
return nil, fmt.Errorf("创建短信客户端失败: %w", err)
}
return &AliSMSService{
client: client,
config: cfg,

View File

@@ -0,0 +1,332 @@
package storage
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"io"
"path/filepath"
"strings"
"time"
"github.com/qiniu/go-sdk/v7/auth/qbox"
"github.com/qiniu/go-sdk/v7/storage"
"go.uber.org/zap"
)
// QiNiuStorageService 七牛云存储服务
type QiNiuStorageService struct {
accessKey string
secretKey string
bucket string
domain string
region 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"`
Region string `yaml:"region"`
}
// NewQiNiuStorageService 创建七牛云存储服务
func NewQiNiuStorageService(accessKey, secretKey, bucket, domain, region string, logger *zap.Logger) *QiNiuStorageService {
mac := qbox.NewMac(accessKey, secretKey)
cfg := storage.Config{
Region: &storage.Zone{
RsHost: fmt.Sprintf("rs-%s.qiniu.com", region),
},
}
bucketManager := storage.NewBucketManager(mac, &cfg)
return &QiNiuStorageService{
accessKey: accessKey,
secretKey: secretKey,
bucket: bucket,
domain: domain,
region: region,
logger: logger,
mac: mac,
bucketManager: bucketManager,
}
}
// UploadFile 上传文件到七牛云
func (s *QiNiuStorageService) UploadFile(ctx context.Context, fileBytes []byte, fileName string) (*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{
Region: &storage.Zone{
RsHost: fmt.Sprintf("rs-%s.qiniu.com", s.region),
},
}
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 &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) (*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 &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) ([]*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 []*FileInfo
for _, entry := range entries {
fileInfo := &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) (*UploadResult, error) {
s.logger.Info("从Reader上传文件到七牛云",
zap.String("file_name", fileName),
zap.Int64("file_size", fileSize),
)
// 生成唯一的文件key
key := s.generateFileKey(fileName)
// 创建上传凭证
putPolicy := storage.PutPolicy{
Scope: s.bucket,
}
upToken := putPolicy.UploadToken(s.mac)
// 配置上传参数
cfg := storage.Config{
Region: &storage.Zone{
RsHost: fmt.Sprintf("rs-%s.qiniu.com", s.region),
},
}
formUploader := storage.NewFormUploader(&cfg)
ret := storage.PutRet{}
// 上传文件
err := formUploader.Put(ctx, &ret, upToken, key, reader, fileSize, &storage.PutExtra{})
if err != nil {
s.logger.Error("从Reader上传文件失败",
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("从Reader上传文件成功",
zap.String("file_name", fileName),
zap.String("key", key),
zap.String("url", fileURL),
)
return &UploadResult{
Key: key,
URL: fileURL,
MimeType: s.getMimeType(fileName),
Size: fileSize,
Hash: ret.Hash,
}, nil
}

View File

@@ -0,0 +1,43 @@
package storage
import (
"context"
"io"
)
// StorageService 存储服务接口
type StorageService interface {
// 文件上传
UploadFile(ctx context.Context, fileBytes []byte, fileName string) (*UploadResult, error)
UploadFromReader(ctx context.Context, reader io.Reader, fileName string, size int64) (*UploadResult, error)
// 生成上传凭证
GenerateUploadToken(ctx context.Context, key string) (string, error)
// 文件访问
GetFileURL(ctx context.Context, key string) string
GetPrivateFileURL(ctx context.Context, key string, expires int64) (string, error)
// 文件管理
DeleteFile(ctx context.Context, key string) error
FileExists(ctx context.Context, key string) (bool, error)
GetFileInfo(ctx context.Context, key string) (*FileInfo, error)
}
// UploadResult 文件上传结果
type UploadResult struct {
URL string `json:"url"` // 文件访问URL
Key string `json:"key"` // 存储键名
Size int64 `json:"size"` // 文件大小
MimeType string `json:"mime_type"` // 文件类型
Hash string `json:"hash"` // 文件哈希值
}
// FileInfo 文件信息
type FileInfo struct {
Key string `json:"key"` // 存储键名
Size int64 `json:"size"` // 文件大小
MimeType string `json:"mime_type"` // 文件类型
Hash string `json:"hash"` // 文件哈希值
PutTime int64 `json:"put_time"` // 上传时间戳
}