1aad002a3e
When ops staff reset a tenant's current-plan usage, the existing 'refunded' terminal status conflated user-initiated refunds with ops-driven resets, and tenants still saw the old balance in the top-nav chip until the quota-summary cache TTL expired. - Introduce a dedicated 'reset' status for quota_reservations, separate from 'refunded'. Both the initial and an incremental migration widen the CHECK constraint to allow it; the down migration folds 'reset' rows back into 'refunded' before tightening the constraint. - resetQuotaReservations / resetAIPointReservations now write 'reset' without touching refunded_amount, and stop cascading into ai_point_usage_logs (carried no audit value for ops resets). - AdminUserService gains an optional cache dependency and calls invalidateQuotaSummaryCache(tenant_id) immediately after a successful reset so the tenant's top-nav chip refreshes on the next request without waiting on TTL. - Wire the existing appCache into AdminUserService at boot. - Drop the now-unused ai_point_usage_logs field from the reset response and from the ops-web AdminUsersView type. - Add a unit test covering the cache-key invalidation contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
694 lines
22 KiB
Go
694 lines
22 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
|
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
|
sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
)
|
|
|
|
const (
|
|
ActionAdminUserCreate = "admin_user.create"
|
|
ActionAdminUserUpdateProfile = "admin_user.update_profile"
|
|
ActionAdminUserChangePlan = "admin_user.change_plan"
|
|
ActionAdminUserChangeRole = "admin_user.change_role"
|
|
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
|
|
ActionAdminUserResetUsage = "admin_user.reset_usage"
|
|
ActionAdminUserSetKOL = "admin_user.set_kol"
|
|
ActionAdminUserDisable = "admin_user.disable"
|
|
ActionAdminUserEnable = "admin_user.enable"
|
|
ActionAdminUserResetPassword = "admin_user.reset_password"
|
|
ActionAdminUserResetLoginLock = "admin_user.reset_login_lock"
|
|
)
|
|
|
|
var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
|
|
|
type AdminUserService struct {
|
|
users *repository.AdminUserRepository
|
|
audits *AuditService
|
|
loginGuard *sharedauth.LoginGuard
|
|
cache sharedcache.Cache
|
|
configMu sync.RWMutex
|
|
defaultPlanCode string
|
|
}
|
|
|
|
func NewAdminUserService(users *repository.AdminUserRepository, audits *AuditService, defaultPlanCode string) *AdminUserService {
|
|
defaultPlanCode = strings.TrimSpace(defaultPlanCode)
|
|
if defaultPlanCode == "" {
|
|
defaultPlanCode = "free"
|
|
}
|
|
return &AdminUserService{
|
|
users: users,
|
|
audits: audits,
|
|
defaultPlanCode: defaultPlanCode,
|
|
}
|
|
}
|
|
|
|
func (s *AdminUserService) WithLoginGuard(g *sharedauth.LoginGuard) *AdminUserService {
|
|
s.loginGuard = g
|
|
return s
|
|
}
|
|
|
|
func (s *AdminUserService) WithCache(c sharedcache.Cache) *AdminUserService {
|
|
s.cache = c
|
|
return s
|
|
}
|
|
|
|
func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
defaultPlanCode = strings.TrimSpace(defaultPlanCode)
|
|
if defaultPlanCode == "" {
|
|
defaultPlanCode = "free"
|
|
}
|
|
s.configMu.Lock()
|
|
s.defaultPlanCode = defaultPlanCode
|
|
s.configMu.Unlock()
|
|
}
|
|
|
|
func (s *AdminUserService) currentDefaultPlanCode() string {
|
|
if s == nil {
|
|
return "free"
|
|
}
|
|
s.configMu.RLock()
|
|
defer s.configMu.RUnlock()
|
|
if strings.TrimSpace(s.defaultPlanCode) == "" {
|
|
return "free"
|
|
}
|
|
return s.defaultPlanCode
|
|
}
|
|
|
|
type AdminUserView struct {
|
|
ID int64 `json:"id"`
|
|
Email *string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
Name *string `json:"name"`
|
|
Status string `json:"status"`
|
|
TenantID *int64 `json:"tenant_id"`
|
|
TenantName *string `json:"tenant_name"`
|
|
TenantStatus *string `json:"tenant_status"`
|
|
TenantRole *string `json:"tenant_role"`
|
|
PrimaryWorkspaceID *int64 `json:"primary_workspace_id"`
|
|
PlanCode *string `json:"plan_code"`
|
|
PlanName *string `json:"plan_name"`
|
|
SubscriptionStatus *string `json:"subscription_status"`
|
|
SubscriptionEndAt *time.Time `json:"subscription_end_at"`
|
|
KolProfileID *int64 `json:"kol_profile_id"`
|
|
KolDisplayName *string `json:"kol_display_name"`
|
|
KolStatus *string `json:"kol_status"`
|
|
KolMarketEnabled *bool `json:"kol_market_enabled"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type PlanView struct {
|
|
ID int64 `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
DurationSecs int64 `json:"duration_secs"`
|
|
ArticleLimit int `json:"article_limit"`
|
|
AIPointsMonthly int `json:"ai_points_monthly"`
|
|
BrandLimit int `json:"brand_limit"`
|
|
}
|
|
|
|
type RoleView struct {
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Permissions []string `json:"permissions"`
|
|
}
|
|
|
|
type AdminUserListInput struct {
|
|
Keyword string
|
|
Status string
|
|
Page int
|
|
Size int
|
|
}
|
|
|
|
type AdminUserListResult struct {
|
|
Items []AdminUserView `json:"items"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
Size int `json:"size"`
|
|
}
|
|
|
|
type CreateAdminUserInput struct {
|
|
Phone string
|
|
Email string
|
|
Password string
|
|
Name string
|
|
PlanCode string
|
|
Role string
|
|
}
|
|
|
|
type UpdateAdminUserInput struct {
|
|
Phone string
|
|
Email string
|
|
Name string
|
|
}
|
|
|
|
type SetAdminUserKOLInput struct {
|
|
Enabled bool
|
|
DisplayName string
|
|
MarketEnabled bool
|
|
}
|
|
|
|
type ResetPlanUsageView struct {
|
|
TenantID int64 `json:"tenant_id"`
|
|
SubscriptionID int64 `json:"subscription_id"`
|
|
ArticleReservations int64 `json:"article_reservations"`
|
|
ArticleAmount int64 `json:"article_amount"`
|
|
AIPointReservations int64 `json:"ai_point_reservations"`
|
|
AIPointsAmount int64 `json:"ai_points_amount"`
|
|
ArticleWindowStart time.Time `json:"article_window_start"`
|
|
ArticleWindowEnd time.Time `json:"article_window_end"`
|
|
AIPointsWindowStart time.Time `json:"ai_points_window_start"`
|
|
AIPointsWindowEnd time.Time `json:"ai_points_window_end"`
|
|
}
|
|
|
|
type ResetAdminUserPlanUsageResult struct {
|
|
User AdminUserView `json:"user"`
|
|
Reset ResetPlanUsageView `json:"reset"`
|
|
}
|
|
|
|
type ResetLoginLockResult struct {
|
|
ID int64 `json:"id"`
|
|
RedisDeletedKeys int64 `json:"redis_deleted_keys"`
|
|
FallbackDeletedKeys int `json:"fallback_deleted_keys"`
|
|
RedisScanPatterns []string `json:"redis_scan_patterns"`
|
|
RedisAddr string `json:"redis_addr,omitempty"`
|
|
RedisDB int `json:"redis_db"`
|
|
}
|
|
|
|
func toAdminUserView(u *repository.AdminUserRecord) AdminUserView {
|
|
return AdminUserView{
|
|
ID: u.ID,
|
|
Email: u.Email,
|
|
Phone: u.Phone,
|
|
Name: u.Name,
|
|
Status: u.Status,
|
|
TenantID: u.TenantID,
|
|
TenantName: u.TenantName,
|
|
TenantStatus: u.TenantStatus,
|
|
TenantRole: u.TenantRole,
|
|
PrimaryWorkspaceID: u.PrimaryWorkspaceID,
|
|
PlanCode: u.PlanCode,
|
|
PlanName: u.PlanName,
|
|
SubscriptionStatus: u.SubscriptionStatus,
|
|
SubscriptionEndAt: u.SubscriptionEndAt,
|
|
KolProfileID: u.KolProfileID,
|
|
KolDisplayName: u.KolDisplayName,
|
|
KolStatus: u.KolStatus,
|
|
KolMarketEnabled: u.KolMarketEnabled,
|
|
CreatedAt: u.CreatedAt,
|
|
UpdatedAt: u.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func toPlanView(p repository.PlanRecord) PlanView {
|
|
return PlanView{
|
|
ID: p.ID,
|
|
Code: p.Code,
|
|
Name: p.Name,
|
|
Status: p.Status,
|
|
DurationSecs: p.DurationSecs,
|
|
ArticleLimit: p.ArticleLimit,
|
|
AIPointsMonthly: p.AIPointsMonthly,
|
|
BrandLimit: p.BrandLimit,
|
|
}
|
|
}
|
|
|
|
func roleViews() []RoleView {
|
|
return []RoleView{
|
|
{
|
|
Code: "tenant_admin",
|
|
Name: "管理员",
|
|
Description: "拥有当前个人空间的全部管理权限,包括成员、品牌、内容和精调模版管理。",
|
|
Permissions: sharedauth.PermissionsForRole("tenant_admin"),
|
|
},
|
|
{
|
|
Code: "editor",
|
|
Name: "编辑",
|
|
Description: "可管理品牌和内容生成,但不能执行成员与高权限管理操作。",
|
|
Permissions: sharedauth.PermissionsForRole("editor"),
|
|
},
|
|
{
|
|
Code: "viewer",
|
|
Name: "只读",
|
|
Description: "仅可查看工作台、品牌、文章和模版信息。",
|
|
Permissions: sharedauth.PermissionsForRole("viewer"),
|
|
},
|
|
}
|
|
}
|
|
|
|
func isValidTenantRole(role string) bool {
|
|
for _, item := range roleViews() {
|
|
if item.Code == role {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func workspaceRoleForTenantRole(role string) string {
|
|
switch role {
|
|
case "tenant_admin":
|
|
return "owner"
|
|
case "editor":
|
|
return "editor"
|
|
default:
|
|
return "viewer"
|
|
}
|
|
}
|
|
|
|
func (s *AdminUserService) List(ctx context.Context, in AdminUserListInput) (*AdminUserListResult, error) {
|
|
page := in.Page
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
size := in.Size
|
|
if size <= 0 || size > 200 {
|
|
size = 20
|
|
}
|
|
|
|
items, total, err := s.users.List(ctx, repository.AdminUserListFilter{
|
|
Keyword: strings.TrimSpace(in.Keyword),
|
|
Status: strings.TrimSpace(in.Status),
|
|
Limit: size,
|
|
Offset: (page - 1) * size,
|
|
})
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50030, "query_failed", "failed to list users")
|
|
}
|
|
|
|
views := make([]AdminUserView, 0, len(items))
|
|
for i := range items {
|
|
views = append(views, toAdminUserView(&items[i]))
|
|
}
|
|
return &AdminUserListResult{Items: views, Total: total, Page: page, Size: size}, nil
|
|
}
|
|
|
|
func (s *AdminUserService) ListPlans(ctx context.Context) ([]PlanView, error) {
|
|
plans, err := s.users.ListActivePlans(ctx)
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50036, "query_failed", "failed to list plans")
|
|
}
|
|
views := make([]PlanView, 0, len(plans))
|
|
for _, plan := range plans {
|
|
views = append(views, toPlanView(plan))
|
|
}
|
|
return views, nil
|
|
}
|
|
|
|
func (s *AdminUserService) ListRoles() []RoleView {
|
|
return roleViews()
|
|
}
|
|
|
|
func (s *AdminUserService) Get(ctx context.Context, id int64) (*AdminUserView, error) {
|
|
user, err := s.users.GetByID(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
return nil, response.ErrInternal(50031, "query_failed", "failed to get user")
|
|
}
|
|
view := toAdminUserView(user)
|
|
return &view, nil
|
|
}
|
|
|
|
func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAdminUserInput) (*AdminUserView, error) {
|
|
phone, err := normalizePhone(in.Phone)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
email, err := normalizeOptionalEmail(in.Email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(in.Password) < 8 {
|
|
return nil, response.ErrBadRequest(40031, "weak_password", "密码至少 8 位")
|
|
}
|
|
planCode := strings.TrimSpace(in.PlanCode)
|
|
if planCode == "" {
|
|
planCode = s.currentDefaultPlanCode()
|
|
}
|
|
role := strings.TrimSpace(in.Role)
|
|
if role == "" {
|
|
role = "tenant_admin"
|
|
}
|
|
if !isValidTenantRole(role) {
|
|
return nil, response.ErrBadRequest(40036, "invalid_role", "角色值无效")
|
|
}
|
|
|
|
name := normalizeOptionalName(in.Name)
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err := s.users.CreatePersonalTenantUser(ctx, repository.CreateAdminUserInput{
|
|
Email: email,
|
|
Phone: phone,
|
|
PasswordHash: string(hash),
|
|
Name: name,
|
|
PlanCode: planCode,
|
|
TenantRole: role,
|
|
WorkspaceRole: workspaceRoleForTenantRole(role),
|
|
})
|
|
if err != nil {
|
|
return nil, s.mapWriteError(err)
|
|
}
|
|
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserCreate, "admin_user", user.ID, map[string]any{
|
|
"phone": phone,
|
|
"email": email,
|
|
"name": name,
|
|
"plan_code": planCode,
|
|
"tenant_role": role,
|
|
"tenant_id": user.TenantID,
|
|
}))
|
|
}
|
|
|
|
view := toAdminUserView(user)
|
|
return &view, nil
|
|
}
|
|
|
|
func (s *AdminUserService) Update(ctx context.Context, actor *Actor, id int64, in UpdateAdminUserInput) error {
|
|
phone, err := normalizePhone(in.Phone)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
email, err := normalizeOptionalEmail(in.Email)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := normalizeOptionalName(in.Name)
|
|
|
|
if err := s.users.UpdateProfile(ctx, id, repository.UpdateAdminUserProfileInput{
|
|
Email: email,
|
|
Phone: phone,
|
|
Name: name,
|
|
}); err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
return s.mapWriteError(err)
|
|
}
|
|
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserUpdateProfile, "admin_user", id, map[string]any{
|
|
"phone": phone,
|
|
"email": email,
|
|
"name": name,
|
|
}))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AdminUserService) ChangePlan(ctx context.Context, actor *Actor, id int64, planCode string) (*AdminUserView, error) {
|
|
normalizedPlanCode := strings.TrimSpace(planCode)
|
|
if normalizedPlanCode == "" {
|
|
return nil, response.ErrBadRequest(40034, "invalid_plan", "请选择套餐")
|
|
}
|
|
|
|
user, err := s.users.ChangePlan(ctx, id, normalizedPlanCode)
|
|
if err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
if errors.Is(err, repository.ErrDefaultPlanNotFound) {
|
|
return nil, response.ErrBadRequest(40035, "plan_not_found", "套餐不存在或已停用")
|
|
}
|
|
return nil, response.ErrInternal(50037, "update_failed", "failed to update user plan")
|
|
}
|
|
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserChangePlan, "admin_user", id, map[string]any{
|
|
"plan_code": normalizedPlanCode,
|
|
"tenant_id": user.TenantID,
|
|
}))
|
|
}
|
|
|
|
view := toAdminUserView(user)
|
|
return &view, nil
|
|
}
|
|
|
|
func (s *AdminUserService) UpdateSubscriptionExpiry(ctx context.Context, actor *Actor, id int64, endAt time.Time) (*AdminUserView, error) {
|
|
if endAt.IsZero() {
|
|
return nil, response.ErrBadRequest(40038, "invalid_subscription_expiry", "请选择会员到期时间")
|
|
}
|
|
|
|
user, err := s.users.UpdateSubscriptionEndAt(ctx, id, endAt.UTC())
|
|
if err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
if errors.Is(err, repository.ErrActivePlanNotFound) {
|
|
return nil, response.ErrBadRequest(40039, "subscription_not_found", "用户当前没有可编辑的套餐")
|
|
}
|
|
return nil, response.ErrInternal(50040, "update_failed", "failed to update subscription expiry")
|
|
}
|
|
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserChangeExpiry, "admin_user", id, map[string]any{
|
|
"tenant_id": user.TenantID,
|
|
"end_at": endAt.UTC(),
|
|
}))
|
|
}
|
|
|
|
view := toAdminUserView(user)
|
|
return &view, nil
|
|
}
|
|
|
|
func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Actor, id int64) (*ResetAdminUserPlanUsageResult, error) {
|
|
user, reset, err := s.users.ResetCurrentPlanUsage(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
if errors.Is(err, repository.ErrActivePlanNotFound) {
|
|
return nil, response.ErrBadRequest(40040, "subscription_not_found", "用户当前没有可重置的套餐")
|
|
}
|
|
return nil, response.ErrInternal(50041, "reset_failed", "failed to reset current plan usage")
|
|
}
|
|
|
|
resetView := ResetPlanUsageView{
|
|
TenantID: reset.TenantID,
|
|
SubscriptionID: reset.SubscriptionID,
|
|
ArticleReservations: reset.ArticleReservations,
|
|
ArticleAmount: reset.ArticleAmount,
|
|
AIPointReservations: reset.AIPointReservations,
|
|
AIPointsAmount: reset.AIPointsAmount,
|
|
ArticleWindowStart: reset.ArticleWindowStart,
|
|
ArticleWindowEnd: reset.ArticleWindowEnd,
|
|
AIPointsWindowStart: reset.AIPointsWindowStart,
|
|
AIPointsWindowEnd: reset.AIPointsWindowEnd,
|
|
}
|
|
s.invalidateQuotaSummaryCache(ctx, reset.TenantID)
|
|
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetUsage, "admin_user", id, map[string]any{
|
|
"tenant_id": reset.TenantID,
|
|
"subscription_id": reset.SubscriptionID,
|
|
"article_reservations": reset.ArticleReservations,
|
|
"article_amount": reset.ArticleAmount,
|
|
"ai_point_reservations": reset.AIPointReservations,
|
|
"ai_points_amount": reset.AIPointsAmount,
|
|
"article_window_start": reset.ArticleWindowStart,
|
|
"article_window_end": reset.ArticleWindowEnd,
|
|
"ai_points_window_start": reset.AIPointsWindowStart,
|
|
"ai_points_window_end": reset.AIPointsWindowEnd,
|
|
}))
|
|
}
|
|
|
|
return &ResetAdminUserPlanUsageResult{
|
|
User: toAdminUserView(user),
|
|
Reset: resetView,
|
|
}, nil
|
|
}
|
|
|
|
func (s *AdminUserService) invalidateQuotaSummaryCache(ctx context.Context, tenantID int64) {
|
|
if s == nil || s.cache == nil || tenantID <= 0 {
|
|
return
|
|
}
|
|
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:quota_summary:%d", tenantID))
|
|
}
|
|
|
|
func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) {
|
|
normalizedRole := strings.TrimSpace(role)
|
|
if !isValidTenantRole(normalizedRole) {
|
|
return nil, response.ErrBadRequest(40036, "invalid_role", "角色值无效")
|
|
}
|
|
|
|
user, err := s.users.ChangeRole(ctx, id, normalizedRole, workspaceRoleForTenantRole(normalizedRole))
|
|
if err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
return nil, response.ErrInternal(50038, "update_failed", "failed to update user role")
|
|
}
|
|
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserChangeRole, "admin_user", id, map[string]any{
|
|
"tenant_role": normalizedRole,
|
|
"tenant_id": user.TenantID,
|
|
}))
|
|
}
|
|
|
|
view := toAdminUserView(user)
|
|
return &view, nil
|
|
}
|
|
|
|
func (s *AdminUserService) SetKOL(ctx context.Context, actor *Actor, id int64, in SetAdminUserKOLInput) (*AdminUserView, error) {
|
|
displayName := strings.TrimSpace(in.DisplayName)
|
|
if in.Enabled && len([]rune(displayName)) > 100 {
|
|
return nil, response.ErrBadRequest(40037, "invalid_kol_display_name", "KOL 昵称不能超过 100 个字")
|
|
}
|
|
|
|
user, err := s.users.SetKOL(ctx, id, in.Enabled, displayName, in.MarketEnabled)
|
|
if err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
return nil, response.ErrInternal(50039, "update_failed", "failed to update KOL identity")
|
|
}
|
|
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserSetKOL, "admin_user", id, map[string]any{
|
|
"enabled": in.Enabled,
|
|
"display_name": displayName,
|
|
"market_enabled": in.MarketEnabled,
|
|
"tenant_id": user.TenantID,
|
|
"kol_profile_id": user.KolProfileID,
|
|
}))
|
|
}
|
|
|
|
view := toAdminUserView(user)
|
|
return &view, nil
|
|
}
|
|
|
|
func (s *AdminUserService) SetStatus(ctx context.Context, actor *Actor, id int64, status string) error {
|
|
if status != domain.StatusActive && status != domain.StatusDisabled {
|
|
return response.ErrBadRequest(40032, "invalid_status", "状态值无效")
|
|
}
|
|
if err := s.users.UpdateStatus(ctx, id, status); err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
return response.ErrInternal(50032, "update_failed", "failed to update user status")
|
|
}
|
|
|
|
action := ActionAdminUserEnable
|
|
if status == domain.StatusDisabled {
|
|
action = ActionAdminUserDisable
|
|
}
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(action, "admin_user", id, nil))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AdminUserService) ResetPassword(ctx context.Context, actor *Actor, id int64, password string) error {
|
|
if len(password) < 8 {
|
|
return response.ErrBadRequest(40031, "weak_password", "密码至少 8 位")
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.users.UpdatePassword(ctx, id, string(hash)); err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
return response.ErrInternal(50033, "update_failed", "failed to update user password")
|
|
}
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetPassword, "admin_user", id, nil))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) (*ResetLoginLockResult, error) {
|
|
user, err := s.users.GetByID(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
|
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
|
}
|
|
return nil, response.ErrInternal(50034, "query_failed", "failed to get user")
|
|
}
|
|
|
|
result := &ResetLoginLockResult{ID: id}
|
|
if s.loginGuard != nil {
|
|
unlock, err := s.loginGuard.UnlockWithResult(ctx, user.Phone)
|
|
if err != nil {
|
|
return nil, response.ErrServiceUnavailable(50305, "login_guard_unavailable", "登录保护暂时不可用,请稍后重试")
|
|
}
|
|
result.RedisDeletedKeys = unlock.RedisDeletedKeys
|
|
result.FallbackDeletedKeys = unlock.FallbackDeletedKeys
|
|
result.RedisScanPatterns = unlock.RedisScanPatterns
|
|
result.RedisAddr = unlock.RedisAddr
|
|
result.RedisDB = unlock.RedisDB
|
|
}
|
|
if actor != nil {
|
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetLoginLock, "admin_user", id, nil))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *AdminUserService) mapWriteError(err error) error {
|
|
if errors.Is(err, repository.ErrDefaultPlanNotFound) {
|
|
return response.ErrInternal(50034, "default_plan_missing", "默认套餐未配置")
|
|
}
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
return response.ErrConflict(40930, "duplicate_user", "手机号或邮箱已存在")
|
|
}
|
|
return response.ErrInternal(50035, "write_failed", "failed to save user")
|
|
}
|
|
|
|
func normalizePhone(input string) (string, error) {
|
|
phone := strings.TrimSpace(input)
|
|
phone = strings.NewReplacer(" ", "", "-", "", "\t", "").Replace(phone)
|
|
if !phonePattern.MatchString(phone) {
|
|
return "", response.ErrBadRequest(40030, "invalid_phone", "请输入有效手机号")
|
|
}
|
|
return phone, nil
|
|
}
|
|
|
|
func normalizeOptionalEmail(input string) (*string, error) {
|
|
email := strings.TrimSpace(input)
|
|
if email == "" {
|
|
return nil, nil
|
|
}
|
|
parsed, err := mail.ParseAddress(email)
|
|
if err != nil || parsed.Address != email {
|
|
return nil, response.ErrBadRequest(40033, "invalid_email", "邮箱格式无效")
|
|
}
|
|
return &email, nil
|
|
}
|
|
|
|
func normalizeOptionalName(input string) *string {
|
|
name := strings.TrimSpace(input)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
return &name
|
|
}
|