Files
geo/docs/superpowers/plans/2026-04-17-kol-feature.md
T
root 3e1b68f771 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) <noreply@anthropic.com>
2026-04-17 07:46:29 +08:00

135 KiB
Raw Blame History

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:

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:

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
cd server && go run ./cmd/migrate up

Expected: all tables created, migration logged.

  • Step 4: Verify rollback works
cd server && go run ./cmd/migrate down 1 && go run ./cmd/migrate up

Expected: clean rollback + re-apply.

  • Step 5: Commit
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

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
DROP INDEX IF EXISTS idx_article_kol_prompt;
ALTER TABLE articles DROP COLUMN IF EXISTS kol_prompt_id;
  • Step 3: Run migration + commit
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

-- 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
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

-- 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
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

-- 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
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

-- 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
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

-- 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
-- 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)
-- 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:

    if [[ "$basename" == "audit.sql" ]] || [[ "$basename" == "kol_marketplace.sql" ]]; then
        continue
    fi
  • Step 5: Regenerate sqlc + verify scope check + commit
cd server/internal/tenant/repository && sqlc generate
cd ../../.. && bash scripts/check_tenant_scope.sh

Expected: "All query files contain tenant_id filters."

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.

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:

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: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:

cd server && TEST_DATABASE_URL=$TEST_DATABASE_URL go test -tags=integration ./internal/tenant/repository/ -run TestKolProfile -v

Expected: PASS.

  • Step 4: Commit repos
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

// ---------- 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<string, unknown> | 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<string, unknown>;
}

export interface KolGenerateRequest {
  variables: Record<string, string | number | boolean | string[]>;
}

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
cd packages/shared-types && pnpm build
cd ../../apps/admin-web && pnpm vue-tsc --noEmit

Expected: no errors.

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:

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:

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:

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
# 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
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:

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.

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
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
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

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
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:

nav: {
  // existing keys...
  kolMarket: "KOL 市场",
  kolMarketplace: "模版市场",
  kolWorkspace: "KOL 工作台",
  kolManage: "提示词管理",
  kolDashboard: "数据看板",
},

Plus the new top-level kol namespace:

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:

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:

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
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.

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
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)

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.

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
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:

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:

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
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
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

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:

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
# 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
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

