3e1b68f771
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>
3726 lines
135 KiB
Markdown
3726 lines
135 KiB
Markdown
# 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, ¶ms)
|
||
rendered := params["rendered_prompt"].(string)
|
||
|
||
// Mark running
|
||
_ = w.taskRepo.MarkRunning(ctx, msg.TenantID, msg.TaskID)
|
||
|
||
// Call LLM (reuse existing client config used by article_generation_worker)
|
||
result, err := w.llm.Complete(ctx, SystemPromptForKOLArticle, rendered)
|
||
if err != nil {
|
||
w.onFailure(ctx, msg, err)
|
||
return nil
|
||
}
|
||
parsed, err := parseArticleLLMResponse(result)
|
||
if err != nil {
|
||
w.onFailure(ctx, msg, err)
|
||
return nil
|
||
}
|
||
|
||
tx, err := w.pool.BeginTx(ctx, pgx.TxOptions{})
|
||
if err != nil { return err }
|
||
defer tx.Rollback(ctx)
|
||
if err := w.versionRepo.Create(ctx, tx, repository.CreateArticleVersionInput{
|
||
ArticleID: msg.ArticleID, VersionNo: 1, Title: parsed.Title,
|
||
HTMLContent: parsed.HTML, MarkdownContent: parsed.Markdown, WordCount: parsed.WordCount,
|
||
SourceLabel: "kol",
|
||
}); err != nil { return err }
|
||
if err := w.articleRepo.MarkCompleted(ctx, tx, msg.TenantID, msg.ArticleID, 1); err != nil { return err }
|
||
if err := w.quotaRepo.Confirm(ctx, tx, msg.TenantID, task.QuotaReservationID); err != nil { return err }
|
||
if err := w.usageRepo.MarkCompletedByTask(ctx, tx, msg.TenantID, msg.TaskID, msg.ArticleID); err != nil { return err }
|
||
if err := w.taskRepo.MarkCompleted(ctx, tx, msg.TenantID, msg.TaskID); err != nil { return err }
|
||
return tx.Commit(ctx)
|
||
}
|
||
|
||
func (w *KolGenerationWorker) onFailure(ctx context.Context, msg messaging.ArticleGenerateMessage, cause error) {
|
||
tx, _ := w.pool.BeginTx(ctx, pgx.TxOptions{})
|
||
defer tx.Rollback(ctx)
|
||
_ = w.taskRepo.MarkFailed(ctx, tx, msg.TenantID, msg.TaskID, cause.Error())
|
||
_ = w.quotaRepo.Refund(ctx, tx, msg.TenantID, msg.TaskID)
|
||
_ = w.usageRepo.MarkFailedByTask(ctx, tx, msg.TenantID, msg.TaskID, cause.Error())
|
||
_ = w.articleRepo.MarkFailed(ctx, tx, msg.TenantID, msg.ArticleID)
|
||
_ = tx.Commit(ctx)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Dispatcher routing**
|
||
|
||
In the existing `article_generation_worker.go` (or wherever dispatch switch lives), route by `task_type`:
|
||
|
||
```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 |
|