Files
geo/server/internal/ops/app/kol_subscription.go
T
root 8761e47f78 feat(kol): cache marketplace and generation reads with singleflight
Add cache-aside + singleflight to KOL marketplace list/detail, my-subscription
prompts, and generation schema lookups. Invalidate the shared public-facing
caches when subscriptions are added/revoked (ops) or KOL profile is updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:33:17 +08:00

295 lines
9.6 KiB
Go

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))
_ = s.cache.DeletePrefix(ctx, "kol:marketplace:")
}
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")
}
}