export const kolManageApi = {
  profile() {
    return apiClient.get<KolProfile>("/api/tenant/kol/manage/profile");
  },
  listPackages() {
    return apiClient.get<KolPackageSummary[]>("/api/tenant/kol/manage/packages");
  },
  createPackage(body: CreateKolPackageRequest) {
    return apiClient.post<KolPackageSummary, CreateKolPackageRequest>("/api/tenant/kol/manage/packages", body);
  },
  updatePackage(id: number, body: UpdateKolPackageRequest) {
    return apiClient.put<KolPackageSummary, UpdateKolPackageRequest>(`/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<KolPromptDetail[]>(`/api/tenant/kol/manage/packages/${packageId}/prompts`);
  },
  createPrompt(packageId: number, body: CreateKolPromptRequest) {
    return apiClient.post<KolPromptDetail, CreateKolPromptRequest>(`/api/tenant/kol/manage/packages/${packageId}/prompts`, body);
  },
  getPrompt(id: number) {
    return apiClient.get<KolPromptDetail>(`/api/tenant/kol/manage/prompts/${id}`);
  },
  updatePromptMetadata(id: number, body: UpdateKolPromptRequest) {
    return apiClient.put<KolPromptDetail, UpdateKolPromptRequest>(`/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:

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<string, unknown>; }
  • Step 2: Add router entries + auth-store guard

In router/index.ts, add:

{
  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:

if (to.meta.requiresKol && !authStore.isActiveKol) {
  return { name: "workspace" };
}
  • Step 3: Commit
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

import { nanoid } from "nanoid";

export function newVariableId(): string {
  return `var_${nanoid(8)}`;
}

Install nanoid if missing:

cd apps/admin-web && pnpm add nanoid
  • Step 2: Commit
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).

<script setup lang="ts">
import { useI18n } from "vue-i18n";
import type { KolVariableType } from "@geo/shared-types";

const { t } = useI18n();

const components: { type: KolVariableType; labelKey: string; icon: string }[] = [
  { type: "input",    labelKey: "kol.manage.variable.input",    icon: "Aa" },
  { type: "textarea", labelKey: "kol.manage.variable.textarea", icon: "≡"  },
  { type: "select",   labelKey: "kol.manage.variable.select",   icon: "▼"  },
  { type: "number",   labelKey: "kol.manage.variable.number",   icon: "#"  },
  { type: "checkbox", labelKey: "kol.manage.variable.checkbox", icon: "☑"  },
];

function onDragStart(event: DragEvent, type: KolVariableType) {
  event.dataTransfer?.setData("application/kol-variable-type", type);
}
</script>

<template>
  <aside class="kol-variable-panel">
    <h4>{{ t("kol.manage.variable.input") /* heading */ }}</h4>
    <div
      v-for="c in components" :key="c.type"
      class="kol-variable-item"
      draggable="true"
      @dragstart="onDragStart($event, c.type)"
    >
      <span class="icon">{{ c.icon }}</span>
      <span class="label">{{ t(c.labelKey) }}</span>
    </div>
  </aside>
</template>

<style scoped>
.kol-variable-panel { width: 200px; border-right: 1px solid #f0f0f0; padding: 16px; }
.kol-variable-item  { cursor: grab; padding: 8px; border: 1px solid #e8e8e8; margin-bottom: 8px; border-radius: 6px; display: flex; gap: 8px; }
.kol-variable-item .icon { width: 20px; text-align: center; }
</style>
  • Step 2: KolVariableConfig.vue (right pane)

Props: variables: KolVariableDefinition[]. Emits update:variables. Each card has inline edit for label, key, required, placeholder, options.

<script setup lang="ts">
import type { KolVariableDefinition } from "@geo/shared-types";
import { useI18n } from "vue-i18n";

defineProps<{ variables: KolVariableDefinition[] }>();
const emit = defineEmits<{ "update:variables": [KolVariableDefinition[]] }>();
const { t } = useI18n();

function updateVar(id: string, patch: Partial<KolVariableDefinition>, all: KolVariableDefinition[]) {
  const next = all.map(v => (v.id === id ? { ...v, ...patch } : v));
  emit("update:variables", next);
}

function removeVar(id: string, all: KolVariableDefinition[]) {
  emit("update:variables", all.filter(v => v.id !== id));
}
</script>

<template>
  <aside class="kol-variable-config">
    <h4>已添加的变量</h4>
    <div v-for="v in variables" :key="v.id" class="card">
      <header>
        <input :value="v.label" @input="updateVar(v.id, { label: ($event.target as HTMLInputElement).value, key: ($event.target as HTMLInputElement).value }, variables)" />
        <span class="type">{{ v.type }}</span>
        <button @click="removeVar(v.id, variables)">×</button>
      </header>
      <label>
        <input type="checkbox" :checked="v.required" @change="updateVar(v.id, { required: ($event.target as HTMLInputElement).checked }, variables)" />
        {{ t("kol.manage.variable.required") }}
      </label>
      <input :placeholder="t('kol.manage.variable.placeholder')" :value="v.placeholder" @input="updateVar(v.id, { placeholder: ($event.target as HTMLInputElement).value }, variables)" />
      <input v-if="['select','checkbox'].includes(v.type)" :placeholder="t('kol.manage.variable.options')" :value="(v.options ?? []).join(',')" @input="updateVar(v.id, { options: ($event.target as HTMLInputElement).value.split(',').map(s => s.trim()).filter(Boolean) }, variables)" />
      <code class="var-token">{{ '{{' + v.key + '}}' }}</code>
    </div>
  </aside>
</template>
  • Step 3: Commit
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).

<script setup lang="ts">
import { ref, computed } from "vue";
import { useI18n } from "vue-i18n";
import { newVariableId } from "@/lib/kol-variable-id";
import type { KolVariableDefinition, KolVariableType } from "@geo/shared-types";

const props = defineProps<{
  content: string;
  variables: KolVariableDefinition[];
}>();
const emit = defineEmits<{
  "update:content":    [string];
  "update:variables":  [KolVariableDefinition[]];
  "ai-generate":       [string];
  "ai-optimize":       [];
}>();

const aiInput = ref("");
const textareaRef = ref<HTMLTextAreaElement | null>(null);

function onDrop(event: DragEvent) {
  event.preventDefault();
  const type = event.dataTransfer?.getData("application/kol-variable-type") as KolVariableType | "";
  if (!type) return;
  const label = prompt(`请输入变量显示名(默认中文,如:公司名称)`);
  if (!label) return;
  const variable: KolVariableDefinition = {
    id: newVariableId(),
    key: label,
    label,
    type,
    required: false,
  };
  emit("update:variables", [...props.variables, variable]);
  const ta = textareaRef.value!;
  const pos = ta.selectionStart;
  const placeholder = `{{${variable.key}}}`;
  const next = props.content.slice(0, pos) + placeholder + props.content.slice(pos);
  emit("update:content", next);
}
</script>

<template>
  <section class="kol-edit-area" @dragover.prevent @drop="onDrop">
    <div class="ai-bar">
      <input v-model="aiInput" :placeholder="$t('kol.manage.editor.aiPlaceholder')" />
      <button @click="emit('ai-generate', aiInput)">{{ $t("kol.manage.editor.aiGenerate") }}</button>
      <button @click="emit('ai-optimize')">{{ $t("kol.manage.editor.aiOptimize") }}</button>
    </div>
    <textarea ref="textareaRef" :value="content" @input="emit('update:content', ($event.target as HTMLTextAreaElement).value)" />
  </section>
</template>

<style scoped>
.kol-edit-area { flex: 1; display: flex; flex-direction: column; gap: 12px; padding: 20px; }
.ai-bar { display: flex; gap: 8px; align-items: center; padding: 10px 14px; background: linear-gradient(135deg,#f0f5ff,#f9f0ff); border-radius: 8px; }
.ai-bar input { flex: 1; border: none; background: transparent; outline: none; }
textarea { flex: 1; min-height: 400px; font-family: monospace; padding: 16px; border: 1px solid #e8e8e8; border-radius: 8px; }
</style>
  • Step 2: Commit
git add apps/admin-web/src/components/kol/KolPromptEditArea.vue
git commit -m "feat(kol): prompt edit area with drag-drop placeholder insert"

Task 4.5: KolPromptEditor root + KolManageView

Files:

  • Create: apps/admin-web/src/components/kol/KolPromptEditor.vue

  • Create: apps/admin-web/src/components/kol/KolDynamicForm.vue

  • Create: apps/admin-web/src/components/kol/KolPackageFormModal.vue

  • Create: apps/admin-web/src/components/kol/KolPromptFormModal.vue

  • Create: apps/admin-web/src/views/KolManageView.vue

  • Step 1: KolDynamicForm.vue (reused by preview + user-facing generate page)

<script setup lang="ts">
import type { KolVariableDefinition } from "@geo/shared-types";

defineProps<{
  variables: KolVariableDefinition[];
  modelValue: Record<string, unknown>;
}>();
defineEmits<{ "update:modelValue": [Record<string, unknown>] }>();
</script>

<template>
  <form class="kol-dynamic-form">
    <div v-for="v in variables" :key="v.id" class="field">
      <label>{{ v.label }}<span v-if="v.required"> *</span></label>
      <input v-if="v.type === 'input'"    :value="modelValue[v.id] ?? ''" :placeholder="v.placeholder" @input="$emit('update:modelValue', { ...modelValue, [v.id]: ($event.target as HTMLInputElement).value })" />
      <textarea v-else-if="v.type === 'textarea'" :value="modelValue[v.id] ?? ''" :placeholder="v.placeholder" @input="$emit('update:modelValue', { ...modelValue, [v.id]: ($event.target as HTMLTextAreaElement).value })" />
      <select v-else-if="v.type === 'select'" :value="modelValue[v.id] ?? ''" @change="$emit('update:modelValue', { ...modelValue, [v.id]: ($event.target as HTMLSelectElement).value })">
        <option value="" disabled>{{ v.placeholder }}</option>
        <option v-for="o in v.options" :key="o" :value="o">{{ o }}</option>
      </select>
      <input v-else-if="v.type === 'number'" type="number" :value="modelValue[v.id] ?? ''" @input="$emit('update:modelValue', { ...modelValue, [v.id]: Number(($event.target as HTMLInputElement).value) })" />
      <div v-else-if="v.type === 'checkbox'" class="checkbox-group">
        <label v-for="o in v.options" :key="o">
          <input type="checkbox" :checked="((modelValue[v.id] ?? []) as string[]).includes(o)" @change="
            $emit('update:modelValue', { ...modelValue, [v.id]: (($event.target as HTMLInputElement).checked
              ? [ ...((modelValue[v.id] ?? []) as string[]).filter(x => x !== o), o ]
              : ((modelValue[v.id] ?? []) as string[]).filter(x => x !== o)) })
          " />
          {{ o }}
        </label>
      </div>
    </div>
  </form>
</template>
  • Step 2: KolPromptEditor.vue

Root that wires the three panes and exposes save/publish actions. Props: promptId. Uses TanStack Query for the initial fetch + save mutation.

<script setup lang="ts">
import { computed, ref } from "vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, Modal } from "ant-design-vue";
import { useI18n } from "vue-i18n";
import type { KolVariableDefinition } from "@geo/shared-types";
import { kolManageApi } from "@/lib/api";
import KolVariablePanel from "./KolVariablePanel.vue";
import KolPromptEditArea from "./KolPromptEditArea.vue";
import KolVariableConfig from "./KolVariableConfig.vue";
import KolDynamicForm from "./KolDynamicForm.vue";

const props = defineProps<{ promptId: number }>();
const { t } = useI18n();
const qc = useQueryClient();

const content = ref("");
const variables = ref<KolVariableDefinition[]>([]);

const detailQuery = useQuery({
  queryKey: ["kol-prompt-latest", props.promptId],
  queryFn: () => kolManageApi.getPrompt(props.promptId),
});

// On load, hydrate local state
watchEffect(() => {
  const d = detailQuery.data.value;
  if (d) {
    content.value = d.latest_prompt_content ?? "";
    variables.value = d.latest_schema_json?.variables ?? [];
  }
});

const saveDraftMutation = useMutation({
  mutationFn: () => kolManageApi.saveDraft(props.promptId, {
    content: content.value,
    schema: { variables: variables.value },
    card_config: {},
  }),
  onSuccess: () => {
    message.success(t("common.savedDraft"));
    qc.invalidateQueries({ queryKey: ["kol-prompt-latest", props.promptId] });
  },
});

const publishMutation = useMutation({
  mutationFn: () => kolManageApi.publishPrompt(props.promptId),
  onSuccess: () => message.success(t("common.published")),
});

const previewOpen = ref(false);
const previewValues = ref<Record<string, unknown>>({});
</script>

<template>
  <div class="kol-prompt-editor">
    <header class="toolbar">
      <span>{{ detailQuery.data.value?.name }}</span>
      <div class="actions">
        <button @click="previewOpen = true">{{ t("kol.manage.editor.preview") }}</button>
        <button :disabled="saveDraftMutation.isPending.value" @click="saveDraftMutation.mutate()">{{ t("kol.manage.editor.saveDraft") }}</button>
        <button :disabled="publishMutation.isPending.value" @click="publishMutation.mutate()">{{ t("kol.manage.editor.publish") }}</button>
      </div>
    </header>
    <section class="panes">
      <KolVariablePanel />
      <KolPromptEditArea v-model:content="content" v-model:variables="variables" @ai-generate="/* Phase 4.6 */" />
      <KolVariableConfig :variables="variables" @update:variables="variables = $event" />
    </section>
    <a-modal v-model:open="previewOpen" :title="t('kol.manage.editor.preview')" :footer="null">
      <KolDynamicForm :variables="variables" :model-value="previewValues" @update:model-value="previewValues = $event" />
    </a-modal>
  </div>
</template>

<style scoped>
.kol-prompt-editor { display: flex; flex-direction: column; height: 100%; }
.toolbar { display: flex; justify-content: space-between; padding: 12px 20px; border-bottom: 1px solid #f0f0f0; }
.actions { display: flex; gap: 8px; }
.panes { display: flex; flex: 1; min-height: 500px; }
</style>
  • Step 3: KolPackageFormModal + KolPromptFormModal

Simple a-modal wrappers with a-form inputs for name, description, industry, tags, etc. Both emit submit with a typed payload and update:open for close.

  • Step 4: KolManageView.vue — shell

Two-column layout: left is package list with "Create" button, right is selected package's prompt list with "Create prompt" button. Clicking a prompt opens the KolPromptEditor in a drawer.

<script setup lang="ts">
import { ref } from "vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { kolManageApi } from "@/lib/api";
import KolPromptEditor from "@/components/kol/KolPromptEditor.vue";
import KolPackageFormModal from "@/components/kol/KolPackageFormModal.vue";
import KolPromptFormModal from "@/components/kol/KolPromptFormModal.vue";

const selectedPackageId = ref<number | null>(null);
const selectedPromptId  = ref<number | null>(null);
const pkgModalOpen      = ref(false);
const promptModalOpen   = ref(false);

const packagesQuery = useQuery({
  queryKey: ["kol-my-packages"],
  queryFn: () => kolManageApi.listPackages(),
});
const promptsQuery = useQuery({
  queryKey: ["kol-package-prompts", selectedPackageId],
  queryFn: () => kolManageApi.listPrompts(selectedPackageId.value!),
  enabled: () => !!selectedPackageId.value,
});
</script>

<template>
  <div class="kol-manage-view">
    <aside class="pkg-list">
      <header>
        <h3>{{ $t("kol.manage.title") }}</h3>
        <button @click="pkgModalOpen = true">{{ $t("kol.manage.createPackage") }}</button>
      </header>
      <ul>
        <li v-for="pkg in packagesQuery.data.value" :key="pkg.id" :class="{ active: pkg.id === selectedPackageId }" @click="selectedPackageId = pkg.id">
          {{ pkg.name }} <span class="status">{{ pkg.status }}</span>
        </li>
      </ul>
    </aside>

    <section class="prompt-list" v-if="selectedPackageId">
      <header>
        <h3>Prompts</h3>
        <button @click="promptModalOpen = true">{{ $t("kol.manage.createPrompt") }}</button>
      </header>
      <ul>
        <li v-for="pr in promptsQuery.data.value" :key="pr.id" @click="selectedPromptId = pr.id">
          {{ pr.name }} <span>{{ pr.platform_hint || '通用' }}</span> <span>{{ pr.status }}</span>
        </li>
      </ul>
    </section>

    <a-drawer :open="!!selectedPromptId" width="90vw" @close="selectedPromptId = null">
      <KolPromptEditor v-if="selectedPromptId" :prompt-id="selectedPromptId" />
    </a-drawer>

    <KolPackageFormModal v-model:open="pkgModalOpen" @submit="(body) => kolManageApi.createPackage(body).then(() => packagesQuery.refetch())" />
    <KolPromptFormModal v-model:open="promptModalOpen" :package-id="selectedPackageId!" @submit="(body) => kolManageApi.createPrompt(selectedPackageId!, body).then(() => promptsQuery.refetch())" />
  </div>
</template>
  • Step 5: Manual verify

Sign in as an active KOL → go to /kol/manage. Create a package → create a prompt → open editor → drag 输入框 into the text area → save draft → publish. Check DB: kol_prompt_revisions has entries; kol_prompts.published_revision_no is set.

  • Step 6: Commit
git add apps/admin-web/src/components/kol apps/admin-web/src/views/KolManageView.vue
git commit -m "feat(kol): KolManageView + three-pane prompt editor"

Task 4.6: AI assist backend + frontend wiring

Files:

  • Create: server/internal/tenant/app/kol_assist_service.go

  • Create: server/internal/worker/assist/kol_assist_worker.go

  • Modify: server/internal/tenant/transport/kol_manage_handler.go (add assist endpoints)

  • Modify: apps/admin-web/src/components/kol/KolPromptEditor.vue (wire AI buttons)

  • Step 1: Assist service

type KolAssistService struct {
    profileSvc *KolProfileService
    repo       repository.KolAssistRepository
    llm        llm.Client
    messaging  messaging.Publisher
}

type AssistRequest struct {
    Mode            string             `json:"mode"`   // generate | optimize
    Description     string             `json:"description"`
    CurrentContent  string             `json:"current_content"`
    PromptID        *int64             `json:"prompt_id"`
    Schema          *d.KolSchemaJSON   `json:"schema"`
}

func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req AssistRequest) (string, error) {
    prof, err := s.profileSvc.RequireActiveKol(ctx, actor)
    if err != nil { return "", err }
    id := newTaskID()
    body, _ := json.Marshal(req)
    // tenant_id = prof.TenantID (billing tenant), NOT actor.TenantID
    if err := s.repo.Create(ctx, repository.CreateKolAssistInput{
        ID: id, TenantID: prof.TenantID, KolProfileID: prof.ID,
        PromptID: req.PromptID, OperatorID: actor.UserID,
        TaskType: "ai_" + req.Mode, RequestJSON: body,
    }); err != nil { return "", err }
    if err := s.messaging.Publish(ctx, "kol.assist", id); err != nil { return "", err }
    return id, nil
}

func (s *KolAssistService) Get(ctx context.Context, actor auth.Actor, id string) (*repository.KolAssistTask, error) {
    prof, err := s.profileSvc.RequireActiveKol(ctx, actor)
    if err != nil { return nil, err }
    return s.repo.Get(ctx, prof.TenantID, id)
}
  • Step 2: Worker
// server/internal/worker/assist/kol_assist_worker.go
func (w *KolAssistWorker) Process(ctx context.Context, id string) error {
    task, err := w.repo.GetByID(ctx, id)   // no tenant filter — worker is platform-wide
    if err != nil { return err }
    if err := w.repo.MarkStarted(ctx, task.TenantID, id); err != nil { return err }

    var req AssistRequest
    if err := json.Unmarshal(task.RequestJSON, &req); err != nil {
        w.repo.MarkFailed(ctx, task.TenantID, id, err.Error())
        return nil
    }
    systemPrompt := "You are a prompt designer for marketing content. Respond ONLY with JSON: {content: string, schema: {variables: [...]}}"
    userPrompt := req.Description
    if req.Mode == "optimize" {
        userPrompt = fmt.Sprintf("Optimize the following prompt. Keep variable placeholders intact. Current schema: %s\n\nPROMPT:\n%s", req.Schema, req.CurrentContent)
    }
    resp, err := w.llm.Complete(ctx, systemPrompt, userPrompt)
    if err != nil {
        w.repo.MarkFailed(ctx, task.TenantID, id, err.Error())
        return nil
    }
    result, err := parseAssistLLMResponse(resp)
    if err != nil {
        w.repo.MarkFailed(ctx, task.TenantID, id, err.Error())
        return nil
    }
    b, _ := json.Marshal(result)
    return w.repo.MarkCompleted(ctx, task.TenantID, id, b)
}
  • Step 3: Handler + route

Add to kol_manage_handler.go:

func (h *KolManageHandler) AssistSubmit(c *gin.Context) {
    var req app.AssistRequest
    if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, 400, err.Error()); return }
    actor := auth.FromCtx(c)
    id, err := h.assistSvc.Submit(c.Request.Context(), actor, req)
    if err != nil { response.Error(c, 400, err.Error()); return }
    response.OK(c, gin.H{"task_id": id})
}

func (h *KolManageHandler) AssistGet(c *gin.Context) {
    id := c.Param("id")
    actor := auth.FromCtx(c)
    task, err := h.assistSvc.Get(c.Request.Context(), actor, id)
    if err != nil { response.Error(c, 404, err.Error()); return }
    response.OK(c, task)
}

Routes:

kolManage.POST("/assist",     kolManageHandler.AssistSubmit)
kolManage.GET("/assist/:id",  kolManageHandler.AssistGet)
  • Step 4: Frontend wiring

In KolPromptEditor.vue, replace the TODO AI handler with:

const aiTaskId = ref<string | null>(null);
const aiTaskQuery = useQuery({
  queryKey: ["kol-assist", aiTaskId],
  queryFn: () => kolManageApi.getAssist(aiTaskId.value!),
  enabled: () => !!aiTaskId.value,
  refetchInterval: (data) => (data?.status === "completed" || data?.status === "failed") ? false : 2000,
});

async function onAiGenerate(description: string) {
  const r = await kolManageApi.submitAssist({ mode: "generate", description });
  aiTaskId.value = r.task_id;
}

watchEffect(() => {
  const t = aiTaskQuery.data.value;
  if (t?.status === "completed" && t.result) {
    content.value = t.result.content;
    variables.value = t.result.schema.variables;
    aiTaskId.value = null;
  }
});
  • Step 5: Commit
git add server/internal/tenant/app/kol_assist_service.go
git add server/internal/worker/assist/kol_assist_worker.go
git add server/internal/tenant/transport/kol_manage_handler.go
git add apps/admin-web/src/components/kol/KolPromptEditor.vue
git commit -m "feat(kol): AI assist backend worker + editor wiring"

PHASE 5 — Marketplace + Subscription

Deliverable: Non-KOL tenants can browse marketplace, apply for subscription, platform admin approves or manually binds, and once active the subscription prompt access rows appear on the subscribing tenant's side.

Task 5.1: Marketplace service + handler (cross-tenant)

Files:

  • Create: server/internal/tenant/app/kol_marketplace_service.go

  • Create: server/internal/tenant/transport/kol_marketplace_handler.go

  • Modify: server/internal/tenant/transport/router.go

  • Step 1: Service

type KolMarketplaceService struct {
    repo     repository.KolMarketplaceRepository
    subRepo  repository.KolSubscriptionRepository
    pkgRepo  repository.KolPackageRepository
    promptRepo repository.KolPromptRepository
}

func (s *KolMarketplaceService) ListPackages(ctx context.Context, actor auth.Actor, filter MarketFilter) ([]repository.MarketPackageRow, error) {
    return s.repo.ListPublished(ctx, filter)   // cross-tenant, no tenant arg
}

func (s *KolMarketplaceService) GetPackage(ctx context.Context, actor auth.Actor, id int64) (*PackageDetail, error) {
    pkg, err := s.repo.GetPublished(ctx, id)
    if err != nil { return nil, err }
    if pkg == nil { return nil, ErrNotFound }
    prompts, err := s.repo.ListPublicPrompts(ctx, id)
    if err != nil { return nil, err }
    // attach current tenant's subscription state
    sub, err := s.subRepo.GetActiveForTenant(ctx, actor.TenantID, id)
    if err != nil { return nil, err }
    return &PackageDetail{Package: pkg, Prompts: prompts, Subscription: sub}, nil
}

func (s *KolMarketplaceService) Subscribe(ctx context.Context, actor auth.Actor, packageID int64) (*repository.KolSubscription, error) {
    pkg, err := s.repo.GetPublished(ctx, packageID)
    if err != nil { return nil, err }
    if pkg == nil { return nil, ErrNotFound }
    return s.subRepo.Create(ctx, actor.TenantID, packageID)
}

func (s *KolMarketplaceService) ListSubscriptions(ctx context.Context, actor auth.Actor) ([]repository.KolSubscription, error) {
    return s.subRepo.ListByTenant(ctx, actor.TenantID)
}
  • Step 2: Handler + routes
func (h *KolMarketplaceHandler) List(c *gin.Context) {
    filter := MarketFilter{
        Industry: c.Query("industry"), Keyword: c.Query("keyword"),
        Limit: parseInt(c.Query("limit"), 20), Offset: parseInt(c.Query("offset"), 0),
    }
    actor := auth.FromCtx(c)
    out, err := h.svc.ListPackages(c.Request.Context(), actor, filter)
    if err != nil { response.Error(c, 500, err.Error()); return }
    response.OK(c, out)
}

func (h *KolMarketplaceHandler) Detail(c *gin.Context) {
    id := parsePathInt(c, "id")
    actor := auth.FromCtx(c)
    out, err := h.svc.GetPackage(c.Request.Context(), actor, id)
    if err != nil { response.Error(c, 404, err.Error()); return }
    response.OK(c, out)
}

func (h *KolMarketplaceHandler) Subscribe(c *gin.Context) {
    id := parsePathInt(c, "id")
    actor := auth.FromCtx(c)
    out, err := h.svc.Subscribe(c.Request.Context(), actor, id)
    if err != nil { response.Error(c, 400, err.Error()); return }
    response.OK(c, out)
}

func (h *KolMarketplaceHandler) ListSubscriptions(c *gin.Context) { /* ... */ }
func (h *KolMarketplaceHandler) ListSubscriptionPrompts(c *gin.Context) { /* ... */ }

Routes in router.go:

mkt := protected.Group("/tenant/kol/marketplace")
mh := NewKolMarketplaceHandler(app)
mkt.GET("/packages",           mh.List)
mkt.GET("/packages/:id",       mh.Detail)
mkt.POST("/packages/:id/subscribe", mh.Subscribe)

kolSub := protected.Group("/tenant/kol")
kolSub.GET("/subscriptions",        mh.ListSubscriptions)
kolSub.GET("/subscription-prompts", mh.ListSubscriptionPrompts)
  • Step 3: Commit
git add server/internal/tenant/app/kol_marketplace_service.go
git add server/internal/tenant/transport/kol_marketplace_handler.go
git add server/internal/tenant/transport/router.go
git commit -m "feat(kol): marketplace browse + subscribe tenant API"

Task 5.2: Platform admin — subscription approval + manual bind + revoke

Files:

  • Modify: server/internal/platform/app/kol_subscription_admin_service.go

  • Modify: server/internal/platform/transport/kol_admin_handler.go

  • Modify: server/internal/platform/transport/router.go

  • Step 1: Admin service with fan-out to access rows

type KolSubscriptionAdminService struct {
    pool       *pgxpool.Pool
    subRepo    repository.KolSubscriptionRepository
    pkgRepo    repository.KolPackageRepository
    promptRepo repository.KolPromptRepository
}

func (s *KolSubscriptionAdminService) Approve(ctx context.Context, id int64, endAt *time.Time, operatorID int64) error {
    tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
    if err != nil { return err }
    defer tx.Rollback(ctx)

    sub, err := s.subRepo.GetByIDAny(ctx, tx, id)  // cross-tenant: platform admin acts on any sub
    if err != nil { return err }
    if sub.Status != "pending" { return errors.New("subscription not pending") }

    // Mark active
    if err := s.subRepo.Approve(ctx, tx, id, sub.TenantID, operatorID, endAt); err != nil {
        return err
    }

    // Fan-out: insert access rows for all active prompts
    prompts, err := s.promptRepo.ListActiveByPackageAny(ctx, tx, sub.PackageID)
    if err != nil { return err }
    for _, p := range prompts {
        if err := s.subRepo.CreateAccessRow(ctx, tx, repository.CreateAccessRowInput{
            TenantID: sub.TenantID, SubscriptionID: id, PackageID: sub.PackageID, PromptID: p.ID,
        }); err != nil { return err }
    }
    return tx.Commit(ctx)
}

func (s *KolSubscriptionAdminService) ManualBind(ctx context.Context, tenantID, packageID int64, endAt *time.Time, operatorID int64) (*repository.KolSubscription, error) {
    tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
    if err != nil { return nil, err }
    defer tx.Rollback(ctx)
    sub, err := s.subRepo.Create(ctx, tx, tenantID, packageID)
    if err != nil { return nil, err }
    // Immediately approve
    if err := s.subRepo.Approve(ctx, tx, sub.ID, tenantID, operatorID, endAt); err != nil {
        return nil, err
    }
    prompts, err := s.promptRepo.ListActiveByPackageAny(ctx, tx, packageID)
    if err != nil { return nil, err }
    for _, p := range prompts {
        if err := s.subRepo.CreateAccessRow(ctx, tx, repository.CreateAccessRowInput{
            TenantID: tenantID, SubscriptionID: sub.ID, PackageID: packageID, PromptID: p.ID,
        }); err != nil { return nil, err }
    }
    if err := tx.Commit(ctx); err != nil { return nil, err }
    sub.Status = "active"
    return sub, nil
}

func (s *KolSubscriptionAdminService) Revoke(ctx context.Context, id int64, operatorID int64) error {
    tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
    if err != nil { return err }
    defer tx.Rollback(ctx)
    sub, err := s.subRepo.GetByIDAny(ctx, tx, id)
    if err != nil { return err }
    if err := s.subRepo.RevokeByTenant(ctx, tx, id, sub.TenantID); err != nil { return err }
    if err := s.subRepo.RevokeAccessRowsBySubscription(ctx, tx, id, sub.TenantID); err != nil { return err }
    return tx.Commit(ctx)
}
  • Step 2: Handlers + routes

Add to kol_admin_handler.go:

func (h *KolAdminHandler) ApproveSubscription(c *gin.Context) { /* parse id + optional end_at; call svc */ }
func (h *KolAdminHandler) ManualBind(c *gin.Context) { /* body: tenant_id, package_id, end_at */ }
func (h *KolAdminHandler) RevokeSubscription(c *gin.Context) { /* parse id; call svc */ }

Routes:

kolAdmin.PUT("/subscriptions/:id/approve", kolAdminHandler.ApproveSubscription)
kolAdmin.POST("/subscriptions",            kolAdminHandler.ManualBind)
kolAdmin.PUT("/subscriptions/:id/revoke",  kolAdminHandler.RevokeSubscription)
  • Step 3: Smoke test
# As tenant user: subscribe to a package
curl -X POST http://localhost:8080/api/tenant/kol/marketplace/packages/1/subscribe -H "Authorization: Bearer $TENANT_TOKEN"
# Response: subscription id

# As platform admin: approve
curl -X PUT http://localhost:8080/api/platform/admin/kol/subscriptions/1/approve \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"end_at":"2027-04-17T00:00:00Z"}'

# As tenant user: verify access rows
curl http://localhost:8080/api/tenant/kol/subscription-prompts -H "Authorization: Bearer $TENANT_TOKEN"

Expected: subscription becomes active, access rows appear, tenant sees them.

  • Step 4: Commit
git add server/internal/platform/app/kol_subscription_admin_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): subscription approval + manual bind + revoke"

Task 5.3: Publish-prompt fan-out

Files:

  • Modify: server/internal/tenant/app/kol_prompt_service.go

  • Step 1: Extend Publish to fan out access rows to existing active subscriptions

func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, promptID int64) error {
    // ... existing ownership + draft checks ...

    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
    }
    // Fan-out: for all active subscriptions of this prompt's package, create missing access rows
    subs, err := s.subRepo.ListActiveSubscribersForPackage(ctx, tx, prompt.PackageID)
    if err != nil { return err }
    for _, sub := range subs {
        if err := s.subRepo.CreateAccessRow(ctx, tx, repository.CreateAccessRowInput{
            TenantID: sub.TenantID, SubscriptionID: sub.ID, PackageID: prompt.PackageID, PromptID: promptID,
        }); err != nil { return err }
    }
    return tx.Commit(ctx)
}

Note: for V1 this fan-out is synchronous. Future iteration can move it to async via task_records (see spec §6.3 note).

  • Step 2: Commit
git add server/internal/tenant/app/kol_prompt_service.go
git commit -m "feat(kol): fan out new prompts to existing active subscriptions"

Task 5.4: Marketplace frontend — views + API client

Files:

  • Modify: apps/admin-web/src/lib/api.ts

  • Create: apps/admin-web/src/views/KolMarketplaceView.vue

  • Create: apps/admin-web/src/views/KolPackageDetailView.vue

  • Create: apps/admin-web/src/components/kol/KolPackageCard.vue

  • Step 1: Extend api.ts

export const kolMarketplaceApi = {
  list(params: { industry?: string; keyword?: string; limit?: number; offset?: number }) {
    return apiClient.get<KolPackageSummary[]>("/api/tenant/kol/marketplace/packages", { params });
  },
  detail(id: number) {
    return apiClient.get<KolPackageDetail>(`/api/tenant/kol/marketplace/packages/${id}`);
  },
  subscribe(id: number) {
    return apiClient.post<KolSubscription, {}>(`/api/tenant/kol/marketplace/packages/${id}/subscribe`, {});
  },
  mySubscriptions() {
    return apiClient.get<KolSubscription[]>("/api/tenant/kol/subscriptions");
  },
  mySubscriptionPrompts() {
    return apiClient.get<KolSubscriptionPromptCard[]>("/api/tenant/kol/subscription-prompts");
  },
};
  • Step 2: KolPackageCard.vue

Reusable card with cover image, name, KOL badge, industry tag, subscribe/subscribed state, prompt count.

  • Step 3: KolMarketplaceView.vue

Grid of cards with industry + keyword filters. Clicking a card routes to /kol/packages/:id.

  • Step 4: KolPackageDetailView.vue

Header (cover, name, KOL), prompt list, subscribe button / status banner. Clicking a prompt routes to /kol/generate/:subscriptionPromptId (lookup via mySubscriptionPrompts to resolve the access row id).

  • Step 5: Commit
git add apps/admin-web/src/lib/api.ts apps/admin-web/src/views/KolMarketplaceView.vue apps/admin-web/src/views/KolPackageDetailView.vue apps/admin-web/src/components/kol/KolPackageCard.vue
git commit -m "feat(kol): marketplace + package detail UI"

PHASE 6 — Generation Pipeline

Deliverable: A tenant with an active subscription prompt can fill the dynamic form on /kol/generate/:subscriptionPromptId, submit, see SSE progress, and read the generated article. kol_usage_logs reflects pending → completed/failed.

Task 6.1: Generation service (transaction boundary)

Files:

  • Create: server/internal/tenant/app/kol_generation_service.go

  • Step 1: Implement service following spec §7

type KolGenerationService struct {
    pool       *pgxpool.Pool
    subRepo    repository.KolSubscriptionRepository
    promptRepo repository.KolPromptRepository
    usageRepo  repository.KolUsageRepository
    articleRepo repository.ArticleRepository
    taskRepo   repository.GenerationTaskRepository
    recordRepo repository.TaskRecordRepository
    quotaRepo  repository.QuotaRepository
    asset      *KolPromptAsset
    queue      messaging.Publisher
}

func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, subPromptID int64, variables map[string]any) (*GenerationResult, error) {
    // 1. Resolve access row + enforce auth chain (spec §7.1 seven checks)
    row, err := s.subRepo.GetSubscriptionPromptByID(ctx, actor.TenantID, subPromptID)
    if err != nil { return nil, err }
    if row == nil { return nil, ErrForbidden }
    if row.Status != "active" { return nil, ErrForbidden }
    if row.SubscriptionStatus != "active" { return nil, ErrForbidden }
    if row.SubscriptionEndAt != nil && row.SubscriptionEndAt.Before(time.Now()) { return nil, ErrForbidden }
    if row.PackageStatus != "published" { return nil, ErrForbidden }
    if row.PromptStatus != "active" || row.PublishedRevisionNo == nil { return nil, ErrForbidden }

    // 2. Load revision (cross-tenant read: creator's tenant)
    rev, err := s.promptRepo.GetRevisionAny(ctx, row.PromptID, *row.PublishedRevisionNo)
    if err != nil { return nil, err }

    // 3. Load prompt content + render
    content, err := s.asset.Get(ctx, rev.PromptAssetKey)
    if err != nil { return nil, err }
    rendered, err := RenderPrompt(content, rev.Schema, variables)
    if err != nil { return nil, err }
    hash := HashPrompt(rendered)

    // 4. Transaction: quota reserve + article + task + usage log + task_record
    tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
    if err != nil { return nil, err }
    defer tx.Rollback(ctx)

    reservation, err := s.quotaRepo.Reserve(ctx, tx, actor.TenantID, "article_generation", 1, "kol")
    if err != nil { return nil, err }
    article, err := s.articleRepo.Create(ctx, tx, repository.CreateArticleInput{
        TenantID: actor.TenantID, SourceType: "kol", KolPromptID: row.PromptID,
    })
    if err != nil { return nil, err }

    inputJSON, _ := json.Marshal(map[string]any{
        "subscription_prompt_id": subPromptID,
        "prompt_id":              row.PromptID,
        "prompt_revision_no":     *row.PublishedRevisionNo,
        "variables":              variables,
        "schema_snapshot":        rev.Schema,
        "rendered_prompt_hash":   hash,
        "rendered_prompt":        rendered,    // worker reads; never returned to client
    })

    task, err := s.taskRepo.Create(ctx, tx, repository.CreateGenerationTaskInput{
        TenantID: actor.TenantID, ArticleID: article.ID,
        TaskType: "kol", RequestHash: hash,
        QuotaReservationID: reservation.ID, InputParamsJSON: inputJSON,
    })
    if err != nil { return nil, err }

    schemaJSON, _ := json.Marshal(rev.Schema)
    varsJSON, _ := json.Marshal(variables)
    usage, err := s.usageRepo.Create(ctx, tx, repository.CreateKolUsageInput{
        TenantID: actor.TenantID, SubscriptionID: row.SubscriptionID,
        SubscriptionPromptID: row.ID, PackageID: row.PackageID, PromptID: row.PromptID,
        PromptRevisionNo: *row.PublishedRevisionNo, GenerationTaskID: task.ID,
        VariablesSnapshot: varsJSON, SchemaSnapshot: schemaJSON, RenderedPromptHash: hash,
    })
    if err != nil { return nil, err }

    if err := s.recordRepo.Create(ctx, tx, repository.CreateTaskRecordInput{
        TenantID: actor.TenantID, ResourceType: "article", ResourceID: article.ID,
        TaskType: "kol_generation", Status: "queued",
    }); err != nil { return nil, err }

    if err := tx.Commit(ctx); err != nil { return nil, err }

    // 5. Publish message after commit
    if err := s.queue.Publish(ctx, "articles.generate", messaging.ArticleGenerateMessage{
        TaskID: task.ID, TenantID: actor.TenantID, ArticleID: article.ID, TaskType: "kol",
    }); err != nil {
        // log — task will be recovered by scheduler scan
    }
    return &GenerationResult{
        ArticleID: article.ID, GenerationTaskID: task.ID, UsageLogID: usage.ID,
    }, nil
}
  • Step 2: Handler + route

