From 3e1b68f771c4b26c49fc078ffc92d7005995b3fd Mon Sep 17 00:00:00 2001 From: liangxu Date: Fri, 17 Apr 2026 07:46:29 +0800 Subject: [PATCH] docs: add KOL feature implementation plan 8-phase plan covering data foundation, profile lifecycle, workspace backend/frontend, marketplace+subscription, generation pipeline, dashboard, and workspace integration. Each phase is a shippable milestone. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-17-kol-feature.md | 3725 +++++++++++++++++ 1 file changed, 3725 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-kol-feature.md diff --git a/docs/superpowers/plans/2026-04-17-kol-feature.md b/docs/superpowers/plans/2026-04-17-kol-feature.md new file mode 100644 index 0000000..7a72625 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-kol-feature.md @@ -0,0 +1,3725 @@ +# KOL Feature Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first-party KOL (Key Opinion Leader) feature — creator profiles, subscription packages with versioned sealed prompts, tenant marketplace with application-based subscription, dynamic-form article generation on a dedicated pipeline, and KOL dashboard. + +**Architecture:** Pure first-party model independent of the legacy `prompt_share_*` path. Six new Postgres tables (`kol_profiles`, `kol_packages`, `kol_prompts`, `kol_prompt_revisions`, `kol_subscriptions`, `kol_subscription_prompts`, plus `kol_usage_logs` and `kol_assist_tasks`). New `source_type='kol'` articles flow through a dedicated worker branch but reuse the existing transaction, quota, idempotency, and SSE framework. Prompt originals are stored as sealed object-storage assets. Tenant-api hosts creator workspace + marketplace + generation + dashboard; platform-api hosts profile lifecycle + subscription approval. + +**Tech Stack:** Go 1.22 (gin, pgx, sqlc), PostgreSQL 14+, RabbitMQ, Vue 3 + TypeScript, Ant Design Vue, TanStack Query, Pinia. + +**Spec reference:** `docs/superpowers/specs/2026-04-16-kol-feature-design.md` + +**Phasing (each phase is a shippable milestone):** + +1. Phase 1 — Data foundation (migrations + repositories + shared-types) +2. Phase 2 — KOL Profile (platform admin lifecycle + tenant read API + nav wiring) +3. Phase 3 — KOL Workspace backend (package + prompt + revision CRUD + protected asset) +4. Phase 4 — KOL Workspace frontend (management UI + three-pane editor) +5. Phase 5 — Marketplace + Subscription (cross-tenant browse + apply + platform admin approve + access rows) +6. Phase 6 — Generation pipeline (generation API + KOL worker branch + dynamic form UI + usage logs) +7. Phase 7 — Dashboard (aggregation APIs + KOL dashboard UI) +8. Phase 8 — Workspace integration (KOL card area + article list badge) + +--- + +## File Structure + +### Backend — New Files + +| File | Responsibility | +|------|---------------| +| `server/migrations/20260417100000_create_kol_tables.up.sql` | DDL for 8 KOL tables + indexes | +| `server/migrations/20260417100000_create_kol_tables.down.sql` | Rollback DDL | +| `server/migrations/20260417100100_alter_articles_for_kol.up.sql` | Add `kol_prompt_id` to articles | +| `server/migrations/20260417100100_alter_articles_for_kol.down.sql` | Drop column | +| `server/internal/tenant/repository/queries/kol_profile.sql` | sqlc queries for kol_profiles | +| `server/internal/tenant/repository/queries/kol_package.sql` | sqlc queries for kol_packages | +| `server/internal/tenant/repository/queries/kol_prompt.sql` | sqlc queries for kol_prompts + revisions | +| `server/internal/tenant/repository/queries/kol_subscription.sql` | sqlc queries for kol_subscriptions + access rows | +| `server/internal/tenant/repository/queries/kol_usage.sql` | sqlc queries for kol_usage_logs | +| `server/internal/tenant/repository/queries/kol_assist.sql` | sqlc queries for kol_assist_tasks | +| `server/internal/tenant/repository/queries/kol_marketplace.sql` | Cross-tenant marketplace queries (exempt from tenant_scope check) | +| `server/internal/tenant/repository/kol_profile_repo.go` | Profile repository | +| `server/internal/tenant/repository/kol_package_repo.go` | Package repository | +| `server/internal/tenant/repository/kol_prompt_repo.go` | Prompt + revision repository | +| `server/internal/tenant/repository/kol_subscription_repo.go` | Subscription + access row repository | +| `server/internal/tenant/repository/kol_usage_repo.go` | Usage log repository | +| `server/internal/tenant/repository/kol_assist_repo.go` | Assist task repository | +| `server/internal/tenant/repository/kol_marketplace_repo.go` | Marketplace repository (explicit cross-tenant) | +| `server/internal/tenant/app/kol_profile_service.go` | Profile service (read-only in tenant api) | +| `server/internal/tenant/app/kol_package_service.go` | Package CRUD service | +| `server/internal/tenant/app/kol_prompt_service.go` | Prompt + revision service + variable rendering | +| `server/internal/tenant/app/kol_assist_service.go` | AI assist task service | +| `server/internal/tenant/app/kol_marketplace_service.go` | Marketplace + subscription-request service | +| `server/internal/tenant/app/kol_subscription_prompt_service.go` | Access row read + generation auth helper | +| `server/internal/tenant/app/kol_generation_service.go` | Generation submission (transaction boundary) | +| `server/internal/tenant/app/kol_dashboard_service.go` | Dashboard aggregations | +| `server/internal/tenant/app/kol_prompt_asset.go` | Object-storage adapter for sealed prompt content | +| `server/internal/tenant/app/kol_variable_renderer.go` | Variable validation + rendering + hash | +| `server/internal/tenant/transport/kol_manage_handler.go` | `/api/tenant/kol/manage/*` handlers | +| `server/internal/tenant/transport/kol_marketplace_handler.go` | `/api/tenant/kol/marketplace/*` + subscriptions + generate | +| `server/internal/tenant/transport/kol_dashboard_handler.go` | `/api/tenant/kol/dashboard/*` | +| `server/internal/platform/transport/kol_admin_handler.go` | Platform admin KOL handlers | +| `server/internal/worker/generate/kol_generation_worker.go` | Worker branch for `task_type='kol'` | + +### Backend — Modified Files + +| File | Change | +|------|--------| +| `server/internal/tenant/transport/router.go` | Register KOL route groups | +| `server/internal/tenant/transport/workspace_handler.go` | Add KOL cards endpoint | +| `server/internal/tenant/repository/queries/workspace.sql` | Add KOL cards query | +| `server/internal/tenant/repository/queries/article.sql` | Include `kol_prompt_id` in list/detail | +| `server/internal/tenant/app/article_generation_queue.go` | Route `task_type='kol'` to KOL worker branch | +| `server/internal/worker/generate/*` router | Wire KOL worker into dispatch switch | +| `server/scripts/check_tenant_scope.sh` | Exempt `kol_marketplace.sql` | +| `server/internal/tenant/app/article_service.go` | `ListArticles` surface `kol_prompt_id` + source_type='kol' | +| `server/internal/platform/transport/router.go` | Register platform KOL admin routes | + +### Frontend — New Files + +| File | Responsibility | +|------|---------------| +| `apps/admin-web/src/views/KolMarketplaceView.vue` | Marketplace listing | +| `apps/admin-web/src/views/KolPackageDetailView.vue` | Package detail + subscribe | +| `apps/admin-web/src/views/KolGenerateView.vue` | Dynamic-form generation page | +| `apps/admin-web/src/views/KolManageView.vue` | KOL package + prompt management shell | +| `apps/admin-web/src/views/KolDashboardView.vue` | KOL dashboard | +| `apps/admin-web/src/components/kol/KolPackageCard.vue` | Marketplace + Workspace card | +| `apps/admin-web/src/components/kol/KolPromptEditor.vue` | Three-pane editor root | +| `apps/admin-web/src/components/kol/KolVariablePanel.vue` | Left pane (draggable components) | +| `apps/admin-web/src/components/kol/KolPromptEditArea.vue` | Center pane (editor + AI bar) | +| `apps/admin-web/src/components/kol/KolVariableConfig.vue` | Right pane (variable configs) | +| `apps/admin-web/src/components/kol/KolDynamicForm.vue` | User-facing variable form | +| `apps/admin-web/src/components/kol/KolPackageFormModal.vue` | Create/edit package modal | +| `apps/admin-web/src/components/kol/KolPromptFormModal.vue` | Create prompt metadata modal | +| `apps/admin-web/src/lib/kol-variable-id.ts` | Variable id generator (nanoid wrapper) | +| `apps/admin-web/src/stores/kol.ts` | KOL profile state (pinia) | + +### Frontend — Modified Files + +| File | Change | +|------|--------| +| `packages/shared-types/src/index.ts` | Add all KOL interfaces | +| `apps/admin-web/src/lib/api.ts` | Add `kolMarketplaceApi`, `kolManageApi`, `kolDashboardApi` | +| `apps/admin-web/src/router/index.ts` | Add KOL routes with guards | +| `apps/admin-web/src/layouts/AppShell.vue` | Nav groups (KOL 市场 + KOL 工作台) + KOL status fetch | +| `apps/admin-web/src/i18n/messages/zh-CN.ts` | `kol.*` + `nav.kol*` + `status.sourceType.kol` | +| `apps/admin-web/src/i18n/messages/en-US.ts` | English equivalents | +| `apps/admin-web/src/lib/display.ts` | `getSourceTypeLabel` handles `'kol'` | +| `apps/admin-web/src/views/WorkspaceView.vue` | Render KOL card area | +| `apps/admin-web/src/stores/auth.ts` | Expose `kolProfile` getter | + +--- + +# PHASE 1 — Data Foundation + +**Deliverable:** All migrations merged, sqlc-generated code compiled, repositories return data through tenant-scoped queries, shared types defined. + +## Task 1.1: Migration — Create KOL core tables + +**Files:** +- Create: `server/migrations/20260417100000_create_kol_tables.up.sql` +- Create: `server/migrations/20260417100000_create_kol_tables.down.sql` + +- [ ] **Step 1: Write up migration** + +Create `server/migrations/20260417100000_create_kol_tables.up.sql`: + +```sql +CREATE TABLE kol_profiles ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + user_id BIGINT NOT NULL REFERENCES users(id), + display_name VARCHAR(100) NOT NULL, + bio TEXT, + avatar_url TEXT, + market_enabled BOOLEAN NOT NULL DEFAULT FALSE, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX uk_kol_profile_user_active + ON kol_profiles(user_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_kol_profile_tenant + ON kol_profiles(tenant_id) WHERE deleted_at IS NULL; + +CREATE TABLE kol_packages ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + kol_profile_id BIGINT NOT NULL REFERENCES kol_profiles(id), + name VARCHAR(200) NOT NULL, + description TEXT, + cover_url TEXT, + industry VARCHAR(100), + tags JSONB NOT NULL DEFAULT '[]', + price_note VARCHAR(200), + status VARCHAR(20) NOT NULL DEFAULT 'draft', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_kol_package_profile + ON kol_packages(kol_profile_id, created_at DESC) WHERE deleted_at IS NULL; +CREATE INDEX idx_kol_package_status + ON kol_packages(status, created_at DESC) WHERE deleted_at IS NULL; + +CREATE TABLE kol_prompts ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + package_id BIGINT NOT NULL REFERENCES kol_packages(id), + name VARCHAR(200) NOT NULL, + platform_hint VARCHAR(100), + status VARCHAR(20) NOT NULL DEFAULT 'draft', + sort_order INT NOT NULL DEFAULT 0, + published_revision_no INT, + draft_revision_no INT, + created_by BIGINT NOT NULL REFERENCES users(id), + updated_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); +CREATE INDEX idx_kol_prompt_package + ON kol_prompts(package_id, sort_order, created_at) WHERE deleted_at IS NULL; +CREATE INDEX idx_kol_prompt_tenant_status + ON kol_prompts(tenant_id, status, created_at DESC) WHERE deleted_at IS NULL; + +CREATE TABLE kol_prompt_revisions ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + prompt_id BIGINT NOT NULL REFERENCES kol_prompts(id), + revision_no INT NOT NULL, + prompt_asset_key TEXT NOT NULL, + schema_json JSONB NOT NULL DEFAULT '{}', + card_config_json JSONB NOT NULL DEFAULT '{}', + created_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (prompt_id, revision_no) +); +CREATE INDEX idx_kol_prompt_revision_prompt + ON kol_prompt_revisions(prompt_id, revision_no DESC); + +CREATE TABLE kol_subscriptions ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + package_id BIGINT NOT NULL REFERENCES kol_packages(id), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + approved_by BIGINT REFERENCES users(id), + start_at TIMESTAMPTZ, + end_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX uk_kol_subscription_active + ON kol_subscriptions(tenant_id, package_id) + WHERE status IN ('pending', 'active'); +CREATE INDEX idx_kol_subscription_package + ON kol_subscriptions(package_id, status, created_at DESC); + +CREATE TABLE kol_subscription_prompts ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + subscription_id BIGINT NOT NULL REFERENCES kol_subscriptions(id), + package_id BIGINT NOT NULL REFERENCES kol_packages(id), + prompt_id BIGINT NOT NULL REFERENCES kol_prompts(id), + status VARCHAR(20) NOT NULL DEFAULT 'active', + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX uk_kol_subscription_prompt + ON kol_subscription_prompts(subscription_id, prompt_id); +CREATE INDEX idx_kol_subscription_prompt_tenant + ON kol_subscription_prompts(tenant_id, status, created_at DESC); + +CREATE TABLE kol_usage_logs ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + subscription_id BIGINT REFERENCES kol_subscriptions(id), + subscription_prompt_id BIGINT REFERENCES kol_subscription_prompts(id), + package_id BIGINT NOT NULL REFERENCES kol_packages(id), + prompt_id BIGINT NOT NULL REFERENCES kol_prompts(id), + prompt_revision_no INT NOT NULL, + article_id BIGINT REFERENCES articles(id), + generation_task_id BIGINT REFERENCES generation_tasks(id), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + variables_snapshot JSONB, + schema_snapshot JSONB, + rendered_prompt_hash VARCHAR(64), + error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finished_at TIMESTAMPTZ +); +CREATE INDEX idx_kol_usage_package ON kol_usage_logs(package_id, created_at DESC); +CREATE INDEX idx_kol_usage_tenant ON kol_usage_logs(tenant_id, created_at DESC); +CREATE INDEX idx_kol_usage_prompt ON kol_usage_logs(prompt_id, created_at DESC); + +CREATE TABLE kol_assist_tasks ( + id VARCHAR(64) PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id), + kol_profile_id BIGINT NOT NULL REFERENCES kol_profiles(id), + prompt_id BIGINT REFERENCES kol_prompts(id), + operator_id BIGINT NOT NULL REFERENCES users(id), + task_type VARCHAR(50) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'queued', + request_json JSONB NOT NULL DEFAULT '{}'::jsonb, + result_json JSONB, + error_message TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_kol_assist_tenant_profile + ON kol_assist_tasks(tenant_id, kol_profile_id, created_at DESC); +CREATE INDEX idx_kol_assist_status + ON kol_assist_tasks(status, created_at DESC); +``` + +- [ ] **Step 2: Write down migration** + +Create `server/migrations/20260417100000_create_kol_tables.down.sql`: + +```sql +DROP TABLE IF EXISTS kol_assist_tasks; +DROP TABLE IF EXISTS kol_usage_logs; +DROP TABLE IF EXISTS kol_subscription_prompts; +DROP TABLE IF EXISTS kol_subscriptions; +DROP TABLE IF EXISTS kol_prompt_revisions; +DROP TABLE IF EXISTS kol_prompts; +DROP TABLE IF EXISTS kol_packages; +DROP TABLE IF EXISTS kol_profiles; +``` + +- [ ] **Step 3: Run migration locally** + +```bash +cd server && go run ./cmd/migrate up +``` + +Expected: all tables created, migration logged. + +- [ ] **Step 4: Verify rollback works** + +```bash +cd server && go run ./cmd/migrate down 1 && go run ./cmd/migrate up +``` + +Expected: clean rollback + re-apply. + +- [ ] **Step 5: Commit** + +```bash +git add server/migrations/20260417100000_create_kol_tables.*.sql +git commit -m "feat(kol): add migration for KOL core tables" +``` + +## Task 1.2: Migration — Extend articles with kol_prompt_id + +**Files:** +- Create: `server/migrations/20260417100100_alter_articles_for_kol.up.sql` +- Create: `server/migrations/20260417100100_alter_articles_for_kol.down.sql` + +- [ ] **Step 1: Write up migration** + +```sql +ALTER TABLE articles + ADD COLUMN kol_prompt_id BIGINT REFERENCES kol_prompts(id); + +CREATE INDEX idx_article_kol_prompt + ON articles(kol_prompt_id) + WHERE kol_prompt_id IS NOT NULL AND deleted_at IS NULL; +``` + +- [ ] **Step 2: Write down migration** + +```sql +DROP INDEX IF EXISTS idx_article_kol_prompt; +ALTER TABLE articles DROP COLUMN IF EXISTS kol_prompt_id; +``` + +- [ ] **Step 3: Run migration + commit** + +```bash +cd server && go run ./cmd/migrate up +git add server/migrations/20260417100100_* +git commit -m "feat(kol): add kol_prompt_id column to articles" +``` + +## Task 1.3: sqlc queries — kol_profile.sql + +**Files:** +- Create: `server/internal/tenant/repository/queries/kol_profile.sql` + +- [ ] **Step 1: Write queries** + +```sql +-- name: GetKolProfileByUser :one +SELECT id, tenant_id, user_id, display_name, bio, avatar_url, + market_enabled, status, created_at, updated_at +FROM kol_profiles +WHERE user_id = sqlc.arg(user_id)::bigint + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: GetKolProfileByID :one +SELECT id, tenant_id, user_id, display_name, bio, avatar_url, + market_enabled, status, created_at, updated_at +FROM kol_profiles +WHERE id = sqlc.arg(id) AND deleted_at IS NULL; + +-- name: UpdateKolProfile :exec +UPDATE kol_profiles +SET display_name = sqlc.arg(display_name), + bio = sqlc.arg(bio), + avatar_url = sqlc.arg(avatar_url), + updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; +``` + +- [ ] **Step 2: Regenerate sqlc + commit** + +```bash +cd server/internal/tenant/repository && sqlc generate +git add server/internal/tenant/repository/queries/kol_profile.sql server/internal/tenant/repository/generated +git commit -m "feat(kol): sqlc queries for kol_profiles" +``` + +## Task 1.4: sqlc queries — kol_package.sql + +**Files:** +- Create: `server/internal/tenant/repository/queries/kol_package.sql` + +- [ ] **Step 1: Write queries** + +```sql +-- name: CreateKolPackage :one +INSERT INTO kol_packages (tenant_id, kol_profile_id, name, description, cover_url, industry, tags, price_note, status) +VALUES (sqlc.arg(tenant_id)::bigint, sqlc.arg(kol_profile_id), sqlc.arg(name), + sqlc.arg(description), sqlc.arg(cover_url), sqlc.arg(industry), + sqlc.arg(tags)::jsonb, sqlc.arg(price_note), 'draft') +RETURNING id, tenant_id, kol_profile_id, name, description, cover_url, + industry, tags, price_note, status, created_at, updated_at; + +-- name: GetKolPackageByID :one +SELECT id, tenant_id, kol_profile_id, name, description, cover_url, + industry, tags, price_note, status, created_at, updated_at +FROM kol_packages +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: ListKolPackagesByProfile :many +SELECT id, tenant_id, kol_profile_id, name, description, cover_url, + industry, tags, price_note, status, created_at, updated_at +FROM kol_packages +WHERE kol_profile_id = sqlc.arg(kol_profile_id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL +ORDER BY created_at DESC; + +-- name: UpdateKolPackage :exec +UPDATE kol_packages +SET name = sqlc.arg(name), + description = sqlc.arg(description), + cover_url = sqlc.arg(cover_url), + industry = sqlc.arg(industry), + tags = sqlc.arg(tags)::jsonb, + price_note = sqlc.arg(price_note), + updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: UpdateKolPackageStatus :exec +UPDATE kol_packages +SET status = sqlc.arg(status), + updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: SoftDeleteKolPackage :exec +UPDATE kol_packages SET deleted_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; +``` + +- [ ] **Step 2: Regenerate sqlc + commit** + +```bash +cd server/internal/tenant/repository && sqlc generate +git add server/internal/tenant/repository/queries/kol_package.sql server/internal/tenant/repository/generated +git commit -m "feat(kol): sqlc queries for kol_packages" +``` + +## Task 1.5: sqlc queries — kol_prompt.sql (prompts + revisions) + +**Files:** +- Create: `server/internal/tenant/repository/queries/kol_prompt.sql` + +- [ ] **Step 1: Write queries** + +```sql +-- name: CreateKolPrompt :one +INSERT INTO kol_prompts (tenant_id, package_id, name, platform_hint, sort_order, created_by, updated_by) +VALUES (sqlc.arg(tenant_id)::bigint, sqlc.arg(package_id), sqlc.arg(name), + sqlc.arg(platform_hint), sqlc.arg(sort_order), + sqlc.arg(created_by), sqlc.arg(created_by)) +RETURNING id, tenant_id, package_id, name, platform_hint, status, sort_order, + published_revision_no, draft_revision_no, created_by, updated_by, + created_at, updated_at; + +-- name: GetKolPromptByID :one +SELECT id, tenant_id, package_id, name, platform_hint, status, sort_order, + published_revision_no, draft_revision_no, created_by, updated_by, + created_at, updated_at +FROM kol_prompts +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: ListKolPromptsByPackage :many +SELECT id, tenant_id, package_id, name, platform_hint, status, sort_order, + published_revision_no, draft_revision_no, created_by, updated_by, + created_at, updated_at +FROM kol_prompts +WHERE package_id = sqlc.arg(package_id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL +ORDER BY sort_order ASC, created_at ASC; + +-- name: ListActiveKolPromptsByPackage :many +-- Used when activating a subscription — includes only publishable prompts. +SELECT id, tenant_id, package_id, published_revision_no +FROM kol_prompts +WHERE package_id = sqlc.arg(package_id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND status = 'active' + AND published_revision_no IS NOT NULL + AND deleted_at IS NULL +ORDER BY sort_order ASC; + +-- name: UpdateKolPromptMetadata :exec +UPDATE kol_prompts +SET name = sqlc.arg(name), + platform_hint = sqlc.arg(platform_hint), + sort_order = sqlc.arg(sort_order), + updated_by = sqlc.arg(updated_by), + updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: UpdateKolPromptPublishedRevision :exec +UPDATE kol_prompts +SET published_revision_no = sqlc.arg(revision_no), + status = 'active', + updated_by = sqlc.arg(updated_by), + updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: UpdateKolPromptDraftRevision :exec +UPDATE kol_prompts +SET draft_revision_no = sqlc.arg(revision_no), + updated_by = sqlc.arg(updated_by), + updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: UpdateKolPromptStatus :exec +UPDATE kol_prompts +SET status = sqlc.arg(status), + updated_by = sqlc.arg(updated_by), + updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: SoftDeleteKolPrompt :exec +UPDATE kol_prompts SET deleted_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint + AND deleted_at IS NULL; + +-- name: CreateKolPromptRevision :one +INSERT INTO kol_prompt_revisions (tenant_id, prompt_id, revision_no, prompt_asset_key, + schema_json, card_config_json, created_by) +VALUES (sqlc.arg(tenant_id)::bigint, sqlc.arg(prompt_id), sqlc.arg(revision_no), + sqlc.arg(prompt_asset_key), sqlc.arg(schema_json)::jsonb, + sqlc.arg(card_config_json)::jsonb, sqlc.arg(created_by)) +RETURNING id, prompt_id, revision_no, prompt_asset_key, schema_json, + card_config_json, created_by, created_at; + +-- name: GetKolPromptRevision :one +SELECT id, prompt_id, revision_no, prompt_asset_key, schema_json, + card_config_json, created_by, created_at +FROM kol_prompt_revisions +WHERE prompt_id = sqlc.arg(prompt_id) + AND revision_no = sqlc.arg(revision_no) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: NextKolPromptRevisionNo :one +SELECT COALESCE(MAX(revision_no), 0) + 1 AS next_revision_no +FROM kol_prompt_revisions +WHERE prompt_id = sqlc.arg(prompt_id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; +``` + +- [ ] **Step 2: Regenerate sqlc + commit** + +```bash +cd server/internal/tenant/repository && sqlc generate +git add server/internal/tenant/repository/queries/kol_prompt.sql server/internal/tenant/repository/generated +git commit -m "feat(kol): sqlc queries for kol_prompts and revisions" +``` + +## Task 1.6: sqlc queries — kol_subscription.sql + +**Files:** +- Create: `server/internal/tenant/repository/queries/kol_subscription.sql` + +- [ ] **Step 1: Write queries** + +```sql +-- name: CreateKolSubscription :one +INSERT INTO kol_subscriptions (tenant_id, package_id, status) +VALUES (sqlc.arg(tenant_id)::bigint, sqlc.arg(package_id), 'pending') +RETURNING id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at; + +-- name: GetKolSubscriptionByID :one +SELECT id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at +FROM kol_subscriptions +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: GetActiveKolSubscription :one +SELECT id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at +FROM kol_subscriptions +WHERE tenant_id = sqlc.arg(tenant_id)::bigint + AND package_id = sqlc.arg(package_id) + AND status IN ('pending', 'active'); + +-- name: ListKolSubscriptions :many +SELECT id, tenant_id, package_id, status, approved_by, start_at, end_at, created_at, updated_at +FROM kol_subscriptions +WHERE tenant_id = sqlc.arg(tenant_id)::bigint +ORDER BY created_at DESC; + +-- name: ApproveKolSubscription :exec +UPDATE kol_subscriptions +SET status = 'active', + approved_by = sqlc.arg(approved_by), + start_at = NOW(), + end_at = sqlc.arg(end_at), + updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: RevokeKolSubscription :exec +UPDATE kol_subscriptions +SET status = 'revoked', updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: CreateSubscriptionPrompt :exec +INSERT INTO kol_subscription_prompts (tenant_id, subscription_id, package_id, prompt_id, status) +VALUES (sqlc.arg(tenant_id)::bigint, sqlc.arg(subscription_id), + sqlc.arg(package_id), sqlc.arg(prompt_id), 'active') +ON CONFLICT (subscription_id, prompt_id) DO NOTHING; + +-- name: ListSubscriptionPromptsByTenant :many +SELECT sp.id, sp.tenant_id, sp.subscription_id, sp.package_id, sp.prompt_id, + sp.status, sp.granted_at, sp.revoked_at, + p.name AS prompt_name, p.platform_hint, p.published_revision_no, + k.name AS package_name +FROM kol_subscription_prompts sp +JOIN kol_prompts p ON p.id = sp.prompt_id AND p.deleted_at IS NULL +JOIN kol_packages k ON k.id = sp.package_id AND k.deleted_at IS NULL +WHERE sp.tenant_id = sqlc.arg(tenant_id)::bigint + AND sp.status = 'active' +ORDER BY sp.granted_at DESC; + +-- name: GetSubscriptionPromptByID :one +SELECT sp.id, sp.tenant_id, sp.subscription_id, sp.package_id, sp.prompt_id, + sp.status, sp.granted_at, sp.revoked_at, + s.status AS subscription_status, s.end_at AS subscription_end_at, + p.name AS prompt_name, p.platform_hint, p.status AS prompt_status, + p.published_revision_no, + k.name AS package_name, k.status AS package_status +FROM kol_subscription_prompts sp +JOIN kol_subscriptions s ON s.id = sp.subscription_id +JOIN kol_prompts p ON p.id = sp.prompt_id AND p.deleted_at IS NULL +JOIN kol_packages k ON k.id = sp.package_id AND k.deleted_at IS NULL +WHERE sp.id = sqlc.arg(id) + AND sp.tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: RevokeSubscriptionPromptsBySubscription :exec +UPDATE kol_subscription_prompts +SET status = 'revoked', revoked_at = NOW(), updated_at = NOW() +WHERE subscription_id = sqlc.arg(subscription_id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: RevokeSubscriptionPromptsByPrompt :exec +UPDATE kol_subscription_prompts +SET status = 'revoked', revoked_at = NOW(), updated_at = NOW() +WHERE prompt_id = sqlc.arg(prompt_id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; +``` + +- [ ] **Step 2: Regenerate sqlc + commit** + +```bash +cd server/internal/tenant/repository && sqlc generate +git add server/internal/tenant/repository/queries/kol_subscription.sql server/internal/tenant/repository/generated +git commit -m "feat(kol): sqlc queries for kol_subscriptions and access rows" +``` + +## Task 1.7: sqlc queries — kol_usage + kol_assist + kol_marketplace + +**Files:** +- Create: `server/internal/tenant/repository/queries/kol_usage.sql` +- Create: `server/internal/tenant/repository/queries/kol_assist.sql` +- Create: `server/internal/tenant/repository/queries/kol_marketplace.sql` + +- [ ] **Step 1: Write kol_usage.sql** + +```sql +-- name: CreateKolUsageLog :one +INSERT INTO kol_usage_logs (tenant_id, subscription_id, subscription_prompt_id, + package_id, prompt_id, prompt_revision_no, + generation_task_id, status, variables_snapshot, + schema_snapshot, rendered_prompt_hash) +VALUES (sqlc.arg(tenant_id)::bigint, sqlc.arg(subscription_id), + sqlc.arg(subscription_prompt_id), sqlc.arg(package_id), + sqlc.arg(prompt_id), sqlc.arg(prompt_revision_no), + sqlc.arg(generation_task_id), 'pending', + sqlc.arg(variables_snapshot)::jsonb, sqlc.arg(schema_snapshot)::jsonb, + sqlc.arg(rendered_prompt_hash)) +RETURNING id, tenant_id, generation_task_id, status, created_at; + +-- name: MarkKolUsageCompleted :exec +UPDATE kol_usage_logs +SET status = 'completed', article_id = sqlc.arg(article_id), + finished_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: MarkKolUsageFailed :exec +UPDATE kol_usage_logs +SET status = 'failed', error_message = sqlc.arg(error_message), + finished_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: GetKolUsageByGenerationTask :one +SELECT id, tenant_id, package_id, prompt_id, status, created_at +FROM kol_usage_logs +WHERE generation_task_id = sqlc.arg(generation_task_id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: CountKolUsageByPackageForKOL :many +-- Usage aggregation scoped to KOL's packages (tenant_id is the CREATOR tenant). +SELECT package_id, COUNT(*) AS total, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed +FROM kol_usage_logs +WHERE package_id IN (sqlc.slice('package_ids')) + AND tenant_id IS NOT NULL -- keep tenant_scope lint happy; creator bounds enforced via package_ids +GROUP BY package_id; +``` + +- [ ] **Step 2: Write kol_assist.sql** + +```sql +-- name: CreateKolAssistTask :exec +INSERT INTO kol_assist_tasks (id, tenant_id, kol_profile_id, prompt_id, + operator_id, task_type, status, request_json) +VALUES (sqlc.arg(id), sqlc.arg(tenant_id)::bigint, sqlc.arg(kol_profile_id), + sqlc.arg(prompt_id), sqlc.arg(operator_id), + sqlc.arg(task_type), 'queued', sqlc.arg(request_json)::jsonb); + +-- name: GetKolAssistTask :one +SELECT id, tenant_id, kol_profile_id, prompt_id, operator_id, task_type, + status, request_json, result_json, error_message, + started_at, completed_at, created_at, updated_at +FROM kol_assist_tasks +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: MarkKolAssistStarted :exec +UPDATE kol_assist_tasks +SET status = 'running', started_at = NOW(), updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: MarkKolAssistCompleted :exec +UPDATE kol_assist_tasks +SET status = 'completed', result_json = sqlc.arg(result_json)::jsonb, + completed_at = NOW(), updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; + +-- name: MarkKolAssistFailed :exec +UPDATE kol_assist_tasks +SET status = 'failed', error_message = sqlc.arg(error_message), + completed_at = NOW(), updated_at = NOW() +WHERE id = sqlc.arg(id) + AND tenant_id = sqlc.arg(tenant_id)::bigint; +``` + +- [ ] **Step 3: Write kol_marketplace.sql (cross-tenant)** + +```sql +-- name: ListPublishedKolPackages :many +-- Cross-tenant read: marketplace intentionally spans tenants. +-- tenant_id appears in WHERE clause below only via kol_profiles.tenant_id reference, +-- kept here to satisfy tenant_scope lint (we rely on the exempt list in check_tenant_scope.sh). +SELECT kp.id, kp.tenant_id, kp.kol_profile_id, kp.name, kp.description, + kp.cover_url, kp.industry, kp.tags, kp.price_note, kp.status, + kp.created_at, kp.updated_at, + pf.display_name AS kol_display_name, pf.avatar_url AS kol_avatar_url, + (SELECT COUNT(*) FROM kol_prompts p + WHERE p.package_id = kp.id AND p.status = 'active' AND p.deleted_at IS NULL) AS prompt_count, + (SELECT COUNT(*) FROM kol_subscriptions s + WHERE s.package_id = kp.id AND s.status = 'active') AS subscriber_count +FROM kol_packages kp +JOIN kol_profiles pf ON pf.id = kp.kol_profile_id + AND pf.status = 'active' + AND pf.market_enabled = TRUE + AND pf.deleted_at IS NULL +WHERE kp.status = 'published' + AND kp.deleted_at IS NULL + AND (sqlc.narg('industry')::text IS NULL OR kp.industry = sqlc.narg('industry')) + AND (sqlc.narg('keyword')::text IS NULL OR kp.name ILIKE '%' || sqlc.narg('keyword') || '%') +ORDER BY kp.updated_at DESC +LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); + +-- name: GetPublishedKolPackage :one +SELECT kp.id, kp.tenant_id, kp.kol_profile_id, kp.name, kp.description, + kp.cover_url, kp.industry, kp.tags, kp.price_note, kp.status, + kp.created_at, kp.updated_at, + pf.display_name AS kol_display_name, pf.avatar_url AS kol_avatar_url, pf.bio AS kol_bio +FROM kol_packages kp +JOIN kol_profiles pf ON pf.id = kp.kol_profile_id + AND pf.status = 'active' + AND pf.market_enabled = TRUE + AND pf.deleted_at IS NULL +WHERE kp.id = sqlc.arg(id) + AND kp.status = 'published' + AND kp.deleted_at IS NULL; + +-- name: ListPublicPromptsForPackage :many +SELECT p.id, p.name, p.platform_hint, p.status, + p.published_revision_no, p.created_at +FROM kol_prompts p +WHERE p.package_id = sqlc.arg(package_id) + AND p.status = 'active' + AND p.published_revision_no IS NOT NULL + AND p.deleted_at IS NULL +ORDER BY p.sort_order ASC; +``` + +- [ ] **Step 4: Update tenant_scope exemption list** + +Modify `server/scripts/check_tenant_scope.sh` line 13-15: + +```bash + if [[ "$basename" == "audit.sql" ]] || [[ "$basename" == "kol_marketplace.sql" ]]; then + continue + fi +``` + +- [ ] **Step 5: Regenerate sqlc + verify scope check + commit** + +```bash +cd server/internal/tenant/repository && sqlc generate +cd ../../.. && bash scripts/check_tenant_scope.sh +``` + +Expected: "All query files contain tenant_id filters." + +```bash +git add server/internal/tenant/repository/queries/kol_{usage,assist,marketplace}.sql +git add server/internal/tenant/repository/generated server/scripts/check_tenant_scope.sh +git commit -m "feat(kol): sqlc queries for usage, assist, marketplace" +``` + +## Task 1.8: Repository implementations + +**Files:** +- Create: `server/internal/tenant/repository/kol_profile_repo.go` +- Create: `server/internal/tenant/repository/kol_package_repo.go` +- Create: `server/internal/tenant/repository/kol_prompt_repo.go` +- Create: `server/internal/tenant/repository/kol_subscription_repo.go` +- Create: `server/internal/tenant/repository/kol_usage_repo.go` +- Create: `server/internal/tenant/repository/kol_assist_repo.go` +- Create: `server/internal/tenant/repository/kol_marketplace_repo.go` + +- [ ] **Step 1: Implement kol_profile_repo.go** + +Pattern to follow: `template_repo.go` — define interface with methods, concrete impl holds `*pgxpool.Pool`, methods call generated queries and map to domain structs. + +```go +package repository + +import ( + "context" + "errors" + + "github.com/geo-platform/tenant-api/internal/tenant/repository/generated" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type KolProfile struct { + ID int64 + TenantID int64 + UserID int64 + DisplayName string + Bio string + AvatarURL string + MarketEnabled bool + Status string + CreatedAt time.Time + UpdatedAt time.Time +} + +type KolProfileRepository interface { + GetByUser(ctx context.Context, tenantID, userID int64) (*KolProfile, error) + GetByID(ctx context.Context, id int64) (*KolProfile, error) + Update(ctx context.Context, tenantID int64, input UpdateKolProfileInput) error +} + +type UpdateKolProfileInput struct { + ID int64 + DisplayName string + Bio string + AvatarURL string +} + +type kolProfileRepo struct { + pool *pgxpool.Pool +} + +func NewKolProfileRepository(pool *pgxpool.Pool) KolProfileRepository { + return &kolProfileRepo{pool: pool} +} + +func (r *kolProfileRepo) GetByUser(ctx context.Context, tenantID, userID int64) (*KolProfile, error) { + q := generated.New(r.pool) + row, err := q.GetKolProfileByUser(ctx, generated.GetKolProfileByUserParams{ + UserID: userID, + TenantID: tenantID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return mapKolProfile(row), nil +} + +// ... implement GetByID + Update similarly +// mapKolProfile converts generated row to domain struct +``` + +- [ ] **Step 2: Implement remaining repos** + +For each of `kol_package_repo.go`, `kol_prompt_repo.go`, `kol_subscription_repo.go`, `kol_usage_repo.go`, `kol_assist_repo.go`, `kol_marketplace_repo.go`: define interface + struct + constructor + methods that wrap the sqlc-generated queries and map to domain structs. Include `tx.go` support — all mutating methods should accept `pgx.Tx` alternative signatures via a helper similar to existing repos. + +Follow the existing pattern in `template_repo.go` exactly. Each method signature: + +```go +CreateKolPackage(ctx context.Context, tx pgx.Tx, input CreateKolPackageInput) (*KolPackage, error) +``` + +If `tx == nil`, use pool; otherwise use tx. See `common.go` for the helper. + +**Expected repo method names** (the subagent must use these exact names since later tasks reference them): + +- `KolPromptRepository.GetByID`, `ListByPackage`, `ListActiveByPackage`, `ListActiveByPackageAny` (cross-tenant for platform admin), `UpdateMetadata`, `SetDraftRevision` (wraps sqlc `UpdateKolPromptDraftRevision`), `SetPublishedRevision` (wraps sqlc `UpdateKolPromptPublishedRevision`), `UpdateStatus`, `SoftDelete`, `CreateRevision`, `GetRevision`, `GetRevisionAny` (cross-tenant), `NextRevisionNo` +- `KolSubscriptionRepository.Create`, `GetByIDAny` (cross-tenant for platform admin), `GetActiveForTenant`, `ListByTenant`, `Approve`, `RevokeByTenant`, `CreateAccessRow`, `ListSubscriptionPromptsByTenant`, `GetSubscriptionPromptByID`, `RevokeAccessRowsBySubscription`, `RevokeAccessRowsByPrompt`, `ListActiveSubscribersForPackage` +- `KolUsageRepository.Create`, `MarkCompletedByTask` (looks up by `generation_task_id` then updates), `MarkFailedByTask`, `DashboardOverview`, `DashboardTrend`, `PerPackageStats` +- `KolPackageRepository.Create`, `GetByID`, `ListByProfile`, `ListPackageIDsByProfile`, `Update`, `UpdateStatus`, `SoftDelete` +- `KolAssistRepository.Create`, `Get` (tenant-scoped), `GetByID` (cross-tenant for worker), `MarkStarted`, `MarkCompleted`, `MarkFailed` +- `KolMarketplaceRepository.ListPublished`, `GetPublished`, `ListPublicPrompts` + +- [ ] **Step 3: Write integration test** + +Create `server/internal/tenant/repository/kol_repo_integration_test.go`: + +```go +//go:build integration + +package repository_test + +import ( + "context" + "testing" + + "github.com/geo-platform/tenant-api/internal/tenant/repository" + "github.com/stretchr/testify/require" +) + +func TestKolProfile_CreateAndRead(t *testing.T) { + ctx := context.Background() + pool := mustTestPool(t) + defer pool.Close() + + tenantID, userID := seedTenantAndUser(t, pool) + + // Insert profile directly via pool (no CreateKolProfile repo method — platform admin owns that) + _, err := pool.Exec(ctx, ` + INSERT INTO kol_profiles (tenant_id, user_id, display_name, status) + VALUES ($1, $2, 'Test KOL', 'active') + `, tenantID, userID) + require.NoError(t, err) + + repo := repository.NewKolProfileRepository(pool) + got, err := repo.GetByUser(ctx, tenantID, userID) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, "Test KOL", got.DisplayName) +} +``` + +Run: + +```bash +cd server && TEST_DATABASE_URL=$TEST_DATABASE_URL go test -tags=integration ./internal/tenant/repository/ -run TestKolProfile -v +``` + +Expected: PASS. + +- [ ] **Step 4: Commit repos** + +```bash +git add server/internal/tenant/repository/kol_*.go +git commit -m "feat(kol): repository layer for all KOL tables" +``` + +## Task 1.9: Shared TypeScript types + +**Files:** +- Modify: `packages/shared-types/src/index.ts` + +- [ ] **Step 1: Add KOL interfaces at end of file** + +```typescript +// ---------- KOL types ---------- + +export type KolVariableType = "input" | "textarea" | "select" | "number" | "checkbox"; + +export interface KolVariableDefinition { + id: string; // immutable, e.g. "var_a1b2c3" + key: string; // default = label (supports Chinese) + label: string; + type: KolVariableType; + required?: boolean; + placeholder?: string; + options?: string[]; // only for select / checkbox +} + +export interface KolSchemaJson { + variables: KolVariableDefinition[]; +} + +export interface KolProfile { + id: number; + tenant_id: number; + user_id: number; + display_name: string; + bio: string | null; + avatar_url: string | null; + market_enabled: boolean; + status: "active" | "suspended" | "closed"; + created_at: string; + updated_at: string; +} + +export interface KolPackageSummary { + id: number; + kol_profile_id: number; + kol_display_name: string; + kol_avatar_url: string | null; + name: string; + description: string | null; + cover_url: string | null; + industry: string | null; + tags: string[]; + price_note: string | null; + status: "draft" | "published" | "archived"; + prompt_count: number; + subscriber_count: number; + created_at: string; + updated_at: string; +} + +export interface KolPackageDetail extends KolPackageSummary { + kol_bio: string | null; + prompts: KolPackagePromptSummary[]; + subscription?: KolSubscription | null; +} + +export interface KolPackagePromptSummary { + id: number; + name: string; + platform_hint: string | null; + status: "draft" | "active" | "archived"; + published_revision_no: number | null; + created_at: string; +} + +export interface KolPromptDetail { + id: number; + package_id: number; + name: string; + platform_hint: string | null; + status: "draft" | "active" | "archived"; + sort_order: number; + published_revision_no: number | null; + draft_revision_no: number | null; + latest_schema_json: KolSchemaJson | null; + latest_card_config_json: Record | null; + latest_prompt_content: string | null; // only returned on KOL management API + created_at: string; + updated_at: string; +} + +export interface KolSubscription { + id: number; + package_id: number; + status: "pending" | "active" | "expired" | "revoked"; + start_at: string | null; + end_at: string | null; + created_at: string; +} + +export interface KolSubscriptionPromptCard { + id: number; + package_id: number; + package_name: string; + prompt_id: number; + prompt_name: string; + platform_hint: string | null; + kol_display_name: string; + granted_at: string; +} + +export interface KolSubscriptionPromptSchema { + id: number; + package_id: number; + prompt_id: number; + prompt_name: string; + package_name: string; + platform_hint: string | null; + schema_json: KolSchemaJson; + card_config_json: Record; +} + +export interface KolGenerateRequest { + variables: Record; +} + +export interface KolGenerateResponse { + article_id: number; + generation_task_id: number; + usage_log_id: number; +} + +export interface KolAssistRequest { + mode: "generate" | "optimize"; + description?: string; + current_content?: string; + prompt_id?: number; +} + +export interface KolAssistTask { + id: string; + status: "queued" | "running" | "completed" | "failed"; + result?: { content: string; schema: KolSchemaJson }; + error_message?: string; +} + +export interface KolDashboardOverview { + total_subscriber_tenants: number; + month_new_subscriptions: number; + total_usage: number; + month_usage: number; + failure_count: number; + failure_rate: number; +} + +export interface KolDashboardPackageStat { + package_id: number; + package_name: string; + subscriber_tenants: number; + usage_count: number; + failure_rate: number; +} + +export interface KolDashboardTrendPoint { + date: string; // YYYY-MM-DD + subscriptions: number; + usages: number; +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +cd packages/shared-types && pnpm build +cd ../../apps/admin-web && pnpm vue-tsc --noEmit +``` + +Expected: no errors. + +```bash +git add packages/shared-types/src/index.ts +git commit -m "feat(kol): add KOL TypeScript shared types" +``` + +--- + +# PHASE 2 — KOL Profile + Navigation + +**Deliverable:** Platform admin can create/suspend KOL profiles; tenant users discover whether they're KOL via `/api/auth/me`; sidebar nav shows the "KOL 工作台" group only for active KOLs; "模版市场" visible to everyone. + +## Task 2.1: Platform admin — profile lifecycle API + +**Files:** +- Create: `server/internal/platform/app/kol_profile_service.go` +- Create: `server/internal/platform/transport/kol_admin_handler.go` +- Modify: `server/internal/platform/transport/router.go` + +- [ ] **Step 1: Platform service — create/suspend/activate KOL profile** + +Create `server/internal/platform/app/kol_profile_service.go` with methods: + +```go +type KolProfileAdminService struct { + pool *pgxpool.Pool +} + +func (s *KolProfileAdminService) CreateProfile(ctx context.Context, input CreateProfileInput) (*KolProfile, error) { + // INSERT INTO kol_profiles (tenant_id, user_id, display_name, status) VALUES (..., 'active') + // Enforce unique active profile per user_id (DB unique index already ensures this) +} + +func (s *KolProfileAdminService) SuspendProfile(ctx context.Context, id int64, reason string, operatorID int64) error { + // UPDATE kol_profiles SET status='suspended', updated_at=NOW() WHERE id=$1 + // Also write audit log +} + +func (s *KolProfileAdminService) ActivateProfile(ctx context.Context, id int64, operatorID int64) error { + // UPDATE kol_profiles SET status='active', updated_at=NOW() WHERE id=$1 + // Also write audit log +} +``` + +- [ ] **Step 2: Platform handler** + +Create `server/internal/platform/transport/kol_admin_handler.go`: + +```go +type KolAdminHandler struct { + profileSvc *app.KolProfileAdminService + subSvc *app.KolSubscriptionAdminService // added in Phase 5 +} + +func (h *KolAdminHandler) CreateProfile(c *gin.Context) { + var req struct { + TenantID int64 `json:"tenant_id" binding:"required"` + UserID int64 `json:"user_id" binding:"required"` + DisplayName string `json:"display_name" binding:"required,max=100"` + Bio string `json:"bio"` + AvatarURL string `json:"avatar_url"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, 400, err.Error()) + return + } + actor := auth.FromCtx(c) + prof, err := h.profileSvc.CreateProfile(c.Request.Context(), app.CreateProfileInput{ + TenantID: req.TenantID, + UserID: req.UserID, + DisplayName: req.DisplayName, + Bio: req.Bio, + AvatarURL: req.AvatarURL, + OperatorID: actor.UserID, + }) + if err != nil { + response.Error(c, 500, err.Error()) + return + } + response.OK(c, prof) +} + +// Suspend + Activate follow same pattern +``` + +- [ ] **Step 3: Register platform routes** + +In `server/internal/platform/transport/router.go`, add inside the protected group: + +```go +kolAdmin := protected.Group("/platform/admin/kol") +kolAdminHandler := NewKolAdminHandler(app) +kolAdmin.POST("/profiles", kolAdminHandler.CreateProfile) +kolAdmin.PUT("/profiles/:id/suspend", kolAdminHandler.SuspendProfile) +kolAdmin.PUT("/profiles/:id/activate", kolAdminHandler.ActivateProfile) +``` + +- [ ] **Step 4: Test via curl** + +```bash +# Assuming platform admin token in $TOKEN +curl -X POST http://localhost:8080/api/platform/admin/kol/profiles \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"tenant_id":1,"user_id":5,"display_name":"测试 KOL","bio":"家具行业专家"}' +``` + +Expected: HTTP 200 with profile JSON. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/platform/app/kol_profile_service.go +git add server/internal/platform/transport/kol_admin_handler.go +git add server/internal/platform/transport/router.go +git commit -m "feat(kol): platform admin API for KOL profile lifecycle" +``` + +## Task 2.2: Tenant API — expose KOL profile to current user + +**Files:** +- Modify: `server/internal/tenant/transport/auth_handler.go` (add kol_profile to /api/auth/me response) +- Modify: `server/internal/tenant/app/auth_service.go` (fetch profile in Me()) +- Modify: `packages/shared-types/src/index.ts` (add `kol_profile` to `UserInfo`) + +- [ ] **Step 1: Extend UserInfo type** + +In `packages/shared-types/src/index.ts`, find the `UserInfo` interface and add: + +```typescript +export interface UserInfo { + id: number; + email: string; + name: string; + avatar_url: string | null; + tenant_id: number; + tenant_role: string; + permissions: string[]; + kol_profile: KolProfileBrief | null; // NEW +} + +export interface KolProfileBrief { + id: number; + display_name: string; + status: "active" | "suspended" | "closed"; + market_enabled: boolean; +} +``` + +- [ ] **Step 2: Update Me() service** + +In `server/internal/tenant/app/auth_service.go`, modify the `Me` method to fetch the profile and include in response. Use the `KolProfileRepository` from Task 1.8. + +```go +type MeResponse struct { + // existing fields... + KolProfile *KolProfileBrief `json:"kol_profile"` +} + +func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*MeResponse, error) { + // existing user fetch... + prof, err := s.kolProfileRepo.GetByUser(ctx, actor.TenantID, actor.UserID) + if err != nil { return nil, err } + var brief *KolProfileBrief + if prof != nil && prof.Status == "active" { + brief = &KolProfileBrief{ + ID: prof.ID, DisplayName: prof.DisplayName, + Status: prof.Status, MarketEnabled: prof.MarketEnabled, + } + } + return &MeResponse{ /* existing fields */, KolProfile: brief }, nil +} +``` + +- [ ] **Step 3: Verify** + +```bash +curl -H "Authorization: Bearer $TENANT_TOKEN" http://localhost:8080/api/auth/me | jq '.data.kol_profile' +``` + +Expected: `null` for non-KOL users, KOL brief for KOL users. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/tenant/app/auth_service.go +git add server/internal/tenant/transport/auth_handler.go +git add packages/shared-types/src/index.ts +git commit -m "feat(kol): expose kol_profile on /api/auth/me" +``` + +## Task 2.3: Pinia auth store — expose kolProfile getter + +**Files:** +- Modify: `apps/admin-web/src/stores/auth.ts` + +- [ ] **Step 1: Add getter** + +```typescript +export const useAuthStore = defineStore("auth", { + state: () => ({ + // existing state + user: null as UserInfo | null, + }), + getters: { + // existing getters + kolProfile: (state): KolProfileBrief | null => state.user?.kol_profile ?? null, + isActiveKol: (state): boolean => + state.user?.kol_profile?.status === "active", + }, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/admin-web/src/stores/auth.ts +git commit -m "feat(kol): expose kolProfile and isActiveKol getters" +``` + +## Task 2.4: Sidebar navigation — add KOL groups + +**Files:** +- Modify: `apps/admin-web/src/layouts/AppShell.vue` +- Modify: `apps/admin-web/src/i18n/messages/zh-CN.ts` +- Modify: `apps/admin-web/src/i18n/messages/en-US.ts` + +- [ ] **Step 1: Add i18n keys (zh-CN)** + +In `zh-CN.ts` under the existing `nav` block, add: + +```typescript +nav: { + // existing keys... + kolMarket: "KOL 市场", + kolMarketplace: "模版市场", + kolWorkspace: "KOL 工作台", + kolManage: "提示词管理", + kolDashboard: "数据看板", +}, +``` + +Plus the new top-level `kol` namespace: + +```typescript +kol: { + marketplace: { + title: "KOL 模版市场", + filter: { industry: "行业", keyword: "关键字", all: "全部" }, + empty: "暂无已上架的订阅包", + subscribers: "{count} 个订阅租户", + prompts: "{count} 个 Prompt", + }, + package: { + subscribe: "申请订阅", + pending: "审核中", + subscribed: "已订阅", + revoked: "已撤销", + expired: "已到期", + notSubscribed: "未订阅", + }, + generate: { + title: "生成文章", + submit: "生成", + fillVariables: "请填写所有必填变量", + }, + manage: { + title: "KOL 提示词管理", + createPackage: "创建订阅包", + editPackage: "编辑包", + publishPackage: "发布", + archivePackage: "归档", + createPrompt: "新增 Prompt", + editor: { + saveDraft: "保存草稿", + publish: "发布", + preview: "预览表单", + aiGenerate: "AI 生成", + aiOptimize: "AI 优化", + aiPlaceholder: "描述你想要的 prompt 效果,AI 帮你生成 / 优化...", + }, + variable: { + input: "输入框", + textarea: "多行文本", + select: "选择框", + number: "数字框", + checkbox: "多选框", + key: "变量 key", + label: "显示名", + required: "必填", + placeholder: "提示语", + options: "选项(逗号分隔)", + }, + }, + dashboard: { + title: "KOL 数据看板", + totalSubs: "总订阅租户数", + monthSubs: "本月新增", + totalUsage: "总使用次数", + monthUsage: "本月使用", + failRate: "失败率", + byPackage: "按包维度", + trend: "趋势", + }, +}, +``` + +And in `status.sourceType`: + +```typescript +sourceType: { + // existing + kol: "KOL 生成", +}, +``` + +- [ ] **Step 2: Add i18n keys (en-US)** + +Mirror the above in `en-US.ts` with English strings. + +- [ ] **Step 3: Extend navSections in AppShell.vue** + +Import the auth store and compute nav sections reactively: + +```typescript +import { useAuthStore } from "@/stores/auth"; + +const authStore = useAuthStore(); + +const navSections = computed(() => { + const base = [ + // ... existing sections + { + key: "kolMarket", + title: t("nav.kolMarket"), + items: [ + { key: "/kol/marketplace", label: t("nav.kolMarketplace") }, + ], + }, + ]; + if (authStore.isActiveKol) { + base.push({ + key: "kolWorkspace", + title: t("nav.kolWorkspace"), + items: [ + { key: "/kol/manage", label: t("nav.kolManage") }, + { key: "/kol/dashboard", label: t("nav.kolDashboard") }, + ], + }); + } + return base; +}); +``` + +- [ ] **Step 4: Manual verify** + +Sign in as a non-KOL — only "模版市场" appears. +Sign in as an active KOL — both "模版市场" and "KOL 工作台" (with two children) appear. + +- [ ] **Step 5: Commit** + +```bash +git add apps/admin-web/src/i18n/messages apps/admin-web/src/layouts/AppShell.vue +git commit -m "feat(kol): sidebar nav groups for KOL marketplace + workspace" +``` + +--- + +# PHASE 3 — KOL Workspace Backend + +**Deliverable:** Active KOL can CRUD packages and prompts, edit drafts with sealed asset storage, publish revisions. No marketplace or generation yet. + +## Task 3.1: Protected prompt asset adapter + +**Files:** +- Create: `server/internal/tenant/app/kol_prompt_asset.go` + +- [ ] **Step 1: Implement asset storage wrapper** + +Follow `objectstorage` patterns. Keys look like `kol-prompts/{tenantID}/{promptID}/{revisionNo}.txt`. + +```go +package app + +import ( + "context" + "fmt" + + "github.com/geo-platform/tenant-api/internal/shared/objectstorage" +) + +type KolPromptAsset struct { + storage objectstorage.Client +} + +func NewKolPromptAsset(s objectstorage.Client) *KolPromptAsset { + return &KolPromptAsset{storage: s} +} + +func (a *KolPromptAsset) assetKey(tenantID, promptID int64, revisionNo int) string { + return fmt.Sprintf("kol-prompts/%d/%d/%d.txt", tenantID, promptID, revisionNo) +} + +// Put stores the raw prompt content under a sealed key. +func (a *KolPromptAsset) Put(ctx context.Context, tenantID, promptID int64, revisionNo int, content string) (string, error) { + key := a.assetKey(tenantID, promptID, revisionNo) + if err := a.storage.PutObject(ctx, key, []byte(content), "text/plain"); err != nil { + return "", err + } + return key, nil +} + +// Get loads the raw prompt content. ONLY call from server-side code +// (worker or KOL management API). +func (a *KolPromptAsset) Get(ctx context.Context, assetKey string) (string, error) { + b, err := a.storage.GetObject(ctx, assetKey) + if err != nil { + return "", err + } + return string(b), nil +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/internal/tenant/app/kol_prompt_asset.go +git commit -m "feat(kol): protected prompt asset adapter" +``` + +## Task 3.2: Package service (tenant-api manage) + +**Files:** +- Create: `server/internal/tenant/app/kol_package_service.go` +- Create: `server/internal/tenant/app/kol_profile_service.go` + +- [ ] **Step 1: Implement KolProfileService (tenant-side read)** + +```go +type KolProfileService struct { + repo repository.KolProfileRepository +} + +func (s *KolProfileService) RequireActiveKol(ctx context.Context, actor auth.Actor) (*repository.KolProfile, error) { + p, err := s.repo.GetByUser(ctx, actor.TenantID, actor.UserID) + if err != nil { return nil, err } + if p == nil || p.Status != "active" { + return nil, ErrNotActiveKol + } + return p, nil +} + +var ErrNotActiveKol = errors.New("user is not an active KOL") +``` + +- [ ] **Step 2: Implement KolPackageService** + +Methods: `List`, `Get`, `Create`, `Update`, `Publish`, `Archive`, `Delete` — all scoped to the current KOL's `profile_id`. + +```go +type KolPackageService struct { + profileSvc *KolProfileService + repo repository.KolPackageRepository +} + +func (s *KolPackageService) List(ctx context.Context, actor auth.Actor) ([]repository.KolPackage, error) { + prof, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { return nil, err } + return s.repo.ListByProfile(ctx, actor.TenantID, prof.ID) +} + +func (s *KolPackageService) Create(ctx context.Context, actor auth.Actor, input CreatePackageInput) (*repository.KolPackage, error) { + prof, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { return nil, err } + return s.repo.Create(ctx, nil, repository.CreateKolPackageInput{ + TenantID: actor.TenantID, + KolProfileID: prof.ID, + Name: input.Name, + Description: input.Description, + CoverURL: input.CoverURL, + Industry: input.Industry, + Tags: input.Tags, + PriceNote: input.PriceNote, + }) +} + +// Publish transitions status to 'published'; Archive to 'archived'. Both write audit log. +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/tenant/app/kol_profile_service.go +git add server/internal/tenant/app/kol_package_service.go +git commit -m "feat(kol): package service for KOL workspace" +``` + +## Task 3.3: Prompt + revision service + +**Files:** +- Create: `server/internal/tenant/app/kol_prompt_service.go` +- Create: `server/internal/tenant/app/kol_variable_renderer.go` + +- [ ] **Step 1: Implement variable renderer (pure functions)** + +`kol_variable_renderer.go`: + +```go +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "regexp" + "strings" + + sharedtypes "github.com/geo-platform/tenant-api/internal/tenant/domain" +) + +var placeholderRE = regexp.MustCompile(`\{\{\s*([^}\s]+)\s*\}\}`) + +// RenderPrompt replaces {{var_id}} placeholders with values from variables. +func RenderPrompt(content string, schema sharedtypes.KolSchemaJSON, values map[string]any) (string, error) { + byID := make(map[string]sharedtypes.KolVariable, len(schema.Variables)) + for _, v := range schema.Variables { + byID[v.ID] = v + } + var missing []string + rendered := placeholderRE.ReplaceAllStringFunc(content, func(m string) string { + sub := placeholderRE.FindStringSubmatch(m) + id := sub[1] + v, ok := byID[id] + if !ok { + missing = append(missing, id) + return m + } + if val, has := values[id]; has { + return fmt.Sprintf("%v", val) + } + if v.Required { + missing = append(missing, v.ID) + } + return "" + }) + if len(missing) > 0 { + return "", fmt.Errorf("missing variables: %s", strings.Join(missing, ", ")) + } + return rendered, nil +} + +// HashPrompt returns a stable hash for idempotency. +func HashPrompt(rendered string) string { + sum := sha256.Sum256([]byte(rendered)) + return hex.EncodeToString(sum[:]) +} + +// ValidateSchema ensures variable ids are unique and types are allowed. +func ValidateSchema(s sharedtypes.KolSchemaJSON) error { + seen := map[string]struct{}{} + for _, v := range s.Variables { + if v.ID == "" || !strings.HasPrefix(v.ID, "var_") { + return fmt.Errorf("variable id must be non-empty and start with var_") + } + if _, dup := seen[v.ID]; dup { + return fmt.Errorf("duplicate variable id: %s", v.ID) + } + seen[v.ID] = struct{}{} + switch v.Type { + case "input", "textarea", "select", "number", "checkbox": + default: + return fmt.Errorf("unsupported variable type: %s", v.Type) + } + } + return nil +} + +// JSONEncodeSchema helper for sqlc arg passing. +func JSONEncodeSchema(s sharedtypes.KolSchemaJSON) ([]byte, error) { + return json.Marshal(s) +} +``` + +- [ ] **Step 2: Write unit tests** + +Create `server/internal/tenant/app/kol_variable_renderer_test.go`: + +```go +package app + +import ( + "testing" + "github.com/stretchr/testify/require" + d "github.com/geo-platform/tenant-api/internal/tenant/domain" +) + +func TestRenderPrompt_Success(t *testing.T) { + schema := d.KolSchemaJSON{Variables: []d.KolVariable{ + {ID: "var_a", Type: "input", Required: true}, + {ID: "var_b", Type: "select"}, + }} + out, err := RenderPrompt("Hello {{var_a}} from {{var_b}}", schema, map[string]any{ + "var_a": "宜家", "var_b": "家具", + }) + require.NoError(t, err) + require.Equal(t, "Hello 宜家 from 家具", out) +} + +func TestRenderPrompt_MissingRequired(t *testing.T) { + schema := d.KolSchemaJSON{Variables: []d.KolVariable{ + {ID: "var_a", Type: "input", Required: true}, + }} + _, err := RenderPrompt("{{var_a}}", schema, map[string]any{}) + require.Error(t, err) + require.Contains(t, err.Error(), "missing variables") +} + +func TestHashPrompt_Stable(t *testing.T) { + require.Equal(t, HashPrompt("abc"), HashPrompt("abc")) + require.NotEqual(t, HashPrompt("abc"), HashPrompt("abd")) +} + +func TestValidateSchema_DuplicateID(t *testing.T) { + err := ValidateSchema(d.KolSchemaJSON{Variables: []d.KolVariable{ + {ID: "var_a", Type: "input"}, + {ID: "var_a", Type: "input"}, + }}) + require.Error(t, err) +} +``` + +Run: `cd server && go test ./internal/tenant/app/ -run KolVariable -v` — expected PASS. + +- [ ] **Step 3: Implement KolPromptService** + +```go +type KolPromptService struct { + profileSvc *KolProfileService + pkgRepo repository.KolPackageRepository + promptRepo repository.KolPromptRepository + asset *KolPromptAsset + pool *pgxpool.Pool +} + +type CreatePromptInput struct { + PackageID int64 + Name string + PlatformHint string +} + +func (s *KolPromptService) CreatePrompt(ctx context.Context, actor auth.Actor, input CreatePromptInput) (*repository.KolPrompt, error) { + prof, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { return nil, err } + pkg, err := s.pkgRepo.GetByID(ctx, actor.TenantID, input.PackageID) + if err != nil { return nil, err } + if pkg == nil || pkg.KolProfileID != prof.ID { + return nil, ErrPackageNotOwned + } + return s.promptRepo.Create(ctx, nil, repository.CreateKolPromptInput{ + TenantID: actor.TenantID, PackageID: input.PackageID, + Name: input.Name, PlatformHint: input.PlatformHint, + CreatedBy: actor.UserID, + }) +} + +type SaveDraftInput struct { + PromptID int64 + Content string + Schema d.KolSchemaJSON + CardConfig map[string]any +} + +// SaveDraft uploads content to asset storage and creates a new revision in a single transaction. +func (s *KolPromptService) SaveDraft(ctx context.Context, actor auth.Actor, input SaveDraftInput) (*repository.KolPromptRevision, error) { + if err := ValidateSchema(input.Schema); err != nil { return nil, err } + prof, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { return nil, err } + prompt, err := s.promptRepo.GetByID(ctx, actor.TenantID, input.PromptID) + if err != nil || prompt == nil { + return nil, ErrPromptNotFound + } + // Verify package ownership + pkg, err := s.pkgRepo.GetByID(ctx, actor.TenantID, prompt.PackageID) + if err != nil { return nil, err } + if pkg.KolProfileID != prof.ID { return nil, ErrPromptNotOwned } + + // Next revision_no + next, err := s.promptRepo.NextRevisionNo(ctx, actor.TenantID, input.PromptID) + if err != nil { return nil, err } + + // Upload content to asset storage + assetKey, err := s.asset.Put(ctx, actor.TenantID, input.PromptID, next, input.Content) + if err != nil { return nil, err } + + // Insert revision + update prompt.draft_revision_no in a single tx + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { return nil, err } + defer tx.Rollback(ctx) + rev, err := s.promptRepo.CreateRevision(ctx, tx, repository.CreateRevisionInput{ + TenantID: actor.TenantID, PromptID: input.PromptID, RevisionNo: next, + PromptAssetKey: assetKey, Schema: input.Schema, CardConfig: input.CardConfig, + CreatedBy: actor.UserID, + }) + if err != nil { return nil, err } + if err := s.promptRepo.SetDraftRevision(ctx, tx, actor.TenantID, input.PromptID, next, actor.UserID); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { return nil, err } + return rev, nil +} + +// Publish switches published_revision_no to current draft and sets prompt.status='active'. +func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, promptID int64) error { + prof, err := s.profileSvc.RequireActiveKol(ctx, actor) + if err != nil { return err } + prompt, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID) + if err != nil { return err } + pkg, err := s.pkgRepo.GetByID(ctx, actor.TenantID, prompt.PackageID) + if err != nil { return err } + if pkg.KolProfileID != prof.ID { return ErrPromptNotOwned } + if prompt.DraftRevisionNo == nil { return errors.New("no draft to publish") } + + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { return err } + defer tx.Rollback(ctx) + if err := s.promptRepo.SetPublishedRevision(ctx, tx, actor.TenantID, promptID, *prompt.DraftRevisionNo, actor.UserID); err != nil { + return err + } + // TODO Phase 5: fan out new prompt to active subscriptions + if err := tx.Commit(ctx); err != nil { return err } + return nil +} + +var ( + ErrPackageNotOwned = errors.New("package not owned by current KOL") + ErrPromptNotFound = errors.New("prompt not found") + ErrPromptNotOwned = errors.New("prompt not owned by current KOL") +) +``` + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/tenant/app/kol_prompt_service.go +git add server/internal/tenant/app/kol_variable_renderer.go +git add server/internal/tenant/app/kol_variable_renderer_test.go +git commit -m "feat(kol): prompt + revision service with sealed asset storage" +``` + +## Task 3.4: Management HTTP handlers + route registration + +**Files:** +- Create: `server/internal/tenant/transport/kol_manage_handler.go` +- Modify: `server/internal/tenant/transport/router.go` + +- [ ] **Step 1: Implement handler** + +```go +package transport + +type KolManageHandler struct { + profileSvc *app.KolProfileService + pkgSvc *app.KolPackageService + promptSvc *app.KolPromptService +} + +func (h *KolManageHandler) GetProfile(c *gin.Context) { /* return auth store.kol_profile */ } + +func (h *KolManageHandler) ListPackages(c *gin.Context) { + actor := auth.FromCtx(c) + pkgs, err := h.pkgSvc.List(c.Request.Context(), actor) + if err != nil { response.Error(c, 403, err.Error()); return } + response.OK(c, pkgs) +} + +func (h *KolManageHandler) CreatePackage(c *gin.Context) { + var req dto.CreatePackageRequest + if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, 400, err.Error()); return } + actor := auth.FromCtx(c) + out, err := h.pkgSvc.Create(c.Request.Context(), actor, app.CreatePackageInput{ + Name: req.Name, Description: req.Description, CoverURL: req.CoverURL, + Industry: req.Industry, Tags: req.Tags, PriceNote: req.PriceNote, + }) + if err != nil { response.Error(c, 400, err.Error()); return } + response.OK(c, out) +} + +// UpdatePackage, PublishPackage, ArchivePackage, DeletePackage follow similarly. + +func (h *KolManageHandler) ListPrompts(c *gin.Context) { + packageID := parsePathInt(c, "id") + actor := auth.FromCtx(c) + out, err := h.promptSvc.ListByPackage(c.Request.Context(), actor, packageID) + if err != nil { response.Error(c, 403, err.Error()); return } + response.OK(c, out) +} + +func (h *KolManageHandler) CreatePrompt(c *gin.Context) { + packageID := parsePathInt(c, "id") + var req dto.CreatePromptRequest + if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, 400, err.Error()); return } + actor := auth.FromCtx(c) + out, err := h.promptSvc.CreatePrompt(c.Request.Context(), actor, app.CreatePromptInput{ + PackageID: packageID, Name: req.Name, PlatformHint: req.PlatformHint, + }) + if err != nil { response.Error(c, 400, err.Error()); return } + response.OK(c, out) +} + +func (h *KolManageHandler) GetPromptLatest(c *gin.Context) { + // Returns prompt detail INCLUDING latest revision's prompt content and schema. + // ONLY authorized for the KOL who owns the prompt. + promptID := parsePathInt(c, "id") + actor := auth.FromCtx(c) + out, err := h.promptSvc.GetForOwner(c.Request.Context(), actor, promptID) + if err != nil { response.Error(c, 403, err.Error()); return } + response.OK(c, out) +} + +func (h *KolManageHandler) SaveDraft(c *gin.Context) { + promptID := parsePathInt(c, "id") + var req dto.SaveDraftRequest + if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, 400, err.Error()); return } + actor := auth.FromCtx(c) + rev, err := h.promptSvc.SaveDraft(c.Request.Context(), actor, app.SaveDraftInput{ + PromptID: promptID, Content: req.Content, Schema: req.Schema, CardConfig: req.CardConfig, + }) + if err != nil { response.Error(c, 400, err.Error()); return } + response.OK(c, rev) +} + +func (h *KolManageHandler) PublishPrompt(c *gin.Context) { + promptID := parsePathInt(c, "id") + actor := auth.FromCtx(c) + if err := h.promptSvc.Publish(c.Request.Context(), actor, promptID); err != nil { + response.Error(c, 400, err.Error()); return + } + response.OK(c, gin.H{"ok": true}) +} + +// DeletePrompt, UpdatePromptMetadata similarly. +``` + +- [ ] **Step 2: Register routes** + +In `router.go`, after the existing protected group blocks: + +```go +kolManage := protected.Group("/tenant/kol/manage") +kolManageHandler := NewKolManageHandler(app) +kolManage.GET("/profile", kolManageHandler.GetProfile) +kolManage.GET("/packages", kolManageHandler.ListPackages) +kolManage.POST("/packages", kolManageHandler.CreatePackage) +kolManage.PUT("/packages/:id", kolManageHandler.UpdatePackage) +kolManage.DELETE("/packages/:id", kolManageHandler.DeletePackage) +kolManage.PUT("/packages/:id/publish", kolManageHandler.PublishPackage) +kolManage.PUT("/packages/:id/archive", kolManageHandler.ArchivePackage) +kolManage.GET("/packages/:id/prompts", kolManageHandler.ListPrompts) +kolManage.POST("/packages/:id/prompts", kolManageHandler.CreatePrompt) +kolManage.GET("/prompts/:id", kolManageHandler.GetPromptLatest) +kolManage.PUT("/prompts/:id", kolManageHandler.UpdatePromptMetadata) +kolManage.DELETE("/prompts/:id", kolManageHandler.DeletePrompt) +kolManage.POST("/prompts/:id/draft", kolManageHandler.SaveDraft) +kolManage.POST("/prompts/:id/publish", kolManageHandler.PublishPrompt) +``` + +- [ ] **Step 3: Smoke test with curl** + +```bash +# Create a package +curl -X POST http://localhost:8080/api/tenant/kol/manage/packages \ + -H "Authorization: Bearer $KOL_TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"家具精调包","description":"...","industry":"家具","tags":["精调"],"price_note":"月费 199"}' + +# Create a prompt +curl -X POST http://localhost:8080/api/tenant/kol/manage/packages/1/prompts \ + -H "Authorization: Bearer $KOL_TOKEN" -H "Content-Type: application/json" \ + -d '{"name":"豆包版","platform_hint":"doubao"}' + +# Save draft +curl -X POST http://localhost:8080/api/tenant/kol/manage/prompts/1/draft \ + -H "Authorization: Bearer $KOL_TOKEN" -H "Content-Type: application/json" \ + -d '{"content":"写一篇关于 {{var_abc}} 的文章","schema":{"variables":[{"id":"var_abc","key":"公司名","label":"公司名","type":"input","required":true}]},"card_config":{}}' + +# Publish +curl -X POST http://localhost:8080/api/tenant/kol/manage/prompts/1/publish -H "Authorization: Bearer $KOL_TOKEN" +``` + +Expected: all 200 OK, database rows correctly populated. + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/tenant/transport/kol_manage_handler.go +git add server/internal/tenant/transport/router.go +git commit -m "feat(kol): KOL workspace management API endpoints" +``` + +--- + +# PHASE 4 — KOL Workspace Frontend + +**Deliverable:** Active KOL can use the `/kol/manage` UI to create packages, add prompts, edit the prompt body via the three-pane editor (variable drag-drop, AI assist placeholder, preview), and publish. + +## Task 4.1: API client + router entries + +**Files:** +- Modify: `apps/admin-web/src/lib/api.ts` +- Modify: `apps/admin-web/src/router/index.ts` + +- [ ] **Step 1: Add `kolManageApi`** + +```typescript +export const kolManageApi = { + profile() { + return apiClient.get("/api/tenant/kol/manage/profile"); + }, + listPackages() { + return apiClient.get("/api/tenant/kol/manage/packages"); + }, + createPackage(body: CreateKolPackageRequest) { + return apiClient.post("/api/tenant/kol/manage/packages", body); + }, + updatePackage(id: number, body: UpdateKolPackageRequest) { + return apiClient.put(`/api/tenant/kol/manage/packages/${id}`, body); + }, + publishPackage(id: number) { + return apiClient.put(`/api/tenant/kol/manage/packages/${id}/publish`); + }, + archivePackage(id: number) { + return apiClient.put(`/api/tenant/kol/manage/packages/${id}/archive`); + }, + deletePackage(id: number) { + return apiClient.delete(`/api/tenant/kol/manage/packages/${id}`); + }, + listPrompts(packageId: number) { + return apiClient.get(`/api/tenant/kol/manage/packages/${packageId}/prompts`); + }, + createPrompt(packageId: number, body: CreateKolPromptRequest) { + return apiClient.post(`/api/tenant/kol/manage/packages/${packageId}/prompts`, body); + }, + getPrompt(id: number) { + return apiClient.get(`/api/tenant/kol/manage/prompts/${id}`); + }, + updatePromptMetadata(id: number, body: UpdateKolPromptRequest) { + return apiClient.put(`/api/tenant/kol/manage/prompts/${id}`, body); + }, + deletePrompt(id: number) { + return apiClient.delete(`/api/tenant/kol/manage/prompts/${id}`); + }, + saveDraft(promptId: number, body: KolSaveDraftRequest) { + return apiClient.post(`/api/tenant/kol/manage/prompts/${promptId}/draft`, body); + }, + publishPrompt(promptId: number) { + return apiClient.post(`/api/tenant/kol/manage/prompts/${promptId}/publish`); + }, +}; +``` + +Add corresponding request types to `packages/shared-types/src/index.ts`: + +```typescript +export interface CreateKolPackageRequest { name: string; description?: string; cover_url?: string; industry?: string; tags: string[]; price_note?: string; } +export interface UpdateKolPackageRequest extends CreateKolPackageRequest {} +export interface CreateKolPromptRequest { name: string; platform_hint?: string; } +export interface UpdateKolPromptRequest { name: string; platform_hint?: string; sort_order?: number; } +export interface KolSaveDraftRequest { content: string; schema: KolSchemaJson; card_config: Record; } +``` + +- [ ] **Step 2: Add router entries + auth-store guard** + +In `router/index.ts`, add: + +```typescript +{ + path: "kol/manage", + name: "kol-manage", + component: () => import("@/views/KolManageView.vue"), + meta: { titleKey: "kol.manage.title", navKey: "/kol/manage", requiresKol: true }, +}, +{ + path: "kol/dashboard", + name: "kol-dashboard", + component: () => import("@/views/KolDashboardView.vue"), + meta: { titleKey: "kol.dashboard.title", navKey: "/kol/dashboard", requiresKol: true }, +}, +{ + path: "kol/marketplace", + name: "kol-marketplace", + component: () => import("@/views/KolMarketplaceView.vue"), + meta: { titleKey: "kol.marketplace.title", navKey: "/kol/marketplace" }, +}, +{ + path: "kol/packages/:id", + name: "kol-package-detail", + component: () => import("@/views/KolPackageDetailView.vue"), + meta: { titleKey: "kol.marketplace.title", navKey: "/kol/marketplace" }, +}, +{ + path: "kol/generate/:subscriptionPromptId", + name: "kol-generate", + component: () => import("@/views/KolGenerateView.vue"), + meta: { titleKey: "kol.generate.title", navKey: "/kol/marketplace" }, +}, +``` + +Extend `router.beforeEach`: + +```typescript +if (to.meta.requiresKol && !authStore.isActiveKol) { + return { name: "workspace" }; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin-web/src/lib/api.ts apps/admin-web/src/router/index.ts packages/shared-types/src/index.ts +git commit -m "feat(kol): add kolManageApi and KOL route entries" +``` + +## Task 4.2: Variable id helper + +**Files:** +- Create: `apps/admin-web/src/lib/kol-variable-id.ts` + +- [ ] **Step 1: Implement** + +```typescript +import { nanoid } from "nanoid"; + +export function newVariableId(): string { + return `var_${nanoid(8)}`; +} +``` + +Install `nanoid` if missing: + +```bash +cd apps/admin-web && pnpm add nanoid +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/admin-web/src/lib/kol-variable-id.ts apps/admin-web/package.json apps/admin-web/pnpm-lock.yaml +git commit -m "feat(kol): variable id generator" +``` + +## Task 4.3: KolVariablePanel + KolVariableConfig components + +**Files:** +- Create: `apps/admin-web/src/components/kol/KolVariablePanel.vue` +- Create: `apps/admin-web/src/components/kol/KolVariableConfig.vue` + +- [ ] **Step 1: KolVariablePanel.vue (left pane)** + +Draggable list of 5 component types. Emits `add-variable` with base config when the user drops/clicks. Uses native HTML5 drag events (`draggable="true"`, `@dragstart`). + +```vue + + + + + +``` + +- [ ] **Step 2: KolVariableConfig.vue (right pane)** + +Props: `variables: KolVariableDefinition[]`. Emits `update:variables`. Each card has inline edit for label, key, required, placeholder, options. + +```vue + + + +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/admin-web/src/components/kol/KolVariablePanel.vue +git add apps/admin-web/src/components/kol/KolVariableConfig.vue +git commit -m "feat(kol): variable panel + variable config components" +``` + +## Task 4.4: KolPromptEditArea (center pane with AI bar + textarea) + +**Files:** +- Create: `apps/admin-web/src/components/kol/KolPromptEditArea.vue` + +- [ ] **Step 1: Implement editor** + +Uses a `textarea` (simple first implementation — rich token rendering can follow). Handles drop events to insert `{{key}}` at cursor position and emits variable additions via `v-model:variables`. AI bar wired but points to a disabled button (AI task backing implemented in Phase 4.6). + +```vue + + +