feat: 更新租户和订单相关功能,添加租户成员列表接口,优化数据处理和前端展示
This commit is contained in:
@@ -11,7 +11,7 @@ type OrderStatisticsRow struct {
|
||||
}
|
||||
|
||||
type OrderStatisticsResponse struct {
|
||||
TotalCount int64 `json:"total_count"`
|
||||
TotalAmountPaidSum int64 `json:"total_amount_paid_sum"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
TotalAmountPaidSum int64 `json:"total_amount_paid_sum"`
|
||||
ByStatus []*OrderStatisticsRow `json:"by_status"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/requests"
|
||||
@@ -10,19 +11,59 @@ import (
|
||||
)
|
||||
|
||||
type TenantFilter struct {
|
||||
requests.Pagination
|
||||
requests.SortQueryFilter
|
||||
// Pagination page/limit.
|
||||
requests.Pagination `json:",inline" query:",inline"`
|
||||
|
||||
// SortQueryFilter defines asc/desc ordering.
|
||||
requests.SortQueryFilter `json:",inline" query:",inline"`
|
||||
|
||||
Name *string `json:"name,omitempty" query:"name"`
|
||||
Code *string `json:"code,omitempty" query:"code"`
|
||||
ID *int64 `json:"id,omitempty" query:"id"`
|
||||
UserID *int64 `json:"user_id,omitempty" query:"user_id"`
|
||||
Status *consts.TenantStatus `json:"status,omitempty" query:"status"`
|
||||
|
||||
ExpiredAtFrom *time.Time `json:"expired_at_from,omitempty" query:"expired_at_from"`
|
||||
ExpiredAtTo *time.Time `json:"expired_at_to,omitempty" query:"expired_at_to"`
|
||||
|
||||
CreatedAtFrom *time.Time `json:"created_at_from,omitempty" query:"created_at_from"`
|
||||
CreatedAtTo *time.Time `json:"created_at_to,omitempty" query:"created_at_to"`
|
||||
}
|
||||
|
||||
func (f *TenantFilter) NameTrimmed() string {
|
||||
if f == nil || f.Name == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*f.Name)
|
||||
}
|
||||
|
||||
func (f *TenantFilter) CodeTrimmed() string {
|
||||
if f == nil || f.Code == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(*f.Code))
|
||||
}
|
||||
|
||||
type TenantItem struct {
|
||||
*models.Tenant
|
||||
|
||||
UserCount int64 `json:"user_count"`
|
||||
UserBalance int64 `json:"user_balance"`
|
||||
StatusDescription string `json:"status_description"`
|
||||
UserCount int64 `json:"user_count"`
|
||||
// IncomeAmountPaidSum 累计收入金额(单位:分,CNY):按 orders 聚合得到的已支付净收入(不含退款中/已退款订单)。
|
||||
IncomeAmountPaidSum int64 `json:"income_amount_paid_sum"`
|
||||
StatusDescription string `json:"status_description"`
|
||||
|
||||
Owner *TenantOwnerUserLite `json:"owner,omitempty"`
|
||||
AdminUsers []*TenantAdminUserLite `json:"admin_users,omitempty"`
|
||||
}
|
||||
|
||||
type TenantOwnerUserLite struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type TenantAdminUserLite struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type TenantCreateForm struct {
|
||||
|
||||
26
backend/app/http/super/dto/tenant_user.go
Normal file
26
backend/app/http/super/dto/tenant_user.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"quyun/v2/database/models"
|
||||
"quyun/v2/pkg/consts"
|
||||
|
||||
"go.ipao.vip/gen/types"
|
||||
)
|
||||
|
||||
type SuperUserLite struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Status consts.UserStatus `json:"status"`
|
||||
Roles types.Array[consts.Role] `json:"roles"`
|
||||
VerifiedAt time.Time `json:"verified_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
StatusDescription string `json:"status_description,omitempty"`
|
||||
}
|
||||
|
||||
type SuperTenantUserItem struct {
|
||||
TenantUser *models.TenantUser `json:"tenant_user,omitempty"`
|
||||
User *SuperUserLite `json:"user,omitempty"`
|
||||
}
|
||||
@@ -22,4 +22,3 @@ type order struct{}
|
||||
func (*order) statistics(ctx fiber.Ctx) (*dto.OrderStatisticsResponse, error) {
|
||||
return services.Order.SuperStatistics(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package super
|
||||
|
||||
import (
|
||||
"quyun/v2/app/http/super/dto"
|
||||
tenantdto "quyun/v2/app/http/tenant/dto"
|
||||
"quyun/v2/app/middlewares"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
@@ -70,6 +71,12 @@ func (r *Routes) Register(router fiber.Router) {
|
||||
r.tenant.create,
|
||||
Body[dto.TenantCreateForm]("form"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID/users -> tenant.users")
|
||||
router.Get("/super/v1/tenants/:tenantID/users"[len(r.Path()):], DataFunc2(
|
||||
r.tenant.users,
|
||||
PathParam[int64]("tenantID"),
|
||||
Query[tenantdto.AdminTenantUserListFilter]("filter"),
|
||||
))
|
||||
r.log.Debugf("Registering route: Get /super/v1/tenants/statuses -> tenant.statusList")
|
||||
router.Get("/super/v1/tenants/statuses"[len(r.Path()):], DataFunc0(
|
||||
r.tenant.statusList,
|
||||
|
||||
@@ -3,6 +3,7 @@ package super
|
||||
import (
|
||||
"quyun/v2/app/errorx"
|
||||
"quyun/v2/app/http/super/dto"
|
||||
tenantdto "quyun/v2/app/http/tenant/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/app/services"
|
||||
"quyun/v2/database/models"
|
||||
@@ -44,6 +45,23 @@ func (*tenant) create(ctx fiber.Ctx, form *dto.TenantCreateForm) (*models.Tenant
|
||||
return services.Tenant.SuperCreateTenant(ctx, form)
|
||||
}
|
||||
|
||||
// users
|
||||
//
|
||||
// @Summary 租户成员列表(平台侧)
|
||||
// @Tags Super
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param tenantID path int64 true "TenantID"
|
||||
// @Param filter query tenantdto.AdminTenantUserListFilter true "Filter"
|
||||
// @Success 200 {object} requests.Pager{items=dto.SuperTenantUserItem}
|
||||
//
|
||||
// @Router /super/v1/tenants/:tenantID/users [get]
|
||||
// @Bind tenantID path
|
||||
// @Bind filter query
|
||||
func (*tenant) users(ctx fiber.Ctx, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) {
|
||||
return services.Tenant.SuperTenantUsersPage(ctx, tenantID, filter)
|
||||
}
|
||||
|
||||
// updateExpire
|
||||
//
|
||||
// @Summary 更新过期时间
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"quyun/v2/app/http/super/dto"
|
||||
superdto "quyun/v2/app/http/super/dto"
|
||||
tenantdto "quyun/v2/app/http/tenant/dto"
|
||||
"quyun/v2/app/requests"
|
||||
"quyun/v2/database"
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/samber/lo"
|
||||
"github.com/sirupsen/logrus"
|
||||
"go.ipao.vip/gen"
|
||||
"go.ipao.vip/gen/field"
|
||||
"go.ipao.vip/gen/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -26,7 +27,7 @@ import (
|
||||
type tenant struct{}
|
||||
|
||||
// SuperCreateTenant 超级管理员创建租户,并将指定用户设为租户管理员。
|
||||
func (t *tenant) SuperCreateTenant(ctx context.Context, form *dto.TenantCreateForm) (*models.Tenant, error) {
|
||||
func (t *tenant) SuperCreateTenant(ctx context.Context, form *superdto.TenantCreateForm) (*models.Tenant, error) {
|
||||
if form == nil {
|
||||
return nil, errors.New("form is nil")
|
||||
}
|
||||
@@ -42,7 +43,7 @@ func (t *tenant) SuperCreateTenant(ctx context.Context, form *dto.TenantCreateFo
|
||||
if form.AdminUserID <= 0 {
|
||||
return nil, errors.New("admin_user_id must be > 0")
|
||||
}
|
||||
duration, err := (&dto.TenantExpireUpdateForm{Duration: form.Duration}).ParseDuration()
|
||||
duration, err := (&superdto.TenantExpireUpdateForm{Duration: form.Duration}).ParseDuration()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -164,6 +165,95 @@ func (t *tenant) AdminTenantUsersPage(ctx context.Context, tenantID int64, filte
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SuperTenantUsersPage 超级管理员分页查询租户成员(脱敏 user 字段,避免泄露 password)。
|
||||
func (t *tenant) SuperTenantUsersPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminTenantUserListFilter) (*requests.Pager, error) {
|
||||
if tenantID <= 0 {
|
||||
return nil, errors.New("tenant_id must be > 0")
|
||||
}
|
||||
if filter == nil {
|
||||
filter = &tenantdto.AdminTenantUserListFilter{}
|
||||
}
|
||||
|
||||
filter.Pagination.Format()
|
||||
|
||||
tbl, query := models.TenantUserQuery.QueryContext(ctx)
|
||||
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
|
||||
if filter.UserID != nil && *filter.UserID > 0 {
|
||||
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
|
||||
}
|
||||
if filter.Role != nil && *filter.Role != "" {
|
||||
conds = append(conds, tbl.Role.Contains(types.NewArray([]consts.TenantUserRole{*filter.Role})))
|
||||
}
|
||||
if filter.Status != nil && *filter.Status != "" {
|
||||
conds = append(conds, tbl.Status.Eq(*filter.Status))
|
||||
}
|
||||
if username := filter.UsernameTrimmed(); username != "" {
|
||||
uTbl, _ := models.UserQuery.QueryContext(ctx)
|
||||
query = query.LeftJoin(uTbl, uTbl.ID.EqCol(tbl.UserID))
|
||||
conds = append(conds, uTbl.Username.Like(database.WrapLike(username)))
|
||||
}
|
||||
|
||||
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userIDs := make([]int64, 0, len(items))
|
||||
for _, tu := range items {
|
||||
if tu == nil {
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, tu.UserID)
|
||||
}
|
||||
|
||||
var users []*models.User
|
||||
if len(userIDs) > 0 {
|
||||
uTbl, uQuery := models.UserQuery.QueryContext(ctx)
|
||||
users, err = uQuery.Where(uTbl.ID.In(userIDs...)).Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
userMap := make(map[int64]*models.User, len(users))
|
||||
for _, u := range users {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
|
||||
out := make([]*superdto.SuperTenantUserItem, 0, len(items))
|
||||
for _, tu := range items {
|
||||
if tu == nil {
|
||||
continue
|
||||
}
|
||||
u := userMap[tu.UserID]
|
||||
var lite *superdto.SuperUserLite
|
||||
if u != nil {
|
||||
lite = &superdto.SuperUserLite{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Status: u.Status,
|
||||
StatusDescription: u.Status.Description(),
|
||||
Roles: u.Roles,
|
||||
VerifiedAt: u.VerifiedAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
out = append(out, &superdto.SuperTenantUserItem{
|
||||
TenantUser: tu,
|
||||
User: lite,
|
||||
})
|
||||
}
|
||||
|
||||
return &requests.Pager{
|
||||
Pagination: filter.Pagination,
|
||||
Total: total,
|
||||
Items: out,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *tenant) ContainsUserID(ctx context.Context, tenantID, userID int64) (*models.User, error) {
|
||||
tbl, query := models.TenantUserQuery.QueryContext(ctx)
|
||||
|
||||
@@ -242,12 +332,28 @@ func (t *tenant) SetUserRole(ctx context.Context, tenantID, userID int64, role .
|
||||
}
|
||||
|
||||
// Pager
|
||||
func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests.Pager, error) {
|
||||
func (t *tenant) Pager(ctx context.Context, filter *superdto.TenantFilter) (*requests.Pager, error) {
|
||||
tbl, query := models.TenantQuery.QueryContext(ctx)
|
||||
|
||||
conds := []gen.Condition{}
|
||||
if filter.Name != nil {
|
||||
conds = append(conds, tbl.Name.Like(database.WrapLike(*filter.Name)))
|
||||
if filter == nil {
|
||||
filter = &superdto.TenantFilter{}
|
||||
}
|
||||
|
||||
if filter.ID != nil && *filter.ID > 0 {
|
||||
conds = append(conds, tbl.ID.Eq(*filter.ID))
|
||||
}
|
||||
if filter.UserID != nil && *filter.UserID > 0 {
|
||||
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
|
||||
}
|
||||
|
||||
if name := filter.NameTrimmed(); name != "" {
|
||||
conds = append(conds, tbl.Name.Like(database.WrapLike(name)))
|
||||
}
|
||||
|
||||
if code := filter.CodeTrimmed(); code != "" {
|
||||
// code 在库内按约定存储为 lower-case;这里统一转小写后做 like。
|
||||
conds = append(conds, tbl.Code.Like(database.WrapLike(code)))
|
||||
}
|
||||
|
||||
if filter.Status != nil {
|
||||
@@ -255,7 +361,65 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests
|
||||
}
|
||||
|
||||
filter.Pagination.Format()
|
||||
mm, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
|
||||
|
||||
if filter.ExpiredAtFrom != nil {
|
||||
conds = append(conds, tbl.ExpiredAt.Gte(*filter.ExpiredAtFrom))
|
||||
}
|
||||
if filter.ExpiredAtTo != nil {
|
||||
conds = append(conds, tbl.ExpiredAt.Lte(*filter.ExpiredAtTo))
|
||||
}
|
||||
if filter.CreatedAtFrom != nil {
|
||||
conds = append(conds, tbl.CreatedAt.Gte(*filter.CreatedAtFrom))
|
||||
}
|
||||
if filter.CreatedAtTo != nil {
|
||||
conds = append(conds, tbl.CreatedAt.Lte(*filter.CreatedAtTo))
|
||||
}
|
||||
|
||||
// 排序白名单:避免把任意字符串拼进 SQL 导致注入或慢查询。
|
||||
orderBys := make([]field.Expr, 0, 6)
|
||||
allowedAsc := map[string]field.Expr{
|
||||
"id": tbl.ID.Asc(),
|
||||
"code": tbl.Code.Asc(),
|
||||
"name": tbl.Name.Asc(),
|
||||
"status": tbl.Status.Asc(),
|
||||
"expired_at": tbl.ExpiredAt.Asc(),
|
||||
"created_at": tbl.CreatedAt.Asc(),
|
||||
"updated_at": tbl.UpdatedAt.Asc(),
|
||||
}
|
||||
allowedDesc := map[string]field.Expr{
|
||||
"id": tbl.ID.Desc(),
|
||||
"code": tbl.Code.Desc(),
|
||||
"name": tbl.Name.Desc(),
|
||||
"status": tbl.Status.Desc(),
|
||||
"expired_at": tbl.ExpiredAt.Desc(),
|
||||
"created_at": tbl.CreatedAt.Desc(),
|
||||
"updated_at": tbl.UpdatedAt.Desc(),
|
||||
}
|
||||
for _, f := range filter.AscFields() {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if ob, ok := allowedAsc[f]; ok {
|
||||
orderBys = append(orderBys, ob)
|
||||
}
|
||||
}
|
||||
for _, f := range filter.DescFields() {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if ob, ok := allowedDesc[f]; ok {
|
||||
orderBys = append(orderBys, ob)
|
||||
}
|
||||
}
|
||||
if len(orderBys) == 0 {
|
||||
orderBys = append(orderBys, tbl.ID.Desc())
|
||||
} else {
|
||||
orderBys = append(orderBys, tbl.ID.Desc())
|
||||
}
|
||||
|
||||
mm, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -267,20 +431,42 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userBalanceMapping, err := t.TenantUserBalanceMapping(ctx, tenantIds)
|
||||
incomeMapping, err := t.TenantIncomePaidMapping(ctx, tenantIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := lo.Map(mm, func(model *models.Tenant, _ int) *dto.TenantItem {
|
||||
return &dto.TenantItem{
|
||||
Tenant: model,
|
||||
UserCount: lo.ValueOr(userCountMapping, model.ID, 0),
|
||||
UserBalance: lo.ValueOr(userBalanceMapping, model.ID, 0),
|
||||
StatusDescription: model.Status.Description(),
|
||||
items := lo.Map(mm, func(model *models.Tenant, _ int) *superdto.TenantItem {
|
||||
return &superdto.TenantItem{
|
||||
Tenant: model,
|
||||
UserCount: lo.ValueOr(userCountMapping, model.ID, 0),
|
||||
IncomeAmountPaidSum: lo.ValueOr(incomeMapping, model.ID, 0),
|
||||
StatusDescription: model.Status.Description(),
|
||||
}
|
||||
})
|
||||
|
||||
ownerMapping, err := t.TenantOwnerUserMapping(ctx, mm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, it := range items {
|
||||
if it == nil || it.Tenant == nil {
|
||||
continue
|
||||
}
|
||||
it.Owner = ownerMapping[it.Tenant.ID]
|
||||
}
|
||||
|
||||
adminUsersMapping, err := t.TenantAdminUsersMapping(ctx, tenantIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, it := range items {
|
||||
if it == nil || it.Tenant == nil {
|
||||
continue
|
||||
}
|
||||
it.AdminUsers = adminUsersMapping[it.Tenant.ID]
|
||||
}
|
||||
|
||||
return &requests.Pager{
|
||||
Pagination: filter.Pagination,
|
||||
Total: total,
|
||||
@@ -288,6 +474,124 @@ func (t *tenant) Pager(ctx context.Context, filter *dto.TenantFilter) (*requests
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *tenant) TenantOwnerUserMapping(ctx context.Context, tenants []*models.Tenant) (map[int64]*superdto.TenantOwnerUserLite, error) {
|
||||
result := make(map[int64]*superdto.TenantOwnerUserLite, len(tenants))
|
||||
|
||||
userIDs := make([]int64, 0, len(tenants))
|
||||
tenantIDs := make([]int64, 0, len(tenants))
|
||||
for _, te := range tenants {
|
||||
if te == nil || te.ID <= 0 {
|
||||
continue
|
||||
}
|
||||
tenantIDs = append(tenantIDs, te.ID)
|
||||
if te.UserID > 0 {
|
||||
userIDs = append(userIDs, te.UserID)
|
||||
}
|
||||
}
|
||||
for _, tenantID := range tenantIDs {
|
||||
result[tenantID] = nil
|
||||
}
|
||||
userIDs = lo.Uniq(userIDs)
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
uTbl, uQuery := models.UserQuery.QueryContext(ctx)
|
||||
users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userMap := make(map[int64]*models.User, len(users))
|
||||
for _, u := range users {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
|
||||
for _, te := range tenants {
|
||||
if te == nil || te.ID <= 0 || te.UserID <= 0 {
|
||||
continue
|
||||
}
|
||||
u := userMap[te.UserID]
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
result[te.ID] = &superdto.TenantOwnerUserLite{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TenantAdminUsersMapping 返回每个租户的管理员用户(用于 superadmin 租户列表展示)。
|
||||
func (t *tenant) TenantAdminUsersMapping(ctx context.Context, tenantIDs []int64) (map[int64][]*superdto.TenantAdminUserLite, error) {
|
||||
result := make(map[int64][]*superdto.TenantAdminUserLite, len(tenantIDs))
|
||||
for _, id := range tenantIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
result[id] = nil
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
tuTbl, tuQuery := models.TenantUserQuery.QueryContext(ctx)
|
||||
tus, err := tuQuery.Where(
|
||||
tuTbl.TenantID.In(tenantIDs...),
|
||||
tuTbl.Role.Contains(types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleTenantAdmin})),
|
||||
).Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userIDs := make([]int64, 0, len(tus))
|
||||
type pair struct {
|
||||
tenantID int64
|
||||
userID int64
|
||||
}
|
||||
pairs := make([]pair, 0, len(tus))
|
||||
for _, tu := range tus {
|
||||
if tu == nil || tu.TenantID <= 0 || tu.UserID <= 0 {
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, tu.UserID)
|
||||
pairs = append(pairs, pair{tenantID: tu.TenantID, userID: tu.UserID})
|
||||
}
|
||||
userIDs = lo.Uniq(userIDs)
|
||||
|
||||
userMap := map[int64]*models.User{}
|
||||
if len(userIDs) > 0 {
|
||||
uTbl, uQuery := models.UserQuery.QueryContext(ctx)
|
||||
users, err := uQuery.Where(uTbl.ID.In(userIDs...)).Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range users {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range pairs {
|
||||
u := userMap[p.userID]
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
result[p.tenantID] = append(result[p.tenantID], &superdto.TenantAdminUserLite{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (t *tenant) TenantUserCountMapping(ctx context.Context, tenantIds []int64) (map[int64]int64, error) {
|
||||
// 关键语义:返回值必须包含入参中的所有 tenant_id。
|
||||
// 即便该租户当前没有成员,也应返回 count=0,便于调用方直接取值而无需额外补全逻辑。
|
||||
@@ -368,6 +672,41 @@ func (t *tenant) TenantUserBalanceMapping(ctx context.Context, tenantIds []int64
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TenantIncomePaidMapping 按租户维度统计“已支付订单”的累计收入(单位:分,CNY)。
|
||||
// 说明:
|
||||
// - 仅统计 orders.status = paid 的订单金额;
|
||||
// - refunding/refunded 不计入收入(避免把已退/退款中的金额当作收入)。
|
||||
func (t *tenant) TenantIncomePaidMapping(ctx context.Context, tenantIDs []int64) (map[int64]int64, error) {
|
||||
result := make(map[int64]int64, len(tenantIDs))
|
||||
for _, id := range tenantIDs {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
result[id] = 0
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
oTbl, oQuery := models.OrderQuery.QueryContext(ctx)
|
||||
var rows []struct {
|
||||
TenantID int64
|
||||
Income int64
|
||||
}
|
||||
err := oQuery.
|
||||
Select(oTbl.TenantID, oTbl.AmountPaid.Sum().As("income")).
|
||||
Where(oTbl.TenantID.In(tenantIDs...), oTbl.Status.Eq(consts.OrderStatusPaid)).
|
||||
Group(oTbl.TenantID).
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.TenantID] = row.Income
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// FindByID
|
||||
func (t *tenant) FindByID(ctx context.Context, id int64) (*models.Tenant, error) {
|
||||
tbl, query := models.TenantQuery.QueryContext(ctx)
|
||||
|
||||
4
frontend/superadmin/dist/index.html
vendored
4
frontend/superadmin/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sakai Vue</title>
|
||||
<link href="https://fonts.cdnfonts.com/css/lato" rel="stylesheet">
|
||||
<script type="module" crossorigin src="./assets/index-Brwtp57n.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Ba8sjR1v.css">
|
||||
<script type="module" crossorigin src="./assets/index-CpopCMB_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BJeWLZjR.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -7,8 +7,41 @@ function normalizeItems(items) {
|
||||
}
|
||||
|
||||
export const TenantService = {
|
||||
async listTenants({ page, limit, name, code, status, sortField, sortOrder } = {}) {
|
||||
const query = { page, limit, name, code, status };
|
||||
async listTenants({
|
||||
page,
|
||||
limit,
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
code,
|
||||
status,
|
||||
expired_at_from,
|
||||
expired_at_to,
|
||||
created_at_from,
|
||||
created_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,
|
||||
id,
|
||||
user_id,
|
||||
name,
|
||||
code,
|
||||
status,
|
||||
expired_at_from: iso(expired_at_from),
|
||||
expired_at_to: iso(expired_at_to),
|
||||
created_at_from: iso(created_at_from),
|
||||
created_at_to: iso(created_at_to)
|
||||
};
|
||||
if (sortField && sortOrder) {
|
||||
if (sortOrder === 1) query.asc = sortField;
|
||||
if (sortOrder === -1) query.desc = sortField;
|
||||
@@ -33,6 +66,11 @@ export const TenantService = {
|
||||
}
|
||||
});
|
||||
},
|
||||
async listTenantUsers(tenantID, { page, limit, user_id, username, role, status } = {}) {
|
||||
return requestJson(`/super/v1/tenants/${tenantID}/users`, {
|
||||
query: { page, limit, user_id, username, role, status }
|
||||
});
|
||||
},
|
||||
async renewTenantExpire({ tenantID, duration }) {
|
||||
return requestJson(`/super/v1/tenants/${tenantID}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -15,11 +15,30 @@ const totalRecords = ref(0);
|
||||
const page = ref(1);
|
||||
const rows = ref(10);
|
||||
|
||||
const keyword = ref('');
|
||||
const nameKeyword = ref('');
|
||||
const codeKeyword = ref('');
|
||||
const status = ref('');
|
||||
const tenantID = ref(null);
|
||||
const ownerUserID = ref(null);
|
||||
const expiredAtFrom = ref(null);
|
||||
const expiredAtTo = ref(null);
|
||||
const createdAtFrom = ref(null);
|
||||
const createdAtTo = ref(null);
|
||||
const sortField = ref('id');
|
||||
const sortOrder = ref(-1);
|
||||
|
||||
const tenantUsersDialogVisible = ref(false);
|
||||
const tenantUsersLoading = ref(false);
|
||||
const tenantUsersTenant = ref(null);
|
||||
const tenantUsers = ref([]);
|
||||
const tenantUsersTotal = ref(0);
|
||||
const tenantUsersPage = ref(1);
|
||||
const tenantUsersRows = ref(10);
|
||||
const tenantUsersUsername = ref('');
|
||||
const tenantUsersUserID = ref(null);
|
||||
const tenantUsersRole = ref('');
|
||||
const tenantUsersStatus = ref('');
|
||||
|
||||
const createDialogVisible = ref(false);
|
||||
const creating = ref(false);
|
||||
const createCode = ref('');
|
||||
@@ -38,6 +57,12 @@ function formatDate(value) {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function formatCny(amountInCents) {
|
||||
const amount = Number(amountInCents) / 100;
|
||||
if (!Number.isFinite(amount)) return '-';
|
||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
|
||||
}
|
||||
|
||||
function getStatusSeverity(status) {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
@@ -76,9 +101,15 @@ async function loadTenants() {
|
||||
const result = await TenantService.listTenants({
|
||||
page: page.value,
|
||||
limit: rows.value,
|
||||
name: keyword.value,
|
||||
code: keyword.value,
|
||||
id: tenantID.value || undefined,
|
||||
user_id: ownerUserID.value || undefined,
|
||||
name: nameKeyword.value,
|
||||
code: codeKeyword.value,
|
||||
status: status.value,
|
||||
expired_at_from: expiredAtFrom.value || undefined,
|
||||
expired_at_to: expiredAtTo.value || undefined,
|
||||
created_at_from: createdAtFrom.value || undefined,
|
||||
created_at_to: createdAtTo.value || undefined,
|
||||
sortField: sortField.value,
|
||||
sortOrder: sortOrder.value
|
||||
});
|
||||
@@ -102,8 +133,15 @@ function onSearch() {
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
keyword.value = '';
|
||||
nameKeyword.value = '';
|
||||
codeKeyword.value = '';
|
||||
status.value = '';
|
||||
tenantID.value = null;
|
||||
ownerUserID.value = null;
|
||||
expiredAtFrom.value = null;
|
||||
expiredAtTo.value = null;
|
||||
createdAtFrom.value = null;
|
||||
createdAtTo.value = null;
|
||||
sortField.value = 'id';
|
||||
sortOrder.value = -1;
|
||||
page.value = 1;
|
||||
@@ -123,6 +161,70 @@ function onSort(event) {
|
||||
loadTenants();
|
||||
}
|
||||
|
||||
function openTenantUsersDialog(tenant) {
|
||||
tenantUsersTenant.value = tenant;
|
||||
tenantUsersDialogVisible.value = true;
|
||||
tenantUsersPage.value = 1;
|
||||
tenantUsersRows.value = 10;
|
||||
tenantUsersUsername.value = '';
|
||||
tenantUsersUserID.value = null;
|
||||
tenantUsersRole.value = '';
|
||||
tenantUsersStatus.value = '';
|
||||
loadTenantUsers();
|
||||
}
|
||||
|
||||
async function loadTenantUsers() {
|
||||
const tenant = tenantUsersTenant.value;
|
||||
const id = tenant?.id;
|
||||
if (!id) return;
|
||||
|
||||
tenantUsersLoading.value = true;
|
||||
try {
|
||||
const data = await TenantService.listTenantUsers(id, {
|
||||
page: tenantUsersPage.value,
|
||||
limit: tenantUsersRows.value,
|
||||
username: tenantUsersUsername.value,
|
||||
user_id: tenantUsersUserID.value || undefined,
|
||||
role: tenantUsersRole.value || undefined,
|
||||
status: tenantUsersStatus.value || undefined
|
||||
});
|
||||
|
||||
const normalizeItems = (items) => {
|
||||
if (Array.isArray(items)) return items;
|
||||
if (items && typeof items === 'object') return [items];
|
||||
return [];
|
||||
};
|
||||
|
||||
tenantUsers.value = normalizeItems(data?.items);
|
||||
tenantUsersTotal.value = data?.total ?? 0;
|
||||
} catch (error) {
|
||||
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载租户成员列表', life: 4000 });
|
||||
} finally {
|
||||
tenantUsersLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onTenantUsersSearch() {
|
||||
tenantUsersPage.value = 1;
|
||||
loadTenantUsers();
|
||||
}
|
||||
|
||||
function onTenantUsersReset() {
|
||||
tenantUsersUsername.value = '';
|
||||
tenantUsersUserID.value = null;
|
||||
tenantUsersRole.value = '';
|
||||
tenantUsersStatus.value = '';
|
||||
tenantUsersPage.value = 1;
|
||||
tenantUsersRows.value = 10;
|
||||
loadTenantUsers();
|
||||
}
|
||||
|
||||
function onTenantUsersPageChange(event) {
|
||||
tenantUsersPage.value = (event.page ?? 0) + 1;
|
||||
tenantUsersRows.value = event.rows ?? tenantUsersRows.value;
|
||||
loadTenantUsers();
|
||||
}
|
||||
|
||||
const renewDialogVisible = ref(false);
|
||||
const renewing = ref(false);
|
||||
const renewTenant = ref(null);
|
||||
@@ -282,17 +384,43 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<SearchPanel :loading="loading" @search="onSearch" @reset="onReset">
|
||||
<SearchField label="名称 / Code">
|
||||
<SearchField label="TenantID">
|
||||
<InputNumber v-model="tenantID" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="Owner UserID">
|
||||
<InputNumber v-model="ownerUserID" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="名称">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-search" />
|
||||
</InputIcon>
|
||||
<InputText v-model="keyword" placeholder="请输入" class="w-full" @keyup.enter="onSearch" />
|
||||
<InputText v-model="nameKeyword" placeholder="模糊匹配" class="w-full" @keyup.enter="onSearch" />
|
||||
</IconField>
|
||||
</SearchField>
|
||||
<SearchField label="Code">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-search" />
|
||||
</InputIcon>
|
||||
<InputText v-model="codeKeyword" placeholder="模糊匹配(小写)" class="w-full" @keyup.enter="onSearch" />
|
||||
</IconField>
|
||||
</SearchField>
|
||||
<SearchField label="状态">
|
||||
<Select v-model="status" :options="tenantStatusOptions" optionLabel="label" optionValue="value" placeholder="请选择" :loading="tenantStatusOptionsLoading" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="过期时间 From">
|
||||
<DatePicker v-model="expiredAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="过期时间 To">
|
||||
<DatePicker v-model="expiredAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="创建时间 From">
|
||||
<DatePicker v-model="createdAtFrom" showIcon showButtonBar placeholder="开始时间" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="创建时间 To">
|
||||
<DatePicker v-model="createdAtTo" showIcon showButtonBar placeholder="结束时间" class="w-full" />
|
||||
</SearchField>
|
||||
</SearchPanel>
|
||||
|
||||
<DataTable
|
||||
@@ -319,13 +447,42 @@ onMounted(() => {
|
||||
<Column field="id" header="ID" sortable style="min-width: 6rem" />
|
||||
<Column field="code" header="Code" style="min-width: 10rem" />
|
||||
<Column field="name" header="名称" sortable style="min-width: 14rem" />
|
||||
<Column field="user_id" header="Owner" sortable style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data.owner?.username">{{ data.owner.username }}</span>
|
||||
<span v-else class="text-muted-color">{{ data.user_id ?? '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="管理员" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag v-for="u in data.admin_users || []" :key="u.id" :value="u.username || String(u.id)" severity="info" />
|
||||
<span v-if="!data.admin_users || data.admin_users.length === 0" class="text-muted-color">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status_description" header="状态" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status_description || data.status || '-'" :severity="getStatusSeverity(data.status)" class="cursor-pointer" @click="openTenantStatusDialog(data)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="user_count" header="用户数" sortable style="min-width: 8rem" />
|
||||
<Column field="user_balance" header="余额" sortable style="min-width: 8rem" />
|
||||
<Column field="user_count" header="用户数" sortable style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
:label="String(data.user_count ?? 0)"
|
||||
text
|
||||
size="small"
|
||||
icon="pi pi-users"
|
||||
class="p-0"
|
||||
@click="openTenantUsersDialog(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="income_amount_paid_sum" header="累计收入" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatCny(data.income_amount_paid_sum) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="expired_at" header="过期时间" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data.expired_at" v-tooltip="getExpiryDaysInfo(data.expired_at).tooltipText" :class="getExpiryDaysInfo(data.expired_at).textClass">
|
||||
@@ -444,5 +601,96 @@ onMounted(() => {
|
||||
<Button label="确认创建" icon="pi pi-check" @click="confirmCreateTenant" :loading="creating" :disabled="!canCreateTenant" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="tenantUsersDialogVisible" :modal="true" :style="{ width: '980px' }">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">租户成员</span>
|
||||
<span class="text-muted-color truncate max-w-[420px]">{{ tenantUsersTenant?.name ?? tenantUsersTenant?.code ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<SearchPanel :loading="tenantUsersLoading" @search="onTenantUsersSearch" @reset="onTenantUsersReset">
|
||||
<SearchField label="UserID">
|
||||
<InputNumber v-model="tenantUsersUserID" :min="1" placeholder="精确匹配" class="w-full" />
|
||||
</SearchField>
|
||||
<SearchField label="用户名">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-search" />
|
||||
</InputIcon>
|
||||
<InputText v-model="tenantUsersUsername" placeholder="模糊匹配" class="w-full" @keyup.enter="onTenantUsersSearch" />
|
||||
</IconField>
|
||||
</SearchField>
|
||||
<SearchField label="角色">
|
||||
<Select
|
||||
v-model="tenantUsersRole"
|
||||
:options="[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'member', value: 'member' },
|
||||
{ label: 'tenant_admin', value: 'tenant_admin' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="请选择"
|
||||
class="w-full"
|
||||
/>
|
||||
</SearchField>
|
||||
<SearchField label="状态">
|
||||
<Select
|
||||
v-model="tenantUsersStatus"
|
||||
:options="[
|
||||
{ label: '全部', value: '' },
|
||||
{ label: 'pending_verify', value: 'pending_verify' },
|
||||
{ label: 'verified', value: 'verified' },
|
||||
{ label: 'banned', value: 'banned' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="请选择"
|
||||
class="w-full"
|
||||
/>
|
||||
</SearchField>
|
||||
</SearchPanel>
|
||||
|
||||
<DataTable
|
||||
:value="tenantUsers"
|
||||
dataKey="tenant_user.id"
|
||||
:loading="tenantUsersLoading"
|
||||
lazy
|
||||
:paginator="true"
|
||||
:rows="tenantUsersRows"
|
||||
:totalRecords="tenantUsersTotal"
|
||||
:first="(tenantUsersPage - 1) * tenantUsersRows"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
@page="onTenantUsersPageChange"
|
||||
currentPageReportTemplate="显示第 {first} - {last} 条,共 {totalRecords} 条"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
scrollable
|
||||
scrollHeight="420px"
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="tenant_user.user_id" header="UserID" style="min-width: 7rem" />
|
||||
<Column field="user.username" header="用户名" style="min-width: 14rem" />
|
||||
<Column field="tenant_user.role" header="角色" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag v-for="r in data?.tenant_user?.role || []" :key="r" :value="r" severity="secondary" />
|
||||
<span v-if="!data?.tenant_user?.role || data.tenant_user.role.length === 0" class="text-muted-color">-</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="user.status_description" header="状态" style="min-width: 10rem" />
|
||||
<Column field="tenant_user.created_at" header="加入时间" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data?.tenant_user?.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="关闭" icon="pi pi-times" text @click="tenantUsersDialogVisible = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user