In kol_marketplace_handler.go:

func (h *KolMarketplaceHandler) Generate(c *gin.Context) {
    subPromptID := parsePathInt(c, "id")
    var req struct {
        Variables map[string]any `json:"variables"`
    }
    if err := c.ShouldBindJSON(&req); err != nil { response.Error(c, 400, err.Error()); return }
    actor := auth.FromCtx(c)
    out, err := h.genSvc.Submit(c.Request.Context(), actor, subPromptID, req.Variables)
    if err != nil { response.Error(c, 403, err.Error()); return }
    response.OK(c, out)
}

func (h *KolMarketplaceHandler) SubscriptionPromptSchema(c *gin.Context) {
    id := parsePathInt(c, "id")
    actor := auth.FromCtx(c)
    out, err := h.genSvc.GetSchema(c.Request.Context(), actor, id)
    if err != nil { response.Error(c, 403, err.Error()); return }
    response.OK(c, out)
}

Routes:

kolSub.GET("/subscription-prompts/:id/schema",   mh.SubscriptionPromptSchema)
kolSub.POST("/subscription-prompts/:id/generate", mh.Generate)
  • Step 3: Commit
git add server/internal/tenant/app/kol_generation_service.go
git add server/internal/tenant/transport/kol_marketplace_handler.go
git add server/internal/tenant/transport/router.go
git commit -m "feat(kol): generation submit endpoint with full transaction"

