Files
junhong_cmp_fiber/internal/handler/admin/withdrawal_qualification.go
break 957a235585
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m37s
fix(提现): 修复路径参数未回填导致的参数校验恒失败并给出字段级提示
提现资料资格提交对任何请求都返回 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,原始请求体已通过校验;
缺附件、非法主体类型、超长身份证号、缺作废原因等均返回可定位提示。
2026-09-14 15:36:47 +08:00

222 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}