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