Task 6.2: Worker branch for task_type='kol'

Files:

  • Create: server/internal/worker/generate/kol_generation_worker.go

  • Modify: server/internal/worker/generate/dispatcher.go (or equivalent router)

  • Step 1: Implement worker

Follow the existing template worker pattern. Read rendered_prompt from generation_tasks.input_params_json, call LLM, on success write article_versions + update article + mark quota confirmed + mark usage log completed in a single tx. On failure, mark failed + refund quota + mark usage log failed.

func (w *KolGenerationWorker) Process(ctx context.Context, msg messaging.ArticleGenerateMessage) error {
    task, err := w.taskRepo.GetByID(ctx, msg.TenantID, msg.TaskID)
    if err != nil { return err }
    var params map[string]any
    _ = json.Unmarshal(task.InputParamsJSON, &params)
    rendered := params["rendered_prompt"].(string)

    // Mark running
    _ = w.taskRepo.MarkRunning(ctx, msg.TenantID, msg.TaskID)

    // Call LLM (reuse existing client config used by article_generation_worker)
    result, err := w.llm.Complete(ctx, SystemPromptForKOLArticle, rendered)
    if err != nil {
        w.onFailure(ctx, msg, err)
        return nil
    }
    parsed, err := parseArticleLLMResponse(result)
    if err != nil {
        w.onFailure(ctx, msg, err)
        return nil
    }

    tx, err := w.pool.BeginTx(ctx, pgx.TxOptions{})
    if err != nil { return err }
    defer tx.Rollback(ctx)
    if err := w.versionRepo.Create(ctx, tx, repository.CreateArticleVersionInput{
        ArticleID: msg.ArticleID, VersionNo: 1, Title: parsed.Title,
        HTMLContent: parsed.HTML, MarkdownContent: parsed.Markdown, WordCount: parsed.WordCount,
        SourceLabel: "kol",
    }); err != nil { return err }
    if err := w.articleRepo.MarkCompleted(ctx, tx, msg.TenantID, msg.ArticleID, 1); err != nil { return err }
    if err := w.quotaRepo.Confirm(ctx, tx, msg.TenantID, task.QuotaReservationID); err != nil { return err }
    if err := w.usageRepo.MarkCompletedByTask(ctx, tx, msg.TenantID, msg.TaskID, msg.ArticleID); err != nil { return err }
    if err := w.taskRepo.MarkCompleted(ctx, tx, msg.TenantID, msg.TaskID); err != nil { return err }
    return tx.Commit(ctx)
}

