Files
hfb_sys/backend/internal/modules/distribution/handler.go
T
yml2213 d762b33f54 refactor(distribution): 重写分销迁移并落地利润百分比分成与货架比例
将 000064-000078 合并为 064/065/066,默认规则改为收号 35%、卖出 40%、服务费 25%。
货架调价改为增加分站 1 元兑换哈夫币比例。sql-guard 仅保护 main 已发布迁移。
2026-09-08 19:02:58 +08:00

773 lines
20 KiB
Go

package distribution
import (
"errors"
"net/http"
"strconv"
"strings"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct{ service *Service }
func NewHandler(service *Service) *Handler { return &Handler{service: service} }
func (h *Handler) ListSites(c *gin.Context) {
adminID, _ := currentAdminID(c)
items, err := h.service.ListSitesForAdmin(c.Request.Context(), adminID)
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) GetSite(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.FindSite(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) CreateSite(c *gin.Context) {
var req CreateSiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销网站参数不正确")
return
}
item, err := h.service.CreateSite(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) UpdateSite(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateSiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销网站参数不正确")
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.UpdateSite(c.Request.Context(), adminID, id, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) ListRules(c *gin.Context) {
var siteID *uint64
if raw := strings.TrimSpace(c.Query("site_id")); raw != "" {
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "site_id 不正确")
return
}
siteID = &id
}
adminID, _ := currentAdminID(c)
items, err := h.service.ListRules(c.Request.Context(), adminID, siteID, c.Query("mode"))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) CreateRule(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销规则参数不正确")
return
}
item, err := h.service.CreateRule(c.Request.Context(), adminID, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) UpdateRuleStatus(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateRuleStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "规则状态不正确")
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.UpdateRuleStatus(c.Request.Context(), adminID, id, req.Status)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) ListOrders(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID < 0 {
return
}
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := h.service.ListOrders(c.Request.Context(), DistributionOrderListQuery{SiteID: scoped, Kind: c.Query("kind"), Status: c.Query("status"), Page: page, Size: size})
if err != nil {
writeError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) FindOrder(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.FindOrder(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) CreateFinancialSnapshot(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req CreateFinancialSnapshotRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "分销财务快照参数不正确")
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.CreateFinancialSnapshot(c.Request.Context(), adminID, id, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) ListAccountDisplays(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID < 0 {
return
}
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
if scoped == 0 && siteID == 0 {
response.BadRequest(c, "site_id 不正确")
return
}
items, err := h.service.ListAccountDisplays(c.Request.Context(), scoped, c.Query("status"))
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) UpdateAccountDisplay(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateAccountDisplayRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "账号展示状态不正确")
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.UpdateAccountDisplay(c.Request.Context(), adminID, id, req.DisplayStatus)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
func (h *Handler) SyncSiteAccounts(c *gin.Context) {
var siteID uint64
id, ok := parseID(c)
if ok && id > 0 {
siteID = id
} else {
qID := parseQueryUint(c, "site_id")
if qID > 0 {
siteID = uint64(qID)
}
}
var req SyncSiteAccountsRequest
if c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "同步参数不正确")
return
}
}
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, siteID)
if err != nil {
writeError(c, err)
return
}
if scoped == 0 {
response.BadRequest(c, "site_id 不正确")
return
}
result, err := h.service.SyncSiteAccounts(c.Request.Context(), adminID, scoped, req.ListingIDs)
if err != nil {
writeError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) ListPublicSiteListings(c *gin.Context) {
siteCode := strings.TrimSpace(c.Param("site_code"))
if siteCode == "" {
siteCode = strings.TrimSpace(c.Param("id"))
}
if siteCode == "" {
siteCode = strings.TrimSpace(c.Query("site_code"))
}
if siteCode == "" {
response.BadRequest(c, "site_code 不能为空")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
result, err := h.service.ListPublicSiteListings(c.Request.Context(), siteCode, page, pageSize)
if err != nil {
writeError(c, err)
return
}
response.OK(c, result)
}
func (h *Handler) GetPublicSiteInfo(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
if code == "" {
code = strings.TrimSpace(c.Query("site_code"))
}
domain := strings.TrimSpace(c.Query("domain"))
if code == "" && domain == "" {
host := c.Request.Host
if colon := strings.Index(host, ":"); colon != -1 {
host = host[:colon]
}
domain = host
}
info, err := h.service.GetPublicSiteInfo(c.Request.Context(), code, domain)
if err != nil {
writeError(c, err)
return
}
response.OK(c, info)
}
func (h *Handler) ListPaymentChannels(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
response.BadRequest(c, "site_id 不正确")
return
}
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
items, err := h.service.ListPaymentChannels(c.Request.Context(), scoped)
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) BindPaymentChannel(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
response.BadRequest(c, "site_id 不正确")
return
}
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
var req BindPaymentChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "支付通道参数不正确")
return
}
item, err := h.service.BindPaymentChannel(c.Request.Context(), scoped, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) UnbindPaymentChannel(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
if err := h.service.UnbindPaymentChannel(c.Request.Context(), adminID, id); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) ListSettlementBatches(c *gin.Context) {
siteID := parseQueryUint(c, "site_id")
if siteID <= 0 {
response.BadRequest(c, "site_id 不正确")
return
}
adminID, _ := currentAdminID(c)
scoped, err := h.service.ScopeSiteID(c.Request.Context(), adminID, uint64(siteID))
if err != nil {
writeError(c, err)
return
}
items, err := h.service.ListSettlementBatches(c.Request.Context(), scoped)
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) CreateSettlementBatch(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
var req CreateSettlementBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "结算周期参数不正确")
return
}
item, err := h.service.CreateSettlementBatch(c.Request.Context(), adminID, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) ListSettlementItems(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
items, err := h.service.ListSettlementItems(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"items": items})
}
func (h *Handler) MarkSettlementBatch(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
adminID, _ := currentAdminID(c)
item, err := h.service.MarkSettlementBatch(c.Request.Context(), adminID, id)
if err != nil {
writeError(c, err)
return
}
response.OK(c, item)
}
// parseQueryUint 返回 -1 表示格式错误,0 表示未传入。
func parseQueryUint(c *gin.Context, key string) int64 {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return 0
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, key+" 不正确")
return -1
}
return int64(id)
}
func (h *Handler) Login(c *gin.Context) {
var req DistributionLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "用户名和密码不能为空")
return
}
resp, err := h.service.Login(c.Request.Context(), req, c.ClientIP())
if err != nil {
writeError(c, err)
return
}
secure := isHTTPSRequest(c)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(middleware.AdminAccessCookieName, resp.Token, 2*3600, "/api", "", secure, true)
response.OK(c, resp)
}
func (h *Handler) Me(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
profile, err := h.service.Me(c.Request.Context(), adminID)
if err != nil {
writeError(c, err)
return
}
response.OK(c, profile)
}
func (h *Handler) Logout(c *gin.Context) {
secure := isHTTPSRequest(c)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(middleware.AdminAccessCookieName, "", -1, "/api", "", secure, true)
response.OK(c, gin.H{"logged_out": true})
}
func (h *Handler) OnboardSite(c *gin.Context) {
var req OnboardSiteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "开通分销商参数格式不正确")
return
}
resp, err := h.service.OnboardSite(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, resp)
}
func (h *Handler) resolveSiteID(c *gin.Context) (uint64, error) {
adminID, _ := currentAdminID(c)
var requested uint64
if raw := c.Param("id"); raw != "" {
if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 {
requested = id
}
}
if requested == 0 {
if q := parseQueryUint(c, "site_id"); q > 0 {
requested = uint64(q)
}
}
return h.service.ScopeSiteID(c.Request.Context(), adminID, requested)
}
func (h *Handler) UpdateSiteBranding(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
var req UpdateSiteBrandingRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "品牌装修参数格式不正确")
return
}
adminID, _ := currentAdminID(c)
dto, err := h.service.UpdateSiteBranding(c.Request.Context(), adminID, siteID, req)
if err != nil {
writeError(c, err)
return
}
response.OK(c, dto)
}
func (h *Handler) UpdateAccountMarkup(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateAccountMarkupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "加价参数格式不正确")
return
}
adminID, _ := currentAdminID(c)
dto, err := h.service.UpdateAccountMarkup(c.Request.Context(), adminID, id, req.ShelfRatioDelta)
if err != nil {
writeError(c, err)
return
}
response.OK(c, dto)
}
func (h *Handler) BatchUpdateAccountMarkup(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
var req BatchUpdateAccountMarkupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "批量加价参数格式不正确")
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
adminID, _ := currentAdminID(c)
if err := h.service.BatchUpdateAccountMarkup(c.Request.Context(), adminID, siteID, req); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
func (h *Handler) ListReceivedListings(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
q := ReceivedListingQuery{
Page: page,
PageSize: pageSize,
Status: c.Query("status"),
ReviewStatus: c.Query("review_status"),
GameName: c.Query("game_name"),
Keyword: c.Query("keyword"),
}
adminID, _ := currentAdminID(c)
resp, err := h.service.ListReceivedListings(c.Request.Context(), adminID, siteID, q)
if err != nil {
writeError(c, err)
return
}
response.OK(c, resp)
}
func (h *Handler) UpdateReceivedListingStatus(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req UpdateReceivedListingStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "状态参数格式不正确")
return
}
adminID, _ := currentAdminID(c)
if err := h.service.UpdateReceivedListingStatus(c.Request.Context(), adminID, id, req.Status); err != nil {
writeError(c, err)
return
}
response.OK(c, gin.H{"updated": true})
}
func (h *Handler) GetSiteWallet(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
adminID, _ := currentAdminID(c)
walletDTO, err := h.service.GetSiteWallet(c.Request.Context(), adminID, siteID)
if err != nil {
writeError(c, err)
return
}
response.OK(c, walletDTO)
}
func (h *Handler) ApplySiteWithdrawal(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
var req ApplySiteWithdrawalRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "提现申请参数格式不正确")
return
}
adminID, _ := currentAdminID(c)
dto, err := h.service.ApplySiteWithdrawal(c.Request.Context(), adminID, siteID, req)
if err != nil {
writeError(c, err)
return
}
response.Created(c, dto)
}
func (h *Handler) ListSiteWithdrawals(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
adminID, _ := currentAdminID(c)
pageResult, err := h.service.ListSiteWithdrawals(c.Request.Context(), adminID, siteID, page, pageSize)
if err != nil {
writeError(c, err)
return
}
response.OK(c, pageResult)
}
func (h *Handler) GetDashboardAnalytics(c *gin.Context) {
siteID, err := h.resolveSiteID(c)
if err != nil {
writeError(c, err)
return
}
if siteID == 0 {
response.BadRequest(c, "未指定分销站点")
return
}
adminID, _ := currentAdminID(c)
analytics, err := h.service.GetDashboardAnalytics(c.Request.Context(), adminID, siteID)
if err != nil {
writeError(c, err)
return
}
response.OK(c, analytics)
}
func isHTTPSRequest(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
func currentAdminID(c *gin.Context) (uint64, bool) {
value, ok := c.Get(middleware.ContextAdminID)
if !ok {
return 0, false
}
id, ok := value.(uint64)
return id, ok && id > 0
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
response.BadRequest(c, "ID 不正确")
return 0, false
}
return id, true
}
func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrSiteNotFound), errors.Is(err, ErrRuleNotFound), errors.Is(err, ErrOrderNotFound), errors.Is(err, ErrAccountDisplayNotFound), errors.Is(err, ErrPaymentChannelNotFound), errors.Is(err, ErrSettlementBatchNotFound):
response.NotFound(c, "分销资源不存在")
case errors.Is(err, ErrPaymentConfigNotFound):
response.BadRequest(c, "支付商户配置不存在")
case errors.Is(err, ErrOrderSnapshotExists):
response.Error(c, http.StatusConflict, "snapshot_exists", "该订单已存在财务快照,不能重复补数")
case errors.Is(err, ErrDuplicateSiteCodeOrDomain):
response.Error(c, http.StatusConflict, "duplicate_site", "分销站点标识或域名已被占用")
case errors.Is(err, ErrDuplicateUsername):
response.Error(c, http.StatusConflict, "duplicate_username", "管理员用户名已被占用")
case errors.Is(err, ErrInvalidCredential):
response.BadRequest(c, "用户名或密码错误")
case errors.Is(err, ErrAdminDisabled):
response.Error(c, http.StatusForbidden, "admin_disabled", "分销管理员账号已被禁用")
case errors.Is(err, ErrInvalidAmount), errors.Is(err, ErrInvalidRate), errors.Is(err, ErrInvalidServiceUnit), errors.Is(err, ErrUnsupportedMode), errors.Is(err, ErrInvalidOrderKind), errors.Is(err, ErrInvalidPeriod), errors.Is(err, ErrBatchAlreadySettled), errors.Is(err, ErrSettlementBatchEmpty), errors.Is(err, ErrSettlementTotalMismatch):
response.BadRequest(c, "分销规则参数不正确")
case errors.Is(err, ErrTooManySyncListings):
response.BadRequest(c, "一次勾选同步的商品过多")
case errors.Is(err, ErrSyncListingNotFound):
response.BadRequest(c, "勾选的商品不存在")
case errors.Is(err, ErrEmptyListingIDs):
response.BadRequest(c, "请选择要加价的商品")
case errors.Is(err, ErrInvalidListingStatus):
response.BadRequest(c, "商品展示状态不正确")
case errors.Is(err, ErrLiveMoneyMovementFrozen):
response.Error(c, http.StatusForbidden, "settlement_frozen", "分销结算与提现已冻结,待分成口径确认后再开放")
case errors.Is(err, ErrSiteAccessDenied):
response.Error(c, http.StatusForbidden, "forbidden", "无权访问该分销站点")
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "分销服务暂时不可用")
default:
response.RecordError(c, err)
response.Error(c, http.StatusInternalServerError, "distribution_error", "分销服务暂时不可用")
}
}