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>
135 KiB
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):
- Phase 1 — Data foundation (migrations + repositories + shared-types)
- Phase 2 — KOL Profile (platform admin lifecycle + tenant read API + nav wiring)
- Phase 3 — KOL Workspace backend (package + prompt + revision CRUD + protected asset)
- Phase 4 — KOL Workspace frontend (management UI + three-pane editor)
- Phase 5 — Marketplace + Subscription (cross-tenant browse + apply + platform admin approve + access rows)
- Phase 6 — Generation pipeline (generation API + KOL worker branch + dynamic form UI + usage logs)
- Phase 7 — Dashboard (aggregation APIs + KOL dashboard UI)
- 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 sqlcUpdateKolPromptDraftRevision),SetPublishedRevision(wraps sqlcUpdateKolPromptPublishedRevision),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 bygeneration_task_idthen 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(addkol_profiletoUserInfo) -
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, ¶ms)
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:
-
articlesrow withsource_type='kol' -
generation_tasksrow withtask_type='kol',request_hashset -
kol_usage_logsrow 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:
- Platform admin creates a KOL profile for user U in tenant T1
- U logs into tenant T1, sees "KOL 工作台" in sidebar
- U creates a package, creates a prompt, edits the body (dragging variables), saves draft, publishes
- U marks profile
market_enabled=true(requires platform admin or direct DB update for V1) - Platform admin publishes the package
- User V in tenant T2 visits
/kol/marketplace, sees the package - V clicks "Apply subscription" → subscription becomes
pending - Platform admin approves → access rows created
- V returns to workspace → sees KOL card for the prompt
- V clicks card → dynamic form renders from
schema_json - V fills form → clicks "Generate" → article appears in list with
source_type='kol'badge - 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 |