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:
2026-04-29 22:07:25 +08:00
parent 4dd8a1bb0e
commit 9bb7144593
9 changed files with 1088 additions and 380 deletions
@@ -1,264 +0,0 @@
package app
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/repository"
)
type ManualBindKolSubscriptionInput struct {
TenantID int64 `json:"tenant_id" binding:"required"`
PackageID int64 `json:"package_id" binding:"required"`
EndAt *time.Time `json:"end_at"`
}
type KolSubscriptionAdminService struct {
pool *pgxpool.Pool
auditLogs *auditlog.AsyncWriter
marketplaceRepo repository.KolMarketplaceRepository
subRepo repository.KolSubscriptionRepository
promptRepo repository.KolPromptRepository
cache sharedcache.Cache
}
func NewKolSubscriptionAdminService(
pool *pgxpool.Pool,
auditLogs *auditlog.AsyncWriter,
marketplaceRepo repository.KolMarketplaceRepository,
subRepo repository.KolSubscriptionRepository,
promptRepo repository.KolPromptRepository,
) *KolSubscriptionAdminService {
return &KolSubscriptionAdminService{
pool: pool,
auditLogs: auditLogs,
marketplaceRepo: marketplaceRepo,
subRepo: subRepo,
promptRepo: promptRepo,
}
}
func (s *KolSubscriptionAdminService) WithCache(c sharedcache.Cache) *KolSubscriptionAdminService {
s.cache = c
return s
}
func (s *KolSubscriptionAdminService) Approve(ctx context.Context, id int64, endAt *time.Time, operatorID int64) (*KolSubscriptionResponse, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50096, "kol_subscription_approve_tx_failed", "failed to begin subscription approval transaction")
}
defer tx.Rollback(ctx)
subTx := repository.NewKolSubscriptionRepository(tx)
marketplaceTx := repository.NewKolMarketplaceRepository(tx)
promptTx := repository.NewKolPromptRepository(tx)
sub, err := subTx.GetByIDAny(ctx, id)
if err != nil {
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
if sub == nil {
return nil, errKolSubscriptionNotFound
}
if sub.Status != "pending" {
return nil, errKolSubscriptionNotPending
}
pkg, err := marketplaceTx.GetPublished(ctx, sub.PackageID)
if err != nil {
return nil, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
}
if pkg == nil {
return nil, errKolMarketplacePackageNotFound
}
if err := subTx.Approve(ctx, id, sub.TenantID, operatorID, endAt); err != nil {
return nil, response.ErrInternal(50097, "kol_subscription_approve_failed", err.Error())
}
if err := grantActivePackagePrompts(ctx, subTx, promptTx, sub.TenantID, id, pkg.TenantID, pkg.ID); err != nil {
return nil, err
}
updated, err := subTx.GetByIDAny(ctx, id)
if err != nil {
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50098, "kol_subscription_approve_commit_failed", "failed to commit subscription approval")
}
InvalidateKolPromptCaches(ctx, s.cache)
s.logAdminAction(ctx, "approve", operatorID, updated.TenantID, updated.ID, map[string]interface{}{
"id": updated.ID,
"tenant_id": updated.TenantID,
"package_id": updated.PackageID,
"status": updated.Status,
"end_at": timeStringPtr(endAt),
})
return newKolSubscriptionResponse(updated), nil
}
func (s *KolSubscriptionAdminService) ManualBind(ctx context.Context, input ManualBindKolSubscriptionInput, operatorID int64) (*KolSubscriptionResponse, error) {
if input.TenantID <= 0 {
return nil, response.ErrBadRequest(40071, "kol_subscription_tenant_required", "tenant_id is required")
}
if input.PackageID <= 0 {
return nil, response.ErrBadRequest(40072, "kol_subscription_package_required", "package_id is required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50099, "kol_subscription_bind_tx_failed", "failed to begin manual bind transaction")
}
defer tx.Rollback(ctx)
subTx := repository.NewKolSubscriptionRepository(tx)
marketplaceTx := repository.NewKolMarketplaceRepository(tx)
promptTx := repository.NewKolPromptRepository(tx)
pkg, err := marketplaceTx.GetPublished(ctx, input.PackageID)
if err != nil {
return nil, response.ErrInternal(50090, "kol_marketplace_query_failed", err.Error())
}
if pkg == nil {
return nil, errKolMarketplacePackageNotFound
}
existing, err := subTx.GetActiveForTenant(ctx, input.TenantID, input.PackageID)
if err != nil {
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
if existing != nil {
return nil, errKolSubscriptionExists
}
sub, err := subTx.Create(ctx, input.TenantID, input.PackageID)
if err != nil {
if isUniqueConstraintError(err, "uk_kol_subscription_active") {
return nil, errKolSubscriptionExists
}
return nil, response.ErrInternal(50100, "kol_subscription_create_failed", err.Error())
}
if err := subTx.Approve(ctx, sub.ID, input.TenantID, operatorID, input.EndAt); err != nil {
return nil, response.ErrInternal(50097, "kol_subscription_approve_failed", err.Error())
}
if err := grantActivePackagePrompts(ctx, subTx, promptTx, input.TenantID, sub.ID, pkg.TenantID, pkg.ID); err != nil {
return nil, err
}
updated, err := subTx.GetByIDAny(ctx, sub.ID)
if err != nil {
return nil, response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50101, "kol_subscription_bind_commit_failed", "failed to commit manual bind")
}
InvalidateKolPromptCaches(ctx, s.cache)
s.logAdminAction(ctx, "manual_bind", operatorID, updated.TenantID, updated.ID, map[string]interface{}{
"id": updated.ID,
"tenant_id": updated.TenantID,
"package_id": updated.PackageID,
"status": updated.Status,
"end_at": timeStringPtr(input.EndAt),
})
return newKolSubscriptionResponse(updated), nil
}
func (s *KolSubscriptionAdminService) Revoke(ctx context.Context, id int64, operatorID int64) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(50102, "kol_subscription_revoke_tx_failed", "failed to begin revoke transaction")
}
defer tx.Rollback(ctx)
subTx := repository.NewKolSubscriptionRepository(tx)
sub, err := subTx.GetByIDAny(ctx, id)
if err != nil {
return response.ErrInternal(50093, "kol_subscription_query_failed", err.Error())
}
if sub == nil {
return errKolSubscriptionNotFound
}
if err := subTx.RevokeByTenant(ctx, id, sub.TenantID); err != nil {
return response.ErrInternal(50103, "kol_subscription_revoke_failed", err.Error())
}
if err := subTx.RevokeAccessRowsBySubscription(ctx, id, sub.TenantID); err != nil {
return response.ErrInternal(50104, "kol_subscription_access_revoke_failed", err.Error())
}
if err := tx.Commit(ctx); err != nil {
return response.ErrInternal(50105, "kol_subscription_revoke_commit_failed", "failed to commit subscription revoke")
}
InvalidateKolPromptCaches(ctx, s.cache)
s.logAdminAction(ctx, "revoke", operatorID, sub.TenantID, sub.ID, map[string]interface{}{
"id": sub.ID,
"tenant_id": sub.TenantID,
"package_id": sub.PackageID,
"status": "revoked",
})
return nil
}
func grantActivePackagePrompts(
ctx context.Context,
subRepo repository.KolSubscriptionRepository,
promptRepo repository.KolPromptRepository,
subscriberTenantID, subscriptionID, creatorTenantID, packageID int64,
) error {
prompts, err := promptRepo.ListActiveByPackage(ctx, creatorTenantID, packageID)
if err != nil {
return response.ErrInternal(50092, "kol_marketplace_prompt_query_failed", err.Error())
}
for _, prompt := range prompts {
if err := subRepo.CreateAccessRow(ctx, repository.CreateAccessRowInput{
TenantID: subscriberTenantID,
SubscriptionID: subscriptionID,
PackageID: packageID,
PromptID: prompt.ID,
}); err != nil {
return response.ErrInternal(50084, "kol_subscription_access_create_failed", err.Error())
}
}
return nil
}
func (s *KolSubscriptionAdminService) logAdminAction(
ctx context.Context,
action string,
operatorID, targetTenantID, subscriptionID int64,
after map[string]interface{},
) {
if s.auditLogs == nil {
return
}
afterJSON, _ := json.Marshal(after)
result := "success"
resourceType := "kol_subscription"
requestID := middleware.RequestIDFromContext(ctx)
s.auditLogs.Log(auditlog.Entry{
OperatorID: operatorID,
TenantID: &targetTenantID,
Module: "kol_admin",
Action: action,
ResourceType: &resourceType,
ResourceID: &subscriptionID,
RequestID: nilIfEmptyString(requestID),
AfterJSON: afterJSON,
Result: &result,
})
}
@@ -1,116 +0,0 @@
package transport
import (
"time"
"github.com/gin-gonic/gin"
"github.com/geo-platform/tenant-api/internal/bootstrap"
"github.com/geo-platform/tenant-api/internal/shared/auth"
"github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/tenant/app"
)
var errTenantAdminRequired = response.ErrForbidden(40371, "tenant_admin_required", "tenant admin role is required")
type KolAdminHandler struct {
svc *app.KolSubscriptionAdminService
}
type approveKolSubscriptionRequest struct {
EndAt *time.Time `json:"end_at"`
}
func NewKolAdminHandler(a *bootstrap.App) *KolAdminHandler {
return &KolAdminHandler{
svc: app.NewKolSubscriptionAdminService(
a.DB,
a.AuditLogs,
a.KolMarketplace,
a.KolSubscriptions,
a.KolPrompts,
).WithCache(a.Cache),
}
}
func (h *KolAdminHandler) ApproveSubscription(c *gin.Context) {
id, ok := parseInt64Param(c, "id", "subscription id")
if !ok {
return
}
actor, ok := requireTenantAdmin(c)
if !ok {
return
}
var req approveKolSubscriptionRequest
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
}
data, err := h.svc.Approve(c.Request.Context(), id, req.EndAt, actor.UserID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *KolAdminHandler) ManualBind(c *gin.Context) {
actor, ok := requireTenantAdmin(c)
if !ok {
return
}
var req app.ManualBindKolSubscriptionInput
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
data, err := h.svc.ManualBind(c.Request.Context(), req, actor.UserID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *KolAdminHandler) RevokeSubscription(c *gin.Context) {
id, ok := parseInt64Param(c, "id", "subscription id")
if !ok {
return
}
actor, ok := requireTenantAdmin(c)
if !ok {
return
}
if err := h.svc.Revoke(c.Request.Context(), id, actor.UserID); err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"ok": true})
}
// V1 compromise: these admin endpoints live on tenant-api until a dedicated
// platform-api/module exists. The temporary gate is tenant_role=tenant_admin.
func requireTenantAdmin(c *gin.Context) (auth.Actor, bool) {
actor, ok := actorFromRequest(c)
if !ok {
return auth.Actor{}, false
}
if actor.Role != "tenant_admin" {
response.Error(c, errTenantAdminRequired)
return auth.Actor{}, false
}
return actor, true
}