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