Files
team-api/internal/logic/admin/tenant.go
T

703 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"context"
"encoding/json"
"strconv"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/common"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/qianfree/team-api/internal/model/entity"
"github.com/qianfree/team-api/internal/utility/crypto"
"github.com/qianfree/team-api/internal/utility/export"
)
// TenantSelect returns a lightweight paginated tenant list for dropdown selectors.
func (s *sAdmin) TenantSelect(ctx context.Context, req *v1.TenantSelectReq) (*v1.TenantSelectRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
m := dao.TntTenants.Ctx(ctx)
if req.Keyword != "" {
keyword := "%" + strings.TrimSpace(req.Keyword) + "%"
m = m.Where("name LIKE ? OR code LIKE ?", keyword, keyword)
}
total, err := m.Count()
if err != nil {
return nil, err
}
m = dao.TntTenants.Ctx(ctx)
if req.Keyword != "" {
keyword := "%" + strings.TrimSpace(req.Keyword) + "%"
m = m.Where("name LIKE ? OR code LIKE ?", keyword, keyword)
}
var tenants []struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
}
err = m.Fields("id, name, code").OrderAsc("id").
Page(page, pageSize).
Scan(&tenants)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
items := make([]v1.TenantSelectItem, len(tenants))
for i, t := range tenants {
items[i] = v1.TenantSelectItem{
ID: t.Id,
Name: t.Name,
Code: t.Code,
}
}
return &v1.TenantSelectRes{
List: items,
Total: int(total),
Page: page,
PageSize: pageSize,
}, nil
}
// CreateTenant creates a new tenant with its owner user and wallet.
func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1.TenantCreateRes, error) {
tenantCode := strings.TrimSpace(strings.ToLower(req.TenantCode))
username := strings.TrimSpace(req.Username)
email := strings.TrimSpace(strings.ToLower(req.Email))
// Validate username format
if err := common.ValidateUsername(username); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error())
}
tenantName := strings.TrimSpace(req.TenantName)
if err := common.ValidateTenantName(tenantName); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
count, err := dao.TntTenants.Ctx(ctx).
Where("code", tenantCode).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists)
}
count, err = dao.TntUsers.Ctx(ctx).
Where("email", email).Count()
if err != nil {
return nil, err
}
if count > 0 {
return nil, common.NewBadRequestError("邮箱已被使用")
}
if err := common.ValidatePassword(req.Password); err != nil {
return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak)
}
passwordHash, err := crypto.HashPassword(req.Password)
if err != nil {
return nil, err
}
// max_members 和 max_concurrency 默认为 nil(跟随等级配置)
var maxMembersVal *int
if req.MaxMembers != nil && *req.MaxMembers >= 1 {
maxMembersVal = req.MaxMembers
}
var maxConcurrencyVal *int
if req.MaxConcurrency != nil {
maxConcurrencyVal = req.MaxConcurrency
}
var tenantID int64
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
tenantResult, err := dao.TntTenants.Ctx(ctx).Data(do.TntTenants{
Name: tenantName,
Code: tenantCode,
MaxMembers: maxMembersVal,
MaxConcurrency: maxConcurrencyVal,
Level: 1,
Settings: "{}",
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create tenant")
}
tenantID, err = tenantResult.LastInsertId()
if err != nil {
return gerror.Wrapf(err, "get tenant id")
}
userResult, err := dao.TntUsers.Ctx(ctx).Data(do.TntUsers{
TenantId: tenantID,
Username: username,
Email: email,
PasswordHash: passwordHash,
DisplayName: username,
Role: "owner",
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create owner user")
}
ownerUserID, err := userResult.LastInsertId()
if err != nil {
return gerror.Wrapf(err, "get owner user id")
}
_, err = dao.TntTenants.Ctx(ctx).
Where("id", tenantID).
Data(do.TntTenants{
OwnerUserId: ownerUserID,
}).Update()
if err != nil {
return gerror.Wrapf(err, "set tenant owner")
}
warningThreshold := billing.Zero // 预警阈值默认 0 = 关闭,用户可自行开启
_, err = dao.BilWallets.Ctx(ctx).Data(do.BilWallets{
TenantId: tenantID,
Balance: 0,
FrozenBalance: 0,
WarningThreshold: &warningThreshold,
Currency: billing.Currency(ctx),
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create wallet")
}
// Assign default model groups
var defaultGroups []struct {
Id int64
}
if err := dao.MdlModelGroups.Ctx(ctx).
Where("is_default", true).Where("status", "active").
Fields("id").Scan(&defaultGroups); err == nil && len(defaultGroups) > 0 {
insertData := make([]do.MdlTenantGroups, 0, len(defaultGroups))
for _, dg := range defaultGroups {
insertData = append(insertData, do.MdlTenantGroups{
TenantId: tenantID,
GroupId: dg.Id,
})
}
if _, err := dao.MdlTenantGroups.Ctx(ctx).
Batch(len(insertData)).Insert(insertData); err != nil {
g.Log().Warningf(ctx, "assign default model groups failed: %v", err)
}
}
return nil
})
if err != nil {
return nil, err
}
return &v1.TenantCreateRes{Id: tenantID}, nil
}
// batchTenantAggregates 批量查询一批租户的 owner 名称、成员数、钱包余额,
// 用三条聚合查询替代逐行 N+1 查询。
// 返回:ownerNames(ownerUserID→名称)、memberCounts(tenantID→成员数)、walletBalances(tenantID→余额)。
func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) (
ownerNames map[int64]string,
memberCounts map[int64]int,
walletBalances map[int64]string,
) {
ownerNames = make(map[int64]string, len(ownerIDs))
memberCounts = make(map[int64]int, len(tenantIDs))
walletBalances = make(map[int64]string, len(tenantIDs))
// owner 名称:按 owner_user_id 批量取
if len(ownerIDs) > 0 {
var owners []struct {
Id int64 `json:"id"`
DisplayName string `json:"display_name"`
}
_ = dao.TntUsers.Ctx(ctx).Fields("id, display_name").WhereIn("id", ownerIDs).Scan(&owners)
for _, o := range owners {
ownerNames[o.Id] = o.DisplayName
}
}
if len(tenantIDs) > 0 {
// 成员数:按 tenant_id 分组统计
var counts []struct {
TenantID int64 `json:"tenant_id"`
Count int `json:"count"`
}
_ = dao.TntUsers.Ctx(ctx).Fields("tenant_id, COUNT(*) AS count").
WhereIn("tenant_id", tenantIDs).Group("tenant_id").Scan(&counts)
for _, c := range counts {
memberCounts[c.TenantID] = c.Count
}
// 钱包余额:按 tenant_id 批量取
var wallets []struct {
TenantID int64 `json:"tenant_id"`
Balance string `json:"balance"`
}
_ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance").
WhereIn("tenant_id", tenantIDs).Scan(&wallets)
for _, w := range wallets {
walletBalances[w.TenantID] = w.Balance
}
}
return
}
// ListTenants returns a paginated list of tenants.
func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.TenantListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
m := dao.TntTenants.Ctx(ctx)
if req.Keyword != "" {
keyword := "%" + strings.TrimSpace(req.Keyword) + "%"
m = m.Where("name LIKE ? OR code LIKE ?", keyword, keyword)
}
if req.Status != "" {
m = m.Where("status", req.Status)
}
total, err := m.Count()
if err != nil {
return nil, err
}
// Rebuild model for data query
m = dao.TntTenants.Ctx(ctx)
if req.Keyword != "" {
keyword := "%" + strings.TrimSpace(req.Keyword) + "%"
m = m.Where("name LIKE ? OR code LIKE ?", keyword, keyword)
}
if req.Status != "" {
m = m.Where("status", req.Status)
}
var tenants []struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
LogoURL string `json:"logo_url"`
OwnerUserID int64 `json:"owner_user_id"`
Status string `json:"status"`
MaxMembers *int `json:"max_members"`
MaxConcurrency *int `json:"max_concurrency"`
DefaultChannelScope string `json:"default_channel_scope"`
Settings string `json:"settings"`
Level int `json:"level"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}
err = m.OrderDesc("id").
Page(page, pageSize).
Scan(&tenants)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
// 批量获取等级配置(避免 N+1)
var levelConfigs []*entity.TntTenantLevelConfigs
dao.TntTenantLevelConfigs.Ctx(ctx).OrderAsc("level").Scan(&levelConfigs)
levelNameMap := make(map[int]string, len(levelConfigs))
levelMaxMembersMap := make(map[int]int, len(levelConfigs))
levelMaxConcMap := make(map[int]int, len(levelConfigs))
for _, lc := range levelConfigs {
levelNameMap[lc.Level] = lc.Name
levelMaxMembersMap[lc.Level] = lc.MaxMembers
levelMaxConcMap[lc.Level] = lc.MaxConcurrency
}
items := make([]v1.TenantItem, len(tenants))
// 批量查询 owner 名称 / 成员数 / 钱包余额(避免逐行 N+1)
tenantIDs := make([]int64, len(tenants))
ownerIDs := make([]int64, len(tenants))
for i, t := range tenants {
tenantIDs[i] = t.Id
ownerIDs[i] = t.OwnerUserID
}
ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
for i, t := range tenants {
item := v1.TenantItem{
ID: t.Id,
Name: t.Name,
Code: t.Code,
LogoURL: t.LogoURL,
OwnerUserID: t.OwnerUserID,
Status: t.Status,
MaxMembers: t.MaxMembers,
MaxConcurrency: t.MaxConcurrency,
DefaultChannelScope: t.DefaultChannelScope,
Level: t.Level,
LevelName: levelNameMap[t.Level],
CreatedAt: t.CreatedAt.String(),
UpdatedAt: t.UpdatedAt.String(),
}
// 计算实际生效值
if t.MaxMembers != nil {
item.EffectiveMaxMembers = *t.MaxMembers
} else if v, ok := levelMaxMembersMap[t.Level]; ok {
item.EffectiveMaxMembers = v
} else {
item.EffectiveMaxMembers = 10
}
if t.MaxConcurrency != nil {
item.EffectiveMaxConcurrency = *t.MaxConcurrency
} else if v, ok := levelMaxConcMap[t.Level]; ok {
item.EffectiveMaxConcurrency = v
} else {
item.EffectiveMaxConcurrency = 0
}
// 从批量结果中取 owner 名称 / 成员数 / 钱包余额
item.OwnerName = ownerNameMap[t.OwnerUserID]
item.MemberCount = memberCountMap[t.Id]
if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" {
item.WalletBalance = bal
} else {
item.WalletBalance = "0"
}
items[i] = item
}
return &v1.TenantListRes{
List: items,
Total: int(total),
Page: page,
PageSize: pageSize,
}, nil
}
// GetTenant returns detail of a single tenant.
func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.TenantGetRes, error) {
var tenant *struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
LogoURL string `json:"logo_url"`
OwnerUserID int64 `json:"owner_user_id"`
Status string `json:"status"`
MaxMembers *int `json:"max_members"`
MaxConcurrency *int `json:"max_concurrency"`
DefaultChannelScope string `json:"default_channel_scope"`
Settings string `json:"settings"`
Level int `json:"level"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}
err := dao.TntTenants.Ctx(ctx).
Where("id", req.Id).Scan(&tenant)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if tenant == nil {
return nil, common.NewNotFoundError("租户")
}
// Get owner name
var owner *struct {
DisplayName string `json:"display_name"`
}
_ = dao.TntUsers.Ctx(ctx).
Where("id", tenant.OwnerUserID).Scan(&owner)
// Get member count
memberCount, _ := dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.Id).Count()
// Get wallet balance:优先读 Redis 权威值(调整余额/入账后详情页立即可见),DB 物化值兜底
walletBalance := "0"
if w, err := billing.GetWallet(ctx, req.Id); err == nil {
walletBalance = strconv.FormatFloat(w.Balance, 'f', -1, 64)
} else {
var wallet *struct {
Balance string `json:"balance"`
}
_ = dao.BilWallets.Ctx(ctx).
Where("tenant_id", req.Id).Scan(&wallet)
if wallet != nil && wallet.Balance != "" {
walletBalance = wallet.Balance
}
}
ownerName := ""
if owner != nil {
ownerName = owner.DisplayName
}
// Get level name
levelName := ""
var levelConfig *entity.TntTenantLevelConfigs
_ = dao.TntTenantLevelConfigs.Ctx(ctx).Where("level", tenant.Level).Scan(&levelConfig)
if levelConfig != nil {
levelName = levelConfig.Name
}
// 计算实际生效值
effectiveMaxMembers := 10
effectiveMaxConc := 0
if tenant.MaxMembers != nil {
effectiveMaxMembers = *tenant.MaxMembers
} else if levelConfig != nil {
effectiveMaxMembers = levelConfig.MaxMembers
}
if tenant.MaxConcurrency != nil {
effectiveMaxConc = *tenant.MaxConcurrency
} else if levelConfig != nil {
effectiveMaxConc = levelConfig.MaxConcurrency
}
return &v1.TenantGetRes{
TenantItem: v1.TenantItem{
ID: tenant.Id,
Name: tenant.Name,
Code: tenant.Code,
LogoURL: tenant.LogoURL,
OwnerUserID: tenant.OwnerUserID,
OwnerName: ownerName,
Status: tenant.Status,
MaxMembers: tenant.MaxMembers,
MaxConcurrency: tenant.MaxConcurrency,
EffectiveMaxMembers: effectiveMaxMembers,
EffectiveMaxConcurrency: effectiveMaxConc,
DefaultChannelScope: tenant.DefaultChannelScope,
MemberCount: memberCount,
WalletBalance: walletBalance,
Level: tenant.Level,
LevelName: levelName,
CreatedAt: tenant.CreatedAt.String(),
UpdatedAt: tenant.UpdatedAt.String(),
},
Settings: tenant.Settings,
}, nil
}
// UpdateTenantStatus updates a tenant's status.
func (s *sAdmin) UpdateTenantStatus(ctx context.Context, req *v1.TenantUpdateStatusReq) (*v1.TenantUpdateStatusRes, error) {
if req.Status != "active" && req.Status != "suspended" && req.Status != "closed" {
return nil, common.NewBadRequestError("状态值无效")
}
// Check tenant exists
var tenant *struct {
Id int64 `json:"id"`
}
err := dao.TntTenants.Ctx(ctx).
Where("id", req.Id).Scan(&tenant)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if tenant == nil {
return nil, common.NewNotFoundError("租户")
}
_, err = dao.TntTenants.Ctx(ctx).Where("id", req.Id).Update(do.TntTenants{
Status: req.Status,
})
if err != nil {
return nil, err
}
return nil, nil
}
// UpdateTenant updates tenant information.
func (s *sAdmin) UpdateTenant(ctx context.Context, req *v1.TenantUpdateReq) (*v1.TenantUpdateRes, error) {
data := do.TntTenants{}
if req.Name != "" {
tenantName := strings.TrimSpace(req.Name)
if err := common.ValidateTenantName(tenantName); err != nil {
return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error())
}
data.Name = tenantName
}
if req.MaxMembers != nil {
if *req.MaxMembers < 1 {
return nil, common.NewBadRequestError("最大成员数不能小于1")
}
data.MaxMembers = *req.MaxMembers
}
if req.MaxConcurrency != nil {
data.MaxConcurrency = *req.MaxConcurrency
}
// 管理员手动调整等级:仅更新等级,不自动填充成员数和并发数
// 成员数和并发数为 NULL 时自动跟随等级配置
if req.Level != nil {
var config *entity.TntTenantLevelConfigs
err := dao.TntTenantLevelConfigs.Ctx(ctx).Where("level", *req.Level).Scan(&config)
if err = common.IgnoreScanNoRows(err); err != nil || config == nil {
return nil, common.NewBadRequestError("等级配置不存在")
}
data.Level = *req.Level
}
_, err := dao.TntTenants.Ctx(ctx).Where("id", req.Id).Update(data)
if err != nil {
return nil, err
}
return nil, nil
}
// UpdateTenantChannelScope 更新租户默认渠道范围
func (s *sAdmin) UpdateTenantChannelScope(ctx context.Context, req *v1.TenantChannelScopeUpdateReq) (*v1.TenantChannelScopeUpdateRes, error) {
var scopeValue any
if req.DefaultChannelScope == nil || *req.DefaultChannelScope == "" || *req.DefaultChannelScope == "all" {
scopeValue = nil
} else if json.Valid([]byte(*req.DefaultChannelScope)) {
scopeValue = *req.DefaultChannelScope
} else {
return nil, gerror.New("default_channel_scope 必须是有效的 JSON(如 [1,5,12])或 null")
}
_, err := dao.TntTenants.Ctx(ctx).Where("id", req.Id).Data(do.TntTenants{
DefaultChannelScope: scopeValue,
}).Update()
if err != nil {
return nil, err
}
return nil, nil
}
// ExportTenants exports tenant list to CSV or Excel.
func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v1.TenantExportRes, error) {
columns := []export.Column{
{Field: "id", Header: "ID"},
{Field: "name", Header: "名称"},
{Field: "code", Header: "代码"},
{Field: "owner_name", Header: "所有者"},
{Field: "status", Header: "状态"},
{Field: "member_count", Header: "成员数"},
{Field: "wallet_balance", Header: "钱包余额"},
{Field: "created_at", Header: "创建时间"},
}
config := export.Config{
Format: req.Format,
Filename: "租户_" + gtime.Now().Format("Ymd_His"),
Columns: columns,
}
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
for {
m := dao.TntTenants.Ctx(ctx)
if req.Keyword != "" {
keyword := "%" + strings.TrimSpace(req.Keyword) + "%"
m = m.Where("name LIKE ? OR code LIKE ?", keyword, keyword)
}
if req.Status != "" {
m = m.Where("status", req.Status)
}
var batch []struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
OwnerUserID int64 `json:"owner_user_id"`
Status string `json:"status"`
CreatedAt *gtime.Time `json:"created_at"`
}
if err := m.Fields("id, name, code, owner_user_id, status, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
return
}
// 批量查询本页的 owner 名称 / 成员数 / 钱包余额(避免逐行 N+1)
tenantIDs := make([]int64, len(batch))
ownerIDs := make([]int64, len(batch))
for i, t := range batch {
tenantIDs[i] = t.Id
ownerIDs[i] = t.OwnerUserID
}
ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
for _, t := range batch {
walletBalance := "0"
if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" {
walletBalance = bal
}
row := map[string]any{
"id": t.Id,
"name": t.Name,
"code": t.Code,
"owner_name": ownerNameMap[t.OwnerUserID],
"status": t.Status,
"member_count": memberCountMap[t.Id],
"wallet_balance": walletBalance,
"created_at": t.CreatedAt.String(),
}
if !yield(row) {
return
}
}
if len(batch) < 1000 {
break
}
offset += 1000
}
})
}
// TenantApiKeySelect 租户 API Key 下拉选择列表(轻量,不返回密钥原值)。
// 供渠道调试目标过滤等管理端选择器使用;user_id > 0 时按创建者过滤。
// 注意:Fields 不能挂在 Count 之前的模型上——gdb 会生成 COUNT(col1, col2, ...),
// PostgreSQL 的 count 只接受单参数,会直接报错。
func (s *sAdmin) TenantApiKeySelect(ctx context.Context, req *v1.TenantApiKeySelectReq) (*v1.TenantApiKeySelectRes, error) {
m := dao.ApiKeys.Ctx(ctx).
Where("tenant_id", req.TenantID).
OrderDesc("id")
if req.UserID > 0 {
m = m.Where("user_id", req.UserID)
}
total, err := m.Count()
if err != nil {
return nil, err
}
var keys []struct {
ID int64 `json:"id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
UserID int64 `json:"user_id"`
Status string `json:"status"`
}
if err := m.Fields("id, name, key_prefix, user_id, status").Page(req.Page, req.PageSize).Scan(&keys); err != nil {
return nil, err
}
list := make([]v1.TenantApiKeySelectItem, len(keys))
for i, k := range keys {
list[i] = v1.TenantApiKeySelectItem{
ID: k.ID,
Name: k.Name,
KeyPrefix: k.KeyPrefix,
UserID: k.UserID,
Status: k.Status,
}
}
return &v1.TenantApiKeySelectRes{List: list, Total: total, Page: req.Page, PageSize: req.PageSize}, nil
}