Files
chengqian 62a23325c7 improve:导出接口护栏与两端控制台查询性能优化
- 导出统一行数上限:xlsx 超限在拉取阶段截停并报错,CSV 到限截断并在文件末尾写入可见标记
- 全部导出接口 OFFSET 循环翻页改为 keyset 游标(用量日志/计费记录/成员/渠道/钱包流水)
- 用量日志导出强制时间窗(开始时间必填、跨度≤31天),校验下沉 common 供管理后台与租户端共用
- 管理后台仪表盘趋势/租户排行/模型分布/小时费用聚合加 5 分钟双层缓存
- 交易流水 Username 筛选改 EXISTS 子查询解除对 JOIN 别名的依赖,COUNT 与列表拆分免背联表
- 管理后台成员列表 COUNT 免联表(筛选条件只引用主表列)
- 租户端用量日志列表/统计/导出共用筛选构造器,COUNT 与 SUM 全程剥离联表,钱包流水 COUNT 拆分
2026-09-18 15:24:09 +08:00

440 lines
13 KiB
Go
Raw Permalink 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"
"crypto/rand"
"encoding/hex"
"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/utility/crypto"
"github.com/qianfree/team-api/internal/utility/export"
)
// CreateMember adds a new member to a specified tenant.
func (s *sAdmin) CreateMember(ctx context.Context, req *v1.AdminMemberCreateReq) (*v1.AdminMemberCreateRes, error) {
username := strings.TrimSpace(req.Username)
email := strings.TrimSpace(strings.ToLower(req.Email))
displayName := req.DisplayName
if displayName == "" {
displayName = username
}
// Validate password
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
}
var userID int64
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// Check tenant exists and status
var tenant *struct {
Id int64 `json:"id"`
Status string `json:"status"`
}
err := dao.TntTenants.Ctx(ctx).
Where("id", req.TenantID).Scan(&tenant)
if err = common.IgnoreScanNoRows(err); err != nil {
return err
}
if tenant == nil {
return common.NewNotFoundError("租户")
}
if tenant.Status != "active" {
return common.NewBadRequestError("租户状态异常,无法添加成员")
}
// Check member limit(NULL时取等级配置)
effectiveMaxMembers, _, err := billing.GetTenantEffectiveLimits(ctx, req.TenantID)
if err != nil {
return err
}
memberCount, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("status", "active").
Count()
if err != nil {
return err
}
// effectiveMaxMembers == 0 表示无限制
if effectiveMaxMembers > 0 && memberCount >= effectiveMaxMembers {
return common.NewBusinessError(consts.CodeMemberLimitReached, consts.MsgMemberLimitReached)
}
// Check username uniqueness within tenant
count, err := dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("username", username).Count()
if err != nil {
return err
}
if count > 0 {
return common.NewBusinessError(consts.CodeUsernameExists, consts.MsgUsernameExists)
}
// Check email uniqueness within tenant
count, err = dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.TenantID).
Where("email", email).Count()
if err != nil {
return err
}
if count > 0 {
return common.NewBadRequestError("该邮箱已被使用")
}
// Create user
userResult, err := dao.TntUsers.Ctx(ctx).Data(do.TntUsers{
TenantId: req.TenantID,
Username: username,
Email: email,
PasswordHash: passwordHash,
DisplayName: displayName,
Role: req.Role,
Status: "active",
}).Insert()
if err != nil {
return gerror.Wrapf(err, "创建用户失败")
}
userID, err = userResult.LastInsertId()
if err != nil {
return gerror.Wrapf(err, "获取用户ID失败")
}
return nil
})
if err != nil {
return nil, err
}
return &v1.AdminMemberCreateRes{Id: userID}, nil
}
// ListAllMembers returns a paginated list of all tenant members across all tenants.
func (s *sAdmin) ListAllMembers(ctx context.Context, req *v1.AdminMemberListReq) (*v1.AdminMemberListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
// COUNT 不需要租户名称列,且 buildMemberFilters 的条件只引用 tnt_users.* ——
// 计数免联表(联表目标是 tnt_tenants 主键,1:1 不影响行数)
total, err := buildMemberFilters(dao.TntUsers.Ctx(ctx), req.Keyword, req.Status, req.Role, req.TenantID).Count()
if err != nil {
return nil, err
}
// 列表查询才需要 JOIN 补租户名称/代码
m := dao.TntUsers.Ctx(ctx).
LeftJoin("tnt_tenants t ON tnt_users.tenant_id = t.id")
m = buildMemberFilters(m, req.Keyword, req.Status, req.Role, req.TenantID)
var members []struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
TenantName string `json:"tenant_name"`
TenantCode string `json:"tenant_code"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
LastLoginAt *gtime.Time `json:"last_login_at"`
LastLoginIp string `json:"last_login_ip"`
FailedAttempts int `json:"failed_attempts"`
LockedUntil *gtime.Time `json:"locked_until"`
CreatedAt *gtime.Time `json:"created_at"`
}
err = m.Fields("tnt_users.id, tnt_users.tenant_id, t.name as tenant_name, t.code as tenant_code, tnt_users.username, tnt_users.email, tnt_users.display_name, tnt_users.role, tnt_users.status, tnt_users.last_login_at, tnt_users.last_login_ip, tnt_users.failed_attempts, tnt_users.locked_until, tnt_users.created_at").
OrderDesc("tnt_users.id").
Page(page, pageSize).
Scan(&members)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
items := make([]v1.AdminMemberItem, len(members))
for i, m := range members {
lockedUntil := ""
if m.LockedUntil != nil {
lockedUntil = m.LockedUntil.String()
}
items[i] = v1.AdminMemberItem{
ID: m.Id,
TenantID: m.TenantId,
TenantName: m.TenantName,
TenantCode: m.TenantCode,
Username: m.Username,
Email: m.Email,
DisplayName: m.DisplayName,
Role: m.Role,
Status: m.Status,
LastLoginAt: m.LastLoginAt.String(),
LastLoginIP: m.LastLoginIp,
FailedAttempts: m.FailedAttempts,
LockedUntil: lockedUntil,
CreatedAt: m.CreatedAt.String(),
}
}
return &v1.AdminMemberListRes{
List: items,
Total: int(total),
Page: page,
PageSize: pageSize,
}, nil
}
// DisableMember disables a tenant member by admin.
func (s *sAdmin) DisableMember(ctx context.Context, req *v1.AdminMemberDisableReq) (*v1.AdminMemberDisableRes, error) {
var user *struct {
Role string `json:"role"`
Status string `json:"status"`
}
err := dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Scan(&user)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if user == nil {
return nil, common.NewNotFoundError("成员")
}
if user.Status == "disabled" {
return nil, common.NewBusinessError(consts.CodeBadRequest, "该成员已是禁用状态")
}
// Revoke all active API keys and disable user in transaction
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := dao.ApiKeys.Ctx(ctx).
Where("user_id", req.Id).
Where("status", "active").
Data(do.ApiKeys{
Status: "disabled",
}).Update()
if err != nil {
return err
}
_, err = dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Data(do.TntUsers{
Status: "disabled",
}).Update()
return err
})
if err != nil {
return nil, err
}
return &v1.AdminMemberDisableRes{}, nil
}
// EnableMember re-enables a tenant member by admin.
func (s *sAdmin) EnableMember(ctx context.Context, req *v1.AdminMemberEnableReq) (*v1.AdminMemberEnableRes, error) {
var user *struct {
Role string `json:"role"`
Status string `json:"status"`
}
err := dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Scan(&user)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if user == nil {
return nil, common.NewNotFoundError("成员")
}
if user.Status != "disabled" {
return nil, common.NewBusinessError(consts.CodeBadRequest, "该成员不是禁用状态")
}
// Restore disabled API keys and enable user in transaction
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := dao.ApiKeys.Ctx(ctx).
Where("user_id", req.Id).
Where("status", "disabled").
Data(do.ApiKeys{
Status: "active",
}).Update()
if err != nil {
return err
}
_, err = dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Data(do.TntUsers{
Status: "active",
}).Update()
return err
})
if err != nil {
return nil, err
}
return &v1.AdminMemberEnableRes{}, nil
}
// ResetMemberPassword resets a member's password by admin, returns the new random password.
func (s *sAdmin) ResetMemberPassword(ctx context.Context, req *v1.AdminMemberResetPasswordReq) (*v1.AdminMemberResetPasswordRes, error) {
var user *struct {
Status string `json:"status"`
}
err := dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Scan(&user)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if user == nil {
return nil, common.NewNotFoundError("成员")
}
// Generate random password
passwordBytes := make([]byte, 16)
if _, err := rand.Read(passwordBytes); err != nil {
return nil, err
}
newPassword := hex.EncodeToString(passwordBytes)[:12]
passwordHash, err := crypto.HashPassword(newPassword)
if err != nil {
return nil, err
}
_, err = dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Data(map[string]interface{}{
"password_hash": passwordHash,
"failed_attempts": 0,
"locked_until": nil,
}).Update()
if err != nil {
return nil, err
}
return &v1.AdminMemberResetPasswordRes{NewPassword: newPassword}, nil
}
// UnlockMember 清除成员的登录锁定状态(平台视角,跨租户)。
func (s *sAdmin) UnlockMember(ctx context.Context, req *v1.AdminMemberUnlockReq) (*v1.AdminMemberUnlockRes, error) {
var user *struct {
Id int64 `json:"id"`
}
err := dao.TntUsers.Ctx(ctx).Where("id", req.Id).Scan(&user)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if user == nil {
return nil, common.NewNotFoundError("成员")
}
_, err = dao.TntUsers.Ctx(ctx).
Where("id", req.Id).
Data(map[string]interface{}{
"failed_attempts": 0,
"locked_until": nil,
}).Update()
if err != nil {
return nil, err
}
return nil, nil
}
// buildMemberFilters builds the WHERE conditions for member queries.
func buildMemberFilters(m *gdb.Model, keyword, status, role string, tenantID int64) *gdb.Model {
if keyword != "" {
kw := "%" + strings.TrimSpace(keyword) + "%"
m = m.Where("tnt_users.username LIKE ? OR tnt_users.email LIKE ? OR tnt_users.display_name LIKE ?", kw, kw, kw)
}
if status != "" {
m = m.Where("tnt_users.status", status)
}
if role != "" {
m = m.Where("tnt_users.role", role)
}
if tenantID > 0 {
m = m.Where("tnt_users.tenant_id", tenantID)
}
return m
}
// ExportMembers exports member list to CSV or Excel.
func (s *sAdmin) ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq) (*v1.AdminMemberExportRes, error) {
memberFields := "tnt_users.id, tnt_users.tenant_id, t.name as tenant_name, t.code as tenant_code, tnt_users.username, tnt_users.email, tnt_users.display_name, tnt_users.role, tnt_users.status, tnt_users.last_login_at, tnt_users.last_login_ip, tnt_users.failed_attempts, tnt_users.created_at"
config := export.Config{
Format: req.Format,
Filename: "成员_" + gtime.Now().Format("Ymd_His"),
Columns: []export.Column{
{Field: "id", Header: "ID"},
{Field: "tenant_name", Header: "租户名称"},
{Field: "username", Header: "用户名"},
{Field: "email", Header: "邮箱"},
{Field: "display_name", Header: "显示名称"},
{Field: "role", Header: "角色"},
{Field: "status", Header: "状态"},
{Field: "last_login_at", Header: "最后登录时间"},
{Field: "created_at", Header: "创建时间"},
},
}
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
// keyset 翻页:id 游标替代 OFFSET,避免深翻页时重复扫弃前面的行(排序本就是 id DESC)
var cursorID int64
for {
m := buildMemberFilters(
dao.TntUsers.Ctx(ctx).LeftJoin("tnt_tenants t ON tnt_users.tenant_id = t.id"),
req.Keyword, req.Status, req.Role, req.TenantID,
)
if cursorID > 0 {
m = m.Where("tnt_users.id < ?", cursorID)
}
var batch []struct {
Id int64 `json:"id"`
TenantName string `json:"tenant_name"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
LastLoginAt *gtime.Time `json:"last_login_at"`
CreatedAt *gtime.Time `json:"created_at"`
}
if err := m.Fields(memberFields).OrderDesc("tnt_users.id").Limit(1000).Scan(&batch); err != nil {
return
}
for _, m := range batch {
if !yield(map[string]any{
"id": m.Id,
"tenant_name": m.TenantName,
"username": m.Username,
"email": m.Email,
"display_name": m.DisplayName,
"role": m.Role,
"status": m.Status,
"last_login_at": m.LastLoginAt.String(),
"created_at": m.CreatedAt.String(),
}) {
return
}
cursorID = m.Id
}
if len(batch) < 1000 {
break
}
}
})
}