All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m37s
提现资料资格提交对任何请求都返回 1001。根因是 ShopID 为 json:"-" 的路径字段, Handler 在 c.Params 解析前就执行 validator.Struct,required 校验恒失败; 提现重提与提现驳回存在同一缺陷。 - 路径参数在解析后、校验前回填 DTO(资格提交 shop_id、资格作废 id、重提 shop_id/id、驳回 id) - 校验失败改用 validationMessage 输出首个失败字段与规则,字段名取 DTO 中文 description,不拼接底层错误文本、不回显字段值 - 工程约束新增 ENG-ERR-002 固化上述规则 验证:驱动真实 Handler 与全局 ErrorHandler,原始请求体已通过校验; 缺附件、非法主体类型、超长身份证号、缺作废原因等均返回可定位提示。
222 lines
8.4 KiB
Go
222 lines
8.4 KiB
Go
package admin
|
||
|
||
import (
|
||
"reflect"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/go-playground/validator/v10"
|
||
"github.com/gofiber/fiber/v2"
|
||
|
||
distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal"
|
||
distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution"
|
||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||
distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||
)
|
||
|
||
// WithdrawalQualificationHandler 提现资料资格后台 Handler。
|
||
type WithdrawalQualificationHandler struct {
|
||
service *distributionapp.QualificationService
|
||
query *distributionquery.Query
|
||
validator *validator.Validate
|
||
}
|
||
|
||
// NewWithdrawalQualificationHandler 创建提现资料资格后台 Handler。
|
||
func NewWithdrawalQualificationHandler(
|
||
service *distributionapp.QualificationService,
|
||
query *distributionquery.Query,
|
||
validate *validator.Validate,
|
||
) *WithdrawalQualificationHandler {
|
||
return &WithdrawalQualificationHandler{service: service, query: query, validator: validate}
|
||
}
|
||
|
||
// SubmitWithdrawalQualification 提交或替换提现资料资格
|
||
// POST /api/admin/shops/:shop_id/withdrawal-qualifications
|
||
// 仅本人代理店铺;替换合同或法人身份证时同一事务新增版本并使旧有效版本失效。
|
||
func (h *WithdrawalQualificationHandler) SubmitWithdrawalQualification(c *fiber.Ctx) error {
|
||
if h.service == nil {
|
||
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
|
||
}
|
||
var req dto.SubmitWithdrawalQualificationReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||
}
|
||
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
|
||
if err != nil || shopID == 0 {
|
||
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
|
||
}
|
||
// shop_id 只来自路径,必须在校验前回填,否则 ShopID 的 required 恒失败。
|
||
req.ShopID = uint(shopID)
|
||
if h.validator == nil {
|
||
return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置")
|
||
}
|
||
if err := h.validator.Struct(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam, validationMessage("提现资料资格参数不合法", &req, err))
|
||
}
|
||
result, err := h.service.Submit(c.UserContext(), uint(shopID), distributiondomain.QualificationInput{
|
||
SubjectType: req.SubjectType,
|
||
SubjectCode: req.SubjectCode,
|
||
LegalPersonIDCard: req.LegalPersonIDCard,
|
||
ContractFileKey: req.ContractFileKey,
|
||
IDCardFrontFileKey: req.IDCardFrontFileKey,
|
||
IDCardBackFileKey: req.IDCardBackFileKey,
|
||
BusinessLicenseFileKey: req.BusinessLicenseFileKey,
|
||
ShopFrontFileKey: req.ShopFrontFileKey,
|
||
InvoiceFileKey: req.InvoiceFileKey,
|
||
InvoiceTitle: req.InvoiceTitle,
|
||
InvoiceSubjectCode: req.InvoiceSubjectCode,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return response.Success(c, &dto.SubmitWithdrawalQualificationResp{
|
||
ID: result.QualificationID,
|
||
Status: result.Status,
|
||
StatusName: constants.GetWithdrawalQualificationStatusName(result.Status),
|
||
})
|
||
}
|
||
|
||
// VoidWithdrawalQualification 超级管理员作废有效提现资料资格
|
||
// POST /api/admin/withdrawal-qualifications/:id/void
|
||
func (h *WithdrawalQualificationHandler) VoidWithdrawalQualification(c *fiber.Ctx) error {
|
||
if h.service == nil {
|
||
return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置")
|
||
}
|
||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||
if err != nil || id == 0 {
|
||
return errors.New(errors.CodeInvalidParam, "无效的资格 ID")
|
||
}
|
||
var req dto.VoidWithdrawalQualificationReq
|
||
if err := c.BodyParser(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||
}
|
||
if h.validator == nil {
|
||
return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置")
|
||
}
|
||
if err := h.validator.Struct(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam, validationMessage("作废提现资料资格参数不合法", &req, err))
|
||
}
|
||
if err := h.service.Void(c.UserContext(), uint(id), req.Reason); err != nil {
|
||
return err
|
||
}
|
||
return response.Success(c, nil)
|
||
}
|
||
|
||
// validationMessage 把请求校验失败转换为可定位字段的中文提示。
|
||
// 只使用字段的 description 与校验规则,不拼接底层错误文本,也不回显字段值。
|
||
func validationMessage(prefix string, req any, err error) string {
|
||
fieldErrs, ok := err.(validator.ValidationErrors)
|
||
if !ok || len(fieldErrs) == 0 {
|
||
return prefix
|
||
}
|
||
return prefix + ":" + describeFieldError(req, fieldErrs[0])
|
||
}
|
||
|
||
// describeFieldError 用字段中文名与失败规则描述单个字段错误。
|
||
func describeFieldError(req any, fieldErr validator.FieldError) string {
|
||
label := fieldDescription(req, fieldErr.StructField())
|
||
switch fieldErr.Tag() {
|
||
case "required":
|
||
// 数字字段的 required 只在零值失败;说“不能为空”会误导为缺字段。
|
||
if isNumericField(req, fieldErr.StructField()) {
|
||
return label + "必须大于 0"
|
||
}
|
||
return label + "不能为空"
|
||
case "min":
|
||
if isNumericField(req, fieldErr.StructField()) {
|
||
return label + "不能小于 " + fieldErr.Param()
|
||
}
|
||
return label + "长度不能小于 " + fieldErr.Param()
|
||
case "max":
|
||
if isNumericField(req, fieldErr.StructField()) {
|
||
return label + "不能超过 " + fieldErr.Param()
|
||
}
|
||
return label + "长度不能超过 " + fieldErr.Param()
|
||
case "oneof":
|
||
return label + "必须为 " + strings.ReplaceAll(fieldErr.Param(), " ", "/") + " 之一"
|
||
default:
|
||
return label + "不合法(" + fieldErr.Tag() + ")"
|
||
}
|
||
}
|
||
|
||
// isNumericField 判断字段是否为整数或浮点类型。
|
||
func isNumericField(req any, fieldName string) bool {
|
||
field, ok := lookupField(req, fieldName)
|
||
if !ok {
|
||
return false
|
||
}
|
||
switch field.Type.Kind() {
|
||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||
reflect.Float32, reflect.Float64:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// lookupField 在去指针的结构体类型上按名取字段。
|
||
func lookupField(req any, fieldName string) (reflect.StructField, bool) {
|
||
typ := reflect.TypeOf(req)
|
||
for typ != nil && typ.Kind() == reflect.Ptr {
|
||
typ = typ.Elem()
|
||
}
|
||
if typ == nil || typ.Kind() != reflect.Struct {
|
||
return reflect.StructField{}, false
|
||
}
|
||
return typ.FieldByName(fieldName)
|
||
}
|
||
|
||
// fieldDescription 取字段 description 的首个中文短语作为提示名,缺失时退回字段名。
|
||
func fieldDescription(req any, fieldName string) string {
|
||
field, ok := lookupField(req, fieldName)
|
||
if !ok {
|
||
return fieldName
|
||
}
|
||
description := strings.TrimSpace(field.Tag.Get("description"))
|
||
if description == "" {
|
||
return fieldName
|
||
}
|
||
if cut := strings.IndexAny(description, "((::,,;;"); cut > 0 {
|
||
description = strings.TrimSpace(description[:cut])
|
||
}
|
||
if description == "" {
|
||
return fieldName
|
||
}
|
||
// 提示名以拉丁字母/数字结尾时补一个空格,避免与后续中文粘连。
|
||
if last := description[len(description)-1]; last < 0x80 {
|
||
description += " "
|
||
}
|
||
return description
|
||
}
|
||
|
||
// ListWithdrawalQualifications 查询提现资料资格版本
|
||
// GET /api/admin/shops/:shop_id/withdrawal-qualifications
|
||
// 仅返回当前账号数据范围内的资料版本;证件号脱敏,附件只返回对象存储 Key。
|
||
func (h *WithdrawalQualificationHandler) ListWithdrawalQualifications(c *fiber.Ctx) error {
|
||
if h.query == nil {
|
||
return errors.New(errors.CodeInternalError, "提现资料资格查询能力未配置")
|
||
}
|
||
var req dto.WithdrawalQualificationListReq
|
||
if err := c.QueryParser(&req); err != nil {
|
||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||
}
|
||
// 路由路径必带 shop_id;数据范围由 CanManageShop 在业务边界强制。
|
||
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
|
||
if err != nil || shopID == 0 {
|
||
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
|
||
}
|
||
if err := middleware.CanManageShop(c.UserContext(), uint(shopID)); err != nil {
|
||
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||
}
|
||
result, err := h.query.ListQualifications(c.UserContext(), []uint{uint(shopID)}, &req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
|
||
}
|