From 957a235585183dd46c5da139d4a212e1af4ce1b2 Mon Sep 17 00:00:00 2001 From: break Date: Mon, 14 Sep 2026 15:36:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=8F=90=E7=8E=B0):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=8F=82=E6=95=B0=E6=9C=AA=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=E5=8F=82=E6=95=B0=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=81=92=E5=A4=B1=E8=B4=A5=E5=B9=B6=E7=BB=99=E5=87=BA=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E7=BA=A7=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提现资料资格提交对任何请求都返回 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,原始请求体已通过校验; 缺附件、非法主体类型、超长身份证号、缺作废原因等均返回可定位提示。 --- docs/engineering/工程约束.md | 13 +++ .../handler/admin/commission_withdrawal.go | 4 +- internal/handler/admin/shop_commission.go | 5 +- .../handler/admin/withdrawal_qualification.go | 104 +++++++++++++++++- 4 files changed, 118 insertions(+), 8 deletions(-) diff --git a/docs/engineering/工程约束.md b/docs/engineering/工程约束.md index 07292fe..e57adda 100644 --- a/docs/engineering/工程约束.md +++ b/docs/engineering/工程约束.md @@ -41,6 +41,19 @@ - **最后验证日期**:2026-08-07 - **更新触发条件**:错误系统或 ErrorHandler 变化 +## ENG-ERR-002 +- **状态**:生效 +- **适用范围**:请求 DTO 中来自 URL 路径的字段,以及 Handler 的参数校验失败响应 +- **规则**:路径来源字段 MUST 在 Handler 内由 `c.Params` 解析后回填,再执行 `validator.Struct`;DTO MUST NOT 依赖 `validate:"required"` 覆盖路径字段而不回填。新增或修改的参数校验点 MUST 让校验失败返回 1001 且在 `msg` 中说明首个失败字段与规则,字段名取自该字段的中文 `description`;未触碰的既有 Handler 的通用提示按 As-Is 保留。 +- **理由**:`json:"-"` 的路径字段不参与 Body/Query 绑定,不回填则 `required` 恒失败,接口对任何合法请求都返回“参数不合法”,且原提示不指出字段,无法定位。 +- **最小正例**:`shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64)` 后 `req.ShopID = uint(shopID)` 再 `validator.Struct(&req)`;失败时 `errors.New(errors.CodeInvalidParam, validationMessage("提现资料资格参数不合法", &req, err))` 产出“提现资料资格参数不合法:合同附件对象存储 Key 不能为空”。 +- **最小反例**:`c.BodyParser(&req)` 后直接 `validator.Struct(&req)` 并返回无字段信息的“XX参数不合法”。 +- **机械检查/人工原因**:对每个被 `validator.Struct` 校验的 DTO,核对携带 `path:"..."` 且 `validate` 含 `required` 的字段是否在调用点赋值;`go build ./cmd/api`。全仓同类 DTO 中存在未被校验的路径字段,不能只靠 grep 判定违规。 +- **例外条件**:路径字段不带 `validate:"required"` 且调用方显式回填的 DTO 不受本规则约束;未纳入本次触碰范围的 Handler 通用提示不要求整改。 +- **Owner**:API 负责人 +- **最后验证日期**:2026-09-14 +- **更新触发条件**:DTO 绑定方式、校验消息约定或请求绑定工具变化 + ## ENG-RESP-001 - **状态**:生效 - **适用范围**:HTTP Handler diff --git a/internal/handler/admin/commission_withdrawal.go b/internal/handler/admin/commission_withdrawal.go index 70c9be6..0473ebb 100644 --- a/internal/handler/admin/commission_withdrawal.go +++ b/internal/handler/admin/commission_withdrawal.go @@ -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) diff --git a/internal/handler/admin/shop_commission.go b/internal/handler/admin/shop_commission.go index 3cfca1d..cb07295 100644 --- a/internal/handler/admin/shop_commission.go +++ b/internal/handler/admin/shop_commission.go @@ -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 { diff --git a/internal/handler/admin/withdrawal_qualification.go b/internal/handler/admin/withdrawal_qualification.go index 8e89392..7b110f5 100644 --- a/internal/handler/admin/withdrawal_qualification.go +++ b/internal/handler/admin/withdrawal_qualification.go @@ -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。