Files
geo/docs/superpowers/plans/2026-04-17-kol-feature.md
T
root 3e1b68f771 docs: add KOL feature implementation plan
8-phase plan covering data foundation, profile lifecycle, workspace
backend/frontend, marketplace+subscription, generation pipeline, dashboard,
and workspace integration. Each phase is a shippable milestone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 07:46:29 +08:00

3726 lines
135 KiB
Markdown
Raw Blame History

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