8761e47f78
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>
295 lines
9.6 KiB
Go
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")
|
|
}
|
|
}
|