feat(tenant/kol): let KOLs self-subscribe to their own published packages
Add /kol/manage/packages/:id/self-subscription POST/DELETE on the tenant side so a KOL can grant themselves access to their own published package without going through the marketplace approval flow. Reject self-subscribe through the marketplace with a clear error, expose owned_by_current_tenant on package responses, and surface a self_subscription block on KolPackage Response. The admin-web KOL workspace gets 订阅自己 / 取消订阅 entries with matching messaging in the marketplace detail view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -449,6 +449,17 @@ export const kolManageApi = {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
selfSubscribePackage(id: number) {
|
||||||
|
return apiClient.post<KolPackageSummary, Record<string, never>>(
|
||||||
|
`/api/tenant/kol/manage/packages/${id}/self-subscription`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cancelSelfSubscription(id: number) {
|
||||||
|
return apiClient.remove<KolPackageSummary>(
|
||||||
|
`/api/tenant/kol/manage/packages/${id}/self-subscription`,
|
||||||
|
);
|
||||||
|
},
|
||||||
deletePackage(id: number) {
|
deletePackage(id: number) {
|
||||||
return apiClient.remove<null>(`/api/tenant/kol/manage/packages/${id}`);
|
return apiClient.remove<null>(`/api/tenant/kol/manage/packages/${id}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
CloudUploadOutlined,
|
CloudUploadOutlined,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
StopOutlined,
|
||||||
} from "@ant-design/icons-vue";
|
} from "@ant-design/icons-vue";
|
||||||
|
|
||||||
import KolPackageFormModal from "@/components/kol/KolPackageFormModal.vue";
|
import KolPackageFormModal from "@/components/kol/KolPackageFormModal.vue";
|
||||||
@@ -79,6 +81,24 @@ const archivePackageMutation = useMutation({
|
|||||||
onError: (error) => message.error(formatError(error)),
|
onError: (error) => message.error(formatError(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selfSubscribePackageMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => kolManageApi.selfSubscribePackage(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("已订阅自己的订阅包");
|
||||||
|
invalidateKolPackageAccess();
|
||||||
|
},
|
||||||
|
onError: (error) => message.error(formatError(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelSelfSubscriptionMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => kolManageApi.cancelSelfSubscription(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("已取消订阅自己的订阅包");
|
||||||
|
invalidateKolPackageAccess();
|
||||||
|
},
|
||||||
|
onError: (error) => message.error(formatError(error)),
|
||||||
|
});
|
||||||
|
|
||||||
const deletePackageMutation = useMutation({
|
const deletePackageMutation = useMutation({
|
||||||
mutationFn: (id: number) => kolManageApi.deletePackage(id),
|
mutationFn: (id: number) => kolManageApi.deletePackage(id),
|
||||||
onSuccess: (_, id) => {
|
onSuccess: (_, id) => {
|
||||||
@@ -150,6 +170,12 @@ const deletePromptMutation = useMutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function invalidateKolPackageAccess() {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["kol", "packages"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["kol", "my-subscription-prompts"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["workspace"] });
|
||||||
|
}
|
||||||
|
|
||||||
const packageInitialValue = computed<Partial<CreateKolPackageRequest> | undefined>(() => {
|
const packageInitialValue = computed<Partial<CreateKolPackageRequest> | undefined>(() => {
|
||||||
if (!editingPackage.value) return undefined;
|
if (!editingPackage.value) return undefined;
|
||||||
return {
|
return {
|
||||||
@@ -220,6 +246,18 @@ function packageStatusLabel(status: string) {
|
|||||||
if (status === "archived") return t("kol.manage.packageStatus.archived");
|
if (status === "archived") return t("kol.manage.packageStatus.archived");
|
||||||
return t("kol.manage.packageStatus.draft");
|
return t("kol.manage.packageStatus.draft");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSelfSubscribed(pkg: KolPackageSummary) {
|
||||||
|
return pkg.self_subscription?.status === "active";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSelfSubscribe(pkg: KolPackageSummary) {
|
||||||
|
return pkg.status === "published" && !isSelfSubscribed(pkg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCancelSelfSubscription(pkg: KolPackageSummary) {
|
||||||
|
return pkg.self_subscription?.status === "active";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -267,6 +305,7 @@ function packageStatusLabel(status: string) {
|
|||||||
>
|
>
|
||||||
{{ packageStatusLabel(item.status) }}
|
{{ packageStatusLabel(item.status) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
|
<a-tag v-if="isSelfSubscribed(item)" color="blue" size="small">自己已订阅</a-tag>
|
||||||
<span>{{ t("kol.marketplace.prompts", { count: item.prompt_count }) }}</span>
|
<span>{{ t("kol.marketplace.prompts", { count: item.prompt_count }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -290,6 +329,19 @@ function packageStatusLabel(status: string) {
|
|||||||
>
|
>
|
||||||
<InboxOutlined /> {{ t('kol.manage.archivePackage') }}
|
<InboxOutlined /> {{ t('kol.manage.archivePackage') }}
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
|
<a-menu-item
|
||||||
|
v-if="canSelfSubscribe(item)"
|
||||||
|
@click="selfSubscribePackageMutation.mutate(item.id)"
|
||||||
|
>
|
||||||
|
<CheckCircleOutlined /> 订阅自己
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item
|
||||||
|
v-if="canCancelSelfSubscription(item)"
|
||||||
|
danger
|
||||||
|
@click="cancelSelfSubscriptionMutation.mutate(item.id)"
|
||||||
|
>
|
||||||
|
<StopOutlined /> 取消订阅
|
||||||
|
</a-menu-item>
|
||||||
<a-menu-divider />
|
<a-menu-divider />
|
||||||
<a-menu-item danger @click="handleDeletePackage(item.id)">
|
<a-menu-item danger @click="handleDeletePackage(item.id)">
|
||||||
<DeleteOutlined /> {{ t('common.delete') }}
|
<DeleteOutlined /> {{ t('common.delete') }}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const subscribeMutation = useMutation({
|
|||||||
const pkg = computed(() => packageQuery.data.value);
|
const pkg = computed(() => packageQuery.data.value);
|
||||||
const resolvedCoverUrl = computed(() => resolveApiURL(pkg.value?.cover_url));
|
const resolvedCoverUrl = computed(() => resolveApiURL(pkg.value?.cover_url));
|
||||||
const subscription = computed(() => pkg.value?.subscription);
|
const subscription = computed(() => pkg.value?.subscription);
|
||||||
|
const isOwnPackage = computed(() => Boolean(pkg.value?.owned_by_current_tenant));
|
||||||
|
|
||||||
const subscriptionStatusMeta = computed(() => {
|
const subscriptionStatusMeta = computed(() => {
|
||||||
if (!subscription.value) return null;
|
if (!subscription.value) return null;
|
||||||
@@ -67,7 +68,7 @@ const subscriptionStatusMeta = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleSubscribe() {
|
function handleSubscribe() {
|
||||||
if (pkg.value) {
|
if (pkg.value && !isOwnPackage.value) {
|
||||||
subscribeMutation.mutate(pkg.value.id);
|
subscribeMutation.mutate(pkg.value.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +183,11 @@ function goBack() {
|
|||||||
<a-col :xs="24" :lg="8">
|
<a-col :xs="24" :lg="8">
|
||||||
<a-card class="subscription-card">
|
<a-card class="subscription-card">
|
||||||
<h3 class="card-title">订阅状态</h3>
|
<h3 class="card-title">订阅状态</h3>
|
||||||
<div v-if="!subscription" class="not-subscribed">
|
<div v-if="!subscription && isOwnPackage" class="not-subscribed">
|
||||||
|
<p class="status-text">这是你自己发布的订阅包,不能在模版市场申请订阅。</p>
|
||||||
|
<p class="owner-hint">如需自己使用,请回到 KOL 工作台,在订阅包菜单中点击「订阅自己」。</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!subscription" class="not-subscribed">
|
||||||
<p class="status-text">您尚未订阅该 KOL 提示词包。</p>
|
<p class="status-text">您尚未订阅该 KOL 提示词包。</p>
|
||||||
<a-button
|
<a-button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -208,7 +213,7 @@ function goBack() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="subscription.status === 'expired' || subscription.status === 'revoked'"
|
v-if="!isOwnPackage && (subscription.status === 'expired' || subscription.status === 'revoked')"
|
||||||
type="primary"
|
type="primary"
|
||||||
block
|
block
|
||||||
size="large"
|
size="large"
|
||||||
@@ -471,6 +476,17 @@ function goBack() {
|
|||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.owner-hint {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin: -8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1578,6 +1578,8 @@ export interface KolPackageSummary {
|
|||||||
status: "draft" | "published" | "archived";
|
status: "draft" | "published" | "archived";
|
||||||
prompt_count: number;
|
prompt_count: number;
|
||||||
subscriber_count: number;
|
subscriber_count: number;
|
||||||
|
owned_by_current_tenant?: boolean;
|
||||||
|
self_subscription?: KolSubscription | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ var (
|
|||||||
errKolSubscriptionNotFound = response.ErrNotFound(40472, "kol_subscription_not_found", "subscription not found")
|
errKolSubscriptionNotFound = response.ErrNotFound(40472, "kol_subscription_not_found", "subscription not found")
|
||||||
errKolSubscriptionExists = response.ErrConflict(40971, "kol_subscription_exists", "subscription already exists")
|
errKolSubscriptionExists = response.ErrConflict(40971, "kol_subscription_exists", "subscription already exists")
|
||||||
errKolSubscriptionNotPending = response.ErrConflict(40972, "kol_subscription_not_pending", "subscription is not pending")
|
errKolSubscriptionNotPending = response.ErrConflict(40972, "kol_subscription_not_pending", "subscription is not pending")
|
||||||
|
errKolSelfSubscribeViaMarket = response.ErrBadRequest(40074, "kol_self_subscribe_via_marketplace", "自己的订阅包请在 KOL 工作台订阅")
|
||||||
)
|
)
|
||||||
|
|
||||||
type MarketFilter struct {
|
type MarketFilter struct {
|
||||||
@@ -37,6 +38,7 @@ type KolMarketplacePackageResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
PromptCount int `json:"prompt_count"`
|
PromptCount int `json:"prompt_count"`
|
||||||
SubscriberCount int `json:"subscriber_count"`
|
SubscriberCount int `json:"subscriber_count"`
|
||||||
|
OwnedByCurrentTenant bool `json:"owned_by_current_tenant"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -107,7 +109,7 @@ func (s *KolMarketplaceService) ListPackages(ctx context.Context, actor auth.Act
|
|||||||
|
|
||||||
result := make([]KolMarketplacePackageResponse, 0, len(packages))
|
result := make([]KolMarketplacePackageResponse, 0, len(packages))
|
||||||
for _, pkg := range packages {
|
for _, pkg := range packages {
|
||||||
item, err := newKolMarketplacePackageResponse(pkg)
|
item, err := newKolMarketplacePackageResponse(pkg, actor.TenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
|
return nil, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
|
||||||
}
|
}
|
||||||
@@ -134,7 +136,7 @@ func (s *KolMarketplaceService) GetPackage(ctx context.Context, actor auth.Actor
|
|||||||
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
|
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
pkgResp, err := newKolMarketplacePackageResponse(*pkg)
|
pkgResp, err := newKolMarketplacePackageResponse(*pkg, actor.TenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
|
return nil, response.ErrInternal(50091, "kol_marketplace_decode_failed", err.Error())
|
||||||
}
|
}
|
||||||
@@ -167,6 +169,9 @@ func (s *KolMarketplaceService) Subscribe(ctx context.Context, actor auth.Actor,
|
|||||||
if pkg == nil {
|
if pkg == nil {
|
||||||
return nil, errKolMarketplacePackageNotFound
|
return nil, errKolMarketplacePackageNotFound
|
||||||
}
|
}
|
||||||
|
if pkg.TenantID == actor.TenantID {
|
||||||
|
return nil, errKolSelfSubscribeViaMarket
|
||||||
|
}
|
||||||
|
|
||||||
existing, err := s.subRepo.GetActiveForTenant(ctx, actor.TenantID, packageID)
|
existing, err := s.subRepo.GetActiveForTenant(ctx, actor.TenantID, packageID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -227,7 +232,7 @@ func (s *KolMarketplaceService) ListMySubscriptionPrompts(ctx context.Context, a
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newKolMarketplacePackageResponse(pkg repository.PublishedKolPackage) (*KolMarketplacePackageResponse, error) {
|
func newKolMarketplacePackageResponse(pkg repository.PublishedKolPackage, viewerTenantID int64) (*KolMarketplacePackageResponse, error) {
|
||||||
tags, err := decodeKolStringList(pkg.Tags)
|
tags, err := decodeKolStringList(pkg.Tags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -247,6 +252,7 @@ func newKolMarketplacePackageResponse(pkg repository.PublishedKolPackage) (*KolM
|
|||||||
Status: pkg.Status,
|
Status: pkg.Status,
|
||||||
PromptCount: int(pkg.PromptCount),
|
PromptCount: int(pkg.PromptCount),
|
||||||
SubscriberCount: int(pkg.SubscriberCount),
|
SubscriberCount: int(pkg.SubscriberCount),
|
||||||
|
OwnedByCurrentTenant: pkg.TenantID == viewerTenantID,
|
||||||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type KolPackageResponse struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
PromptCount int `json:"prompt_count"`
|
PromptCount int `json:"prompt_count"`
|
||||||
SubscriberCount int `json:"subscriber_count"`
|
SubscriberCount int `json:"subscriber_count"`
|
||||||
|
SelfSubscription *KolSubscriptionResponse `json:"self_subscription"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -45,6 +46,7 @@ type KolPackageService struct {
|
|||||||
profileSvc *KolProfileService
|
profileSvc *KolProfileService
|
||||||
repo repository.KolPackageRepository
|
repo repository.KolPackageRepository
|
||||||
promptRepo repository.KolPromptRepository
|
promptRepo repository.KolPromptRepository
|
||||||
|
subRepo repository.KolSubscriptionRepository
|
||||||
cache sharedcache.Cache
|
cache sharedcache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +54,13 @@ func NewKolPackageService(
|
|||||||
profileSvc *KolProfileService,
|
profileSvc *KolProfileService,
|
||||||
repo repository.KolPackageRepository,
|
repo repository.KolPackageRepository,
|
||||||
promptRepo repository.KolPromptRepository,
|
promptRepo repository.KolPromptRepository,
|
||||||
|
subRepo repository.KolSubscriptionRepository,
|
||||||
) *KolPackageService {
|
) *KolPackageService {
|
||||||
return &KolPackageService{
|
return &KolPackageService{
|
||||||
profileSvc: profileSvc,
|
profileSvc: profileSvc,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
promptRepo: promptRepo,
|
promptRepo: promptRepo,
|
||||||
|
subRepo: subRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +231,50 @@ func (s *KolPackageService) Delete(ctx context.Context, actor auth.Actor, id int
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) SelfSubscribe(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) {
|
||||||
|
profile, pkg, err := s.loadOwnedPackage(ctx, actor, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if pkg.Status != "published" {
|
||||||
|
return nil, response.ErrConflict(40973, "kol_package_not_published", "package must be published before self subscription")
|
||||||
|
}
|
||||||
|
if s.subRepo == nil {
|
||||||
|
return nil, response.ErrInternal(50093, "kol_subscription_repo_missing", "subscription repository is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.subRepo.SelfSubscribe(ctx, actor.TenantID, id); err != nil {
|
||||||
|
if isUniqueConstraintError(err, "uk_kol_subscription_active") {
|
||||||
|
return nil, errKolSubscriptionExists
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50094, "kol_self_subscription_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
InvalidateKolPromptCaches(ctx, s.cache)
|
||||||
|
return s.buildPackageResponse(ctx, profile, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) CancelSelfSubscription(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) {
|
||||||
|
profile, pkg, err := s.loadOwnedPackage(ctx, actor, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.subRepo == nil {
|
||||||
|
return nil, response.ErrInternal(50093, "kol_subscription_repo_missing", "subscription repository is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := s.subRepo.CancelSelfSubscription(ctx, actor.TenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50095, "kol_self_subscription_cancel_failed", err.Error())
|
||||||
|
}
|
||||||
|
if sub == nil {
|
||||||
|
return nil, errKolSubscriptionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
InvalidateKolPromptCaches(ctx, s.cache)
|
||||||
|
return s.buildPackageResponse(ctx, profile, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *KolPackageService) loadOwnedPackage(ctx context.Context, actor auth.Actor, id int64) (*repository.KolProfile, *repository.KolPackage, error) {
|
func (s *KolPackageService) loadOwnedPackage(ctx context.Context, actor auth.Actor, id int64) (*repository.KolProfile, *repository.KolPackage, error) {
|
||||||
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -266,6 +314,15 @@ func (s *KolPackageService) buildPackageResponse(
|
|||||||
promptCount = len(prompts)
|
promptCount = len(prompts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var selfSubscription *KolSubscriptionResponse
|
||||||
|
if s.subRepo != nil {
|
||||||
|
sub, err := s.subRepo.GetActiveForTenant(ctx, pkg.TenantID, pkg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
selfSubscription = newKolSubscriptionResponse(sub)
|
||||||
|
}
|
||||||
|
|
||||||
return &KolPackageResponse{
|
return &KolPackageResponse{
|
||||||
ID: pkg.ID,
|
ID: pkg.ID,
|
||||||
KolProfileID: pkg.KolProfileID,
|
KolProfileID: pkg.KolProfileID,
|
||||||
@@ -280,6 +337,7 @@ func (s *KolPackageService) buildPackageResponse(
|
|||||||
Status: pkg.Status,
|
Status: pkg.Status,
|
||||||
PromptCount: promptCount,
|
PromptCount: promptCount,
|
||||||
SubscriberCount: 0,
|
SubscriberCount: 0,
|
||||||
|
SelfSubscription: selfSubscription,
|
||||||
CreatedAt: pkg.CreatedAt,
|
CreatedAt: pkg.CreatedAt,
|
||||||
UpdatedAt: pkg.UpdatedAt,
|
UpdatedAt: pkg.UpdatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
|
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
|
||||||
)
|
)
|
||||||
@@ -54,6 +55,8 @@ type CreateAccessRowInput struct {
|
|||||||
|
|
||||||
type KolSubscriptionRepository interface {
|
type KolSubscriptionRepository interface {
|
||||||
Create(ctx context.Context, tenantID, packageID int64) (*KolSubscription, error)
|
Create(ctx context.Context, tenantID, packageID int64) (*KolSubscription, error)
|
||||||
|
SelfSubscribe(ctx context.Context, tenantID, packageID int64) (*KolSubscription, error)
|
||||||
|
CancelSelfSubscription(ctx context.Context, tenantID, packageID int64) (*KolSubscription, error)
|
||||||
GetByID(ctx context.Context, tenantID, id int64) (*KolSubscription, error)
|
GetByID(ctx context.Context, tenantID, id int64) (*KolSubscription, error)
|
||||||
GetByIDAny(ctx context.Context, id int64) (*KolSubscription, error)
|
GetByIDAny(ctx context.Context, id int64) (*KolSubscription, error)
|
||||||
GetActiveForTenant(ctx context.Context, tenantID, packageID int64) (*KolSubscription, error)
|
GetActiveForTenant(ctx context.Context, tenantID, packageID int64) (*KolSubscription, error)
|
||||||
@@ -69,11 +72,12 @@ type KolSubscriptionRepository interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type kolSubscriptionRepository struct {
|
type kolSubscriptionRepository struct {
|
||||||
|
db generated.DBTX
|
||||||
q generated.Querier
|
q generated.Querier
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKolSubscriptionRepository(db generated.DBTX) KolSubscriptionRepository {
|
func NewKolSubscriptionRepository(db generated.DBTX) KolSubscriptionRepository {
|
||||||
return &kolSubscriptionRepository{q: newQuerier(db)}
|
return &kolSubscriptionRepository{db: db, q: newQuerier(db)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapKolSubscription(row generated.KolSubscription) KolSubscription {
|
func mapKolSubscription(row generated.KolSubscription) KolSubscription {
|
||||||
@@ -104,6 +108,218 @@ func (r *kolSubscriptionRepository) Create(ctx context.Context, tenantID, packag
|
|||||||
return &sub, nil
|
return &sub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *kolSubscriptionRepository) SelfSubscribe(ctx context.Context, tenantID, packageID int64) (*KolSubscription, error) {
|
||||||
|
tx, err := beginKolSubscriptionTx(ctx, r.db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tx != nil {
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
}
|
||||||
|
db := kolSubscriptionDB(r.db, tx)
|
||||||
|
|
||||||
|
sub, err := lockCurrentKolSubscription(ctx, db, tenantID, packageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if sub == nil {
|
||||||
|
sub, err = insertSelfKolSubscription(ctx, db, tenantID, packageID)
|
||||||
|
} else if sub.Status != "active" {
|
||||||
|
sub, err = activateSelfKolSubscription(ctx, db, sub.ID, tenantID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := grantSelfSubscriptionPrompts(ctx, db, tenantID, packageID, sub.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := getKolSubscriptionByTenantAndID(ctx, db, tenantID, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx != nil {
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *kolSubscriptionRepository) CancelSelfSubscription(ctx context.Context, tenantID, packageID int64) (*KolSubscription, error) {
|
||||||
|
tx, err := beginKolSubscriptionTx(ctx, r.db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tx != nil {
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
}
|
||||||
|
db := kolSubscriptionDB(r.db, tx)
|
||||||
|
|
||||||
|
sub, err := lockCurrentKolSubscription(ctx, db, tenantID, packageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sub == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := revokeKolSubscriptionByID(ctx, db, tenantID, sub.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := revokeKolSubscriptionAccessRows(ctx, db, tenantID, sub.ID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tx != nil {
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginKolSubscriptionTx(ctx context.Context, db generated.DBTX) (pgx.Tx, error) {
|
||||||
|
if starter, ok := db.(interface {
|
||||||
|
Begin(context.Context) (pgx.Tx, error)
|
||||||
|
}); ok {
|
||||||
|
return starter.Begin(ctx)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func kolSubscriptionDB(fallback generated.DBTX, tx pgx.Tx) generated.DBTX {
|
||||||
|
if tx != nil {
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanKolSubscriptionRaw(row interface{ Scan(...any) error }) (*KolSubscription, error) {
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
tenantID int64
|
||||||
|
packageID int64
|
||||||
|
status string
|
||||||
|
approvedBy pgtype.Int8
|
||||||
|
startAt pgtype.Timestamptz
|
||||||
|
endAt pgtype.Timestamptz
|
||||||
|
createdAt pgtype.Timestamptz
|
||||||
|
updatedAt pgtype.Timestamptz
|
||||||
|
)
|
||||||
|
if err := row.Scan(
|
||||||
|
&id,
|
||||||
|
&tenantID,
|
||||||
|
&packageID,
|
||||||
|
&status,
|
||||||
|
&approvedBy,
|
||||||
|
&startAt,
|
||||||
|
&endAt,
|
||||||
|
&createdAt,
|
||||||
|
&updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &KolSubscription{
|
||||||
|
ID: id,
|
||||||
|
TenantID: tenantID,
|
||||||
|
PackageID: packageID,
|
||||||
|
Status: status,
|
||||||
|
ApprovedBy: nullableInt64(approvedBy),
|
||||||
|
StartAt: optionalTime(startAt),
|
||||||
|
EndAt: optionalTime(endAt),
|
||||||
|
CreatedAt: timeFromTimestamp(createdAt),
|
||||||
|
UpdatedAt: timeFromTimestamp(updatedAt),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockCurrentKolSubscription(ctx context.Context, db generated.DBTX, tenantID, packageID int64) (*KolSubscription, error) {
|
||||||
|
return scanKolSubscriptionRaw(db.QueryRow(ctx, `
|
||||||
|
SELECT id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at
|
||||||
|
FROM kol_subscriptions
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND package_id = $2
|
||||||
|
AND status IN ('pending', 'active')
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE`, tenantID, packageID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSelfKolSubscription(ctx context.Context, db generated.DBTX, tenantID, packageID int64) (*KolSubscription, error) {
|
||||||
|
return scanKolSubscriptionRaw(db.QueryRow(ctx, `
|
||||||
|
INSERT INTO kol_subscriptions (tenant_id, package_id, status, approved_by, start_at, end_at)
|
||||||
|
VALUES ($1, $2, 'active', NULL, NOW(), NULL)
|
||||||
|
RETURNING id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at`, tenantID, packageID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func activateSelfKolSubscription(ctx context.Context, db generated.DBTX, id, tenantID int64) (*KolSubscription, error) {
|
||||||
|
return scanKolSubscriptionRaw(db.QueryRow(ctx, `
|
||||||
|
UPDATE kol_subscriptions
|
||||||
|
SET status = 'active',
|
||||||
|
approved_by = NULL,
|
||||||
|
start_at = NOW(),
|
||||||
|
end_at = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND tenant_id = $2
|
||||||
|
RETURNING id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at`, id, tenantID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getKolSubscriptionByTenantAndID(ctx context.Context, db generated.DBTX, tenantID, id int64) (*KolSubscription, error) {
|
||||||
|
return scanKolSubscriptionRaw(db.QueryRow(ctx, `
|
||||||
|
SELECT id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at
|
||||||
|
FROM kol_subscriptions
|
||||||
|
WHERE id = $1
|
||||||
|
AND tenant_id = $2`, id, tenantID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeKolSubscriptionByID(ctx context.Context, db generated.DBTX, tenantID, id int64) (*KolSubscription, error) {
|
||||||
|
return scanKolSubscriptionRaw(db.QueryRow(ctx, `
|
||||||
|
UPDATE kol_subscriptions
|
||||||
|
SET status = 'revoked',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND tenant_id = $2
|
||||||
|
AND status IN ('pending', 'active')
|
||||||
|
RETURNING id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at`, id, tenantID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func grantSelfSubscriptionPrompts(ctx context.Context, db generated.DBTX, tenantID, packageID, subscriptionID int64) error {
|
||||||
|
_, err := db.Exec(ctx, `
|
||||||
|
INSERT INTO kol_subscription_prompts (tenant_id, subscription_id, package_id, prompt_id, status)
|
||||||
|
SELECT $1, $2, $3, p.id, 'active'
|
||||||
|
FROM kol_prompts p
|
||||||
|
WHERE p.tenant_id = $1
|
||||||
|
AND p.package_id = $3
|
||||||
|
AND p.status = 'active'
|
||||||
|
AND p.published_revision_no IS NOT NULL
|
||||||
|
AND p.deleted_at IS NULL
|
||||||
|
ON CONFLICT (subscription_id, prompt_id) DO UPDATE
|
||||||
|
SET status = 'active',
|
||||||
|
revoked_at = NULL,
|
||||||
|
updated_at = NOW()`, tenantID, subscriptionID, packageID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeKolSubscriptionAccessRows(ctx context.Context, db generated.DBTX, tenantID, subscriptionID int64) error {
|
||||||
|
_, err := db.Exec(ctx, `
|
||||||
|
UPDATE kol_subscription_prompts
|
||||||
|
SET status = 'revoked',
|
||||||
|
revoked_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND subscription_id = $2
|
||||||
|
AND status <> 'revoked'`, tenantID, subscriptionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *kolSubscriptionRepository) GetByID(ctx context.Context, tenantID, id int64) (*KolSubscription, error) {
|
func (r *kolSubscriptionRepository) GetByID(ctx context.Context, tenantID, id int64) (*KolSubscription, error) {
|
||||||
row, err := r.q.GetKolSubscriptionByID(ctx, generated.GetKolSubscriptionByIDParams{
|
row, err := r.q.GetKolSubscriptionByID(ctx, generated.GetKolSubscriptionByIDParams{
|
||||||
ID: id,
|
ID: id,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func NewKolManageHandler(a *bootstrap.App, assets *AssetHandler) *KolManageHandl
|
|||||||
|
|
||||||
return &KolManageHandler{
|
return &KolManageHandler{
|
||||||
profileSvc: profileSvc,
|
profileSvc: profileSvc,
|
||||||
pkgSvc: app.NewKolPackageService(profileSvc, a.KolPackages, a.KolPrompts).WithCache(a.Cache),
|
pkgSvc: app.NewKolPackageService(profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions).WithCache(a.Cache),
|
||||||
promptSvc: app.NewKolPromptService(a.DB, profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions, a.KolPromptAsset).WithCache(a.Cache),
|
promptSvc: app.NewKolPromptService(a.DB, profileSvc, a.KolPackages, a.KolPrompts, a.KolSubscriptions, a.KolPromptAsset).WithCache(a.Cache),
|
||||||
assistSvc: app.NewKolAssistService(a.DB, profileSvc, a.KolAssists, a.LLM, a.RabbitMQ, a.Config.LLM, a.Logger).WithCache(a.Cache),
|
assistSvc: app.NewKolAssistService(a.DB, profileSvc, a.KolAssists, a.LLM, a.RabbitMQ, a.Config.LLM, a.Logger).WithCache(a.Cache),
|
||||||
assets: assets,
|
assets: assets,
|
||||||
@@ -249,6 +249,46 @@ func (h *KolManageHandler) ArchivePackage(c *gin.Context) {
|
|||||||
response.Success(c, data)
|
response.Success(c, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *KolManageHandler) SelfSubscribePackage(c *gin.Context) {
|
||||||
|
id, ok := parseInt64Param(c, "id", "package id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor, ok := actorFromRequest(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.pkgSvc.SelfSubscribe(c.Request.Context(), actor, id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KolManageHandler) CancelSelfSubscription(c *gin.Context) {
|
||||||
|
id, ok := parseInt64Param(c, "id", "package id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor, ok := actorFromRequest(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.pkgSvc.CancelSelfSubscription(c.Request.Context(), actor, id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *KolManageHandler) ListPrompts(c *gin.Context) {
|
func (h *KolManageHandler) ListPrompts(c *gin.Context) {
|
||||||
packageID, ok := parseInt64Param(c, "id", "package id")
|
packageID, ok := parseInt64Param(c, "id", "package id")
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
kolManage.DELETE("/packages/:id", kolManageHandler.DeletePackage)
|
kolManage.DELETE("/packages/:id", kolManageHandler.DeletePackage)
|
||||||
kolManage.PUT("/packages/:id/publish", kolManageHandler.PublishPackage)
|
kolManage.PUT("/packages/:id/publish", kolManageHandler.PublishPackage)
|
||||||
kolManage.PUT("/packages/:id/archive", kolManageHandler.ArchivePackage)
|
kolManage.PUT("/packages/:id/archive", kolManageHandler.ArchivePackage)
|
||||||
|
kolManage.POST("/packages/:id/self-subscription", kolManageHandler.SelfSubscribePackage)
|
||||||
|
kolManage.DELETE("/packages/:id/self-subscription", kolManageHandler.CancelSelfSubscription)
|
||||||
kolManage.GET("/packages/:id/prompts", kolManageHandler.ListPrompts)
|
kolManage.GET("/packages/:id/prompts", kolManageHandler.ListPrompts)
|
||||||
kolManage.POST("/packages/:id/prompts", kolManageHandler.CreatePrompt)
|
kolManage.POST("/packages/:id/prompts", kolManageHandler.CreatePrompt)
|
||||||
kolManage.GET("/prompts/:id", kolManageHandler.GetPromptLatest)
|
kolManage.GET("/prompts/:id", kolManageHandler.GetPromptLatest)
|
||||||
@@ -139,14 +141,6 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
kolSubscriptions.GET("/subscription-prompts/:id/schema", kolMarketplaceHandler.SubscriptionPromptSchema)
|
kolSubscriptions.GET("/subscription-prompts/:id/schema", kolMarketplaceHandler.SubscriptionPromptSchema)
|
||||||
kolSubscriptions.POST("/subscription-prompts/:id/generate", kolMarketplaceHandler.Generate)
|
kolSubscriptions.POST("/subscription-prompts/:id/generate", kolMarketplaceHandler.Generate)
|
||||||
|
|
||||||
// V1 compromise: these admin operations live on tenant-api and are gated by
|
|
||||||
// tenant_role=tenant_admin until the platform-api/module is built.
|
|
||||||
kolAdmin := tenantProtected.Group("/kol/admin")
|
|
||||||
kolAdminHandler := NewKolAdminHandler(app)
|
|
||||||
kolAdmin.PUT("/subscriptions/:id/approve", kolAdminHandler.ApproveSubscription)
|
|
||||||
kolAdmin.POST("/subscriptions", kolAdminHandler.ManualBind)
|
|
||||||
kolAdmin.PUT("/subscriptions/:id/revoke", kolAdminHandler.RevokeSubscription)
|
|
||||||
|
|
||||||
articles := tenantProtected.Group("/articles")
|
articles := tenantProtected.Group("/articles")
|
||||||
artHandler := NewArticleHandler(app)
|
artHandler := NewArticleHandler(app)
|
||||||
articles.GET("", artHandler.List)
|
articles.GET("", artHandler.List)
|
||||||
|
|||||||
Reference in New Issue
Block a user