func (w *KolGenerationWorker) onFailure(ctx context.Context, msg messaging.ArticleGenerateMessage, cause error) {
    tx, _ := w.pool.BeginTx(ctx, pgx.TxOptions{})
    defer tx.Rollback(ctx)
    _ = w.taskRepo.MarkFailed(ctx, tx, msg.TenantID, msg.TaskID, cause.Error())
    _ = w.quotaRepo.Refund(ctx, tx, msg.TenantID, msg.TaskID)
    _ = w.usageRepo.MarkFailedByTask(ctx, tx, msg.TenantID, msg.TaskID, cause.Error())
    _ = w.articleRepo.MarkFailed(ctx, tx, msg.TenantID, msg.ArticleID)
    _ = tx.Commit(ctx)
}
  • Step 2: Dispatcher routing

In the existing article_generation_worker.go (or wherever dispatch switch lives), route by task_type:

switch task.TaskType {
case "template", "custom_generation":
    // existing
case "kol":
    return w.kolWorker.Process(ctx, msg)
default:
    return fmt.Errorf("unknown task_type %s", task.TaskType)
}
  • Step 3: Commit
git add server/internal/worker/generate/
git commit -m "feat(kol): worker branch for task_type=kol with transactional success/failure"

Task 6.3: KolGenerateView frontend

