package app import ( "context" "errors" "fmt" "strings" "time" "github.com/geo-platform/tenant-api/internal/ops/repository" sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" "github.com/geo-platform/tenant-api/internal/shared/response" ) const ( ActionKolSubscriptionApprove = "kol_subscription.approve" ActionKolSubscriptionManualBind = "kol_subscription.manual_bind" ActionKolSubscriptionRevoke = "kol_subscription.revoke" ) type KolSubscriptionService struct { subs *repository.KolSubscriptionRepository audits *AuditService cache sharedcache.Cache } func NewKolSubscriptionService(subs *repository.KolSubscriptionRepository, audits *AuditService) *KolSubscriptionService { return &KolSubscriptionService{subs: subs, audits: audits} } func (s *KolSubscriptionService) WithCache(c sharedcache.Cache) *KolSubscriptionService { s.cache = c return s } type KolSubscriptionListInput struct { Keyword string Status string Page int Size int } type KolSubscriptionListResult struct { Items []KolSubscriptionView `json:"items"` Total int64 `json:"total"` Page int `json:"page"` Size int `json:"size"` } type KolSubscriptionView struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` TenantName string `json:"tenant_name"` SubscriberName *string `json:"subscriber_name"` SubscriberPhone *string `json:"subscriber_phone"` PackageID int64 `json:"package_id"` PackageName string `json:"package_name"` PackageStatus string `json:"package_status"` CreatorTenantID int64 `json:"creator_tenant_id"` CreatorTenantName string `json:"creator_tenant_name"` KolProfileID int64 `json:"kol_profile_id"` KolDisplayName string `json:"kol_display_name"` KolAvatarURL *string `json:"kol_avatar_url"` Status string `json:"status"` PromptCount int64 `json:"prompt_count"` AccessPromptCount int64 `json:"access_prompt_count"` StartAt *time.Time `json:"start_at"` EndAt *time.Time `json:"end_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type KolPackageListInput struct { Keyword string Size int } type KolPackageView struct { ID int64 `json:"id"` Name string `json:"name"` Status string `json:"status"` CreatorTenantID int64 `json:"creator_tenant_id"` CreatorTenantName string `json:"creator_tenant_name"` KolProfileID int64 `json:"kol_profile_id"` KolDisplayName string `json:"kol_display_name"` KolAvatarURL *string `json:"kol_avatar_url"` PromptCount int64 `json:"prompt_count"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type KolSubscriptionManualBindInput struct { Phone string PackageID int64 EndAt *time.Time } func (s *KolSubscriptionService) List(ctx context.Context, in KolSubscriptionListInput) (*KolSubscriptionListResult, error) { page := in.Page if page <= 0 { page = 1 } size := in.Size if size <= 0 || size > 200 { size = 20 } status := strings.TrimSpace(in.Status) if status != "" && !isValidKolSubscriptionStatus(status) { return nil, response.ErrBadRequest(40090, "invalid_subscription_status", "无效的订阅状态") } items, total, err := s.subs.List(ctx, repository.KolSubscriptionListFilter{ Keyword: strings.TrimSpace(in.Keyword), Status: status, Limit: size, Offset: (page - 1) * size, }) if err != nil { return nil, response.ErrInternal(50090, "kol_subscription_list_failed", "failed to list KOL subscriptions") } views := make([]KolSubscriptionView, len(items)) for i, item := range items { views[i] = toKolSubscriptionView(item) } return &KolSubscriptionListResult{ Items: views, Total: total, Page: page, Size: size, }, nil } func (s *KolSubscriptionService) ListPackages(ctx context.Context, in KolPackageListInput) ([]KolPackageView, error) { size := in.Size if size <= 0 || size > 100 { size = 50 } items, err := s.subs.ListPublishedPackages(ctx, strings.TrimSpace(in.Keyword), size) if err != nil { return nil, response.ErrInternal(50094, "kol_package_list_failed", "failed to list KOL packages") } views := make([]KolPackageView, len(items)) for i, item := range items { views[i] = KolPackageView{ ID: item.ID, Name: item.Name, Status: item.Status, CreatorTenantID: item.CreatorTenantID, CreatorTenantName: item.CreatorTenantName, KolProfileID: item.KolProfileID, KolDisplayName: item.KolDisplayName, KolAvatarURL: item.KolAvatarURL, PromptCount: item.PromptCount, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } return views, nil } func (s *KolSubscriptionService) Approve(ctx context.Context, actor *Actor, id int64, endAt *time.Time) (*KolSubscriptionView, error) { if endAt != nil && endAt.Before(time.Now()) { return nil, response.ErrBadRequest(40091, "invalid_subscription_end_at", "有效期不能早于当前时间") } item, err := s.subs.Approve(ctx, id, endAt) if err != nil { return nil, mapKolSubscriptionError(err) } if actor != nil && s.audits != nil { _ = s.audits.Append(ctx, actor.audit(ActionKolSubscriptionApprove, "kol_subscription", id, map[string]any{ "tenant_id": item.TenantID, "package_id": item.PackageID, "package_name": item.PackageName, "end_at": item.EndAt, })) } s.invalidateAccessCaches(ctx, item.TenantID) view := toKolSubscriptionView(*item) return &view, nil } func (s *KolSubscriptionService) ManualBind(ctx context.Context, actor *Actor, in KolSubscriptionManualBindInput) (*KolSubscriptionView, error) { phone, err := normalizePhone(in.Phone) if err != nil { return nil, err } if in.PackageID <= 0 { return nil, response.ErrBadRequest(40094, "invalid_package_id", "请选择模板包") } if in.EndAt != nil && in.EndAt.Before(time.Now()) { return nil, response.ErrBadRequest(40091, "invalid_subscription_end_at", "有效期不能早于当前时间") } item, err := s.subs.BindByPhone(ctx, phone, in.PackageID, in.EndAt) if err != nil { return nil, mapKolSubscriptionError(err) } if actor != nil && s.audits != nil { _ = s.audits.Append(ctx, actor.audit(ActionKolSubscriptionManualBind, "kol_subscription", item.ID, map[string]any{ "phone": phone, "tenant_id": item.TenantID, "package_id": item.PackageID, "package_name": item.PackageName, "end_at": item.EndAt, })) } s.invalidateAccessCaches(ctx, item.TenantID) view := toKolSubscriptionView(*item) return &view, nil } func (s *KolSubscriptionService) Revoke(ctx context.Context, actor *Actor, id int64) (*KolSubscriptionView, error) { item, err := s.subs.Revoke(ctx, id) if err != nil { return nil, mapKolSubscriptionError(err) } if actor != nil && s.audits != nil { _ = s.audits.Append(ctx, actor.audit(ActionKolSubscriptionRevoke, "kol_subscription", id, map[string]any{ "tenant_id": item.TenantID, "package_id": item.PackageID, "package_name": item.PackageName, })) } s.invalidateAccessCaches(ctx, item.TenantID) view := toKolSubscriptionView(*item) return &view, nil } func (s *KolSubscriptionService) invalidateAccessCaches(ctx context.Context, tenantID int64) { if s.cache == nil || tenantID <= 0 { return } _ = s.cache.Delete(ctx, fmt.Sprintf("workspace:kol_cards:%d", tenantID)) _ = s.cache.DeletePrefix(ctx, fmt.Sprintf("kol:subscription_prompt:list:%d:", tenantID)) _ = s.cache.DeletePrefix(ctx, fmt.Sprintf("kol:subscription_prompt:schema:%d:", tenantID)) } func toKolSubscriptionView(item repository.KolSubscriptionRecord) KolSubscriptionView { return KolSubscriptionView{ ID: item.ID, TenantID: item.TenantID, TenantName: item.TenantName, SubscriberName: item.SubscriberName, SubscriberPhone: item.SubscriberPhone, PackageID: item.PackageID, PackageName: item.PackageName, PackageStatus: item.PackageStatus, CreatorTenantID: item.CreatorTenantID, CreatorTenantName: item.CreatorTenantName, KolProfileID: item.KolProfileID, KolDisplayName: item.KolDisplayName, KolAvatarURL: item.KolAvatarURL, Status: item.Status, PromptCount: item.PromptCount, AccessPromptCount: item.AccessPromptCount, StartAt: item.StartAt, EndAt: item.EndAt, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } func isValidKolSubscriptionStatus(status string) bool { switch status { case "pending", "active", "expired", "revoked": return true default: return false } } func mapKolSubscriptionError(err error) error { switch { case errors.Is(err, repository.ErrKolSubscriptionNotFound): return response.ErrNotFound(40490, "kol_subscription_not_found", "订阅申请不存在") case errors.Is(err, repository.ErrKolSubscriptionNotPending): return response.ErrBadRequest(40092, "kol_subscription_not_pending", "只有审核中的订阅申请可以通过") case errors.Is(err, repository.ErrKolSubscriptionPackageUnavailable): return response.ErrBadRequest(40093, "kol_package_unavailable", "订阅包未发布或不可用") case errors.Is(err, repository.ErrKolSubscriptionUserNotFound): return response.ErrNotFound(40491, "kol_subscription_user_not_found", "未找到该手机号对应的用户或租户") default: return response.ErrInternal(50091, "kol_subscription_update_failed", "failed to update KOL subscription") } }