fix(提现): 修复路径参数未回填导致的参数校验恒失败并给出字段级提示
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,原始请求体已通过校验;
缺附件、非法主体类型、超长身份证号、缺作废原因等均返回可定位提示。
This commit is contained in:
2026-09-14 15:36:47 +08:00
parent 18796b16ff
commit 957a235585
4 changed files with 118 additions and 8 deletions

View File

@@ -66,8 +66,10 @@ func (h *CommissionWithdrawalHandler) RejectWithdrawal(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// id 只来自路径,必须在校验前回填,否则 ID 的 required 恒失败。
req.ID = uint(id)
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
return errors.New(errors.CodeInvalidParam, validationMessage("提现驳回参数不合法", &req, err))
}
result, err := h.service.Reject(c.UserContext(), uint(id), &req)

View File

@@ -231,11 +231,14 @@ func (h *ShopCommissionHandler) ResubmitWithdrawal(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// shop_id 与 id 只来自路径,必须在校验前回填,否则两者的 required 恒失败。
req.ShopID = uint(shopID)
req.ID = uint(requestID)
if h.validator == nil {
return errors.New(errors.CodeInternalError, "提现重提校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "提现重提参数不合法")
return errors.New(errors.CodeInvalidParam, validationMessage("提现重提参数不合法", &req, err))
}
result, err := h.service.ResubmitWithdrawalRequest(c.UserContext(), uint(shopID), uint(requestID), &req)
if err != nil {

View File

@@ -1,7 +1,9 @@
package admin
import (
"reflect"
"strconv"
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -43,15 +45,17 @@ func (h *WithdrawalQualificationHandler) SubmitWithdrawalQualification(c *fiber.
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, "提现资料资格参数不合法")
}
shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)
if err != nil || shopID == 0 {
return errors.New(errors.CodeInvalidParam, "无效的店铺 ID")
return errors.New(errors.CodeInvalidParam, validationMessage("提现资料资格参数不合法", &req, err))
}
result, err := h.service.Submit(c.UserContext(), uint(shopID), distributiondomain.QualificationInput{
SubjectType: req.SubjectType,
@@ -94,7 +98,7 @@ func (h *WithdrawalQualificationHandler) VoidWithdrawalQualification(c *fiber.Ct
return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因")
return errors.New(errors.CodeInvalidParam, validationMessage("作废提现资料资格参数不合法", &req, err))
}
if err := h.service.Void(c.UserContext(), uint(id), req.Reason); err != nil {
return err
@@ -102,6 +106,94 @@ func (h *WithdrawalQualificationHandler) VoidWithdrawalQualification(c *fiber.Ct
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。