Files:

  • Create: apps/admin-web/src/views/KolGenerateView.vue

  • Modify: apps/admin-web/src/lib/api.ts

  • Step 1: Add API methods

export const kolGenerateApi = {
  schema(id: number) {
    return apiClient.get<KolSubscriptionPromptSchema>(`/api/tenant/kol/subscription-prompts/${id}/schema`);
  },
  submit(id: number, body: KolGenerateRequest) {
    return apiClient.post<KolGenerateResponse, KolGenerateRequest>(`/api/tenant/kol/subscription-prompts/${id}/generate`, body);
  },
};
  • Step 2: View
<script setup lang="ts">
import { ref, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useQuery, useMutation } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import { kolGenerateApi } from "@/lib/api";
import KolDynamicForm from "@/components/kol/KolDynamicForm.vue";

const route = useRoute();
const router = useRouter();
const id = Number(route.params.subscriptionPromptId);
const values = ref<Record<string, unknown>>({});

const schemaQuery = useQuery({
  queryKey: ["kol-sub-prompt-schema", id],
  queryFn: () => kolGenerateApi.schema(id),
});

const submitMutation = useMutation({
  mutationFn: () => kolGenerateApi.submit(id, { variables: values.value as Record<string, any> }),
  onSuccess: (r) => {
    message.success("文章生成已开始");
    router.push({ name: "article-editor", params: { id: r.article_id } });
  },
  onError: (e: any) => message.error(e.message),
});
</script>

