# 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