diff --git a/backend/app/http/super/v1/coupons.go b/backend/app/http/super/v1/coupons.go index 6779f65..7b63d3c 100644 --- a/backend/app/http/super/v1/coupons.go +++ b/backend/app/http/super/v1/coupons.go @@ -45,6 +45,22 @@ func (c *coupons) ListGrants(ctx fiber.Ctx, filter *dto.SuperCouponGrantListFilt return services.Super.ListCouponGrants(ctx, filter) } +// List coupon risks +// +// @Router /super/v1/coupon-risks [get] +// @Summary List coupon risks +// @Description List coupon risk records across tenants +// @Tags Coupon +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param limit query int false "Page size" +// @Success 200 {object} requests.Pager{items=[]dto.SuperCouponRiskItem} +// @Bind filter query +func (c *coupons) ListRisks(ctx fiber.Ctx, filter *dto.SuperCouponRiskListFilter) (*requests.Pager, error) { + return services.Super.ListCouponRisks(ctx, filter) +} + // Update coupon status // // @Router /super/v1/coupons/:id/status [patch] diff --git a/backend/app/http/super/v1/dto/super_coupon.go b/backend/app/http/super/v1/dto/super_coupon.go index 6d324da..becb969 100644 --- a/backend/app/http/super/v1/dto/super_coupon.go +++ b/backend/app/http/super/v1/dto/super_coupon.go @@ -142,3 +142,82 @@ type SuperCouponGrantItem struct { // CreatedAt 领取时间(RFC3339)。 CreatedAt string `json:"created_at"` } + +// SuperCouponRiskListFilter 超管优惠券异常核查过滤条件。 +type SuperCouponRiskListFilter struct { + requests.Pagination + // RiskType 异常类型过滤(used_without_order/order_status_mismatch/used_outside_window/unused_has_order_or_used_at/duplicate_grant)。 + RiskType *string `query:"risk_type"` + // CouponID 优惠券ID过滤(精确匹配)。 + CouponID *int64 `query:"coupon_id"` + // TenantID 租户ID过滤(精确匹配)。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码过滤(模糊匹配)。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称过滤(模糊匹配)。 + TenantName *string `query:"tenant_name"` + // Keyword 优惠券标题/描述关键词(模糊匹配)。 + Keyword *string `query:"keyword"` + // UserID 用户ID过滤(精确匹配)。 + UserID *int64 `query:"user_id"` + // Username 用户名过滤(模糊匹配)。 + Username *string `query:"username"` + // Status 用户券状态过滤(unused/used/expired)。 + Status *consts.UserCouponStatus `query:"status"` + // OrderStatus 订单状态过滤。 + OrderStatus *consts.OrderStatus `query:"order_status"` + // CreatedAtFrom 领取时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 领取时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` + // UsedAtFrom 使用时间起始(RFC3339)。 + UsedAtFrom *string `query:"used_at_from"` + // UsedAtTo 使用时间结束(RFC3339)。 + UsedAtTo *string `query:"used_at_to"` + // Asc 升序字段(id/created_at/used_at)。 + Asc *string `query:"asc"` + // Desc 降序字段(id/created_at/used_at)。 + Desc *string `query:"desc"` +} + +// SuperCouponRiskItem 超管优惠券异常核查记录。 +type SuperCouponRiskItem struct { + // ID 用户券ID。 + ID int64 `json:"id"` + // RiskType 异常类型。 + RiskType string `json:"risk_type"` + // RiskReason 异常说明。 + RiskReason string `json:"risk_reason"` + // CouponID 优惠券ID。 + CouponID int64 `json:"coupon_id"` + // CouponTitle 优惠券标题。 + CouponTitle string `json:"coupon_title"` + // TenantID 租户ID。 + TenantID int64 `json:"tenant_id"` + // TenantCode 租户编码。 + TenantCode string `json:"tenant_code"` + // TenantName 租户名称。 + TenantName string `json:"tenant_name"` + // UserID 用户ID。 + UserID int64 `json:"user_id"` + // Username 用户名。 + Username string `json:"username"` + // Status 用户券状态。 + Status consts.UserCouponStatus `json:"status"` + // StatusDescription 状态描述(用于展示)。 + StatusDescription string `json:"status_description"` + // OrderID 使用订单ID。 + OrderID int64 `json:"order_id"` + // OrderStatus 订单状态。 + OrderStatus consts.OrderStatus `json:"order_status"` + // OrderStatusDescription 订单状态描述(用于展示)。 + OrderStatusDescription string `json:"order_status_description"` + // OrderAmountPaid 订单实付金额(分)。 + OrderAmountPaid int64 `json:"order_amount_paid"` + // PaidAt 订单支付时间(RFC3339)。 + PaidAt string `json:"paid_at"` + // UsedAt 使用时间(RFC3339)。 + UsedAt string `json:"used_at"` + // CreatedAt 领取时间(RFC3339)。 + CreatedAt string `json:"created_at"` +} diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index bb10748..4c4d91d 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -114,6 +114,11 @@ func (r *Routes) Register(router fiber.Router) { r.coupons.ListGrants, Query[dto.SuperCouponGrantListFilter]("filter"), )) + r.log.Debugf("Registering route: Get /super/v1/coupon-risks -> coupons.ListRisks") + router.Get("/super/v1/coupon-risks"[len(r.Path()):], DataFunc1( + r.coupons.ListRisks, + Query[dto.SuperCouponRiskListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/coupons -> coupons.List") router.Get("/super/v1/coupons"[len(r.Path()):], DataFunc1( r.coupons.List, diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 78dce4d..c22a9a1 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "path/filepath" + "sort" "strconv" "strings" "time" @@ -5087,6 +5088,407 @@ func (s *super) ListCouponGrants(ctx context.Context, filter *super_dto.SuperCou }, nil } +func (s *super) ListCouponRisks(ctx context.Context, filter *super_dto.SuperCouponRiskListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperCouponRiskListFilter{} + } + + // 风控类型校验,避免无效筛选导致误判。 + targetRiskType := "" + if filter.RiskType != nil { + targetRiskType = strings.TrimSpace(*filter.RiskType) + } + if targetRiskType != "" { + switch targetRiskType { + case "used_without_order", "order_status_mismatch", "used_outside_window", "unused_has_order_or_used_at", "duplicate_grant": + default: + return nil, errorx.ErrBadRequest.WithMsg("risk_type 无效") + } + } + + // 先做基础筛选,减少异常核查的候选数据量。 + tbl, q := models.UserCouponQuery.QueryContext(ctx) + if filter.UserID != nil && *filter.UserID > 0 { + q = q.Where(tbl.UserID.Eq(*filter.UserID)) + } + if filter.Status != nil && *filter.Status != "" { + q = q.Where(tbl.Status.Eq(*filter.Status)) + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + filter.Pagination.Format() + return &requests.Pager{ + Pagination: filter.Pagination, + Total: 0, + Items: []super_dto.SuperCouponRiskItem{}, + }, nil + } + q = q.Where(tbl.UserID.In(userIDs...)) + } + + couponIDs, couponFilter, err := s.filterCouponRiskCouponIDs(ctx, filter) + if err != nil { + return nil, err + } + if couponFilter { + if len(couponIDs) == 0 { + filter.Pagination.Format() + return &requests.Pager{ + Pagination: filter.Pagination, + Total: 0, + Items: []super_dto.SuperCouponRiskItem{}, + }, nil + } + q = q.Where(tbl.CouponID.In(couponIDs...)) + } + + if filter.CreatedAtFrom != nil { + from, err := s.parseFilterTime(filter.CreatedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.CreatedAt.Gte(*from)) + } + } + if filter.CreatedAtTo != nil { + to, err := s.parseFilterTime(filter.CreatedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.CreatedAt.Lte(*to)) + } + } + if filter.UsedAtFrom != nil { + from, err := s.parseFilterTime(filter.UsedAtFrom) + if err != nil { + return nil, err + } + if from != nil { + q = q.Where(tbl.UsedAt.Gte(*from)) + } + } + if filter.UsedAtTo != nil { + to, err := s.parseFilterTime(filter.UsedAtTo) + if err != nil { + return nil, err + } + if to != nil { + q = q.Where(tbl.UsedAt.Lte(*to)) + } + } + + if filter.OrderStatus != nil && *filter.OrderStatus != "" { + q = q.Where(tbl.OrderID.Gt(0)) + } + + if targetRiskType != "" { + switch targetRiskType { + case "used_without_order": + q = q.Where(tbl.Status.Eq(consts.UserCouponStatusUsed)) + case "order_status_mismatch": + q = q.Where(tbl.Status.Eq(consts.UserCouponStatusUsed)).Where(tbl.OrderID.Gt(0)) + case "used_outside_window": + q = q.Where(tbl.Status.Eq(consts.UserCouponStatusUsed)) + case "unused_has_order_or_used_at": + q = q.Where(tbl.Status.Eq(consts.UserCouponStatusUnused)) + case "duplicate_grant": + } + } + + // 异常规则较为复杂,先拉取候选数据,再做细粒度判断与分页。 + list, err := q.Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + if len(list) == 0 { + filter.Pagination.Format() + return &requests.Pager{ + Pagination: filter.Pagination, + Total: 0, + Items: []super_dto.SuperCouponRiskItem{}, + }, nil + } + + couponIDSet := make(map[int64]struct{}) + userIDSet := make(map[int64]struct{}) + orderIDSet := make(map[int64]struct{}) + for _, uc := range list { + if uc.CouponID > 0 { + couponIDSet[uc.CouponID] = struct{}{} + } + if uc.UserID > 0 { + userIDSet[uc.UserID] = struct{}{} + } + if uc.OrderID > 0 { + orderIDSet[uc.OrderID] = struct{}{} + } + } + + couponIDs = couponIDs[:0] + for id := range couponIDSet { + couponIDs = append(couponIDs, id) + } + userIDs = userIDs[:0] + for id := range userIDSet { + userIDs = append(userIDs, id) + } + orderIDs := make([]int64, 0, len(orderIDSet)) + for id := range orderIDSet { + orderIDs = append(orderIDs, id) + } + + // 批量加载关联信息,避免 N+1 查询。 + couponMap := make(map[int64]*models.Coupon, len(couponIDs)) + tenantMap := make(map[int64]*models.Tenant) + if len(couponIDs) > 0 { + couponTbl, couponQuery := models.CouponQuery.QueryContext(ctx) + coupons, err := couponQuery.Where(couponTbl.ID.In(couponIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + tenantSet := make(map[int64]struct{}) + for _, coupon := range coupons { + couponMap[coupon.ID] = coupon + if coupon.TenantID > 0 { + tenantSet[coupon.TenantID] = struct{}{} + } + } + tenantIDs := make([]int64, 0, len(tenantSet)) + for id := range tenantSet { + tenantIDs = append(tenantIDs, id) + } + if len(tenantIDs) > 0 { + tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx) + tenants, err := tenantQuery.Where(tenantTbl.ID.In(tenantIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, tenant := range tenants { + tenantMap[tenant.ID] = tenant + } + } + } + + userMap := make(map[int64]*models.User, len(userIDs)) + if len(userIDs) > 0 { + userTbl, userQuery := models.UserQuery.QueryContext(ctx) + users, err := userQuery.Where(userTbl.ID.In(userIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, user := range users { + userMap[user.ID] = user + } + } + + orderMap := make(map[int64]*models.Order, len(orderIDs)) + if len(orderIDs) > 0 { + orderTbl, orderQuery := models.OrderQuery.QueryContext(ctx) + orders, err := orderQuery.Where(orderTbl.ID.In(orderIDs...)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, order := range orders { + orderMap[order.ID] = order + } + } + + type couponGrantKey struct { + userID int64 + couponID int64 + } + grantCounts := make(map[couponGrantKey]int, len(list)) + for _, uc := range list { + if uc.UserID == 0 || uc.CouponID == 0 { + continue + } + key := couponGrantKey{userID: uc.UserID, couponID: uc.CouponID} + grantCounts[key]++ + } + + type couponRiskRecord struct { + item super_dto.SuperCouponRiskItem + createdAt time.Time + usedAt time.Time + } + records := make([]couponRiskRecord, 0, len(list)) + + matchRisk := func(target string) bool { + return targetRiskType == "" || targetRiskType == target + } + + for _, uc := range list { + order := orderMap[uc.OrderID] + if filter.OrderStatus != nil && *filter.OrderStatus != "" { + if order == nil || order.Status != *filter.OrderStatus { + continue + } + } + + coupon := couponMap[uc.CouponID] + user := userMap[uc.UserID] + key := couponGrantKey{userID: uc.UserID, couponID: uc.CouponID} + isDuplicate := grantCounts[key] > 1 + + riskType := "" + riskReason := "" + + if matchRisk("used_without_order") && uc.Status == consts.UserCouponStatusUsed { + if uc.OrderID == 0 { + riskType = "used_without_order" + riskReason = "已核销但未关联订单" + } else if order == nil { + riskType = "used_without_order" + riskReason = "已核销但订单不存在" + } + } + if riskType == "" && matchRisk("order_status_mismatch") && uc.Status == consts.UserCouponStatusUsed { + if uc.OrderID > 0 && order != nil && order.Status != consts.OrderStatusPaid { + riskType = "order_status_mismatch" + riskReason = "已核销但订单状态为" + order.Status.Description() + } + } + if riskType == "" && matchRisk("used_outside_window") && uc.Status == consts.UserCouponStatusUsed && coupon != nil && !uc.UsedAt.IsZero() { + if !coupon.StartAt.IsZero() && uc.UsedAt.Before(coupon.StartAt) { + riskType = "used_outside_window" + riskReason = "核销时间早于优惠券生效时间" + } else if !coupon.EndAt.IsZero() && uc.UsedAt.After(coupon.EndAt) { + riskType = "used_outside_window" + riskReason = "核销时间晚于优惠券截止时间" + } + } + if riskType == "" && matchRisk("unused_has_order_or_used_at") && uc.Status == consts.UserCouponStatusUnused { + if uc.OrderID > 0 && !uc.UsedAt.IsZero() { + riskType = "unused_has_order_or_used_at" + riskReason = "未使用但存在订单与使用时间" + } else if uc.OrderID > 0 { + riskType = "unused_has_order_or_used_at" + riskReason = "未使用但已关联订单" + } else if !uc.UsedAt.IsZero() { + riskType = "unused_has_order_or_used_at" + riskReason = "未使用但存在使用时间" + } + } + if riskType == "" && matchRisk("duplicate_grant") && isDuplicate { + riskType = "duplicate_grant" + riskReason = "同一用户重复领券" + } + if riskType == "" { + continue + } + + item := super_dto.SuperCouponRiskItem{ + ID: uc.ID, + RiskType: riskType, + RiskReason: riskReason, + CouponID: uc.CouponID, + UserID: uc.UserID, + Status: uc.Status, + StatusDescription: uc.Status.Description(), + OrderID: uc.OrderID, + UsedAt: s.formatTime(uc.UsedAt), + CreatedAt: s.formatTime(uc.CreatedAt), + } + if user != nil { + item.Username = user.Username + } else if uc.UserID > 0 { + item.Username = "ID:" + strconv.FormatInt(uc.UserID, 10) + } + if coupon != nil { + item.CouponTitle = coupon.Title + item.TenantID = coupon.TenantID + if tenant := tenantMap[coupon.TenantID]; tenant != nil { + item.TenantCode = tenant.Code + item.TenantName = tenant.Name + } + } + if order != nil { + item.OrderStatus = order.Status + item.OrderStatusDescription = order.Status.Description() + item.OrderAmountPaid = order.AmountPaid + item.PaidAt = s.formatTime(order.PaidAt) + } + + records = append(records, couponRiskRecord{ + item: item, + createdAt: uc.CreatedAt, + usedAt: uc.UsedAt, + }) + } + + filter.Pagination.Format() + if len(records) == 0 { + return &requests.Pager{ + Pagination: filter.Pagination, + Total: 0, + Items: []super_dto.SuperCouponRiskItem{}, + }, nil + } + + sortField := "created_at" + desc := true + if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" { + sortField = strings.TrimSpace(*filter.Asc) + desc = false + } + if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" { + sortField = strings.TrimSpace(*filter.Desc) + desc = true + } + + sort.Slice(records, func(i, j int) bool { + left := records[i] + right := records[j] + var less bool + switch sortField { + case "id": + less = left.item.ID < right.item.ID + case "used_at": + less = left.usedAt.Before(right.usedAt) + case "created_at": + less = left.createdAt.Before(right.createdAt) + default: + less = left.createdAt.Before(right.createdAt) + } + if desc { + return !less + } + return less + }) + + total := int64(len(records)) + start := int(filter.Pagination.Offset()) + if start >= len(records) { + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: []super_dto.SuperCouponRiskItem{}, + }, nil + } + end := start + int(filter.Pagination.Limit) + if end > len(records) { + end = len(records) + } + + items := make([]super_dto.SuperCouponRiskItem, 0, end-start) + for _, record := range records[start:end] { + items = append(items, record.item) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + func (s *super) UpdateCouponStatus(ctx context.Context, operatorID, couponID int64, form *super_dto.SuperCouponStatusUpdateForm) error { if operatorID == 0 { return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") @@ -5722,6 +6124,56 @@ func (s *super) filterCouponGrantCouponIDs(ctx context.Context, filter *super_dt return ids, true, nil } +func (s *super) filterCouponRiskCouponIDs(ctx context.Context, filter *super_dto.SuperCouponRiskListFilter) ([]int64, bool, error) { + if filter == nil { + return nil, false, nil + } + + if filter.CouponID != nil && *filter.CouponID > 0 { + return []int64{*filter.CouponID}, true, nil + } + + couponTbl, couponQuery := models.CouponQuery.QueryContext(ctx) + applied := false + + if filter.TenantID != nil && *filter.TenantID > 0 { + applied = true + couponQuery = couponQuery.Where(couponTbl.TenantID.Eq(*filter.TenantID)) + } else { + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, true, err + } + if tenantFilter { + applied = true + if len(tenantIDs) == 0 { + return []int64{}, true, nil + } + couponQuery = couponQuery.Where(couponTbl.TenantID.In(tenantIDs...)) + } + } + + if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" { + applied = true + keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%" + couponQuery = couponQuery.Where(field.Or(couponTbl.Title.Like(keyword), couponTbl.Description.Like(keyword))) + } + + if !applied { + return nil, false, nil + } + + coupons, err := couponQuery.Select(couponTbl.ID).Find() + if err != nil { + return nil, true, errorx.ErrDatabaseError.WithCause(err) + } + ids := make([]int64, 0, len(coupons)) + for _, coupon := range coupons { + ids = append(ids, coupon.ID) + } + return ids, true, nil +} + func (s *super) RejectWithdrawal(ctx context.Context, operatorID, id int64, reason string) error { if operatorID == 0 { return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") diff --git a/backend/docs/docs.go b/backend/docs/docs.go index dcea878..2568dce 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -398,6 +398,58 @@ const docTemplate = `{ } } }, + "/super/v1/coupon-risks": { + "get": { + "description": "List coupon risk records across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Coupon" + ], + "summary": "List coupon risks", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperCouponRiskItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/coupons": { "get": { "description": "List coupon templates across tenants", @@ -7326,6 +7378,95 @@ const docTemplate = `{ } } }, + "dto.SuperCouponRiskItem": { + "type": "object", + "properties": { + "coupon_id": { + "description": "CouponID 优惠券ID。", + "type": "integer" + }, + "coupon_title": { + "description": "CouponTitle 优惠券标题。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 领取时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 用户券ID。", + "type": "integer" + }, + "order_amount_paid": { + "description": "OrderAmountPaid 订单实付金额(分)。", + "type": "integer" + }, + "order_id": { + "description": "OrderID 使用订单ID。", + "type": "integer" + }, + "order_status": { + "description": "OrderStatus 订单状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderStatus" + } + ] + }, + "order_status_description": { + "description": "OrderStatusDescription 订单状态描述(用于展示)。", + "type": "string" + }, + "paid_at": { + "description": "PaidAt 订单支付时间(RFC3339)。", + "type": "string" + }, + "risk_reason": { + "description": "RiskReason 异常说明。", + "type": "string" + }, + "risk_type": { + "description": "RiskType 异常类型。", + "type": "string" + }, + "status": { + "description": "Status 用户券状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserCouponStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 状态描述(用于展示)。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "used_at": { + "description": "UsedAt 使用时间(RFC3339)。", + "type": "string" + }, + "user_id": { + "description": "UserID 用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, "dto.SuperCouponStatusUpdateForm": { "type": "object", "required": [ diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 6fecc8c..bfdb9bb 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -392,6 +392,58 @@ } } }, + "/super/v1/coupon-risks": { + "get": { + "description": "List coupon risk records across tenants", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Coupon" + ], + "summary": "List coupon risks", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Page size", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/requests.Pager" + }, + { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/dto.SuperCouponRiskItem" + } + } + } + } + ] + } + } + } + } + }, "/super/v1/coupons": { "get": { "description": "List coupon templates across tenants", @@ -7320,6 +7372,95 @@ } } }, + "dto.SuperCouponRiskItem": { + "type": "object", + "properties": { + "coupon_id": { + "description": "CouponID 优惠券ID。", + "type": "integer" + }, + "coupon_title": { + "description": "CouponTitle 优惠券标题。", + "type": "string" + }, + "created_at": { + "description": "CreatedAt 领取时间(RFC3339)。", + "type": "string" + }, + "id": { + "description": "ID 用户券ID。", + "type": "integer" + }, + "order_amount_paid": { + "description": "OrderAmountPaid 订单实付金额(分)。", + "type": "integer" + }, + "order_id": { + "description": "OrderID 使用订单ID。", + "type": "integer" + }, + "order_status": { + "description": "OrderStatus 订单状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.OrderStatus" + } + ] + }, + "order_status_description": { + "description": "OrderStatusDescription 订单状态描述(用于展示)。", + "type": "string" + }, + "paid_at": { + "description": "PaidAt 订单支付时间(RFC3339)。", + "type": "string" + }, + "risk_reason": { + "description": "RiskReason 异常说明。", + "type": "string" + }, + "risk_type": { + "description": "RiskType 异常类型。", + "type": "string" + }, + "status": { + "description": "Status 用户券状态。", + "allOf": [ + { + "$ref": "#/definitions/consts.UserCouponStatus" + } + ] + }, + "status_description": { + "description": "StatusDescription 状态描述(用于展示)。", + "type": "string" + }, + "tenant_code": { + "description": "TenantCode 租户编码。", + "type": "string" + }, + "tenant_id": { + "description": "TenantID 租户ID。", + "type": "integer" + }, + "tenant_name": { + "description": "TenantName 租户名称。", + "type": "string" + }, + "used_at": { + "description": "UsedAt 使用时间(RFC3339)。", + "type": "string" + }, + "user_id": { + "description": "UserID 用户ID。", + "type": "integer" + }, + "username": { + "description": "Username 用户名。", + "type": "string" + } + } + }, "dto.SuperCouponStatusUpdateForm": { "type": "object", "required": [ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index a60f4ce..dd490b4 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1345,6 +1345,68 @@ definitions: description: Value 优惠券面额/折扣值。 type: integer type: object + dto.SuperCouponRiskItem: + properties: + coupon_id: + description: CouponID 优惠券ID。 + type: integer + coupon_title: + description: CouponTitle 优惠券标题。 + type: string + created_at: + description: CreatedAt 领取时间(RFC3339)。 + type: string + id: + description: ID 用户券ID。 + type: integer + order_amount_paid: + description: OrderAmountPaid 订单实付金额(分)。 + type: integer + order_id: + description: OrderID 使用订单ID。 + type: integer + order_status: + allOf: + - $ref: '#/definitions/consts.OrderStatus' + description: OrderStatus 订单状态。 + order_status_description: + description: OrderStatusDescription 订单状态描述(用于展示)。 + type: string + paid_at: + description: PaidAt 订单支付时间(RFC3339)。 + type: string + risk_reason: + description: RiskReason 异常说明。 + type: string + risk_type: + description: RiskType 异常类型。 + type: string + status: + allOf: + - $ref: '#/definitions/consts.UserCouponStatus' + description: Status 用户券状态。 + status_description: + description: StatusDescription 状态描述(用于展示)。 + type: string + tenant_code: + description: TenantCode 租户编码。 + type: string + tenant_id: + description: TenantID 租户ID。 + type: integer + tenant_name: + description: TenantName 租户名称。 + type: string + used_at: + description: UsedAt 使用时间(RFC3339)。 + type: string + user_id: + description: UserID 用户ID。 + type: integer + username: + description: Username 用户名。 + type: string + type: object dto.SuperCouponStatusUpdateForm: properties: status: @@ -2975,6 +3037,37 @@ paths: summary: List coupon grants tags: - Coupon + /super/v1/coupon-risks: + get: + consumes: + - application/json + description: List coupon risk records across tenants + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Page size + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/requests.Pager' + - properties: + items: + items: + $ref: '#/definitions/dto.SuperCouponRiskItem' + type: array + type: object + summary: List coupon risks + tags: + - Coupon /super/v1/coupons: get: consumes: diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index 77189da..873b6f5 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -4,7 +4,7 @@ ## 1) 总体结论 -- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录、互动(收藏/点赞/关注)与内容消费明细视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)。 +- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录、互动(收藏/点赞/关注)与内容消费明细视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录/异常核查、资产治理(列表/用量/清理)、通知中心(列表/群发/模板)。 - **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺评论/举报)、创作者治理(缺提现审核联动与结算账户审批流)、财务(缺钱包流水/异常排查)、报表(缺提现/内容深度指标与钻取)。 - **未落地**:审计与系统配置类能力。 @@ -56,9 +56,9 @@ - 缺口:结算账户审批流(若需要区分通过/驳回状态),创作者维度提现审核与财务联动入口。 ### 2.10 优惠券 `/superadmin/coupons` -- 状态:**部分完成** -- 已有:跨租户优惠券列表、创建/编辑、发放、冻结、发放记录查询。 -- 缺口:异常核查与自动告警策略。 +- 状态:**已完成** +- 已有:跨租户优惠券列表、创建/编辑、发放、冻结、发放记录查询、异常核查。 +- 缺口:无显著功能缺口。 ### 2.11 财务与钱包 `/superadmin/finance` - 状态:**部分完成** @@ -83,10 +83,10 @@ ## 3) `/super/v1` 接口覆盖度概览 - **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包/通知/优惠券/实名/互动/内容消费)、Contents、Orders、Withdrawals、Reports、Coupons(列表/创建/编辑/发放/冻结/记录)、Creators(列表/申请/成员审核)、Payout Accounts(列表/删除)、Assets(列表/用量/删除)、Notifications(列表/群发/模板)。 -- **缺失/待补**:创作者提现审核、优惠券异常核查与风控、审计与系统配置类能力。 +- **缺失/待补**:创作者提现审核、审计与系统配置类能力。 ## 4) 建议的下一步(按优先级) -1. **优惠券异常核查**:完善发放/核销异常监测与风控处理流程。 -2. **报表深化**:补齐提现/内容维度指标与多维钻取能力。 -3. **审计与系统配置**:完善全量操作审计与系统级配置能力。 +1. **报表深化**:补齐提现/内容维度指标与多维钻取能力。 +2. **审计与系统配置**:完善全量操作审计与系统级配置能力。 +3. **创作者提现审核**:补齐跨租户提现审核与财务联动入口。 diff --git a/frontend/superadmin/src/service/CouponService.js b/frontend/superadmin/src/service/CouponService.js index 45b5791..fefa5fa 100644 --- a/frontend/superadmin/src/service/CouponService.js +++ b/frontend/superadmin/src/service/CouponService.js @@ -109,6 +109,45 @@ export const CouponService = { total: data?.total ?? 0, items: normalizeItems(data?.items) }; + }, + async listCouponRisks({ page, limit, risk_type, coupon_id, tenant_id, tenant_code, tenant_name, keyword, user_id, username, status, order_status, created_at_from, created_at_to, used_at_from, used_at_to, sortField, sortOrder } = {}) { + const iso = (d) => { + if (!d) return undefined; + const date = d instanceof Date ? d : new Date(d); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); + }; + + const query = { + page, + limit, + risk_type, + coupon_id, + tenant_id, + tenant_code, + tenant_name, + keyword, + user_id, + username, + status, + order_status, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to), + used_at_from: iso(used_at_from), + used_at_to: iso(used_at_to) + }; + if (sortField && sortOrder) { + if (sortOrder === 1) query.asc = sortField; + if (sortOrder === -1) query.desc = sortField; + } + + const data = await requestJson('/super/v1/coupon-risks', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; } }; diff --git a/frontend/superadmin/src/views/superadmin/Coupons.vue b/frontend/superadmin/src/views/superadmin/Coupons.vue index 5798982..a48f0c1 100644 --- a/frontend/superadmin/src/views/superadmin/Coupons.vue +++ b/frontend/superadmin/src/views/superadmin/Coupons.vue @@ -45,6 +45,28 @@ const grantCreatedAtTo = ref(null); const grantUsedAtFrom = ref(null); const grantUsedAtTo = ref(null); +const risks = ref([]); +const risksLoading = ref(false); +const risksTotal = ref(0); +const risksPage = ref(1); +const risksRows = ref(10); +const riskType = ref(''); +const riskCouponID = ref(null); +const riskTenantID = ref(null); +const riskTenantCode = ref(''); +const riskTenantName = ref(''); +const riskKeyword = ref(''); +const riskUserID = ref(null); +const riskUsername = ref(''); +const riskStatus = ref(''); +const riskOrderStatus = ref(''); +const riskCreatedAtFrom = ref(null); +const riskCreatedAtTo = ref(null); +const riskUsedAtFrom = ref(null); +const riskUsedAtTo = ref(null); +const riskSortField = ref('created_at'); +const riskSortOrder = ref(-1); + const typeOptions = [ { label: '全部', value: '' }, { label: '固定金额', value: 'fix_amount' }, @@ -65,6 +87,27 @@ const grantStatusOptions = [ { label: '已过期', value: 'expired' } ]; +const riskStatusOptions = grantStatusOptions; + +const riskTypeOptions = [ + { label: '全部', value: '' }, + { label: '已核销无订单', value: 'used_without_order' }, + { label: '订单状态不匹配', value: 'order_status_mismatch' }, + { label: '核销超出有效期', value: 'used_outside_window' }, + { label: '未使用但有订单/时间', value: 'unused_has_order_or_used_at' }, + { label: '重复领券', value: 'duplicate_grant' } +]; + +const orderStatusOptions = [ + { label: '全部', value: '' }, + { label: '已创建', value: 'created' }, + { label: '已支付', value: 'paid' }, + { label: '退款中', value: 'refunding' }, + { label: '已退款', value: 'refunded' }, + { label: '已取消', value: 'canceled' }, + { label: '失败', value: 'failed' } +]; + const editDialogVisible = ref(false); const couponSubmitting = ref(false); const editingCoupon = ref(null); @@ -135,6 +178,26 @@ function getGrantStatusSeverity(value) { } } +function getOrderStatusSeverity(value) { + switch (value) { + case 'paid': + return 'success'; + case 'created': + case 'refunding': + return 'warn'; + case 'failed': + case 'canceled': + return 'danger'; + default: + return 'secondary'; + } +} + +function resolveRiskTypeLabel(value) { + const option = riskTypeOptions.find((item) => item.value === value); + return option?.label || value || '-'; +} + function resetCouponForm() { formTenantID.value = null; formTitle.value = ''; @@ -315,6 +378,38 @@ async function loadGrants() { } } +async function loadRisks() { + risksLoading.value = true; + try { + const result = await CouponService.listCouponRisks({ + page: risksPage.value, + limit: risksRows.value, + risk_type: riskType.value || undefined, + coupon_id: riskCouponID.value || undefined, + tenant_id: riskTenantID.value || undefined, + tenant_code: riskTenantCode.value, + tenant_name: riskTenantName.value, + keyword: riskKeyword.value, + user_id: riskUserID.value || undefined, + username: riskUsername.value, + status: riskStatus.value || undefined, + order_status: riskOrderStatus.value || undefined, + created_at_from: riskCreatedAtFrom.value || undefined, + created_at_to: riskCreatedAtTo.value || undefined, + used_at_from: riskUsedAtFrom.value || undefined, + used_at_to: riskUsedAtTo.value || undefined, + sortField: riskSortField.value, + sortOrder: riskSortOrder.value + }); + risks.value = result.items; + risksTotal.value = result.total; + } catch (error) { + toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载异常记录', life: 4000 }); + } finally { + risksLoading.value = false; + } +} + function onSearch() { page.value = 1; loadCoupons(); @@ -325,6 +420,11 @@ function onGrantSearch() { loadGrants(); } +function onRiskSearch() { + risksPage.value = 1; + loadRisks(); +} + function onReset() { couponID.value = null; tenantID.value = null; @@ -359,6 +459,28 @@ function onGrantReset() { loadGrants(); } +function onRiskReset() { + riskType.value = ''; + riskCouponID.value = null; + riskTenantID.value = null; + riskTenantCode.value = ''; + riskTenantName.value = ''; + riskKeyword.value = ''; + riskUserID.value = null; + riskUsername.value = ''; + riskStatus.value = ''; + riskOrderStatus.value = ''; + riskCreatedAtFrom.value = null; + riskCreatedAtTo.value = null; + riskUsedAtFrom.value = null; + riskUsedAtTo.value = null; + riskSortField.value = 'created_at'; + riskSortOrder.value = -1; + risksPage.value = 1; + risksRows.value = 10; + loadRisks(); +} + function onPage(event) { page.value = (event.page ?? 0) + 1; rows.value = event.rows ?? rows.value; @@ -371,15 +493,28 @@ function onGrantPage(event) { loadGrants(); } +function onRiskPage(event) { + risksPage.value = (event.page ?? 0) + 1; + risksRows.value = event.rows ?? risksRows.value; + loadRisks(); +} + function onSort(event) { sortField.value = event.sortField ?? sortField.value; sortOrder.value = event.sortOrder ?? sortOrder.value; loadCoupons(); } +function onRiskSort(event) { + riskSortField.value = event.sortField ?? riskSortField.value; + riskSortOrder.value = event.sortOrder ?? riskSortOrder.value; + loadRisks(); +} + onMounted(() => { loadCoupons(); loadGrants(); + loadRisks(); }); @@ -389,6 +524,7 @@ onMounted(() => { 券模板 发放记录 + 异常核查 @@ -625,6 +761,151 @@ onMounted(() => { + +
+

异常核查

+
+ + + + + + +