<template>
  <div class="kol-generate-view" v-if="schemaQuery.data.value">
    <header>
      <h2>{{ schemaQuery.data.value.package_name }} / {{ schemaQuery.data.value.prompt_name }}</h2>
      <span class="platform" v-if="schemaQuery.data.value.platform_hint">{{ schemaQuery.data.value.platform_hint }}</span>
    </header>
    <KolDynamicForm :variables="schemaQuery.data.value.schema_json.variables" :model-value="values" @update:model-value="values = $event" />
    <footer>
      <button :disabled="submitMutation.isPending.value" @click="submitMutation.mutate()">{{ $t("kol.generate.submit") }}</button>
    </footer>
  </div>
</template>
  • Step 3: End-to-end manual test

Sign in as subscriber tenant → /kol/marketplace → open package → click a prompt → fill form → submit. Verify:

  • articles row with source_type='kol'

  • generation_tasks row with task_type='kol', request_hash set

  • kol_usage_logs row transitions pending → completed

  • Quota deducted

  • Worker logs show successful LLM call

  • SSE / editor shows article content

  • Step 4: Commit

git add apps/admin-web/src/views/KolGenerateView.vue apps/admin-web/src/lib/api.ts
git commit -m "feat(kol): dynamic-form generation view"

PHASE 7 — Dashboard

Deliverable: Active KOL sees aggregate metrics (subscription count, usage count, failure rate) scoped to their own packages.

Task 7.1: Dashboard service + queries

Files:

  • Create: server/internal/tenant/app/kol_dashboard_service.go

  • Create: server/internal/tenant/transport/kol_dashboard_handler.go

  • Modify: server/internal/tenant/repository/queries/kol_usage.sql (add trend query if missing)

  • Modify: server/internal/tenant/transport/router.go

  • Step 1: Dashboard queries

Add to kol_usage.sql:

-- name: KolDashboardOverview :one
SELECT
  (SELECT COUNT(DISTINCT s.tenant_id) FROM kol_subscriptions s
     WHERE s.package_id IN (sqlc.slice('package_ids')) AND s.status='active'
       AND (s.end_at IS NULL OR s.end_at > NOW())) AS total_subs,
  (SELECT COUNT(*) FROM kol_subscriptions s
     WHERE s.package_id IN (sqlc.slice('package_ids'))
       AND s.status='active'
       AND s.created_at >= date_trunc('month', NOW())) AS month_subs,
  (SELECT COUNT(*) FROM kol_usage_logs u
     WHERE u.package_id IN (sqlc.slice('package_ids'))) AS total_usage,
  (SELECT COUNT(*) FROM kol_usage_logs u
     WHERE u.package_id IN (sqlc.slice('package_ids'))
       AND u.created_at >= date_trunc('month', NOW())) AS month_usage,
  (SELECT COUNT(*) FROM kol_usage_logs u
     WHERE u.package_id IN (sqlc.slice('package_ids'))
       AND u.status='failed') AS failure_count;

-- name: KolDashboardTrend :many
SELECT date_trunc('day', u.created_at)::date AS d,
       COUNT(*) FILTER (WHERE u.status='completed') AS usages,
       (SELECT COUNT(*) FROM kol_subscriptions s
         WHERE s.package_id IN (sqlc.slice('package_ids'))
           AND date_trunc('day', s.created_at)::date = date_trunc('day', u.created_at)::date) AS subs
FROM kol_usage_logs u
WHERE u.package_id IN (sqlc.slice('package_ids'))
  AND u.created_at >= NOW() - sqlc.arg('period_days')::int * INTERVAL '1 day'
GROUP BY d
ORDER BY d ASC;

Regenerate sqlc.

  • Step 2: Service
type KolDashboardService struct {
    profileSvc *KolProfileService
    pkgRepo    repository.KolPackageRepository
    usageRepo  repository.KolUsageRepository
}

func (s *KolDashboardService) Overview(ctx context.Context, actor auth.Actor) (*Overview, error) {
    prof, err := s.profileSvc.RequireActiveKol(ctx, actor)
    if err != nil { return nil, err }
    ids, err := s.pkgRepo.ListPackageIDsByProfile(ctx, actor.TenantID, prof.ID)
    if err != nil { return nil, err }
    if len(ids) == 0 { return &Overview{}, nil }
    return s.usageRepo.DashboardOverview(ctx, ids)
}

func (s *KolDashboardService) Trend(ctx context.Context, actor auth.Actor, periodDays int) ([]TrendPoint, error) {
    prof, err := s.profileSvc.RequireActiveKol(ctx, actor)
    if err != nil { return nil, err }
    ids, err := s.pkgRepo.ListPackageIDsByProfile(ctx, actor.TenantID, prof.ID)
    if err != nil { return nil, err }
    return s.usageRepo.DashboardTrend(ctx, ids, periodDays)
}

func (s *KolDashboardService) PerPackage(ctx context.Context, actor auth.Actor) ([]PackageStat, error) {
    prof, err := s.profileSvc.RequireActiveKol(ctx, actor)
    if err != nil { return nil, err }
    return s.usageRepo.PerPackageStats(ctx, actor.TenantID, prof.ID)
}
  • Step 3: Handler + routes
kolDash := protected.Group("/tenant/kol/dashboard")
dh := NewKolDashboardHandler(app)
kolDash.GET("/overview", dh.Overview)
kolDash.GET("/packages", dh.PerPackage)
kolDash.GET("/trend",    dh.Trend)
  • Step 4: Commit
git add server/internal/tenant/repository/queries/kol_usage.sql server/internal/tenant/repository/generated
git add server/internal/tenant/app/kol_dashboard_service.go
git add server/internal/tenant/transport/kol_dashboard_handler.go
git add server/internal/tenant/transport/router.go
git commit -m "feat(kol): dashboard service + API"

Task 7.2: KolDashboardView

Files:

  • Modify: apps/admin-web/src/lib/api.ts

  • Create: apps/admin-web/src/views/KolDashboardView.vue

  • Step 1: API methods

