feat: add tenant admin invite management, ledger overview, order details, and order management features

- Implemented Invite management with creation, searching, and disabling functionalities.
- Added Ledger overview for financial transactions with filtering options.
- Developed Order Detail view for individual order insights and refund capabilities.
- Created Orders management page with search, reset, and pagination features.
- Enhanced user experience with toast notifications for actions and error handling.
This commit is contained in:
2025-12-24 19:41:44 +08:00
parent 1d0b38bbb7
commit 87f569cc6a
27 changed files with 3652 additions and 151 deletions

View File

@@ -11,6 +11,7 @@ import (
"quyun/v2/app/http/tenant_join"
"quyun/v2/app/http/tenant_media"
"quyun/v2/app/http/tenant_public"
"quyun/v2/app/http/web"
"quyun/v2/app/jobs"
"quyun/v2/app/middlewares"
"quyun/v2/app/services"
@@ -60,7 +61,7 @@ func Command() atom.Option {
tenant_public.Provide,
tenant_media.Provide,
// {Provider: api.Provide},
// {Provider: web.Provide},
web.Provide,
),
),
)

View File

@@ -5,6 +5,7 @@ import (
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/app/services"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
@@ -28,6 +29,45 @@ func requireTenantAdmin(tenantUser *models.TenantUser) error {
return nil
}
// list
//
// @Summary 内容列表(租户管理)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenantCode path string true "Tenant Code"
// @Param filter query dto.AdminContentListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=dto.AdminContentItem}
//
// @Router /t/:tenantCode/v1/admin/contents [get]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind filter query
func (*contentAdmin) list(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, filter *dto.AdminContentListFilter) (*requests.Pager, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
if filter == nil {
filter = &dto.AdminContentListFilter{}
}
filter.Pagination.Format()
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": tenantUser.UserID,
"query_user_id": filter.UserID,
"keyword": filter.KeywordTrimmed(),
"status": filter.Status,
"visibility": filter.Visibility,
"published_at_from": filter.PublishedAtFrom,
"published_at_to": filter.PublishedAtTo,
"created_at_from": filter.CreatedAtFrom,
"created_at_to": filter.CreatedAtTo,
}).Info("tenant.admin.contents.list")
return services.Content.AdminContentPage(ctx.Context(), tenant.ID, filter)
}
// create
//
// @Summary 创建内容(草稿)

View File

