diff --git a/AGENTS.md b/AGENTS.md index a02c3c8..1262407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,15 @@ - Go tests: `go test ./...` (some service tests exist under `backend/app/services/*_test.go`). - Frontend: build + lint are the main checks (`npm -C frontend/superadmin run build && npm -C frontend/superadmin run lint`). +## Planning Requirements + +- Before any non-trivial development work, first produce a complete plan document (tasks, sequence, dependencies, and acceptance criteria) and get confirmation to proceed. +- Plan format MUST follow spec-kit `plan-template.md` structure (Summary, Technical Context, Constitution Check, Project Structure, Plan Phases, Tasks, Dependencies, Acceptance Criteria, Risks). `docs/plan.md` MUST include a task breakdown list. +- Use the local template `docs/templates/plan-template.md` as the source of truth (no web fetch required). +- Use `docs/plan.md` as the active plan for the current phase. +- When the phase completes, move `docs/plan.md` to `docs/plans/.md` for archival. +- After archiving, clear `docs/plan.md` to await the next plan. + ## Commit & Pull Request Guidelines - Commits generally follow a simple convention like `feat: ...` / `fix: ...` / `chore: ...` (keep subject short and imperative). diff --git a/backend/app/http/super/v1/auth/routes.manual.go b/backend/app/http/super/v1/auth/routes.manual.go index 4dff8df..bac8569 100644 --- a/backend/app/http/super/v1/auth/routes.manual.go +++ b/backend/app/http/super/v1/auth/routes.manual.go @@ -5,5 +5,7 @@ func (r *Routes) Path() string { } func (r *Routes) Middlewares() []any { - return []any{} + return []any{ + r.middlewares.SuperAuth, + } } diff --git a/backend/app/http/v1/content.go b/backend/app/http/v1/content.go index 703fc2b..49735ae 100644 --- a/backend/app/http/v1/content.go +++ b/backend/app/http/v1/content.go @@ -22,7 +22,8 @@ type Content struct{} // @Produce json // @Param keyword query string false "Search keyword" // @Param genre query string false "Genre" -// @Param tenant_id query int64 false "Filter by creator" +// @Param tenant_id query int64 false "Filter by tenant" +// @Param author_id query int64 false "Filter by author" // @Param sort query string false "Sort order" Enums(latest, hot, price_asc) // @Param page query int false "Page number" // @Success 200 {object} requests.Pager{items=[]dto.ContentItem} diff --git a/backend/app/http/v1/routes.gen.go b/backend/app/http/v1/routes.gen.go index 8e253a4..649d1d7 100644 --- a/backend/app/http/v1/routes.gen.go +++ b/backend/app/http/v1/routes.gen.go @@ -496,13 +496,13 @@ func (r *Routes) Register(router fiber.Router) { router.Post("/t/:tenantCode/v1/me/favorites"[len(r.Path()):], Func2( r.user.AddFavorite, Local[*models.User]("__ctx_user"), - QueryParam[int64]("contentId"), + QueryParam[int64]("content_id"), )) r.log.Debugf("Registering route: Post /t/:tenantCode/v1/me/likes -> user.AddLike") router.Post("/t/:tenantCode/v1/me/likes"[len(r.Path()):], Func2( r.user.AddLike, Local[*models.User]("__ctx_user"), - QueryParam[int64]("contentId"), + QueryParam[int64]("content_id"), )) r.log.Debugf("Registering route: Post /t/:tenantCode/v1/me/notifications/:id/read -> user.MarkNotificationRead") router.Post("/t/:tenantCode/v1/me/notifications/:id/read"[len(r.Path()):], Func2( diff --git a/backend/app/http/v1/tenant.go b/backend/app/http/v1/tenant.go index 9974ca7..e0b58f9 100644 --- a/backend/app/http/v1/tenant.go +++ b/backend/app/http/v1/tenant.go @@ -78,7 +78,7 @@ func (t *Tenant) Get(ctx fiber.Ctx, user *models.User, id int64) (*dto.TenantPro if tenantID > 0 && id != tenantID { return nil, errorx.ErrForbidden.WithMsg("租户不匹配") } - return services.Tenant.GetPublicProfile(ctx, tenantID, uid) + return services.Tenant.GetPublicProfile(ctx, id, uid) } // Follow a tenant diff --git a/backend/app/http/v1/user.go b/backend/app/http/v1/user.go index 7ec61d1..0a80927 100644 --- a/backend/app/http/v1/user.go +++ b/backend/app/http/v1/user.go @@ -165,10 +165,10 @@ func (u *User) Favorites(ctx fiber.Ctx, user *models.User) ([]dto.ContentItem, e // @Tags UserCenter // @Accept json // @Produce json -// @Param contentId query int64 true "Content ID" +// @Param content_id query int64 true "Content ID" // @Success 200 {string} string "Added" // @Bind user local key(__ctx_user) -// @Bind contentId query +// @Bind contentId query key(content_id) func (u *User) AddFavorite(ctx fiber.Ctx, user *models.User, contentId int64) error { tenantID := getTenantID(ctx) return services.Content.AddFavorite(ctx, tenantID, user.ID, contentId) @@ -214,10 +214,10 @@ func (u *User) Likes(ctx fiber.Ctx, user *models.User) ([]dto.ContentItem, error // @Tags UserCenter // @Accept json // @Produce json -// @Param contentId query int64 true "Content ID" +// @Param content_id query int64 true "Content ID" // @Success 200 {string} string "Liked" // @Bind user local key(__ctx_user) -// @Bind contentId query +// @Bind contentId query key(content_id) func (u *User) AddLike(ctx fiber.Ctx, user *models.User, contentId int64) error { tenantID := getTenantID(ctx) return services.Content.AddLike(ctx, tenantID, user.ID, contentId) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index c911213..af5f4d3 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -3902,10 +3902,17 @@ const docTemplate = `{ { "type": "integer", "format": "int64", - "description": "Filter by creator", + "description": "Filter by tenant", "name": "tenant_id", "in": "query" }, + { + "type": "integer", + "format": "int64", + "description": "Filter by author", + "name": "author_id", + "in": "query" + }, { "enum": [ "latest", @@ -5576,7 +5583,7 @@ const docTemplate = `{ "type": "integer", "format": "int64", "description": "Content ID", - "name": "contentId", + "name": "content_id", "in": "query", "required": true } @@ -5718,7 +5725,7 @@ const docTemplate = `{ "type": "integer", "format": "int64", "description": "Content ID", - "name": "contentId", + "name": "content_id", "in": "query", "required": true } diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index a9a30cd..cadc0d4 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -3896,10 +3896,17 @@ { "type": "integer", "format": "int64", - "description": "Filter by creator", + "description": "Filter by tenant", "name": "tenant_id", "in": "query" }, + { + "type": "integer", + "format": "int64", + "description": "Filter by author", + "name": "author_id", + "in": "query" + }, { "enum": [ "latest", @@ -5570,7 +5577,7 @@ "type": "integer", "format": "int64", "description": "Content ID", - "name": "contentId", + "name": "content_id", "in": "query", "required": true } @@ -5712,7 +5719,7 @@ "type": "integer", "format": "int64", "description": "Content ID", - "name": "contentId", + "name": "content_id", "in": "query", "required": true } diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 5900cec..965a3a0 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -6171,11 +6171,16 @@ paths: in: query name: genre type: string - - description: Filter by creator + - description: Filter by tenant format: int64 in: query name: tenant_id type: integer + - description: Filter by author + format: int64 + in: query + name: author_id + type: integer - description: Sort order enum: - latest @@ -7273,7 +7278,7 @@ paths: - description: Content ID format: int64 in: query - name: contentId + name: content_id required: true type: integer produces: @@ -7367,7 +7372,7 @@ paths: - description: Content ID format: int64 in: query - name: contentId + name: content_id required: true type: integer produces: diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..c05b8aa --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,157 @@ +# Implementation Plan: 多租户隔离优先 + 契约对齐 + +**Branch**: `N/A` | **Date**: 2026-01-23 | **Spec**: `docs/review_report.md` +**Input**: 生产评估与当前系统缺口确认 + +**Note**: 本计划遵循 `docs/templates/plan-template.md`。 + +## Summary + +本阶段聚焦多租户强隔离、鉴权与权限完善、超管接口补齐,并同步前后端契约(路由前缀、参数命名、ID 类型)以形成最小可运行闭环。 + +## Technical Context + +**Language/Version**: Go 1.22, Node 20+ (Vite), Vue 3 +**Primary Dependencies**: Fiber, GORM-Gen, PrimeVue +**Storage**: PostgreSQL + Redis +**Testing**: `go test ./...`, `npm -C frontend/superadmin run build && npm -C frontend/superadmin run lint` +**Target Platform**: Linux server (Docker ready) +**Project Type**: Web application (backend + frontend) +**Performance Goals**: N/A (遵循现有服务要求) +**Constraints**: 多租户隔离、鉴权强制、生成文件不可手改 +**Scale/Scope**: 现有核心业务与超管链路可跑通 + +## Constitution Check + +- 必须遵循 `backend/llm.txt`:DTO 注释、路由规范、服务层约束、生成流程。 +- `*.gen.go`、`backend/docs/docs.go` 禁止手改,必须通过 `atomctl` 生成。 +- Controller 仅做绑定与调用,`tenantID/userID` 从 Controller 传入 Service。 + +## Project Structure + +### Documentation (this feature) + +```text +docs/ +├── plan.md +└── templates/ + └── plan-template.md +``` + +### Source Code (repository root) + +```text +backend/ +├── app/ +│ ├── http/ +│ ├── middlewares/ +│ └── services/ +├── database/ +└── docs/ + +frontend/ +├── portal/ +└── superadmin/ +``` + +**Structure Decision**: 采用现有 `backend/` + `frontend/` 双端结构。 + +## Plan Phases + +### Phase 1: 影响面确认与现有能力盘点 +- 明确 `/t/:tenantCode/v1` 作为统一路由前缀。 +- 盘点必须补齐的超管接口与核心业务链路。 + +### Phase 2: 多租户强隔离(后端) +- 新增 tenant 解析中间件,注入 `tenantID` 到上下文。 +- 所有业务路由切换为 `/t/:tenantCode/v1` 前缀。 +- Service 层所有查询显式带 `tenantID` 条件。 + +### Phase 3: 鉴权与权限(后端) +- 拆分 `AuthOptional` / `AuthRequired`。 +- 超管路由增加 `super_admin` 角色校验。 +- 完成 `Super.Login` / `CheckToken`。 + +### Phase 4: 超管接口实现(后端) +- 补齐超管统计、列表、详情接口实现与 DTO 映射。 +- 分页接口统一使用 `requests.Pager`。 + +### Phase 5: 契约对齐(前后端) +- API 前缀与路由基座统一。 +- 统一参数命名与 ID 类型。 +- 更新前端调用与路由配置。 + +### Phase 6: 生成与回归 +- `atomctl gen route` / `atomctl gen provider` / `atomctl swag init`。 +- 关键流程自测与最小回归。 + +## Tasks + +**Format**: `[ID] [P?] [Story] Description` + +### Phase 1: Foundational +- [ ] T001 [US0] 盘点现有路由与接口空实现(`backend/app/http/*`, `backend/app/services/*`) +- [ ] T002 [US0] 确认多租户前缀与 tenant 解析策略(`/t/:tenantCode/v1`) + +### Phase 2: User Story 1 - 多租户强隔离 (P1) +**Goal**: 路由前缀与服务查询强制 tenant 隔离。 + +- [ ] T010 [US1] 新增 tenant 解析中间件(`backend/app/middlewares/tenant_resolver.go`) +- [ ] T011 [US1] 调整 HTTP 模块路由前缀与注解(`backend/app/http/**/routes.manual.go` + controller 注解) +- [ ] T012 [US1] Controller 中提取 `tenantID` 并显式传入 Service(`backend/app/http/**`) +- [ ] T013 [US1] Service 查询统一加 `tenantID` 条件(`backend/app/services/*`) + +**Checkpoint**: 所有业务路由在 `/t/:tenantCode/v1` 下可访问,查询带租户隔离。 + +### Phase 3: User Story 2 - 鉴权与权限 (P1) +**Goal**: 受保护接口强制鉴权,超管角色校验。 + +- [ ] T020 [US2] 拆分并实现 `AuthOptional` / `AuthRequired`(`backend/app/middlewares/*`) +- [ ] T021 [US2] 超管路由注入角色校验中间件(`backend/app/http/super/v1/routes.manual.go`) +- [ ] T022 [US2] 完成 `Super.Login` / `CheckToken`(`backend/app/services/super.go`) + +**Checkpoint**: 受保护接口必须授权;超管接口角色有效。 + +### Phase 4: User Story 3 - 超管接口补齐 (P1) +**Goal**: 超管核心页面可用,不再空实现。 + +- [ ] T030 [US3] 统计/列表/详情接口实现与 DTO 映射(`backend/app/services/super.go`) +- [ ] T031 [US3] 统一分页返回 `requests.Pager`(`backend/app/http/super/v1/*`) + +**Checkpoint**: 超管关键接口返回有效数据。 + +### Phase 5: User Story 4 - 契约对齐 (P1) +**Goal**: 前后端路由、参数、ID 类型一致。 + +- [ ] T040 [US4] 统一后端路由前缀与 `@Router` 参数(`backend/app/http/**`) +- [ ] T041 [US4] 统一 API 参数命名(后端 `@Bind` 与前端调用匹配) +- [ ] T042 [US4] ID 类型调整与前端适配(`backend/app/http/**`, `frontend/portal/*`, `frontend/superadmin/*`) + +**Checkpoint**: 前后端关键链路可跑通。 + +### Phase 6: Generation & Regression +- [ ] T050 [US5] 生成路由与 provider(`atomctl gen route`, `atomctl gen provider`) +- [ ] T051 [US5] 生成 swagger(`atomctl swag init`) +- [ ] T052 [US5] 回归测试(`go test ./...`,必要时前端 build/lint) + +## Dependencies + +- Phase 1 → Phase 2 → Phase 3 → Phase 4 → Phase 5 → Phase 6。 +- Phase 2 完成后,Phase 3/4 可并行推进,但路由前缀需先统一。 + +## Acceptance Criteria + +- 多租户路由与解析生效,查询强制带 `tenantID`。 +- 受保护接口必须鉴权通过;超管接口具备角色校验。 +- 超管核心页面可调用对应接口获取数据。 +- 前后端接口路径与参数命名一致。 +- 生成文件已更新且未手改。 + +## Risks + +- 路由前缀变更影响全部前端调用。 +- ID 类型调整涉及 DTO/前端展示与校验,需要逐模块推进。 + +## Complexity Tracking + +无。 diff --git a/docs/templates/plan-template.md b/docs/templates/plan-template.md new file mode 100644 index 0000000..b739adb --- /dev/null +++ b/docs/templates/plan-template.md @@ -0,0 +1,124 @@ +# Implementation Plan: [FEATURE] + +**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link] +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. + +## Summary + +[Extract from feature spec: primary requirement + technical approach from research] + +## Technical Context + + + +**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION] +**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION] +**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A] +**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION] +**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION] +**Project Type**: [single/web/mobile - determines source structure] +**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION] +**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION] +**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION] + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +[Gates determined based on constitution file] + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + + +```text +# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT) +src/ +├── models/ +├── services/ +├── cli/ +└── lib/ + +tests/ +├── contract/ +├── integration/ +└── unit/ + +# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected) +backend/ +├── src/ +│ ├── models/ +│ ├── services/ +│ └── api/ +└── tests/ + +frontend/ +├── src/ +│ ├── components/ +│ ├── pages/ +│ └── services/ +└── tests/ + +# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected) +api/ +└── [same as backend above] + +ios/ or android/ +└── [platform-specific structure: feature modules, UI flows, platform tests] +``` + +**Structure Decision**: [Document the selected structure and reference the real +directories captured above] + +## Plan Phases + +[Phase breakdown with sequencing] + +## Tasks + +[Task breakdown list] + +## Dependencies + +[Phase/task dependencies and ordering] + +## Acceptance Criteria + +[Definition of done for this phase] + +## Risks + +[Key risks and mitigations] + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/frontend/portal/src/api/content.js b/frontend/portal/src/api/content.js index af20497..3c77d5d 100644 --- a/frontend/portal/src/api/content.js +++ b/frontend/portal/src/api/content.js @@ -1,7 +1,12 @@ import { request } from "../utils/request"; export const contentApi = { - list: (params) => { + list: (params = {}) => { + if (params.tenantId) { + const { tenantId: tenantID, ...rest } = params; + const qs = new URLSearchParams(rest).toString(); + return request(`/creators/${tenantID}/contents?${qs}`); + } if (params.tenant_id) { const { tenant_id: tenantID, ...rest } = params; const qs = new URLSearchParams(rest).toString(); diff --git a/frontend/portal/src/api/user.js b/frontend/portal/src/api/user.js index 25d895b..dab1833 100644 --- a/frontend/portal/src/api/user.js +++ b/frontend/portal/src/api/user.js @@ -12,12 +12,12 @@ export const userApi = { getLibrary: () => request("/me/library"), getFavorites: () => request("/me/favorites"), addFavorite: (contentId) => - request(`/me/favorites?contentId=${contentId}`, { method: "POST" }), + request(`/me/favorites?content_id=${contentId}`, { method: "POST" }), removeFavorite: (contentId) => request(`/me/favorites/${contentId}`, { method: "DELETE" }), getLikes: () => request("/me/likes"), addLike: (contentId) => - request(`/me/likes?contentId=${contentId}`, { method: "POST" }), + request(`/me/likes?content_id=${contentId}`, { method: "POST" }), removeLike: (contentId) => request(`/me/likes/${contentId}`, { method: "DELETE" }), getNotifications: (type, page) => diff --git a/frontend/superadmin/dist/index.html b/frontend/superadmin/dist/index.html index 3d83a57..b6733df 100644 --- a/frontend/superadmin/dist/index.html +++ b/frontend/superadmin/dist/index.html @@ -7,8 +7,8 @@ Sakai Vue - - + +