temp
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -95,9 +95,6 @@ type RequestValidator interface {
|
||||
|
||||
// 绑定和验证
|
||||
BindAndValidate(c *gin.Context, dto interface{}) error
|
||||
|
||||
// 直接验证结构体
|
||||
ValidateStruct(dto interface{}) error
|
||||
}
|
||||
|
||||
// PaginationMeta 分页元数据
|
||||
|
||||
515
internal/shared/notification/wechat_work_service.go
Normal file
515
internal/shared/notification/wechat_work_service.go
Normal 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×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)
|
||||
}
|
||||
548
internal/shared/ocr/baidu_ocr_service.go
Normal file
548
internal/shared/ocr/baidu_ocr_service.go
Normal 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
|
||||
}
|
||||
44
internal/shared/ocr/ocr_interface.go
Normal file
44
internal/shared/ocr/ocr_interface.go
Normal 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"` // 置信度
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
332
internal/shared/storage/qiniu_storage_service.go
Normal file
332
internal/shared/storage/qiniu_storage_service.go
Normal 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
|
||||
}
|
||||
43
internal/shared/storage/storage_interface.go
Normal file
43
internal/shared/storage/storage_interface.go
Normal 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"` // 上传时间戳
|
||||
}
|
||||
Reference in New Issue
Block a user