export const kolDashboardApi = {
  overview() { return apiClient.get<KolDashboardOverview>("/api/tenant/kol/dashboard/overview"); },
  packages() { return apiClient.get<KolDashboardPackageStat[]>("/api/tenant/kol/dashboard/packages"); },
  trend(periodDays = 30) { return apiClient.get<KolDashboardTrendPoint[]>(`/api/tenant/kol/dashboard/trend?period_days=${periodDays}`); },
};
  • Step 2: View

Top row of 4 stat cards (total subs, month subs, total usage, failure rate). Below: per-package table + line chart of trend (reuse echarts if already installed; else use a simple SVG).

  • Step 3: Commit
git add apps/admin-web/src/lib/api.ts apps/admin-web/src/views/KolDashboardView.vue
git commit -m "feat(kol): KOL dashboard UI"

PHASE 8 — Workspace Integration + Article List

Deliverable: Tenant Workspace shows a dedicated "KOL Prompts" card area with the tenant's active subscription prompts. Article list marks source_type='kol' entries.

Task 8.1: KOL cards API + Workspace integration

Files:

  • Modify: server/internal/tenant/transport/workspace_handler.go

  • Modify: server/internal/tenant/repository/queries/workspace.sql

  • Modify: apps/admin-web/src/views/WorkspaceView.vue

  • Modify: apps/admin-web/src/lib/api.ts

  • Step 1: Query

Add to workspace.sql:

-- name: ListKolCardsForTenant :many
SELECT sp.id AS subscription_prompt_id, sp.granted_at,
       p.name AS prompt_name, p.platform_hint,
       k.name AS package_name, k.cover_url AS package_cover,
       pf.display_name AS kol_display_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
JOIN kol_profiles pf ON pf.id = k.kol_profile_id
WHERE sp.tenant_id = sqlc.arg(tenant_id)::bigint
  AND sp.status = 'active'
  AND p.status = 'active'
  AND p.published_revision_no IS NOT NULL
ORDER BY sp.granted_at DESC
LIMIT 8;

Regenerate sqlc.

  • Step 2: Handler

Add to workspace_handler.go:

func (h *WorkspaceHandler) KolCards(c *gin.Context) {
    actor := auth.FromCtx(c)
    out, err := h.workspaceSvc.ListKolCards(c.Request.Context(), actor)
    if err != nil { response.Error(c, 500, err.Error()); return }
    response.OK(c, out)
}

Route:

workspace.GET("/kol-cards", wsHandler.KolCards)
  • Step 3: WorkspaceView additions

Add a "KOL Prompts" section (placed below template cards) rendering a grid of KolPackageCard components with a "立即生成" link to /kol/generate/:subscriptionPromptId.

const kolCardsQuery = useQuery({
  queryKey: ["workspace", "kol-cards"],
  queryFn: () => workspaceApi.kolCards(),
});

Template:

<section class="panel panel-kol" v-if="kolCardsQuery.data.value?.length">
  <h3 class="panel-title">{{ $t("kol.marketplace.title") }}</h3>
  <div class="kol-cards">
    <article v-for="card in kolCardsQuery.data.value" :key="card.subscription_prompt_id"
             class="kol-card" @click="router.push(`/kol/generate/${card.subscription_prompt_id}`)">
      <h4>{{ card.package_name }} · {{ card.prompt_name }}</h4>
      <p>{{ card.kol_display_name }}</p>
      <span v-if="card.platform_hint">{{ card.platform_hint }}</span>
    </article>
  </div>
</section>
  • Step 4: Commit
git add server/internal/tenant/repository/queries/workspace.sql server/internal/tenant/repository/generated
git add server/internal/tenant/transport/workspace_handler.go
git add server/internal/tenant/transport/router.go
git add apps/admin-web/src/views/WorkspaceView.vue apps/admin-web/src/lib/api.ts
git commit -m "feat(kol): workspace KOL cards area"

Task 8.2: Article list — surface kol source

Files:

  • Modify: apps/admin-web/src/lib/display.ts

  • Modify: apps/admin-web/src/components/ArticleSourceMeta.vue

  • Modify: server/internal/tenant/repository/queries/article.sql (include kol_prompt_id in list)

  • Step 1: Include kol_prompt_id in article list query

Find the ListArticles / GetArticle queries in article.sql and extend the SELECT to include kol_prompt_id. Regenerate sqlc.

Update ArticleListItem in shared-types/src/index.ts to include kol_prompt_id: number | null.

  • Step 2: Display helper

In display.ts getSourceTypeLabel:

if (sourceType === "kol") {
  return i18n.global.t("status.sourceType.kol");
}

Extend ArticleSourceMeta.vue to show a small KOL badge when sourceType === 'kol'.

  • Step 3: Commit
git add server/internal/tenant/repository/queries/article.sql server/internal/tenant/repository/generated
git add packages/shared-types/src/index.ts apps/admin-web/src/lib/display.ts apps/admin-web/src/components/ArticleSourceMeta.vue
git commit -m "feat(kol): show kol source type in article list"

Final Verification Checklist

After all phases complete, verify end-to-end:

  • Migrations clean apply + rollback: go run ./cmd/migrate up && go run ./cmd/migrate down N && go run ./cmd/migrate up
  • tenant_scope check passes: bash server/scripts/check_tenant_scope.sh
  • Go tests pass: cd server && go test ./...
  • Integration tests pass: cd server && TEST_DATABASE_URL=... go test -tags=integration ./...
  • Frontend typecheck: cd apps/admin-web && pnpm vue-tsc --noEmit
  • Frontend build: cd apps/admin-web && pnpm build
  • End-to-end manual smoke:
    1. Platform admin creates a KOL profile for user U in tenant T1
    2. U logs into tenant T1, sees "KOL 工作台" in sidebar
    3. U creates a package, creates a prompt, edits the body (dragging variables), saves draft, publishes
    4. U marks profile market_enabled=true (requires platform admin or direct DB update for V1)
    5. Platform admin publishes the package
    6. User V in tenant T2 visits /kol/marketplace, sees the package
    7. V clicks "Apply subscription" → subscription becomes pending
    8. Platform admin approves → access rows created
    9. V returns to workspace → sees KOL card for the prompt
    10. V clicks card → dynamic form renders from schema_json
    11. V fills form → clicks "Generate" → article appears in list with source_type='kol' badge
    12. U logs in, opens dashboard → sees 1 subscription, 1 usage

Spec Coverage Self-Review

Mapping each spec section to tasks above:

Spec Section Task(s)
§2.1 kol_profiles 1.1, 1.3, 1.8, 2.1, 2.2
§2.1 kol_packages 1.1, 1.4, 1.8, 3.2, 3.4, 5.1, 5.4
§2.1 kol_prompts + revisions 1.1, 1.5, 1.8, 3.3, 3.4, 4.5
§2.1 kol_subscriptions + access 1.1, 1.6, 1.8, 5.1, 5.2, 5.3
§2.1 kol_usage_logs 1.1, 1.7, 1.8, 6.1, 7.1
§2.1 kol_assist_tasks (tenant=creator) 1.1, 1.7, 1.8, 4.6
§2.2 articles + generation_tasks changes 1.2, 6.1, 6.2, 8.2
§3 variable model (immutable id + revision) 1.5, 3.3, 4.2, 4.3, 4.5
§4 routing + nav 2.4, 4.1
§5 API design 3.4, 5.1, 5.2, 6.1, 6.2, 7.1
§6 subscription lifecycle 5.2, 5.3
§6.4 V1 no auto-expire 6.1 (generation auth checks end_at live)
§7 generation pipeline transactions 6.1, 6.2
§8 three-pane editor 4.3, 4.4, 4.5
§8.4 AI assist 4.6
§9 dashboard 7.1, 7.2
§10 workspace integration 8.1, 8.2
§11 auth chain 2.2, 2.3, 2.4, 4.1 (router guard), 6.1 (seven-check)
§12 i18n 2.4
§13 V1 scope whole plan — V1 excludes are not implemented