feat: add superadmin member review and coupon ops

This commit is contained in:
2026-01-15 11:53:52 +08:00
parent 56082bad4f
commit 8419ddede7
11 changed files with 1254 additions and 96 deletions

View File

@@ -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<int>/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<int>/coupons/:id<int> [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<int>/coupons/:id<int> [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<int>/coupons/:id<int>/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
}

View File

@@ -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。

View File

@@ -73,3 +73,9 @@ type SuperCouponItem struct {
// UpdatedAt 更新时间RFC3339
UpdatedAt string `json:"updated_at"`
}
// SuperCouponGrantResponse 超管优惠券发放结果。
type SuperCouponGrantResponse struct {
// Granted 实际发放数量。
Granted int `json:"granted"`
}

View File

@@ -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<int>/coupons/:id<int> -> coupons.Get")
router.Get("/super/v1/tenants/:tenantID<int>/coupons/:id<int>"[len(r.Path()):], DataFunc2(
r.coupons.Get,
PathParam[int64]("tenantID"),
PathParam[int64]("id"),
))
r.log.Debugf("Registering route: Post /super/v1/tenants/:tenantID<int>/coupons -> coupons.Create")
router.Post("/super/v1/tenants/:tenantID<int>/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<int>/coupons/:id<int>/grant -> coupons.Grant")
router.Post("/super/v1/tenants/:tenantID<int>/coupons/:id<int>/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<int>/coupons/:id<int> -> coupons.Update")
router.Put("/super/v1/tenants/:tenantID<int>/coupons/:id<int>"[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<int>/review -> tenants.ReviewJoinRequest")
router.Post("/super/v1/tenant-join-requests/:id<int>/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<int>/invites -> tenants.CreateInvite")
router.Post("/super/v1/tenants/:tenantID<int>/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(

View File

@@ -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<int>/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<int>/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]

View File

@@ -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))