Files
junhong_cmp_fiber/internal/routes/shop.go
break 575d056f54
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(代理分销提现): 落地扫码注册、提现资料资格与企微终审提现
AUG26-008。

- 迁移 000214–000217:tb_shop 全局唯一且不可修改的随机分销码(含存量回填)、
  tb_agent_distribution_registration 待审批注册记录、tb_withdrawal_qualification 资料版本、
  tb_commission_withdrawal_request_attempt 审批尝试记录,以及提现申请的 latest_*/异常标记列;
  不修改既有迁移,down 在存在本 Change 业务事实或新类型场景行时拒绝破坏性回滚。
- 公开接口 POST /api/c/v1/agent-distribution-registrations:无认证,复用既有短信验证码校验、
  消费与限流;无效分销码、停用上级、验证码无效或已消费统一返回「分销码不可用」且不落库,
  审批通过前不创建店铺、账号或钱包。
- 审批通过才在同一事务内建启用店铺、代理主账号、钱包、上级层级与业务员快照,驳回不建实体,
  重复回调不重复建实体,提交后清理上级下级缓存。
- 提现资料资格按不可变版本保存,替换合同或法人身份证即新增版本并同事务失效旧有效版本;
  超管作废原因必填;代理停用与店铺删除联动失效。
- 提现每次提交或重提新增不可变审批尝试记录并冻结金额;企业微信通过仅一次从冻结扣减、
  保持状态 2 并写 paid_at(不使用状态 4),驳回/cancelled/deleted 仅一次释放,
  通过后撤销不回滚、不重新冻结、只写正交异常标记;加锁顺序统一为申请→尝试→钱包。
- 本地人工终审对已关联审批实例的申请返回状态冲突,approval_instance_id 为空的存量申请保持既有行为,
  不新增任何配置开关。
- 补齐审批业务类型注册点全集:业务类型与场景字段常量、场景 DTO 两处枚举与中文描述、
  场景字段白名单/合法类型/中文名、数据库 CHECK、Worker 决策消费者与装配、审批审计资源映射,
  以及三个新审计资源与 13 个审计动作;失败/拒绝审计改为必达。
- 新增后台路由与 OpenAPI:资格提交/查询/作废、提现申请/重提/详情、店铺详情返回只读分销码。
- 归档本 Change:主 Spec 新增 agent-distribution-withdrawal 能力(5 个 Requirement)。

验证(junhong_cmp_test + Redis DB 6,显式 DB_*,未重置整库):
- 迁移 up → version 217 且 dirty=false → down 3 → up 回 217,fixture 复核残留为 0。
- 受控状态机脚手架 227 项通过 / 0 项失败,覆盖 18 组场景(幂等与乱序回调、资金冻结/释放/重提、
  退款回扣 × 在途提现并发、负向场景拒绝审计与 14 个动作码审计真实落库)。
- gofmt 空、go build/go vet 通过、gendocs 与工作区逐字节一致、context-health 通过、
  openspec validate --strict 通过、doctor healthy;自动化测试按项目决策为 N/A。

运行期前置(未完成,非代码交付物):由超管经 PUT /api/admin/wecom/scenes/{business_type} 为
agent_distribution_approval、withdrawal_qualification_approval、commission_withdrawal_approval
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
2026-09-14 09:45:13 +08:00

267 lines
11 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 routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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/openapi"
)
func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "GET", "", coreShopManagement(handler.List), RouteSpec{
Summary: "店铺列表",
Description: constants.ShopManagementAccessDescription + constants.ShopListPaginationDescription,
Tags: []string{"店铺管理"},
Input: new(dto.ShopListRequest),
Output: new(dto.ShopPageResult),
Auth: true,
})
Register(shops, doc, groupPath, "POST", "", coreShopManagement(handler.Create), RouteSpec{
Summary: "创建店铺",
Description: constants.ShopManagementAccessDescription,
Tags: []string{"店铺管理"},
Input: new(dto.CreateShopRequest),
Output: new(dto.ShopResponse),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/business-owner-candidates", coreShopManagement(handler.BusinessOwnerCandidates), RouteSpec{
Summary: "查询店铺业务员候选",
Description: "仅超级管理员和平台账号可查询;只返回当前启用、未删除的平台账号最小摘要。",
Tags: []string{"店铺管理"},
Input: new(dto.ShopBusinessOwnerCandidateRequest),
Output: new(dto.ShopBusinessOwnerCandidatePageResult),
Auth: true,
})
Register(shops, doc, groupPath, "PUT", "/:id", coreShopManagement(handler.Update), RouteSpec{
Summary: "更新店铺",
Description: constants.ShopManagementAccessDescription,
Tags: []string{"店铺管理"},
Input: new(dto.UpdateShopParams),
Output: new(dto.ShopResponse),
Auth: true,
})
Register(shops, doc, groupPath, "PUT", "/:id/credit-limit", handler.UpdateCreditLimit, RouteSpec{
Summary: "调整既有店铺实际信用额度",
Description: "后端不校验按钮权限shop:credit-limit:manage 仅供前端控制按钮显示。",
Tags: []string{"代理商资金管理"},
Input: new(dto.UpdateShopCreditLimitParams),
Output: new(dto.ShopCreditLimitResponse),
Auth: true,
})
Register(shops, doc, groupPath, "DELETE", "/:id", coreShopManagement(handler.Delete), RouteSpec{
Summary: "删除店铺",
Description: constants.ShopManagementAccessDescription,
Tags: []string{"店铺管理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/cascade", coreShopManagement(handler.Cascade), RouteSpec{
Summary: "店铺联级查询",
Description: constants.ShopManagementAccessDescription,
Tags: []string{"店铺管理"},
Input: new(dto.ShopCascadeRequest),
Output: new([]dto.ShopCascadeItem),
Auth: true,
})
}
func registerShopDetailRoute(router fiber.Router, handler *admin.ShopHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "GET", "/:id", coreShopManagement(handler.Detail), RouteSpec{
Summary: "查询店铺详情",
Description: constants.ShopManagementAccessDescription + " 返回与店铺列表一致的业务员归属摘要。",
Tags: []string{"店铺管理"},
Input: new(dto.IDReq),
Output: new(dto.ShopResponse),
Auth: true,
})
}
func coreShopManagement(handler fiber.Handler) fiber.Handler {
return func(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, constants.ShopManagementForbiddenMessage)
}
return handler(c)
}
}
func registerShopRoleRoutes(router fiber.Router, handler *admin.ShopRoleHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "POST", "/:shop_id/roles", handler.AssignShopRoles, RouteSpec{
Summary: "分配店铺默认角色",
Tags: []string{"店铺管理"},
Input: new(dto.AssignShopRolesRequest),
Output: new(dto.ShopRolesResponse),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/roles", handler.GetShopRoles, RouteSpec{
Summary: "查询店铺默认角色",
Tags: []string{"店铺管理"},
Input: new(dto.GetShopRolesRequest),
Output: new(dto.ShopRolesResponse),
Auth: true,
})
Register(shops, doc, groupPath, "DELETE", "/:shop_id/roles/:role_id", handler.DeleteShopRole, RouteSpec{
Summary: "删除店铺默认角色",
Tags: []string{"店铺管理"},
Input: new(dto.DeleteShopRoleRequest),
Output: nil,
Auth: true,
})
}
func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommissionHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
groupPath := basePath + "/shops"
Register(shops, doc, groupPath, "GET", "/fund-summary", agentFundManagement(handler.ListFundSummary), RouteSpec{
Summary: "代理商资金概况",
Description: "按当前店铺数据范围分页返回主钱包、佣金及信用资金事实;现金可用、总可用和欠款均由服务端计算,读取不代表具有调额权限。",
Tags: []string{"代理商资金管理"},
Input: new(dto.ShopFundSummaryListReq),
Output: new(dto.ShopFundSummaryPageResult),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/withdrawal-requests", handler.ListWithdrawalRequests, RouteSpec{
Summary: "代理商提现记录",
Tags: []string{"代理商资金管理"},
Input: new(dto.ShopWithdrawalRequestListReq),
Output: new(dto.ShopWithdrawalRequestPageResult),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/commission-records", handler.ListCommissionRecords, RouteSpec{
Summary: "代理商佣金明细",
Tags: []string{"代理商资金管理"},
Input: new(dto.ShopCommissionRecordListReq),
Output: new(dto.ShopCommissionRecordPageResult),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/main-wallet/transactions", handler.ListMainWalletTransactions, RouteSpec{
Summary: "代理商预充值钱包流水",
Tags: []string{"代理商资金管理"},
Input: new(dto.MainWalletTransactionListRequest),
Output: new(dto.MainWalletTransactionListResponse),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/commission-stats", handler.GetCommissionStats, RouteSpec{
Summary: "代理商佣金统计",
Tags: []string{"代理商资金管理"},
Input: new(dto.CommissionStatsRequest),
Output: new(dto.CommissionStatsResponse),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/commission-daily-stats", handler.GetCommissionDailyStats, RouteSpec{
Summary: "代理商每日佣金统计",
Tags: []string{"代理商资金管理"},
Input: new(dto.DailyCommissionStatsRequest),
Output: []dto.DailyCommissionStatsResponse{},
Auth: true,
})
Register(shops, doc, groupPath, "POST", "/:shop_id/withdrawal-requests", handler.CreateWithdrawal, RouteSpec{
Summary: "发起提现申请",
Tags: []string{"代理商资金管理"},
Input: new(dto.CreateMyWithdrawalReq),
Output: new(dto.CreateMyWithdrawalResp),
Auth: true,
})
Register(shops, doc, groupPath, "PUT", "/:shop_id/withdrawal-requests/:id", handler.ResubmitWithdrawal, RouteSpec{
Summary: "重提被驳回的提现申请",
Description: "仅本人代理店铺,且仅已被企业微信驳回的申请可重提。事务内先释放旧未结算冻结再按新金额冻结,新增审批尝试记录与新的审批实例,历史尝试与审批结果不被覆盖。",
Tags: []string{"代理商资金管理"},
Input: new(dto.ResubmitWithdrawalReq),
Output: new(dto.CreateMyWithdrawalResp),
Auth: true,
})
Register(shops, doc, groupPath, "GET", "/:shop_id/withdrawal-requests/:id", handler.WithdrawalDetail, RouteSpec{
Summary: "提现申请详情",
Description: "返回指定提现申请及其全部审批尝试记录与正交异常标记,用于详情展示;仅限有数据范围的后台账号。",
Tags: []string{"代理商资金管理"},
Input: new(dto.ShopWithdrawalRequestDetailReq),
Output: new(dto.ShopWithdrawalRequestDetailResp),
Auth: true,
})
commissionRecords := router.Group("/commission-records")
crPath := basePath + "/commission-records"
Register(commissionRecords, doc, crPath, "POST", "/:id/resolve", handler.ResolveCommissionRecord, RouteSpec{
Summary: "修正待审佣金记录",
Tags: []string{"代理商资金管理"},
Input: new(dto.CommissionRecordResolveRequest),
Auth: true,
})
}
func agentFundManagement(handler fiber.Handler) fiber.Handler {
return func(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, constants.AgentFundManagementForbiddenMessage)
}
return handler(c)
}
}
// registerWithdrawalQualificationRoutes 注册提现资料资格路由。
// 资格提交与查询沿用既有认证与数据范围校验,绝不公开。
func registerWithdrawalQualificationRoutes(router fiber.Router, handler *admin.WithdrawalQualificationHandler, doc *openapi.Generator, basePath string) {
shops := router.Group("/shops")
shopsPath := basePath + "/shops"
qualifications := router.Group("/withdrawal-qualifications")
qualificationPath := basePath + "/withdrawal-qualifications"
Register(shops, doc, shopsPath, "POST", "/:shop_id/withdrawal-qualifications", agentFundManagement(handler.SubmitWithdrawalQualification), RouteSpec{
Summary: "提交提现资料资格",
Description: "仅本人代理店铺。合同与法人身份证正反面必填;企业必填统一社会信用代码,个人填写法人身份证号。替换合同或法人身份证时同一事务新增资料版本并使旧有效版本失效,需重新审批通过后才可提现。",
Tags: []string{"代理商提现资格"},
Input: new(dto.SubmitWithdrawalQualificationReq),
Output: new(dto.SubmitWithdrawalQualificationResp),
Auth: true,
})
Register(shops, doc, shopsPath, "GET", "/:shop_id/withdrawal-qualifications", agentFundManagement(handler.ListWithdrawalQualifications), RouteSpec{
Summary: "查询提现资料资格版本",
Description: "仅返回当前账号数据范围内店铺的资料版本,按版本从新到旧;证件号脱敏,附件只返回对象存储 Key 引用。",
Tags: []string{"代理商提现资格"},
Input: new(dto.WithdrawalQualificationListReq),
Output: new(dto.WithdrawalQualificationPageResult),
Auth: true,
})
Register(qualifications, doc, qualificationPath, "POST", "/:id/void", agentFundManagement(handler.VoidWithdrawalQualification), RouteSpec{
Summary: "作废提现资料资格",
Description: "仅超级管理员reason 必填。作废后该资格失效且原因可查询,代理提现被拒绝直至重新审批通过。",
Tags: []string{"代理商提现资格"},
Input: new(dto.VoidWithdrawalQualificationParams),
Output: nil,
Auth: true,
})
}