diff --git a/backend/app/http/super/v1/coupons.go b/backend/app/http/super/v1/coupons.go index 9385515..6670fe3 100644 --- a/backend/app/http/super/v1/coupons.go +++ b/backend/app/http/super/v1/coupons.go @@ -2,8 +2,10 @@ package v1 import ( dto "quyun/v2/app/http/super/v1/dto" + v1_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" "quyun/v2/app/services" + "quyun/v2/database/models" "github.com/gofiber/fiber/v3" ) @@ -26,3 +28,81 @@ type coupons struct{} func (c *coupons) List(ctx fiber.Ctx, filter *dto.SuperCouponListFilter) (*requests.Pager, error) { return services.Super.ListCoupons(ctx, filter) } + +// Create coupon +// +// @Router /super/v1/tenants/:tenantID/coupons [post] +// @Summary Create coupon +// @Description Create coupon for tenant +// @Tags Coupon +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Param form body v1_dto.CouponCreateForm true "Create form" +// @Success 200 {object} v1_dto.CouponItem +// @Bind tenantID path +// @Bind form body +// @Bind user local key(__ctx_user) +func (c *coupons) Create(ctx fiber.Ctx, user *models.User, tenantID int64, form *v1_dto.CouponCreateForm) (*v1_dto.CouponItem, error) { + return services.Coupon.Create(ctx, tenantID, user.ID, form) +} + +// Get coupon +// +// @Router /super/v1/tenants/:tenantID/coupons/:id [get] +// @Summary Get coupon +// @Description Get coupon detail +// @Tags Coupon +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Param id path int64 true "Coupon ID" +// @Success 200 {object} v1_dto.CouponItem +// @Bind tenantID path +// @Bind id path +func (c *coupons) Get(ctx fiber.Ctx, tenantID, id int64) (*v1_dto.CouponItem, error) { + return services.Coupon.Get(ctx, tenantID, id) +} + +// Update coupon +// +// @Router /super/v1/tenants/:tenantID/coupons/:id [put] +// @Summary Update coupon +// @Description Update coupon for tenant +// @Tags Coupon +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Param id path int64 true "Coupon ID" +// @Param form body v1_dto.CouponUpdateForm true "Update form" +// @Success 200 {object} v1_dto.CouponItem +// @Bind tenantID path +// @Bind id path +// @Bind form body +// @Bind user local key(__ctx_user) +func (c *coupons) Update(ctx fiber.Ctx, user *models.User, tenantID, id int64, form *v1_dto.CouponUpdateForm) (*v1_dto.CouponItem, error) { + return services.Coupon.Update(ctx, tenantID, user.ID, id, form) +} + +// Grant coupon +// +// @Router /super/v1/tenants/:tenantID/coupons/:id/grant [post] +// @Summary Grant coupon +// @Description Grant coupon to users +// @Tags Coupon +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Param id path int64 true "Coupon ID" +// @Param form body v1_dto.CouponGrantForm true "Grant form" +// @Success 200 {object} dto.SuperCouponGrantResponse +// @Bind tenantID path +// @Bind id path +// @Bind form body +func (c *coupons) Grant(ctx fiber.Ctx, tenantID, id int64, form *v1_dto.CouponGrantForm) (*dto.SuperCouponGrantResponse, error) { + granted, err := services.Coupon.Grant(ctx, tenantID, id, form.UserIDs) + if err != nil { + return nil, err + } + return &dto.SuperCouponGrantResponse{Granted: granted}, nil +} diff --git a/backend/app/http/super/v1/dto/super.go b/backend/app/http/super/v1/dto/super.go index 650df96..e475055 100644 --- a/backend/app/http/super/v1/dto/super.go +++ b/backend/app/http/super/v1/dto/super.go @@ -454,6 +454,59 @@ type TenantUser struct { UpdatedAt string `json:"updated_at"` } +// SuperTenantJoinRequestListFilter 超管成员申请列表过滤条件。 +type SuperTenantJoinRequestListFilter struct { + requests.Pagination + // TenantID 租户ID,精确匹配。 + TenantID *int64 `query:"tenant_id"` + // TenantCode 租户编码,模糊匹配。 + TenantCode *string `query:"tenant_code"` + // TenantName 租户名称,模糊匹配。 + TenantName *string `query:"tenant_name"` + // UserID 申请用户ID,精确匹配。 + UserID *int64 `query:"user_id"` + // Username 申请用户用户名/昵称,模糊匹配。 + Username *string `query:"username"` + // Status 申请状态(pending/approved/rejected)。 + Status *consts.TenantJoinRequestStatus `query:"status"` + // CreatedAtFrom 申请时间起始(RFC3339)。 + CreatedAtFrom *string `query:"created_at_from"` + // CreatedAtTo 申请时间结束(RFC3339)。 + CreatedAtTo *string `query:"created_at_to"` +} + +// SuperTenantJoinRequestItem 超管成员申请列表项。 +type SuperTenantJoinRequestItem struct { + // ID 申请记录ID。 + ID int64 `json:"id"` + // 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 string `json:"status"` + // StatusDescription 状态描述(用于展示)。 + StatusDescription string `json:"status_description"` + // Reason 申请说明。 + Reason string `json:"reason"` + // DecidedAt 审核时间(RFC3339)。 + DecidedAt string `json:"decided_at"` + // DecidedOperatorUserID 审核操作者ID。 + DecidedOperatorUserID int64 `json:"decided_operator_user_id"` + // DecidedReason 审核备注/原因。 + DecidedReason string `json:"decided_reason"` + // CreatedAt 申请时间(RFC3339)。 + CreatedAt string `json:"created_at"` + // UpdatedAt 更新时间(RFC3339)。 + UpdatedAt string `json:"updated_at"` +} + // Order Related type SuperOrderItem struct { // ID 订单ID。 diff --git a/backend/app/http/super/v1/dto/super_coupon.go b/backend/app/http/super/v1/dto/super_coupon.go index 88f3fc6..d00801b 100644 --- a/backend/app/http/super/v1/dto/super_coupon.go +++ b/backend/app/http/super/v1/dto/super_coupon.go @@ -73,3 +73,9 @@ type SuperCouponItem struct { // UpdatedAt 更新时间(RFC3339)。 UpdatedAt string `json:"updated_at"` } + +// SuperCouponGrantResponse 超管优惠券发放结果。 +type SuperCouponGrantResponse struct { + // Granted 实际发放数量。 + Granted int `json:"granted"` +} diff --git a/backend/app/http/super/v1/routes.gen.go b/backend/app/http/super/v1/routes.gen.go index bb05f1a..780b708 100644 --- a/backend/app/http/super/v1/routes.gen.go +++ b/backend/app/http/super/v1/routes.gen.go @@ -6,6 +6,7 @@ package v1 import ( dto "quyun/v2/app/http/super/v1/dto" + v1_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/middlewares" "quyun/v2/database/models" @@ -87,6 +88,34 @@ func (r *Routes) Register(router fiber.Router) { r.coupons.List, Query[dto.SuperCouponListFilter]("filter"), )) + r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/coupons/:id -> coupons.Get") + router.Get("/super/v1/tenants/:tenantID/coupons/:id"[len(r.Path()):], DataFunc2( + r.coupons.Get, + PathParam[int64]("tenantID"), + PathParam[int64]("id"), + )) + r.log.Debugf("Registering route: Post /super/v1/tenants/:tenantID/coupons -> coupons.Create") + router.Post("/super/v1/tenants/:tenantID/coupons"[len(r.Path()):], DataFunc3( + r.coupons.Create, + Local[*models.User]("__ctx_user"), + PathParam[int64]("tenantID"), + Body[v1_dto.CouponCreateForm]("form"), + )) + r.log.Debugf("Registering route: Post /super/v1/tenants/:tenantID/coupons/:id/grant -> coupons.Grant") + router.Post("/super/v1/tenants/:tenantID/coupons/:id/grant"[len(r.Path()):], DataFunc3( + r.coupons.Grant, + PathParam[int64]("tenantID"), + PathParam[int64]("id"), + Body[v1_dto.CouponGrantForm]("form"), + )) + r.log.Debugf("Registering route: Put /super/v1/tenants/:tenantID/coupons/:id -> coupons.Update") + router.Put("/super/v1/tenants/:tenantID/coupons/:id"[len(r.Path()):], DataFunc4( + r.coupons.Update, + Local[*models.User]("__ctx_user"), + PathParam[int64]("tenantID"), + PathParam[int64]("id"), + Body[v1_dto.CouponUpdateForm]("form"), + )) // Register routes for controller: creators r.log.Debugf("Registering route: Get /super/v1/creators -> creators.List") router.Get("/super/v1/creators"[len(r.Path()):], DataFunc1( @@ -126,6 +155,11 @@ func (r *Routes) Register(router fiber.Router) { Body[dto.SuperReportExportForm]("form"), )) // Register routes for controller: tenants + r.log.Debugf("Registering route: Get /super/v1/tenant-join-requests -> tenants.ListJoinRequests") + router.Get("/super/v1/tenant-join-requests"[len(r.Path()):], DataFunc1( + r.tenants.ListJoinRequests, + Query[dto.SuperTenantJoinRequestListFilter]("filter"), + )) r.log.Debugf("Registering route: Get /super/v1/tenants -> tenants.List") router.Get("/super/v1/tenants"[len(r.Path()):], DataFunc1( r.tenants.List, @@ -163,11 +197,24 @@ func (r *Routes) Register(router fiber.Router) { PathParam[int64]("id"), Body[dto.TenantStatusUpdateForm]("form"), )) + r.log.Debugf("Registering route: Post /super/v1/tenant-join-requests/:id/review -> tenants.ReviewJoinRequest") + router.Post("/super/v1/tenant-join-requests/:id/review"[len(r.Path()):], Func3( + r.tenants.ReviewJoinRequest, + Local[*models.User]("__ctx_user"), + PathParam[int64]("id"), + Body[v1_dto.TenantJoinReviewForm]("form"), + )) r.log.Debugf("Registering route: Post /super/v1/tenants -> tenants.Create") router.Post("/super/v1/tenants"[len(r.Path()):], Func1( r.tenants.Create, Body[dto.TenantCreateForm]("form"), )) + r.log.Debugf("Registering route: Post /super/v1/tenants/:tenantID/invites -> tenants.CreateInvite") + router.Post("/super/v1/tenants/:tenantID/invites"[len(r.Path()):], DataFunc2( + r.tenants.CreateInvite, + PathParam[int64]("tenantID"), + Body[v1_dto.TenantInviteCreateForm]("form"), + )) // Register routes for controller: users r.log.Debugf("Registering route: Get /super/v1/users -> users.List") router.Get("/super/v1/users"[len(r.Path()):], DataFunc1( diff --git a/backend/app/http/super/v1/tenants.go b/backend/app/http/super/v1/tenants.go index 3f05f47..c228f2f 100644 --- a/backend/app/http/super/v1/tenants.go +++ b/backend/app/http/super/v1/tenants.go @@ -2,8 +2,10 @@ package v1 import ( dto "quyun/v2/app/http/super/v1/dto" + v1_dto "quyun/v2/app/http/v1/dto" "quyun/v2/app/requests" "quyun/v2/app/services" + "quyun/v2/database/models" "github.com/gofiber/fiber/v3" ) @@ -62,6 +64,57 @@ func (c *tenants) ListUsers(ctx fiber.Ctx, tenantID int64, filter *dto.SuperTena return services.Super.ListTenantUsers(ctx, tenantID, filter) } +// List tenant join requests +// +// @Router /super/v1/tenant-join-requests [get] +// @Summary List tenant join requests +// @Description List tenant join requests across tenants +// @Tags Tenant +// @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.SuperTenantJoinRequestItem} +// @Bind filter query +func (c *tenants) ListJoinRequests(ctx fiber.Ctx, filter *dto.SuperTenantJoinRequestListFilter) (*requests.Pager, error) { + return services.Super.ListTenantJoinRequests(ctx, filter) +} + +// Review tenant join request +// +// @Router /super/v1/tenant-join-requests/:id/review [post] +// @Summary Review tenant join request +// @Description Approve or reject a tenant join request +// @Tags Tenant +// @Accept json +// @Produce json +// @Param id path int64 true "Join request ID" +// @Param form body v1_dto.TenantJoinReviewForm true "Review form" +// @Success 200 {string} string "Reviewed" +// @Bind user local key(__ctx_user) +// @Bind id path +// @Bind form body +func (c *tenants) ReviewJoinRequest(ctx fiber.Ctx, user *models.User, id int64, form *v1_dto.TenantJoinReviewForm) error { + return services.Super.ReviewTenantJoinRequest(ctx, user.ID, id, form) +} + +// Create tenant invite +// +// @Router /super/v1/tenants/:tenantID/invites [post] +// @Summary Create tenant invite +// @Description Create tenant invite code +// @Tags Tenant +// @Accept json +// @Produce json +// @Param tenantID path int64 true "Tenant ID" +// @Param form body v1_dto.TenantInviteCreateForm true "Invite form" +// @Success 200 {object} v1_dto.TenantInviteItem +// @Bind tenantID path +// @Bind form body +func (c *tenants) CreateInvite(ctx fiber.Ctx, tenantID int64, form *v1_dto.TenantInviteCreateForm) (*v1_dto.TenantInviteItem, error) { + return services.Super.CreateTenantInvite(ctx, tenantID, form) +} + // Create tenant // // @Router /super/v1/tenants [post] diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 3a50048..bc6b2c3 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -768,6 +768,273 @@ func (s *super) ListTenantUsers(ctx context.Context, tenantID int64, filter *sup }, nil } +func (s *super) ListTenantJoinRequests(ctx context.Context, filter *super_dto.SuperTenantJoinRequestListFilter) (*requests.Pager, error) { + if filter == nil { + filter = &super_dto.SuperTenantJoinRequestListFilter{} + } + + tbl, q := models.TenantJoinRequestQuery.QueryContext(ctx) + if filter.TenantID != nil && *filter.TenantID > 0 { + q = q.Where(tbl.TenantID.Eq(*filter.TenantID)) + } + 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(string(*filter.Status))) + } + + tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName) + if err != nil { + return nil, err + } + if tenantFilter { + if len(tenantIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.TenantID.In(tenantIDs...)) + } + } + + userIDs, userFilter, err := s.lookupUserIDs(ctx, filter.Username) + if err != nil { + return nil, err + } + if userFilter { + if len(userIDs) == 0 { + q = q.Where(tbl.ID.Eq(-1)) + } else { + q = q.Where(tbl.UserID.In(userIDs...)) + } + } + + 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)) + } + } + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + list, err := q.Order(tbl.CreatedAt.Desc()). + Offset(int(filter.Pagination.Offset())). + Limit(int(filter.Pagination.Limit)). + Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + // 补齐租户与用户信息,便于前端展示。 + tenantMap := make(map[int64]*models.Tenant) + userMap := make(map[int64]*models.User) + if len(list) > 0 { + tenantIDSet := make(map[int64]struct{}) + userIDSet := make(map[int64]struct{}) + for _, req := range list { + if req.TenantID > 0 { + tenantIDSet[req.TenantID] = struct{}{} + } + if req.UserID > 0 { + userIDSet[req.UserID] = struct{}{} + } + } + + tenantIDs := make([]int64, 0, len(tenantIDSet)) + for id := range tenantIDSet { + 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 + } + } + + userIDs := make([]int64, 0, len(userIDSet)) + for id := range userIDSet { + userIDs = append(userIDs, id) + } + 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 + } + } + } + + items := make([]super_dto.SuperTenantJoinRequestItem, 0, len(list)) + for _, req := range list { + tenant := tenantMap[req.TenantID] + user := userMap[req.UserID] + status := consts.TenantJoinRequestStatus(req.Status) + statusDescription := status.Description() + if statusDescription == "" { + statusDescription = req.Status + } + + tenantCode := "" + tenantName := "" + if tenant != nil { + tenantCode = tenant.Code + tenantName = tenant.Name + } + username := "" + if user != nil { + username = user.Username + } + if username == "" && req.UserID > 0 { + username = "ID:" + strconv.FormatInt(req.UserID, 10) + } + + items = append(items, super_dto.SuperTenantJoinRequestItem{ + ID: req.ID, + TenantID: req.TenantID, + TenantCode: tenantCode, + TenantName: tenantName, + UserID: req.UserID, + Username: username, + Status: req.Status, + StatusDescription: statusDescription, + Reason: req.Reason, + DecidedAt: s.formatTime(req.DecidedAt), + DecidedOperatorUserID: req.DecidedOperatorUserID, + DecidedReason: req.DecidedReason, + CreatedAt: s.formatTime(req.CreatedAt), + UpdatedAt: s.formatTime(req.UpdatedAt), + }) + } + + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: items, + }, nil +} + +func (s *super) ReviewTenantJoinRequest(ctx context.Context, operatorID, requestID int64, form *v1_dto.TenantJoinReviewForm) error { + if operatorID == 0 { + return errorx.ErrUnauthorized.WithMsg("缺少操作者信息") + } + if form == nil { + return errorx.ErrBadRequest.WithMsg("审核参数不能为空") + } + action := strings.ToLower(strings.TrimSpace(form.Action)) + if action != "approve" && action != "reject" { + return errorx.ErrBadRequest.WithMsg("审核动作无效") + } + + tblReq, qReq := models.TenantJoinRequestQuery.QueryContext(ctx) + req, err := qReq.Where(tblReq.ID.Eq(requestID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errorx.ErrRecordNotFound.WithMsg("申请不存在") + } + return errorx.ErrDatabaseError.WithCause(err) + } + if req.Status != string(consts.TenantJoinRequestStatusPending) { + return errorx.ErrBadRequest.WithMsg("申请已处理") + } + + reason := strings.TrimSpace(form.Reason) + now := time.Now() + if action == "reject" { + _, err = qReq.Where( + tblReq.ID.Eq(req.ID), + tblReq.Status.Eq(string(consts.TenantJoinRequestStatusPending)), + ).UpdateSimple( + tblReq.Status.Value(string(consts.TenantJoinRequestStatusRejected)), + tblReq.DecidedAt.Value(now), + tblReq.DecidedOperatorUserID.Value(operatorID), + tblReq.DecidedReason.Value(reason), + ) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil + } + + // 审核通过需在事务内写入成员并更新申请状态。 + return models.Q.Transaction(func(tx *models.Query) error { + tblMember, qMember := tx.TenantUser.QueryContext(ctx) + exists, err := qMember.Where( + tblMember.TenantID.Eq(req.TenantID), + tblMember.UserID.Eq(req.UserID), + ).Exists() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + if exists { + return errorx.ErrBadRequest.WithMsg("用户已是成员") + } + + member := &models.TenantUser{ + TenantID: req.TenantID, + UserID: req.UserID, + Role: types.Array[consts.TenantUserRole]{consts.TenantUserRoleMember}, + Status: consts.UserStatusVerified, + } + if err := qMember.Create(member); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + + tblReqTx, qReqTx := tx.TenantJoinRequest.QueryContext(ctx) + _, err = qReqTx.Where( + tblReqTx.ID.Eq(req.ID), + tblReqTx.Status.Eq(string(consts.TenantJoinRequestStatusPending)), + ).UpdateSimple( + tblReqTx.Status.Value(string(consts.TenantJoinRequestStatusApproved)), + tblReqTx.DecidedAt.Value(now), + tblReqTx.DecidedOperatorUserID.Value(operatorID), + tblReqTx.DecidedReason.Value(reason), + ) + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } + return nil + }) +} + +func (s *super) CreateTenantInvite(ctx context.Context, tenantID int64, form *v1_dto.TenantInviteCreateForm) (*v1_dto.TenantInviteItem, error) { + if tenantID == 0 { + return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + + // 使用租户主账号执行创建邀请码逻辑,复用既有校验流程。 + tbl, q := models.TenantQuery.QueryContext(ctx) + tenant, err := q.Where(tbl.ID.Eq(tenantID)).First() + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errorx.ErrRecordNotFound.WithMsg("租户不存在") + } + return nil, errorx.ErrDatabaseError.WithCause(err) + } + return Tenant.CreateInvite(ctx, tenantID, tenant.UserID, form) +} + func (s *super) ListUserTenants(ctx context.Context, userID int64, filter *super_dto.SuperUserTenantListFilter) (*requests.Pager, error) { tbl, q := models.TenantUserQuery.QueryContext(ctx) q = q.Where(tbl.UserID.Eq(userID)) diff --git a/docs/superadmin_progress.md b/docs/superadmin_progress.md index aa1e110..191cd8f 100644 --- a/docs/superadmin_progress.md +++ b/docs/superadmin_progress.md @@ -4,8 +4,8 @@ ## 1) 总体结论 -- **已落地**:登录、租户/用户/订单/内容的基础管理、提现审核、报表概览与导出、优惠券与创作者基础列表。 -- **部分落地**:平台概览(缺少内容统计与运营趋势)、内容治理(缺审核流)、创作者/优惠券/财务(缺跨租户深度操作与明细)。 +- **已落地**:登录、租户/用户/订单/内容的基础管理、内容审核(含批量)、提现审核、报表概览与导出、用户钱包查看、创作者成员审核/邀请、优惠券创建/编辑/发放。 +- **部分落地**:平台概览(缺少内容统计与运营趋势)、租户详情(缺财务/报表聚合)、用户管理/详情(缺通知/优惠券/实名/充值/收藏等明细)、创作者/优惠券(缺结算账户审核/发放记录/冻结等深度治理)。 - **未落地**:资产治理、通知中心、审计与系统配置类能力。 ## 2) 按页面完成度(对照 2.x) @@ -28,7 +28,7 @@ ### 2.4 租户详情 `/superadmin/tenants/:tenantID` - 状态:**部分完成** - 已有:租户信息、状态/续期、成员列表、内容与订单查询。 -- 缺口:成员审核/邀请、租户级财务与报表聚合入口。 +- 缺口:租户级财务与报表聚合入口(成员审核/邀请由超管入口完成)。 ### 2.5 用户管理 `/superadmin/users` - 状态:**部分完成** @@ -38,7 +38,7 @@ ### 2.6 用户详情 `/superadmin/users/:userID` - 状态:**部分完成** - 已有:用户资料、租户关系、订单查询、钱包余额与流水。 -- 缺口:通知、优惠券、收藏/点赞等详情。 +- 缺口:通知、优惠券、收藏/点赞、实名认证详情等明细。 ### 2.7 内容治理 `/superadmin/contents` - 状态:**部分完成** @@ -52,13 +52,13 @@ ### 2.9 创作者与成员审核 `/superadmin/creators` - 状态:**部分完成** -- 已有:创作者(租户)列表、状态更新。 -- 缺口:成员审核/邀请、结算账户审核、跨租户创作者审核流。 +- 已有:创作者(租户)列表、状态更新、成员申请列表/审核、成员邀请创建。 +- 缺口:跨租户创作者申请审核、结算账户审核、提现审核入口(与财务联动)。 ### 2.10 优惠券 `/superadmin/coupons` - 状态:**部分完成** -- 已有:跨租户优惠券列表。 -- 缺口:创建/编辑/冻结、发放记录与异常核查。 +- 已有:跨租户优惠券列表、创建/编辑、发放。 +- 缺口:冻结/归档、发放记录与异常核查。 ### 2.11 财务与钱包 `/superadmin/finance` - 状态:**部分完成** @@ -82,12 +82,12 @@ ## 3) `/super/v1` 接口覆盖度概览 -- **已具备**:Auth、Tenants、Users(含钱包)、Contents、Orders、Withdrawals、Reports、Coupons(列表)、Creators(列表)。 -- **缺失/待补**:资产治理、通知中心、用户优惠券/通知明细、创作者成员审核、优惠券发放与冻结。 +- **已具备**:Auth、Tenants(含成员审核/邀请)、Users(含钱包)、Contents、Orders、Withdrawals、Reports、Coupons(列表/创建/编辑/发放)、Creators(列表)。 +- **缺失/待补**:资产治理、通知中心、用户优惠券/通知明细、创作者申请/结算账户审核、优惠券冻结与发放记录。 ## 4) 建议的下一步(按优先级) -1. **补齐内容审核与违规治理**:落地 `/super/v1/contents/:id/review` 前端入口与批量操作。 -2. **完善用户与钱包视图**:增加钱包流水、充值/退款/通知/优惠券详情接口及页面。 -3. **创作者与优惠券深度操作**:新增成员审核/邀请、优惠券冻结/发放记录。 -4. **资产与通知中心**:补齐接口与页面,形成治理闭环。 +1. **完善用户与通知/优惠券明细**:补齐用户通知、优惠券、实名详情、充值记录等超管视图接口与页面。 +2. **创作者/优惠券深度治理**:补齐创作者申请/结算账户审核、优惠券冻结/发放记录。 +3. **平台概览增强**:补齐内容总量与趋势、退款率、订单漏斗等核心指标。 +4. **资产与通知中心**:补齐资产治理与通知中心接口/页面,形成治理闭环。 diff --git a/frontend/superadmin/src/service/CouponService.js b/frontend/superadmin/src/service/CouponService.js index 2369a50..68b6721 100644 --- a/frontend/superadmin/src/service/CouponService.js +++ b/frontend/superadmin/src/service/CouponService.js @@ -40,5 +40,55 @@ export const CouponService = { total: data?.total ?? 0, items: normalizeItems(data?.items) }; + }, + async createCoupon(tenantID, form = {}) { + if (!tenantID) throw new Error('tenantID is required'); + return requestJson(`/super/v1/tenants/${tenantID}/coupons`, { + method: 'POST', + body: normalizeCouponForm(form) + }); + }, + async getCoupon(tenantID, couponID) { + if (!tenantID) throw new Error('tenantID is required'); + if (!couponID) throw new Error('couponID is required'); + return requestJson(`/super/v1/tenants/${tenantID}/coupons/${couponID}`); + }, + async updateCoupon(tenantID, couponID, form = {}) { + if (!tenantID) throw new Error('tenantID is required'); + if (!couponID) throw new Error('couponID is required'); + return requestJson(`/super/v1/tenants/${tenantID}/coupons/${couponID}`, { + method: 'PUT', + body: normalizeCouponForm(form) + }); + }, + async grantCoupon(tenantID, couponID, userIDs = []) { + if (!tenantID) throw new Error('tenantID is required'); + if (!couponID) throw new Error('couponID is required'); + if (!Array.isArray(userIDs) || userIDs.length === 0) throw new Error('userIDs is required'); + return requestJson(`/super/v1/tenants/${tenantID}/coupons/${couponID}/grant`, { + method: 'POST', + body: { user_ids: userIDs } + }); } }; + +function normalizeCouponForm(form) { + 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(); + }; + + return { + title: form.title, + description: form.description, + type: form.type, + value: form.value, + min_order_amount: form.min_order_amount, + max_discount: form.max_discount, + total_quantity: form.total_quantity, + start_at: iso(form.start_at), + end_at: iso(form.end_at) + }; +} diff --git a/frontend/superadmin/src/service/CreatorService.js b/frontend/superadmin/src/service/CreatorService.js index 0e78415..be5b217 100644 --- a/frontend/superadmin/src/service/CreatorService.js +++ b/frontend/superadmin/src/service/CreatorService.js @@ -38,5 +38,52 @@ export const CreatorService = { total: data?.total ?? 0, items: normalizeItems(data?.items) }; + }, + async listJoinRequests({ page, limit, tenant_id, tenant_code, tenant_name, user_id, username, status, created_at_from, created_at_to } = {}) { + 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, + tenant_id, + tenant_code, + tenant_name, + user_id, + username, + status, + created_at_from: iso(created_at_from), + created_at_to: iso(created_at_to) + }; + + const data = await requestJson('/super/v1/tenant-join-requests', { query }); + return { + page: data?.page ?? page ?? 1, + limit: data?.limit ?? limit ?? 10, + total: data?.total ?? 0, + items: normalizeItems(data?.items) + }; + }, + async reviewJoinRequest(requestID, { action, reason } = {}) { + if (!requestID) throw new Error('requestID is required'); + return requestJson(`/super/v1/tenant-join-requests/${requestID}/review`, { + method: 'POST', + body: { action, reason } + }); + }, + async createInvite(tenantID, { max_uses, expires_at, remark } = {}) { + if (!tenantID) throw new Error('tenantID is required'); + return requestJson(`/super/v1/tenants/${tenantID}/invites`, { + method: 'POST', + body: { + max_uses, + expires_at, + remark + } + }); } }; diff --git a/frontend/superadmin/src/views/superadmin/Coupons.vue b/frontend/superadmin/src/views/superadmin/Coupons.vue index 546ec68..8db2a54 100644 --- a/frontend/superadmin/src/views/superadmin/Coupons.vue +++ b/frontend/superadmin/src/views/superadmin/Coupons.vue @@ -39,6 +39,25 @@ const statusOptions = [ { label: '已过期', value: 'expired' } ]; +const editDialogVisible = ref(false); +const couponSubmitting = ref(false); +const editingCoupon = ref(null); +const formTenantID = ref(null); +const formTitle = ref(''); +const formDescription = ref(''); +const formType = ref('fix_amount'); +const formValue = ref(0); +const formMinOrderAmount = ref(0); +const formMaxDiscount = ref(0); +const formTotalQuantity = ref(0); +const formStartAt = ref(null); +const formEndAt = ref(null); + +const grantDialogVisible = ref(false); +const grantSubmitting = ref(false); +const grantCoupon = ref(null); +const grantUserIDsText = ref(''); + function formatDate(value) { if (!value) return '-'; if (String(value).startsWith('0001-01-01')) return '-'; @@ -47,6 +66,13 @@ function formatDate(value) { return date.toLocaleString(); } +function parseDateValue(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date; +} + function formatCny(amountInCents) { const amount = Number(amountInCents) / 100; if (!Number.isFinite(amount)) return '-'; @@ -66,6 +92,111 @@ function getStatusSeverity(value) { } } +function resetCouponForm() { + formTenantID.value = null; + formTitle.value = ''; + formDescription.value = ''; + formType.value = 'fix_amount'; + formValue.value = 0; + formMinOrderAmount.value = 0; + formMaxDiscount.value = 0; + formTotalQuantity.value = 0; + formStartAt.value = null; + formEndAt.value = null; +} + +function openCreateDialog() { + editingCoupon.value = null; + resetCouponForm(); + editDialogVisible.value = true; +} + +function openEditDialog(row) { + editingCoupon.value = row; + formTenantID.value = row?.tenant_id ?? null; + formTitle.value = row?.title ?? ''; + formDescription.value = row?.description ?? ''; + formType.value = row?.type ?? 'fix_amount'; + formValue.value = row?.value ?? 0; + formMinOrderAmount.value = row?.min_order_amount ?? 0; + formMaxDiscount.value = row?.max_discount ?? 0; + formTotalQuantity.value = row?.total_quantity ?? 0; + formStartAt.value = parseDateValue(row?.start_at); + formEndAt.value = parseDateValue(row?.end_at); + editDialogVisible.value = true; +} + +async function confirmSaveCoupon() { + const tenantValue = formTenantID.value; + if (!tenantValue) { + toast.add({ severity: 'warn', summary: '缺少租户', detail: '请填写 TenantID', life: 3000 }); + return; + } + if (!formTitle.value.trim()) { + toast.add({ severity: 'warn', summary: '缺少标题', detail: '优惠券标题不能为空', life: 3000 }); + return; + } + + couponSubmitting.value = true; + try { + const payload = { + title: formTitle.value, + description: formDescription.value, + type: formType.value, + value: formValue.value, + min_order_amount: formMinOrderAmount.value, + max_discount: formMaxDiscount.value, + total_quantity: formTotalQuantity.value, + start_at: formStartAt.value, + end_at: formEndAt.value + }; + if (editingCoupon.value?.id) { + await CouponService.updateCoupon(tenantValue, editingCoupon.value.id, payload); + toast.add({ severity: 'success', summary: '更新成功', detail: `CouponID: ${editingCoupon.value.id}`, life: 3000 }); + } else { + await CouponService.createCoupon(tenantValue, payload); + toast.add({ severity: 'success', summary: '创建成功', detail: `TenantID: ${tenantValue}`, life: 3000 }); + } + editDialogVisible.value = false; + await loadCoupons(); + } catch (error) { + toast.add({ severity: 'error', summary: '保存失败', detail: error?.message || '无法保存优惠券', life: 4000 }); + } finally { + couponSubmitting.value = false; + } +} + +function openGrantDialog(row) { + grantCoupon.value = row; + grantUserIDsText.value = ''; + grantDialogVisible.value = true; +} + +async function confirmGrant() { + const coupon = grantCoupon.value; + if (!coupon?.id || !coupon?.tenant_id) return; + const ids = grantUserIDsText.value + .split(/[\s,]+/g) + .map((item) => Number(item)) + .filter((value) => Number.isFinite(value) && value > 0); + if (ids.length === 0) { + toast.add({ severity: 'warn', summary: '请输入用户ID', detail: '至少填写 1 个用户ID', life: 3000 }); + return; + } + + grantSubmitting.value = true; + try { + const res = await CouponService.grantCoupon(coupon.tenant_id, coupon.id, ids); + toast.add({ severity: 'success', summary: '发放成功', detail: `已发放 ${res?.granted ?? ids.length} 张`, life: 3000 }); + grantDialogVisible.value = false; + await loadCoupons(); + } catch (error) { + toast.add({ severity: 'error', summary: '发放失败', detail: error?.message || '无法发放优惠券', life: 4000 }); + } finally { + grantSubmitting.value = false; + } +} + async function loadCoupons() { loading.value = true; try { @@ -136,6 +267,7 @@ onMounted(() => {

优惠券

+
@@ -246,6 +378,96 @@ onMounted(() => { {{ formatDate(data.created_at) }} + + +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +