fix all
This commit is contained in:
parent
7ed9f50a2c
commit
2e0b625735
|
|
@ -1,24 +1,32 @@
|
||||||
# --- secrets / env ---
|
# --- secrets / env ---
|
||||||
deploy/.env
|
|
||||||
deploy/.env.dev
|
|
||||||
deploy/.env.prod
|
|
||||||
infra/.env
|
|
||||||
*.env
|
*.env
|
||||||
**/.env
|
**/.env
|
||||||
!.env.example
|
!.env.example
|
||||||
!*.env.example
|
!*.env.example
|
||||||
|
deploy/.env
|
||||||
|
deploy/.env.dev
|
||||||
|
deploy/.env.prod
|
||||||
|
infra/.env
|
||||||
|
old/deploy/.env
|
||||||
|
old/deploy/.env.dev
|
||||||
|
old/deploy/.env.prod
|
||||||
|
old/infra/.env
|
||||||
|
|
||||||
# --- runtime / logs ---
|
# --- runtime / logs ---
|
||||||
.run/
|
.run/
|
||||||
|
old/.run/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# --- build artifacts ---
|
# --- build artifacts ---
|
||||||
backend/bin/
|
|
||||||
backend/web/dist/
|
|
||||||
backend/dev-console/dist/
|
|
||||||
frontend/dist/
|
|
||||||
dist/
|
dist/
|
||||||
backend/web/public/downloads/*.zip
|
**/dist/
|
||||||
|
apps/*/dist/
|
||||||
|
apps/web/dist/
|
||||||
|
services/*/bin/
|
||||||
|
old/backend/bin/
|
||||||
|
old/backend/web/dist/
|
||||||
|
old/backend/dev-console/dist/
|
||||||
|
old/backend/web/public/downloads/*.zip
|
||||||
|
|
||||||
# --- dependencies ---
|
# --- dependencies ---
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
---
|
||||||
|
name: spec-driven
|
||||||
|
description: >
|
||||||
|
Spec-driven product workflow: requirements → spec.md → plan.md → small task.md files
|
||||||
|
with explicit inputs/outputs (≈100–200 line changes each). Use when the user wants to
|
||||||
|
restart cleanly, write requirements, draft a product spec, break a plan into tasks,
|
||||||
|
run /spec-driven, or says 重新開始 / 需求文件 / 拆 task / 從需求做到 task.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spec-Driven Workflow
|
||||||
|
|
||||||
|
Freeze **what we want**, then **how it behaves**, then **how we deliver**, then **one-step tasks**. Never invent code while a phase is unapproved.
|
||||||
|
|
||||||
|
## Artifact root
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/product/<slug>/
|
||||||
|
README.md # phase status
|
||||||
|
requirements.md
|
||||||
|
spec.md # after requirements approved
|
||||||
|
plan.md # after spec approved
|
||||||
|
tasks/
|
||||||
|
INDEX.md
|
||||||
|
T001-short-slug.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Templates live in this skill:
|
||||||
|
|
||||||
|
```text
|
||||||
|
references/templates/{requirements,spec,plan,task}.md
|
||||||
|
references/workflow.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional scaffold:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash .grok/skills/spec-driven/scripts/init-run.sh <slug> "<one-line goal>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invocation
|
||||||
|
|
||||||
|
| Form | Behavior |
|
||||||
|
|------|----------|
|
||||||
|
| `/spec-driven` | Ask slug + goal if missing; continue current phase |
|
||||||
|
| `/spec-driven <slug>` | Work on that run |
|
||||||
|
| `/spec-driven <slug> --status` | Report phase + file presence only |
|
||||||
|
| `/spec-driven <slug> --phase N` | Only if prior artifacts exist; still require user OK for *this* phase output |
|
||||||
|
|
||||||
|
Phases: `0=init`, `1=requirements`, `2=spec`, `3=plan`, `4=tasks`, `5=ready-to-implement`.
|
||||||
|
|
||||||
|
## Hard gates
|
||||||
|
|
||||||
|
1. **One phase per turn cycle.** Write the current phase document(s), summarize for the user, **stop**.
|
||||||
|
2. Advance only when the user clearly approves (e.g. 「需求 OK」「spec 通過」「可以拆 task」).
|
||||||
|
3. Do **not** implement product code until:
|
||||||
|
- `plan.md` approved, **and**
|
||||||
|
- `tasks/INDEX.md` exists with at least one `todo` task, **and**
|
||||||
|
- user says to implement a specific `T###` (or all).
|
||||||
|
4. Do **not** skip templates' required sections.
|
||||||
|
5. If prior phase file is missing → stop and say which file is needed.
|
||||||
|
|
||||||
|
## Step 0 — Init
|
||||||
|
|
||||||
|
1. Resolve `slug` (kebab-case, e.g. `haixun-console`).
|
||||||
|
2. If `docs/product/<slug>/` missing → run `init-run.sh` or create the same structure.
|
||||||
|
3. Write/update `README.md` with goal, phase=`requirements`, dates.
|
||||||
|
4. Ask user to confirm scope in one sentence if ambiguous.
|
||||||
|
|
||||||
|
## Step 1 — Requirements
|
||||||
|
|
||||||
|
1. Read templates + existing product context (`docs/architecture.md`, related plans, `AGENTS.md` constraints).
|
||||||
|
2. Write `requirements.md` using the template (product language only).
|
||||||
|
3. **Forbidden in requirements:** API paths, Go package paths, PR lists, implementation steps.
|
||||||
|
4. Present: In/Out scope, P0 vs later, success criteria, open questions.
|
||||||
|
5. Set README phase note: `awaiting-requirements-approval`.
|
||||||
|
6. **STOP.**
|
||||||
|
|
||||||
|
## Step 2 — Spec
|
||||||
|
|
||||||
|
Preconditions: `requirements.md` exists and user approved.
|
||||||
|
|
||||||
|
1. Write `spec.md` from requirements (behavior contracts).
|
||||||
|
2. Include Retain / Replace / Remove vs current codebase where relevant.
|
||||||
|
3. Follow project rules when specifying contracts: JSON envelope, `page/pageSize`, unix nanoseconds UTC, job guarded updates, auth header matrix.
|
||||||
|
4. **Forbidden in spec:** milestone calendars, task IDs, "we'll refactor later" without a concrete behavior.
|
||||||
|
5. Present summary → README `awaiting-spec-approval` → **STOP.**
|
||||||
|
|
||||||
|
## Step 3 — Plan
|
||||||
|
|
||||||
|
Preconditions: `spec.md` approved.
|
||||||
|
|
||||||
|
1. Write `plan.md` with milestones M0…Mn, dependencies, risks, verification commands.
|
||||||
|
2. Map each milestone to a **task id range** (e.g. M1 → T010–T019) but do not write full task files yet unless user asked to combine plan+tasks in one approval (default: plan only).
|
||||||
|
3. Present summary → **STOP.**
|
||||||
|
|
||||||
|
## Step 4 — Tasks
|
||||||
|
|
||||||
|
Preconditions: `plan.md` approved.
|
||||||
|
|
||||||
|
1. Create `tasks/INDEX.md` and one file per task from `references/templates/task.md`.
|
||||||
|
2. **Task size rule:** estimate **100–200 lines** of production code change. If larger, split. If smaller and independent, still OK but avoid micro-noise (<~30 lines unless critical fix).
|
||||||
|
3. Every task MUST have: Goal, Inputs, Outputs (paths + behavior), Out of scope, Acceptance checks (commands), Depends on.
|
||||||
|
4. No vague tasks ("improve UX", "cleanup backend").
|
||||||
|
5. Present INDEX + count → **STOP** until user approves task breakdown.
|
||||||
|
6. On approval: README phase=`ready-to-implement`.
|
||||||
|
|
||||||
|
## Step 5 — Implement (only when asked)
|
||||||
|
|
||||||
|
1. Pick exactly one `T###` (or user-specified set).
|
||||||
|
2. Mark INDEX `doing` → implement only that task's Outputs → acceptance → `done`.
|
||||||
|
3. Do not pull in neighbor tasks "while you're there" unless user expands scope.
|
||||||
|
4. Prefer `make test` / `go test ./...` / `make web-build` as listed in the task.
|
||||||
|
|
||||||
|
## Status reporting (`--status`)
|
||||||
|
|
||||||
|
Print:
|
||||||
|
|
||||||
|
- slug, goal, phase
|
||||||
|
- which of requirements/spec/plan/tasks exist
|
||||||
|
- task counts by status from INDEX
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Chinese or English matching the user; product docs for this repo default to **繁體中文**.
|
||||||
|
- Complete sentences in requirements/spec; tasks can be tighter checklists.
|
||||||
|
- Link related existing docs; do not duplicate architecture textbooks into requirements.
|
||||||
|
|
||||||
|
## Anti-patterns
|
||||||
|
|
||||||
|
- Writing all four layers in one go without approval
|
||||||
|
- Coding during requirements/spec
|
||||||
|
- Tasks without file paths or acceptance commands
|
||||||
|
- Treating `docs/post-project-plan.md` as approved requirements without copying into this pipeline
|
||||||
|
- Inflating a "whole product" requirements doc without P0/P1/P2 prioritization
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Plan: <名稱>
|
||||||
|
|
||||||
|
> Status: `draft` | `approved`
|
||||||
|
> Source: `docs/product/<slug>/spec.md`
|
||||||
|
> Last updated: `YYYY-MM-DD`
|
||||||
|
|
||||||
|
## 1. 交付策略
|
||||||
|
|
||||||
|
<先穩什麼、再換什麼、最後加什麼 — 3–6 句>
|
||||||
|
|
||||||
|
## 2. 里程碑
|
||||||
|
|
||||||
|
### M0 — <名稱>
|
||||||
|
|
||||||
|
- **目標:**
|
||||||
|
- **完成定義:**
|
||||||
|
- **建議 task 區間:** T0xx–T0yy
|
||||||
|
- **驗證:**
|
||||||
|
|
||||||
|
### M1 — …
|
||||||
|
|
||||||
|
## 3. 依賴
|
||||||
|
|
||||||
|
```text
|
||||||
|
M0 → M1 → M2
|
||||||
|
↘ M3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 風險與緩解
|
||||||
|
|
||||||
|
| 風險 | 緩解 |
|
||||||
|
|------|------|
|
||||||
|
| | |
|
||||||
|
|
||||||
|
## 5. 刻意不做(本 plan 週期內)
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## 6. 全域驗證指令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./...
|
||||||
|
make web-build # 若動前端
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 批准
|
||||||
|
|
||||||
|
- [ ] Plan 已批准:
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Requirements: <產品 / 功能名稱>
|
||||||
|
|
||||||
|
> Status: `draft` | `approved`
|
||||||
|
> Slug: `<slug>`
|
||||||
|
> Last updated: `YYYY-MM-DD`
|
||||||
|
|
||||||
|
## 1. 一句話
|
||||||
|
|
||||||
|
<用一句話說明這個產品或這次範圍要達成什麼>
|
||||||
|
|
||||||
|
## 2. 誰用、在什麼情境
|
||||||
|
|
||||||
|
| 角色 | 目標 |
|
||||||
|
|------|------|
|
||||||
|
| | |
|
||||||
|
|
||||||
|
## 3. 背景與動機(As-is → 為什麼改)
|
||||||
|
|
||||||
|
- **現況問題:**
|
||||||
|
- **不改的後果:**
|
||||||
|
- **參考文件(非需求本體):**
|
||||||
|
|
||||||
|
## 4. 目標(To-be)
|
||||||
|
|
||||||
|
### 4.1 必須達成(P0)
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
|
||||||
|
### 4.2 應該有(P1)
|
||||||
|
|
||||||
|
1.
|
||||||
|
|
||||||
|
### 4.3 以後再說(P2)
|
||||||
|
|
||||||
|
1.
|
||||||
|
|
||||||
|
## 5. 範圍
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### Out of scope(明確不做)
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## 6. 使用者故事(可驗收)
|
||||||
|
|
||||||
|
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|
||||||
|
|----|--------|---------|--------|----------|
|
||||||
|
| US-01 | | | | |
|
||||||
|
|
||||||
|
## 7. 成功標準
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## 8. 約束與假設
|
||||||
|
|
||||||
|
- 技術 / 合規 / 時程約束:
|
||||||
|
- 假設(未驗證當真):
|
||||||
|
|
||||||
|
## 9. 風險
|
||||||
|
|
||||||
|
| 風險 | 影響 | 緩解(需求層,非實作細節) |
|
||||||
|
|------|------|---------------------------|
|
||||||
|
| | | |
|
||||||
|
|
||||||
|
## 10. 開放問題
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
|
||||||
|
## 11. 批准
|
||||||
|
|
||||||
|
- [ ] 需求已批准(日期 / 誰):
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
# Spec: <名稱>
|
||||||
|
|
||||||
|
> Status: `draft` | `approved`
|
||||||
|
> Source: `docs/product/<slug>/requirements.md`
|
||||||
|
> Last updated: `YYYY-MM-DD`
|
||||||
|
|
||||||
|
## 1. 摘要
|
||||||
|
|
||||||
|
<2–4 句,對齊需求 P0>
|
||||||
|
|
||||||
|
## 2. 名詞與實體
|
||||||
|
|
||||||
|
| 名詞 | 定義 |
|
||||||
|
|------|------|
|
||||||
|
| | |
|
||||||
|
|
||||||
|
## 3. 狀態機(若有)
|
||||||
|
|
||||||
|
```text
|
||||||
|
stateA --> stateB : 觸發條件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 使用者流程(行為)
|
||||||
|
|
||||||
|
### 流程 A:…
|
||||||
|
|
||||||
|
1. 使用者…
|
||||||
|
2. 系統…
|
||||||
|
3. 失敗時…
|
||||||
|
|
||||||
|
## 5. 介面契約
|
||||||
|
|
||||||
|
### 5.1 HTTP / API(若適用)
|
||||||
|
|
||||||
|
- Auth:
|
||||||
|
- Envelope:`code/message/data/error`
|
||||||
|
- 列表:`page` / `pageSize` → `pagination` + `list`
|
||||||
|
- 時間:unix nanoseconds UTC
|
||||||
|
|
||||||
|
| Method | Path | 用途 | 主要 request/response 欄位 |
|
||||||
|
|--------|------|------|---------------------------|
|
||||||
|
| | | | |
|
||||||
|
|
||||||
|
### 5.2 前端頁面 / 導覽(若適用)
|
||||||
|
|
||||||
|
| 路由 / hash | 目的 | 主要操作 |
|
||||||
|
|-------------|------|----------|
|
||||||
|
| | | |
|
||||||
|
|
||||||
|
### 5.3 Job / 背景任務(若適用)
|
||||||
|
|
||||||
|
| Template type | Worker | 觸發 | 成功結果 |
|
||||||
|
|---------------|--------|------|----------|
|
||||||
|
| | | | |
|
||||||
|
|
||||||
|
## 6. 與現況對照
|
||||||
|
|
||||||
|
| 能力 | Retain | Replace | Remove | 備註 |
|
||||||
|
|------|--------|---------|--------|------|
|
||||||
|
| | | | | |
|
||||||
|
|
||||||
|
## 7. 非功能
|
||||||
|
|
||||||
|
- 安全 / 權限:
|
||||||
|
- 錯誤碼 scope:
|
||||||
|
- 可觀測性(job 進度、錯誤訊息):
|
||||||
|
- 效能邊界(可接受延遲 / 筆數上限):
|
||||||
|
|
||||||
|
## 8. 資料保存(邏輯層,非 Mongo 細節亦可)
|
||||||
|
|
||||||
|
| 實體 | 關鍵欄位 | 生命週期 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| | | |
|
||||||
|
|
||||||
|
## 9. 驗收場景(Given / When / Then)
|
||||||
|
|
||||||
|
1. **Given** … **When** … **Then** …
|
||||||
|
|
||||||
|
## 10. 開放規格問題
|
||||||
|
|
||||||
|
1.
|
||||||
|
|
||||||
|
## 11. 批准
|
||||||
|
|
||||||
|
- [ ] Spec 已批准:
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# T### — <短標題>
|
||||||
|
|
||||||
|
> Status: `todo` | `doing` | `done` | `blocked`
|
||||||
|
> Milestone: `M#`
|
||||||
|
> Est. change: `~NNN lines` (target 100–200)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
完成後系統應:<一句可驗證的 Done>
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- T###(若無:`—`)
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- 讀取 / 依賴:
|
||||||
|
- 檔案:
|
||||||
|
- 既有能力 / API:
|
||||||
|
- Spec 段落:
|
||||||
|
|
||||||
|
## Outputs
|
||||||
|
|
||||||
|
### 程式變更(預期路徑)
|
||||||
|
|
||||||
|
| 路徑 | 動作 | 說明 |
|
||||||
|
|------|------|------|
|
||||||
|
| | add/edit/delete | |
|
||||||
|
|
||||||
|
### 行為變更
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### API / 契約(若有)
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ] 指令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# e.g.
|
||||||
|
cd backend && go test ./path/...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
<可選:實作提示,非必做細節>
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Spec-Driven 四階段說明
|
||||||
|
|
||||||
|
## 為什麼要閘門
|
||||||
|
|
||||||
|
邊想邊寫碼會讓範圍漂掉。四份文件各解一個問題:
|
||||||
|
|
||||||
|
| 文件 | 回答的問題 | 讀者 |
|
||||||
|
|------|------------|------|
|
||||||
|
| requirements | 我們要什麼?不要什麼? | 產品 / 你自己 |
|
||||||
|
| spec | 系統對外行為是什麼? | 實作者 + 審查 |
|
||||||
|
| plan | 分幾段交付、依賴與風險? | 排程 |
|
||||||
|
| task | 下一小時具體改什麼? | 動手 agent / 人 |
|
||||||
|
|
||||||
|
## 閘門 checklist
|
||||||
|
|
||||||
|
### 需求通過
|
||||||
|
|
||||||
|
- [ ] 一句話產品目標清楚
|
||||||
|
- [ ] In / Out of scope 無互相打架
|
||||||
|
- [ ] P0 可獨立交付價值
|
||||||
|
- [ ] 成功標準可驗收(不是「更好用」)
|
||||||
|
- [ ] 開放問題已列出(可帶預設假設)
|
||||||
|
|
||||||
|
### Spec 通過
|
||||||
|
|
||||||
|
- [ ] 主要實體與狀態可畫出
|
||||||
|
- [ ] 關鍵使用者路徑有行為描述
|
||||||
|
- [ ] Retain / Replace / Remove 對現況有交代
|
||||||
|
- [ ] 錯誤、權限、時間、分頁等跨切約定有寫
|
||||||
|
- [ ] 沒有偷偷寫實作步驟當規格
|
||||||
|
|
||||||
|
### Plan 通過
|
||||||
|
|
||||||
|
- [ ] 里程碑順序合理、依賴無環
|
||||||
|
- [ ] 每里程碑有驗收方式
|
||||||
|
- [ ] 風險與不做清單明確
|
||||||
|
- [ ] 對應 task 區間可預估
|
||||||
|
|
||||||
|
### Tasks 通過
|
||||||
|
|
||||||
|
- [ ] 每 task 單一 Done
|
||||||
|
- [ ] 預估 100–200 行級(過大已拆)
|
||||||
|
- [ ] Inputs / Outputs / 不做 / 驗收齊
|
||||||
|
- [ ] INDEX 可追蹤狀態
|
||||||
|
|
||||||
|
## 與現有技能的關係
|
||||||
|
|
||||||
|
| 技能 | 關係 |
|
||||||
|
|------|------|
|
||||||
|
| `/design` | 偏設計文件 + PR Plan;本 skill 偏產品需求→小 task |
|
||||||
|
| `/execute-plan` | 可在 **plan/tasks 批准後** 用來並行實作,非必須 |
|
||||||
|
| `/check-work` | 單一 task 做完後驗證 |
|
||||||
|
|
||||||
|
第一版 **不必** 自動串 execute-plan;使用者說「實作 T003」即可。
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage: init-run.sh <slug> "<one-line goal>"
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||||
|
SLUG="${1:-}"
|
||||||
|
GOAL="${2:-}"
|
||||||
|
|
||||||
|
if [[ -z "$SLUG" ]]; then
|
||||||
|
echo "usage: $0 <slug> \"<one-line goal>\"" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DIR="$ROOT/docs/product/$SLUG"
|
||||||
|
mkdir -p "$DIR/tasks"
|
||||||
|
|
||||||
|
if [[ ! -f "$DIR/README.md" ]]; then
|
||||||
|
cat >"$DIR/README.md" <<EOF
|
||||||
|
# Product run: $SLUG
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|--------|
|
||||||
|
| Goal | ${GOAL:-TBD} |
|
||||||
|
| Phase | requirements |
|
||||||
|
| Status | draft |
|
||||||
|
| Updated | $(date -u +%Y-%m-%d) |
|
||||||
|
|
||||||
|
## Phase legend
|
||||||
|
|
||||||
|
\`requirements\` → \`spec\` → \`plan\` → \`tasks\` → \`ready-to-implement\`
|
||||||
|
|
||||||
|
## How to continue
|
||||||
|
|
||||||
|
\`\`\`text
|
||||||
|
/spec-driven $SLUG
|
||||||
|
/spec-driven $SLUG --status
|
||||||
|
\`\`\`
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$DIR/tasks/INDEX.md" ]]; then
|
||||||
|
cat >"$DIR/tasks/INDEX.md" <<'EOF'
|
||||||
|
# Tasks INDEX
|
||||||
|
|
||||||
|
| ID | Title | Milestone | Status | Est. lines |
|
||||||
|
|----|-------|-----------|--------|------------|
|
||||||
|
| — | (尚未拆 task;plan 批准後填入) | | | |
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Initialized $DIR"
|
||||||
431
AGENTS.md
431
AGENTS.md
|
|
@ -1,413 +1,46 @@
|
||||||
|
# Agent 規範(重構主線)
|
||||||
|
|
||||||
# Agent Handoff Notes
|
## 封存與主線
|
||||||
|
|
||||||
這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。
|
- **`old/`**:舊系統整包封存,**只讀參考**。不要在 `old/` 修功能當交付。
|
||||||
|
- **新碼**:只寫在 repo 根下新目錄(`apps/`、`services/` 等,以 `docs/product` 為準)。
|
||||||
|
- **產品真相**:`docs/product/<slug>/`(requirements → spec → plan → tasks)。
|
||||||
|
- **流程**:`/spec-driven`(`.grok/skills/spec-driven/`)。
|
||||||
|
|
||||||
## 核心原則
|
## 交付順序(硬性)
|
||||||
|
|
||||||
- 全系統時間一律 **UTC+0**;寫入 Mongo / API 的時間欄位一律 **unix nanoseconds**(`int64`)。排程的 `timezone` 只用於 cron 解讀與下發 payload,不作為儲存時區。
|
1. Phase A 地基
|
||||||
- 複製模式,不複製舊業務。
|
2. Phase B 前端 + mock(操作順再往下)
|
||||||
- `logic` 做 API 編排,`model/usecase` 做可重複使用能力。
|
3. Phase C 接真 API
|
||||||
- provider adapter 不讀 setting、不碰 Mongo、不知道 HTTP。
|
4. Phase D 業務加深
|
||||||
- setting 是通用 key-value model,不依賴 AI 或其他業務。
|
|
||||||
- token / API key 第一版每次 request 帶入,不寫入 config。
|
|
||||||
- SSE contract 由本服務 normalize,前端不要讀 provider 原始 chunk。
|
|
||||||
- JSON API 必須使用 `code/message/data/error` envelope 與 `SSCCCDDD` 錯誤碼。
|
|
||||||
- 列表 API 必須使用 `page/pageSize` query,並在 `data` 回傳 `pagination/list`。
|
|
||||||
- Job 狀態轉移必須使用 guarded/conditional update;不要在 API/worker 直接裸 `Update` 覆蓋 job 狀態。
|
|
||||||
- Redis job lock 的 value 是 `workerID`;release / refresh 必須檢查 owner,長任務必須 heartbeat。
|
|
||||||
- Auth 目前是 native email/password + JWT,不包含 OAuth / OTP / MFA / Zitadel。不要為了相容 template-monorepo 把重依賴搬進來。
|
|
||||||
- AI provider token 與會員 JWT 是兩種不同 token;AI token 每次 request header 帶入,會員 JWT 由 `/api/v1/auth/*` 簽發。
|
|
||||||
|
|
||||||
## 設計文件
|
B 未過關前,不大寫業務後端。
|
||||||
|
|
||||||
- `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。
|
## 後端契約精神(實作時遵守)
|
||||||
- `docs/post-project-plan.md`:發文計畫(文案任務 Flow A 砍掉重做)— 主題發想、風格分析、矩陣產 N 篇草稿、定時發送。
|
|
||||||
- `docs/scan-placement-plan.md`:海巡獲客(流程 B)— 知識圖譜、Brave 擴展、雙軌爬取(7 天重點 / 30 天補充)、產品匹配、島民交接。
|
|
||||||
|
|
||||||
## 新增 API 流程
|
- 時間:**UTC**;儲存 **unix nanoseconds** `int64`。
|
||||||
|
- JSON:`code` / `message` / `data` / `error`;成功碼 `102000`。
|
||||||
|
- 列表:`page` / `pageSize` → `pagination` + `list`。
|
||||||
|
- Job:狀態 **guarded** 更新;Redis lock value = `workerID`。
|
||||||
|
- 會員 JWT vs AI provider token 分離。
|
||||||
|
- **禁止**未實作 API 回成功空資料。
|
||||||
|
|
||||||
1. 修改 `generate/api/*.api`。
|
## 前端精神(Harbor Desk)
|
||||||
2. 優先使用 `make gen-api` 重新產生 handler/logic/types。
|
|
||||||
3. 若手寫 handler,仍需遵守 `response.Write` 與 validator 流程。
|
|
||||||
4. SSE endpoint 不使用 `response.Write`,直接輸出 `text/event-stream`。
|
|
||||||
5. 更新 `README.md` 的 API 與架構說明。
|
|
||||||
|
|
||||||
## Response / Error Code
|
- 新殼在 `apps/web`;不引入 MUI/Ant/Chakra。
|
||||||
|
- 不複製任天堂 / Nook UI;不用 emoji 當主 icon。
|
||||||
|
- 字體:Inter + Taipei Sans TC。
|
||||||
|
- mock | live 同一 repository 介面。
|
||||||
|
- 詳見 `docs/product/haixun-console/spec.md`。
|
||||||
|
|
||||||
錯誤碼格式是 `SSCCCDDD`:
|
## 參考舊碼
|
||||||
|
|
||||||
|
需要對照實作時:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SS = scope
|
old/backend/ # 舊 Go / web / node workers
|
||||||
CCC = category
|
old/AGENTS.md # 舊詳細規範
|
||||||
DDD = detail
|
old/docs/ # 舊計劃文件
|
||||||
```
|
```
|
||||||
|
|
||||||
目前 scope:
|
複製模式可以,**不要**把半成品與死碼整包搬回主線。
|
||||||
|
|
||||||
```text
|
|
||||||
10 = Facade
|
|
||||||
32 = Setting
|
|
||||||
33 = AI
|
|
||||||
34 = Job
|
|
||||||
35 = Auth
|
|
||||||
36 = Member
|
|
||||||
37 = Permission
|
|
||||||
```
|
|
||||||
|
|
||||||
建立錯誤時使用:
|
|
||||||
|
|
||||||
```go
|
|
||||||
errs.For(code.AI).InputMissingRequired("缺少 AI provider token")
|
|
||||||
errs.For(code.Setting).ResNotFound("找不到設定")
|
|
||||||
errs.For(code.Job).ResInvalidState("job state changed; update rejected")
|
|
||||||
errs.For(code.Auth).AuthUnauthorized("missing bearer token")
|
|
||||||
```
|
|
||||||
|
|
||||||
不要直接手寫 `33104000` 這種數字,也不要回傳裸 `error` 給 handler 後讓使用者看到內部錯誤。
|
|
||||||
|
|
||||||
## Pagination
|
|
||||||
|
|
||||||
列表 API 使用:
|
|
||||||
|
|
||||||
```text
|
|
||||||
?page=1&pageSize=10
|
|
||||||
```
|
|
||||||
|
|
||||||
response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 102000,
|
|
||||||
"message": "SUCCESS",
|
|
||||||
"data": {
|
|
||||||
"pagination": {
|
|
||||||
"total": 100,
|
|
||||||
"page": 1,
|
|
||||||
"pageSize": 10,
|
|
||||||
"totalPages": 10
|
|
||||||
},
|
|
||||||
"list": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`page/pageSize` 必須是 server 正規化後的值。不要使用 `offset/limit/items`。
|
|
||||||
|
|
||||||
## 新增 Model 流程
|
|
||||||
|
|
||||||
模組放在:
|
|
||||||
|
|
||||||
```text
|
|
||||||
internal/model/<module>/
|
|
||||||
domain/entity
|
|
||||||
domain/repository
|
|
||||||
domain/usecase
|
|
||||||
repository
|
|
||||||
usecase
|
|
||||||
```
|
|
||||||
|
|
||||||
依賴方向:
|
|
||||||
|
|
||||||
```text
|
|
||||||
handler -> logic -> model/domain/usecase
|
|
||||||
model/usecase -> model/domain/repository
|
|
||||||
model/repository -> Mongo / Redis
|
|
||||||
```
|
|
||||||
|
|
||||||
不要讓 `logic` import `model/<module>/repository`。
|
|
||||||
|
|
||||||
## Auth / Permission 擴充
|
|
||||||
|
|
||||||
目前已接:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /api/v1/auth/register
|
|
||||||
POST /api/v1/auth/login
|
|
||||||
POST /api/v1/auth/refresh
|
|
||||||
POST /api/v1/auth/logout # requires member JWT
|
|
||||||
GET /api/v1/members/me
|
|
||||||
PATCH /api/v1/members/me
|
|
||||||
GET /api/v1/permissions/catalog
|
|
||||||
GET /api/v1/permissions/me
|
|
||||||
```
|
|
||||||
|
|
||||||
Auth matrix(`internal/handler/routes.go`):
|
|
||||||
|
|
||||||
| 路由 | 需要會員 JWT |
|
|
||||||
|------|----------------|
|
|
||||||
| `GET /api/v1/health` | 否 |
|
|
||||||
| `POST /api/v1/auth/register/login/refresh` | 否 |
|
|
||||||
| `POST /api/v1/auth/logout` | 是(`Authorization`) |
|
|
||||||
| `GET /api/v1/ai/providers` | 否 |
|
|
||||||
| `POST /api/v1/ai/chat/stream/models` | 是(`X-Member-Authorization`)+ provider token(`Authorization`) |
|
|
||||||
| `/api/v1/members/*`、`/api/v1/permissions/me` | 是(`Authorization`) |
|
|
||||||
| `/api/v1/permissions/catalog`、`/api/v1/settings/*`、`/api/v1/jobs*`、`/api/v1/job/*` | 是(`Authorization`) |
|
|
||||||
|
|
||||||
規則:
|
|
||||||
|
|
||||||
- 保護路由用 `internal/middleware.Auth`(`Authorization: Bearer <access_token>`);AI 變更路由用 `middleware.MemberAuth`(`X-Member-Authorization`),因 `Authorization` 保留給 provider API key。
|
|
||||||
- logic 從 `authctx.ActorFromContext` 讀 `tenant_id` / `uid`。
|
|
||||||
- 不要在 handler 直接 parse JWT;token 驗證集中在 `model/auth/usecase`。
|
|
||||||
- 密碼只存 bcrypt hash,不回傳、不寫 log。
|
|
||||||
- `members.roles` 第一版是簡化 role key。正式 RBAC 可逐步補 roles collection,但不要破壞 `role_permissions` 的 tenant + role_key contract。
|
|
||||||
- `Auth.DevHeaderFallback` 只給本機開發,正式環境應關閉。
|
|
||||||
|
|
||||||
## AI Provider 擴充
|
|
||||||
|
|
||||||
新增 provider 時:
|
|
||||||
|
|
||||||
1. 在 `internal/model/ai/domain/enum` 新增 provider id。
|
|
||||||
2. 在 `internal/model/ai/provider` 新增 adapter。
|
|
||||||
3. 在 `internal/model/ai/usecase` registry 註冊 provider 與 models。
|
|
||||||
4. 確保 adapter 回傳統一 `StreamEvent`。
|
|
||||||
5. 不要改 `logic/ai` 的 SSE 格式。
|
|
||||||
|
|
||||||
## Job Worker 擴充
|
|
||||||
|
|
||||||
新增 job step 時優先註冊 runner handler:
|
|
||||||
|
|
||||||
```go
|
|
||||||
runner.RegisterStepHandler("analyze_8d", func(ctx context.Context, step job.StepContext) error {
|
|
||||||
if err := step.Heartbeat(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// do work, check cancel via job usecase if needed
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
規則:
|
|
||||||
|
|
||||||
- Handler 不要直接操作 Mongo / Redis,透過 job usecase 更新進度、完成、失敗或取消。
|
|
||||||
- 長任務每個 checkpoint 呼叫 `StepContext.Heartbeat` 或 `RefreshRunLock`。
|
|
||||||
- 收到 cancel signal 後呼叫 `AcknowledgeCancel(jobId, workerID)`,不要自行把狀態改成 `cancelled`。
|
|
||||||
- release lock 時必須帶 `workerID`;不要新增無 owner 的 release helper。
|
|
||||||
|
|
||||||
## 前端設計規則(`web/`)
|
|
||||||
|
|
||||||
巡樓 Console 前端在 `haixun-backend/web/`,視覺為**沉穩田園巡檢台**(動森感:天空、雲朵、奶油卡片、青綠 brand;**不是**任天堂 UI 複製)。
|
|
||||||
|
|
||||||
**字體固定** Inter + Taipei Sans TC;圖示僅 `AcIcon` / `AuthDecor` 內原創 SVG 線條圖。**禁止** emoji、貼圖 JPG、咖啡色木質頂欄、Nook / 任天堂命名。樣式集中在 `index.css`(`--hx-*` token + `hx-*` / `ac-*` / `auth-*` class)。
|
|
||||||
|
|
||||||
不要把舊 Next.js / `template-monorepo` UI 搬進來,也不要引入重型 UI 框架。
|
|
||||||
|
|
||||||
### 視覺架構(登入前後共用)
|
|
||||||
|
|
||||||
```text
|
|
||||||
全頁背景 .hx-scene 灰藍天空 → 淡草地單一漸層(淺/深各一套,見 index.css)
|
|
||||||
裝飾層 SceneDecor 雲朵(多朵緩動)+ 淡光暈 + 小葉子;登入與 Layout 共用
|
|
||||||
奶油卡片 .auth-ticket 2px line 邊框、圓角 2rem、surface 底、可選 .ac-dialog-texture 點陣
|
|
||||||
頂部品牌列 圖示 .auth-ticket-icon(brand-soft 底)+ ink 標題;不用獨立色塊 ribbon
|
|
||||||
主內容 表單或 Outlet;內文頁用 PageTitle / Card(綠色 .ac-title-bar 僅內容區小標)
|
|
||||||
桌面側欄 .ac-pocket-device 掌上終端外框;PATROL PAD 狀態列;固定尺寸 + .ac-pocket-scroll 內捲
|
|
||||||
手機 .ac-dock 底部最多 4 格 +「更多」sheet
|
|
||||||
```
|
|
||||||
|
|
||||||
| 區域 | 元件 | 關鍵 class |
|
|
||||||
|------|------|------------|
|
|
||||||
| 未登入 | `AuthShell` + `LoginPage` / `RegisterPage` | `hx-scene` `auth-scene` `auth-ticket` `auth-welcome` `auth-shell-form` |
|
|
||||||
| 已登入外殼 | `Layout` | `hx-scene` `ac-app-shell` `ac-app-header` `auth-ticket` `ac-app-main-inner` |
|
|
||||||
| 背景裝飾 | `AuthDecor.tsx` → `SceneDecor` | `hx-scene-deco` `auth-cloud--*` |
|
|
||||||
| 品牌小圖 | `AuthTicketIcon` | `auth-ticket-icon`(小屋+樹,原創 SVG) |
|
|
||||||
| 側欄導覽 | `Layout` + `navApps` | `ac-pocket-device` `ac-app-tile` |
|
|
||||||
| 手機導覽 | `MobileBottomNav` | `ac-dock` |
|
|
||||||
|
|
||||||
**登入頁刻意不做的事**:上方不要獨立大色塊 header;表單上方**不要**再放 `ac-title-bar`「登入」大牌(品牌已在 `auth-welcome`)。註冊頁同理可省略重複大標。
|
|
||||||
|
|
||||||
**已淘汰、勿加回**:`ac-island`(改用 `hx-scene`)、`ac-wood-bar` / 咖啡色木質頂欄、`public/ac/` 貼圖、Nook Phone 文案。
|
|
||||||
|
|
||||||
### 技術棧與指令
|
|
||||||
|
|
||||||
```text
|
|
||||||
web/
|
|
||||||
src/
|
|
||||||
api/ # API client(envelope、JWT refresh)
|
|
||||||
auth/ # AuthContext
|
|
||||||
components/ # Layout、AuthShell、AuthDecor、ui、ThemeToggle、MobileBottomNav、AcIcon
|
|
||||||
theme/ # ThemeContext(淺色 / 深色)
|
|
||||||
pages/ # 路由頁面
|
|
||||||
lib/ # acAssets(導覽 icon key)、jobStatus 等
|
|
||||||
index.css # 設計 token 與場景樣式唯一來源
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make web-dev # dev server :5173,proxy 到 :8890
|
|
||||||
make web-build # tsc + vite build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 字型
|
|
||||||
|
|
||||||
| 語言 | 字型 | 載入方式 |
|
|
||||||
|------|------|----------|
|
|
||||||
| 繁體中文 | **台北黑體 Taipei Sans TC** | npm `taipei-sans-tc`,在 `index.css` `@import` Regular + Bold |
|
|
||||||
| 英文 | **Inter**(與 simular.co 相同,Google Fonts 免費) | `web/index.html` link |
|
|
||||||
|
|
||||||
規則:
|
|
||||||
|
|
||||||
- `body` / 中文標題:`Inter` + `Taipei Sans TC` 混排(`--font-sans`)。
|
|
||||||
- 純英文裝飾字(導覽副標、Hero 小字):加 class `display-en`,使用 `--font-en`。
|
|
||||||
- 中文 `line-height` 維持 **1.7+**;不要用過細字重當標題(標題用 `font-bold` / `font-black`)。
|
|
||||||
- 只載入 Taipei Sans TC **Regular + Bold**,不要載入 Light,避免小字過細。
|
|
||||||
- 不要改回 Noto Sans TC,也不要手寫 `#333` 這類裸色碼當主色。
|
|
||||||
|
|
||||||
### 對比度與字級
|
|
||||||
|
|
||||||
- 內文、表頭、表單 label、卡片說明:優先 `text-ink` / `text-ink-secondary`,**不要**拿 `text-muted` 當主要閱讀文字。
|
|
||||||
- `text-muted` 只給次要提示(筆數、hint、placeholder 用 `text-subtle`)。
|
|
||||||
- 表單輸入字級 **15px**(`text-[15px]`),輸入框底用 `bg-surface` 白底,確保與背景拉開。
|
|
||||||
- 淺色 `muted` 約 `#5a6578`、深色約 `#b8c4d6`;改色時以「小字仍可舒適閱讀」為準,不要回到 `#94a3b8` 那種淡灰。
|
|
||||||
|
|
||||||
### 主題(淺色 / 深色)
|
|
||||||
|
|
||||||
- `ThemeProvider`(`src/theme/ThemeContext.tsx`)包住 App;偏好存 `localStorage` key:`haixun.theme`(`light` | `dark`)。
|
|
||||||
- `index.html` 內嵌 script 在 React 載入前設定 `data-theme`,避免閃爍。
|
|
||||||
- 所有顏色必須走 CSS 變數 `--hx-*`,再映射到 Tailwind `@theme`(`bg-canvas`、`text-brand` 等)。
|
|
||||||
- 切換按鈕用 `ThemeToggle`(`ac-btn-secondary` 樣式);`Layout` 頂欄與 `AuthShell` 右上角都要有。
|
|
||||||
- **禁止**在元件裡寫死 `bg-slate-*`、`text-emerald-*`、`bg-amber-*` 等 Tailwind 預設色;語意狀態用 `text-success` / `text-warning` / `text-danger` 或 `jobStatus.ts` 的 badge class。
|
|
||||||
|
|
||||||
淺色:低飽和灰藍天空 + 灰綠草地 + 奶油 `surface` + **brand 青綠**;深色:黃昏低對比、同一套 token 自動切換。頂欄與卡片內品牌區都用 **surface / ink / brand**,不要再用木色 `#c4a882` 當 header 底。
|
|
||||||
|
|
||||||
### 場景與卡片 class(維護時對照)
|
|
||||||
|
|
||||||
| Class | 用途 |
|
|
||||||
|-------|------|
|
|
||||||
| `.hx-scene` | 全頁天空→草地漸層(登入 + 已登入根節點) |
|
|
||||||
| `.hx-scene-deco` / `SceneDecor` | 背景雲、光暈、葉子(`pointer-events: none`) |
|
|
||||||
| `.auth-ticket` | 奶油主卡片外框(登入卡、已登入主內容區) |
|
|
||||||
| `.auth-welcome` | 卡片內品牌列:圖示 + 標題 + 一句 tagline,底部分隔線 |
|
|
||||||
| `.ac-app-header` | 已登入 sticky 頂欄:半透明 surface + blur,**非**木色 |
|
|
||||||
| `.ac-title-bar` | 內容區綠色小標題(裝置色漸層);用於 `PageTitle` 等,**不**用於登入頁表單上方大牌 |
|
|
||||||
| `.ac-pocket-device` | 側欄掌上終端;`--pocket-width`(28rem)、`--pocket-screen-height` 固定,內容在 `.ac-pocket-scroll` 捲動 |
|
|
||||||
| `.ac-app-tile` / `.ac-dock` | App 格導覽、手機底欄 |
|
|
||||||
| `.auth-shell-form` | 登入/註冊表單放大字級(僅 auth 頁) |
|
|
||||||
|
|
||||||
側欄標示用 **PATROL PAD** 等中性英文裝飾字(`display-en`);圖示僅 `AcIcon` SVG。
|
|
||||||
|
|
||||||
### 色彩 token(語意命名)
|
|
||||||
|
|
||||||
開發時只用這些 Tailwind class(值定義在 `web/src/index.css`):
|
|
||||||
|
|
||||||
| Token | 用途 |
|
|
||||||
|-------|------|
|
|
||||||
| `canvas` | 全頁背景 |
|
|
||||||
| `surface` / `surface-muted` | 卡片、輸入框底 |
|
|
||||||
| `ink` / `ink-secondary` / `muted` | 主文 / 次文 / 輔助 |
|
|
||||||
| `line` | 邊框 |
|
|
||||||
| `brand` / `brand-hover` / `brand-soft` | 主 CTA、active 導覽、連結 hover |
|
|
||||||
| `glow` | 裝飾色塊(`.glow-blob-alt`) |
|
|
||||||
| `success` / `warning` / `danger`(含 `*-soft`) | 狀態、錯誤、Job badge |
|
|
||||||
|
|
||||||
主按鈕一律 `Button variant="primary"` → `bg-brand`,不要用全黑按鈕。
|
|
||||||
|
|
||||||
### 圓角與陰影
|
|
||||||
|
|
||||||
```text
|
|
||||||
--radius-sm 0.75rem 小元素、code
|
|
||||||
--radius-md 1.25rem Input / Textarea
|
|
||||||
--radius-lg 1.75rem Card
|
|
||||||
--radius-xl 2.25rem Hero、QuickLink、StatCard
|
|
||||||
--radius-pill 9999px Button、Badge、導覽 pill
|
|
||||||
```
|
|
||||||
|
|
||||||
陰影用 utility:`shadow-card`(一般卡片)、`shadow-soft`(主按鈕、Hero、`.auth-ticket`)。內容 Hero 可用 `ac-bulletin` + `ac-hero-gradient` token;全頁裝飾雲朵走 `SceneDecor`,不要另加會打架的強色 blob。
|
|
||||||
|
|
||||||
### 共用元件(優先復用)
|
|
||||||
|
|
||||||
新頁面必須從 `src/components/ui.tsx` 組裝,不要另寫一套按鈕樣式:
|
|
||||||
|
|
||||||
| 元件 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `PageTitle` | 頁面標題 + 副標 |
|
|
||||||
| `Card` | 內容區塊 |
|
|
||||||
| `Field` + `Input` / `Textarea` | 表單 |
|
|
||||||
| `Button` | `primary` / `ghost` / `danger` / `soft` |
|
|
||||||
| `Badge` | 標籤 pill(`brand` / `sky` / `success` / `warning` / `danger` / `neutral`) |
|
|
||||||
| `StatCard` / `QuickLinkCard` | 總覽統計與快捷入口 |
|
|
||||||
| `ErrorText` / `CopyableId` | 錯誤與可複製 ID |
|
|
||||||
|
|
||||||
`Button` 必須渲染 `{children}`;文案用**中文動詞**(例:「建立背景任務」「重新載入任務列表」),不要留空白小框。
|
|
||||||
|
|
||||||
### RWD(手機)
|
|
||||||
|
|
||||||
- `< lg`:隱藏左側欄;**底部固定導覽**最多 **4 格**(總覽 / 任務 / 排程 / **更多**),不要把漢堡或 ⋯ 選單放在左上角。
|
|
||||||
- 「更多」以底部 sheet 展開:AI、模板、設定、會員、權限、主題切換、登出。
|
|
||||||
- 主內容加 `layout-main` 底部 padding,避開 tab bar + `safe-area-inset-bottom`。
|
|
||||||
- 寬表格包 `overflow-x-auto` + `min-w-*`,避免小螢幕擠爆版面。
|
|
||||||
|
|
||||||
### 版面與導覽
|
|
||||||
|
|
||||||
- 已登入(桌面):`Layout` = `hx-scene` 背景 + `SceneDecor` + `ac-app-header`(品牌 + 角色 chip + `ThemeToggle`)+ 左 `ac-pocket-device` + 右 `auth-ticket` 主內容 `Outlet`。
|
|
||||||
- 已登入(手機):同上頂欄;導覽走 `MobileBottomNav`(總覽/任務/排程/更多)。
|
|
||||||
- 側欄 App 來源:`src/lib/acAssets.ts` 的 `navApps`;圖示 key 對應 `AcIcon`。
|
|
||||||
- Active 導覽:`ac-app-tile--active`(brand-soft 底 + brand 字色);hover:`bg-brand-soft text-brand`。
|
|
||||||
- 未登入:`AuthShell` 置中 `auth-ticket` + 右上 `ThemeToggle`;`auth-welcome` 內品牌,表單緊接說明文字。
|
|
||||||
- 語氣:年輕、直接、短句;可帶「島民」「巡樓」等原創文案,避免企業八股與任天堂用語。
|
|
||||||
|
|
||||||
### API 與狀態
|
|
||||||
|
|
||||||
- JSON 一律走 `api/client.ts`(`code/message/data` envelope);需登入加 `{ auth: true }`。
|
|
||||||
- AI 路由用 `X-Member-Authorization`;provider token 用 `Authorization`(見後端 Auth matrix)。
|
|
||||||
- Job 狀態中文與 badge 色:`src/lib/jobStatus.ts`(`jobStatusLabel` / `jobStatusBadgeClass`),列表有進行中任務時可每 3 秒 refresh。
|
|
||||||
- 不要在前端 parse JWT;`uid` / `tenant_id` 從 `AuthContext` 讀。
|
|
||||||
|
|
||||||
### 新增頁面流程
|
|
||||||
|
|
||||||
1. 在 `App.tsx` 掛路由(需登入的放在 `Layout` 底下,自動享有 `hx-scene` + 頂欄 + 主內容 `auth-ticket`)。
|
|
||||||
2. 頁面內用 `PageTitle`(含 `.ac-title-bar` 小標)+ `Card` / `ac-bulletin` + `ui.tsx` 元件;色票只引用 semantic token。
|
|
||||||
3. 若需新語意色,**先**改 `index.css` 的 `--hx-*` 與 `@theme`,再改元件;不要頁面內硬編色碼。
|
|
||||||
4. 新導覽項:改 `acAssets.ts` 的 `navApps`,並在 `AcIcon` 補 SVG path。
|
|
||||||
5. 完成後執行 `make web-build`。
|
|
||||||
|
|
||||||
### 島民頁面互動(可推廣 runtime)
|
|
||||||
|
|
||||||
掛在 `Layout` 底下的新頁面**自動**支援島民操作,不需每頁手寫 executor。
|
|
||||||
|
|
||||||
模組入口:`web/src/lib/islander/index.ts`
|
|
||||||
|
|
||||||
| 層 | 職責 |
|
|
||||||
|----|------|
|
|
||||||
| `pageSnapshot` | 掃描 `.ac-app-shell` 內可互動元素,產生 `hx-*` ref |
|
|
||||||
| `islanderActions` | 解析/剝除 `islander-actions` JSON 區塊 |
|
|
||||||
| `actionExecutor` | 執行 navigate/click/fill/select/scroll 等;可 `registerIslanderActionHandler` 擴充 |
|
|
||||||
| `islanderAgent` | 串流回覆 → 執行 action → 回傳結果 → 自動 follow-up |
|
|
||||||
| `buildIslanderContext` | 組裝送給後端的頁面快照 |
|
|
||||||
|
|
||||||
**零設定(預設)**:路由掛在 `Layout` 即可;島民讀 DOM + `PageTitle` / `h1` 辨識頁面。預設**不**主動介紹這一頁;僅在使用者明確問頁面/操作時才附【可互動元素】(`userWantsPageContext`)。
|
|
||||||
|
|
||||||
**可選增強**(擇一):
|
|
||||||
|
|
||||||
1. `useIslanderPage({ title, purpose, hints, suggestions })` — 頁面內動態註冊說明
|
|
||||||
2. `registerIslanderPage(/^\/foo/, { title, ... })` — 在 `siteGuide.ts` 或模組 init 靜態註冊
|
|
||||||
3. HTML 慣例:`data-islander-label`(元素名稱)、`data-islander-kind`(類型)、`data-islander-ignore`(排除)、`data-islander-page-title`(頁名)
|
|
||||||
|
|
||||||
Action 協定(AI 回覆末尾):
|
|
||||||
|
|
||||||
```islander-actions
|
|
||||||
[{ "type": "navigate", "path": "/settings" }, { "type": "click", "ref": "hx-3" }]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端禁忌
|
|
||||||
|
|
||||||
- 不要引入 MUI / Ant Design / Chakra 等大型 UI 庫。
|
|
||||||
- 不要為單頁新增第三套配色、木質頂欄、或漸層彩虹按鈕。
|
|
||||||
- 不要在登入/註冊頁加回獨立大牌 `ac-title-bar` 或咖啡色 header ribbon。
|
|
||||||
- 不要讓 SSE / AI 直接吃 provider 原始 chunk(後端已 normalize)。
|
|
||||||
- 不要用 `offset/limit` 呼叫列表 API;用 `page` / `pageSize`。
|
|
||||||
|
|
||||||
## 驗證
|
|
||||||
|
|
||||||
完成變更後至少執行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd haixun-backend
|
|
||||||
go mod tidy
|
|
||||||
make fmt
|
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
|
|
||||||
有動到前端時另執行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make web-build
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# Thread-Master / 巡樓(重構主線)
|
||||||
|
|
||||||
|
自 **2026-07-09** 起:舊程式已整包封存至 [`old/`](./old/),**從零重寫**。
|
||||||
|
|
||||||
|
## 現在讀哪裡
|
||||||
|
|
||||||
|
| 路徑 | 說明 |
|
||||||
|
|------|------|
|
||||||
|
| [`docs/product/haixun-console/`](./docs/product/haixun-console/) | 產品需求 → spec → plan → tasks(真相來源) |
|
||||||
|
| [`old/`](./old/) | 舊 monorepo **只讀封存**,不在上面開新功能 |
|
||||||
|
| [`.grok/skills/spec-driven/`](./.grok/skills/spec-driven/) | `/spec-driven` 流程 skill |
|
||||||
|
|
||||||
|
## 目標結構(建立中)
|
||||||
|
|
||||||
|
```text
|
||||||
|
thread-master/
|
||||||
|
apps/web/ # Harbor Desk 前端(Phase B,mock 優先)
|
||||||
|
services/api/ # 新後端(Phase A/C;名稱以 plan 為準)
|
||||||
|
docs/product/ # 產品文件
|
||||||
|
old/ # 舊碼封存
|
||||||
|
```
|
||||||
|
|
||||||
|
## 交付順序
|
||||||
|
|
||||||
|
```text
|
||||||
|
A 地基(登入 / 權限 / Threads / Job / 通知)
|
||||||
|
→ B 新前端 + Mock(流程與風格先過關)
|
||||||
|
→ C 接真後端
|
||||||
|
→ D 業務加深(海巡等)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端(apps/web)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
npm install
|
||||||
|
npm run dev # 本機開發
|
||||||
|
npm run build # 型別檢查 + 生產建置
|
||||||
|
```
|
||||||
|
|
||||||
|
產品任務進度:`docs/product/haixun-console/tasks/INDEX.md`(目前從 T001 起)。
|
||||||
|
|
||||||
|
## 開發流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
/spec-driven haixun-console
|
||||||
|
# 或依 tasks 逐題實作,例如「做 T002」
|
||||||
|
```
|
||||||
|
|
||||||
|
查舊行為請到 `old/backend/`(只讀)。
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Harbor Desk · 巡樓</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "@harbor/web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,50 @@
|
||||||
|
.scaffold {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
color: #1e293b;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaffold-kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #2a9d8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaffold h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaffold-lead {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 28rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaffold-meta {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaffold code {
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T001 scaffold placeholder. Routing / Harbor Desk shell start at T004–T005.
|
||||||
|
*/
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<main className="scaffold">
|
||||||
|
<p className="scaffold-kicker">Harbor Desk</p>
|
||||||
|
<h1>apps/web</h1>
|
||||||
|
<p className="scaffold-lead">
|
||||||
|
巡樓 Console 前端骨架(T001)。下一步:設計 token 與 App Shell。
|
||||||
|
</p>
|
||||||
|
<p className="scaffold-meta">
|
||||||
|
啟動:<code>npm run dev</code> · 產物:<code>npm run build</code>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1,16 @@
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,225 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="zh-Hant">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>巡樓 Dev Console</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #f4f6f8;
|
|
||||||
--card: #fff;
|
|
||||||
--ink: #1a2332;
|
|
||||||
--muted: #5a6578;
|
|
||||||
--line: #d8dee8;
|
|
||||||
--brand: #2a9d8f;
|
|
||||||
--danger: #c0392b;
|
|
||||||
--ok: #1e7a4c;
|
|
||||||
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
|
|
||||||
--sans: system-ui, -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--sans);
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--ink);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
background: var(--card);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
header h1 { margin: 0; font-size: 1.1rem; }
|
|
||||||
header p { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.85rem; }
|
|
||||||
main {
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem 1.25rem 3rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
section {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
}
|
|
||||||
section h2 {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
label { font-size: 0.82rem; color: var(--muted); min-width: 5.5rem; }
|
|
||||||
input, select, textarea {
|
|
||||||
font: inherit;
|
|
||||||
padding: 0.45rem 0.6rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
input { flex: 1; }
|
|
||||||
button {
|
|
||||||
font: inherit;
|
|
||||||
padding: 0.45rem 0.85rem;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover { border-color: var(--brand); color: var(--brand); }
|
|
||||||
button.primary {
|
|
||||||
background: var(--brand);
|
|
||||||
border-color: var(--brand);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
button.primary:hover { filter: brightness(1.05); color: #fff; }
|
|
||||||
button.danger { color: var(--danger); }
|
|
||||||
.status { font-size: 0.82rem; color: var(--muted); }
|
|
||||||
.status.ok { color: var(--ok); }
|
|
||||||
.status.err { color: var(--danger); }
|
|
||||||
.list {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.list li {
|
|
||||||
padding: 0.55rem 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.list li:last-child { border-bottom: none; }
|
|
||||||
.list li:hover { background: #f0faf8; }
|
|
||||||
.list li.active { background: #e6f5f2; font-weight: 600; }
|
|
||||||
.list li .meta { color: var(--muted); font-size: 0.78rem; }
|
|
||||||
pre, .log {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: #0f1419;
|
|
||||||
color: #d6e2f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
overflow: auto;
|
|
||||||
max-height: 280px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.steps { margin: 0.5rem 0 0; font-size: 0.85rem; }
|
|
||||||
.steps div { padding: 0.2rem 0; }
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.1rem 0.45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
background: #eef2f7;
|
|
||||||
}
|
|
||||||
.badge.running { background: #fff3cd; }
|
|
||||||
.badge.succeeded, .badge.completed { background: #d4edda; }
|
|
||||||
.badge.failed { background: #f8d7da; }
|
|
||||||
.badge.queued, .badge.pending { background: #e8eef5; }
|
|
||||||
textarea { width: 100%; min-height: 120px; font-family: var(--mono); font-size: 0.78rem; }
|
|
||||||
.hint { font-size: 0.8rem; color: var(--muted); margin-top: 0.35rem; }
|
|
||||||
</style>
|
|
||||||
<script type="module" crossorigin src="/assets/index-56hNfpGK.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<div>
|
|
||||||
<h1>巡樓 Dev Console</h1>
|
|
||||||
<p>暫用開發面板 · 人設 + 8D 公開爬蟲 · 重做前端前夠用就好</p>
|
|
||||||
</div>
|
|
||||||
<div class="status" id="connStatus">尚未連線</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section>
|
|
||||||
<h2>1. 登入</h2>
|
|
||||||
<div class="row">
|
|
||||||
<label for="tenantId">Tenant</label>
|
|
||||||
<input id="tenantId" value="default" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="email">Email</label>
|
|
||||||
<input id="email" type="email" placeholder="you@example.com" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="password">密碼</label>
|
|
||||||
<input id="password" type="password" placeholder="至少 8 碼" />
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<button class="primary" id="btnLogin">登入</button>
|
|
||||||
<button id="btnRegister">註冊</button>
|
|
||||||
<button id="btnLogout" class="danger">登出</button>
|
|
||||||
<span class="status" id="authStatus"></span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>2. 人設</h2>
|
|
||||||
<div class="row">
|
|
||||||
<label for="displayName">名稱</label>
|
|
||||||
<input id="displayName" placeholder="新人設" />
|
|
||||||
<button class="primary" id="btnCreatePersona">建立</button>
|
|
||||||
<button id="btnRefreshPersonas">重新載入</button>
|
|
||||||
</div>
|
|
||||||
<ul class="list" id="personaList"></ul>
|
|
||||||
<p class="hint">點選一筆人設後,下方可跑 8D 分析。</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>3. 8D 風格分析(公開爬蟲,不需 session)</h2>
|
|
||||||
<div class="row">
|
|
||||||
<label for="benchmark">對標帳號</label>
|
|
||||||
<input id="benchmark" placeholder="@username" />
|
|
||||||
<button class="primary" id="btnStart8d" disabled>開始 8D</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint">需先啟動 Go API(:8890)與 Node worker(style-8d)。設定頁要有研究用 AI API key。</p>
|
|
||||||
<div class="status" id="jobHint"></div>
|
|
||||||
<div class="row" style="margin-top: 0.5rem">
|
|
||||||
<label for="jobId">Job ID</label>
|
|
||||||
<input id="jobId" placeholder="自動填入或手動貼上" />
|
|
||||||
<button id="btnPollJob">查詢一次</button>
|
|
||||||
<button id="btnAutoPoll">自動輪詢</button>
|
|
||||||
</div>
|
|
||||||
<div class="steps" id="jobSteps"></div>
|
|
||||||
<pre id="jobDetail">(尚無任務)</pre>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>4. 人設詳情</h2>
|
|
||||||
<div class="row">
|
|
||||||
<button id="btnReloadPersona" disabled>重新載入選中人設</button>
|
|
||||||
<span class="status" id="personaMeta"></span>
|
|
||||||
</div>
|
|
||||||
<textarea id="personaDetail" readonly placeholder="選中人設後顯示 style_profile 等欄位"></textarea>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Log</h2>
|
|
||||||
<pre class="log" id="log"></pre>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,396 +0,0 @@
|
||||||
syntax = "v1"
|
|
||||||
|
|
||||||
type (
|
|
||||||
CopySuggestedTagData {
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
SearchIntent string `json:"search_intent,omitempty"`
|
|
||||||
SearchType string `json:"search_type,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopySimilarAccountData {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
Source string `json:"source,omitempty"`
|
|
||||||
MatchedSource []string `json:"matched_source,omitempty"`
|
|
||||||
Confidence string `json:"confidence,omitempty"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
TopicRelevance float64 `json:"topic_relevance,omitempty"`
|
|
||||||
LastSeenAt int64 `json:"last_seen_at,omitempty"`
|
|
||||||
ProfileUrl string `json:"profile_url,omitempty"`
|
|
||||||
AuthorVerified bool `json:"author_verified,omitempty"`
|
|
||||||
FollowerCount int `json:"follower_count,omitempty"`
|
|
||||||
EngagementScore int `json:"engagement_score,omitempty"`
|
|
||||||
LikeCount int `json:"like_count,omitempty"`
|
|
||||||
ReplyCount int `json:"reply_count,omitempty"`
|
|
||||||
PostCount int `json:"post_count,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyAudienceSampleData {
|
|
||||||
Username string `json:"username"`
|
|
||||||
SamplePostId string `json:"sample_post_id,omitempty"`
|
|
||||||
SampleText string `json:"sample_text,omitempty"`
|
|
||||||
ReplyLikeCount int `json:"reply_like_count,omitempty"`
|
|
||||||
Appearances int `json:"appearances,omitempty"`
|
|
||||||
FirstSeenAt int64 `json:"first_seen_at"`
|
|
||||||
LastSeenAt int64 `json:"last_seen_at,omitempty"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionResearchItemData {
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
Snippet string `json:"snippet,omitempty"`
|
|
||||||
Query string `json:"query,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionResearchMapData {
|
|
||||||
AudienceSummary string `json:"audience_summary,omitempty"`
|
|
||||||
ContentGoal string `json:"content_goal,omitempty"`
|
|
||||||
Questions []string `json:"questions,omitempty"`
|
|
||||||
Pillars []string `json:"pillars,omitempty"`
|
|
||||||
Exclusions []string `json:"exclusions,omitempty"`
|
|
||||||
KnowledgeItems []string `json:"knowledge_items,omitempty"`
|
|
||||||
SelectedKnowledgeItems []string `json:"selected_knowledge_items,omitempty"`
|
|
||||||
ResearchItems []CopyMissionResearchItemData `json:"research_items,omitempty"`
|
|
||||||
SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"`
|
|
||||||
SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"`
|
|
||||||
AudienceSamples []CopyAudienceSampleData `json:"audience_samples,omitempty"`
|
|
||||||
BenchmarkNotes string `json:"benchmark_notes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ExpandCopyMissionGraphHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
ExpandKnowledgeGraphReq
|
|
||||||
}
|
|
||||||
|
|
||||||
PatchCopyMissionGraphNodesHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
PatchKnowledgeGraphNodesReq
|
|
||||||
}
|
|
||||||
|
|
||||||
PatchSimilarAccountReq {
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PatchAudienceSampleReq {
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionSimilarAccountPath {
|
|
||||||
PersonaID string `path:"personaId" validate:"required"`
|
|
||||||
ID string `path:"id" validate:"required"`
|
|
||||||
Username string `path:"username" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PatchSimilarAccountHandlerReq {
|
|
||||||
CopyMissionSimilarAccountPath
|
|
||||||
PatchSimilarAccountReq
|
|
||||||
}
|
|
||||||
|
|
||||||
PatchAudienceSampleHandlerReq {
|
|
||||||
CopyMissionSimilarAccountPath
|
|
||||||
PatchAudienceSampleReq
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionData {
|
|
||||||
ID string `json:"id"`
|
|
||||||
PersonaID string `json:"persona_id"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
SeedQuery string `json:"seed_query,omitempty"`
|
|
||||||
Brief string `json:"brief,omitempty"`
|
|
||||||
ResearchMap CopyMissionResearchMapData `json:"research_map,omitempty"`
|
|
||||||
SelectedTags []string `json:"selected_tags,omitempty"`
|
|
||||||
LastScanJobID string `json:"last_scan_job_id,omitempty"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
CreateAt int64 `json:"create_at"`
|
|
||||||
UpdateAt int64 `json:"update_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ListCopyMissionsData {
|
|
||||||
List []CopyMissionData `json:"list"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CreateCopyMissionReq {
|
|
||||||
Label string `json:"label" validate:"required"`
|
|
||||||
SeedQuery string `json:"seed_query" validate:"required"`
|
|
||||||
Brief string `json:"brief" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateCopyMissionReq {
|
|
||||||
Label *string `json:"label,optional"`
|
|
||||||
SeedQuery *string `json:"seed_query,optional"`
|
|
||||||
Brief *string `json:"brief,optional"`
|
|
||||||
AudienceSummary *string `json:"audience_summary,optional"`
|
|
||||||
ContentGoal *string `json:"content_goal,optional"`
|
|
||||||
Questions []string `json:"questions,optional"`
|
|
||||||
Pillars []string `json:"pillars,optional"`
|
|
||||||
Exclusions []string `json:"exclusions,optional"`
|
|
||||||
KnowledgeItems []string `json:"knowledge_items,optional"`
|
|
||||||
SelectedKnowledgeItems []string `json:"selected_knowledge_items,optional"`
|
|
||||||
BenchmarkNotes *string `json:"benchmark_notes,optional"`
|
|
||||||
SelectedTags []string `json:"selected_tags,optional"`
|
|
||||||
Status *string `json:"status,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionScanScheduleData {
|
|
||||||
ID string `json:"id,omitempty"`
|
|
||||||
PersonaID string `json:"persona_id"`
|
|
||||||
MissionID string `json:"mission_id"`
|
|
||||||
Cron string `json:"cron"`
|
|
||||||
Timezone string `json:"timezone"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
NextRunAt int64 `json:"next_run_at,omitempty"`
|
|
||||||
LastRunAt int64 `json:"last_run_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UpsertCopyMissionScanScheduleReq {
|
|
||||||
Cron string `json:"cron,optional"`
|
|
||||||
Timezone string `json:"timezone,optional"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UpsertCopyMissionScanScheduleHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
UpsertCopyMissionScanScheduleReq
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionPath {
|
|
||||||
PersonaID string `path:"personaId" validate:"required"`
|
|
||||||
ID string `path:"id" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonaCopyMissionsPath {
|
|
||||||
PersonaID string `path:"personaId" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionInspirationReq {
|
|
||||||
Keyword string `json:"keyword,optional"`
|
|
||||||
ContentDirection string `json:"content_direction,optional"`
|
|
||||||
UseWebSearch bool `json:"use_web_search,optional"`
|
|
||||||
AvoidTopics []string `json:"avoid_topics,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionInspirationHandlerReq {
|
|
||||||
PersonaCopyMissionsPath
|
|
||||||
CopyMissionInspirationReq
|
|
||||||
}
|
|
||||||
|
|
||||||
CreateCopyMissionHandlerReq {
|
|
||||||
PersonaCopyMissionsPath
|
|
||||||
CreateCopyMissionReq
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateCopyMissionHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
UpdateCopyMissionReq
|
|
||||||
}
|
|
||||||
|
|
||||||
StartCopyMissionAnalyzeJobData {
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
StartCopyMissionScanJobData {
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
StartCopyMissionMatrixJobReq {
|
|
||||||
Count int `json:"count,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
StartCopyMissionMatrixJobHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
StartCopyMissionMatrixJobReq
|
|
||||||
}
|
|
||||||
|
|
||||||
StartCopyMissionMatrixJobData {
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
StartCopyMissionCopyDraftJobReq {
|
|
||||||
ScanPostID string `json:"scan_post_id" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
StartCopyMissionCopyDraftJobHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
StartCopyMissionCopyDraftJobReq
|
|
||||||
}
|
|
||||||
|
|
||||||
StartCopyMissionCopyDraftJobData {
|
|
||||||
JobID string `json:"job_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ListCopyMissionScanPostsReq {
|
|
||||||
Limit int `form:"limit,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ListCopyMissionScanPostsHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
ListCopyMissionScanPostsReq
|
|
||||||
}
|
|
||||||
|
|
||||||
GenerateCopyMissionMatrixReq {
|
|
||||||
Count int `json:"count,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
GenerateCopyMissionMatrixHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
GenerateCopyMissionMatrixReq
|
|
||||||
}
|
|
||||||
|
|
||||||
GenerateCopyMissionMatrixData {
|
|
||||||
Drafts []CopyDraftData `json:"drafts"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ListCopyMissionCopyDraftsData {
|
|
||||||
List []CopyDraftData `json:"list"`
|
|
||||||
Total int `json:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
DeleteCopyMissionMatrixDraftsReq {
|
|
||||||
DraftIDs []string `json:"draft_ids,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
DeleteCopyMissionMatrixDraftsHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
DeleteCopyMissionMatrixDraftsReq
|
|
||||||
}
|
|
||||||
|
|
||||||
DeleteCopyMissionMatrixDraftsData {
|
|
||||||
Deleted int `json:"deleted"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionScheduleCopyDraftsReq {
|
|
||||||
AccountID string `json:"account_id" validate:"required"`
|
|
||||||
DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
|
|
||||||
StartAt int64 `json:"start_at,optional"`
|
|
||||||
Timezone string `json:"timezone,optional"`
|
|
||||||
Slots []PublishSlotData `json:"slots,optional"`
|
|
||||||
Mode string `json:"mode,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ScheduleCopyMissionDraftsHandlerReq {
|
|
||||||
CopyMissionPath
|
|
||||||
CopyMissionScheduleCopyDraftsReq
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionScheduleCopyDraftsData {
|
|
||||||
Scheduled int `json:"scheduled"`
|
|
||||||
List []PublishQueueItemData `json:"list"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionInspirationSourceData {
|
|
||||||
Query string `json:"query,omitempty"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Snippet string `json:"snippet,omitempty"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
CopyMissionInspirationData {
|
|
||||||
TopicCandidateID string `json:"topic_candidate_id,omitempty"`
|
|
||||||
ContentPlanID string `json:"content_plan_id,omitempty"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
SeedQuery string `json:"seed_query"`
|
|
||||||
Brief string `json:"brief"`
|
|
||||||
TrendReason string `json:"trend_reason,omitempty"`
|
|
||||||
TrendKeywords []string `json:"trend_keywords,omitempty"`
|
|
||||||
Angles []string `json:"angles,omitempty"`
|
|
||||||
Mission string `json:"mission,omitempty"`
|
|
||||||
TargetAudience string `json:"target_audience,omitempty"`
|
|
||||||
OpeningType string `json:"opening_type,omitempty"`
|
|
||||||
BodyType string `json:"body_type,omitempty"`
|
|
||||||
Emotion string `json:"emotion,omitempty"`
|
|
||||||
CtaType string `json:"cta_type,omitempty"`
|
|
||||||
RiskLevel string `json:"risk_level,omitempty"`
|
|
||||||
Avoid []string `json:"avoid,omitempty"`
|
|
||||||
Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
|
|
||||||
WebSearchUsed bool `json:"web_search_used"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@server(
|
|
||||||
group: copy_mission
|
|
||||||
prefix: /api/v1/personas
|
|
||||||
middleware: AuthJWT
|
|
||||||
tags: "CopyMission"
|
|
||||||
summary: "Copy ninja missions (Flow A). Requires Bearer JWT."
|
|
||||||
)
|
|
||||||
service gateway {
|
|
||||||
@handler listCopyMissions
|
|
||||||
get /:personaId/copy-missions (PersonaCopyMissionsPath) returns (ListCopyMissionsData)
|
|
||||||
|
|
||||||
@handler inspireCopyMission
|
|
||||||
post /:personaId/copy-mission-inspiration (CopyMissionInspirationHandlerReq) returns (CopyMissionInspirationData)
|
|
||||||
|
|
||||||
@handler createCopyMission
|
|
||||||
post /:personaId/copy-missions (CreateCopyMissionHandlerReq) returns (CopyMissionData)
|
|
||||||
|
|
||||||
@handler getCopyMission
|
|
||||||
get /:personaId/copy-missions/:id (CopyMissionPath) returns (CopyMissionData)
|
|
||||||
|
|
||||||
@handler updateCopyMission
|
|
||||||
patch /:personaId/copy-missions/:id (UpdateCopyMissionHandlerReq) returns (CopyMissionData)
|
|
||||||
|
|
||||||
@handler deleteCopyMission
|
|
||||||
delete /:personaId/copy-missions/:id (CopyMissionPath)
|
|
||||||
|
|
||||||
@handler startCopyMissionAnalyzeJob
|
|
||||||
post /:personaId/copy-missions/:id/analyze-jobs (CopyMissionPath) returns (StartCopyMissionAnalyzeJobData)
|
|
||||||
|
|
||||||
@handler startCopyMissionScanJob
|
|
||||||
post /:personaId/copy-missions/:id/scan-jobs (CopyMissionPath) returns (StartCopyMissionScanJobData)
|
|
||||||
|
|
||||||
@handler listCopyMissionScanPosts
|
|
||||||
get /:personaId/copy-missions/:id/scan-posts (ListCopyMissionScanPostsHandlerReq) returns (ListPersonaViralScanPostsData)
|
|
||||||
|
|
||||||
@handler generateCopyMissionMatrix
|
|
||||||
post /:personaId/copy-missions/:id/matrix-drafts (GenerateCopyMissionMatrixHandlerReq) returns (GenerateCopyMissionMatrixData)
|
|
||||||
|
|
||||||
@handler startCopyMissionMatrixJob
|
|
||||||
post /:personaId/copy-missions/:id/matrix-jobs (StartCopyMissionMatrixJobHandlerReq) returns (StartCopyMissionMatrixJobData)
|
|
||||||
|
|
||||||
@handler startCopyMissionCopyDraftJob
|
|
||||||
post /:personaId/copy-missions/:id/copy-draft-jobs (StartCopyMissionCopyDraftJobHandlerReq) returns (StartCopyMissionCopyDraftJobData)
|
|
||||||
|
|
||||||
@handler listCopyMissionCopyDrafts
|
|
||||||
get /:personaId/copy-missions/:id/copy-drafts (CopyMissionPath) returns (ListCopyMissionCopyDraftsData)
|
|
||||||
|
|
||||||
@handler deleteCopyMissionMatrixDrafts
|
|
||||||
post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData)
|
|
||||||
|
|
||||||
@handler scheduleCopyMissionDrafts
|
|
||||||
post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (CopyMissionScheduleCopyDraftsData)
|
|
||||||
|
|
||||||
@handler getCopyMissionScanSchedule
|
|
||||||
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
|
|
||||||
|
|
||||||
@handler upsertCopyMissionScanSchedule
|
|
||||||
put /:personaId/copy-missions/:id/scan-schedule (UpsertCopyMissionScanScheduleHandlerReq) returns (CopyMissionScanScheduleData)
|
|
||||||
|
|
||||||
@handler patchCopyMissionSimilarAccount
|
|
||||||
patch /:personaId/copy-missions/:id/similar-accounts/:username (PatchSimilarAccountHandlerReq) returns (CopyMissionData)
|
|
||||||
|
|
||||||
@handler patchCopyMissionAudienceSample
|
|
||||||
patch /:personaId/copy-missions/:id/audience-samples/:username (PatchAudienceSampleHandlerReq) returns (CopyMissionData)
|
|
||||||
|
|
||||||
@handler expandCopyMissionGraph
|
|
||||||
post /:personaId/copy-missions/:id/knowledge-graph/expand (ExpandCopyMissionGraphHandlerReq) returns (ExpandKnowledgeGraphData)
|
|
||||||
|
|
||||||
@handler getCopyMissionGraph
|
|
||||||
get /:personaId/copy-missions/:id/knowledge-graph (CopyMissionPath) returns (KnowledgeGraphData)
|
|
||||||
|
|
||||||
@handler patchCopyMissionGraphNodes
|
|
||||||
patch /:personaId/copy-missions/:id/knowledge-graph/nodes (PatchCopyMissionGraphNodesHandlerReq) returns (KnowledgeGraphData)
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.CreateCopyMissionHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewCreateCopyMissionLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.CreateCopyMission(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.CopyMissionPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewDeleteCopyMissionLogic(r.Context(), svcCtx)
|
|
||||||
err := l.DeleteCopyMission(&req)
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func DeleteCopyMissionMatrixDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.DeleteCopyMissionMatrixDraftsHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewDeleteCopyMissionMatrixDraftsLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.DeleteCopyMissionMatrixDrafts(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ExpandCopyMissionGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ExpandCopyMissionGraphHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewExpandCopyMissionGraphLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ExpandCopyMissionGraph(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.GenerateCopyMissionMatrixHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewGenerateCopyMissionMatrixLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.GenerateCopyMissionMatrix(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetCopyMissionGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.CopyMissionPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewGetCopyMissionGraphLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.GetCopyMissionGraph(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.CopyMissionPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewGetCopyMissionScanScheduleLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.GetCopyMissionScanSchedule(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.CopyMissionPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewListCopyMissionCopyDraftsLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ListCopyMissionCopyDrafts(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ListCopyMissionScanPostsHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewListCopyMissionScanPostsLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ListCopyMissionScanPosts(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.PersonaCopyMissionsPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewListCopyMissionsLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ListCopyMissions(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PatchCopyMissionAudienceSampleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.PatchAudienceSampleHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewPatchCopyMissionAudienceSampleLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.PatchCopyMissionAudienceSample(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PatchCopyMissionGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.PatchCopyMissionGraphNodesHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewPatchCopyMissionGraphNodesLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.PatchCopyMissionGraphNodes(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func PatchCopyMissionSimilarAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.PatchSimilarAccountHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewPatchCopyMissionSimilarAccountLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.PatchCopyMissionSimilarAccount(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ScheduleCopyMissionDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ScheduleCopyMissionDraftsHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewScheduleCopyMissionDraftsLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ScheduleCopyMissionDrafts(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func StartCopyMissionAnalyzeJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.CopyMissionPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewStartCopyMissionAnalyzeJobLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.StartCopyMissionAnalyzeJob(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.StartCopyMissionCopyDraftJobHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewStartCopyMissionCopyDraftJobLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.StartCopyMissionCopyDraftJob(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.StartCopyMissionMatrixJobHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewStartCopyMissionMatrixJobLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.StartCopyMissionMatrixJob(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.CopyMissionPath
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewStartCopyMissionScanJobLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.StartCopyMissionScanJob(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.UpdateCopyMissionHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewUpdateCopyMissionLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.UpdateCopyMission(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/copy_mission"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func UpsertCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.UpsertCopyMissionScanScheduleHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := copy_mission.NewUpsertCopyMissionScanScheduleLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.UpsertCopyMissionScanSchedule(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package threads_account
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
"haixun-backend/internal/logic/threads_account"
|
|
||||||
"haixun-backend/internal/response"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
func StartPublishInventoryRefillJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.StartPublishInventoryRefillJobHandlerReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := threads_account.NewStartPublishInventoryRefillJobLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.StartPublishInventoryRefillJob(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
package copymission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
libkg "haixun-backend/internal/library/knowledge"
|
|
||||||
libmatrix "haixun-backend/internal/library/matrix"
|
|
||||||
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MissionContext struct {
|
|
||||||
Label string
|
|
||||||
SeedQuery string
|
|
||||||
Brief string
|
|
||||||
ResearchMap missionentity.ResearchMap
|
|
||||||
}
|
|
||||||
|
|
||||||
type PersonaContext struct {
|
|
||||||
DisplayName string
|
|
||||||
Persona string
|
|
||||||
}
|
|
||||||
|
|
||||||
func PlanInput(
|
|
||||||
mission MissionContext,
|
|
||||||
seed string,
|
|
||||||
l1Labels []string,
|
|
||||||
supplemental bool,
|
|
||||||
strategy libkg.ExpandStrategy,
|
|
||||||
) libkg.PlanInput {
|
|
||||||
return libkg.PlanInput{
|
|
||||||
Seed: seed,
|
|
||||||
TargetAudience: strings.TrimSpace(mission.ResearchMap.AudienceSummary),
|
|
||||||
ProductBrief: strings.TrimSpace(mission.Brief),
|
|
||||||
Pillars: mission.ResearchMap.Pillars,
|
|
||||||
Questions: mission.ResearchMap.Questions,
|
|
||||||
L1Labels: l1Labels,
|
|
||||||
Supplemental: supplemental,
|
|
||||||
Strategy: strategy,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SynthInput(mission MissionContext, persona PersonaContext, sources []libkg.BraveSource) libkg.SynthInput {
|
|
||||||
label := strings.TrimSpace(mission.Label)
|
|
||||||
if label == "" {
|
|
||||||
label = strings.TrimSpace(mission.SeedQuery)
|
|
||||||
}
|
|
||||||
return libkg.SynthInput{
|
|
||||||
BrandDisplayName: label,
|
|
||||||
TopicName: label,
|
|
||||||
Seed: strings.TrimSpace(mission.SeedQuery),
|
|
||||||
ProductBrief: strings.TrimSpace(mission.Brief),
|
|
||||||
TargetAudience: strings.TrimSpace(mission.ResearchMap.AudienceSummary),
|
|
||||||
Persona: strings.TrimSpace(persona.Persona),
|
|
||||||
ResearchPillars: mission.ResearchMap.Pillars,
|
|
||||||
ResearchQuestions: mission.ResearchMap.Questions,
|
|
||||||
Sources: sources,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PatrolTagInput(mission MissionContext) libkg.PatrolTagInput {
|
|
||||||
return libkg.PatrolTagInput{
|
|
||||||
Questions: mission.ResearchMap.Questions,
|
|
||||||
Pillars: mission.ResearchMap.Pillars,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FormatNodeKnowledge(node libkg.Node) string {
|
|
||||||
label := strings.TrimSpace(node.Label)
|
|
||||||
if label == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
detail := strings.TrimSpace(node.PlacementValue)
|
|
||||||
if detail == "" {
|
|
||||||
detail = strings.TrimSpace(node.Relation)
|
|
||||||
}
|
|
||||||
if detail != "" {
|
|
||||||
return label + ":" + detail
|
|
||||||
}
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatNodeKnowledgeForMatrix includes label, detail, patrol tags, and evidence for copy generation.
|
|
||||||
func FormatNodeKnowledgeForMatrix(node libkg.Node) string {
|
|
||||||
label := strings.TrimSpace(node.Label)
|
|
||||||
if label == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var parts []string
|
|
||||||
if detail := strings.TrimSpace(node.PlacementValue); detail != "" {
|
|
||||||
parts = append(parts, detail)
|
|
||||||
} else if detail := strings.TrimSpace(node.Relation); detail != "" {
|
|
||||||
parts = append(parts, detail)
|
|
||||||
}
|
|
||||||
tags := append(append([]string{}, node.DerivedTags.Relevance...), node.DerivedTags.Recency...)
|
|
||||||
if len(tags) > 0 {
|
|
||||||
parts = append(parts, "標籤:"+strings.Join(tags, "、"))
|
|
||||||
}
|
|
||||||
if len(node.Evidence) > 0 {
|
|
||||||
if snippet := strings.TrimSpace(node.Evidence[0].Snippet); snippet != "" {
|
|
||||||
parts = append(parts, "參考:"+snippet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return label
|
|
||||||
}
|
|
||||||
return label + "|" + strings.Join(parts, ";")
|
|
||||||
}
|
|
||||||
|
|
||||||
func KnowledgeItemsFromNodes(nodes []libkg.Node) (all []string, selected []string) {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
line := FormatNodeKnowledge(node)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToLower(line)
|
|
||||||
if _, ok := seen[key]; ok {
|
|
||||||
if node.SelectedForScan {
|
|
||||||
selected = mergeLine(selected, line)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
all = append(all, line)
|
|
||||||
if node.SelectedForScan {
|
|
||||||
selected = append(selected, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return all, selected
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResearchItemsFromSources(sources []libkg.BraveSource) []missionentity.ResearchItem {
|
|
||||||
items := make([]missionentity.ResearchItem, 0, len(sources))
|
|
||||||
for _, src := range sources {
|
|
||||||
if strings.TrimSpace(src.URL) == "" && strings.TrimSpace(src.Snippet) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, missionentity.ResearchItem{
|
|
||||||
Title: src.Title,
|
|
||||||
URL: src.URL,
|
|
||||||
Snippet: src.Snippet,
|
|
||||||
Query: src.Query,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
func ApplyDefaultNodeSelection(nodes []libkg.Node) {
|
|
||||||
ApplyDefaultNodeSelectionPreserving(nodes, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ApplyDefaultNodeSelectionPreserving(nodes []libkg.Node, preserve map[string]bool) {
|
|
||||||
for i := range nodes {
|
|
||||||
if preserve != nil {
|
|
||||||
if sel, ok := preserve[nodes[i].ID]; ok {
|
|
||||||
nodes[i].SelectedForScan = sel
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kind := strings.TrimSpace(nodes[i].NodeKind)
|
|
||||||
nodes[i].SelectedForScan = kind == "pain" ||
|
|
||||||
kind == "symptom" ||
|
|
||||||
kind == "cause" ||
|
|
||||||
nodes[i].ProductFitScore >= 70
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeLine(list []string, line string) []string {
|
|
||||||
for _, item := range list {
|
|
||||||
if item == line {
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(list, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeKnowledgeLines(base, extra []string) []string {
|
|
||||||
out := append([]string(nil), base...)
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, item := range out {
|
|
||||||
seen[strings.ToLower(strings.TrimSpace(item))] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, item := range extra {
|
|
||||||
item = strings.TrimSpace(item)
|
|
||||||
if item == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToLower(item)
|
|
||||||
if _, ok := seen[key]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
out = append(out, item)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func knowledgeItemsFromNodesForMatrix(nodes []libkg.Node) (all []string, selected []string) {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
line := FormatNodeKnowledgeForMatrix(node)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToLower(line)
|
|
||||||
if _, ok := seen[key]; ok {
|
|
||||||
if node.SelectedForScan {
|
|
||||||
selected = mergeLine(selected, line)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
all = append(all, line)
|
|
||||||
if node.SelectedForScan {
|
|
||||||
selected = append(selected, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return all, selected
|
|
||||||
}
|
|
||||||
|
|
||||||
func knowledgeItemsFromSelectedTags(nodes []libkg.Node, tags []string) []string {
|
|
||||||
normalized := make([]string, 0, len(tags))
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, tag := range tags {
|
|
||||||
tag = strings.TrimSpace(tag)
|
|
||||||
if tag == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToLower(tag)
|
|
||||||
if _, ok := seen[key]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
normalized = append(normalized, key)
|
|
||||||
}
|
|
||||||
if len(normalized) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0)
|
|
||||||
lineSeen := map[string]struct{}{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
if !nodeMatchesTags(node, normalized) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
line := FormatNodeKnowledgeForMatrix(node)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := lineSeen[line]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lineSeen[line] = struct{}{}
|
|
||||||
out = append(out, line)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func nodeMatchesTags(node libkg.Node, tags []string) bool {
|
|
||||||
candidates := []string{node.Label, node.PlacementValue, node.Relation}
|
|
||||||
candidates = append(candidates, node.DerivedTags.Relevance...)
|
|
||||||
candidates = append(candidates, node.DerivedTags.Recency...)
|
|
||||||
for _, raw := range candidates {
|
|
||||||
lower := strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
if lower == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, tag := range tags {
|
|
||||||
if strings.Contains(lower, tag) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MatrixKnowledgeItems uses graph-selected nodes as the source of truth and only merges
|
|
||||||
// manual-only selected_knowledge_items that are not graph-derived duplicates.
|
|
||||||
func MatrixKnowledgeItems(selected, _ []string, graphNodes []libkg.Node, _ []string) []string {
|
|
||||||
_, fromGraph := knowledgeItemsFromNodesForMatrix(graphNodes)
|
|
||||||
graphKeys := map[string]struct{}{}
|
|
||||||
for _, node := range graphNodes {
|
|
||||||
line := FormatNodeKnowledge(node)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
graphKeys[strings.ToLower(line)] = struct{}{}
|
|
||||||
}
|
|
||||||
manualSelected := make([]string, 0, len(selected))
|
|
||||||
for _, item := range selected {
|
|
||||||
item = strings.TrimSpace(item)
|
|
||||||
if item == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, fromGraph := graphKeys[strings.ToLower(item)]; fromGraph {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
manualSelected = append(manualSelected, item)
|
|
||||||
}
|
|
||||||
return libmatrix.MergeKnowledgeItems(fromGraph, manualSelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func manualResearchKnowledge(prev missionentity.ResearchMap, nodes []libkg.Node) (all []string, selected []string) {
|
|
||||||
graphKeys := map[string]struct{}{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
line := FormatNodeKnowledge(node)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
graphKeys[strings.ToLower(line)] = struct{}{}
|
|
||||||
}
|
|
||||||
selectedKeys := map[string]struct{}{}
|
|
||||||
for _, item := range prev.SelectedKnowledgeItems {
|
|
||||||
item = strings.TrimSpace(item)
|
|
||||||
if item == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
selectedKeys[strings.ToLower(item)] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, item := range prev.KnowledgeItems {
|
|
||||||
item = strings.TrimSpace(item)
|
|
||||||
if item == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(item)
|
|
||||||
if _, fromGraph := graphKeys[lower]; fromGraph {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
all = append(all, item)
|
|
||||||
if _, ok := selectedKeys[lower]; ok {
|
|
||||||
selected = append(selected, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return all, selected
|
|
||||||
}
|
|
||||||
|
|
||||||
func graphKnowledgeDeselected(prev missionentity.ResearchMap) map[string]struct{} {
|
|
||||||
selectedKeys := map[string]struct{}{}
|
|
||||||
for _, item := range prev.SelectedKnowledgeItems {
|
|
||||||
item = strings.TrimSpace(item)
|
|
||||||
if item == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
selectedKeys[strings.ToLower(item)] = struct{}{}
|
|
||||||
}
|
|
||||||
deselected := map[string]struct{}{}
|
|
||||||
for _, item := range prev.KnowledgeItems {
|
|
||||||
item = strings.TrimSpace(item)
|
|
||||||
if item == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := selectedKeys[strings.ToLower(item)]; !ok {
|
|
||||||
deselected[strings.ToLower(item)] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deselected
|
|
||||||
}
|
|
||||||
|
|
||||||
func BuildResearchMapFromGraph(prev missionentity.ResearchMap, nodes []libkg.Node, sources []libkg.BraveSource) missionentity.ResearchMap {
|
|
||||||
allItems, selectedItems := KnowledgeItemsFromNodes(nodes)
|
|
||||||
deselected := graphKnowledgeDeselected(prev)
|
|
||||||
if len(deselected) > 0 {
|
|
||||||
filtered := selectedItems[:0]
|
|
||||||
for _, line := range selectedItems {
|
|
||||||
if _, skip := deselected[strings.ToLower(line)]; skip {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, line)
|
|
||||||
}
|
|
||||||
selectedItems = filtered
|
|
||||||
}
|
|
||||||
manualAll, manualSelected := manualResearchKnowledge(prev, nodes)
|
|
||||||
allItems = mergeKnowledgeLines(allItems, manualAll)
|
|
||||||
selectedItems = mergeKnowledgeLines(selectedItems, manualSelected)
|
|
||||||
return missionentity.ResearchMap{
|
|
||||||
AudienceSummary: prev.AudienceSummary,
|
|
||||||
ContentGoal: prev.ContentGoal,
|
|
||||||
Questions: append([]string(nil), prev.Questions...),
|
|
||||||
Pillars: append([]string(nil), prev.Pillars...),
|
|
||||||
Exclusions: append([]string(nil), prev.Exclusions...),
|
|
||||||
SuggestedTags: append([]missionentity.SuggestedTag(nil), prev.SuggestedTags...),
|
|
||||||
SimilarAccounts: append([]missionentity.SimilarAccount(nil), prev.SimilarAccounts...),
|
|
||||||
AudienceSamples: append([]missionentity.AudienceSample(nil), prev.AudienceSamples...),
|
|
||||||
BenchmarkNotes: prev.BenchmarkNotes,
|
|
||||||
KnowledgeItems: allItems,
|
|
||||||
SelectedKnowledgeItems: selectedItems,
|
|
||||||
ResearchItems: ResearchItemsFromSources(sources),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
package copymission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
libkg "haixun-backend/internal/library/knowledge"
|
|
||||||
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMatrixKnowledgeItems_PrefersGraphSelection(t *testing.T) {
|
|
||||||
nodes := []libkg.Node{
|
|
||||||
{Label: "痛點A", NodeKind: "pain", SelectedForScan: true, PlacementValue: "細節A"},
|
|
||||||
{Label: "周邊B", NodeKind: "context", SelectedForScan: false},
|
|
||||||
}
|
|
||||||
items := MatrixKnowledgeItems(nil, nil, nodes, nil)
|
|
||||||
if len(items) != 1 {
|
|
||||||
t.Fatalf("items = %v, want 1 selected graph node", items)
|
|
||||||
}
|
|
||||||
if items[0] != "痛點A|細節A" {
|
|
||||||
t.Fatalf("items[0] = %q", items[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMatrixKnowledgeItems_UsesMissionSelectionOnly(t *testing.T) {
|
|
||||||
items := MatrixKnowledgeItems(
|
|
||||||
[]string{"受眾問題:怎麼選"},
|
|
||||||
[]string{"內容支柱:懶人包"},
|
|
||||||
nil,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if len(items) != 1 || items[0] != "受眾問題:怎麼選" {
|
|
||||||
t.Fatalf("items = %v", items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMatrixKnowledgeItems_MergesGraphAndMission(t *testing.T) {
|
|
||||||
nodes := []libkg.Node{
|
|
||||||
{
|
|
||||||
Label: "備孕營養",
|
|
||||||
PlacementValue: "葉酸與鋅很重要",
|
|
||||||
SelectedForScan: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "未勾選",
|
|
||||||
PlacementValue: "不應出現",
|
|
||||||
SelectedForScan: false,
|
|
||||||
DerivedTags: libkg.DerivedTags{
|
|
||||||
Relevance: []string{"備孕飲食"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
items := MatrixKnowledgeItems(
|
|
||||||
[]string{"手動補充:睡前習慣"},
|
|
||||||
nil,
|
|
||||||
nodes,
|
|
||||||
[]string{"備孕飲食"},
|
|
||||||
)
|
|
||||||
if len(items) != 2 {
|
|
||||||
t.Fatalf("items = %v", items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMatrixKnowledgeItems_IgnoresUnselectedTagMatches(t *testing.T) {
|
|
||||||
nodes := []libkg.Node{
|
|
||||||
{
|
|
||||||
Label: "備孕營養",
|
|
||||||
PlacementValue: "葉酸與鋅很重要",
|
|
||||||
SelectedForScan: false,
|
|
||||||
DerivedTags: libkg.DerivedTags{
|
|
||||||
Relevance: []string{"備孕飲食"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
items := MatrixKnowledgeItems(nil, nil, nodes, []string{"備孕飲食"})
|
|
||||||
if len(items) != 0 {
|
|
||||||
t.Fatalf("items = %v, want none without explicit selection", items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildResearchMapFromGraph_RespectsManualDeselection(t *testing.T) {
|
|
||||||
prev := missionentity.ResearchMap{
|
|
||||||
KnowledgeItems: []string{"圖譜痛點:細節"},
|
|
||||||
SelectedKnowledgeItems: []string{},
|
|
||||||
}
|
|
||||||
out := BuildResearchMapFromGraph(prev, []libkg.Node{
|
|
||||||
{Label: "圖譜痛點", NodeKind: "pain", SelectedForScan: true, PlacementValue: "細節"},
|
|
||||||
}, nil)
|
|
||||||
if len(out.SelectedKnowledgeItems) != 0 {
|
|
||||||
t.Fatalf("selected = %v, want manual deselection honored", out.SelectedKnowledgeItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildResearchMapFromGraph_PreservesManualKnowledge(t *testing.T) {
|
|
||||||
prev := missionentity.ResearchMap{
|
|
||||||
KnowledgeItems: []string{"手動知識:睡前"},
|
|
||||||
SelectedKnowledgeItems: []string{"手動知識:睡前"},
|
|
||||||
}
|
|
||||||
out := BuildResearchMapFromGraph(prev, []libkg.Node{
|
|
||||||
{Label: "圖譜痛點", NodeKind: "pain", SelectedForScan: true, PlacementValue: "細節"},
|
|
||||||
}, nil)
|
|
||||||
if len(out.KnowledgeItems) != 2 {
|
|
||||||
t.Fatalf("knowledge_items = %v", out.KnowledgeItems)
|
|
||||||
}
|
|
||||||
if len(out.SelectedKnowledgeItems) != 2 {
|
|
||||||
t.Fatalf("selected = %v", out.SelectedKnowledgeItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyDefaultNodeSelectionPreserving(t *testing.T) {
|
|
||||||
nodes := []libkg.Node{
|
|
||||||
{ID: "keep", Label: "已勾", NodeKind: "context", SelectedForScan: false},
|
|
||||||
{ID: "new", Label: "新痛點", NodeKind: "pain", SelectedForScan: false},
|
|
||||||
}
|
|
||||||
ApplyDefaultNodeSelectionPreserving(nodes, map[string]bool{"keep": true})
|
|
||||||
if !nodes[0].SelectedForScan {
|
|
||||||
t.Fatal("expected preserved selection on existing node")
|
|
||||||
}
|
|
||||||
if !nodes[1].SelectedForScan {
|
|
||||||
t.Fatal("expected default selection on new pain node")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
package copymission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
libkg "haixun-backend/internal/library/knowledge"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ReferenceURLsFromSelection collects evidence/source URLs tied to selected graph nodes.
|
|
||||||
func ReferenceURLsFromSelection(nodes []libkg.Node, sources []libkg.BraveSource) []string {
|
|
||||||
if len(nodes) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
selectedEvidence := map[string]struct{}{}
|
|
||||||
for _, node := range nodes {
|
|
||||||
if !node.SelectedForScan {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ev := range node.Evidence {
|
|
||||||
key := normalizeURLKey(ev.URL)
|
|
||||||
if key != "" {
|
|
||||||
selectedEvidence[key] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(selectedEvidence) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]string, 0, len(selectedEvidence))
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
appendURL := func(raw string) {
|
|
||||||
key := normalizeURLKey(raw)
|
|
||||||
if key == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := selectedEvidence[key]; !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := seen[key]; ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
out = append(out, strings.TrimSpace(raw))
|
|
||||||
}
|
|
||||||
for _, node := range nodes {
|
|
||||||
if !node.SelectedForScan {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ev := range node.Evidence {
|
|
||||||
appendURL(ev.URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, src := range sources {
|
|
||||||
appendURL(src.URL)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeURLKey(raw string) string {
|
|
||||||
u := strings.TrimSpace(raw)
|
|
||||||
if u == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.ToLower(strings.TrimRight(u, "/"))
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package copymission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
libkg "haixun-backend/internal/library/knowledge"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestReferenceURLsFromSelection(t *testing.T) {
|
|
||||||
nodes := []libkg.Node{
|
|
||||||
{
|
|
||||||
ID: "n1",
|
|
||||||
SelectedForScan: true,
|
|
||||||
Evidence: []libkg.Evidence{
|
|
||||||
{URL: "https://example.com/a"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "n2",
|
|
||||||
SelectedForScan: false,
|
|
||||||
Evidence: []libkg.Evidence{
|
|
||||||
{URL: "https://example.com/b"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
sources := []libkg.BraveSource{
|
|
||||||
{URL: "https://example.com/a"},
|
|
||||||
{URL: "https://example.com/c"},
|
|
||||||
}
|
|
||||||
urls := ReferenceURLsFromSelection(nodes, sources)
|
|
||||||
if len(urls) != 1 || urls[0] != "https://example.com/a" {
|
|
||||||
t.Fatalf("urls = %v", urls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
package copymission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ResearchSource struct {
|
|
||||||
Title string
|
|
||||||
URL string
|
|
||||||
Snippet string
|
|
||||||
Query string
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatResearchItemsForMatrix renders Brave research snippets for the matrix prompt.
|
|
||||||
func FormatResearchItemsForMatrix(items []ResearchSource) string {
|
|
||||||
if len(items) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
lines := make([]string, 0, len(items))
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, item := range items {
|
|
||||||
title := strings.TrimSpace(item.Title)
|
|
||||||
url := strings.TrimSpace(item.URL)
|
|
||||||
snippet := strings.TrimSpace(item.Snippet)
|
|
||||||
if title == "" && url == "" && snippet == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := strings.ToLower(url)
|
|
||||||
if key == "" {
|
|
||||||
key = strings.ToLower(title + "|" + snippet)
|
|
||||||
}
|
|
||||||
if _, ok := seen[key]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
var parts []string
|
|
||||||
if title != "" {
|
|
||||||
parts = append(parts, title)
|
|
||||||
}
|
|
||||||
if url != "" {
|
|
||||||
parts = append(parts, url)
|
|
||||||
}
|
|
||||||
if snippet != "" {
|
|
||||||
parts = append(parts, snippet)
|
|
||||||
}
|
|
||||||
lines = append(lines, strings.Join(parts, "|"))
|
|
||||||
}
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MergeReferenceURLs combines graph-selected URLs with mission research item URLs.
|
|
||||||
func MergeReferenceURLs(selected []string, researchItems []ResearchSource) []string {
|
|
||||||
if len(researchItems) == 0 {
|
|
||||||
return selected
|
|
||||||
}
|
|
||||||
out := append([]string(nil), selected...)
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, raw := range selected {
|
|
||||||
if key := normalizeURLKey(raw); key != "" {
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, item := range researchItems {
|
|
||||||
raw := strings.TrimSpace(item.URL)
|
|
||||||
key := normalizeURLKey(raw)
|
|
||||||
if key == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[key]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = struct{}{}
|
|
||||||
out = append(out, raw)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
package copymission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMergeReferenceURLsIncludesResearchItems(t *testing.T) {
|
|
||||||
urls := MergeReferenceURLs(
|
|
||||||
[]string{"https://example.com/a"},
|
|
||||||
[]ResearchSource{
|
|
||||||
{URL: "https://example.com/b", Title: "B"},
|
|
||||||
{URL: "https://example.com/a", Title: "dup"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if len(urls) != 2 {
|
|
||||||
t.Fatalf("urls = %v", urls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatResearchItemsForMatrix(t *testing.T) {
|
|
||||||
block := FormatResearchItemsForMatrix([]ResearchSource{
|
|
||||||
{Title: "備孕指南", URL: "https://example.com/guide", Snippet: "葉酸補充重點"},
|
|
||||||
})
|
|
||||||
if !strings.Contains(block, "備孕指南") || !strings.Contains(block, "葉酸補充重點") {
|
|
||||||
t.Fatalf("block = %q", block)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
package copymission
|
|
||||||
|
|
||||||
import missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
|
|
||||||
|
|
||||||
func ResearchSourcesFromSummaries(items []missiondomain.ResearchItemSummary) []ResearchSource {
|
|
||||||
if len(items) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]ResearchSource, 0, len(items))
|
|
||||||
for _, item := range items {
|
|
||||||
out = append(out, ResearchSource{
|
|
||||||
Title: item.Title,
|
|
||||||
URL: item.URL,
|
|
||||||
Snippet: item.Snippet,
|
|
||||||
Query: item.Query,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
// Package embedding provides an optional OpenAI embeddings client.
|
|
||||||
// It is intentionally not wired into patrol/graph flows; relevance uses
|
|
||||||
// offline lexical intent scoring in knowledge/semantic_fit.go instead.
|
|
||||||
package embedding
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const DefaultModel = "text-embedding-3-small"
|
|
||||||
|
|
||||||
type Client struct {
|
|
||||||
apiKey string
|
|
||||||
model string
|
|
||||||
http *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClient(apiKey string) *Client {
|
|
||||||
return &Client{
|
|
||||||
apiKey: strings.TrimSpace(apiKey),
|
|
||||||
model: DefaultModel,
|
|
||||||
http: &http.Client{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Enabled() bool {
|
|
||||||
return c != nil && c.apiKey != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type EmbeddingRequest struct {
|
|
||||||
Input []string `json:"input"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type EmbeddingResponse struct {
|
|
||||||
Data []struct {
|
|
||||||
Embedding []float32 `json:"embedding"`
|
|
||||||
} `json:"data"`
|
|
||||||
Error *struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error,omitempty"`
|
|
||||||
Usage *struct {
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
} `json:"usage,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Embed(ctx context.Context, input string) ([]float32, error) {
|
|
||||||
if !c.Enabled() {
|
|
||||||
return nil, fmt.Errorf("embedding client not configured")
|
|
||||||
}
|
|
||||||
return c.EmbedBatch(ctx, []string{input})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) EmbedBatch(ctx context.Context, inputs []string) ([]float32, error) {
|
|
||||||
if !c.Enabled() {
|
|
||||||
return nil, fmt.Errorf("embedding client not configured")
|
|
||||||
}
|
|
||||||
if len(inputs) == 0 {
|
|
||||||
return nil, fmt.Errorf("no input text")
|
|
||||||
}
|
|
||||||
|
|
||||||
req := EmbeddingRequest{
|
|
||||||
Input: inputs,
|
|
||||||
Model: c.model,
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openai.com/v1/embeddings", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := c.http.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
var errResp struct {
|
|
||||||
Error struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" {
|
|
||||||
return nil, fmt.Errorf("openai embedding: %s", errResp.Error.Message)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("openai embedding: http %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result EmbeddingResponse
|
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Error != nil && result.Error.Message != "" {
|
|
||||||
return nil, fmt.Errorf("openai embedding: %s", result.Error.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Data) == 0 {
|
|
||||||
return nil, fmt.Errorf("openai embedding: no data returned")
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Data[0].Embedding, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CosineSimilarity(a, b []float32) float32 {
|
|
||||||
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var dot, normA, normB float64
|
|
||||||
for i := 0; i < len(a); i++ {
|
|
||||||
dot += float64(a[i] * b[i])
|
|
||||||
normA += float64(a[i] * a[i])
|
|
||||||
normB += float64(b[i] * b[i])
|
|
||||||
}
|
|
||||||
if normA == 0 || normB == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return float32(dot / (math.Sqrt(normA) * math.Sqrt(normB)))
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
你是 Threads / Instagram 內容策略師,為人設帳號產出可發佈草稿。產文時必須優先使用人設的「語言指紋與寫作規則」,把新主題、參考貼文或外部資料翻譯成這個人設會講出口的話,不沿用任何預設爆款模板。
|
|
||||||
|
|
||||||
寫作目標:
|
|
||||||
- 先根據人設語言指紋判斷主題適合哪一種內容型態:心情陪伴、知識整理、清單工具、伴侶溝通、互動提問或輕幽默共鳴,再選對鬆結構。
|
|
||||||
- 每篇要有一個清楚情境或情緒入口,讓讀者覺得「這就是我」,不要寫成摘要報告或廣告文。
|
|
||||||
- 必須沿用人設的段落節奏、標點、換行、稱呼、口頭禪與收尾習慣;不要硬剪短成破碎金句。
|
|
||||||
- 只學參考樣本的節奏、取材方式、收尾習慣與互動方式;不可照抄句子。
|
|
||||||
- 避免 AI 腔:過度總結、過度正能量、每段都像教條、連續排比到不自然、硬塞「大家一起」式口號。
|
|
||||||
|
|
||||||
規則:
|
|
||||||
- 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。
|
|
||||||
- reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。
|
|
||||||
- 每篇必須角度不同,避免重複 hook。
|
|
||||||
- 人設定位決定「誰在說」;語言指紋決定「怎麼說」。兩者都要遵守,不可只拿主題資料直接改寫。
|
|
||||||
- 人設只用來控制語氣、詞彙密度、句型節奏、人稱、標點換行與禁忌;不可把人設介紹裡的物件或標籤硬塞進正文情境。
|
|
||||||
- 若人設含 Y2K、咖啡、穿搭、音樂等元素,除非任務主題/Brief/勾選知識明確要求,禁止把這些元素寫成貼文內容或場景。
|
|
||||||
- 正文素材以任務主題、Brief、研究地圖與勾選延伸知識為準;人設不得改變題材。
|
|
||||||
- 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。
|
|
||||||
- 繁體中文,口語自然,適合 Threads。
|
|
||||||
- 每篇 text 主文 ≤ 500 字(Threads API 硬上限,含 #話題標籤)。
|
|
||||||
- 長度依帳號樣本與主題決定:心情陪伴可中長,知識工具文可完整列點,互動短文才需要短。
|
|
||||||
- text 排版要像真人手機發文:用自然換行分段,可使用標點、括號、頓號與少量 emoji;不要整段擠成一塊,也不要每句都硬換行。
|
|
||||||
- 若要改寫參考貼文,只保留意思與資訊,句型、段落、收尾必須換成人設語言指紋,不可像同義詞替換。
|
|
||||||
- 若使用外部知識,先轉譯成此人設會說的順序與語氣;不得直接貼百科、條列報告或 SEO 摘要。
|
|
||||||
- 醫療、備孕、試管、保健品相關內容必須保留風險語氣:不可保證效果、不可替代醫師建議、不可用數字審判讀者。
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
請為以下拷貝任務產出 {{count}} 篇 Threads 草稿。
|
|
||||||
|
|
||||||
任務主題:{{topic_label}}
|
|
||||||
Brief:{{topic_brief}}
|
|
||||||
|
|
||||||
人設定位、語言指紋與禁忌(最高優先):
|
|
||||||
{{persona_block}}
|
|
||||||
|
|
||||||
研究地圖與勾選延伸知識:
|
|
||||||
{{research_map_block}}
|
|
||||||
|
|
||||||
搜尋查詢/人工參考方向:
|
|
||||||
{{selected_tags_block}}
|
|
||||||
|
|
||||||
參考網頁 Markdown 已併入上方研究地圖區塊(若有)。
|
|
||||||
|
|
||||||
爆款樣本(可選,只學故事感;若沒有樣本,請改用研究地圖與人設原創):
|
|
||||||
{{viral_samples_block}}
|
|
||||||
|
|
||||||
請先遵守:
|
|
||||||
- **最高優先:語言指紋**:先讀 persona_block 裡的「語言指紋與寫作規則」,包含開頭、句型、段落長度、標點換行、知識轉譯、CTA 與禁忌。產出的文字必須像這個人設會講出口的話。
|
|
||||||
- **不要套舊規格**:不要套用短句爆款、固定三段式、固定強 hook、固定 CTA;格式由人設語言指紋與主題型態決定。
|
|
||||||
- **語氣參考**:讀起來要像這個帳號在說話,但每篇敘事節奏與開場都要自然變化。
|
|
||||||
- **人設不是內容素材**:只學語氣、詞彙密度、句型節奏與審美,不要把人設裡的名詞、興趣、裝飾符號硬塞進正文。例:人設寫 Y2K/咖啡/穿搭,只代表語感,不代表每篇都要寫老歌、咖啡、穿搭、MV。
|
|
||||||
- **內容素材**:研究地圖(受眾、目標、問題、支柱)與「勾選延伸知識」(含標籤、細節)決定論點與角度;若有「參考網頁(Markdown)」區塊,優先從中提取事實與論點,再融入草稿。
|
|
||||||
- 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。
|
|
||||||
- 爆款樣本只學故事感、資訊密度、情緒力度與互動方式,不抄原文,不可為仿爆款偏離任務主題。
|
|
||||||
- 若樣本呈現「長 caption + 輪播圖卡重點」的內容型態,Threads 草稿也要保留完整情緒承接與可收藏資訊,不要只輸出金句摘要。
|
|
||||||
- 若任務是改寫或參考別人的貼文,請用人設語言重新說一次,不是同義詞替換;保留觀點,不保留對方句型。
|
|
||||||
- 若任務需要找資料或引用外部知識,請先消化成此人設的說話順序:先安撫或帶入情境,再整理重點,最後用符合人設的方式收尾。
|
|
||||||
- 若沒有爆款樣本,source_permalinks 回傳空陣列,reference_notes 寫明使用了哪些人設特徵、研究地圖重點與勾選延伸知識。
|
|
||||||
|
|
||||||
回傳 JSON rows,每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"haixun-backend/internal/config"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
|
||||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Uploader interface {
|
|
||||||
Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type S3Uploader struct {
|
|
||||||
client *s3.Client
|
|
||||||
bucket string
|
|
||||||
publicBaseURL string
|
|
||||||
bucketReady bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewS3Uploader(ctx context.Context, cfg config.S3Conf) (*S3Uploader, error) {
|
|
||||||
if strings.TrimSpace(cfg.Bucket) == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
|
||||||
awsconfig.WithRegion(cfg.Region),
|
|
||||||
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
|
||||||
if cfg.Endpoint != "" {
|
|
||||||
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
|
||||||
}
|
|
||||||
o.UsePathStyle = cfg.UsePathStyle
|
|
||||||
})
|
|
||||||
return &S3Uploader{client: client, bucket: cfg.Bucket, publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/")}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *S3Uploader) Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error) {
|
|
||||||
if u == nil || u.client == nil {
|
|
||||||
return "", fmt.Errorf("s3 uploader is not configured")
|
|
||||||
}
|
|
||||||
if !u.bucketReady {
|
|
||||||
if _, err := u.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(u.bucket)}); err != nil {
|
|
||||||
if _, createErr := u.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(u.bucket)}); createErr != nil {
|
|
||||||
return "", createErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, u.bucket)
|
|
||||||
_, _ = u.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{Bucket: aws.String(u.bucket), Policy: aws.String(policy)})
|
|
||||||
u.bucketReady = true
|
|
||||||
}
|
|
||||||
key = strings.TrimLeft(path.Clean("/"+key), "/")
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = mime.TypeByExtension(path.Ext(key))
|
|
||||||
}
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = "application/octet-stream"
|
|
||||||
}
|
|
||||||
_, err := u.client.PutObject(ctx, &s3.PutObjectInput{
|
|
||||||
Bucket: aws.String(u.bucket),
|
|
||||||
Key: aws.String(key),
|
|
||||||
Body: body,
|
|
||||||
ContentType: aws.String(contentType),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if u.publicBaseURL != "" {
|
|
||||||
return u.publicBaseURL + "/" + u.bucket + "/" + key, nil
|
|
||||||
}
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CreateCopyMissionLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCreateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateCopyMissionLogic {
|
|
||||||
return &CreateCopyMissionLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DeleteCopyMissionLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDeleteCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionLogic {
|
|
||||||
return &DeleteCopyMissionLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *DeleteCopyMissionLogic) DeleteCopyMission(req *types.CopyMissionPath) error {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DeleteCopyMissionMatrixDraftsLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDeleteCopyMissionMatrixDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionMatrixDraftsLogic {
|
|
||||||
return &DeleteCopyMissionMatrixDraftsLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts(req *types.DeleteCopyMissionMatrixDraftsHandlerReq) (resp *types.DeleteCopyMissionMatrixDraftsData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ExpandCopyMissionGraphLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewExpandCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExpandCopyMissionGraphLogic {
|
|
||||||
return &ExpandCopyMissionGraphLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (resp *types.ExpandKnowledgeGraphData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GenerateCopyMissionMatrixLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGenerateCopyMissionMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCopyMissionMatrixLogic {
|
|
||||||
return &GenerateCopyMissionMatrixLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(req *types.GenerateCopyMissionMatrixHandlerReq) (resp *types.GenerateCopyMissionMatrixData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GetCopyMissionGraphLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGetCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionGraphLogic {
|
|
||||||
return &GetCopyMissionGraphLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (resp *types.KnowledgeGraphData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GetCopyMissionLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGetCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionLogic {
|
|
||||||
return &GetCopyMissionLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (resp *types.CopyMissionData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GetCopyMissionScanScheduleLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGetCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionScanScheduleLogic {
|
|
||||||
return &GetCopyMissionScanScheduleLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule(req *types.CopyMissionPath) (resp *types.CopyMissionScanScheduleData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ListCopyMissionCopyDraftsLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewListCopyMissionCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionCopyDraftsLogic {
|
|
||||||
return &ListCopyMissionCopyDraftsLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (resp *types.ListCopyMissionCopyDraftsData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ListCopyMissionScanPostsLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewListCopyMissionScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionScanPostsLogic {
|
|
||||||
return &ListCopyMissionScanPostsLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts(req *types.ListCopyMissionScanPostsHandlerReq) (resp *types.ListPersonaViralScanPostsData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ListCopyMissionsLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewListCopyMissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionsLogic {
|
|
||||||
return &ListCopyMissionsLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (resp *types.ListCopyMissionsData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PatchCopyMissionAudienceSampleLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPatchCopyMissionAudienceSampleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchCopyMissionAudienceSampleLogic {
|
|
||||||
return &PatchCopyMissionAudienceSampleLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (resp *types.CopyMissionData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PatchCopyMissionGraphNodesLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPatchCopyMissionGraphNodesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchCopyMissionGraphNodesLogic {
|
|
||||||
return &PatchCopyMissionGraphNodesLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (resp *types.KnowledgeGraphData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PatchCopyMissionSimilarAccountLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPatchCopyMissionSimilarAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchCopyMissionSimilarAccountLogic {
|
|
||||||
return &PatchCopyMissionSimilarAccountLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (resp *types.CopyMissionData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ScheduleCopyMissionDraftsLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewScheduleCopyMissionDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ScheduleCopyMissionDraftsLogic {
|
|
||||||
return &ScheduleCopyMissionDraftsLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.CopyMissionScheduleCopyDraftsData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StartCopyMissionAnalyzeJobLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStartCopyMissionAnalyzeJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionAnalyzeJobLogic {
|
|
||||||
return &StartCopyMissionAnalyzeJobLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionAnalyzeJobData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StartCopyMissionCopyDraftJobLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStartCopyMissionCopyDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionCopyDraftJobLogic {
|
|
||||||
return &StartCopyMissionCopyDraftJobLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(req *types.StartCopyMissionCopyDraftJobHandlerReq) (resp *types.StartCopyMissionCopyDraftJobData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StartCopyMissionMatrixJobLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStartCopyMissionMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionMatrixJobLogic {
|
|
||||||
return &StartCopyMissionMatrixJobLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(req *types.StartCopyMissionMatrixJobHandlerReq) (resp *types.StartCopyMissionMatrixJobData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StartCopyMissionScanJobLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStartCopyMissionScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionScanJobLogic {
|
|
||||||
return &StartCopyMissionScanJobLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionScanJobData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UpdateCopyMissionLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUpdateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateCopyMissionLogic {
|
|
||||||
return &UpdateCopyMissionLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package copy_mission
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UpsertCopyMissionScanScheduleLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUpsertCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertCopyMissionScanScheduleLogic {
|
|
||||||
return &UpsertCopyMissionScanScheduleLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule(req *types.UpsertCopyMissionScanScheduleHandlerReq) (resp *types.CopyMissionScanScheduleData, err error) {
|
|
||||||
// todo: add your logic here and delete this line
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
|
||||||
// goctl 1.10.1
|
|
||||||
|
|
||||||
package threads_account
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
jobdom "haixun-backend/internal/model/job/domain/usecase"
|
|
||||||
"haixun-backend/internal/svc"
|
|
||||||
"haixun-backend/internal/types"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StartPublishInventoryRefillJobLogic struct {
|
|
||||||
logx.Logger
|
|
||||||
ctx context.Context
|
|
||||||
svcCtx *svc.ServiceContext
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStartPublishInventoryRefillJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPublishInventoryRefillJobLogic {
|
|
||||||
return &StartPublishInventoryRefillJobLogic{
|
|
||||||
Logger: logx.WithContext(ctx),
|
|
||||||
ctx: ctx,
|
|
||||||
svcCtx: svcCtx,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *StartPublishInventoryRefillJobLogic) StartPublishInventoryRefillJob(req *types.StartPublishInventoryRefillJobHandlerReq) (resp *types.StartPublishInventoryRefillJobData, err error) {
|
|
||||||
tenantID, uid, err := actorFrom(l.ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
count := req.Count
|
|
||||||
if count <= 0 {
|
|
||||||
count = 3
|
|
||||||
}
|
|
||||||
run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
|
|
||||||
TemplateType: "refill-publish-inventory",
|
|
||||||
Scope: "threads_account",
|
|
||||||
ScopeID: req.ID,
|
|
||||||
TenantID: tenantID,
|
|
||||||
OwnerUID: uid,
|
|
||||||
Payload: map[string]any{
|
|
||||||
"tenant_id": tenantID,
|
|
||||||
"owner_uid": uid,
|
|
||||||
"account_id": req.ID,
|
|
||||||
"count": count,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &types.StartPublishInventoryRefillJobData{
|
|
||||||
JobID: run.ID.Hex(), Status: string(run.Status), Message: "補庫存任務已建立",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
package entity
|
|
||||||
|
|
||||||
const CollectionName = "crm_contacts"
|
|
||||||
|
|
||||||
const (
|
|
||||||
ContactStatusLead = "lead"
|
|
||||||
ContactStatusContacted = "contacted"
|
|
||||||
ContactStatusResponded = "responded"
|
|
||||||
ContactStatusConverted = "converted"
|
|
||||||
ContactStatusArchived = "archived"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CRMContact struct {
|
|
||||||
ID string `bson:"_id"`
|
|
||||||
TenantID string `bson:"tenant_id"`
|
|
||||||
OwnerUID string `bson:"owner_uid"`
|
|
||||||
BrandID string `bson:"brand_id,omitempty"`
|
|
||||||
AuthorID string `bson:"author_id"`
|
|
||||||
AuthorName string `bson:"author_name"`
|
|
||||||
AuthorAvatar string `bson:"author_avatar,omitempty"`
|
|
||||||
AuthorFollowers int `bson:"author_followers,omitempty"`
|
|
||||||
ScanPostID string `bson:"scan_post_id,omitempty"`
|
|
||||||
Notes string `bson:"notes,omitempty"`
|
|
||||||
Tags []string `bson:"tags,omitempty"`
|
|
||||||
Status string `bson:"status"`
|
|
||||||
OutreachCount int `bson:"outreach_count,omitempty"`
|
|
||||||
LastContactedAt int64 `bson:"last_contacted_at,omitempty"`
|
|
||||||
CreateAt int64 `bson:"create_at"`
|
|
||||||
UpdateAt int64 `bson:"update_at"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"haixun-backend/internal/model/crm_contact/domain/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ListFilter struct {
|
|
||||||
BrandID string
|
|
||||||
Status string
|
|
||||||
Tag string
|
|
||||||
Limit int
|
|
||||||
Offset int
|
|
||||||
}
|
|
||||||
|
|
||||||
type Repository interface {
|
|
||||||
EnsureIndexes(ctx context.Context) error
|
|
||||||
Create(ctx context.Context, contact *entity.CRMContact) error
|
|
||||||
Get(ctx context.Context, tenantID, ownerUID, contactID string) (*entity.CRMContact, error)
|
|
||||||
GetByAuthor(ctx context.Context, tenantID, ownerUID, authorID string) (*entity.CRMContact, error)
|
|
||||||
Update(ctx context.Context, tenantID, ownerUID, contactID string, patch map[string]interface{}) (*entity.CRMContact, error)
|
|
||||||
Delete(ctx context.Context, tenantID, ownerUID, contactID string) error
|
|
||||||
List(ctx context.Context, tenantID, ownerUID string, filter ListFilter) ([]entity.CRMContact, int, error)
|
|
||||||
UpsertByAuthor(ctx context.Context, contact *entity.CRMContact) (*entity.CRMContact, error)
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
package usecase
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
type CRMContactSummary struct {
|
|
||||||
ID string
|
|
||||||
BrandID string
|
|
||||||
AuthorID string
|
|
||||||
AuthorName string
|
|
||||||
AuthorAvatar string
|
|
||||||
AuthorFollowers int
|
|
||||||
ScanPostID string
|
|
||||||
Notes string
|
|
||||||
Tags []string
|
|
||||||
Status string
|
|
||||||
OutreachCount int
|
|
||||||
LastContactedAt int64
|
|
||||||
CreateAt int64
|
|
||||||
UpdateAt int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListRequest struct {
|
|
||||||
TenantID string
|
|
||||||
OwnerUID string
|
|
||||||
BrandID string
|
|
||||||
Status string
|
|
||||||
Tag string
|
|
||||||
Page int
|
|
||||||
PageSize int
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateRequest struct {
|
|
||||||
TenantID string
|
|
||||||
OwnerUID string
|
|
||||||
ContactID string
|
|
||||||
Notes *string
|
|
||||||
Tags *[]string
|
|
||||||
Status *string
|
|
||||||
}
|
|
||||||
|
|
||||||
type UseCase interface {
|
|
||||||
Create(ctx context.Context, tenantID, ownerUID, brandID, authorID, authorName string) (*CRMContactSummary, error)
|
|
||||||
Get(ctx context.Context, tenantID, ownerUID, contactID string) (*CRMContactSummary, error)
|
|
||||||
Update(ctx context.Context, req UpdateRequest) (*CRMContactSummary, error)
|
|
||||||
Delete(ctx context.Context, tenantID, ownerUID, contactID string) error
|
|
||||||
List(ctx context.Context, req ListRequest) ([]CRMContactSummary, int, error)
|
|
||||||
}
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
app "haixun-backend/internal/library/errors"
|
|
||||||
"haixun-backend/internal/library/errors/code"
|
|
||||||
"haixun-backend/internal/model/crm_contact/domain/entity"
|
|
||||||
domrepo "haixun-backend/internal/model/crm_contact/domain/repository"
|
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/bson"
|
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mongoRepository struct {
|
|
||||||
collection *mongo.Collection
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMongoRepository(db *mongo.Database) domrepo.Repository {
|
|
||||||
if db == nil {
|
|
||||||
return &mongoRepository{}
|
|
||||||
}
|
|
||||||
return &mongoRepository{collection: db.Collection(entity.CollectionName)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func baseFilter(tenantID, ownerUID string) bson.M {
|
|
||||||
return bson.M{
|
|
||||||
"tenant_id": strings.TrimSpace(tenantID),
|
|
||||||
"owner_uid": strings.TrimSpace(ownerUID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
|
|
||||||
if r.collection == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
|
|
||||||
{
|
|
||||||
Keys: bson.D{
|
|
||||||
{Key: "tenant_id", Value: 1},
|
|
||||||
{Key: "owner_uid", Value: 1},
|
|
||||||
{Key: "brand_id", Value: 1},
|
|
||||||
{Key: "create_at", Value: -1},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Keys: bson.D{
|
|
||||||
{Key: "tenant_id", Value: 1},
|
|
||||||
{Key: "owner_uid", Value: 1},
|
|
||||||
{Key: "author_id", Value: 1},
|
|
||||||
},
|
|
||||||
Options: options.Index().SetUnique(true),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mongoRepository) Create(ctx context.Context, contact *entity.CRMContact) error {
|
|
||||||
if r.collection == nil {
|
|
||||||
return app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
|
||||||
}
|
|
||||||
if contact == nil {
|
|
||||||
return app.For(code.Persona).InputMissingRequired("contact is required")
|
|
||||||
}
|
|
||||||
_, err := r.collection.InsertOne(ctx, contact)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, contactID string) (*entity.CRMContact, error) {
|
|
||||||
if r.collection == nil {
|
|
||||||
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
|
||||||
}
|
|
||||||
contactID = strings.TrimSpace(contactID)
|
|
||||||
if contactID == "" {
|
|
||||||
return nil, app.For(code.Persona).InputMissingRequired("contact_id is required")
|
|
||||||
}
|
|
||||||
filter := baseFilter(tenantID, ownerUID)
|
|
||||||
filter["_id"] = contactID
|
|
||||||
var out entity.CRMContact
|
|
||||||
err := r.collection.FindOne(ctx, filter).Decode(&out)
|
|
||||||
if err != nil {
|
|
||||||
if err == mongo.ErrNoDocuments {
|
|
||||||
return nil, app.For(code.Persona).ResNotFound("crm contact not found")
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mongoRepository) GetByAuthor(ctx context.Context, tenantID, ownerUID, authorID string) (*entity.CRMContact, error) {
|
|
||||||
if r.collection == nil {
|
|
||||||
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
|
||||||
}
|
|
||||||
authorID = strings.TrimSpace(authorID)
|
|
||||||
if authorID == "" {
|
|
||||||
return nil, app.For(code.Persona).InputMissingRequired("author_id is required")
|
|
||||||
}
|
|
||||||
filter := baseFilter(tenantID, ownerUID)
|
|
||||||
filter["author_id"] = authorID
|
|
||||||
var out entity.CRMContact
|
|
||||||
err := r.collection.FindOne(ctx, filter).Decode(&out)
|
|
||||||
if err != nil {
|
|
||||||
if err == mongo.ErrNoDocuments {
|
|
||||||
return nil, app.For(code.Persona).ResNotFound("crm contact not found")
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, contactID string, patch map[string]interface{}) (*entity.CRMContact, error) {
|
|
||||||
if r.collection == nil {
|
|
||||||
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
|
||||||
}
|
|
||||||
if len(patch) == 0 {
|
|
||||||
return nil, app.For(code.Persona).InputMissingRequired("patch is required")
|
|
||||||
}
|
|
||||||
contactID = strings.TrimSpace(contactID)
|
|
||||||
filter := baseFilter(tenantID, ownerUID)
|
|
||||||
filter["_id"] = contactID
|
|
||||||
opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
|
|
||||||
var out entity.CRMContact
|
|
||||||
err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, opts).Decode(&out)
|
|
||||||
if err != nil {
|
|
||||||
if err == mongo.ErrNoDocuments {
|
|
||||||
return nil, app.For(code.Persona).ResNotFound("crm contact not found")
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, contactID string) error {
|
|
||||||
if r.collection == nil {
|
|
||||||
return app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
|
||||||
}
|
|
||||||
contactID = strings.TrimSpace(contactID)
|
|
||||||
if contactID == "" {
|
|
||||||
return app.For(code.Persona).InputMissingRequired("contact_id is required")
|
|
||||||
}
|
|
||||||
filter := baseFilter(tenantID, ownerUID)
|
|
||||||
filter["_id"] = contactID
|
|
||||||
_, err := r.collection.DeleteOne(ctx, filter)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, filter domrepo.ListFilter) ([]entity.CRMContact, int, error) {
|
|
||||||
if r.collection == nil {
|
|
||||||
return nil, 0, app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
|
||||||
}
|
|
||||||
query := baseFilter(tenantID, ownerUID)
|
|
||||||
if filter.BrandID != "" {
|
|
||||||
query["brand_id"] = filter.BrandID
|
|
||||||
}
|
|
||||||
if filter.Status != "" {
|
|
||||||
query["status"] = filter.Status
|
|
||||||
}
|
|
||||||
if filter.Tag != "" {
|
|
||||||
query["tags"] = filter.Tag
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := filter.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
if limit > 100 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
skip := filter.Offset
|
|
||||||
if skip < 0 {
|
|
||||||
skip = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
total, err := r.collection.CountDocuments(ctx, query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := options.Find().
|
|
||||||
SetSort(bson.D{{Key: "create_at", Value: -1}}).
|
|
||||||
SetSkip(int64(skip)).
|
|
||||||
SetLimit(int64(limit))
|
|
||||||
cur, err := r.collection.Find(ctx, query, opts)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
defer cur.Close(ctx)
|
|
||||||
|
|
||||||
var out []entity.CRMContact
|
|
||||||
if err := cur.All(ctx, &out); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
return out, int(total), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mongoRepository) UpsertByAuthor(ctx context.Context, contact *entity.CRMContact) (*entity.CRMContact, error) {
|
|
||||||
if r.collection == nil {
|
|
||||||
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
|
|
||||||
}
|
|
||||||
if contact == nil {
|
|
||||||
return nil, app.For(code.Persona).InputMissingRequired("contact is required")
|
|
||||||
}
|
|
||||||
filter := baseFilter(contact.TenantID, contact.OwnerUID)
|
|
||||||
filter["author_id"] = contact.AuthorID
|
|
||||||
update := bson.M{"$set": contact}
|
|
||||||
opts := options.FindOneAndUpdate().
|
|
||||||
SetUpsert(true).
|
|
||||||
SetReturnDocument(options.After)
|
|
||||||
var out entity.CRMContact
|
|
||||||
err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &out, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
package usecase
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"haixun-backend/internal/library/clock"
|
|
||||||
app "haixun-backend/internal/library/errors"
|
|
||||||
"haixun-backend/internal/library/errors/code"
|
|
||||||
"haixun-backend/internal/model/crm_contact/domain/entity"
|
|
||||||
domrepo "haixun-backend/internal/model/crm_contact/domain/repository"
|
|
||||||
domusecase "haixun-backend/internal/model/crm_contact/domain/usecase"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
type crmContactUseCase struct {
|
|
||||||
repo domrepo.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
|
|
||||||
return &crmContactUseCase{repo: repo}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *crmContactUseCase) Create(ctx context.Context, tenantID, ownerUID, brandID, authorID, authorName string) (*domusecase.CRMContactSummary, error) {
|
|
||||||
if err := requireActor(tenantID, ownerUID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
authorID = strings.TrimSpace(authorID)
|
|
||||||
if authorID == "" {
|
|
||||||
return nil, app.For(code.Persona).InputMissingRequired("author_id is required")
|
|
||||||
}
|
|
||||||
authorName = strings.TrimSpace(authorName)
|
|
||||||
if authorName == "" {
|
|
||||||
return nil, app.For(code.Persona).InputMissingRequired("author_name is required")
|
|
||||||
}
|
|
||||||
now := clock.NowUnixNano()
|
|
||||||
contact := &entity.CRMContact{
|
|
||||||
ID: uuid.NewString(),
|
|
||||||
TenantID: tenantID,
|
|
||||||
OwnerUID: ownerUID,
|
|
||||||
BrandID: strings.TrimSpace(brandID),
|
|
||||||
AuthorID: authorID,
|
|
||||||
AuthorName: authorName,
|
|
||||||
Status: entity.ContactStatusLead,
|
|
||||||
CreateAt: now,
|
|
||||||
UpdateAt: now,
|
|
||||||
}
|
|
||||||
if err := u.repo.Create(ctx, contact); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
summary := toSummary(*contact)
|
|
||||||
return &summary, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *crmContactUseCase) Get(ctx context.Context, tenantID, ownerUID, contactID string) (*domusecase.CRMContactSummary, error) {
|
|
||||||
if err := requireActor(tenantID, ownerUID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
contactID = strings.TrimSpace(contactID)
|
|
||||||
if contactID == "" {
|
|
||||||
return nil, app.For(code.Persona).InputMissingRequired("contact_id is required")
|
|
||||||
}
|
|
||||||
item, err := u.repo.Get(ctx, tenantID, ownerUID, contactID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
summary := toSummary(*item)
|
|
||||||
return &summary, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *crmContactUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.CRMContactSummary, error) {
|
|
||||||
if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
contactID := strings.TrimSpace(req.ContactID)
|
|
||||||
if contactID == "" {
|
|
||||||
return nil, app.For(code.Persona).InputMissingRequired("contact_id is required")
|
|
||||||
}
|
|
||||||
patch := map[string]interface{}{}
|
|
||||||
if req.Notes != nil {
|
|
||||||
patch["notes"] = strings.TrimSpace(*req.Notes)
|
|
||||||
}
|
|
||||||
if req.Tags != nil {
|
|
||||||
patch["tags"] = *req.Tags
|
|
||||||
}
|
|
||||||
if req.Status != nil {
|
|
||||||
status := strings.TrimSpace(*req.Status)
|
|
||||||
if status != "" && status != entity.ContactStatusLead && status != entity.ContactStatusContacted && status != entity.ContactStatusResponded && status != entity.ContactStatusConverted && status != entity.ContactStatusArchived {
|
|
||||||
return nil, app.For(code.Persona).InputInvalidFormat("invalid contact status")
|
|
||||||
}
|
|
||||||
if status != "" {
|
|
||||||
patch["status"] = status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(patch) == 0 {
|
|
||||||
return nil, app.For(code.Persona).InputMissingRequired("no fields to update")
|
|
||||||
}
|
|
||||||
patch["update_at"] = clock.NowUnixNano()
|
|
||||||
item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, contactID, patch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
summary := toSummary(*item)
|
|
||||||
return &summary, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *crmContactUseCase) Delete(ctx context.Context, tenantID, ownerUID, contactID string) error {
|
|
||||||
if err := requireActor(tenantID, ownerUID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
contactID = strings.TrimSpace(contactID)
|
|
||||||
if contactID == "" {
|
|
||||||
return app.For(code.Persona).InputMissingRequired("contact_id is required")
|
|
||||||
}
|
|
||||||
return u.repo.Delete(ctx, tenantID, ownerUID, contactID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *crmContactUseCase) List(ctx context.Context, req domusecase.ListRequest) ([]domusecase.CRMContactSummary, int, error) {
|
|
||||||
if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
page := req.Page
|
|
||||||
pageSize := req.PageSize
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
if pageSize < 1 {
|
|
||||||
pageSize = 20
|
|
||||||
}
|
|
||||||
if pageSize > 100 {
|
|
||||||
pageSize = 100
|
|
||||||
}
|
|
||||||
filter := domrepo.ListFilter{
|
|
||||||
BrandID: strings.TrimSpace(req.BrandID),
|
|
||||||
Status: strings.TrimSpace(req.Status),
|
|
||||||
Tag: strings.TrimSpace(req.Tag),
|
|
||||||
Limit: pageSize,
|
|
||||||
Offset: (page - 1) * pageSize,
|
|
||||||
}
|
|
||||||
items, total, err := u.repo.List(ctx, req.TenantID, req.OwnerUID, filter)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
out := make([]domusecase.CRMContactSummary, 0, len(items))
|
|
||||||
for _, item := range items {
|
|
||||||
out = append(out, toSummary(item))
|
|
||||||
}
|
|
||||||
return out, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireActor(tenantID, ownerUID string) error {
|
|
||||||
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
|
|
||||||
return app.For(code.Auth).AuthUnauthorized("missing actor")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toSummary(item entity.CRMContact) domusecase.CRMContactSummary {
|
|
||||||
return domusecase.CRMContactSummary{
|
|
||||||
ID: item.ID,
|
|
||||||
BrandID: item.BrandID,
|
|
||||||
AuthorID: item.AuthorID,
|
|
||||||
AuthorName: item.AuthorName,
|
|
||||||
AuthorAvatar: item.AuthorAvatar,
|
|
||||||
AuthorFollowers: item.AuthorFollowers,
|
|
||||||
ScanPostID: item.ScanPostID,
|
|
||||||
Notes: item.Notes,
|
|
||||||
Tags: item.Tags,
|
|
||||||
Status: item.Status,
|
|
||||||
OutreachCount: item.OutreachCount,
|
|
||||||
LastContactedAt: item.LastContactedAt,
|
|
||||||
CreateAt: item.CreateAt,
|
|
||||||
UpdateAt: item.UpdateAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
package entity
|
|
||||||
|
|
||||||
const CollectionName = "publish_text_drafts"
|
|
||||||
|
|
||||||
type Draft struct {
|
|
||||||
ID string `bson:"_id"`
|
|
||||||
TenantID string `bson:"tenant_id"`
|
|
||||||
OwnerUID string `bson:"owner_uid"`
|
|
||||||
AccountID string `bson:"account_id"`
|
|
||||||
Title string `bson:"title,omitempty"`
|
|
||||||
Text string `bson:"text"`
|
|
||||||
Tags []string `bson:"tags,omitempty"`
|
|
||||||
CreateAt int64 `bson:"create_at"`
|
|
||||||
UpdateAt int64 `bson:"update_at"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"haixun-backend/internal/model/publish_draft/domain/entity"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ListResult struct {
|
|
||||||
Items []entity.Draft
|
|
||||||
Total int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type Repository interface {
|
|
||||||
EnsureIndexes(ctx context.Context) error
|
|
||||||
Create(ctx context.Context, draft *entity.Draft) error
|
|
||||||
Get(ctx context.Context, tenantID, ownerUID, accountID, draftID string) (*entity.Draft, error)
|
|
||||||
List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
|
|
||||||
Update(ctx context.Context, draft *entity.Draft) (*entity.Draft, error)
|
|
||||||
Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
package usecase
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
type DraftSummary struct {
|
|
||||||
ID string
|
|
||||||
AccountID string
|
|
||||||
Title string
|
|
||||||
Text string
|
|
||||||
Tags []string
|
|
||||||
CreateAt int64
|
|
||||||
UpdateAt int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateRequest struct {
|
|
||||||
TenantID string
|
|
||||||
OwnerUID string
|
|
||||||
AccountID string
|
|
||||||
Title string
|
|
||||||
Text string
|
|
||||||
Tags []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdateRequest struct {
|
|
||||||
TenantID string
|
|
||||||
OwnerUID string
|
|
||||||
AccountID string
|
|
||||||
DraftID string
|
|
||||||
Title *string
|
|
||||||
Text *string
|
|
||||||
Tags *[]string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListResult struct {
|
|
||||||
Items []DraftSummary
|
|
||||||
Total int64
|
|
||||||
Page int
|
|
||||||
PageSize int
|
|
||||||
TotalPages int
|
|
||||||
}
|
|
||||||
|
|
||||||
type UseCase interface {
|
|
||||||
Create(ctx context.Context, req CreateRequest) (*DraftSummary, error)
|
|
||||||
Get(ctx context.Context, tenantID, ownerUID, accountID, draftID string) (*DraftSummary, error)
|
|
||||||
List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
|
|
||||||
Update(ctx context.Context, req UpdateRequest) (*DraftSummary, error)
|
|
||||||
Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# 產品文件(Spec-Driven)
|
||||||
|
|
||||||
|
本目錄是 **需求 → 規格 → 計劃 → 小任務** 的產物根,由專案 skill `/spec-driven` 驅動。
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/product/
|
||||||
|
README.md # 本說明
|
||||||
|
<slug>/
|
||||||
|
README.md # 目前 phase 與狀態
|
||||||
|
requirements.md
|
||||||
|
spec.md # 需求批准後
|
||||||
|
plan.md # spec 批准後
|
||||||
|
tasks/
|
||||||
|
INDEX.md
|
||||||
|
T001-....md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 怎麼用
|
||||||
|
|
||||||
|
```text
|
||||||
|
/spec-driven # 開新的或繼續未完成
|
||||||
|
/spec-driven haixun-console # 指定產品 run
|
||||||
|
/spec-driven haixun-console --status # 只看進度
|
||||||
|
```
|
||||||
|
|
||||||
|
**規則:** 每階段寫完要等人批准,才能進下一階段;未批准 plan / tasks 前不寫業務程式。
|
||||||
|
|
||||||
|
模板與流程細節:`.grok/skills/spec-driven/`。
|
||||||
|
|
||||||
|
## 現有 run
|
||||||
|
|
||||||
|
| Slug | 說明 |
|
||||||
|
|------|------|
|
||||||
|
| [haixun-console](./haixun-console/) | 巡樓 Console **從零重寫**(`old/` 封存;地基 → FE mock → 後端) |
|
||||||
|
|
||||||
|
## 與其他 docs 的關係
|
||||||
|
|
||||||
|
| 路徑 | 角色 |
|
||||||
|
|------|------|
|
||||||
|
| `docs/architecture.md` | 程式怎麼長(現況) |
|
||||||
|
| `docs/improvement-notes.md` | 技術改進建議(非產品需求) |
|
||||||
|
| `docs/post-project-plan.md` | 文案流重做構想(輸入,須併入 requirements 才算正式) |
|
||||||
|
| `AGENTS.md` | 實作約束 |
|
||||||
|
| `docs/product/*` | **產品決策真相來源**(本流水線) |
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Product run: haixun-console
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|--------|
|
||||||
|
| Goal | 從零重寫:地基 → 前端 mock → 接真;多帳互回(reply 含 lead) |
|
||||||
|
| Phase | **tasks / ready-to-implement** |
|
||||||
|
| Requirements | **approved** |
|
||||||
|
| Spec | **approved** |
|
||||||
|
| Plan | **approved** |
|
||||||
|
| Legacy | `old/` 封存 |
|
||||||
|
| Updated | 2026-07-09 |
|
||||||
|
|
||||||
|
## Delivery order
|
||||||
|
|
||||||
|
```text
|
||||||
|
M0–M9 (T001–T022) → Phase B mock UI
|
||||||
|
M10 → 人審 B OK
|
||||||
|
M20+ (T100+) → Phase C live API(尚未拆細)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
| File | Status |
|
||||||
|
|------|--------|
|
||||||
|
| [requirements.md](./requirements.md) | approved |
|
||||||
|
| [spec.md](./spec.md) | approved |
|
||||||
|
| [plan.md](./plan.md) | approved |
|
||||||
|
| [tasks/INDEX.md](./tasks/INDEX.md) | **22 tasks ready** |
|
||||||
|
|
||||||
|
## Start implementing
|
||||||
|
|
||||||
|
```text
|
||||||
|
從 T001 開始
|
||||||
|
docs/product/haixun-console/tasks/T001-scaffold-apps-web.md
|
||||||
|
```
|
||||||
|
|
||||||
|
完成一題就把 INDEX 狀態改成 `done`,再做下一題。
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
|
||||||
|
- `apps/web` greenfield · Harbor Desk · Speakers = lead ∪ cast(reply 可選 lead)
|
||||||
|
- B 可純前端 · mock localStorage · Scout 占位
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Plan: 巡樓 Console 從零重寫
|
||||||
|
|
||||||
|
> Status: **`approved`**(與 task 拆分一併啟動)
|
||||||
|
> Source: `docs/product/haixun-console/spec.md`
|
||||||
|
> Last updated: `2026-07-09`
|
||||||
|
|
||||||
|
## 1. 交付策略
|
||||||
|
|
||||||
|
舊碼已在 `old/` 封存。主線 **greenfield**:
|
||||||
|
|
||||||
|
1. **先 B(前端 mock)** 讓 Harbor Desk 流程(含 lead 可回覆)你摸到滿意。
|
||||||
|
2. **A 的 live 地基**不阻塞 B;可在 B 過關後或平行用 `services/api` 補。
|
||||||
|
3. **C** 同一 UI 接真;**D** 海巡等。
|
||||||
|
|
||||||
|
本 plan 的 **可立即開工區間是 M0–M2(tasks T001–T022)**,對應 Phase B 完整 mock。
|
||||||
|
Phase C/D 只定里程碑與 task 編號預留,細 task 待 B 過關再拆。
|
||||||
|
|
||||||
|
## 2. 里程碑
|
||||||
|
|
||||||
|
### M0 — 倉庫骨架
|
||||||
|
|
||||||
|
- **目標:** 可 `npm run dev` 的空應用
|
||||||
|
- **完成定義:** `apps/web` Vite+React+TS 起來;根 README 有啟動指令
|
||||||
|
- **Tasks:** T001
|
||||||
|
- **驗證:** `cd apps/web && npm install && npm run build`
|
||||||
|
|
||||||
|
### M1 — 設計系統 + App Shell
|
||||||
|
|
||||||
|
- **目標:** Harbor Desk 外殼可導覽
|
||||||
|
- **完成定義:** token、基礎元件、桌面側欄+手機底欄、5 入口路由占位
|
||||||
|
- **Tasks:** T002–T005
|
||||||
|
- **驗證:** 手動點 5 入口;build 通過
|
||||||
|
|
||||||
|
### M2 — Auth mock + 路由守衛
|
||||||
|
|
||||||
|
- **目標:** 未登入進不了 `/app/**`
|
||||||
|
- **完成定義:** mock 登入/登出/重整保 session
|
||||||
|
- **Tasks:** T006
|
||||||
|
- **驗證:** 登入後進 Today;logout 回 login
|
||||||
|
|
||||||
|
### M3 — 資料層 mock
|
||||||
|
|
||||||
|
- **目標:** repository 介面 + localStorage + seed
|
||||||
|
- **完成定義:** accounts/plays/outbox/jobs/notifications 可讀寫;`mock|live` 切換點存在(live 可 stub 丟錯)
|
||||||
|
- **Tasks:** T007–T008
|
||||||
|
- **驗證:** 重整後 seed/草稿仍在
|
||||||
|
|
||||||
|
### M4 — Crew(帳號)
|
||||||
|
|
||||||
|
- **目標:** 看帳號列表、mock 新增、不可用狀態
|
||||||
|
- **Tasks:** T009
|
||||||
|
- **驗證:** 列表 ≥2 假帳;連線 error 不可當 lead/cast
|
||||||
|
|
||||||
|
### M5 — Studio 列表 + Wizard 船隊/劇本
|
||||||
|
|
||||||
|
- **目標:** 建立串場;lead+cast;步驟含 lead 發言
|
||||||
|
- **Tasks:** T010–T013
|
||||||
|
- **驗證:** root(A)→reply(B)→reply(A) 可編輯保存
|
||||||
|
|
||||||
|
### M6 — 預覽 / 船期 / 提交
|
||||||
|
|
||||||
|
- **目標:** 預覽泡泡、排程、進 Outbox
|
||||||
|
- **Tasks:** T014
|
||||||
|
- **驗證:** submit 後 Outbox 有對應 bundle
|
||||||
|
|
||||||
|
### M7 — Outbox 發送模擬
|
||||||
|
|
||||||
|
- **目標:** 逐步狀態、主失敗不發後續、重試
|
||||||
|
- **Tasks:** T015–T016
|
||||||
|
- **驗證:** 模擬成功全 published;root fail 時 reply 不 published
|
||||||
|
|
||||||
|
### M8 — Job + 通知 + Today
|
||||||
|
|
||||||
|
- **目標:** 任務進度、鈴鐺、今日總覽
|
||||||
|
- **Tasks:** T017–T019
|
||||||
|
- **驗證:** 模擬 job 完成出通知;Today 有捷徑
|
||||||
|
|
||||||
|
### M9 — Scout / Settings / 收尾
|
||||||
|
|
||||||
|
- **目標:** 占位頁、datasource 切換、RWD 基線、範例劇本
|
||||||
|
- **Tasks:** T020–T022
|
||||||
|
- **驗證:** Scout 無假成功;手機底欄可用;build 通過
|
||||||
|
|
||||||
|
### M10 — Phase B 過關(人審)
|
||||||
|
|
||||||
|
- **目標:** 你主觀認可流程與風格
|
||||||
|
- **Tasks:** 無新碼;走查 checklist(requirements Phase B)
|
||||||
|
- **驗證:** 文件勾選 + 你回「B OK」
|
||||||
|
|
||||||
|
### M20 — Phase C 接真(預留,B 後再拆細 task)
|
||||||
|
|
||||||
|
- `services/api` 最小 auth/me/permissions
|
||||||
|
- Threads OAuth + accounts
|
||||||
|
- Jobs + notifications live
|
||||||
|
- ThreadPlay/Outbox live 垂直切片
|
||||||
|
|
||||||
|
預留 task 號:**T100+**(本輪不寫檔)
|
||||||
|
|
||||||
|
### M30 — Phase D(預留)
|
||||||
|
|
||||||
|
- Scout 海巡滿血等 → **T200+**
|
||||||
|
|
||||||
|
## 3. 依賴
|
||||||
|
|
||||||
|
```text
|
||||||
|
M0 → M1 → M2 → M3 → M4
|
||||||
|
↘ M5 → M6 → M7
|
||||||
|
↘ M8
|
||||||
|
↘ M9 → M10 (人審)
|
||||||
|
↘ M20 → M30
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 風險與緩解
|
||||||
|
|
||||||
|
| 風險 | 緩解 |
|
||||||
|
|------|------|
|
||||||
|
| Task 膨脹超 200 行 | 實作時再拆子 task,更新 INDEX |
|
||||||
|
| Mock 契約與 C 不一致 | T007 介面以 spec §5 為準,C 只換 adapter |
|
||||||
|
| 過早寫 Go | M10 前禁止大範圍 `services/api` 業務 |
|
||||||
|
|
||||||
|
## 5. 刻意不做(本 plan 週期內 = 到 M10)
|
||||||
|
|
||||||
|
- 海巡真功能、舊碼搬回、重型 UI 庫
|
||||||
|
- live Threads 發文(屬 M20)
|
||||||
|
- email 驗證閘
|
||||||
|
|
||||||
|
## 6. 全域驗證指令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web && npm install && npm run build
|
||||||
|
# 之後若有測試:
|
||||||
|
# npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 批准
|
||||||
|
|
||||||
|
- [x] Plan 已批准:2026-07-09(使用者:好請幫我開始拆)
|
||||||
|
- 實作順序:從 **T001** 起,完成一題勾 INDEX 再下一題
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
# Requirements: 巡樓 Console(整包重構)
|
||||||
|
|
||||||
|
> Status: **`approved`**
|
||||||
|
> Slug: `haixun-console`
|
||||||
|
> Last updated: `2026-07-09`
|
||||||
|
> 批准:使用者確認 OK(2026-07-09)
|
||||||
|
> 策略:舊碼整包封存 `old/`;**主線從零重寫**;先地基 → 前端 mock → 再接後端;文案含多帳號互回
|
||||||
|
> 變更:reply 步驟可選 **lead**(主帳可接續回覆)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 一句話
|
||||||
|
|
||||||
|
用 Grok 4.5 **重構**巡樓工作台:先把登入、Threads 串接、Job、通知、權限等地基做對;**前端用全新流程與視覺 + 假資料**把操作順到你滿意,再補真實後端與業務,避免在舊混亂上疊功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 誰用、在什麼情境
|
||||||
|
|
||||||
|
| 角色 | 目標 |
|
||||||
|
|------|------|
|
||||||
|
| 經營者 | 綁多個 Threads、產文案(主帳+配角互回)、海巡外展、排程發文;看任務與通知 |
|
||||||
|
| 管理員 | 建會員、管權限(封閉 beta) |
|
||||||
|
| (系統)Worker | 長任務執行並回報進度 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 背景與動機
|
||||||
|
|
||||||
|
### 為什麼要重構(不是小修)
|
||||||
|
|
||||||
|
- 現況前後端半成品交織:假成功 API、幽靈 worker、巨型頁、文件漂移 → **在舊樹上修會繼續亂**。
|
||||||
|
- 你希望流程與風格都重想;業務好不好用要先**用手走完**,不是先堆後端。
|
||||||
|
- 交付方式:**舊 monorepo 已封存至 `old/`**,根目錄從零建立 `apps/web`、`services/*` 等;`old/` 只讀參考。
|
||||||
|
|
||||||
|
### 參考(非最終規格)
|
||||||
|
|
||||||
|
- 封存:`old/backend/`、`old/docs/`、`old/AGENTS.md`
|
||||||
|
- 產品:`docs/product/haixun-console/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 交付策略(怎麼重構)— 硬性順序
|
||||||
|
|
||||||
|
```text
|
||||||
|
Phase A 地基(最小可用平台)
|
||||||
|
→ Phase B 新前端 + Mock(流程與風格定稿)
|
||||||
|
→ Phase C 後端接真(替換 mock,同一套 UI)
|
||||||
|
→ Phase D 業務加深(海巡、進階互回、成效…)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Phase | 目標 | 完成定義(產品語言) |
|
||||||
|
|-------|------|----------------------|
|
||||||
|
| **A 地基** | 平台能站起來 | 登入會話可用;權限可擋路由;Threads 帳號能綁/能呼叫 API(或明確 mock 開關);Job 能建立/查狀態/通知;**無假成功** |
|
||||||
|
| **B 前端 Mock** | 操作順、好看 | 全主要流程用假資料可點完;你認可 IA + 視覺;API 層可切 `mock | live` |
|
||||||
|
| **C 接後端** | 假資料變真 | 同一 UI,逐頁/逐域替換 live;契約與 mock 對齊 |
|
||||||
|
| **D 業務加深** | 護城河 | 海巡閉環、進階互回/AI 分角、護欄與成效等 |
|
||||||
|
|
||||||
|
**原則:**
|
||||||
|
|
||||||
|
1. **B 未通過前,不開大範圍業務後端**(避免又寫一堆用不到的 API)。
|
||||||
|
2. A 只做「所有業務都會用到的管子」,不做完整海巡/完整矩陣。
|
||||||
|
3. 新碼只在根目錄新結構;**不在 `old/` 上繼續開發**。
|
||||||
|
4. 舊 monorepo **已封存**於 `old/`(2026-07-09)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 目標分級
|
||||||
|
|
||||||
|
### 5.1 P0-A — 地基(平台)
|
||||||
|
|
||||||
|
1. **登入與會話**
|
||||||
|
email/password 登入、logout、refresh;未登入不可進主殼。
|
||||||
|
(封閉註冊:admin 建號或 seed;公開註冊非 P0。)
|
||||||
|
2. **權限**
|
||||||
|
至少:已登入會員 vs admin;前端路由與關鍵操作可依角色顯示/阻擋;後端同語意。
|
||||||
|
3. **Threads API 串接完成(基礎)**
|
||||||
|
- 綁定帳號(OAuth 或文件化的連線方式)
|
||||||
|
- 列表已綁帳號、連線健康/錯誤可讀
|
||||||
|
- 具備「用指定帳號打 Threads API」的統一出口(發文/回覆可先最小 smoke,完整業務在 C/D)
|
||||||
|
4. **Job 系統(最小)**
|
||||||
|
建立任務、查列表/詳情、狀態(queued/running/succeeded/failed/cancelled)、進度摘要。
|
||||||
|
5. **Notification(任務通知)**
|
||||||
|
任務完成/失敗時,Console 內可感知(鈴鐺列表或 toast + 可點進 job);不要求 email/push 推播為 P0。
|
||||||
|
6. **契約底線**
|
||||||
|
JSON envelope、錯誤可讀、時間 UTC nano(後端)、**禁止 stub 回成功**。
|
||||||
|
|
||||||
|
### 5.2 P0-B — 新前端 + Mock(你要先滿意的部分)
|
||||||
|
|
||||||
|
1. **全新資訊架構與視覺**(見 §8),不沿用「掌機側欄 + 巨型單頁」當預設。
|
||||||
|
2. **Mock 驅動的完整主流程**(假資料也要真實感):
|
||||||
|
- 登入殼 → 總覽(今日待辦)
|
||||||
|
- 帳號(多 Threads)
|
||||||
|
- **發文計畫 / 串場**(主題 → 主帳+cast → 草稿串 → 預覽 → 排程預覽)
|
||||||
|
- 出站佇列(Outbox)狀態
|
||||||
|
- 任務與通知中心
|
||||||
|
- 設定(AI key 可先 UI only)
|
||||||
|
3. **多帳號互回(流程先做對)**
|
||||||
|
- 主帳號 **lead** 1 個:負責樓主貼(root)
|
||||||
|
- **cast** 多選:本串可出場的其他帳號
|
||||||
|
- **發言池 = lead ∪ cast**:每一則「回覆步驟」都可指定池內**任一帳號**,**包含 lead**
|
||||||
|
(例:lead 發主貼 → cast 帳回 → **lead 再回那則** → 另一 cast 再接)
|
||||||
|
- 單線接龍步驟可編輯、可預覽「誰說什麼」
|
||||||
|
- Mock 發布過程:逐步狀態動畫/列表即可
|
||||||
|
4. **雙模式資料層**
|
||||||
|
`dataSource: mock | api`;B 預設 mock;切 api 時契約不變。
|
||||||
|
5. **RWD**:手機可走完核心流(不必完美,但不可壞掉)。
|
||||||
|
|
||||||
|
### 5.3 P0-C — 接後端(在 B 過關後)
|
||||||
|
|
||||||
|
1. Auth / me / permissions 接真。
|
||||||
|
2. Threads 帳號與 OAuth 接真。
|
||||||
|
3. Job + 站內通知接真。
|
||||||
|
4. 發文計畫 / 互回草稿 / 佇列逐步接真(可按垂直切片:先單帳發文,再互回)。
|
||||||
|
|
||||||
|
### 5.4 P1 — 業務加深
|
||||||
|
|
||||||
|
1. 海巡:品牌 → 主題 → 掃描 → 外展草稿。
|
||||||
|
2. 互回進階:AI 分角色產整串、間隔/護欄 per-account、失敗單步重試完善。
|
||||||
|
3. 提及收件匣、成效儀表。
|
||||||
|
4. Job 可靠度硬化(dedupe/heartbeat 等)。
|
||||||
|
5. Email 驗證閘道(若仍需要)。
|
||||||
|
|
||||||
|
### 5.5 P2
|
||||||
|
|
||||||
|
公開註冊、Islander agent、extension 安全同步、大型多租戶計費等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 範圍
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
- **從零重寫**(舊碼在 `old/` 封存,主線新樹)。
|
||||||
|
- 地基:Auth、Permission、Threads 基礎串接、Job、站內 Notification。
|
||||||
|
- 全新前端體驗 + mock 業務流(含多帳互回)。
|
||||||
|
- 之後同一套 UI 接後端。
|
||||||
|
- 產品文件:`docs/product/haixun-console/` 為決策真相。
|
||||||
|
|
||||||
|
### 多帳號互回 — 行為邊界
|
||||||
|
|
||||||
|
| 項目 | P0 要 | P0 不要 |
|
||||||
|
|------|-------|---------|
|
||||||
|
| lead | 必選 1 個;**root 固定 lead**;**也可在 reply 再出場** | 主帳只能發主貼、不能回後續 |
|
||||||
|
| cast | 多選其他帳;與 lead 組成**發言池** | 跨會員借號 |
|
||||||
|
| reply 發言者 | **lead 或任一 cast**(主帳可回別人的回覆) | 僅 cast 能回 |
|
||||||
|
| 串結構 | 單線接龍:每步接上一步 | 樹狀多分支(以後再說) |
|
||||||
|
|
||||||
|
### Out of scope(本重構週期明確不做)
|
||||||
|
|
||||||
|
- 在 `old/` 或舊 UI 上繼續疊功能當最終產品。
|
||||||
|
- 把 `old/` 整包「搬回」當新主線而不重寫。
|
||||||
|
- 復原舊 copy_mission 五段 worker 管線。
|
||||||
|
- 自動 refill 庫存 bot、公開自助註冊、MFA、Zitadel。
|
||||||
|
- 重型 UI 框架(MUI/Ant/Chakra)。
|
||||||
|
- 任天堂 / Nook 元素、emoji 當主圖示。
|
||||||
|
- 把海巡與互回做成同一套語意。
|
||||||
|
- 未綁定帳號、跨會員借號。
|
||||||
|
- B 階段沒過就大寫海巡/矩陣後端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 使用者故事
|
||||||
|
|
||||||
|
### 地基
|
||||||
|
|
||||||
|
| ID | 作為… | 我想要… | 以便… | 驗收 |
|
||||||
|
|----|--------|---------|--------|------|
|
||||||
|
| US-A1 | 經營者 | 登入並維持會話 | 進工作台 | 登入進主殼;logout 後不可進 |
|
||||||
|
| US-A2 | 經營者 | 依權限看到該看的選單 | 不誤操作 | 無 permission 時入口隱藏或 403 |
|
||||||
|
| US-A3 | 經營者 | 綁定並看到 Threads 帳號狀態 | 後續發文有對象 | 列表可見;錯誤可讀 |
|
||||||
|
| US-A4 | 經營者 | 看背景任務狀態與完成通知 | 知道跑完沒 | job 列表;完成/失敗有通知可點 |
|
||||||
|
|
||||||
|
### 前端 Mock / 文案流
|
||||||
|
|
||||||
|
| ID | 作為… | 我想要… | 以便… | 驗收 |
|
||||||
|
|----|--------|---------|--------|------|
|
||||||
|
| US-B1 | 經營者 | 在新 UI 上走完「今日 → 開一個發文計畫」 | 覺得流程順 | mock 下無卡死、步驟清楚 |
|
||||||
|
| US-B2 | 經營者 | 指定主帳 + 選其他帳互回,且 lead 也可再回後續留言 | 多號串場、主帳能接話 | 每句顯示帳號;reply 步驟可選 lead 或 cast;預覽順序正確 |
|
||||||
|
| US-B3 | 經營者 | 在 Outbox 看到這串的排程狀態 | 掌握發送 | mock 狀態流轉合理 |
|
||||||
|
| US-B4 | 經營者 | 從通知跳到對應任務或計畫 | 減少找頁 | 一鍵到達 |
|
||||||
|
|
||||||
|
### 接真後(C)
|
||||||
|
|
||||||
|
| ID | 作為… | 我想要… | 以便… | 驗收 |
|
||||||
|
|----|--------|---------|--------|------|
|
||||||
|
| US-C1 | 經營者 | 同一套畫面改打真 API 仍可用 | 上線 | mock/api 契約一致;關鍵流 live 通過 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 新前端:流程與風格方向(提案,待你選/改)
|
||||||
|
|
||||||
|
> 現況問題(簡述):導覽像「很多 App 格」但主線不清;單頁過重;視覺偏玩具掌機,專業經營感不足;海巡/文案/發布心智模型混。
|
||||||
|
|
||||||
|
### 8.1 產品隱喻(建議)
|
||||||
|
|
||||||
|
**「泊岸工作台 Harbor Desk」** — 不是掌上遊戲機,而是**靠港調度台**:
|
||||||
|
|
||||||
|
- 今天要出港的內容(發文/互回)
|
||||||
|
- 船隊帳號(多 Threads)
|
||||||
|
- 遠航探查(海巡,P1)
|
||||||
|
- 船期與回報(Outbox + Jobs + 通知)
|
||||||
|
|
||||||
|
語氣:年輕、清楚、短句;可留「島」「巡」詞,但 UI **不要**雲朵滿版搶戲。
|
||||||
|
|
||||||
|
### 8.2 資訊架構(建議導覽)
|
||||||
|
|
||||||
|
最多 **5 個主入口**(桌面側欄 + 手機底欄):
|
||||||
|
|
||||||
|
| 入口 | 做什麼 |
|
||||||
|
|------|--------|
|
||||||
|
| **今日 Today** | 待發、失敗需處理、進行中 job、快捷「開新串」 |
|
||||||
|
| **帳號 Crew** | 綁定/健康/選預設主帳 |
|
||||||
|
| **創作 Studio** | 發文計畫 wizard + 草稿庫(多帳互回在這裡) |
|
||||||
|
| **探查 Scout** | 海巡(P1 再滿血;B 可放空狀態說明) |
|
||||||
|
| **發送 Outbox** | 佇列、歷史、護欄狀態 |
|
||||||
|
|
||||||
|
**全域:** 頂欄右側 = 通知鈴 + 主題 + 使用者選單(設定/登出)。
|
||||||
|
**不做:** 十幾個 App tile、Jobs/Gaps 當一級又難找又佔位(Jobs 併入通知與 Today)。
|
||||||
|
|
||||||
|
### 8.3 創作主流程(Studio wizard,B 階段就要順)
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 主題 一句話 + 可選「幫我想」
|
||||||
|
2. 船隊 選 lead(主帳)+ cast(其他可出場帳,可多選)
|
||||||
|
3. 劇本 單線步驟:第 0 步 root=lead;之後每步 = **發言池內帳號(含 lead)** + 文案
|
||||||
|
4. 預覽 像 Threads 對話泡泡;可見 lead 穿插回覆
|
||||||
|
5. 船期 主貼時間 + 回覆間隔
|
||||||
|
6. 提交 進 Outbox(mock 狀態:排程中 → 發送中 → 完成/失敗)
|
||||||
|
```
|
||||||
|
|
||||||
|
任一兩可回上一步;每步自動存 draft(mock localStorage 即可)。
|
||||||
|
|
||||||
|
### 8.4 視覺方向(建議,可整包替換舊 hx-scene)
|
||||||
|
|
||||||
|
| 面向 | 建議 |
|
||||||
|
|------|------|
|
||||||
|
| 調性 | **安靜專業 + 一點溫度**:偏「設計工具 / 編輯台」,不是遊戲 HUD |
|
||||||
|
| 色彩 | 深 slate 字 + 暖白紙面 + **單一 brand**(建議霧青/海色 `#2A9D8F` 系);深淺主題都走 token |
|
||||||
|
| 字體 | 維持 **Inter + Taipei Sans TC**(閱讀與中文標題夠用) |
|
||||||
|
| 版面 | 左窄導覽(icon+字)+ 右內容;**不要**固定超寬掌機外框 |
|
||||||
|
| 卡片 | 1px 細邊、中等圓角(12–16px)、輕陰影;少裝飾 blob |
|
||||||
|
| 動效 | 僅頁面切換與列表 150–200ms;不搶操作 |
|
||||||
|
| 圖示 | 原創線條 SVG 一套即可;禁止 emoji 當主 icon |
|
||||||
|
| 密度 | 表單與表格可讀優先;中文 line-height ≥ 1.6 |
|
||||||
|
|
||||||
|
**明確告別:** 舊「奶油票券 + 滿天雲 + PATROL PAD 掌機」作為預設殼(若你仍想保留可作「懷舊主題」P2,不當預設)。
|
||||||
|
|
||||||
|
### 8.5 Mock 資料原則
|
||||||
|
|
||||||
|
- 預置 2–3 個假 Threads 帳號(不同顯示名/頭像色)。
|
||||||
|
- 預置 1 條進行中 job、1 條失敗通知。
|
||||||
|
- 預置 1 份範例互回劇本:root(lead) → reply(cast) → reply(lead)(示範主帳接話)。
|
||||||
|
- 所有列表可空狀態插畫/短文案,不空白死頁。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 成功標準
|
||||||
|
|
||||||
|
### Phase A
|
||||||
|
|
||||||
|
- [ ] US-A1~A4 在「真後端或契約完整 mock server」下可演示。
|
||||||
|
- [ ] 無對外 stub 成功 API。
|
||||||
|
|
||||||
|
### Phase B(**你主觀過關為準**)
|
||||||
|
|
||||||
|
- [ ] 你親自走完 Studio 互回 wizard + Outbox + 通知,認為**流程順、風格可接受**。
|
||||||
|
- [ ] 能做出 root(lead) → reply(cast) → reply(lead) 並預覽/進 Outbox。
|
||||||
|
- [ ] 主 IA ≤ 5 個一級入口;無巨型單頁不可維護感(單檔建議有分頁/步驟元件)。
|
||||||
|
- [ ] `mock | api` 可切換架構已就位(api 可暫時 501,但不能亂成功)。
|
||||||
|
|
||||||
|
### Phase C
|
||||||
|
|
||||||
|
- [ ] 登入、帳號、job/通知、至少「單串發文或 mock 同等」一條 live 路徑。
|
||||||
|
- [ ] 同一 UI 不需重畫。
|
||||||
|
|
||||||
|
### 全域
|
||||||
|
|
||||||
|
- [ ] 產品決策以本文 + 後續 spec/plan/tasks 為準。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 約束與假設
|
||||||
|
|
||||||
|
- **重構工具:** 以 Grok 4.5 / 本 repo agent 為主;`/spec-driven` 管文件閘門。
|
||||||
|
- **可重寫 monorepo**;舊碼是零件庫與行為參考。
|
||||||
|
- **後端契約精神可保留**(envelope、JWT 分離、job 狀態機),實作可新建。
|
||||||
|
- **前端:** 輕量 React + Vite(或同等);不引入重型 UI kit;**新 design token 允許全新一輪命名**。
|
||||||
|
- **假設:** B 階段你會參與 1~2 輪流程/視覺拍板,否則 mock 白做。
|
||||||
|
- **假設:** Threads App 憑證在接 C 時可取得。
|
||||||
|
- **互回:** 僅自有已綁帳號;P0-B 單線接龍;**reply 發言池含 lead**(主帳可回別人的回覆)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 風險
|
||||||
|
|
||||||
|
| 風險 | 緩解 |
|
||||||
|
|------|------|
|
||||||
|
| 重寫範圍爆炸 | 死守 A→B→C→D;B 過關才加深後端 |
|
||||||
|
| Mock 與真 API 契約漂移 | 先定 thin types / MSW 或 mock 模組與未來 API 同 shape |
|
||||||
|
| 視覺改了又改 | B 只定 **一套** 預設主題;大改須改 requirements/spec |
|
||||||
|
| 舊資料/舊 job 遷移 | C 再定;B 不承諾遷移 |
|
||||||
|
| 多帳互回後端複雜 | B 只鎖 UX;C 垂直切片接真 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 開放問題(請你拍板)
|
||||||
|
|
||||||
|
1. **重寫落點:** ~~二選一~~ → **已決策:舊碼進 `old/`;新前端 `apps/web`;新後端另建(如 `services/api`),不從舊樹原地改。**
|
||||||
|
2. **登入是否要 email 驗證才能進?** **預設:A 可先關,P1 再開。**
|
||||||
|
3. **視覺提案「Harbor Desk」是否當預設?** 若否,請給 3 個形容詞(例如:極簡黑白 / 編輯器感 / 更活潑)。
|
||||||
|
4. **Scout(海巡)在 B 階段:** 完全隱藏,還是放「即將推出」空頁?**預設:空頁占位,免導覽以後大改。**
|
||||||
|
5. **Mock 持久化:** 僅 memory,還是 localStorage 跨重新整理?**預設:localStorage。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 附錄:與舊需求的差異(摘要)
|
||||||
|
|
||||||
|
| 舊 | 新 |
|
||||||
|
|----|-----|
|
||||||
|
| 不在舊樹上重寫 monorepo | **允許整包重構/重寫** |
|
||||||
|
| 海巡/文案/發布 P0 一次閉環 | **先 A 地基 + B 前端 mock,再 C 接真** |
|
||||||
|
| 沿用田園掌機 UI | **新 IA + Harbor Desk 風格提案** |
|
||||||
|
| 多帳互回(已納入) | **保留,且 B 階段就要流程順** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 批准
|
||||||
|
|
||||||
|
- [x] 需求已批准:2026-07-09(使用者 OK;採預設:`apps/web`、Harbor Desk、海巡空頁、email 驗證 A 先關、mock localStorage)
|
||||||
|
- 下一步:審核 [spec.md](./spec.md) → 回 `spec OK` 後寫 plan
|
||||||
|
|
@ -0,0 +1,463 @@
|
||||||
|
# Spec: 巡樓 Console 重構(Harbor Desk)
|
||||||
|
|
||||||
|
> Status: **`approved`**
|
||||||
|
> Source: `docs/product/haixun-console/requirements.md`(approved 2026-07-09)
|
||||||
|
> Last updated: `2026-07-09`
|
||||||
|
> 批准:使用者要求開始拆 plan/tasks(2026-07-09)
|
||||||
|
> 變更:reply 步驟發言池含 lead(主帳可回別人的回覆)
|
||||||
|
> 範圍:Phase A 地基契約 + Phase B 前端/Mock 行為;Phase C 接真映射;Phase D 僅邊界預留
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 摘要
|
||||||
|
|
||||||
|
重構後的巡樓 Console 分四階段交付。本規格鎖定:
|
||||||
|
|
||||||
|
1. **平台地基**:登入會話、角色權限、Threads 帳號基礎、Job、站內通知。
|
||||||
|
2. **新前端 Harbor Desk**:5 入口 IA、Studio 多帳互回 wizard、Outbox、通知;**預設 mock 資料**。
|
||||||
|
3. **同一契約**在 `mock | live` 切換;B 未過關不開大型業務後端。
|
||||||
|
|
||||||
|
落點(2026-07-09 已封存舊碼):
|
||||||
|
|
||||||
|
| 項目 | 決策 |
|
||||||
|
|------|------|
|
||||||
|
| 舊 monorepo | **`old/` 整包封存,只讀** |
|
||||||
|
| 新前端 | repo 根 `apps/web`(從零) |
|
||||||
|
| 新後端 | `services/api`(從零;A 可先 mock-only 前端) |
|
||||||
|
| 視覺 | Harbor Desk 為唯一預設主題 |
|
||||||
|
| 海巡 Scout | B 階段占位空頁 |
|
||||||
|
| Email 驗證閘 | A/B 關閉 |
|
||||||
|
| Mock 持久化 | `localStorage` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 名詞與實體
|
||||||
|
|
||||||
|
| 名詞 | 定義 |
|
||||||
|
|------|------|
|
||||||
|
| Member | 登入會員(email/password + JWT) |
|
||||||
|
| Role | 簡化:`member` \| `admin`(可多值) |
|
||||||
|
| ThreadsAccount | 已綁定的 Threads 經營帳號(OAuth token 由後端保管) |
|
||||||
|
| Lead | 本串**主帳號**:固定發 `root` 樓主貼;並屬於發言池,可在後續 `reply` 再出場 |
|
||||||
|
| Cast | 本串**額外可出場帳號**(通常不含 lead);與 lead 組成發言池 |
|
||||||
|
| Speakers(發言池) | `{ lead_account_id } ∪ cast_account_ids`;**每一則 reply 步驟的 `account_id` 必須 ∈ Speakers**,**允許 = lead** |
|
||||||
|
| ThreadPlay | **發文計畫 / 串場劇本**(一則主貼 + 有序回覆步驟) |
|
||||||
|
| PlayStep | 劇本一步:`kind=root\|reply`、綁定 `account_id`、文案、可選排程偏移;`reply` 語意為接上一步(單線) |
|
||||||
|
| OutboxItem | 出站佇列項目:對應某 PlayStep 的一次發送意圖與狀態 |
|
||||||
|
| Job | 背景任務 run(進度、狀態、結果摘要) |
|
||||||
|
| Notification | 站內通知(多由 Job 終態產生) |
|
||||||
|
| DataSource | `mock` \| `live`:前端資料來源模式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 狀態機
|
||||||
|
|
||||||
|
### 3.1 Job
|
||||||
|
|
||||||
|
```text
|
||||||
|
pending --> queued --> running --> succeeded
|
||||||
|
running --> failed
|
||||||
|
running --> cancel_requested --> cancelled
|
||||||
|
queued|pending --> cancelled (若支援提前取消)
|
||||||
|
* --> expired (lock 逾時,後端語意;mock 可簡化省略)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 ThreadPlay
|
||||||
|
|
||||||
|
```text
|
||||||
|
draft --> ready (劇本與帳號齊全、通過預覽)
|
||||||
|
ready --> scheduling (寫入 Outbox)
|
||||||
|
scheduling --> active (至少一步 in flight 或 scheduled)
|
||||||
|
active --> completed (全部步驟 succeeded 或 skipped)
|
||||||
|
active --> partial_failed (有 failed,可重試)
|
||||||
|
draft|ready --> archived
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 PlayStep / OutboxItem
|
||||||
|
|
||||||
|
```text
|
||||||
|
draft --> scheduled --> publishing --> published
|
||||||
|
publishing --> failed
|
||||||
|
scheduled --> cancelled
|
||||||
|
failed --> scheduled (retry)
|
||||||
|
```
|
||||||
|
|
||||||
|
**規則:**
|
||||||
|
|
||||||
|
- `root` 步驟未 `published` 前,後續 `reply` 不得進入 `publishing`。
|
||||||
|
- `root` 若 `failed`,後續步驟保持 `scheduled` 或改 `blocked`(顯示「等待主貼」)。
|
||||||
|
- `root.account_id` **必須** = `lead_account_id`。
|
||||||
|
- 任一 `reply.account_id` **必須** ∈ Speakers(**含 lead**);校驗失敗不可 submit。
|
||||||
|
- P0 單線:第 n 步 reply 視為回覆第 n−1 步已發出的那則(不另選 parent;D 再談樹狀)。
|
||||||
|
|
||||||
|
### 3.4 ThreadsAccount.connection
|
||||||
|
|
||||||
|
```text
|
||||||
|
unknown --> connected
|
||||||
|
unknown --> error
|
||||||
|
connected --> error
|
||||||
|
error --> connected (重新授權/診斷成功)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 使用者流程
|
||||||
|
|
||||||
|
### 4.1 登入(A/B)
|
||||||
|
|
||||||
|
1. 使用者開 `/login`,輸入 email、password(可選 tenant 若多租戶 UI 需要;mock 固定 `default`)。
|
||||||
|
2. 成功 → 存 access/refresh(live)或 session flag(mock)→ 進 `/app/today`。
|
||||||
|
3. 失敗 → 表單錯誤,不進主殼。
|
||||||
|
4. 重新整理:有有效會話則進主殼,否則回登入。
|
||||||
|
5. 登出 → 清會話 → 登入頁。
|
||||||
|
|
||||||
|
### 4.2 帳號 Crew(B mock / C live)
|
||||||
|
|
||||||
|
1. 列表顯示已綁帳號:顯示名、頭像色、connection、是否預設 lead 候選。
|
||||||
|
2. 「連接 Threads」:live 走 OAuth;mock 新增一個假帳號。
|
||||||
|
3. 標記不健康帳號不可被選為 Lead/Cast(disabled + 原因)。
|
||||||
|
|
||||||
|
### 4.3 創作 Studio — 互回 wizard(B 核心)
|
||||||
|
|
||||||
|
| 步驟 | 畫面行為 |
|
||||||
|
|------|----------|
|
||||||
|
| 1 主題 | 標題/一句主題;可「示範填入」 |
|
||||||
|
| 2 船隊 | 必選 1 Lead;多選 Cast(≥0,UI 列表可預設排除 lead,避免重複勾);不可選 error 帳號 |
|
||||||
|
| 3 劇本 | 第 0 步固定 `root` + `account_id=lead`(不可改帳);其後每步 `reply`:**下拉發言池 = lead + 全部 cast**(**含 lead**,以便主帳回別人的回覆)+ textarea;可增刪排序(root 固定頂) |
|
||||||
|
| 4 預覽 | 對話泡泡:帳號名 + 文案;root 標記;可見 lead 出現在非第 0 步 |
|
||||||
|
| 5 船期 | 主貼時間(datetime);回覆間隔(分鐘,預設 5);可預覽各步預計時間 |
|
||||||
|
| 6 提交 | 建/更新 ThreadPlay=`scheduling`,生成 OutboxItem;導向 Outbox 或 Today |
|
||||||
|
|
||||||
|
任一步「上一步」保留表單狀態。草稿自動存(mock:`localStorage` key 見 §8)。
|
||||||
|
|
||||||
|
### 4.4 Outbox
|
||||||
|
|
||||||
|
1. 列表:Play 標題、狀態、步驟進度(2/5)。
|
||||||
|
2. 點進詳情:逐步狀態時間線。
|
||||||
|
3. Mock「模擬發送」:依序 root→replies 推進狀態(可加速)。
|
||||||
|
4. 失敗步驟顯示錯誤文案 +「重試」(mock 直接 succeeded)。
|
||||||
|
|
||||||
|
### 4.5 Today
|
||||||
|
|
||||||
|
1. 卡片:待處理失敗、今日排程、進行中 Job、快捷「開新串場」。
|
||||||
|
2. 點通知/失敗 → 對應 Outbox 或 Job 詳情。
|
||||||
|
|
||||||
|
### 4.6 通知
|
||||||
|
|
||||||
|
1. 頂欄鈴鐺:未讀數。
|
||||||
|
2. 下拉最近 N 筆;點擊標記已讀並 navigate。
|
||||||
|
3. Job `succeeded|failed` 產生通知(mock 在模擬結束時寫入)。
|
||||||
|
|
||||||
|
### 4.7 Scout(B)
|
||||||
|
|
||||||
|
占位頁:標題「探查」、短說明「海巡獲客將在下一階段開放」、無假成功操作。
|
||||||
|
|
||||||
|
### 4.8 設定
|
||||||
|
|
||||||
|
- 顯示目前 `DataSource` 切換(dev 可見;prod 預設 live 且可藏)。
|
||||||
|
- AI key:B 僅 UI 存 local(不送出);C 再定義。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 介面契約
|
||||||
|
|
||||||
|
### 5.0 共用規則
|
||||||
|
|
||||||
|
| 規則 | 規格 |
|
||||||
|
|------|------|
|
||||||
|
| Envelope | `{ code, message, data?, error? }`;成功 `code=102000` |
|
||||||
|
| 列表 | query `page`/`pageSize`;`data: { pagination, list }` |
|
||||||
|
| 時間 | API/DB:**unix nanoseconds int64 UTC**;UI 顯示本地時區 |
|
||||||
|
| Auth | `Authorization: Bearer <access>`;401 → refresh 一次 → 仍失敗登出 |
|
||||||
|
| 禁止 | 未實作端點回 200+空成功;應 501 或明確業務錯誤 |
|
||||||
|
|
||||||
|
前端 repository 介面(概念):
|
||||||
|
|
||||||
|
```text
|
||||||
|
AuthRepo, MemberRepo, PermissionRepo,
|
||||||
|
ThreadsAccountRepo, ThreadPlayRepo, OutboxRepo,
|
||||||
|
JobRepo, NotificationRepo
|
||||||
|
```
|
||||||
|
|
||||||
|
`createRepos(dataSource)` → mock 或 live 實作同一 TypeScript interface。
|
||||||
|
|
||||||
|
### 5.1 HTTP / API(Phase A 最小 + C 對齊)
|
||||||
|
|
||||||
|
> 路徑為**目標契約**;可與舊 `/api/v1` 對齊以利搬碼。Mock 不經 HTTP 亦可,但 shape 相同。
|
||||||
|
|
||||||
|
#### Auth / Member / Permission
|
||||||
|
|
||||||
|
| Method | Path | 用途 | 要點 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| POST | `/api/v1/auth/login` | 登入 | body: email, password, tenant_id? → tokens |
|
||||||
|
| POST | `/api/v1/auth/refresh` | 換票 | refresh_token → tokens |
|
||||||
|
| POST | `/api/v1/auth/logout` | 登出 | auth |
|
||||||
|
| GET | `/api/v1/members/me` | 我 | tenant_id, uid, email, roles[] |
|
||||||
|
| GET | `/api/v1/permissions/me` | 我的權限 | keys[] 或 * |
|
||||||
|
|
||||||
|
Admin 建號可延後;mock 內建一組使用者即可。
|
||||||
|
|
||||||
|
#### Threads accounts
|
||||||
|
|
||||||
|
| Method | Path | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/v1/threads-accounts` | 列表(pagination) |
|
||||||
|
| POST | `/api/v1/threads-accounts/oauth/start` | 取得授權 URL(live) |
|
||||||
|
| GET | `/api/v1/threads-accounts/:id` | 詳情 / 健康 |
|
||||||
|
| POST | `/api/v1/threads-accounts/:id/diagnostics` | 連線診斷(可選 A) |
|
||||||
|
|
||||||
|
#### Jobs
|
||||||
|
|
||||||
|
| Method | Path | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/v1/jobs` | 列表 |
|
||||||
|
| GET | `/api/v1/jobs/:id` | 詳情 |
|
||||||
|
| POST | `/api/v1/jobs/:id/cancel` | 取消 |
|
||||||
|
| POST | `/api/v1/jobs` | 建立(A 可僅 demo 或內部) |
|
||||||
|
|
||||||
|
Job 資源欄位(最低):`id, template_type, status, progress_summary, progress_percent, error?, created_at, updated_at, completed_at?`
|
||||||
|
|
||||||
|
#### Notifications
|
||||||
|
|
||||||
|
| Method | Path | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/api/v1/notifications` | 列表 |
|
||||||
|
| POST | `/api/v1/notifications/:id/read` | 已讀 |
|
||||||
|
| POST | `/api/v1/notifications/read-all` | 全已讀 |
|
||||||
|
|
||||||
|
欄位:`id, title, body, kind, ref_type, ref_id, read_at?, created_at`
|
||||||
|
|
||||||
|
#### ThreadPlay / Outbox(B mock 完整;C 再 live)
|
||||||
|
|
||||||
|
| Method | Path | 用途 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET/POST | `/api/v1/thread-plays` | 列表 / 建立 |
|
||||||
|
| GET/PATCH | `/api/v1/thread-plays/:id` | 讀 / 更新劇本 |
|
||||||
|
| POST | `/api/v1/thread-plays/:id/submit` | 提交進 Outbox |
|
||||||
|
| GET | `/api/v1/outbox` | 佇列列表 |
|
||||||
|
| GET | `/api/v1/outbox/:id` | 詳情(含 steps) |
|
||||||
|
| POST | `/api/v1/outbox/:id/steps/:stepId/retry` | 重試一步 |
|
||||||
|
|
||||||
|
**ThreadPlay 主體(邏輯 JSON):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "…",
|
||||||
|
"title": "週末咖啡話題",
|
||||||
|
"topic": "…",
|
||||||
|
"status": "draft",
|
||||||
|
"lead_account_id": "…",
|
||||||
|
"cast_account_ids": ["…"],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"id": "s0",
|
||||||
|
"sort_order": 0,
|
||||||
|
"kind": "root",
|
||||||
|
"account_id": "…",
|
||||||
|
"text": "主貼文案",
|
||||||
|
"delay_from_previous_sec": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s1",
|
||||||
|
"sort_order": 1,
|
||||||
|
"kind": "reply",
|
||||||
|
"account_id": "…",
|
||||||
|
"text": "回覆文案",
|
||||||
|
"delay_from_previous_sec": 300
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schedule_start_at": 0,
|
||||||
|
"created_at": 0,
|
||||||
|
"updated_at": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 前端路由(`apps/web`)
|
||||||
|
|
||||||
|
使用 **path router**(非 hash),basename 可 `/`。
|
||||||
|
|
||||||
|
| Path | 名稱 | 需登入 | Phase |
|
||||||
|
|------|------|--------|-------|
|
||||||
|
| `/login` | 登入 | 否 | B |
|
||||||
|
| `/app` | 佈局殼 | 是 | B |
|
||||||
|
| `/app/today` | 今日 | 是 | B |
|
||||||
|
| `/app/crew` | 帳號 | 是 | B |
|
||||||
|
| `/app/studio` | 創作列表 | 是 | B |
|
||||||
|
| `/app/studio/new` | 新 wizard | 是 | B |
|
||||||
|
| `/app/studio/:playId` | 編輯 wizard | 是 | B |
|
||||||
|
| `/app/outbox` | 發送佇列 | 是 | B |
|
||||||
|
| `/app/outbox/:id` | 佇列詳情 | 是 | B |
|
||||||
|
| `/app/jobs` | 任務(可二級) | 是 | B |
|
||||||
|
| `/app/jobs/:id` | 任務詳情 | 是 | B |
|
||||||
|
| `/app/scout` | 探查占位 | 是 | B |
|
||||||
|
| `/app/settings` | 設定 | 是 | B |
|
||||||
|
|
||||||
|
**導覽(一級,≤5):** Today · Crew · Studio · Scout · Outbox
|
||||||
|
Jobs 從 Today/通知進入;Settings 在使用者選單。
|
||||||
|
|
||||||
|
### 5.3 Job types(A 最小)
|
||||||
|
|
||||||
|
| template_type | 用途 | Phase |
|
||||||
|
|---------------|------|-------|
|
||||||
|
| `demo_long_task` | 驗證 job+通知 | A/B mock 可模擬 |
|
||||||
|
| (業務 job) | 發佈串場、8D… | C/D |
|
||||||
|
|
||||||
|
B mock 可不跑真 worker:Timer 推進 job 狀態並寫 notification。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 與現況對照
|
||||||
|
|
||||||
|
| 能力 | 決策 | 備註 |
|
||||||
|
|------|------|------|
|
||||||
|
| 整個舊 monorepo | **Archive** | 實體路徑 `old/`;主線不引用為 package |
|
||||||
|
| 舊 Auth/JWT/envelope | **參考後重寫** | 契約精神可相容 |
|
||||||
|
| 舊 Job 核心 | **參考後重寫** | 可抄模式,不搬半成品 |
|
||||||
|
| copy_mission 管線 | **Discard** | 不復活 |
|
||||||
|
| 舊 web Console | **Archive** | 新 `apps/web` |
|
||||||
|
| 海巡 Patrol 全套 | **Defer D** | Scout 占位 |
|
||||||
|
| 田園掌機 UI | **Discard 預設** | Harbor Desk |
|
||||||
|
| 時間/envelope/job 規則 | **Keep as policy** | 見根目錄 `AGENTS.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 非功能
|
||||||
|
|
||||||
|
### 安全 / 權限
|
||||||
|
|
||||||
|
- 未登入不可進 `/app/**`。
|
||||||
|
- `admin` 專屬操作(若有)前後端雙檢。
|
||||||
|
- Live:token 不進 URL;refresh 單飛。
|
||||||
|
- Mock:不模擬權限繞過成功寫入 live。
|
||||||
|
|
||||||
|
### 錯誤
|
||||||
|
|
||||||
|
- UI 可讀中文 message。
|
||||||
|
- 區分 401(登出)vs 5xx(保留會話,顯示重試)。
|
||||||
|
|
||||||
|
### 可觀測
|
||||||
|
|
||||||
|
- Job 進度摘要必顯示。
|
||||||
|
- Outbox 步驟級狀態必顯示。
|
||||||
|
|
||||||
|
### 效能(B)
|
||||||
|
|
||||||
|
- 首屏可互動 < 3s(本機 dev 網路)。
|
||||||
|
- 列表 mock ≤ 50 筆即可。
|
||||||
|
|
||||||
|
### 無障礙(基線)
|
||||||
|
|
||||||
|
- 表單 label 關聯;主按鈕對比足夠;焦點可見。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 資料保存(邏輯)
|
||||||
|
|
||||||
|
### 8.1 Mock localStorage keys
|
||||||
|
|
||||||
|
| Key | 內容 |
|
||||||
|
|-----|------|
|
||||||
|
| `harbor.datasource` | `mock` \| `live` |
|
||||||
|
| `harbor.session` | mock 登入態 |
|
||||||
|
| `harbor.accounts` | ThreadsAccount[] |
|
||||||
|
| `harbor.plays` | ThreadPlay[] |
|
||||||
|
| `harbor.outbox` | Outbox 聚合 |
|
||||||
|
| `harbor.jobs` | Job[] |
|
||||||
|
| `harbor.notifications` | Notification[] |
|
||||||
|
| `harbor.play_draft` | wizard 未提交草稿 |
|
||||||
|
|
||||||
|
### 8.2 實體欄位(最低)
|
||||||
|
|
||||||
|
| 實體 | 關鍵欄位 |
|
||||||
|
|------|----------|
|
||||||
|
| Member | uid, tenant_id, email, roles |
|
||||||
|
| ThreadsAccount | id, username, display_name, connection, is_usable |
|
||||||
|
| ThreadPlay | 見 §5.1 JSON |
|
||||||
|
| OutboxBundle | id, play_id, status, steps[] |
|
||||||
|
| OutboxStep | step_id, account_id, status, error?, published_at?, scheduled_at? |
|
||||||
|
| Job | 見 §5.1 |
|
||||||
|
| Notification | 見 §5.1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. UI 規格(Harbor Desk)
|
||||||
|
|
||||||
|
### 9.1 Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Logo [今日]… 🔔 主題 使用者 ▾ │ topbar
|
||||||
|
├──────────┬──────────────────────────────────┤
|
||||||
|
│ Today │ Page title │
|
||||||
|
│ Crew │ Content │
|
||||||
|
│ Studio │ │
|
||||||
|
│ Scout │ │
|
||||||
|
│ Outbox │ │
|
||||||
|
└──────────┴──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- 桌面:左導覽 ~220px。
|
||||||
|
- 手機:底欄 5 格(Today/Crew/Studio/Scout/Outbox);設定進使用者選單。
|
||||||
|
- 內容最大寬 ~1080px,表單閱讀寬 ~640–720px(wizard)。
|
||||||
|
|
||||||
|
### 9.2 視覺 token(建議名,實作可微調色值)
|
||||||
|
|
||||||
|
| Token | 用途 | 建議 |
|
||||||
|
|-------|------|------|
|
||||||
|
| `--hb-bg` | 頁背景 | 冷灰白 |
|
||||||
|
| `--hb-surface` | 卡片 | 白 |
|
||||||
|
| `--hb-ink` | 主文 | 近黑 slate |
|
||||||
|
| `--hb-muted` | 次文 | 可讀灰(勿過淡) |
|
||||||
|
| `--hb-line` | 邊框 | 淺灰 |
|
||||||
|
| `--hb-brand` | 主色/CTA | 霧海青 |
|
||||||
|
| `--hb-danger` | 錯誤 | 紅 |
|
||||||
|
| `--hb-success` | 成功 | 綠 |
|
||||||
|
| `--radius` | 卡片 | 12–16px |
|
||||||
|
| `--font-sans` | 混排 | Inter, Taipei Sans TC, system |
|
||||||
|
|
||||||
|
禁止:滿版動態雲、木質頂欄、emoji 主 icon、彩虹漸層按鈕。
|
||||||
|
|
||||||
|
### 9.3 元件基線
|
||||||
|
|
||||||
|
Button(primary/ghost/danger)、Input、Textarea、Select、Card、Badge(狀態)、EmptyState、Stepper(wizard)、Toast、BellMenu。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 驗收場景(Given / When / Then)
|
||||||
|
|
||||||
|
1. **Given** 未登入 **When** 開 `/app/today` **Then** 導向 `/login`。
|
||||||
|
2. **Given** mock 帳密正確 **When** 登入 **Then** 進 Today 且看到至少一張待辦/空狀態。
|
||||||
|
3. **Given** 兩個 usable 帳號 A、B **When** Studio:lead=A cast=B,劇本為 root(A) → reply(B) → reply(A) **Then** 可預覽三句;submit 後 Outbox 三步順序正確且第二則回覆發言者為 lead。
|
||||||
|
3b. **Given** 劇本某 reply 選了不在 Speakers 的帳號 **When** 提交 **Then** 阻擋並提示。
|
||||||
|
4. **Given** 已提交串 **When** 模擬發送 **Then** root→reply 依序 published;通知出現成功。
|
||||||
|
5. **Given** root 模擬失敗 **When** 查看 **Then** reply 不標 published;可重試 root。
|
||||||
|
6. **Given** Job 模擬完成 **When** 開鈴鐺 **Then** 未讀 +1,點進 job 詳情。
|
||||||
|
7. **Given** Scout **When** 進入 **Then** 僅占位,無「成功建立海巡」類假操作。
|
||||||
|
8. **Given** live 模式且後端 5xx **When** 拉 me **Then** 不強制清 token(與需求 US-09 對齊,C 必須)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Phase 邊界(實作不得越級)
|
||||||
|
|
||||||
|
| Phase | 做 | 不做 |
|
||||||
|
|-------|----|------|
|
||||||
|
| A | 契約+可選最小 live auth/job;或純 mock 平台殼 | 海巡、完整互回後端 |
|
||||||
|
| B | `apps/web` 全 mock 體驗 | 大寫業務 API |
|
||||||
|
| C | 逐 repo 接 live | 改 IA 大翻(除非修 bug) |
|
||||||
|
| D | 海巡等 | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 開放規格問題
|
||||||
|
|
||||||
|
1. **Tenant UI:** login 是否顯示 tenant 欄?**預設:隱藏,mock/live 固定 default tenant。**
|
||||||
|
2. **Lead 能否出現在 reply?** → **已決策:必須支援**(主帳回別人的回覆)。Cast 多選 UI 仍可不重複勾 lead;發言下拉一定含 lead。
|
||||||
|
3. **A 是否必須先有可跑的 Go gateway,還是 B 可完全純前端?** **預設:B 可純前端 mock;A 的 live 可與 B 平行,但不阻塞 B 過關。**
|
||||||
|
4. **Outbox 與舊 publish_queue 是否同 collection?** **預設:C 再定映射;規格層當新模型。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 批准
|
||||||
|
|
||||||
|
- [x] Spec 已批准:2026-07-09(使用者:好請幫我開始拆)
|
||||||
|
- 下一步:依 [plan.md](./plan.md) 與 [tasks/](./tasks/) 實作
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Tasks INDEX — haixun-console
|
||||||
|
|
||||||
|
> Phase B 主線:T001–T022
|
||||||
|
> Phase C/D:T100+ / T200+ 預留(未建檔)
|
||||||
|
|
||||||
|
| ID | Title | Milestone | Status | Est. lines |
|
||||||
|
|----|-------|-----------|--------|------------|
|
||||||
|
| T001 | Scaffold apps/web (Vite React TS) | M0 | done | ~120 |
|
||||||
|
| T002 | Harbor design tokens + global CSS + fonts | M1 | todo | ~150 |
|
||||||
|
| T003 | UI primitives (Button Input Card …) | M1 | todo | ~180 |
|
||||||
|
| T004 | App shell layout + desktop nav + mobile dock | M1 | todo | ~180 |
|
||||||
|
| T005 | Router + route table + placeholder pages | M1 | todo | ~150 |
|
||||||
|
| T006 | Mock auth session + login page + guard | M2 | todo | ~160 |
|
||||||
|
| T007 | Domain types + repo interfaces + createRepos | M3 | todo | ~180 |
|
||||||
|
| T008 | Mock repos + localStorage + seed data | M3 | todo | ~200 |
|
||||||
|
| T009 | Crew page (accounts list + mock connect) | M4 | todo | ~160 |
|
||||||
|
| T010 | Studio list page (plays) | M5 | todo | ~120 |
|
||||||
|
| T011 | Studio wizard shell + stepper + draft persist | M5 | todo | ~160 |
|
||||||
|
| T012 | Wizard steps: topic + crew (lead/cast) | M5 | todo | ~160 |
|
||||||
|
| T013 | Wizard script steps (speakers include lead) | M5 | todo | ~180 |
|
||||||
|
| T014 | Wizard preview + schedule + submit to outbox | M6 | todo | ~180 |
|
||||||
|
| T015 | Outbox list + detail timeline | M7 | todo | ~160 |
|
||||||
|
| T016 | Outbox simulate publish + retry rules | M7 | todo | ~180 |
|
||||||
|
| T017 | Mock jobs + progress timer | M8 | todo | ~150 |
|
||||||
|
| T018 | Notification center + bell menu | M8 | todo | ~160 |
|
||||||
|
| T019 | Today dashboard page | M8 | todo | ~150 |
|
||||||
|
| T020 | Scout placeholder + Settings (datasource) | M9 | todo | ~120 |
|
||||||
|
| T021 | Seed example play root→cast→lead | M9 | todo | ~100 |
|
||||||
|
| T022 | RWD polish + build smoke checklist | M9 | todo | ~120 |
|
||||||
|
|
||||||
|
## How to work
|
||||||
|
|
||||||
|
1. Pick next `todo` whose Depends on are `done`.
|
||||||
|
2. Set status `doing` → implement only that task → acceptance → `done`.
|
||||||
|
3. Do not start T100+ until Phase B human OK.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue