refactor(kol-admin): move subscription admin endpoints from tenant-api to ops-api
Approving / manually binding / revoking KOL subscriptions belonged to the operations console, not the tenant-admin role on tenant-api. Add a fresh KolSubscriptionService + repository + handlers under the ops module with its own list/manual-bind/approve/revoke routes, wire a Redis-backed cache into ops-api so admin actions can invalidate tenant prompt caches, and delete the tenant-side admin service/handler now that ops-web owns the UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user