Files
root 1aad002a3e feat(ops): use 'reset' quota status for plan-usage reset and invalidate quota cache
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>
2026-05-12 01:55:29 +08:00

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
}