From a6c32e8bc2bb8db000dc69adc9e203dd594e8b7d Mon Sep 17 00:00:00 2001 From: huayitang Date: Sat, 4 Oct 2025 15:53:44 +0200 Subject: [PATCH 01/11] add migrations --- .../postgres/migrations/000004_add_comments.down.sql | 3 +++ .../postgres/migrations/000004_add_comments.up.sql | 11 +++++++++++ 2 files changed, 14 insertions(+) create mode 100644 apps/product-query-svc/adapters/outbound/postgres/migrations/000004_add_comments.down.sql create mode 100644 apps/product-query-svc/adapters/outbound/postgres/migrations/000004_add_comments.up.sql diff --git a/apps/product-query-svc/adapters/outbound/postgres/migrations/000004_add_comments.down.sql b/apps/product-query-svc/adapters/outbound/postgres/migrations/000004_add_comments.down.sql new file mode 100644 index 0000000..d39b2af --- /dev/null +++ b/apps/product-query-svc/adapters/outbound/postgres/migrations/000004_add_comments.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS comments_user_id_idx; +DROP INDEX IF EXISTS comments_product_id_created_at_idx; +DROP TABLE IF EXISTS comments; diff --git a/apps/product-query-svc/adapters/outbound/postgres/migrations/000004_add_comments.up.sql b/apps/product-query-svc/adapters/outbound/postgres/migrations/000004_add_comments.up.sql new file mode 100644 index 0000000..5fffb3c --- /dev/null +++ b/apps/product-query-svc/adapters/outbound/postgres/migrations/000004_add_comments.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS comments ( + id BIGSERIAL PRIMARY KEY, + product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS comments_product_id_created_at_idx ON comments(product_id, created_at DESC); +CREATE INDEX IF NOT EXISTS comments_user_id_idx ON comments(user_id); From 83f1c24e3499caac72144061c3162d9e9060f577 Mon Sep 17 00:00:00 2001 From: huayitang Date: Sun, 5 Oct 2025 10:33:19 +0200 Subject: [PATCH 02/11] add agents.md --- AGENTS.md | 203 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9b4b51e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,203 @@ +# Repository Guidelines + +## 0) GLOBAL GUARANTEES + +* **MUST 输出**:`UNIFIED_DIFF` 或完整文件内容;能直接落地。 +* **MUST 通过**:`go build ./...` 与 `go test ./...`。 +* **MUST NOT**:无关格式化改动、私自新增第三方依赖、修改生成代码。 +* **SHOULD**:保持行为兼容,除非任务明确允许破坏式变更。 + +--- + +## 1) CORE PHILOSOPHY & REALITY CHECK + +* “Should work” ≠ “does work”。 +* 我们不是堆代码,是解决问题。 +* 未测试的代码只是猜测。 + +**30 秒自检(全部回答 YES)** + +* 我是否构建/运行了代码? +* 我是否触发了**恰好**被改动的功能路径? +* 我是否亲眼看到期望结果(含 HTTP 状态/响应体)? +* 我是否检查了日志与错误分支? + +**禁用措辞** + +* “This should work now”“Try it now”“The logic is correct so...”“I’ve fixed it”(二次以后)。 + +**变更类型最小验收** + +* UI 变更:实际点/点/点(如有 GUI)。 +* API 变更:发真实 HTTP 请求验证。 +* 数据变更:直连数据库验证行数/值。 +* 逻辑变更:跑到具体业务场景断言结果。 +* 配置变更:重启进程确认加载成功。 + +--- + +## 2) REPOSITORY LAYOUT & MODULES + +| Path | Purpose | +| -------------------------------------------------------------- | --------------------------------------------------------- | +| `backend/cmd/marketplace/product-query-svc` | 进程入口与依赖注入(路由、仓库、配置)。 | +| `apps/product-query-svc/domain` | 领域聚合与不变式(`Product`, `Comment`, `User`)。无 http/sql/env 依赖。 | +| `apps/product-query-svc/application` | 用例编排(实现入站端口),只依赖 `ports` 与 `domain`。 | +| `apps/product-query-svc/adapters` | 入站 HTTP handlers;出站持久化实现。禁止写业务规则。 | +| `apps/product-query-svc/adapters/outbound/postgres/migrations` | SQL 迁移(使用 `migrate` 工具)。 | +| `apps/product-query-svc/api/openapi.yaml` | OpenAPI 单一事实源。 | +| `apps/product-query-svc/api/gen` | oapi-codegen 生成物(**禁止手改**)。 | +| `test` | 端到端与集成测试(内存/PG 双路径)。 | +| `scripts`, `Makefile` | 开发脚本、构建、DB 设置、集成流程。 | +| `charts`, `k8s`, `kind` | 部署清单,配置变化时同步。 | + +**分层约定** + +* **Handler**:HTTP/JSON 校验与转换,调用 `application`,装配响应。**不得**嵌业务规则。 +* **Application**:编排用例,调用仓库/缓存/消息等端口。业务规则落在 `domain`。 +* **Domain**:纯对象与不变量,不依赖框架与存储。 +* **Adapters**:实现出站端口;不跨适配器互相引用。 + +--- + +## 3) BUILD & DEV COMMANDS + +| Command | Description | +| --------------------------------- | ---------------------------------------------- | +| `make build` | 编译为 `bin/product-query-svc`。 | +| `make run` | 本地启动服务做冒烟验证。 | +| `go test ./...` | 运行单测;沙箱可用 `GOCACHE=$(pwd)/.gocache`。 | +| `make test-integration-docker` | 在 `test/http_pg` 下跑 Docker 集成套件。 | +| `make migrate-up MIGRATE_URL=...` | 执行迁移;回滚用 `make migrate-down`。 | +| `make gen` | 在 `apps/product-query-svc/api` 内重生 OpenAPI 绑定。 | + +--- + +## 4) DEPENDENCIES & VERSIONS + +| Policy | Details | +| --------- | ------------------------------------------------------ | +| Go | 目标 Go `1.24.x`,与 `go.mod/toolchain` 对齐。 | +| Pinning | 锁到已发布 tag;用 `go get module@version` 升级并 `go mod tidy`。 | +| Review | PR 审查传递依赖的差异;谨慎大版本升级。 | +| Security | 优先打 Postgres/HTTP/OpenAPI 工具链相关 CVE。 | +| Artifacts | `go.mod` 与 `go.sum` 一并提交;默认不 vendor。 | + +**Core Libraries** + +* `github.com/go-chi/chi/v5`(路由) +* `github.com/jackc/pgx/v5`(Postgres 驱动/连接池) +* `github.com/Masterminds/squirrel`(SQL builder) +* `github.com/getkin/kin-openapi`(OpenAPI 校验) +* `github.com/oapi-codegen/runtime`(生成代码运行时) +* `github.com/testcontainers/testcontainers-go`(Docker 集成测试) + +--- + +## 5) OPENAPI & CODEGEN + +* **单一事实源**:`apps/product-query-svc/api/openapi.yaml`。 +* **生成位置**:`apps/product-query-svc/api/gen`,**禁止手改**。 +* **严格路由**:oapi-codegen 开启 `--strict-server`,不允许野路由。 +` + +--- + +## 6) CODING & NAMING + +| Rule | Details | +| ---------- | ------------------------------------------------------ | +| Formatting | `make fmt`(`go fmt ./...`)提交前必跑。 | +| Naming | Go mixedCaps;只导出必要标识符;接口按能力命名(无 `I` 前缀)。 | +| JSON | `snake_case` 与 OpenAPI 完全一致。 | +| Context | 所有外部 I/O 接收并透传 `context.Context`,设置合理超时。 | +| Errors | 包级 sentinel,`fmt.Errorf("op: %w", err)` 包装;HTTP 层统一映射。 | +| Receivers | 单字母稳定命名,如 `func (s *Server)`。 | +| Forbidden | `domain` 依赖 http/sql/env;`handler` 写业务;跨层循环依赖。 | + +--- + +## 7) TESTING (MANDATORY STYLE) + +* **表驱动**:`cases := []struct{ name string; in...; exp... }{...}` +* **子测试**:`t.Run(c.name, func(t *testing.T) { c := c; ... })` +* **断言**:`github.com/stretchr/testify/require` +* **覆盖面**:对外可观察行为为主;常见路径 + 边界 + 错误 +* **集成**:优先 Testcontainers,本地与 CI 一致 + + +--- + +## 8) CONCURRENCY, RESOURCES, SECURITY, OBSERVABILITY + +* 所有外部调用**必须**使用 `context` 并设置超时。 +* 禁止在 handler 内启动长生命周期 goroutine。 +* 文件/连接/游标 `defer Close()`,避免泄漏。 +* 输入校验在 handler 边界;不要信任客户端。 +* 日志用 `log/slog` 键值对;禁止 `fmt.Println`。 +* 传播 W3C `traceparent`(若存在);曝光 P95/P99、error_rate、QPS。 + +--- + +## 9) ROLE BOUNDARIES(Agent 硬约束) + +| 事项 | 必须 | 禁止 | +| ----- | ------------------- | ------------------- | +| 语言/版本 | Go 1.24 与标准库能力 | 过时 API、私有 fork | +| 依赖 | 默认不新增依赖 | 未授权新增第三方库 | +| 输出 | 统一 diff 或完整文件,能编译测试 | 片段化、不可编译拼贴 | +| 变更范围 | 最小必要改动,保持 ABI/行为 | 大范围样式化改动 | +| 错误处理 | 有语义的 `error`,分级日志 | `panic`(测除外)、吞错、裸打印 | +| 并发 | 遵循取消/超时、无共享可变状态 | 忽视 `ctx`、数据竞争 | +| 安全 | 严格校验输入 | 信任外部输入 | +| 文档 | 关键导出符号有注释,公共约定更到本文 | 把约定只写在 PR 里 | + +--- + +## 10) REFACTOR POLICY + +**Allowed(必要时用于“高内聚低耦合”)** + +* `EXTRACT_FUNC`:从 handler 提取到 `presenters`/`application` +* `MOVE_FILE`:把基础设施代码移入 `adapters/{backend}` +* `INTRODUCE_PORT`:用 `ports` 接口替代直连适配器 +* `RENAME_SYMBOL`:统一命名规范 +* `SPLIT_FILE`:大文件按层或关注点拆分 + +**Prohibited** + +* 修改 `/api/gen` 生成物 +* 跨层互相引用导致循环 +* 在 handler/adapter 内塞业务规则 + +**触发条件** + +* handler 含业务决策或多步编排 +* domain 与 infra 互相引用 +* 单文件 > ~500 行且混杂多层 + + +--- + +## 12) COMMIT, PR & CI GATES + +| 检查项 | 要求 | +| ---- | ----------------------------------------------- | +| 构建 | `make build` 通过 | +| 格式 | `go fmt ./...`、`go vet ./...` | +| Lint | `staticcheck ./...` | +| 安全 | `govulncheck ./...` | +| 测试 | `make test` 与 `make test-integration-docker` 通过 | +| 生成 | 改了 `openapi.yaml` 则重生并提交 `api/gen` | +| 文档 | 对外行为变化需更新本文件或 README | + +**PR 提交流程(Checklist)** + +* [ ] 本地 `make build && make test` 通过 +* [ ] 如改 API:更新 `openapi.yaml` 并重生 `api/gen` +* [ ] 单测覆盖核心路径与边界用例 +* [ ] 无无关格式化改动 +* [ ] PR 写明:问题、方案、权衡、回滚策略、影响面 +* [ ] 性能/缓存改动附基线与指标 + + From 294c70c1ee5940ab7c0191d77810a49b01932cec Mon Sep 17 00:00:00 2001 From: huayitang Date: Sun, 5 Oct 2025 11:27:03 +0200 Subject: [PATCH 03/11] add agents.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9b4b51e..d438b4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ * **MUST 输出**:`UNIFIED_DIFF` 或完整文件内容;能直接落地。 * **MUST 通过**:`go build ./...` 与 `go test ./...`。 -* **MUST NOT**:无关格式化改动、私自新增第三方依赖、修改生成代码。 +* **MUST NOT**:无关格式化改动、私自新增第三方依赖、修改生成代码,using hand-written SQL strings * **SHOULD**:保持行为兼容,除非任务明确允许破坏式变更。 --- From 2e327beae1e4bbe17f38428b994f41416285d615 Mon Sep 17 00:00:00 2001 From: huayitang Date: Sun, 5 Oct 2025 11:44:38 +0200 Subject: [PATCH 04/11] add domain model --- apps/product-query-svc/domain/comment.go | 77 ++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/product-query-svc/domain/comment.go diff --git a/apps/product-query-svc/domain/comment.go b/apps/product-query-svc/domain/comment.go new file mode 100644 index 0000000..7d58bf4 --- /dev/null +++ b/apps/product-query-svc/domain/comment.go @@ -0,0 +1,77 @@ +package domain + +import ( + "strings" + "time" + "unicode/utf8" +) + +const ( + MaxCommentLength = 2048 +) + +// Comment represents a user-authored note attached to a product. +type Comment struct { + ID int64 + ProductID int64 + UserID int64 + Content string + CreatedAt time.Time + UpdatedAt time.Time +} + +// NewComment validates and constructs a comment bound to a product and author. +func NewComment(productID, userID int64, content string) (*Comment, error) { + c := &Comment{ + ProductID: productID, + UserID: userID, + } + if err := c.updateContent(strings.TrimSpace(content)); err != nil { + return nil, err + } + now := time.Now().UTC() + c.CreatedAt = now + c.UpdatedAt = now + if err := c.Validate(); err != nil { + return nil, err + } + return c, nil +} + +// Validate ensures the comment satisfies domain invariants. +func (c *Comment) Validate() error { + if c.ProductID <= 0 { + return ValidationError("product id must be positive") + } + if c.UserID <= 0 { + return ValidationError("user id must be positive") + } + trimmed := strings.TrimSpace(c.Content) + if trimmed == "" { + return ValidationError("content required") + } + if utf8.RuneCountInString(trimmed) > MaxCommentLength { + return ValidationError("content too long") + } + return nil +} + +// UpdateContent modifies the body of the comment and bumps the update timestamp. +func (c *Comment) UpdateContent(content string) error { + if err := c.updateContent(strings.TrimSpace(content)); err != nil { + return err + } + c.UpdatedAt = time.Now().UTC() + return nil +} + +func (c *Comment) updateContent(content string) error { + if content == "" { + return ValidationError("content required") + } + if utf8.RuneCountInString(content) > MaxCommentLength { + return ValidationError("content too long") + } + c.Content = content + return nil +} From a9f1bff0acf553c32d33708ba79ab3bc6103d452 Mon Sep 17 00:00:00 2001 From: huayitang Date: Sun, 5 Oct 2025 11:45:36 +0200 Subject: [PATCH 05/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=B1=82=E5=90=84=E4=B8=AA=E6=8E=A5=E5=8F=A3=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=8E=A5=E5=8F=A3=E5=B1=82=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbound/inmem/product_repository.go | 72 +++++++++ .../outbound/postgres/comment_repository.go | 151 ++++++++++++++++++ .../ports/outbound/comment.go | 16 ++ 3 files changed, 239 insertions(+) create mode 100644 apps/product-query-svc/adapters/outbound/postgres/comment_repository.go create mode 100644 apps/product-query-svc/ports/outbound/comment.go diff --git a/apps/product-query-svc/adapters/outbound/inmem/product_repository.go b/apps/product-query-svc/adapters/outbound/inmem/product_repository.go index 310e8ca..4d8bab0 100644 --- a/apps/product-query-svc/adapters/outbound/inmem/product_repository.go +++ b/apps/product-query-svc/adapters/outbound/inmem/product_repository.go @@ -2,6 +2,7 @@ package inmem import ( "context" + "sort" "strings" "sync" "time" @@ -13,6 +14,7 @@ import ( var ( _ outbound.ProductRepository = (*InMemRepo)(nil) _ outbound.UserRepository = (*InMemRepo)(nil) + _ outbound.CommentRepository = (*InMemRepo)(nil) ) // 简单的内存实现,用于本地开发/测试和示例 wiring @@ -21,6 +23,8 @@ type InMemRepo struct { products map[int64]domain.Product nextProduct int64 users map[int64]domain.User + comments map[int64]domain.Comment + nextComment int64 } func NewInMemRepo() *InMemRepo { @@ -28,6 +32,8 @@ func NewInMemRepo() *InMemRepo { products: make(map[int64]domain.Product), nextProduct: 1, users: make(map[int64]domain.User), + comments: make(map[int64]domain.Comment), + nextComment: 1, } // seed demo data r.products[1] = domain.Product{ID: 1, Name: "Blue Widget", Price: 1999} @@ -38,6 +44,72 @@ func NewInMemRepo() *InMemRepo { return r } +func (r *InMemRepo) CreateComment(ctx context.Context, comment *domain.Comment) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + id := r.nextComment + comment.ID = id + if comment.CreatedAt.IsZero() { + now := time.Now().UTC() + comment.CreatedAt = now + comment.UpdatedAt = now + } else if comment.UpdatedAt.IsZero() { + comment.UpdatedAt = comment.CreatedAt + } + r.comments[id] = *comment + r.nextComment = id + 1 + return id, nil +} + +func (r *InMemRepo) GetCommentByID(ctx context.Context, id int64) (*domain.Comment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + c, ok := r.comments[id] + if !ok { + return nil, domain.ErrNotFound + } + copy := c + return ©, nil +} + +func (r *InMemRepo) ListCommentsByProduct(ctx context.Context, productID int64) ([]domain.Comment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + var out []domain.Comment + for _, c := range r.comments { + if c.ProductID == productID { + out = append(out, c) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].ID < out[j].ID + } + return out[i].CreatedAt.Before(out[j].CreatedAt) + }) + return out, nil +} + +func (r *InMemRepo) UpdateComment(ctx context.Context, comment *domain.Comment) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.comments[comment.ID]; !ok { + return domain.ErrNotFound + } + r.comments[comment.ID] = *comment + return nil +} + +func (r *InMemRepo) DeleteComment(ctx context.Context, id int64) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.comments[id]; !ok { + return domain.ErrNotFound + } + delete(r.comments, id) + return nil +} + func (r *InMemRepo) GetByID(ctx context.Context, id int64) (*domain.Product, error) { r.mu.RLock() defer r.mu.RUnlock() diff --git a/apps/product-query-svc/adapters/outbound/postgres/comment_repository.go b/apps/product-query-svc/adapters/outbound/postgres/comment_repository.go new file mode 100644 index 0000000..fdf45e2 --- /dev/null +++ b/apps/product-query-svc/adapters/outbound/postgres/comment_repository.go @@ -0,0 +1,151 @@ +package postgres + +import ( + "context" + "errors" + "time" + + "github.com/Masterminds/squirrel" + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + "github.com/fightingBald/GoTuto/apps/product-query-svc/ports/outbound" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PGCommentRepo struct { + pool *pgxpool.Pool +} + +var _ outbound.CommentRepository = (*PGCommentRepo)(nil) + +func NewCommentRepository(pool *pgxpool.Pool) outbound.CommentRepository { + return &PGCommentRepo{pool: pool} +} + +func (r *PGCommentRepo) CreateComment(ctx context.Context, comment *domain.Comment) (int64, error) { + createdAt := comment.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now().UTC() + } + updatedAt := comment.UpdatedAt + if updatedAt.IsZero() { + updatedAt = createdAt + } + + qb := psql.Insert("comments").Columns( + "product_id", + "user_id", + "content", + "created_at", + "updated_at", + ).Values(comment.ProductID, comment.UserID, comment.Content, createdAt, updatedAt).Suffix("RETURNING id") + + sql, args, err := qb.ToSql() + if err != nil { + return 0, err + } + + var id int64 + if err := r.pool.QueryRow(ctx, sql, args...).Scan(&id); err != nil { + return 0, err + } + + comment.ID = id + comment.CreatedAt = createdAt + comment.UpdatedAt = updatedAt + return id, nil +} + +func (r *PGCommentRepo) GetCommentByID(ctx context.Context, id int64) (*domain.Comment, error) { + qb := psql.Select("id", "product_id", "user_id", "content", "created_at", "updated_at").From("comments").Where(squirrel.Eq{"id": id}) + sql, args, err := qb.ToSql() + if err != nil { + return nil, err + } + + var c domain.Comment + if err := r.pool.QueryRow(ctx, sql, args...).Scan(&c.ID, &c.ProductID, &c.UserID, &c.Content, &c.CreatedAt, &c.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, domain.ErrNotFound + } + return nil, err + } + return &c, nil +} + +func (r *PGCommentRepo) ListCommentsByProduct(ctx context.Context, productID int64) ([]domain.Comment, error) { + qb := psql.Select("id", "product_id", "user_id", "content", "created_at", "updated_at"). + From("comments"). + Where(squirrel.Eq{"product_id": productID}). + OrderBy("created_at DESC", "id DESC") + + sql, args, err := qb.ToSql() + if err != nil { + return nil, err + } + + rows, err := r.pool.Query(ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Comment + for rows.Next() { + var c domain.Comment + if err := rows.Scan(&c.ID, &c.ProductID, &c.UserID, &c.Content, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func (r *PGCommentRepo) UpdateComment(ctx context.Context, comment *domain.Comment) error { + updatedAt := comment.UpdatedAt + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + + qb := psql.Update("comments"). + Set("content", comment.Content). + Set("updated_at", updatedAt). + Where(squirrel.Eq{"id": comment.ID}) + + sql, args, err := qb.ToSql() + if err != nil { + return err + } + + ct, err := r.pool.Exec(ctx, sql, args...) + if err != nil { + return err + } + if ct.RowsAffected() == 0 { + return domain.ErrNotFound + } + + comment.UpdatedAt = updatedAt + return nil +} + +func (r *PGCommentRepo) DeleteComment(ctx context.Context, id int64) error { + qb := psql.Delete("comments").Where(squirrel.Eq{"id": id}) + + sql, args, err := qb.ToSql() + if err != nil { + return err + } + + ct, err := r.pool.Exec(ctx, sql, args...) + if err != nil { + return err + } + if ct.RowsAffected() == 0 { + return domain.ErrNotFound + } + return nil +} diff --git a/apps/product-query-svc/ports/outbound/comment.go b/apps/product-query-svc/ports/outbound/comment.go new file mode 100644 index 0000000..e28ec80 --- /dev/null +++ b/apps/product-query-svc/ports/outbound/comment.go @@ -0,0 +1,16 @@ +package outbound + +import ( + "context" + + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" +) + +// CommentRepository abstracts persistence for product comments. +type CommentRepository interface { + CreateComment(ctx context.Context, comment *domain.Comment) (int64, error) + GetCommentByID(ctx context.Context, id int64) (*domain.Comment, error) + ListCommentsByProduct(ctx context.Context, productID int64) ([]domain.Comment, error) + UpdateComment(ctx context.Context, comment *domain.Comment) error + DeleteComment(ctx context.Context, id int64) error +} From bffed579a033b3529aabb18b0379dd030820c447 Mon Sep 17 00:00:00 2001 From: huayitang Date: Sun, 5 Oct 2025 12:24:01 +0200 Subject: [PATCH 06/11] =?UTF-8?q?=E6=B7=BB=E5=8A=A0api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 +- api/components/parameters/CommentID.yaml | 7 + api/components/parameters/ProductID.yaml | 7 + api/components/parameters/UserIDQuery.yaml | 7 + .../requestBodies/CommentCreate.yaml | 6 + .../requestBodies/CommentUpdate.yaml | 6 + api/openapi.yaml | 14 + api/paths/products/comment-item.yaml | 39 ++ api/paths/products/comments.yaml | 34 + api/schemas/Comment.yaml | 22 + api/schemas/CommentCreate.yaml | 10 + api/schemas/CommentList.yaml | 7 + api/schemas/CommentUpdate.yaml | 10 + .../inbound/http/marketplaceapi.gen.go | 647 +++++++++++++++++- 14 files changed, 802 insertions(+), 21 deletions(-) create mode 100644 api/components/parameters/CommentID.yaml create mode 100644 api/components/parameters/ProductID.yaml create mode 100644 api/components/parameters/UserIDQuery.yaml create mode 100644 api/components/requestBodies/CommentCreate.yaml create mode 100644 api/components/requestBodies/CommentUpdate.yaml create mode 100644 api/paths/products/comment-item.yaml create mode 100644 api/paths/products/comments.yaml create mode 100644 api/schemas/Comment.yaml create mode 100644 api/schemas/CommentCreate.yaml create mode 100644 api/schemas/CommentList.yaml create mode 100644 api/schemas/CommentUpdate.yaml diff --git a/AGENTS.md b/AGENTS.md index d438b4e..af65f56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,10 +158,9 @@ **Allowed(必要时用于“高内聚低耦合”)** -* `EXTRACT_FUNC`:从 handler 提取到 `presenters`/`application` -* `MOVE_FILE`:把基础设施代码移入 `adapters/{backend}` -* `INTRODUCE_PORT`:用 `ports` 接口替代直连适配器 -* `RENAME_SYMBOL`:统一命名规范 +* `EXTRACT_FUNC`: +* `MOVE_FILE` +* `RENAME_SYMBOL`: * `SPLIT_FILE`:大文件按层或关注点拆分 **Prohibited** diff --git a/api/components/parameters/CommentID.yaml b/api/components/parameters/CommentID.yaml new file mode 100644 index 0000000..9ec7a7c --- /dev/null +++ b/api/components/parameters/CommentID.yaml @@ -0,0 +1,7 @@ +name: commentId +in: path +required: true +schema: + type: integer + format: int64 + minimum: 1 diff --git a/api/components/parameters/ProductID.yaml b/api/components/parameters/ProductID.yaml new file mode 100644 index 0000000..76fade1 --- /dev/null +++ b/api/components/parameters/ProductID.yaml @@ -0,0 +1,7 @@ +name: productId +in: path +required: true +schema: + type: integer + format: int64 + minimum: 1 diff --git a/api/components/parameters/UserIDQuery.yaml b/api/components/parameters/UserIDQuery.yaml new file mode 100644 index 0000000..f45ea97 --- /dev/null +++ b/api/components/parameters/UserIDQuery.yaml @@ -0,0 +1,7 @@ +name: userId +in: query +required: true +schema: + type: integer + format: int64 + minimum: 1 diff --git a/api/components/requestBodies/CommentCreate.yaml b/api/components/requestBodies/CommentCreate.yaml new file mode 100644 index 0000000..72b2287 --- /dev/null +++ b/api/components/requestBodies/CommentCreate.yaml @@ -0,0 +1,6 @@ +description: Comment creation payload +required: true +content: + application/json: + schema: + $ref: '../../schemas/CommentCreate.yaml' diff --git a/api/components/requestBodies/CommentUpdate.yaml b/api/components/requestBodies/CommentUpdate.yaml new file mode 100644 index 0000000..a3a8ba6 --- /dev/null +++ b/api/components/requestBodies/CommentUpdate.yaml @@ -0,0 +1,6 @@ +description: Comment update payload +required: true +content: + application/json: + schema: + $ref: '../../schemas/CommentUpdate.yaml' diff --git a/api/openapi.yaml b/api/openapi.yaml index 900d933..7454e97 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -7,6 +7,8 @@ tags: description: Product query and management endpoints - name: Users description: User account retrieval endpoints + - name: Comments + description: Product comment management endpoints paths: /products/{id}: @@ -15,6 +17,10 @@ paths: $ref: './paths/products/search.yaml' /products: $ref: './paths/products/collection.yaml' + /products/{productId}/comments: + $ref: './paths/products/comments.yaml' + /products/{productId}/comments/{commentId}: + $ref: './paths/products/comment-item.yaml' /users/{id}: $ref: './paths/users/item.yaml' @@ -28,6 +34,14 @@ components: $ref: './schemas/ProductCreate.yaml' ProductList: $ref: './schemas/ProductList.yaml' + Comment: + $ref: './schemas/Comment.yaml' + CommentCreate: + $ref: './schemas/CommentCreate.yaml' + CommentUpdate: + $ref: './schemas/CommentUpdate.yaml' + CommentList: + $ref: './schemas/CommentList.yaml' User: $ref: './schemas/User.yaml' Error: diff --git a/api/paths/products/comment-item.yaml b/api/paths/products/comment-item.yaml new file mode 100644 index 0000000..869c297 --- /dev/null +++ b/api/paths/products/comment-item.yaml @@ -0,0 +1,39 @@ +put: + tags: [Comments] + operationId: UpdateProductComment + parameters: + - $ref: '../../components/parameters/ProductID.yaml' + - $ref: '../../components/parameters/CommentID.yaml' + - $ref: '../../components/parameters/UserIDQuery.yaml' + requestBody: + $ref: '../../components/requestBodies/CommentUpdate.yaml' + responses: + '200': + description: Updated comment + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + $ref: '../../components/responses/Error.yaml' + '403': + $ref: '../../components/responses/Error.yaml' + '404': + $ref: '../../components/responses/Error.yaml' + +delete: + tags: [Comments] + operationId: DeleteProductComment + parameters: + - $ref: '../../components/parameters/ProductID.yaml' + - $ref: '../../components/parameters/CommentID.yaml' + - $ref: '../../components/parameters/UserIDQuery.yaml' + responses: + '204': + description: Deleted + '400': + $ref: '../../components/responses/Error.yaml' + '403': + $ref: '../../components/responses/Error.yaml' + '404': + $ref: '../../components/responses/Error.yaml' diff --git a/api/paths/products/comments.yaml b/api/paths/products/comments.yaml new file mode 100644 index 0000000..dff0cde --- /dev/null +++ b/api/paths/products/comments.yaml @@ -0,0 +1,34 @@ +get: + tags: [Comments] + operationId: ListProductComments + parameters: + - $ref: '../../components/parameters/ProductID.yaml' + responses: + '200': + description: Comments for product + content: + application/json: + schema: + $ref: '#/components/schemas/CommentList' + '400': + $ref: '../../components/responses/Error.yaml' + '404': + $ref: '../../components/responses/Error.yaml' +post: + tags: [Comments] + operationId: CreateProductComment + parameters: + - $ref: '../../components/parameters/ProductID.yaml' + requestBody: + $ref: '../../components/requestBodies/CommentCreate.yaml' + responses: + '201': + description: Created comment + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + $ref: '../../components/responses/Error.yaml' + '404': + $ref: '../../components/responses/Error.yaml' diff --git a/api/schemas/Comment.yaml b/api/schemas/Comment.yaml new file mode 100644 index 0000000..010d7ae --- /dev/null +++ b/api/schemas/Comment.yaml @@ -0,0 +1,22 @@ +type: object +properties: + id: + type: integer + format: int64 + productId: + type: integer + format: int64 + userId: + type: integer + format: int64 + content: + type: string + minLength: 1 + maxLength: 2048 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time +required: [id, productId, userId, content, createdAt, updatedAt] diff --git a/api/schemas/CommentCreate.yaml b/api/schemas/CommentCreate.yaml new file mode 100644 index 0000000..a6cafce --- /dev/null +++ b/api/schemas/CommentCreate.yaml @@ -0,0 +1,10 @@ +type: object +properties: + userId: + type: integer + format: int64 + content: + type: string + minLength: 1 + maxLength: 2048 +required: [userId, content] diff --git a/api/schemas/CommentList.yaml b/api/schemas/CommentList.yaml new file mode 100644 index 0000000..fec4390 --- /dev/null +++ b/api/schemas/CommentList.yaml @@ -0,0 +1,7 @@ +type: object +properties: + items: + type: array + items: + $ref: '#/components/schemas/Comment' +required: [items] diff --git a/api/schemas/CommentUpdate.yaml b/api/schemas/CommentUpdate.yaml new file mode 100644 index 0000000..a6cafce --- /dev/null +++ b/api/schemas/CommentUpdate.yaml @@ -0,0 +1,10 @@ +type: object +properties: + userId: + type: integer + format: int64 + content: + type: string + minLength: 1 + maxLength: 2048 +required: [userId, content] diff --git a/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go b/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go index 2d2f737..322d0af 100644 --- a/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go +++ b/apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go @@ -23,6 +23,21 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" ) +// Comment defines model for Comment. +type Comment struct { + Content string `json:"content"` + CreatedAt time.Time `json:"createdAt"` + Id int64 `json:"id"` + ProductId int64 `json:"productId"` + UpdatedAt time.Time `json:"updatedAt"` + UserId int64 `json:"userId"` +} + +// CommentList defines model for CommentList. +type CommentList struct { + Items []Comment `json:"items"` +} + // Product defines model for Product. type Product struct { Id int64 `json:"id"` @@ -65,12 +80,40 @@ type UpdateProductJSONBody struct { Price float32 `json:"price"` } +// CreateProductCommentJSONBody defines parameters for CreateProductComment. +type CreateProductCommentJSONBody struct { + Content string `json:"content"` + UserId int64 `json:"userId"` +} + +// DeleteProductCommentParams defines parameters for DeleteProductComment. +type DeleteProductCommentParams struct { + UserId int64 `form:"userId" json:"userId"` +} + +// UpdateProductCommentJSONBody defines parameters for UpdateProductComment. +type UpdateProductCommentJSONBody struct { + Content string `json:"content"` + UserId int64 `json:"userId"` +} + +// UpdateProductCommentParams defines parameters for UpdateProductComment. +type UpdateProductCommentParams struct { + UserId int64 `form:"userId" json:"userId"` +} + // CreateProductJSONRequestBody defines body for CreateProduct for application/json ContentType. type CreateProductJSONRequestBody CreateProductJSONBody // UpdateProductJSONRequestBody defines body for UpdateProduct for application/json ContentType. type UpdateProductJSONRequestBody UpdateProductJSONBody +// CreateProductCommentJSONRequestBody defines body for CreateProductComment for application/json ContentType. +type CreateProductCommentJSONRequestBody CreateProductCommentJSONBody + +// UpdateProductCommentJSONRequestBody defines body for UpdateProductComment for application/json ContentType. +type UpdateProductCommentJSONRequestBody UpdateProductCommentJSONBody + // ServerInterface represents all server handlers. type ServerInterface interface { @@ -89,6 +132,18 @@ type ServerInterface interface { // (PUT /products/{id}) UpdateProduct(w http.ResponseWriter, r *http.Request, id int64) + // (GET /products/{productId}/comments) + ListProductComments(w http.ResponseWriter, r *http.Request, productId int64) + + // (POST /products/{productId}/comments) + CreateProductComment(w http.ResponseWriter, r *http.Request, productId int64) + + // (DELETE /products/{productId}/comments/{commentId}) + DeleteProductComment(w http.ResponseWriter, r *http.Request, productId int64, commentId int64, params DeleteProductCommentParams) + + // (PUT /products/{productId}/comments/{commentId}) + UpdateProductComment(w http.ResponseWriter, r *http.Request, productId int64, commentId int64, params UpdateProductCommentParams) + // (GET /users/{id}) GetUserByID(w http.ResponseWriter, r *http.Request, id int64) } @@ -122,6 +177,26 @@ func (_ Unimplemented) UpdateProduct(w http.ResponseWriter, r *http.Request, id w.WriteHeader(http.StatusNotImplemented) } +// (GET /products/{productId}/comments) +func (_ Unimplemented) ListProductComments(w http.ResponseWriter, r *http.Request, productId int64) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (POST /products/{productId}/comments) +func (_ Unimplemented) CreateProductComment(w http.ResponseWriter, r *http.Request, productId int64) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (DELETE /products/{productId}/comments/{commentId}) +func (_ Unimplemented) DeleteProductComment(w http.ResponseWriter, r *http.Request, productId int64, commentId int64, params DeleteProductCommentParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (PUT /products/{productId}/comments/{commentId}) +func (_ Unimplemented) UpdateProductComment(w http.ResponseWriter, r *http.Request, productId int64, commentId int64, params UpdateProductCommentParams) { + w.WriteHeader(http.StatusNotImplemented) +} + // (GET /users/{id}) func (_ Unimplemented) GetUserByID(w http.ResponseWriter, r *http.Request, id int64) { w.WriteHeader(http.StatusNotImplemented) @@ -268,6 +343,160 @@ func (siw *ServerInterfaceWrapper) UpdateProduct(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// ListProductComments operation middleware +func (siw *ServerInterfaceWrapper) ListProductComments(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "productId" ------------- + var productId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "productId", chi.URLParam(r, "productId"), &productId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "productId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListProductComments(w, r, productId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateProductComment operation middleware +func (siw *ServerInterfaceWrapper) CreateProductComment(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "productId" ------------- + var productId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "productId", chi.URLParam(r, "productId"), &productId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "productId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateProductComment(w, r, productId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DeleteProductComment operation middleware +func (siw *ServerInterfaceWrapper) DeleteProductComment(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "productId" ------------- + var productId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "productId", chi.URLParam(r, "productId"), &productId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "productId", Err: err}) + return + } + + // ------------- Path parameter "commentId" ------------- + var commentId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "commentId", chi.URLParam(r, "commentId"), &commentId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "commentId", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params DeleteProductCommentParams + + // ------------- Required query parameter "userId" ------------- + + if paramValue := r.URL.Query().Get("userId"); paramValue != "" { + + } else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "userId"}) + return + } + + err = runtime.BindQueryParameter("form", true, true, "userId", r.URL.Query(), ¶ms.UserId) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "userId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeleteProductComment(w, r, productId, commentId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateProductComment operation middleware +func (siw *ServerInterfaceWrapper) UpdateProductComment(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "productId" ------------- + var productId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "productId", chi.URLParam(r, "productId"), &productId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "productId", Err: err}) + return + } + + // ------------- Path parameter "commentId" ------------- + var commentId int64 + + err = runtime.BindStyledParameterWithOptions("simple", "commentId", chi.URLParam(r, "commentId"), &commentId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "commentId", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params UpdateProductCommentParams + + // ------------- Required query parameter "userId" ------------- + + if paramValue := r.URL.Query().Get("userId"); paramValue != "" { + + } else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "userId"}) + return + } + + err = runtime.BindQueryParameter("form", true, true, "userId", r.URL.Query(), ¶ms.UserId) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "userId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateProductComment(w, r, productId, commentId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetUserByID operation middleware func (siw *ServerInterfaceWrapper) GetUserByID(w http.ResponseWriter, r *http.Request) { @@ -421,6 +650,18 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/products/{id}", wrapper.UpdateProduct) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/products/{productId}/comments", wrapper.ListProductComments) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/products/{productId}/comments", wrapper.CreateProductComment) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/products/{productId}/comments/{commentId}", wrapper.DeleteProductComment) + }) + r.Group(func(r chi.Router) { + r.Put(options.BaseURL+"/products/{productId}/comments/{commentId}", wrapper.UpdateProductComment) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/users/{id}", wrapper.GetUserByID) }) @@ -641,6 +882,239 @@ func (response UpdateProduct404JSONResponse) VisitUpdateProductResponse(w http.R return json.NewEncoder(w).Encode(response) } +type ListProductCommentsRequestObject struct { + ProductId int64 `json:"productId"` +} + +type ListProductCommentsResponseObject interface { + VisitListProductCommentsResponse(w http.ResponseWriter) error +} + +type ListProductComments200JSONResponse CommentList + +func (response ListProductComments200JSONResponse) VisitListProductCommentsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ListProductComments400JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response ListProductComments400JSONResponse) VisitListProductCommentsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type ListProductComments404JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response ListProductComments404JSONResponse) VisitListProductCommentsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type CreateProductCommentRequestObject struct { + ProductId int64 `json:"productId"` + Body *CreateProductCommentJSONRequestBody +} + +type CreateProductCommentResponseObject interface { + VisitCreateProductCommentResponse(w http.ResponseWriter) error +} + +type CreateProductComment201JSONResponse Comment + +func (response CreateProductComment201JSONResponse) VisitCreateProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type CreateProductComment400JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response CreateProductComment400JSONResponse) VisitCreateProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type CreateProductComment404JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response CreateProductComment404JSONResponse) VisitCreateProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteProductCommentRequestObject struct { + ProductId int64 `json:"productId"` + CommentId int64 `json:"commentId"` + Params DeleteProductCommentParams +} + +type DeleteProductCommentResponseObject interface { + VisitDeleteProductCommentResponse(w http.ResponseWriter) error +} + +type DeleteProductComment204Response struct { +} + +func (response DeleteProductComment204Response) VisitDeleteProductCommentResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type DeleteProductComment400JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response DeleteProductComment400JSONResponse) VisitDeleteProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteProductComment403JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response DeleteProductComment403JSONResponse) VisitDeleteProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteProductComment404JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response DeleteProductComment404JSONResponse) VisitDeleteProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateProductCommentRequestObject struct { + ProductId int64 `json:"productId"` + CommentId int64 `json:"commentId"` + Params UpdateProductCommentParams + Body *UpdateProductCommentJSONRequestBody +} + +type UpdateProductCommentResponseObject interface { + VisitUpdateProductCommentResponse(w http.ResponseWriter) error +} + +type UpdateProductComment200JSONResponse Comment + +func (response UpdateProductComment200JSONResponse) VisitUpdateProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateProductComment400JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response UpdateProductComment400JSONResponse) VisitUpdateProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateProductComment403JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response UpdateProductComment403JSONResponse) VisitUpdateProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type UpdateProductComment404JSONResponse struct { + Code string `json:"code"` + Details *[]struct { + Field *string `json:"field,omitempty"` + Reason *string `json:"reason,omitempty"` + } `json:"details,omitempty"` + Message string `json:"message"` +} + +func (response UpdateProductComment404JSONResponse) VisitUpdateProductCommentResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + type GetUserByIDRequestObject struct { Id int64 `json:"id"` } @@ -708,6 +1182,18 @@ type StrictServerInterface interface { // (PUT /products/{id}) UpdateProduct(ctx context.Context, request UpdateProductRequestObject) (UpdateProductResponseObject, error) + // (GET /products/{productId}/comments) + ListProductComments(ctx context.Context, request ListProductCommentsRequestObject) (ListProductCommentsResponseObject, error) + + // (POST /products/{productId}/comments) + CreateProductComment(ctx context.Context, request CreateProductCommentRequestObject) (CreateProductCommentResponseObject, error) + + // (DELETE /products/{productId}/comments/{commentId}) + DeleteProductComment(ctx context.Context, request DeleteProductCommentRequestObject) (DeleteProductCommentResponseObject, error) + + // (PUT /products/{productId}/comments/{commentId}) + UpdateProductComment(ctx context.Context, request UpdateProductCommentRequestObject) (UpdateProductCommentResponseObject, error) + // (GET /users/{id}) GetUserByID(ctx context.Context, request GetUserByIDRequestObject) (GetUserByIDResponseObject, error) } @@ -883,6 +1369,128 @@ func (sh *strictHandler) UpdateProduct(w http.ResponseWriter, r *http.Request, i } } +// ListProductComments operation middleware +func (sh *strictHandler) ListProductComments(w http.ResponseWriter, r *http.Request, productId int64) { + var request ListProductCommentsRequestObject + + request.ProductId = productId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListProductComments(ctx, request.(ListProductCommentsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListProductComments") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListProductCommentsResponseObject); ok { + if err := validResponse.VisitListProductCommentsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// CreateProductComment operation middleware +func (sh *strictHandler) CreateProductComment(w http.ResponseWriter, r *http.Request, productId int64) { + var request CreateProductCommentRequestObject + + request.ProductId = productId + + var body CreateProductCommentJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.CreateProductComment(ctx, request.(CreateProductCommentRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateProductComment") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(CreateProductCommentResponseObject); ok { + if err := validResponse.VisitCreateProductCommentResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteProductComment operation middleware +func (sh *strictHandler) DeleteProductComment(w http.ResponseWriter, r *http.Request, productId int64, commentId int64, params DeleteProductCommentParams) { + var request DeleteProductCommentRequestObject + + request.ProductId = productId + request.CommentId = commentId + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DeleteProductComment(ctx, request.(DeleteProductCommentRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteProductComment") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DeleteProductCommentResponseObject); ok { + if err := validResponse.VisitDeleteProductCommentResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateProductComment operation middleware +func (sh *strictHandler) UpdateProductComment(w http.ResponseWriter, r *http.Request, productId int64, commentId int64, params UpdateProductCommentParams) { + var request UpdateProductCommentRequestObject + + request.ProductId = productId + request.CommentId = commentId + request.Params = params + + var body UpdateProductCommentJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateProductComment(ctx, request.(UpdateProductCommentRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateProductComment") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateProductCommentResponseObject); ok { + if err := validResponse.VisitUpdateProductCommentResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUserByID operation middleware func (sh *strictHandler) GetUserByID(w http.ResponseWriter, r *http.Request, id int64) { var request GetUserByIDRequestObject @@ -912,23 +1520,28 @@ func (sh *strictHandler) GetUserByID(w http.ResponseWriter, r *http.Request, id // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xYS2/jNhD+KwLboxorWbcH3XbrojCwRV0Ee1oEASOObW7FR8jRoq6h/16Q1NOigm7W", - "MZq2p8QczvP7ZjTSkRRKaCVBoiX5kWhqqAAE43/1svtecL9eORmXJCea4p6kRFIBJCeckZQYeKy4AUZy", - "NBWkxBZ7ENRpbJURFN09iT8sSUoEl1xUguTXKcGDhiCCHRhS1+mM9w3dQef/sQJz6APQTjZ0yWBLqxK9", - "h2d7u+V/PunRy6Neb7KUCPpH4zbLnhnEb3PeH0duBZfvQe5wT/I3nXWLhssdqZ11Bw1YfKcYh1N8R7L7", - "jVGsKvBHAxQh3JQIEt2/VOuSFxS5kotPVkl31gfxrYEtyck3i972Iki7vyfWfWgMbGG4dkZJTpoLSeFu", - "cCUTTQ+lolN6haysVtJGMmrO738yRpmzZxGsRqL3gqR173FtdJzpJjffbEZpMNiAwVm0SU5Z0oJ/dMxq", - "8b6+Cdzqfk/wT4k2vICGJ4GDWXdLVuKhoWBf4I+hob27Vv2uU1EPn6BAZ7jJ6D23sawQxPifL2CIs964", - "o8bQg0+j6f9pXfSgV6dSVEjLmOg0Zx9n2s6SQYMHC7EKfLBgQuMPeeBOE23UlpeQGMDKSGDJwyHBPSRv", - "N+srX9VhsTzfgb3FERMYRfgOuUfBAGW/yvLQTtcJyiAoL0fq4SQd0uXm+2VEdYaAMy6/lpBPUK2NuC9H", - "rOjjPpwQr1BsyIQ+TQZIeTnm5Fh1y6FkUV0DtJkV02wmAZ4yV4C1Y/LO1MKH3t9/Kvl/3zSJPyQm+V0u", - "7C+N+D88B9uw/5+HF5+HTp/LrfLIciyd7BdqfgfUJS0gWYFQrsgkJZ/B2IDI9VV2lbmIlQZJNSc5eeOP", - "Ur/ZewwWOnAwzEkVuO1A8rvTmpGchB5tuTrcMw9zRB+tm4u/uYeeLns32fVL7aaxvS5EwRLdN+Uyy+Yz", - "bCJdPLGT+ucG3VmH+Kat85077cq+sEBNsXdudhAp/q0Xd8rp6BXuYzy2/spi7o2jTp+p6t/Pvkrbj5n6", - "bgJ29lJg+4kdAdydJ2qbdB1wIcSPnNVhfJYQnn1jyFf+vNF+d1ivzoT6ehUr+3I6yUMA7Cz1cCaWL1HS", - "NN4vPwNeqnLZJafTLZe7Es46nF4QGl1FoPmg2eg5clZkLvVIuijooWLsdaDuZlxlHVTtgJtrULcpvsLu", - "9GvvfGtWFsw/ECEXtYenPzvOfI/znx8TKlkiqKQ7ECAxAcm04tLvHs13yU3/wIy+BNCiUJVE9xJgOHym", - "ZcxIiKu+q/8KAAD//yYnwMOkFgAA", + "H4sIAAAAAAAC/+xaW2/bthf/KgL//0ctVhJvGPTW1sMQoMNSBH0qgoCRjh124iUUVdQz9N0HXnSzKNtx", + "Zc1Z+xSHl8Nzzu/ceMQNSjgVnAFTOYo3SGCJKSiQ5r9m7qGZeHjHKQWmbhZ6CWEoRgKrJxQihimgWO8y", + "8ykKkYTngkhIUaxkASHKkyegWG9cckmxQjEiTP0yRyGihBFaUBRfhkitBdgpWIFEZRkO8DLIBJni9Fu8", + "gvr85wLkumFA6Ln2kSkscZEpc8LRp92Rv3eeaOa9p15FIaL4qzs2io5lQvK0SIbBF25+CvV/GNLEc0cF", + "lLD3wFbqCcXXNfVcScJWO4h/zEHeLD4YwgPHFHrJmIKWlhTk6i1PCWz7YGeucsN3ErACu5IpYEr/xEJk", + "JMGKcDb7nHOmxxqm/i9hiWL0v1lDe2Zn679b1A1rKeSJJEITRTFyC4JEryCcBQKvM4776uhq2CvDR5Ge", + "UAZHfYcMhVlxlATOH06EQpe6RwK34AAUjG3lgrPcY1du/OE3KbkcXQpL1cO9mQiq440zuj2atEPHpCXJ", + "BUhVu0TNHMVfK9++iua/hm1nv+w5e4iMniB9ozrOqdH/SRGqQ2dvC0m9jrztvGEr9B223lrdi3hxEecQ", + "+mXbAj7ZfNgOznXwqrTZVk6bufuaNn/8DInSfDho3pPcAw9RQLs/XuCsmro7DkuJ131BDFEfU84XPAwd", + "ioiN6x2ruryK9hqVkCQBl2lscI/qVaygj0NwmOOq7TskGlPNlZJ6ag5txRJvfJbdqjz6s4ornPmmvMiF", + "VWXUKlcsBZ8GdBa2ZUw7bujRQEi+JBkEElQhGaTB4zpQTxC8ub25sLbeDhl7/V4CTv9k2brK4T2UgWKS", + "dbbbkbAThH6eHx5CBo78VoPcYWoVx406fErfdskfAfjsArC/TBsTqOMl7Ql2gADnlUn8BeQr1G5dz20z", + "nrZjecNWCgqTrKvy7tYlgSz17pWAXXXYj0c9BrdzD4U876afgWhmWG/W7xL+v1cP+K8FPfmmY/ulHH/H", + "lUzF9o+KZvKKRu8nbMkNskRleu4PLP8CJTKcQLAAyrWSUYi+gMwtIpcX0UWkOeYCGBYExejaDIWm42Uw", + "mLl8buMkt7atQTK3ZR3fkfXRylbb/Z31kKF3GgyzAzsP29f7q+jyVN0IbyfFqj8QjVPOo2hYQsfpbEcX", + "wuQNvMo14reVnu/1aK32WQ5YJk/6mBV4lH9npuvNYae9/cnPW7NkNtR1LMMjt5p+8TftNmGmvO+BHZ0K", + "bBOxPYDr8YAvg9oDJkJ8Q9LShs8MbO7rQr4w42732/XNYiTUbxY+tc/7kdwykI6iD01ifgqVhn5/+R3U", + "VJqLpoxOd4StMhg1OJ0QGlF4oLF3kCaPjIrMVClpUtCtxtLXgXo3xtVtgnLmPqbmgzlOB+JK79Xaccyj", + "+dQ3if+2OwHD32ryYMnlGWNaY2A8eX9JWDUrTgPZWI699TVwglqzbuIM15pJ0+c5XyPY69izTf1e4vDC", + "5mRWc2xt2rwJOZpE+4P7v1NqXZ9vINlbEnwfFjFyPKteBkyX3XYVKmPHs+szDolFrmGvLnNDlxFtAK/w", + "JmJafMPXkCIHeYZJS3Nt4GnGNgOvTcw7qACzNKCY4RWYRzTAUsGJLT/dA6nbpjngbXjiJOEFU4EEJQl8", + "wZmPiOWrT6F++uIe8exmpba/8r78JwAA//+6mIfu9SgAAA==", } // GetSwagger returns the content of the embedded swagger specification file From e2d19c96416978004dba5c6b5d1afbc60445e4f4 Mon Sep 17 00:00:00 2001 From: huayitang Date: Sun, 5 Oct 2025 20:24:28 +0200 Subject: [PATCH 07/11] =?UTF-8?q?=EF=BC=9A=E6=8A=8A=E6=96=B0=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=E6=8E=A5=E5=88=B0=20HTTP=20=E5=B1=82=EF=BC=8C?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=94=99=E8=AF=AF=E6=98=A0=E5=B0=84=E5=92=8C?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E6=A0=BC=E5=BC=8F=EF=BC=8C=E5=90=8C=E6=97=B6?= =?UTF-8?q?=E5=9C=A8=E4=B8=BB=E7=A8=8B=E5=BA=8F/=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E4=B8=AD=E6=B3=A8=E5=85=A5=20comment=20servi?= =?UTF-8?q?ce=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/inbound/http/handler_comment.go | 75 +++++++++++ .../adapters/inbound/http/presenters.go | 25 ++++ .../adapters/inbound/http/request_mappers.go | 14 ++ .../adapters/inbound/http/response_helpers.go | 110 +++++++++++++++ .../outbound/inmem/product_repository.go | 4 +- .../application/comment/service.go | 127 ++++++++++++++++++ .../ports/inbound/comment.go | 15 +++ 7 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 apps/product-query-svc/adapters/inbound/http/handler_comment.go create mode 100644 apps/product-query-svc/application/comment/service.go create mode 100644 apps/product-query-svc/ports/inbound/comment.go diff --git a/apps/product-query-svc/adapters/inbound/http/handler_comment.go b/apps/product-query-svc/adapters/inbound/http/handler_comment.go new file mode 100644 index 0000000..71617d7 --- /dev/null +++ b/apps/product-query-svc/adapters/inbound/http/handler_comment.go @@ -0,0 +1,75 @@ +package httpadapter + +import ( + "context" + + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" +) + +func (s *Server) ListProductComments(ctx context.Context, request ListProductCommentsRequestObject) (ListProductCommentsResponseObject, error) { + comments, err := s.comments.ListByProduct(ctx, request.ProductId) + if err != nil { + if resp, handled := listCommentsError(err); handled { + return resp, nil + } + return nil, err + } + return okListComments(comments), nil +} + +func (s *Server) CreateProductComment(ctx context.Context, request CreateProductCommentRequestObject) (CreateProductCommentResponseObject, error) { + userID, content, err := commentCreateInput(request.Body) + if err != nil { + if resp, handled := createCommentError(err); handled { + return resp, nil + } + return nil, err + } + + comment, err := s.comments.Create(ctx, request.ProductId, userID, content) + if err != nil { + if resp, handled := createCommentError(err); handled { + return resp, nil + } + return nil, err + } + + return okCreateComment(comment), nil +} + +func (s *Server) UpdateProductComment(ctx context.Context, request UpdateProductCommentRequestObject) (UpdateProductCommentResponseObject, error) { + userID, content, err := commentUpdateInput(request.Body) + if err != nil { + if resp, handled := updateCommentError(err); handled { + return resp, nil + } + return nil, err + } + if request.Params.UserId != 0 && request.Params.UserId != userID { + if resp, handled := updateCommentError(domain.ValidationError("user id mismatch")); handled { + return resp, nil + } + return nil, domain.ValidationError("user id mismatch") + } + + updated, err := s.comments.Update(ctx, request.ProductId, request.CommentId, userID, content) + if err != nil { + if resp, handled := updateCommentError(err); handled { + return resp, nil + } + return nil, err + } + + return okUpdateComment(updated), nil +} + +func (s *Server) DeleteProductComment(ctx context.Context, request DeleteProductCommentRequestObject) (DeleteProductCommentResponseObject, error) { + if err := s.comments.Delete(ctx, request.ProductId, request.CommentId, request.Params.UserId); err != nil { + if resp, handled := deleteCommentError(err); handled { + return resp, nil + } + return nil, err + } + + return okDeleteComment(), nil +} diff --git a/apps/product-query-svc/adapters/inbound/http/presenters.go b/apps/product-query-svc/adapters/inbound/http/presenters.go index ab5c40b..f695728 100644 --- a/apps/product-query-svc/adapters/inbound/http/presenters.go +++ b/apps/product-query-svc/adapters/inbound/http/presenters.go @@ -50,3 +50,28 @@ func presentUser(u *domain.User) User { CreatedAt: &createdAt, } } + +func presentComment(c *domain.Comment) Comment { + if c == nil { + return Comment{} + } + return Comment{ + Id: c.ID, + ProductId: c.ProductID, + UserId: c.UserID, + Content: c.Content, + CreatedAt: c.CreatedAt.UTC(), + UpdatedAt: c.UpdatedAt.UTC(), + } +} + +func presentComments(items []domain.Comment) []Comment { + if len(items) == 0 { + return []Comment{} + } + out := make([]Comment, 0, len(items)) + for i := range items { + out = append(out, presentComment(&items[i])) + } + return out +} diff --git a/apps/product-query-svc/adapters/inbound/http/request_mappers.go b/apps/product-query-svc/adapters/inbound/http/request_mappers.go index 637d3ae..f9b6c78 100644 --- a/apps/product-query-svc/adapters/inbound/http/request_mappers.go +++ b/apps/product-query-svc/adapters/inbound/http/request_mappers.go @@ -50,3 +50,17 @@ func newProductFromUpdateBody(id int64, body *UpdateProductJSONRequestBody) (*do product.ID = id return product, nil } + +func commentCreateInput(body *CreateProductCommentJSONRequestBody) (int64, string, error) { + if body == nil { + return 0, "", domain.ValidationError("invalid request body") + } + return body.UserId, body.Content, nil +} + +func commentUpdateInput(body *UpdateProductCommentJSONRequestBody) (int64, string, error) { + if body == nil { + return 0, "", domain.ValidationError("invalid request body") + } + return body.UserId, body.Content, nil +} diff --git a/apps/product-query-svc/adapters/inbound/http/response_helpers.go b/apps/product-query-svc/adapters/inbound/http/response_helpers.go index d58bac0..33641a1 100644 --- a/apps/product-query-svc/adapters/inbound/http/response_helpers.go +++ b/apps/product-query-svc/adapters/inbound/http/response_helpers.go @@ -34,6 +34,8 @@ func classifyDomainError(err error) (int, string) { return http.StatusBadRequest, "VALIDATION" case errors.Is(err, domain.ErrNotFound): return http.StatusNotFound, "NOT_FOUND" + case errors.Is(err, domain.ErrForbidden): + return http.StatusForbidden, "FORBIDDEN" default: return http.StatusInternalServerError, "INTERNAL" } @@ -190,3 +192,111 @@ func okSearchProducts(items []domain.Product, page, pageSize, total int) SearchP func okGetUser(user *domain.User) GetUserByIDResponseObject { return GetUserByID200JSONResponse(presentUser(user)) } + +func listCommentsError(err error) (ListProductCommentsResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return ListProductComments400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusNotFound: + return ListProductComments404JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + default: + return nil, false + } +} + +func createCommentError(err error) (CreateProductCommentResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return CreateProductComment400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusNotFound: + return CreateProductComment404JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + default: + return nil, false + } +} + +func updateCommentError(err error) (UpdateProductCommentResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return UpdateProductComment400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusForbidden: + return UpdateProductComment403JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusNotFound: + return UpdateProductComment404JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + default: + return nil, false + } +} + +func deleteCommentError(err error) (DeleteProductCommentResponseObject, bool) { + status, payload := errorPayloadFromDomain(err) + switch status { + case http.StatusBadRequest: + return DeleteProductComment400JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusForbidden: + return DeleteProductComment403JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + case http.StatusNotFound: + return DeleteProductComment404JSONResponse{ + Code: payload.Code, + Message: payload.Message, + Details: payload.Details, + }, true + default: + return nil, false + } +} + +func okListComments(items []domain.Comment) ListProductCommentsResponseObject { + return ListProductComments200JSONResponse(CommentList{Items: presentComments(items)}) +} + +func okCreateComment(comment *domain.Comment) CreateProductCommentResponseObject { + return CreateProductComment201JSONResponse(presentComment(comment)) +} + +func okUpdateComment(comment *domain.Comment) UpdateProductCommentResponseObject { + return UpdateProductComment200JSONResponse(presentComment(comment)) +} + +func okDeleteComment() DeleteProductCommentResponseObject { + return DeleteProductComment204Response{} +} diff --git a/apps/product-query-svc/adapters/outbound/inmem/product_repository.go b/apps/product-query-svc/adapters/outbound/inmem/product_repository.go index 4d8bab0..ec72896 100644 --- a/apps/product-query-svc/adapters/outbound/inmem/product_repository.go +++ b/apps/product-query-svc/adapters/outbound/inmem/product_repository.go @@ -83,9 +83,9 @@ func (r *InMemRepo) ListCommentsByProduct(ctx context.Context, productID int64) } sort.Slice(out, func(i, j int) bool { if out[i].CreatedAt.Equal(out[j].CreatedAt) { - return out[i].ID < out[j].ID + return out[i].ID > out[j].ID } - return out[i].CreatedAt.Before(out[j].CreatedAt) + return out[i].CreatedAt.After(out[j].CreatedAt) }) return out, nil } diff --git a/apps/product-query-svc/application/comment/service.go b/apps/product-query-svc/application/comment/service.go new file mode 100644 index 0000000..4da1814 --- /dev/null +++ b/apps/product-query-svc/application/comment/service.go @@ -0,0 +1,127 @@ +package commentapp + +import ( + "context" + "strings" + + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + "github.com/fightingBald/GoTuto/apps/product-query-svc/ports/inbound" + "github.com/fightingBald/GoTuto/apps/product-query-svc/ports/outbound" +) + +var _ inbound.CommentUseCases = (*Service)(nil) + +// Service coordinates comment operations against domain rules and persistence. +type Service struct { + comments outbound.CommentRepository + products outbound.ProductRepository + users outbound.UserRepository +} + +func NewService(comments outbound.CommentRepository, products outbound.ProductRepository, users outbound.UserRepository) *Service { + return &Service{comments: comments, products: products, users: users} +} + +func (s *Service) ListByProduct(ctx context.Context, productID int64) ([]domain.Comment, error) { + if productID <= 0 { + return nil, domain.ValidationError("product id must be a positive integer") + } + if _, err := s.products.GetByID(ctx, productID); err != nil { + return nil, err + } + return s.comments.ListCommentsByProduct(ctx, productID) +} + +func (s *Service) Create(ctx context.Context, productID, userID int64, content string) (*domain.Comment, error) { + if productID <= 0 { + return nil, domain.ValidationError("product id must be a positive integer") + } + if userID <= 0 { + return nil, domain.ValidationError("user id must be a positive integer") + } + + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return nil, domain.ValidationError("content required") + } + + if _, err := s.products.GetByID(ctx, productID); err != nil { + return nil, err + } + if _, err := s.users.FindByID(ctx, userID); err != nil { + return nil, err + } + + comment, err := domain.NewComment(productID, userID, trimmed) + if err != nil { + return nil, err + } + + id, err := s.comments.CreateComment(ctx, comment) + if err != nil { + return nil, err + } + comment.ID = id + return comment, nil +} + +func (s *Service) Update(ctx context.Context, productID, commentID, userID int64, content string) (*domain.Comment, error) { + if productID <= 0 { + return nil, domain.ValidationError("product id must be a positive integer") + } + if commentID <= 0 { + return nil, domain.ValidationError("comment id must be a positive integer") + } + if userID <= 0 { + return nil, domain.ValidationError("user id must be a positive integer") + } + + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return nil, domain.ValidationError("content required") + } + + existing, err := s.comments.GetCommentByID(ctx, commentID) + if err != nil { + return nil, err + } + if existing.ProductID != productID { + return nil, domain.ErrNotFound + } + if existing.UserID != userID { + return nil, domain.ForbiddenError("cannot modify another user's comment") + } + + if err := existing.UpdateContent(trimmed); err != nil { + return nil, err + } + if err := s.comments.UpdateComment(ctx, existing); err != nil { + return nil, err + } + return existing, nil +} + +func (s *Service) Delete(ctx context.Context, productID, commentID, userID int64) error { + if productID <= 0 { + return domain.ValidationError("product id must be a positive integer") + } + if commentID <= 0 { + return domain.ValidationError("comment id must be a positive integer") + } + if userID <= 0 { + return domain.ValidationError("user id must be a positive integer") + } + + existing, err := s.comments.GetCommentByID(ctx, commentID) + if err != nil { + return err + } + if existing.ProductID != productID { + return domain.ErrNotFound + } + if existing.UserID != userID { + return domain.ForbiddenError("cannot delete another user's comment") + } + + return s.comments.DeleteComment(ctx, commentID) +} diff --git a/apps/product-query-svc/ports/inbound/comment.go b/apps/product-query-svc/ports/inbound/comment.go new file mode 100644 index 0000000..7729b23 --- /dev/null +++ b/apps/product-query-svc/ports/inbound/comment.go @@ -0,0 +1,15 @@ +package inbound + +import ( + "context" + + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" +) + +// CommentUseCases exposes comment workflows to inbound adapters. +type CommentUseCases interface { + ListByProduct(ctx context.Context, productID int64) ([]domain.Comment, error) + Create(ctx context.Context, productID, userID int64, content string) (*domain.Comment, error) + Update(ctx context.Context, productID, commentID, userID int64, content string) (*domain.Comment, error) + Delete(ctx context.Context, productID, commentID, userID int64) error +} From fc6171c696f425059d4efc326c4823371b30879e Mon Sep 17 00:00:00 2001 From: huayitang Date: Sun, 5 Oct 2025 21:09:21 +0200 Subject: [PATCH 08/11] rest --- .../adapters/inbound/http/handler_comment.go | 1 + .../adapters/inbound/http/server.go | 5 +++-- apps/product-query-svc/domain/errors.go | 6 ++++++ backend/cmd/marketplace/product-query-svc/main.go | 13 +++++++++---- internal/testutil/httpserver.go | 10 ++++++---- test/http_inmem/delete_test.go | 2 +- test/http_inmem/search_test.go | 8 ++++---- test/http_inmem/user_test.go | 2 +- test/http_pg/create_test.go | 5 ++++- test/http_pg/search_test.go | 5 ++++- test/http_pg/user_test.go | 5 ++++- 11 files changed, 43 insertions(+), 19 deletions(-) diff --git a/apps/product-query-svc/adapters/inbound/http/handler_comment.go b/apps/product-query-svc/adapters/inbound/http/handler_comment.go index 71617d7..19e08ff 100644 --- a/apps/product-query-svc/adapters/inbound/http/handler_comment.go +++ b/apps/product-query-svc/adapters/inbound/http/handler_comment.go @@ -19,6 +19,7 @@ func (s *Server) ListProductComments(ctx context.Context, request ListProductCom func (s *Server) CreateProductComment(ctx context.Context, request CreateProductCommentRequestObject) (CreateProductCommentResponseObject, error) { userID, content, err := commentCreateInput(request.Body) + if err != nil { if resp, handled := createCommentError(err); handled { return resp, nil diff --git a/apps/product-query-svc/adapters/inbound/http/server.go b/apps/product-query-svc/adapters/inbound/http/server.go index 38ed056..d68b5a8 100644 --- a/apps/product-query-svc/adapters/inbound/http/server.go +++ b/apps/product-query-svc/adapters/inbound/http/server.go @@ -10,10 +10,11 @@ import ( type Server struct { products inbound.ProductUseCases users inbound.UserQueries + comments inbound.CommentUseCases } -func NewServer(products inbound.ProductUseCases, users inbound.UserQueries) *Server { - return &Server{products: products, users: users} +func NewServer(products inbound.ProductUseCases, users inbound.UserQueries, comments inbound.CommentUseCases) *Server { + return &Server{products: products, users: users, comments: comments} } var _ StrictServerInterface = (*Server)(nil) diff --git a/apps/product-query-svc/domain/errors.go b/apps/product-query-svc/domain/errors.go index cf33e5c..5e361dc 100644 --- a/apps/product-query-svc/domain/errors.go +++ b/apps/product-query-svc/domain/errors.go @@ -5,9 +5,15 @@ import "errors" var ( ErrValidation = errors.New("validation error") ErrNotFound = errors.New("not found") + ErrForbidden = errors.New("forbidden") ) // ValidationError wraps ErrValidation with a more specific message. func ValidationError(msg string) error { return errors.Join(ErrValidation, errors.New(msg)) } + +// ForbiddenError wraps ErrForbidden to indicate authorization failures. +func ForbiddenError(msg string) error { + return errors.Join(ErrForbidden, errors.New(msg)) +} diff --git a/backend/cmd/marketplace/product-query-svc/main.go b/backend/cmd/marketplace/product-query-svc/main.go index f5d6440..1f5135e 100644 --- a/backend/cmd/marketplace/product-query-svc/main.go +++ b/backend/cmd/marketplace/product-query-svc/main.go @@ -13,6 +13,7 @@ import ( appshttp "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" appsinmem "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/inmem" appspg "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/postgres" + commentapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/comment" productapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/product" userapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/user" "github.com/fightingBald/GoTuto/apps/product-query-svc/ports/outbound" @@ -40,9 +41,10 @@ func main() { log.Println("starting product-query-svc") var ( - repo outbound.ProductRepository - userRepo outbound.UserRepository - pool *pgxpool.Pool + repo outbound.ProductRepository + userRepo outbound.UserRepository + commentRepo outbound.CommentRepository + pool *pgxpool.Pool ) // If DSN provided, use Postgres wiring @@ -58,17 +60,20 @@ func main() { repo = appspg.NewProductRepository(pool) userRepo = appspg.NewUserRepository(pool) + commentRepo = appspg.NewCommentRepository(pool) } else { store := appsinmem.NewInMemRepo() repo = store userRepo = store + commentRepo = store } // build service productSvc := productapp.NewService(repo) userSvc := userapp.NewService(userRepo) + commentSvc := commentapp.NewService(commentRepo, repo, userRepo) - server := appshttp.NewServer(productSvc, userSvc) + server := appshttp.NewServer(productSvc, userSvc, commentSvc) apiHandler, err := appshttp.NewAPIHandler(server, nil) if err != nil { diff --git a/internal/testutil/httpserver.go b/internal/testutil/httpserver.go index aad902f..fec7b2c 100644 --- a/internal/testutil/httpserver.go +++ b/internal/testutil/httpserver.go @@ -5,16 +5,18 @@ import ( "net/http/httptest" httpadapter "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" + commentapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/comment" productapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/product" userapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/user" "github.com/fightingBald/GoTuto/apps/product-query-svc/ports/outbound" ) // NewHTTPHandler wires repos -> services -> HTTP handler. -func NewHTTPHandler(productRepo outbound.ProductRepository, userRepo outbound.UserRepository) http.Handler { +func NewHTTPHandler(productRepo outbound.ProductRepository, userRepo outbound.UserRepository, commentRepo outbound.CommentRepository) http.Handler { productSvc := productapp.NewService(productRepo) userSvc := userapp.NewService(userRepo) - server := httpadapter.NewServer(productSvc, userSvc) + commentSvc := commentapp.NewService(commentRepo, productRepo, userRepo) + server := httpadapter.NewServer(productSvc, userSvc, commentSvc) h, err := httpadapter.NewAPIHandler(server, nil) if err != nil { panic(err) @@ -23,7 +25,7 @@ func NewHTTPHandler(productRepo outbound.ProductRepository, userRepo outbound.Us } // NewHTTPServer starts an httptest.Server for convenience. -func NewHTTPServer(productRepo outbound.ProductRepository, userRepo outbound.UserRepository) *httptest.Server { - h := NewHTTPHandler(productRepo, userRepo) +func NewHTTPServer(productRepo outbound.ProductRepository, userRepo outbound.UserRepository, commentRepo outbound.CommentRepository) *httptest.Server { + h := NewHTTPHandler(productRepo, userRepo, commentRepo) return httptest.NewServer(h) } diff --git a/test/http_inmem/delete_test.go b/test/http_inmem/delete_test.go index f471a47..dc14e1b 100644 --- a/test/http_inmem/delete_test.go +++ b/test/http_inmem/delete_test.go @@ -10,7 +10,7 @@ import ( func TestDeleteProduct_InMem(t *testing.T) { store := appsinmem.NewInMemRepo() - ts := testutil.NewHTTPServer(store, store) + ts := testutil.NewHTTPServer(store, store, store) defer ts.Close() t.Run("delete id=1 returns 204", func(t *testing.T) { diff --git a/test/http_inmem/search_test.go b/test/http_inmem/search_test.go index ade314d..1b26c68 100644 --- a/test/http_inmem/search_test.go +++ b/test/http_inmem/search_test.go @@ -14,7 +14,7 @@ import ( func TestHTTP_InMem_Product(t *testing.T) { t.Run("search returns items", func(t *testing.T) { store := appsinmem.NewInMemRepo() - ts := testutil.NewHTTPServer(store, store) + ts := testutil.NewHTTPServer(store, store, store) defer ts.Close() resp, err := http.Get(ts.URL + "/products/search?q=wid&page=1&pageSize=10") @@ -36,7 +36,7 @@ func TestHTTP_InMem_Product(t *testing.T) { t.Run("get id=1 returns product", func(t *testing.T) { store := appsinmem.NewInMemRepo() - ts := testutil.NewHTTPServer(store, store) + ts := testutil.NewHTTPServer(store, store, store) defer ts.Close() resp, err := http.Get(ts.URL + "/products/1") @@ -58,7 +58,7 @@ func TestHTTP_InMem_Product(t *testing.T) { t.Run("update id=1 returns updated product", func(t *testing.T) { store := appsinmem.NewInMemRepo() - ts := testutil.NewHTTPServer(store, store) + ts := testutil.NewHTTPServer(store, store, store) defer ts.Close() body := `{"name":"Updated Widget","price":15.25}` @@ -83,7 +83,7 @@ func TestHTTP_InMem_Product(t *testing.T) { t.Run("search with short q returns 400", func(t *testing.T) { store := appsinmem.NewInMemRepo() - ts := testutil.NewHTTPServer(store, store) + ts := testutil.NewHTTPServer(store, store, store) defer ts.Close() resp, err := http.Get(ts.URL + "/products/search?q=ab") diff --git a/test/http_inmem/user_test.go b/test/http_inmem/user_test.go index 96d5a75..29d128e 100644 --- a/test/http_inmem/user_test.go +++ b/test/http_inmem/user_test.go @@ -12,7 +12,7 @@ import ( func TestGetUserByID_InMem(t *testing.T) { store := appsinmem.NewInMemRepo() - ts := testutil.NewHTTPServer(store, store) + ts := testutil.NewHTTPServer(store, store, store) t.Cleanup(ts.Close) resp, err := http.Get(ts.URL + "/users/1") diff --git a/test/http_pg/create_test.go b/test/http_pg/create_test.go index e296dbb..2bd59a1 100644 --- a/test/http_pg/create_test.go +++ b/test/http_pg/create_test.go @@ -12,6 +12,7 @@ import ( appshttp "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" appspg "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/postgres" + commentapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/comment" productapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/product" userapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/user" "github.com/fightingBald/GoTuto/internal/testutil" @@ -32,7 +33,9 @@ func TestCreateProduct_Postgres(t *testing.T) { userRepo := appspg.NewUserRepository(pool) productSvc := productapp.NewService(productRepo) userSvc := userapp.NewService(userRepo) - server := appshttp.NewServer(productSvc, userSvc) + commentRepo := appspg.NewCommentRepository(pool) + commentSvc := commentapp.NewService(commentRepo, productRepo, userRepo) + server := appshttp.NewServer(productSvc, userSvc, commentSvc) h, err := appshttp.NewAPIHandler(server, nil) if err != nil { diff --git a/test/http_pg/search_test.go b/test/http_pg/search_test.go index 37af9fd..c364641 100644 --- a/test/http_pg/search_test.go +++ b/test/http_pg/search_test.go @@ -10,6 +10,7 @@ import ( appshttp "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" appspg "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/postgres" + commentapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/comment" productapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/product" userapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/user" "github.com/fightingBald/GoTuto/internal/testutil" @@ -31,7 +32,9 @@ func TestSearchProducts_Postgres(t *testing.T) { userRepo := appspg.NewUserRepository(pool) productSvc := productapp.NewService(productRepo) userSvc := userapp.NewService(userRepo) - server := appshttp.NewServer(productSvc, userSvc) + commentRepo := appspg.NewCommentRepository(pool) + commentSvc := commentapp.NewService(commentRepo, productRepo, userRepo) + server := appshttp.NewServer(productSvc, userSvc, commentSvc) h, err := appshttp.NewAPIHandler(server, nil) if err != nil { diff --git a/test/http_pg/user_test.go b/test/http_pg/user_test.go index af69cca..d3fd5cf 100644 --- a/test/http_pg/user_test.go +++ b/test/http_pg/user_test.go @@ -13,6 +13,7 @@ import ( appshttp "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/inbound/http" appspg "github.com/fightingBald/GoTuto/apps/product-query-svc/adapters/outbound/postgres" + commentapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/comment" productapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/product" userapp "github.com/fightingBald/GoTuto/apps/product-query-svc/application/user" "github.com/fightingBald/GoTuto/internal/testutil" @@ -54,7 +55,9 @@ func TestGetUserByID_Postgres(t *testing.T) { userRepo := appspg.NewUserRepository(pool) productSvc := productapp.NewService(productRepo) userSvc := userapp.NewService(userRepo) - server := appshttp.NewServer(productSvc, userSvc) + commentRepo := appspg.NewCommentRepository(pool) + commentSvc := commentapp.NewService(commentRepo, productRepo, userRepo) + server := appshttp.NewServer(productSvc, userSvc, commentSvc) h, err := appshttp.NewAPIHandler(server, nil) if err != nil { From 16c814a29557d292693c0d17bbe1d4287bb12e5d Mon Sep 17 00:00:00 2001 From: huayitang Date: Sun, 5 Oct 2025 21:18:38 +0200 Subject: [PATCH 09/11] add repo test --- .../postgres/comment_repository_test.go | 159 ++++++++++++++++++ readme.md | 35 +++- 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 apps/product-query-svc/adapters/outbound/postgres/comment_repository_test.go diff --git a/apps/product-query-svc/adapters/outbound/postgres/comment_repository_test.go b/apps/product-query-svc/adapters/outbound/postgres/comment_repository_test.go new file mode 100644 index 0000000..d7a0d96 --- /dev/null +++ b/apps/product-query-svc/adapters/outbound/postgres/comment_repository_test.go @@ -0,0 +1,159 @@ +//go:build docker + +package postgres + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/fightingBald/GoTuto/apps/product-query-svc/domain" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +func setupCommentRepo(t *testing.T) (*pgxpool.Pool, func()) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + + req := testcontainers.ContainerRequest{ + Image: "postgres:16-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_USER": "app", + "POSTGRES_PASSWORD": "app_password", + "POSTGRES_DB": "productdb", + }, + WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(60 * time.Second), + } + + pgC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ContainerRequest: req, Started: true}) + if err != nil { + cancel() + t.Fatalf("start container: %v", err) + } + + cleanup := func() { + cancel() + _ = pgC.Terminate(context.Background()) + } + + host, err := pgC.Host(ctx) + if err != nil { + cleanup() + t.Fatalf("host: %v", err) + } + port, err := pgC.MappedPort(ctx, "5432/tcp") + if err != nil { + cleanup() + t.Fatalf("mapped port: %v", err) + } + + dsn := fmt.Sprintf("postgres://app:app_password@%s:%s/productdb?sslmode=disable", host, port.Port()) + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + cleanup() + t.Fatalf("pgxpool.New: %v", err) + } + + applyMigrations(ctx, pool, "migrations", t) + + return pool, func() { + pool.Close() + cleanup() + } +} + +func TestCommentRepository_WithDocker(t *testing.T) { + if os.Getenv("SKIP_DOCKER_TESTS") == "1" { + t.Skip("skipped by SKIP_DOCKER_TESTS=1") + } + + pool, teardown := setupCommentRepo(t) + defer teardown() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + productRepo := NewProductRepository(pool) + commentRepo := NewCommentRepository(pool) + + product, err := domain.NewProduct("Fixture Gadget", 1299, nil) + if err != nil { + t.Fatalf("new product: %v", err) + } + productID, err := productRepo.Create(ctx, product) + if err != nil { + t.Fatalf("create product: %v", err) + } + + var userID int64 + email := fmt.Sprintf("commenter-%d@example.com", time.Now().UnixNano()) + if err := pool.QueryRow(ctx, "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id", "Comment User", email).Scan(&userID); err != nil { + t.Fatalf("insert user: %v", err) + } + + first := &domain.Comment{ProductID: productID, UserID: userID, Content: "first"} + firstID, err := commentRepo.CreateComment(ctx, first) + if err != nil { + t.Fatalf("create comment: %v", err) + } + if firstID == 0 { + t.Fatalf("expected comment id to be set") + } + if first.CreatedAt.IsZero() || first.UpdatedAt.IsZero() { + t.Fatalf("expected timestamps to be populated: %#v", first) + } + + fetched, err := commentRepo.GetCommentByID(ctx, firstID) + if err != nil { + t.Fatalf("get comment: %v", err) + } + if fetched.Content != "first" || fetched.ProductID != productID || fetched.UserID != userID { + t.Fatalf("unexpected fetched comment: %#v", fetched) + } + + if err := first.UpdateContent("updated content"); err != nil { + t.Fatalf("update content domain: %v", err) + } + if err := commentRepo.UpdateComment(ctx, first); err != nil { + t.Fatalf("update comment: %v", err) + } + + updated, err := commentRepo.GetCommentByID(ctx, firstID) + if err != nil { + t.Fatalf("get updated comment: %v", err) + } + if updated.Content != "updated content" { + t.Fatalf("expected updated content, got %#v", updated) + } + + second := &domain.Comment{ProductID: productID, UserID: userID, Content: "second"} + secondID, err := commentRepo.CreateComment(ctx, second) + if err != nil { + t.Fatalf("create second comment: %v", err) + } + + list, err := commentRepo.ListCommentsByProduct(ctx, productID) + if err != nil { + t.Fatalf("list comments: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected 2 comments, got %d", len(list)) + } + if list[0].ID != secondID { + t.Fatalf("expected newest comment first, got order %#v", list) + } + + if err := commentRepo.DeleteComment(ctx, firstID); err != nil { + t.Fatalf("delete comment: %v", err) + } + if _, err := commentRepo.GetCommentByID(ctx, firstID); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} diff --git a/readme.md b/readme.md index 5bd3020..b31d927 100644 --- a/readme.md +++ b/readme.md @@ -3,6 +3,7 @@ [![Go CI](https://github.com/fightingBald/GoTuto/actions/workflows/go.yml/badge.svg?branch=main)](https://github.com/fightingBald/GoTuto/actions/workflows/go.yml) 简短说明:本仓库包含一个示例后端服务 product-query-svc(支持 in-memory 与 Postgres),数据库迁移需通过 golang-migrate 执行(不再使用嵌入式迁移),以及用于本地开发的 Tilt + kind 配置与最小 Helm chart(已补全)。 +服务目前提供商品 CRUD 及评论功能,评论支持多用户查看、作者更新/删除,并通过 OpenAPI 严格校验暴露接口。 --- @@ -23,7 +24,9 @@ │ └── product-query-svc/ # 应用层与适配器 │ ├── domain/ # 领域模型与领域错误 │ ├── ports/ # 端口(接口),抽象仓储与服务 -│ ├── app/ # 应用服务实现(业务编排) +│ ├── application/ # 应用服务实现(业务编排) +│ │ ├── product/ # 商品相关用例 +│ │ └── comment/ # 商品评论用例 │ └── adapters/ │ ├── inbound/http/ # OpenAPI 严格服务 + 路由装配 + 轻量 handler │ └── outbound/ @@ -125,6 +128,35 @@ echo; \ curl -i http://localhost:8080/products/$ID # 期望 404 ``` +7) POST /products/{id}/comments(新增评论,需提供已有用户 ID) + +```sh +COMMENT_ID=$(curl -s -X POST http://localhost:8080/products/1/comments \ + -H 'Content-Type: application/json' \ + -d '{"userId":1,"content":"Great product!"}' | jq -r '.id'); \ +echo "comment id=$COMMENT_ID" +``` + +8) GET /products/{id}/comments(查看评论列表,默认按创建时间倒序) + +```sh +curl -s http://localhost:8080/products/1/comments | jq +``` + +9) PUT /products/{id}/comments/{commentId}(更新评论内容,`userId` 需放在查询参数且与原作者一致) + +```sh +curl -s -X PUT "http://localhost:8080/products/1/comments/${COMMENT_ID}?userId=1" \ + -H 'Content-Type: application/json' \ + -d '{"userId":1,"content":"Updated feedback"}' | jq +``` + +10) DELETE /products/{id}/comments/{commentId}(删除评论,同样需要 `userId` 查询参数) + +```sh +curl -i -X DELETE "http://localhost:8080/products/1/comments/${COMMENT_ID}?userId=1" +``` +
@@ -148,6 +180,7 @@ bash scripts/test-integration-docker.sh ./test -run Postgres - 使用 `docker run -P` 启动 postgres:16-alpine,随机映射宿主端口,避免与 Tilt 的 5432 冲突。 - 通过 `migrate/migrate` 容器在同一网络命名空间内执行迁移。 - 自动导出 `DATABASE_URL` 为宿主上的随机端口,并运行 go test。 +- 需要单独验证仓储层(含评论 CRUD)的 Docker 集成测试时,可运行 `go test -tags docker ./apps/product-query-svc/adapters/outbound/postgres -run TestCommentRepository_WithDocker -count=1`,确保本机 Docker 可用;若暂不具备条件,可设置 `SKIP_DOCKER_TESTS=1` 跳过。
From 2dcb54bba00ec9ef8de080c9d92209eee85ff732 Mon Sep 17 00:00:00 2001 From: huayitang Date: Sun, 5 Oct 2025 21:23:25 +0200 Subject: [PATCH 10/11] add repo test --- Makefile | 5 ++++- readme.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index a793c75..cb86304 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Makefile for common tasks -.PHONY: gen build run fmt tidy migrate-up migrate-down migrate-create db-init +.PHONY: gen build run fmt tidy migrate-up migrate-down migrate-create db-init test-repo-docker SERVICE_PKG=./backend/cmd/marketplace/product-query-svc BIN_DIR=bin @@ -41,6 +41,9 @@ db-init: test-integration-docker: bash scripts/test-integration-docker.sh ./test -run Postgres +test-repo-docker: + go test -tags docker ./apps/product-query-svc/adapters/outbound/postgres -run TestCommentRepository_WithDocker -count=1 + # Notes: # - Requires golang-migrate installed to use migrate-* targets diff --git a/readme.md b/readme.md index b31d927..935fbab 100644 --- a/readme.md +++ b/readme.md @@ -180,7 +180,7 @@ bash scripts/test-integration-docker.sh ./test -run Postgres - 使用 `docker run -P` 启动 postgres:16-alpine,随机映射宿主端口,避免与 Tilt 的 5432 冲突。 - 通过 `migrate/migrate` 容器在同一网络命名空间内执行迁移。 - 自动导出 `DATABASE_URL` 为宿主上的随机端口,并运行 go test。 -- 需要单独验证仓储层(含评论 CRUD)的 Docker 集成测试时,可运行 `go test -tags docker ./apps/product-query-svc/adapters/outbound/postgres -run TestCommentRepository_WithDocker -count=1`,确保本机 Docker 可用;若暂不具备条件,可设置 `SKIP_DOCKER_TESTS=1` 跳过。 +- 需要单独验证仓储层(含评论 CRUD)的 Docker 集成测试时,可运行 `make test-repo-docker`(依赖本机 Docker);若暂不具备条件,可设置 `SKIP_DOCKER_TESTS=1 make test-repo-docker` 跳过实际容器启动。 From 6d4edaf4450702693697fde9362f2f8dfb742057 Mon Sep 17 00:00:00 2001 From: huayitang Date: Sat, 11 Oct 2025 03:34:26 +0200 Subject: [PATCH 11/11] add repo test --- AGENTS.md | 22 ++++++++-------- Dockerfile | 2 +- Makefile | 2 +- Tiltfile | 2 +- api/generate.go | 2 +- api/oapi-config.yaml | 2 +- api/openapi.yaml | 2 +- apps/product-query-svc/README.md | 2 +- .../product-query-svc/main.go | 0 charts/product-query-svc/values.yaml | 4 +-- k8s/config-app.yaml | 6 ++--- k8s/namespace.yaml | 2 +- k8s/postgres.yaml | 4 +-- k8s/product-query-svc.yaml | 4 +-- readme.md | 26 +++++++++---------- 15 files changed, 41 insertions(+), 41 deletions(-) rename backend/cmd/{marketplace => }/product-query-svc/main.go (100%) diff --git a/AGENTS.md b/AGENTS.md index af65f56..d47f373 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,18 +38,18 @@ ## 2) REPOSITORY LAYOUT & MODULES -| Path | Purpose | -| -------------------------------------------------------------- | --------------------------------------------------------- | -| `backend/cmd/marketplace/product-query-svc` | 进程入口与依赖注入(路由、仓库、配置)。 | -| `apps/product-query-svc/domain` | 领域聚合与不变式(`Product`, `Comment`, `User`)。无 http/sql/env 依赖。 | -| `apps/product-query-svc/application` | 用例编排(实现入站端口),只依赖 `ports` 与 `domain`。 | -| `apps/product-query-svc/adapters` | 入站 HTTP handlers;出站持久化实现。禁止写业务规则。 | +| Path | Purpose | +| -------------------------------------------------- | --------------------------------------------------------- | +| `backend/cmd/product-query-svc` | 进程入口与依赖注入(路由、仓库、配置)。 | +| `apps/product-query-svc/domain` | 领域聚合与不变式(`Product`, `Comment`, `User`)。无 http/sql/env 依赖。 | +| `apps/product-query-svc/application` | 用例编排(实现入站端口),只依赖 `ports` 与 `domain`。 | +| `apps/product-query-svc/adapters` | 入站 HTTP handlers;出站持久化实现。禁止写业务规则。 | | `apps/product-query-svc/adapters/outbound/postgres/migrations` | SQL 迁移(使用 `migrate` 工具)。 | -| `apps/product-query-svc/api/openapi.yaml` | OpenAPI 单一事实源。 | -| `apps/product-query-svc/api/gen` | oapi-codegen 生成物(**禁止手改**)。 | -| `test` | 端到端与集成测试(内存/PG 双路径)。 | -| `scripts`, `Makefile` | 开发脚本、构建、DB 设置、集成流程。 | -| `charts`, `k8s`, `kind` | 部署清单,配置变化时同步。 | +| `apps/product-query-svc/api/openapi.yaml` | OpenAPI 单一事实源。 | +| `apps/product-query-svc/api/gen` | oapi-codegen 生成物(**禁止手改**)。 | +| `test` | 端到端与集成测试(内存/PG 双路径)。 | +| `scripts`, `Makefile` | 开发脚本、构建、DB 设置、集成流程。 | +| `charts`, `k8s`, `kind` | 部署清单,配置变化时同步。 | **分层约定** diff --git a/Dockerfile b/Dockerfile index 0161fb7..a661323 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ COPY . . RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -o /out/product-query-svc ./backend/cmd/marketplace/product-query-svc + go build -o /out/product-query-svc ./backend/cmd/product-query-svc # Runtime FROM gcr.io/distroless/base-debian12:nonroot diff --git a/Makefile b/Makefile index cb86304..806feb1 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Makefile for common tasks .PHONY: gen build run fmt tidy migrate-up migrate-down migrate-create db-init test-repo-docker -SERVICE_PKG=./backend/cmd/marketplace/product-query-svc +SERVICE_PKG=./backend/cmd/product-query-svc BIN_DIR=bin BIN=$(BIN_DIR)/product-query-svc diff --git a/Tiltfile b/Tiltfile index e5d127e..8b14540 100644 --- a/Tiltfile +++ b/Tiltfile @@ -6,7 +6,7 @@ # Many Tilt installs don't provide that ext; omit the load to avoid startup errors. # Settings -namespace = 'marketplace-dev' +namespace = 'gopractice-dev' svc_name = 'product-query-svc' # Starlark (Tiltfile) 不支持 Python f-strings,使用字符串连接 docker_ref = svc_name + ':dev' diff --git a/api/generate.go b/api/generate.go index 7cd3ceb..53b8cb2 100644 --- a/api/generate.go +++ b/api/generate.go @@ -1,2 +1,2 @@ //go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest -config ./oapi-config.yaml ./openapi.yaml -package marketplaceapi +package gopracticeapi diff --git a/api/oapi-config.yaml b/api/oapi-config.yaml index 2f593e7..1238b3e 100644 --- a/api/oapi-config.yaml +++ b/api/oapi-config.yaml @@ -4,4 +4,4 @@ generate: - chi-server # 生成 chi server 接口 - strict-server # 生成严格 server 接口(带类型安全) - spec # 生成 embedded swagger spec -output: ../apps/product-query-svc/adapters/inbound/http/marketplaceapi.gen.go +output: ../apps/product-query-svc/adapters/inbound/http/gopracticeapi.gen.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 7454e97..0055f73 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1,6 +1,6 @@ openapi: 3.0.0 info: - title: Marketplace Demo API + title: gopractice Demo API version: 1.0.0 tags: - name: Products diff --git a/apps/product-query-svc/README.md b/apps/product-query-svc/README.md index 33c03b2..68748c4 100644 --- a/apps/product-query-svc/README.md +++ b/apps/product-query-svc/README.md @@ -41,7 +41,7 @@ Domain apps/product-query-svc/domain/product.go (实体/校验) Composition Root(组装根) - backend/cmd/marketplace/product-query-svc/main.go + backend/cmd/gopractice/product-query-svc/main.go - 读取配置,选择 inmem 或 postgres 作为 ProductRepository 的实现 - 构造 productapp.Service,并作为 ports/inbound.ProductUseCases 注入 HTTP 适配器 - 启动 HTTP 服务器 diff --git a/backend/cmd/marketplace/product-query-svc/main.go b/backend/cmd/product-query-svc/main.go similarity index 100% rename from backend/cmd/marketplace/product-query-svc/main.go rename to backend/cmd/product-query-svc/main.go diff --git a/charts/product-query-svc/values.yaml b/charts/product-query-svc/values.yaml index 35a56a3..33dcbf1 100644 --- a/charts/product-query-svc/values.yaml +++ b/charts/product-query-svc/values.yaml @@ -18,7 +18,7 @@ resources: env: HTTP_ADDRESS: ":8080" # App listen address; DATABASE_URL now comes from .Values.database.secret when enabled - DATABASE_URL: "postgres://app:app_password@postgres.marketplace-dev.svc.cluster.local:5432/productdb?sslmode=disable" # fallback only + DATABASE_URL: "postgres://app:app_password@postgres.gopractice-dev.svc.cluster.local:5432/productdb?sslmode=disable" # fallback only podAnnotations: {} replicaCount: 1 @@ -37,4 +37,4 @@ database: key: DATABASE_URL create: false # If create=true, a Secret will be created with this URL as stringData.DATABASE_URL - url: "postgres://app:app_password@postgres.marketplace-dev.svc.cluster.local:5432/productdb?sslmode=disable" + url: "postgres://app:app_password@postgres.gopractice-dev.svc.cluster.local:5432/productdb?sslmode=disable" diff --git a/k8s/config-app.yaml b/k8s/config-app.yaml index 6a30e34..41d7cb7 100644 --- a/k8s/config-app.yaml +++ b/k8s/config-app.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: Secret metadata: name: pg-secret - namespace: marketplace-dev + namespace: gopractice-dev stringData: POSTGRES_DB: productdb POSTGRES_USER: app @@ -12,9 +12,9 @@ apiVersion: v1 kind: ConfigMap metadata: name: app-config - namespace: marketplace-dev + namespace: gopractice-dev data: LOG_LEVEL: debug HTTP_ADDRESS: ":8080" - DATABASE_URL: "postgres://app:app_password@postgres.marketplace-dev.svc.cluster.local:5432/productdb?sslmode=disable" + DATABASE_URL: "postgres://app:app_password@postgres.gopractice-dev.svc.cluster.local:5432/productdb?sslmode=disable" diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml index a465f94..6145af0 100644 --- a/k8s/namespace.yaml +++ b/k8s/namespace.yaml @@ -1,5 +1,5 @@ apiVersion: v1 kind: Namespace metadata: - name: marketplace-dev + name: gopractice-dev diff --git a/k8s/postgres.yaml b/k8s/postgres.yaml index 511d3b5..cf71ff3 100644 --- a/k8s/postgres.yaml +++ b/k8s/postgres.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: Service metadata: name: postgres - namespace: marketplace-dev + namespace: gopractice-dev spec: ports: - port: 5432 @@ -14,7 +14,7 @@ apiVersion: apps/v1 kind: StatefulSet metadata: name: postgres - namespace: marketplace-dev + namespace: gopractice-dev spec: selector: matchLabels: diff --git a/k8s/product-query-svc.yaml b/k8s/product-query-svc.yaml index 662d2af..6795cd5 100644 --- a/k8s/product-query-svc.yaml +++ b/k8s/product-query-svc.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: Service metadata: name: product-query-svc - namespace: marketplace-dev + namespace: gopractice-dev spec: selector: app: product-query-svc @@ -16,7 +16,7 @@ apiVersion: apps/v1 kind: Deployment metadata: name: product-query-svc - namespace: marketplace-dev + namespace: gopractice-dev spec: replicas: 1 selector: diff --git a/readme.md b/readme.md index 935fbab..b1710ed 100644 --- a/readme.md +++ b/readme.md @@ -33,7 +33,7 @@ │ ├── inmem/ # 内存仓储实现(开发/测试) │ └── postgres/ # Postgres 仓储与迁移文件 ├── backend/ -│ └── cmd/marketplace/product-query-svc/ # 可执行入口(main.go),装配路由/依赖 +│ └── cmd/product-query-svc/ # 可执行入口(main.go),装配路由/依赖 ├── charts/product-query-svc/ # 最小 Helm Chart(含迁移 Job 与 ConfigMap) ├── k8s/ # 直接应用的 Kubernetes 清单(Service/Deployment/Postgres) ├── kind/ # kind 本地集群配置 @@ -55,7 +55,7 @@ ## HTTP 适配器设计(Strict Server) -- **代码生成统一使用 `oapi-codegen strict-server`**:`api/oapi-config.yaml` 只保留严格服务输出,避免手写 handler 接口。每次变更 OpenAPI 需执行 `go generate ./api` 重新生成 `marketplaceapi.gen.go`。 +- **代码生成统一使用 `oapi-codegen strict-server`**:`api/oapi-config.yaml` 只保留严格服务输出,避免手写 handler 接口。每次变更 OpenAPI 需执行 `go generate ./api` 重新生成 `gopracticeapi.gen.go`。 - **请求校验前移到 OpenAPI**:所有参数/请求体验证(`minimum`/`maxLength`/`enum` 等)写在 `api` 目录的 schema/parameter 中,由 `github.com/oapi-codegen/nethttp-middleware` 提供的 `OapiRequestValidator` 中间件统一拦截。 - **Handler 职责“三件套”**(`apps/product-query-svc/adapters/inbound/http/handler_*.go`): 1. 从生成的强类型 `RequestObject` 中取出入参(无需重复校验); @@ -209,9 +209,9 @@ curl -s http://localhost:8080/products/1 | jq - Pod/日志排查 ```sh -kubectl -n marketplace-dev get pods -kubectl -n marketplace-dev logs deploy/product-query-svc -kubectl -n marketplace-dev logs statefulset/postgres +kubectl -n -dev get pods +kubectl -n -dev logs deploy/product-query-svc +kubectl -n -dev logs statefulset/postgres ``` --- @@ -227,7 +227,7 @@ kubectl -n marketplace-dev logs statefulset/postgres 1. 启动 Postgres(示例): ```sh -docker run --name marketplace-postgres \ +docker run --name -postgres \ -e POSTGRES_USER=app \ -e POSTGRES_PASSWORD=app_password \ -e POSTGRES_DB=productdb \ @@ -245,14 +245,14 @@ export LOG_LEVEL=debug 1. 运行服务(开发): ```sh -cd backend/cmd/marketplace/product-query-svc +cd backend/cmd/product-query-svc go run . ``` 或构建后运行: ```sh -go build -o bin/product-query-svc ./backend/cmd/marketplace/product-query-svc +go build -o bin/product-query-svc ./backend/cmd/product-query-svc ./bin/product-query-svc ``` @@ -313,7 +313,7 @@ psql "postgres://app:app_password@localhost:5432/productdb" - 如需修改连接串,可在 Tiltfile 顶部调整 `MIGRATE_URL`。 - 在 K8s/Helm 中执行(集群内) - - 可选:用 Helm hook 或 Job 在集群内运行 `migrate/migrate`,`DATABASE_URL` 使用集群内 Service(例如 `postgres.marketplace-dev.svc.cluster.local`)。需要的话可以补充该 Job。 + - 可选:用 Helm hook 或 Job 在集群内运行 `migrate/migrate`,`DATABASE_URL` 使用集群内 Service(例如 `postgres.-dev.svc.cluster.local`)。需要的话可以补充该 Job。 常见避坑: @@ -362,7 +362,7 @@ Helm 迁移 Job: docker build -t product-query-svc:dev . ``` -注:Dockerfile 默认构建 backend/cmd/marketplace/product-query-svc 的二进制,用于镜像/部署。 +注:Dockerfile 默认构建 backend/cmd/product-query-svc 的二进制,用于镜像/部署。 --- @@ -380,7 +380,7 @@ go generate ./api # 或者根据 generate.go 的 //go:generate 指定路径 ``` -- 生成后的 `adapters/inbound/http/marketplaceapi.gen.go` **禁止手动修改**;需要调整校验或字段时改 OpenAPI 资源并重新生成。 +- 生成后的 `adapters/inbound/http/api.gen.go` **禁止手动修改**;需要调整校验或字段时改 OpenAPI 资源并重新生成。 - HTTP handler 只能依赖生成的 `StrictServerInterface`,其实现位于 `handler_*.go`,必须配合 `response_helpers.go` 和 `request_mappers.go` 使用。 - `NewAPIHandler` 会自动加载最新的 Swagger 并注册 `OapiRequestValidator` 中间件,生产/测试入口都应通过该函数获取路由。 @@ -431,7 +431,7 @@ go generate ./api
批次 5 — 后端入口 / wiring / router - - 相关文件:backend/cmd/marketplace/product-query-svc、apps/product-query-svc/adapters/inbound/http/ + - 相关文件:backend/cmd/product-query-svc、apps/product-query-svc/adapters/inbound/http/ - 建议 commit message:"chore: add service main and HTTP wiring (router & handlers)"
@@ -460,6 +460,6 @@ go generate ./api - "FATAL: database \"app\" does not exist":确认 Postgres 启动时环境变量 POSTGRES_DB 与服务的 DATABASE_URL 中数据库名一致(示例使用 productdb);或手动创建数据库。 - Docker 构建报 "go.mod: unknown directive: tool":请使用与 go.mod 中 toolchain 对齐的 Go 版本(本项目使用 1.24)。 -- Lens 中看不到资源:确认 Lens 使用的 kubeconfig 与 kubectl 当前上下文一致,并且查看正确命名空间(marketplace-dev)。 +- Lens 中看不到资源:确认 Lens 使用的 kubeconfig 与 kubectl 当前上下文一致,并且查看正确命名空间(-dev)。