@@ -0,0 +1,56 @@
package dto
import (
"strings"
"time"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"go.ipao.vip/gen/types"
)
// AdminContentListFilter 租户管理员查询“内容列表(含草稿/已发布/已下架等)”的过滤条件。
type AdminContentListFilter struct {
requests.Pagination `json:",inline" query:",inline"`
requests.SortQueryFilter `json:",inline" query:",inline"`
ID *int64 `json:"id,omitempty" query:"id"`
UserID *int64 `json:"user_id,omitempty" query:"user_id"`
Keyword *string `json:"keyword,omitempty" query:"keyword"`
Status *consts.ContentStatus `json:"status,omitempty" query:"status"`
Visibility *consts.ContentVisibility `json:"visibility,omitempty" query:"visibility"`
PublishedAtFrom *time.Time `json:"published_at_from,omitempty" query:"published_at_from"`
PublishedAtTo *time.Time `json:"published_at_to,omitempty" query:"published_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 *AdminContentListFilter) KeywordTrimmed() string {
if f == nil || f.Keyword == nil {
return ""
}
return strings.TrimSpace(*f.Keyword)
}
type AdminContentOwnerLite struct {
ID int64 `json:"id"`
Username string `json:"username"`
Status consts.UserStatus `json:"status"`
Roles types.Array[consts.Role] `json:"roles"`
}
type AdminContentItem struct {
Content *models.Content `json:"content,omitempty"`
Price *models.ContentPrice `json:"price,omitempty"`
Owner *AdminContentOwnerLite `json:"owner,omitempty"`
StatusDescription string `json:"status_description,omitempty"`
VisibilityDescription string `json:"visibility_description,omitempty"`
}

View File

@@ -82,6 +82,13 @@ func (r *Routes) Register(router fiber.Router) {
PathParam[int64]("contentID"),
))
// Register routes for controller: contentAdmin
r.log.Debugf("Registering route: Get /t/:tenantCode/v1/admin/contents -> contentAdmin.list")
router.Get("/t/:tenantCode/v1/admin/contents"[len(r.Path()):], DataFunc3(
r.contentAdmin.list,
Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"),
Query[dto.AdminContentListFilter]("filter"),
))
r.log.Debugf("Registering route: Patch /t/:tenantCode/v1/admin/contents/:contentID -> contentAdmin.update")
router.Patch("/t/:tenantCode/v1/admin/contents/:contentID"[len(r.Path()):], DataFunc4(
r.contentAdmin.update,

View File

@@ -0,0 +1,69 @@
package web
import (
"quyun/v2/app/errorx"
"quyun/v2/app/http/web/dto"
"quyun/v2/app/services"
"quyun/v2/pkg/consts"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
)
// @provider
type auth struct {
jwt *jwt.JWT
}
// Login 用户登录(平台侧,非超级管理员)。
//
// @Summary 用户登录
// @Tags Web
// @Accept json
// @Produce json
// @Param form body dto.LoginForm true "form"
// @Success 200 {object} dto.LoginResponse "成功"
// @Router /v1/auth/login [post]
// @Bind form body
func (ctl *auth) login(ctx fiber.Ctx, form *dto.LoginForm) (*dto.LoginResponse, error) {
m, err := services.User.FindByUsername(ctx, form.Username)
if err != nil {
return nil, errorx.Wrap(err).WithMsg("用户名或密码错误")
}
if ok := m.ComparePassword(ctx, form.Password); !ok {
return nil, errorx.Wrap(errorx.ErrInvalidCredentials).WithMsg("用户名或密码错误")
}
token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{
UserID: m.ID,
}))
if err != nil {
return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败")
}
return &dto.LoginResponse{Token: token}, nil
}
// Token 刷新登录凭证。
//
// @Summary 刷新 Token
// @Tags Web
// @Accept json
// @Produce json
// @Success 200 {object} dto.LoginResponse "成功"
// @Router /v1/auth/token [get]
func (ctl *auth) token(ctx fiber.Ctx) (*dto.LoginResponse, error) {
claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims)
if !ok || claims == nil || claims.UserID <= 0 {
return nil, errorx.ErrTokenInvalid
}
token, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{
UserID: claims.UserID,
}))
if err != nil {
return nil, errorx.Wrap(err).WithMsg("登录凭证生成失败")
}
return &dto.LoginResponse{Token: token}, nil
}

View File

@@ -0,0 +1,16 @@
package dto
// LoginForm 平台侧用户登录表单(用于获取 JWT 访问凭证)。
// 注意:此登录是“用户身份”登录(非超级管理员),用于进入租户管理后台前的身份校验与租户列表查询。
type LoginForm struct {
// Username 用户名;必须与数据库 users.username 精确匹配。
Username string `json:"username,omitempty"`
// Password 明文密码;后端会与 users.password 的 bcrypt hash 做比对。
Password string `json:"password,omitempty"`
}
// LoginResponse 登录响应。
type LoginResponse struct {
// Token JWT 访问令牌;前端应以 `Authorization: Bearer <token>` 方式携带。
Token string `json:"token"`
}

View File

@@ -0,0 +1,51 @@
package dto
import (
"time"
"quyun/v2/pkg/consts"
"go.ipao.vip/gen/types"
)
// MeResponse 当前登录用户信息(脱敏)。
type MeResponse struct {
// ID 用户ID全局唯一
ID int64 `json:"id"`
// Username 用户名。
Username string `json:"username"`
// Roles 用户全局角色数组(如 user/super_admin 等)。
Roles types.Array[consts.Role] `json:"roles"`
// Status 用户状态active/verified/banned 等)。
Status consts.UserStatus `json:"status"`
// StatusDescription 用户状态描述(便于前端展示)。
StatusDescription string `json:"status_description"`
// CreatedAt 用户创建时间。
CreatedAt time.Time `json:"created_at"`
// UpdatedAt 用户更新时间。
UpdatedAt time.Time `json:"updated_at"`
}
// MyTenantItem 当前用户可进入的租户条目(用于“选择租户进入后台”页面)。
type MyTenantItem struct {
// TenantID 租户ID数值型主键
TenantID int64 `json:"tenant_id"`
// TenantCode 租户Code路由使用/t/:tenantCode/...)。
TenantCode string `json:"tenant_code"`
// TenantName 租户名称。
TenantName string `json:"tenant_name"`
// TenantStatus 租户状态pending/verified/expired 等)。
TenantStatus consts.TenantStatus `json:"tenant_status"`
// TenantStatusDescription 租户状态描述(便于前端展示)。
TenantStatusDescription string `json:"tenant_status_description"`
// IsOwner 是否为租户Ownertenants.user_id == 当前用户)。
// 说明Owner 通常也在 tenant_users 里具备 tenant_admin 角色,但此字段更直观。
IsOwner bool `json:"is_owner"`
// MemberRoles 当前用户在该租户下的角色tenant_admin/member 等)。
MemberRoles types.Array[consts.TenantUserRole] `json:"member_roles"`
// MemberStatus 当前用户在该租户下的成员状态。
MemberStatus consts.UserStatus `json:"member_status"`
// JoinedAt 加入租户时间tenant_users.created_at
JoinedAt time.Time `json:"joined_at"`
}

View File

@@ -0,0 +1,61 @@
package web
import (
"quyun/v2/app/errorx"
"quyun/v2/app/http/web/dto"
"quyun/v2/app/services"
"quyun/v2/pkg/consts"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
)
// @provider
type me struct{}
// Me 获取当前登录用户信息(脱敏)。
//
// @Summary 当前用户信息
// @Tags Web
// @Accept json
// @Produce json
// @Success 200 {object} dto.MeResponse "成功"
// @Router /v1/me [get]
func (ctl *me) me(ctx fiber.Ctx) (*dto.MeResponse, error) {
claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims)
if !ok || claims == nil || claims.UserID <= 0 {
return nil, errorx.ErrTokenInvalid
}
m, err := services.User.FindByID(ctx, claims.UserID)
if err != nil {
return nil, err
}
return &dto.MeResponse{
ID: m.ID,
Username: m.Username,
Roles: m.Roles,
Status: m.Status,
StatusDescription: m.Status.Description(),
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}, nil
}
// MyTenants 获取当前用户可进入的租户列表。
//
// @Summary 我的租户列表
// @Tags Web
// @Accept json
// @Produce json
// @Success 200 {array} dto.MyTenantItem "成功"
// @Router /v1/me/tenants [get]
func (ctl *me) myTenants(ctx fiber.Ctx) ([]*dto.MyTenantItem, error) {
claims, ok := ctx.Locals(consts.CtxKeyClaims).(*jwt.Claims)
if !ok || claims == nil || claims.UserID <= 0 {
return nil, errorx.ErrTokenInvalid
}
return services.Tenant.UserTenants(ctx, claims.UserID)
}

View File

@@ -0,0 +1,51 @@
package web
import (
"quyun/v2/app/middlewares"
"quyun/v2/providers/jwt"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"go.ipao.vip/atom/opt"
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func(
jwt *jwt.JWT,
) (*auth, error) {
obj := &auth{
jwt: jwt,
}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*me, error) {
obj := &me{}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func(
auth *auth,
me *me,
middlewares *middlewares.Middlewares,
) (contracts.HttpRoute, error) {
obj := &Routes{
auth: auth,
me: me,
middlewares: middlewares,
}
if err := obj.Prepare(); err != nil {
return nil, err
}
return obj, nil
}, atom.GroupRoutes); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,66 @@
// Code generated by atomctl. DO NOT EDIT.
// Package web provides HTTP route definitions and registration
// for the quyun/v2 application.
package web
import (
"quyun/v2/app/http/web/dto"
"quyun/v2/app/middlewares"
"github.com/gofiber/fiber/v3"
log "github.com/sirupsen/logrus"
_ "go.ipao.vip/atom"
_ "go.ipao.vip/atom/contracts"
. "go.ipao.vip/atom/fen"
)
// Routes implements the HttpRoute contract and provides route registration
// for all controllers in the web module.
//
// @provider contracts.HttpRoute atom.GroupRoutes
type Routes struct {
log *log.Entry `inject:"false"`
middlewares *middlewares.Middlewares
// Controller instances
auth *auth
me *me
}
// Prepare initializes the routes provider with logging configuration.
func (r *Routes) Prepare() error {
r.log = log.WithField("module", "routes.web")
r.log.Info("Initializing routes module")
return nil
}
// Name returns the unique identifier for this routes provider.
func (r *Routes) Name() string {
return "web"
}
// Register registers all HTTP routes with the provided fiber router.
// Each route is registered with its corresponding controller action and parameter bindings.
func (r *Routes) Register(router fiber.Router) {
// Register routes for controller: auth
r.log.Debugf("Registering route: Get /v1/auth/token -> auth.token")
router.Get("/v1/auth/token"[len(r.Path()):], DataFunc0(
r.auth.token,
))
r.log.Debugf("Registering route: Post /v1/auth/login -> auth.login")
router.Post("/v1/auth/login"[len(r.Path()):], DataFunc1(
r.auth.login,
Body[dto.LoginForm]("form"),
))
// Register routes for controller: me
r.log.Debugf("Registering route: Get /v1/me -> me.me")
router.Get("/v1/me"[len(r.Path()):], DataFunc0(
r.me.me,
))
r.log.Debugf("Registering route: Get /v1/me/tenants -> me.myTenants")
router.Get("/v1/me/tenants"[len(r.Path()):], DataFunc0(
r.me.myTenants,
))
r.log.Info("Successfully registered all routes")
}

View File

@@ -0,0 +1,11 @@
package web
func (r *Routes) Path() string {
return "/v1"
}
func (r *Routes) Middlewares() []any {
return []any{
r.middlewares.UserAuth,
}
}

View File

@@ -0,0 +1,57 @@
package middlewares
import (
"strings"
"quyun/v2/app/errorx"
"quyun/v2/pkg/consts"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
)
func shouldSkipUserJWTAuth(path string) bool {
// 登录接口无需鉴权。
if strings.Contains(path, "/v1/auth/login") {
return true
}
return false
}
// UserAuth 为平台通用(非租户域)接口提供 JWT 校验,并写入 claims 到 ctx locals。
func (f *Middlewares) UserAuth(c fiber.Ctx) error {
if shouldSkipUserJWTAuth(c.Path()) {
f.log.Debug("middlewares.user.auth.skipped")
return c.Next()
}
authHeader := c.Get(jwt.HttpHeader)
if authHeader == "" {
f.log.Info("middlewares.user.auth.missing_token")
return errorx.ErrTokenMissing
}
claims, err := f.jwt.Parse(authHeader)
if err != nil {
f.log.WithError(err).Warn("middlewares.user.auth.invalid_token")
switch err {
case jwt.TokenExpired:
return errorx.ErrTokenExpired
case jwt.TokenMalformed, jwt.TokenNotValidYet, jwt.TokenInvalid:
return errorx.ErrTokenInvalid
default:
return errorx.ErrTokenInvalid
}
}
if claims.UserID == 0 {
f.log.Warn("middlewares.user.auth.missing_user_id")
return errorx.ErrTokenInvalid
}
f.log.WithFields(map[string]any{
"user_id": claims.UserID,
}).Info("middlewares.user.auth.ok")
c.Locals(consts.CtxKeyClaims, claims)
return c.Next()
}

View File

@@ -0,0 +1,180 @@
package services
import (
"context"
"strings"
tenantdto "quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/models"
"github.com/pkg/errors"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"go.ipao.vip/gen"
"go.ipao.vip/gen/field"
)
// AdminContentPage returns contents list for tenant admin (includes drafts/unpublished/etc).
func (s *content) AdminContentPage(ctx context.Context, tenantID int64, filter *tenantdto.AdminContentListFilter) (*requests.Pager, error) {
if tenantID <= 0 {
return nil, errors.New("tenant_id must be > 0")
}
if filter == nil {
filter = &tenantdto.AdminContentListFilter{}
}
log.WithFields(log.Fields{
"tenant_id": tenantID,
"page": filter.Page,
"limit": filter.Limit,
"user_id": lo.FromPtr(filter.UserID),
"keyword": filter.KeywordTrimmed(),
"status": lo.FromPtr(filter.Status),
}).Info("services.content.admin.page")
filter.Pagination.Format()
cTbl, query := models.ContentQuery.QueryContext(ctx)
query = query.Select(cTbl.ALL)
conds := []gen.Condition{
cTbl.TenantID.Eq(tenantID),
cTbl.DeletedAt.IsNull(),
}
if filter.ID != nil && *filter.ID > 0 {
conds = append(conds, cTbl.ID.Eq(*filter.ID))
}
if filter.UserID != nil && *filter.UserID > 0 {
conds = append(conds, cTbl.UserID.Eq(*filter.UserID))
}
if kw := strings.TrimSpace(filter.KeywordTrimmed()); kw != "" {
conds = append(conds, cTbl.Title.Like(database.WrapLike(kw)))
}
if filter.Status != nil {
conds = append(conds, cTbl.Status.Eq(*filter.Status))
}
if filter.Visibility != nil {
conds = append(conds, cTbl.Visibility.Eq(*filter.Visibility))
}
if filter.PublishedAtFrom != nil {
conds = append(conds, cTbl.PublishedAt.Gte(*filter.PublishedAtFrom))
}
if filter.PublishedAtTo != nil {
conds = append(conds, cTbl.PublishedAt.Lte(*filter.PublishedAtTo))
}
if filter.CreatedAtFrom != nil {
conds = append(conds, cTbl.CreatedAt.Gte(*filter.CreatedAtFrom))
}
if filter.CreatedAtTo != nil {
conds = append(conds, cTbl.CreatedAt.Lte(*filter.CreatedAtTo))
}
orderBys := make([]field.Expr, 0, 6)
allowedAsc := map[string]field.Expr{
"id": cTbl.ID.Asc(),
"title": cTbl.Title.Asc(),
"user_id": cTbl.UserID.Asc(),
"status": cTbl.Status.Asc(),
"visibility": cTbl.Visibility.Asc(),
"published_at": cTbl.PublishedAt.Asc(),
"created_at": cTbl.CreatedAt.Asc(),
"updated_at": cTbl.UpdatedAt.Asc(),
}
allowedDesc := map[string]field.Expr{
"id": cTbl.ID.Desc(),
"title": cTbl.Title.Desc(),
"user_id": cTbl.UserID.Desc(),
"status": cTbl.Status.Desc(),
"visibility": cTbl.Visibility.Desc(),
"published_at": cTbl.PublishedAt.Desc(),
"created_at": cTbl.CreatedAt.Desc(),
"updated_at": cTbl.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, cTbl.ID.Desc())
} else {
orderBys = append(orderBys, cTbl.ID.Desc())
}
items, total, err := query.Where(conds...).Order(orderBys...).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
contentIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) {
if item == nil || item.ID <= 0 {
return 0, false
}
return item.ID, true
}))
ownerIDs := lo.Uniq(lo.FilterMap(items, func(item *models.Content, _ int) (int64, bool) {
if item == nil || item.UserID <= 0 {
return 0, false
}
return item.UserID, true
}))
priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs)
if err != nil {
return nil, err
}
ownerMap := map[int64]*tenantdto.AdminContentOwnerLite{}
if len(ownerIDs) > 0 {
uTbl, uQuery := models.UserQuery.QueryContext(ctx)
users, err := uQuery.Where(uTbl.ID.In(ownerIDs...)).Find()
if err != nil {
return nil, err
}
for _, u := range users {
if u == nil {
continue
}
ownerMap[u.ID] = &tenantdto.AdminContentOwnerLite{
ID: u.ID,
Username: u.Username,
Status: u.Status,
Roles: u.Roles,
}
}
}
respItems := lo.Map(items, func(model *models.Content, _ int) *tenantdto.AdminContentItem {
if model == nil {
return nil
}
return &tenantdto.AdminContentItem{
Content: model,
Price: priceByContent[model.ID],
Owner: ownerMap[model.UserID],
StatusDescription: model.Status.Description(),
VisibilityDescription: model.Visibility.Description(),
}
})
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: respItems,
}, nil
}

View File

@@ -7,6 +7,7 @@ import (
superdto "quyun/v2/app/http/super/dto"
tenantdto "quyun/v2/app/http/tenant/dto"
web_dto "quyun/v2/app/http/web/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/models"
@@ -26,6 +27,72 @@ import (
// @provider
type tenant struct{}
// UserTenants 查询“当前用户可进入的租户列表”(平台通用域 /v1
// - 返回 tenant_users 维度的角色与加入时间,便于前端做“选择租户进入后台”的交互。
// - 不做租户状态过滤:前端可以根据 TenantStatusDescription 做提示或禁用进入按钮。
func (t *tenant) UserTenants(ctx context.Context, userID int64) ([]*web_dto.MyTenantItem, error) {
if userID <= 0 {
return nil, errors.New("user_id must be > 0")
}
// 先查 tenant_users拿到用户加入的租户ID与角色信息避免 join scan 冲突/字段覆盖问题)。
tuTbl, tuQuery := models.TenantUserQuery.QueryContext(ctx)
tenantUsers, err := tuQuery.Where(tuTbl.UserID.Eq(userID)).Order(tuTbl.ID.Desc()).Find()
if err != nil {
return nil, err
}
if len(tenantUsers) == 0 {
return []*web_dto.MyTenantItem{}, nil
}
tenantIDs := lo.Uniq(lo.FilterMap(tenantUsers, func(tu *models.TenantUser, _ int) (int64, bool) {
if tu == nil || tu.TenantID <= 0 {
return 0, false
}
return tu.TenantID, true
}))
if len(tenantIDs) == 0 {
return []*web_dto.MyTenantItem{}, nil
}
// 再查 tenants批量并构建映射保持输出顺序以 tenant_users 为准。
teTbl, teQuery := models.TenantQuery.QueryContext(ctx)
tenants, err := teQuery.Where(teTbl.ID.In(tenantIDs...)).Find()
if err != nil {
return nil, err
}
tenantMap := make(map[int64]*models.Tenant, len(tenants))
for _, te := range tenants {
if te == nil {
continue
}
tenantMap[te.ID] = te
}
items := make([]*web_dto.MyTenantItem, 0, len(tenantUsers))
for _, tu := range tenantUsers {
if tu == nil {
continue
}
te := tenantMap[tu.TenantID]
if te == nil {
continue
}
items = append(items, &web_dto.MyTenantItem{
TenantID: te.ID,
TenantCode: te.Code,
TenantName: te.Name,
TenantStatus: te.Status,
TenantStatusDescription: te.Status.Description(),
IsOwner: te.UserID == userID,
MemberRoles: tu.Role,
MemberStatus: tu.Status,
JoinedAt: tu.CreatedAt,
})
}
return items, nil
}
// SuperDetail 查询单个租户详情(平台侧)。
func (t *tenant) SuperDetail(ctx context.Context, tenantID int64) (*superdto.TenantItem, error) {
if tenantID <= 0 {