diff --git a/AGENTS.md b/AGENTS.md index db13a03..210979f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,36 +1,106 @@ # AGENTS.md -> **Go Version**: 1.26+ (toolchain: go1.26.7) -> **Status**: Active — single source of truth for engineering decisions. +> AI coding-agent execution manual for snowgo. +> +> Go: 1.26+ (toolchain: go1.26.7). +> +> Goal: deliver the smallest necessary, verifiably correct change without compromising correctness, security, or maintainability. -## Document Index +## 1. Instruction Precedence and Document Roles -| Document | Scope | -|----------|-------| -| [CODING.md](./CODING.md) | Naming, error handling, logging, context, validation, concurrency | -| [ARCHITECTURE.md](./ARCHITECTURE.md) | Database design, transactions, caching, query optimization | -| [OPERATIONS.md](./OPERATIONS.md) | Security, testing, review checklist | +Resolve conflicts in this order: + +1. The user's current explicit request, constraints, and explicitly confirmed exceptions. +2. The hard rules in this `AGENTS.md`. +3. The relevant project-specific references. +4. Existing conventions in the affected module, unless they conflict with a higher-priority rule. + +The user's request defines the outcome and scope, but do not infer permission to bypass generated-file, data-integrity, security, or authorization constraints. Surface the conflict and obtain explicit confirmation before treating it as a policy exception. + +`CODING.md` defines code-level practices; `ARCHITECTURE.md` defines layers, data, transactions, caching, and queries; `OPERATIONS.md` defines security, testing, configuration, release, and review practices. + +Before implementation, read only the references relevant to the affected change surface. Use the Reference Index to decide what is required. Do not read unrelated references for a small localized change; if relevance or a boundary is unclear, read the applicable reference or ask. + +Material ambiguity affecting APIs, data, permissions, security, compatibility, or scope must be surfaced with viable options. Do not silently select a high-risk interpretation. + +--- + +## 2. AI Coding Behavior Guidelines + +### Think Before Coding + +For non-trivial changes, understand the requested outcome, identify material assumptions and ambiguities, consider affected boundaries and existing architecture, and define how the result will be verified. Ask when missing information would materially change the solution. + +### Simplicity and Existing Patterns First + +- Write only the minimum code needed for the current requirement. Do not add speculative features, abstractions, configuration, compatibility layers, or extension points. +- Before introducing a pattern for dependency injection, errors, logging, transactions, caching, API responses, authorization, or configuration, search for and reuse the established project pattern unless it is insufficient. +- Add a third-party dependency only when the standard library and existing dependencies are insufficient. Assess maintenance, license, security, and operational impact; then run `go mod tidy` and relevant tests. + +### Surgical, Real Changes + +- Inspect and preserve existing working-tree changes. Every changed line must be traceable to the task; do not opportunistically refactor, format, or clean up adjacent code. +- Do not silently ignore errors or turn unexpected errors into successful results. An explicitly best-effort operation must document its reason and log or measure failure. +- Do not claim a capability is complete when it is a TODO, placeholder, production stub, hard-coded success, or mock that does not perform the required behavior. + +### Evidence-Based Execution + +Define verifiable acceptance criteria. Prefer regression tests for bugs and cover important success, failure, authorization, and boundary paths for changed behavior. Report only verification actually performed, including skipped checks, reasons, and residual risk. + +--- + +## 3. Standard Workflow + +1. **Understand:** read the task, relevant code, and applicable references; identify the affected boundaries. +2. **Inspect:** check existing working-tree changes and define success criteria, risks, and relevant verification. +3. **Plan:** for non-trivial work, state concise steps with verification and material trade-offs. +4. **Implement:** follow existing patterns and the hard constraints below; keep the diff minimal. +5. **Verify:** run checks appropriate to the actual change scope and inspect the diff. +6. **Hand off:** report changed behavior, verification evidence, skipped checks, compatibility or release impact, and known risks. --- -## Core Rules - -1. **Never** manually edit `internal/dal/model/` or `internal/dal/query/`. -2. Service owns transaction boundaries. DAO methods accept caller-provided `*query.Query` and never open transactions themselves. Multi-table mutations and audited business mutations that require operation logs use `WriteQuery().Transaction()`. Independent single-table writes may use a non-transactional `*query.Query` when atomic cross-table consistency is not required. Operation logs for audited business mutations are written synchronously within the same transaction through transaction-aware contracts or DAO methods. -3. All DAO methods that may participate in a transaction accept `*query.Query` as a parameter. The DAO does not care whether it runs in a transaction — the Service layer decides: pass `tx` inside `Transaction()`, pass `repo.Query()` or `repo.WriteQuery()` outside. There is only one method per DAO operation — no separate `Transaction*Xxx` variants. -4. Cache invalidation happens **after** DB commit, never inside a transaction. -5. Admin endpoints require `JWTAuth()` after login. Add `PermissionAuth(constant.PermXxx)` for privileged management or scoped business data endpoints. Login-only endpoints (current user permissions, server info, allowed dictionary lookups) must be documented in route comments. Middleware in `internal/router/middleware/auth.go`. -6. Service dependencies follow package boundaries: same-package services inject concrete `*XxxService`; cross-package service calls use stable contracts from `internal/service/admin/contract` when the dependency is shared infrastructure capability, and consumer-local interfaces only for narrow one-off domain dependencies. Do not call business Services inside an open transaction; only transaction-aware infrastructure contracts that receive the caller's `*query.Query` are allowed. -7. Use `xlogger.InfofCtx` / `xlogger.ErrorfCtx` for all logging. -8. Define sentinel errors in Service using `pkg/xerror/` codes; compare with `errors.Is`. -9. API layer validates all input before reaching Service. -10. No `panic()` in API/Service/DAO for business errors. -11. `created_at` mandatory for all tables; soft delete (`is_deleted tinyint(1) DEFAULT 0` + `deleted_at DATETIME(6) DEFAULT NULL`) is optional, decide per business need. Use for tables requiring audit/compliance/user undo (e.g., orders). Skip for high-volume logs and junction tables. -12. Run tests according to change scope before declaring complete: small, localized changes run affected package tests; broad/shared behavior changes run `go test ./...`. Always run `make lint`. Use coverage commands only when coverage is the explicit goal. Integration tests use `-tags=integration`; `make test-integration` covers pkg and Service integration tests and requires Redis/RabbitMQ plus `MYSQL_DSN` for Service tests. -13. Core and complex code must have comments. Update README / AGENTS docs alongside code changes. +## 4. Project Architecture Hard Constraints + +- The primary business call direction is fixed: `Router → API → Service → DAO → DAL (GORM Gen) → MySQL`. Service may also use approved cache, queue, and external-service components. API never calls DAO directly; Service never calls GORM Gen query APIs directly. +- **Never manually edit** `internal/dal/model/` or `internal/dal/query/`. After a schema change, run `make gen do=add` or `make gen do=update` and inspect the generated diff. `make new-module domain=business module=order` produces a skeleton only. +- Service owns transaction boundaries. DAO receives the caller-provided `*query.Query` and never begins, commits, or rolls back transactions. Do not call business Services inside an open transaction. +- Use `WriteQuery().Transaction()` only for atomic multi-table writes, audited mutations, or other true transactional-consistency needs. Do not open a transaction solely because a read follows a write; use `repo.WriteQuery()` when primary-read consistency is sufficient to avoid replica lag. +- Audited mutations write operation logs synchronously in the same transaction. Service owns cache behavior; invalidate cache only after a successful database commit. +- API performs transport validation: binding, required fields, format, length, enums, and basic request limits. Service validates business rules and state-dependent preconditions. +- Admin endpoints after login require `JWTAuth()`; privileged or scoped endpoints require `PermissionAuth(constant.PermXxx)`. Every endpoint is public, login-only, or permission-protected. Explain only non-obvious public or login-only exceptions in code or route configuration. +- Same-package Services inject concrete Services. Cross-package shared infrastructure uses stable contracts in `internal/service/admin/contract`; narrow one-off dependencies use consumer-local interfaces. Contracts contain interfaces and DTOs only. +- Every table requires `created_at`; choose soft delete based on business need. Core or complex logic needs concise **Chinese** comments explaining **why**, not obvious code. --- -## AI Workflow +## 5. Mandatory Checks by Change Type + +| Change surface | Required focus | +|----------------|----------------| +| Go code, errors, logging, context, validation, concurrency, dependencies | Read `CODING.md`; preserve established patterns and error semantics. | +| API or authorization | Read `CODING.md` and `OPERATIONS.md`; classify endpoint, validate transport input, preserve contract and error-code compatibility. | +| Schema, DAO, transactions, cache, queries | Read `ARCHITECTURE.md`; for schema changes also read `OPERATIONS.md`. Generate DAL code and assess indexes, migration artifacts, transaction scope, cache timing, and compatibility. | +| Writes, retries, queues, external calls | Read `ARCHITECTURE.md` and `OPERATIONS.md`; define idempotency, timeout, retry, durable side effects, and failure handling. | +| Security, configuration, release, observability | Read `OPERATIONS.md`; assess secrets, authorization, rollout, rollback, and production checks. | + +--- + +## 6. Verification and Hand-Off Requirements + +- Run affected-package tests for localized code changes; run `go test ./...` for shared or broad behavior changes. Run integration tests for affected critical paths when dependencies are available. +- For Go changes, format only changed files with `gofmt`, run scope-appropriate tests, and run `make lint`. If local lint tooling is unavailable, report the skipped check and do not claim lint passed; CI uses `golangci-lint v2.12.2` as the canonical gate. For documentation-only or non-Go changes, run relevant link, format, and diff checks. +- Do not change production behavior solely to satisfy a test when it conflicts with the requirement. Surface test/specification conflicts instead of optimizing blindly for green tests. +- Before hand-off, review `git diff` or equivalent and confirm every modified file and meaningful change belongs to the task and no unrelated user change was overwritten. + +--- + +## 7. Reference Index + +| Document | Read when | +|----------|-----------| +| [`CODING.md`](./CODING.md) | Changing Go code, errors, logging, context, validation, concurrency, or Service dependencies. | +| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | Changing layers, database, DAO, transactions, caching, external calls, or query performance. | +| [`OPERATIONS.md`](./OPERATIONS.md) | Changing authorization, security, tests, configuration, release behavior, queues, or delivery review. | -Non-trivial changes: output plan before coding. Steps: understand task → identify files → plan → note risks → verify. For new database tables, run `make gen do=add` / `make gen do=update` to generate Model + Query before coding DAO/Service/API layers. +Update README or engineering references only when code behavior, configuration, APIs, or operational workflows actually change; avoid mechanical updates to unrelated documentation. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7e32d39..c8936ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,10 +9,13 @@ ## 1. Layered Architecture ``` -Router → API → Service → DAO → DAL (GORM Gen) → MySQL / Redis +Router → API → Service → DAO → DAL (GORM Gen) → MySQL + ├→ Cache → Redis + ├→ MQ + └→ External services ``` -- Each layer calls only the layer below. API → Service (never DAO). Service → DAO (never GORM Gen directly). +- Keep business dependencies directed through the declared boundaries. API calls Service, never DAO; Service calls DAO for database access, never GORM Gen directly. Service may call approved infrastructure components and stable contracts. - `repo` (`dal/repo/repo.go`) provides `WriteQuery()` / `ReadQuery()` / `Query()` / `ChangeDB()`. - DI: `di.GetContainer(c)` or `di.GetSystemContainer(c)`. Option pattern, LIFO close. - Cross-package Service dependencies use stable contracts under `internal/service/admin/contract` for shared capabilities. Business packages depend on contracts, not concrete Services from other business packages. @@ -29,7 +32,7 @@ Design principle: drive schema by query patterns, avoid over-engineering, choose |------|------| | Table name | System/admin tables use `sys_`; business tables use `_` when a module prefix is needed | | Association table | `_` with the same prefix strategy, e.g. `sys_user_role` | -| Foreign keys | None. Application-level integrity | +| Foreign keys | None. Enforce application-level integrity with validation, transactions, unique constraints, and reconciliation or cleanup where needed | | created_at | Mandatory. TIMESTAMP/DATETIME with DEFAULT CURRENT_TIMESTAMP | | updated_at | Only for tables with UPDATE | | Primary key | BIGINT default. INT for small/config tables | @@ -46,7 +49,7 @@ Non-mandatory. Decide per business need: - **USE**: audit/compliance, referential integrity, user undo (e.g., orders, payments) - **SKIP**: high-volume logs, junction tables, simple config tables -Column: `is_deleted TINYINT(1) NOT NULL DEFAULT 0` + `deleted_at DATETIME(6) DEFAULT NULL`. Index only if querying both states. Use `UpdateSimple`. +Column: `is_deleted TINYINT(1) NOT NULL DEFAULT 0` + `deleted_at DATETIME(6) DEFAULT NULL`. Do not index `is_deleted` alone by default; include it in a query-driven composite index only when EXPLAIN and selectivity justify it. Use `UpdateSimple`. ### 2.3 Index Design @@ -58,7 +61,7 @@ Column: `is_deleted TINYINT(1) NOT NULL DEFAULT 0` + `deleted_at DATETIME(6) DEF | Covering | All SELECT columns included | | Association | Composite unique (a_id, b_id) | -Left-prefix rule: `(a, b, c)` serves `(a)`, `(a, b)`, `(a, b, c)`. Range queries break the chain. +Left-prefix rule: `(a, b, c)` serves predicates starting with `(a)`, `(a, b)`, or `(a, b, c)`. A range predicate usually prevents later columns from further narrowing the index scan; verify the actual plan with EXPLAIN. **Performance checklist**: avoid SELECT * on large tables; use EXPLAIN before committing complex queries; avoid implicit type conversion in WHERE; prefer covering indexes for hot paths; composite index for low-cardinality + high-cardinality columns; no leading wildcard LIKE in production queries. @@ -72,17 +75,21 @@ Use `WriteQuery().Transaction()` for: - Multi-table writes. - Business mutations that must persist operation logs atomically. -- Read-after-write logic that must be isolated from replica lag. -Independent single-table writes may run without an explicit transaction when there is no cross-table or operation-log atomicity requirement, for example login logs. Prefer `repo.WriteQuery()` for non-transactional writes when the write node must be explicit; `repo.Query()` may rely on dbresolver auto-routing. +Do not open a transaction solely because a read follows a write. Independent single-table writes may run without an explicit transaction when there is no cross-table or operation-log atomicity requirement, for example login logs. When read-after-write consistency only requires avoiding replica lag, use `repo.WriteQuery()` for the read; use a transaction only when atomicity or transactional consistency is actually required. `repo.Query()` may rely on dbresolver auto-routing. Never call business Service methods through `container.SomeService.Method()` inside a transaction. Service MUST NOT directly use GORM Gen query APIs. ```go err := db.WriteQuery().Transaction(func(tx *query.Query) error { - // Pass tx to DAO methods - obj, err := dao.CreateUser(ctx, tx, &model.SysUser{...}) - // Operation log within same tx + // 在同一事务内传递 tx,保证多表写入和审计日志原子一致。 + if _, err := dao.CreateUser(ctx, tx, &model.SysUser{...}); err != nil { + return fmt.Errorf("create user: %w", err) + } + // 审计日志必须复用该 tx,避免业务数据与审计记录不一致。 + if err := logWriter.CreateOperationLog(ctx, tx, &contract.OperationLogInput{...}); err != nil { + return fmt.Errorf("create operation log: %w", err) + } return nil }) ``` @@ -97,18 +104,18 @@ Read/write separation: `repo.WriteQuery()` forces write node, `repo.ReadQuery()` | Outside transaction | `repo.Query()` / `repo.WriteQuery()` | From the Service's repository | ```go -// DAO: unified signature, q source determined by caller +// DAO 使用统一签名,使调用方能够决定是否参与事务。 func (u *UserDao) CreateUser(ctx context.Context, q *query.Query, user *model.SysUser) (*model.SysUser, error) { return q.SysUser.WithContext(ctx).Omit(userDefaultSkipColumns...).Create(user) } -// Service: inside transaction → pass tx +// 事务内传入 tx,确保后续写操作处于同一原子边界。 err := s.db.WriteQuery().Transaction(func(tx *query.Query) error { userObj, err = s.userDao.CreateUser(ctx, tx, &model.SysUser{...}) - return nil + return err }) -// Service: outside transaction → pass repo query explicitly +// 非事务写入显式使用写库,避免路由语义不清晰。 userObj, err := s.userDao.CreateUser(ctx, s.db.WriteQuery(), &model.SysUser{...}) ``` @@ -124,7 +131,7 @@ Operation logs for audited business mutations are synchronous within the same tr ## 4. Caching Strategy -Service layer only. Keys: `constant.CacheXXXPrefix + value`. Invalidation **after** DB commit — never inside transaction. Cache set is non-blocking; failure logged, never propagated. +Service layer only. Keys: `constant.CacheXXXPrefix + value`. Invalidation happens **after** DB commit, never inside a transaction. Cache reads and fills are synchronous calls but may be best-effort when falling back to the database preserves correctness. Every ignored cache failure must be logged or measured; invalidation failures require enough context to diagnose the stale-key risk and a retry, versioning, short-TTL, or reconciliation strategy when stale data is business-critical. Existing silent `_ = cache...` calls are technical debt, not a pattern to copy. | Constant | Key Pattern | TTL | Scope | |----------|-------------|-----|-------| @@ -136,7 +143,7 @@ Service layer only. Keys: `constant.CacheXXXPrefix + value`. Invalidation **afte Non-cache keys: `CacheLoginFailPrefix` (login failure, 3 min), `CacheRefreshJtiPrefix` (JWT refresh JTI). -Pattern: Read-through (cache → miss → DB → fill non-blocking). Write-behind (tx → commit → invalidate). +Pattern: cache-aside read (`cache → miss → DB → best-effort fill`) and post-commit invalidation (`DB transaction → commit → invalidate`). --- @@ -144,7 +151,7 @@ Pattern: Read-through (cache → miss → DB → fill non-blocking). Write-behin Graceful degradation: cache down → query DB and log the cache error. DB down → return stale cache only for read paths that explicitly implement stale-cache semantics; otherwise return a controlled system error. -External calls: `context.WithTimeout`. Retry transient errors only — max 3 times, exponential backoff. No retry on 4xx, validation errors, unique constraint violations. +External calls must have a caller-bounded timeout. Retry only transient failures for idempotent or idempotency-protected operations, within the total deadline and component retry budget. Use exponential backoff with jitter and honor `Retry-After` when applicable. Do not retry validation, authentication, authorization, unique-constraint, or other permanent failures; 408 and 429 require an explicit policy. Default to at most three total attempts unless the component has a stricter documented policy. Idempotency: unique index for create-by-key, request_id in Redis for duplicate detection, WHERE status for transitions, distributed lock + unique tx number for financial. @@ -158,18 +165,18 @@ Paginate all lists. Default limit: `constant.DefaultLimit` (10). Avoid N+1 (JOIN | Concern | Guideline | |---------|-----------| -| Trees | Load all rows, build in memory | +| Trees | For bounded admin trees, load the required rows and build in memory; paginate or redesign unbounded hierarchies | | Batch ops | Validate size limits, bulk insert/update | -| Fuzzy search | ES/Meilisearch. No LIKE '%term%' on large tables | +| Fuzzy search | Avoid leading-wildcard LIKE on large tables; evaluate a search engine only when query volume and search requirements justify it | | Export | Stream results | -Performance targets should be defined per endpoint or module. Default review thresholds: P99 read < 200ms for hot cache-backed reads, P99 write < 500ms for common writes, slow SQL threshold 2s, cache hit rate > 90% for mature hot keys. +Define latency, error-rate, throughput, and cache-hit objectives per endpoint or module from production telemetry. The slow-SQL threshold is configuration-driven. Example targets are not release commitments unless they are documented, measurable, and owned by the affected module. --- ## 7. Code Comments -Core and complex code must have comments. Simple code needs none. The following must have comments: +Core and complex code must have concise Chinese comments. Simple code needs none. Comments explain **why** an invariant, boundary, or decision exists; they must not narrate obvious code. The following must have comments: | What | Why | |------|-----| @@ -179,4 +186,4 @@ Core and complex code must have comments. Simple code needs none. The following | Index rationale | Why this index (or not) | | Error handling branches | Why handled differently | -Style: one line. WHY, not WHAT. +Style: prefer one concise line. Write WHY, not WHAT. diff --git a/CODING.md b/CODING.md index 87c4e2e..4442a8f 100644 --- a/CODING.md +++ b/CODING.md @@ -18,7 +18,7 @@ | Constants | CamelCase exported, camelCase unexported | CacheMenuTree, defaultLimit | | DTOs (API) | {Entity}Info, {Entity}List, {Entity}Param | UserInfo, UserList, UserParam | | DTOs (Service) | no json tags unless crossing boundaries | UserCondition | -| Struct tags | json + form + binding | - | +| API request tags | Add `json` or `form` for the accepted transport and `binding` for transport validation; do not add unused tags | `json:"username" binding:"required,max=64"` | --- @@ -43,9 +43,9 @@ Configuration keys and environment variable names should be documented in `.env. | Layer | Rule | |-------|------| -| API | Use `errors.As` to extract `e.BizError`, respond with `FailByError(c, bizErr.Code)`. Use `Fail` only for binding errors. Never `Fail(c, code, err.Error())` for service errors | +| API | Use `errors.As` to extract `e.BizError`, respond with `FailByError(c, bizErr.Code)`. Use `Fail` only for transport/request validation or protocol-specific messages. Never `Fail(c, code, err.Error())` for Service errors | | Service | Define sentinels with `e.NewBizError(e.Code)`. Never `errors.New` for business errors. Use `fmt.Errorf("%w", err)` for infrastructure errors | -| DAO | Return directly — raw GORM for DB. DAO validation should be minimal; business validation belongs in API/Service | +| DAO | Return directly — raw GORM for DB. DAO validation should be minimal; business validation belongs in Service | | Global | Never `panic()` in API/Service/DAO. Only `xlogger.Panic` for fatal init | ### BizError Pattern @@ -68,7 +68,7 @@ if errors.As(err, &bizErr) { return } xlogger.ErrorfCtx(ctx, "...: %v", err) -xresponse.FailByError(c, e.FallbackCode) +xresponse.FailByError(c, e.HttpInternalServerError) ``` To add a new business error: @@ -79,38 +79,33 @@ To add a new business error: Prefer returning an existing sentinel with `errors.Is` compatibility when callers need branching. Use `WrapBizError` when the underlying cause is useful for logs but should not be exposed to clients. +Never silently ignore an error or turn an unexpected error into a successful result. An explicitly best-effort operation must document why failure is acceptable and log or emit a metric for the failure. Do not log an error and continue when doing so would violate correctness or consistency. + ### Error Code Scheme 5-digit integers via `xerror.NewCode(category, code, msg)`. Duplicate codes panic at init. | Range | Meaning | |-------|---------| -| 0-504 | HTTP status codes | +| 0 and selected 2xx-5xx values | Success and protocol-shaped application codes | | 1xxxx | Business errors | | 2xxxx | System/infra errors | Structure: `[level][module][specific]` — first digit = level, digits 2-3 = module, digits 4-5 = specific. -### HTTP Status Mapping +### Response Code Semantics -| Status | Trigger | -|--------|---------| -| 400 | Validation / bad input | -| 401 | Auth failure | -| 403 | Permission denied | -| 404 | Not found | -| 429 | Rate limit | -| 500 | Server error | +`xresponse.Json` currently returns HTTP 200 for JSON envelopes and places the application code in the response `code` field. Values such as 400, 401, 403, 404, and 500 are therefore protocol-shaped application codes, not transport status codes. Rate limiting currently uses system codes 20101 and 20102. Do not change this contract or assume HTTP status equals application code without an explicit compatibility decision and API tests. --- ## 4. Logging -- Business code uses `xlogger.InfofCtx` / `xlogger.ErrorfCtx` (injects trace_id). +- Business code uses the context-aware logger APIs, all of which inject `trace_id`: prefer `xlogger.InfoCtx` / `xlogger.ErrorCtx` for structured fields and use `xlogger.InfofCtx` / `xlogger.ErrorfCtx` when formatted logging is clearer. - Avoid `fmt.Printf` / `log.Println` in business code. CLI tools, startup banners, package-level fallback loggers, and non-production console access logs may use standard output when intentional. - `Warn`: reserved for access logs via `xlogger.Access()`. - `Info`: business events. `Error`: anomalies. `Debug`: disabled in production. -- Sensitive fields auto-masked: password, token, secret, access_token, refresh_token, phone, id_card, email. +- Never rely on automatic masking as the only control. Business logs must not include credentials, tokens, secrets, or PII. The access logger masks only its configured JSON paths; when a new sensitive field can enter request or response logs, update the masking configuration and tests. --- @@ -124,8 +119,9 @@ Structure: `[level][module][specific]` — first digit = level, digits 2-3 = mod ## 6. Input Validation -- API layer is the gate. Validate before reaching Service. -- Gin binding tags: `binding:"required,max=64"`. Add explicit validation for business rules. +- API validates transport-level input before reaching Service: binding, required fields, format, length, enums, and basic request limits. +- Service validates business rules and state-dependent preconditions, such as resource existence, allowed status transitions, inventory, and operation eligibility. +- Gin binding tags: `binding:"required,max=64"`. Add explicit validation for request-level constraints. - Common tags: `required`, `max=N`, `min=N`, `email`, `oneof=A B`. --- @@ -142,6 +138,8 @@ Structure: `[level][module][specific]` — first digit = level, digits 2-3 = mod Use package boundaries to decide whether a Service dependency needs an interface. +Before introducing a dependency, error, logging, transaction, cache, response, authorization, or configuration pattern, search the repository for the established implementation and reuse it unless it is insufficient for the requirement. + | Scenario | Rule | Example | |----------|------|---------| | Same package | Inject the concrete `*XxxService`. Do not add one-method interfaces only for indirection. | `system.DictService` may depend on `*OperationLogService`. | diff --git a/Makefile b/Makefile index 2a88021..707efd9 100644 --- a/Makefile +++ b/Makefile @@ -149,3 +149,7 @@ gen: ## Generate DAL code (usage: make gen do=init|add|update|query) .PHONY: gen-query gen-query: ## Generate Query code @go run ./internal/dal/cmd/gen/main.go query + +.PHONY: new-module +new-module: ## Generate a business module (domain defaults to admin, module defaults to demo) + @go run ./cmd/modulegen -module "$(or $(module),demo)" -domain "$(or $(domain),admin)" diff --git a/OPERATIONS.md b/OPERATIONS.md index d26da87..db3d7cd 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -10,11 +10,11 @@ - JWT access tokens short-lived. Refresh tokens single-use with JTI tracking in Redis. - Admin endpoints require `JWTAuth()` after login. Add `PermissionAuth(constant.PermXXXX)` for privileged management operations or scoped business data. Login-only endpoints, such as current user permissions, server info, and allowed dictionary lookups, should be explicit in route comments. -- Never log passwords, tokens, secrets, PII. Passwords via `xcryption.HashPassword()` (bcrypt). +- Never log passwords, tokens, secrets, or PII, and do not rely solely on access-log masking. Passwords use `xcryption.HashPassword()` (bcrypt). New logged fields require a sensitivity review and masking tests where applicable. - API responses: no internal error details to clients. - Production secrets must come from injected environment variables or a secret manager. Do not rely on YAML defaults outside local/demo environments. - Rotate JWT secrets, database passwords, Redis passwords, RabbitMQ credentials, and deployment SSH/GHCR tokens through an approved release window. -- New endpoints must define their auth level explicitly: public, login-only, or permission-protected. Public and login-only endpoints require a route comment explaining why `PermissionAuth` is not used. +- New endpoints must define their auth level explicitly: public, login-only, or permission-protected. Non-obvious public and login-only endpoints require a route comment or configuration rationale explaining why `PermissionAuth` is not used; self-evident cases such as login and health checks do not need boilerplate comments. --- @@ -24,16 +24,16 @@ | Type | Scope | Framework | Location | |------|-------|-----------|----------| -| Unit | pkg/ utilities | testify/assert | `pkg/*_test.go` | -| Service Unit | Business logic, mock DAO/contract/cache | testify/assert + require | `internal/service/{module}/*_test.go` | -| Service Integration | Critical write paths, real DB | testify/suite | `internal/service/{module}/*_integration_test.go` | -| DAO | Integration, real DB | testify/suite | `internal/dao/{module}/*_test.go` | +| Unit | pkg/ utilities | `testing`; testify when useful | `pkg/*_test.go` | +| Service Unit | Business logic, fake or mock DAO/contract/cache | `testing`; testify when useful | `internal/service/{domain}/{module}/*_test.go` | +| Service Integration | Critical write paths, real DB | `testing`; testify when useful | `internal/service/{domain}/{module}/*_integration_test.go` | +| DAO | Complex queries and mappings with a real DB, when needed | `testing`; testify when useful | `internal/dao/{domain}/{module}/*_test.go` | ### 2.2 Service Testing Strategy (Two-Tier) **Tier 1 — Service Unit Tests (mock dependencies):** Primary test suite for Service business logic. -Mock all external interfaces: DAO interfaces, `contract.OperationLogWriter`, `xcache.Cache`, cross-service mocks. Verify business logic branches, permission checks, parameter validation, error propagation, and cache invalidation. +Mock external Service dependencies such as DAO interfaces, `contract.OperationLogWriter`, and `xcache.Cache`. Verify business branches, state-dependent authorization, parameter validation, error propagation, and cache invalidation. Test router middleware classification separately from Service authorization rules. **Test Context**: `testCtx()` with auth fields (`xauth.XUserId`, `XUserName`, `XTraceId`, `XIp`, `XSessionId`). @@ -45,23 +45,26 @@ Cover core write operations (create/update/delete) that involve transactions, mu Files use `//go:build integration` build tag and are excluded from `go test ./...`. Run with `go test -tags=integration ./internal/service/...` when service integration tests exist for the touched module. Integration tests use environment variables such as `MYSQL_DSN`, `REDIS_ADDR`, `REDIS_DB`, `REDIS_PASSWORD`, and `RABBITMQ_URL`; Service integration tests must connect only to test databases. -**Coverage targets**: pkg/ >= 80%. New business modules require Service unit tests for non-trivial methods and Service integration tests for critical write paths. Existing modules should be backfilled when they are materially changed. +**Coverage policy**: `pkg/ >= 80%` is currently an advisory target because CI does not enforce a threshold. Do not claim the target is met without measuring it. New business modules require Service unit tests for non-trivial methods and Service integration tests for critical write paths. Existing modules should be backfilled when they are materially changed. ### 2.3 Verification Scope - Small, localized changes: run the affected package tests, for example `go test ./pkg/xauth/...`. - Broad/shared behavior changes: run `go test ./...`. - Integration changes: run integration tests with `make test-integration`, `go test -tags=integration ./internal/service/...`, or a narrower package command when Redis/RabbitMQ/MySQL dependencies are available. Files that depend on external services use the `//go:build integration` build tag and are excluded from `go test ./...`. -- Always run `make lint` before declaring complete. +- For Go code changes, format only changed files with `gofmt`, run `make lint`, and run scoped tests. CI pins `golangci-lint v2.12.2`; if the local executable is unavailable, report lint as skipped and do not claim it passed. For documentation-only or non-Go changes, run relevant link, format, and diff checks. +- For security-sensitive Go changes, run the relevant tests and local security scans when available. CodeQL and gosec in CI are the canonical security gates; report any scan not actually run. - Coverage commands are useful for coverage work, but are not the default completion gate for every change. +- Do not change production behavior solely to satisfy a test when it conflicts with the intended requirement. Surface test/specification conflicts instead of optimizing blindly for green tests. --- ## 3. Release & Configuration - Use immutable image tags for UAT/prod releases. `latest` is allowed only as an additional production convenience tag, not as the release identifier. -- Production deploys must record image tag, commit SHA, target environment, config version, database migration version, and rollback plan. -- Database changes must be backward compatible within one deployment window: add columns before code reads them, deploy code before removing old columns, and keep rollback scripts for destructive changes. +- Production deploys must record image tag, commit SHA, target environment, config version, schema-change artifacts, and rollback plan. +- Database changes must be backward compatible within one deployment window: add columns before code reads them and deploy compatible code before removing old columns. The repository currently has no versioned migration runner; keep `docs/sql/init.sql` as the installation baseline. For each deployable schema change, create a date directory such as `docs/sql/20260903/` and place one or more ordered, descriptive SQL files inside, for example `001_add_order_table.sql`. Document execution order and rollback instructions in the SQL or change hand-off, and do not claim a migration version that does not exist. Introducing migration tooling is separate approved work. +- Changes to routes, request or response fields, application error codes, and authentication behavior require an explicit compatibility assessment and versioning or rollout plan when breaking compatibility cannot be avoided. - Config changes that affect security, persistence, queues, or rate limits require review. Document default values and production overrides in `.env.example` or deployment docs. - RabbitMQ topology changes should be applied by `cmd/mq-declarer` before deploying code that depends on new exchanges, queues, or bindings. - Observability changes should include what to check after deployment: health endpoints, key logs, trace availability, queue depth, slow SQL, and error rate. @@ -96,17 +99,18 @@ Conventional commits: `(): `. Types: feat, fix, docs, refacto - [ ] Transaction boundary is owned by Service; DAO receives caller-provided `*query.Query` - [ ] Multi-table mutations and audited business mutations use `WriteQuery().Transaction()` - [ ] Operation log for audited business mutations is written within the same transaction -- [ ] Admin endpoints: JWTAuth; PermissionAuth added for privileged/scoped endpoints, with login-only exceptions documented -- [ ] Input validation at API layer +- [ ] Admin endpoints: JWTAuth; PermissionAuth added for privileged/scoped endpoints, with non-obvious public/login-only exceptions documented +- [ ] API transport validation and Service business validation are both present where applicable - [ ] Errors use `e.NewBizError(e.Code)` sentinels in Service; API uses `errors.As` + `FailByError`; no `Fail(c, code, err.Error())` leaking internals - [ ] Logs use `*Ctx` variants - [ ] Cache invalidation after DB commit, not inside transaction -- [ ] Sensitive data masked +- [ ] Logs contain no credentials, tokens, secrets, or PII; configured access-log masking has tests where applicable - [ ] Tests appropriate to the change scope pass -- [ ] `make lint` passes +- [ ] Changed Go files are formatted; scoped tests pass; `make lint` passes or an unavailable local tool is reported without claiming success +- [ ] New dependencies are justified and reviewed for maintenance, license, security, and operational impact - [ ] Performance impact reviewed; hot paths have indexes, pagination, and bounded batch sizes - [ ] Indexes follow left-prefix rule - [ ] Interface behavior: cache-first reads when applicable, idempotent writes for retryable operations, clear degradation behavior -- [ ] Complex/important code has WHY comments -- [ ] README / AGENTS docs updated -- [ ] Deployment impact documented for config, database, queue, auth, or observability changes +- [ ] Complex or core code has concise Chinese WHY comments; simple code is not over-commented +- [ ] README / AGENTS docs updated when code behavior, configuration, APIs, or operational workflows change +- [ ] Compatibility and deployment impact documented for API, config, database, queue, auth, or observability changes diff --git a/README.md b/README.md index 6d0416a..315e86e 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,9 @@ make gen do=init # 生成所有表的 Model make gen do=add # 交互式添加指定表的 Model make gen do=update # 更新已有表的 Model make gen do=query # 重新生成 Query API +make new-module # 生成默认 admin/demo 示例模块 +make new-module module=order # 在 admin domain 生成 order 模块 +make new-module domain=business module=order # 在 business domain 生成 order 模块 make mysql-init # 初始化数据库数据 make mq-init # 声明 RabbitMQ 拓扑 @@ -388,19 +391,23 @@ GET /readyz # Readiness probe(服务是否就绪) ↓ make gen do=add / make gen do=update # 生成 Model + Query ↓ +make new-module module=order # 生成 DAO/Service/API/Router,并自动注册 DI 与路由 + ↓ 实现 DAO 层(统一 `*query.Query` 参数,事务内传 `tx`,事务外传 `repo.Query()` / `repo.WriteQuery()`) ↓ 实现 Service 层(业务逻辑、缓存、操作日志) ↓ 实现 API 层(参数绑定、校验、响应转换) ↓ -注册路由 + 鉴权/权限配置(internal/router/*_router.go;登录态接口使用 JWTAuth,敏感管理接口额外使用 PermissionAuth) +完善路由鉴权/权限配置(生成的详情示例已有 JWTAuth,敏感管理接口额外使用 PermissionAuth) ↓ -更新 DI 容器(internal/di/container.go) +按实际依赖扩展生成的模块 Container(internal/di/container.go) ``` > ⚠️ **警告**:`internal/dal/model/` 与 `internal/dal/query/` 为机器生成代码,**禁止手动编辑**。后续 `make gen` 会覆盖所有手动修改。 +模块生成器入口为 `cmd/modulegen`,默认使用 `domain=admin`、`module=demo`。它会生成 DAO/Service/API/Router 骨架、一个不访问数据库的 `GetXXXInfo` 示例,以及独立的模块 Container,并按现有分组自动注册 DI 和 Router;自定义 domain 的 Container 会增加域前缀以避免重名。目标文件、DI 或 Router 已存在同名注册时会在预检阶段报错并终止,新文件不会覆盖或删除已有内容。详情示例继承 domain 的 JWT 保护;正式业务仍需按实际场景补齐 `PermissionAuth`、业务错误码、真实查询逻辑和测试。 + --- ## 🌍 环境变量 diff --git a/cmd/modulegen/main.go b/cmd/modulegen/main.go new file mode 100644 index 0000000..e2d86ad --- /dev/null +++ b/cmd/modulegen/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "snowgo/internal/modulegen" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("modulegen", flag.ContinueOnError) + flags.SetOutput(stderr) + module := flags.String("module", "demo", "business module name") + domain := flags.String("domain", "admin", "business domain name") + root := flags.String("root", ".", "project root") + if err := flags.Parse(args); err != nil { + return 2 + } + + result, err := modulegen.Generate(modulegen.Options{ + Root: *root, + Domain: *domain, + Module: *module, + }) + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + for _, path := range result.Created { + if _, err := fmt.Fprintf(stdout, "created %s\n", path); err != nil { + return 1 + } + } + for _, path := range result.Updated { + if _, err := fmt.Fprintf(stdout, "updated %s\n", path); err != nil { + return 1 + } + } + return 0 +} diff --git a/cmd/modulegen/main_test.go b/cmd/modulegen/main_test.go new file mode 100644 index 0000000..b914505 --- /dev/null +++ b/cmd/modulegen/main_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunUsesDefaults(t *testing.T) { + root := newCommandFixture(t) + var stdout, stderr bytes.Buffer + + exitCode := run([]string{"-root", root}, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr = %q", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "internal/api/admin/demo/demo.go") { + t.Fatalf("stdout = %q, want default admin/demo output", stdout.String()) + } +} + +func newCommandFixture(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeCommandFixture(t, root, "go.mod", "module snowgo\n\ngo 1.26.0\n") + writeCommandFixture(t, root, "internal/di/container.go", `package di + +type Container struct { + AccountContainer + SystemContainer +} + +type AccountContainer struct { + UserService any +} + +type SystemContainer struct { + OperationLogService any +} + +func NewContainer() (*Container, error) { + repository := struct{}{} + userDao := accountDao.NewUserDao(repository) + operationLogDao := systemDao.NewOperationLogDao(repository) + operationLogService := systemService.NewOperationLogService(repository, operationLogDao) + userService := accountService.NewUserService(repository, userDao) + container := &Container{} + container.AccountContainer = AccountContainer{UserService: userService} + container.SystemContainer = SystemContainer{OperationLogService: operationLogService} + return container, nil +} + +func GetContainer(c *gin.Context) *Container { + return nil +} + +func GetAccountContainer(c *gin.Context) *AccountContainer { + container := GetContainer(c) + return &container.AccountContainer +} + +func GetSystemContainer(c *gin.Context) *SystemContainer { + container := GetContainer(c) + return &container.SystemContainer +} +`) + writeCommandFixture(t, root, "internal/router/admin/router.go", `package admin + +import "github.com/gin-gonic/gin" + +func Register(r *gin.RouterGroup) { + admin := r.Group("/admin") + protected := admin.Group("", middleware.JWTAuth()) + { + _ = protected + } +} +`) + writeCommandFixture(t, root, "internal/router/router.go", `package router + +import ( + "github.com/gin-gonic/gin" + "snowgo/internal/router/admin" +) + +type option func(*gin.RouterGroup) + +func loadRouter(apiGroup *gin.RouterGroup) { + options := []option{admin.Register} + for _, opt := range options { + opt(apiGroup) + } +} +`) + return root +} + +func writeCommandFixture(t *testing.T, root, relative, content string) { + t.Helper() + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", relative, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", relative, err) + } +} diff --git a/internal/modulegen/ast_di.go b/internal/modulegen/ast_di.go new file mode 100644 index 0000000..0be9c33 --- /dev/null +++ b/internal/modulegen/ast_di.go @@ -0,0 +1,277 @@ +package modulegen + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "sort" + "strings" + + "golang.org/x/tools/go/ast/astutil" +) + +type sourceInsertion struct { + offset int + content string +} + +func updateDI(source []byte, data templateData) ([]byte, error) { + withImports, err := addDIImports(source, data) + if err != nil { + return nil, err + } + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "container.go", withImports, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse container.go: %w", err) + } + insertions, err := collectDIInsertions(withImports, fset, file, data) + if err != nil { + return nil, err + } + updated := applySourceInsertions(withImports, insertions) + formatted, err := format.Source(updated) + if err != nil { + return nil, fmt.Errorf("format container.go: %w", err) + } + return formatted, nil +} + +func addDIImports(source []byte, data templateData) ([]byte, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "container.go", source, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse container.go: %w", err) + } + astutil.AddNamedImport(fset, file, data.DaoAlias, data.ModulePath+"/internal/dao/"+data.Domain+"/"+data.Module) + astutil.AddNamedImport(fset, file, data.ServiceAlias, data.ModulePath+"/internal/service/"+data.Domain+"/"+data.Module) + + var output bytes.Buffer + if err := format.Node(&output, fset, file); err != nil { + return nil, fmt.Errorf("format container.go imports: %w", err) + } + return output.Bytes(), nil +} + +func collectDIInsertions(source []byte, fset *token.FileSet, file *ast.File, data templateData) ([]sourceInsertion, error) { + embeddingOffset, declarationOffset, err := findContainerOffsets(source, fset, file, data) + if err != nil { + return nil, err + } + daoOffset, serviceOffset, initializationOffset, err := findConstructorOffsets(source, fset, file, data) + if err != nil { + return nil, err + } + getterOffset, err := findContainerGetterOffset(fset, file, data) + if err != nil { + return nil, err + } + + return []sourceInsertion{ + {offset: embeddingOffset, content: "\t" + data.ContainerName + "\n"}, + {offset: declarationOffset, content: fmt.Sprintf("\n\ntype %s struct {\n\t%sService *%s.%sService\n}", data.ContainerName, data.TypeName, data.ServiceAlias, data.TypeName)}, + {offset: daoOffset, content: fmt.Sprintf("\t%s := %s.New%sDao(repository)\n", data.DaoAlias, data.DaoAlias, data.TypeName)}, + {offset: serviceOffset, content: fmt.Sprintf("\t%s := %s.New%sService(repository, %s)\n", data.ServiceAlias, data.ServiceAlias, data.TypeName, data.DaoAlias)}, + {offset: initializationOffset, content: fmt.Sprintf("\t// %s\n\tcontainer.%s = %s{\n\t\t%sService: %s,\n\t}\n", data.Module, data.ContainerName, data.ContainerName, data.TypeName, data.ServiceAlias)}, + {offset: getterOffset, content: fmt.Sprintf("\n\n// Get%s 获取注入的%s service等\nfunc Get%s(c *gin.Context) *%s {\n\tcontainer := GetContainer(c)\n\treturn &container.%s\n}", data.ContainerName, data.Module, data.ContainerName, data.ContainerName, data.ContainerName)}, + }, nil +} + +func findContainerOffsets(source []byte, fset *token.FileSet, file *ast.File, data templateData) (int, int, error) { + embeddingOffset := -1 + declarationOffset := -1 + serviceName := data.TypeName + "Service" + for _, declaration := range file.Decls { + gen, ok := declaration.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + for _, spec := range gen.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if !ok { + continue + } + if typeSpec.Name.Name == data.ContainerName { + return 0, 0, fmt.Errorf("module %s already registered in Container", data.Module) + } + if strings.HasSuffix(typeSpec.Name.Name, "Container") { + declarationOffset = fset.Position(gen.End()).Offset + } + if typeSpec.Name.Name != "Container" { + continue + } + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return 0, 0, fmt.Errorf("container is not a struct in internal/di/container.go") + } + for _, field := range structType.Fields.List { + for _, name := range field.Names { + if name.Name == serviceName { + return 0, 0, fmt.Errorf("module %s already registered in Container", data.Module) + } + } + if len(field.Names) != 0 { + continue + } + name, ok := field.Type.(*ast.Ident) + if !ok { + continue + } + if name.Name == data.ContainerName { + return 0, 0, fmt.Errorf("module %s already registered in Container", data.Module) + } + if strings.HasSuffix(name.Name, "Container") { + embeddingOffset = afterSourceLine(source, fset.Position(field.End()).Offset) + } + } + } + } + if embeddingOffset < 0 { + return 0, 0, fmt.Errorf("container service section not found in internal/di/container.go") + } + if declarationOffset < 0 { + return 0, 0, fmt.Errorf("container type declarations not found in internal/di/container.go") + } + return embeddingOffset, declarationOffset, nil +} + +func findConstructorOffsets(source []byte, fset *token.FileSet, file *ast.File, data templateData) (int, int, int, error) { + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name.Name != "NewContainer" || function.Body == nil { + continue + } + if constructorRegistersModule(function.Body, data) { + return 0, 0, 0, fmt.Errorf("module %s already registered in Container", data.Module) + } + daoOffset := lastStatementLineOffset(source, fset, function.Body.List, func(statement ast.Stmt) bool { + return constructorSuffix(statement, "Dao") + }) + serviceOffset := lastStatementLineOffset(source, fset, function.Body.List, func(statement ast.Stmt) bool { + return constructorSuffix(statement, "Service") + }) + initializationOffset := lastStatementLineOffset(source, fset, function.Body.List, containerAssignment) + if daoOffset < 0 { + return 0, 0, 0, fmt.Errorf("DAO constructor section not found in internal/di/container.go") + } + if serviceOffset < 0 { + return 0, 0, 0, fmt.Errorf("service constructor section not found in internal/di/container.go") + } + if initializationOffset < 0 { + return 0, 0, 0, fmt.Errorf("container initialization section not found in internal/di/container.go") + } + return daoOffset, serviceOffset, initializationOffset, nil + } + return 0, 0, 0, fmt.Errorf("NewContainer not found in internal/di/container.go") +} + +func constructorRegistersModule(body *ast.BlockStmt, data templateData) bool { + registered := false + ast.Inspect(body, func(node ast.Node) bool { + assignment, ok := node.(*ast.AssignStmt) + if !ok { + return true + } + for _, expression := range assignment.Lhs { + switch value := expression.(type) { + case *ast.Ident: + if value.Name == data.DaoAlias || value.Name == data.ServiceAlias { + registered = true + return false + } + case *ast.SelectorExpr: + identifier, ok := value.X.(*ast.Ident) + if ok && identifier.Name == "container" && value.Sel.Name == data.ContainerName { + registered = true + return false + } + } + } + return !registered + }) + return registered +} + +func constructorSuffix(statement ast.Stmt, suffix string) bool { + assignment, ok := statement.(*ast.AssignStmt) + if !ok || assignment.Tok != token.DEFINE || len(assignment.Rhs) != 1 { + return false + } + call, ok := assignment.Rhs[0].(*ast.CallExpr) + if !ok { + return false + } + selector, ok := call.Fun.(*ast.SelectorExpr) + return ok && strings.HasPrefix(selector.Sel.Name, "New") && strings.HasSuffix(selector.Sel.Name, suffix) +} + +func containerAssignment(statement ast.Stmt) bool { + assignment, ok := statement.(*ast.AssignStmt) + if !ok || len(assignment.Lhs) != 1 { + return false + } + selector, ok := assignment.Lhs[0].(*ast.SelectorExpr) + if !ok || !strings.HasSuffix(selector.Sel.Name, "Container") { + return false + } + identifier, ok := selector.X.(*ast.Ident) + return ok && identifier.Name == "container" +} + +func lastStatementLineOffset(source []byte, fset *token.FileSet, statements []ast.Stmt, match func(ast.Stmt) bool) int { + offset := -1 + for _, statement := range statements { + if match(statement) { + offset = afterSourceLine(source, fset.Position(statement.End()).Offset) + } + } + return offset +} + +func findContainerGetterOffset(fset *token.FileSet, file *ast.File, data templateData) (int, error) { + getterName := "Get" + data.ContainerName + offset := -1 + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok { + continue + } + if function.Name.Name == getterName { + return 0, fmt.Errorf("module %s already registered in Container", data.Module) + } + if strings.HasPrefix(function.Name.Name, "Get") && strings.HasSuffix(function.Name.Name, "Container") { + offset = fset.Position(function.End()).Offset + } + } + if offset < 0 { + return 0, fmt.Errorf("container getter section not found in internal/di/container.go") + } + return offset, nil +} + +func afterSourceLine(source []byte, offset int) int { + if offset < 0 || offset >= len(source) { + return len(source) + } + if lineEnd := bytes.IndexByte(source[offset:], '\n'); lineEnd >= 0 { + return offset + lineEnd + 1 + } + return len(source) +} + +func applySourceInsertions(source []byte, insertions []sourceInsertion) []byte { + sort.SliceStable(insertions, func(i, j int) bool { + return insertions[i].offset > insertions[j].offset + }) + updated := append([]byte(nil), source...) + for _, insertion := range insertions { + updated = append(updated, make([]byte, len(insertion.content))...) + copy(updated[insertion.offset+len(insertion.content):], updated[insertion.offset:]) + copy(updated[insertion.offset:], insertion.content) + } + return updated +} diff --git a/internal/modulegen/ast_router.go b/internal/modulegen/ast_router.go new file mode 100644 index 0000000..3a86c60 --- /dev/null +++ b/internal/modulegen/ast_router.go @@ -0,0 +1,257 @@ +package modulegen + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "strconv" + "strings" + + "golang.org/x/tools/go/ast/astutil" +) + +func updateDomainRouter(source []byte, data templateData) ([]byte, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "router.go", source, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("parse domain router: %w", err) + } + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name.Name != "Register" || function.Body == nil { + continue + } + for index, statement := range function.Body.List { + assignment, ok := statement.(*ast.AssignStmt) + if !ok || !definesIdentifier(assignment, "protected") { + continue + } + if !usesMiddlewareJWTAuth(assignment) { + return nil, fmt.Errorf("protected group in domain router must use middleware.JWTAuth()") + } + for _, candidate := range function.Body.List[index+1:] { + block, ok := candidate.(*ast.BlockStmt) + if !ok { + continue + } + if hasModuleRouterCall(block, data.Module) { + return nil, fmt.Errorf("module %s already registered in domain router", data.Module) + } + return insertDomainRouterCall(source, fset, block, data.Module) + } + } + } + return nil, fmt.Errorf("protected Register block not found in domain router") +} + +func usesMiddlewareJWTAuth(assignment *ast.AssignStmt) bool { + found := false + ast.Inspect(assignment, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "JWTAuth" { + return true + } + identifier, ok := selector.X.(*ast.Ident) + if ok && identifier.Name == "middleware" { + found = true + return false + } + return true + }) + return found +} + +func hasModuleRouterCall(block *ast.BlockStmt, module string) bool { + want := module + "Routers" + for _, statement := range block.List { + expression, ok := statement.(*ast.ExprStmt) + if !ok { + continue + } + call, ok := expression.X.(*ast.CallExpr) + if !ok { + continue + } + function, ok := call.Fun.(*ast.Ident) + if ok && function.Name == want { + return true + } + } + return false +} + +func insertDomainRouterCall(source []byte, fset *token.FileSet, block *ast.BlockStmt, module string) ([]byte, error) { + offset := fset.Position(block.Rbrace).Offset + if offset < 0 || offset > len(source) { + return nil, fmt.Errorf("invalid protected block position in domain router") + } + lineStart := bytes.LastIndexByte(source[:offset], '\n') + 1 + indent := string(source[lineStart:offset]) + if strings.TrimSpace(indent) != "" { + return nil, fmt.Errorf("protected block closing brace must be on its own line") + } + insertion := indent + "\t" + module + "Routers(protected) // " + module + " 相关\n" + updated := make([]byte, 0, len(source)+len(insertion)) + updated = append(updated, source[:offset]...) + updated = append(updated, insertion...) + updated = append(updated, source[offset:]...) + formatted, err := format.Source(updated) + if err != nil { + return nil, fmt.Errorf("format domain router: %w", err) + } + return formatted, nil +} + +func updateRootRouter(source []byte, data templateData) ([]byte, bool, error) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "router.go", source, parser.ParseComments) + if err != nil { + return nil, false, fmt.Errorf("parse root router: %w", err) + } + + options := findOptionsLiteral(file) + if options == nil { + return nil, false, fmt.Errorf("options registration list not found in internal/router/router.go") + } + + importPath := data.ModulePath + "/internal/router/" + data.Domain + importAlias, exists, err := findImportAlias(file, importPath, data.Domain) + if err != nil { + return nil, false, err + } + if exists && hasRouterRegistration(options, importAlias) { + return source, false, nil + } + + withImport := source + if !exists { + astutil.AddNamedImport(fset, file, data.RouterAlias, importPath) + importAlias = data.RouterAlias + withImport, err = formatParsedFile(fset, file, "root router imports") + if err != nil { + return nil, false, err + } + } + + fset = token.NewFileSet() + file, err = parser.ParseFile(fset, "router.go", withImport, parser.ParseComments) + if err != nil { + return nil, false, fmt.Errorf("parse root router after imports: %w", err) + } + options = findOptionsLiteral(file) + if options == nil { + return nil, false, fmt.Errorf("options registration list not found in internal/router/router.go") + } + if hasRouterRegistration(options, importAlias) { + return withImport, !bytes.Equal(source, withImport), nil + } + content, err := insertRootRouterRegistration(withImport, fset, options, importAlias) + if err != nil { + return nil, false, err + } + return content, true, nil +} + +func findOptionsLiteral(file *ast.File) *ast.CompositeLit { + for _, declaration := range file.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Name.Name != "loadRouter" || function.Body == nil { + continue + } + var options *ast.CompositeLit + ast.Inspect(function.Body, func(node ast.Node) bool { + if options != nil { + return false + } + assignment, ok := node.(*ast.AssignStmt) + if !ok || !definesIdentifier(assignment, "options") || len(assignment.Rhs) != 1 { + return true + } + literal, ok := assignment.Rhs[0].(*ast.CompositeLit) + if ok { + options = literal + } + return false + }) + return options + } + return nil +} + +func insertRootRouterRegistration(source []byte, fset *token.FileSet, options *ast.CompositeLit, alias string) ([]byte, error) { + offset := fset.Position(options.Rbrace).Offset + if offset < 0 || offset > len(source) { + return nil, fmt.Errorf("invalid options position in root router") + } + lineStart := bytes.LastIndexByte(source[:offset], '\n') + 1 + indent := string(source[lineStart:offset]) + if strings.TrimSpace(indent) != "" { + return nil, fmt.Errorf("options closing brace must be on its own line in root router") + } + insertion := indent + "\t" + alias + ".Register,\n" + updated := make([]byte, 0, len(source)+len(insertion)) + updated = append(updated, source[:offset]...) + updated = append(updated, insertion...) + updated = append(updated, source[offset:]...) + formatted, err := format.Source(updated) + if err != nil { + return nil, fmt.Errorf("format root router: %w", err) + } + return formatted, nil +} + +func findImportAlias(file *ast.File, wantPath, defaultAlias string) (string, bool, error) { + for _, spec := range file.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil || path != wantPath { + continue + } + if spec.Name == nil { + return defaultAlias, true, nil + } + if spec.Name.Name == "." || spec.Name.Name == "_" { + return "", true, fmt.Errorf("unsupported import alias %q for %s", spec.Name.Name, wantPath) + } + return spec.Name.Name, true, nil + } + return "", false, nil +} + +func hasRouterRegistration(options *ast.CompositeLit, alias string) bool { + for _, element := range options.Elts { + selector, ok := element.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "Register" { + continue + } + identifier, ok := selector.X.(*ast.Ident) + if ok && identifier.Name == alias { + return true + } + } + return false +} + +func definesIdentifier(assignment *ast.AssignStmt, name string) bool { + for _, expression := range assignment.Lhs { + identifier, ok := expression.(*ast.Ident) + if ok && identifier.Name == name { + return true + } + } + return false +} + +func formatParsedFile(fset *token.FileSet, file *ast.File, label string) ([]byte, error) { + var output bytes.Buffer + if err := format.Node(&output, fset, file); err != nil { + return nil, fmt.Errorf("format %s: %w", label, err) + } + return output.Bytes(), nil +} diff --git a/internal/modulegen/generator.go b/internal/modulegen/generator.go new file mode 100644 index 0000000..3cb4991 --- /dev/null +++ b/internal/modulegen/generator.go @@ -0,0 +1,293 @@ +package modulegen + +import ( + "errors" + "fmt" + "go/token" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode" +) + +var packageNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*$`) + +var writeExistingFile = writeExistingFileAtomically + +type Options struct { + Root string + Domain string + Module string +} + +type Result struct { + Created []string + Updated []string +} + +func normalizeOptions(opts Options) (Options, error) { + if opts.Root == "" { + opts.Root = "." + } + if opts.Domain == "" { + opts.Domain = "admin" + } + if opts.Module == "" { + opts.Module = "demo" + } + if !validPackageName(opts.Domain) { + return Options{}, fmt.Errorf("invalid domain %q", opts.Domain) + } + if opts.Domain == "middleware" { + return Options{}, fmt.Errorf("reserved domain %q", opts.Domain) + } + if opts.Domain == "main" { + return Options{}, fmt.Errorf("reserved domain %q", opts.Domain) + } + if !validPackageName(opts.Module) { + return Options{}, fmt.Errorf("invalid module %q", opts.Module) + } + if opts.Module == "main" { + return Options{}, fmt.Errorf("reserved module %q", opts.Module) + } + return opts, nil +} + +func validPackageName(name string) bool { + return packageNamePattern.MatchString(name) && !token.Lookup(name).IsKeyword() +} + +// Generate creates and registers a business module skeleton. +func Generate(input Options) (*Result, error) { + opts, err := normalizeOptions(input) + if err != nil { + return nil, err + } + modulePath, err := readModulePath(opts.Root) + if err != nil { + return nil, err + } + typeName := exportedName(opts.Module) + containerName := typeName + "Container" + if opts.Domain != "admin" { + containerName = exportedName(opts.Domain) + containerName + } + data := templateData{ + ModulePath: modulePath, + Domain: opts.Domain, + Module: opts.Module, + TypeName: typeName, + ContainerName: containerName, + DaoAlias: opts.Domain + exportedName(opts.Module) + "Dao", + ServiceAlias: opts.Domain + exportedName(opts.Module) + "Service", + RouterAlias: opts.Domain + "Router", + } + + paths := map[string]string{ + "dao": filepath.Join("internal", "dao", opts.Domain, opts.Module, opts.Module+".go"), + "service": filepath.Join("internal", "service", opts.Domain, opts.Module, opts.Module+".go"), + "api": filepath.Join("internal", "api", opts.Domain, opts.Module, opts.Module+".go"), + "router": filepath.Join("internal", "router", opts.Domain, opts.Module+"_router.go"), + } + + rendered := make(map[string][]byte, len(paths)) + for _, name := range sortedStringMapKeys(paths) { + relative := paths[name] + path := filepath.Join(opts.Root, relative) + if _, err := os.Stat(path); err == nil { + return nil, fmt.Errorf("module already exists: %s", relative) + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("check %s: %w", relative, err) + } + content, err := renderModuleTemplate(name, data) + if err != nil { + return nil, fmt.Errorf("render %s: %w", relative, err) + } + rendered[relative] = content + } + + updated := make(map[string][]byte, 3) + originals := make(map[string][]byte, 3) + diRelative := filepath.Join("internal", "di", "container.go") + diSource, err := os.ReadFile(filepath.Join(opts.Root, diRelative)) // #nosec G304 -- root is the explicit local project directory and diRelative is generator-owned. + if err != nil { + return nil, fmt.Errorf("read %s: %w", diRelative, err) + } + updated[diRelative], err = updateDI(diSource, data) + if err != nil { + return nil, err + } + originals[diRelative] = diSource + + domainRouterRelative := filepath.Join("internal", "router", opts.Domain, "router.go") + domainRouterPath := filepath.Join(opts.Root, domainRouterRelative) + domainRouterSource, err := os.ReadFile(domainRouterPath) // #nosec G304 -- root is the explicit local project directory and domain is validated as a package name. + if err == nil { + updated[domainRouterRelative], err = updateDomainRouter(domainRouterSource, data) + if err != nil { + return nil, err + } + originals[domainRouterRelative] = domainRouterSource + } else if os.IsNotExist(err) { + rendered[domainRouterRelative], err = renderDomainRouter(data) + if err != nil { + return nil, fmt.Errorf("render %s: %w", domainRouterRelative, err) + } + } else { + return nil, fmt.Errorf("read %s: %w", domainRouterRelative, err) + } + + rootRouterRelative := filepath.Join("internal", "router", "router.go") + rootRouterSource, err := os.ReadFile(filepath.Join(opts.Root, rootRouterRelative)) // #nosec G304 -- root is the explicit local project directory and rootRouterRelative is generator-owned. + if err != nil { + return nil, fmt.Errorf("read %s: %w", rootRouterRelative, err) + } + rootRouterContent, rootRouterChanged, err := updateRootRouter(rootRouterSource, data) + if err != nil { + return nil, err + } + if rootRouterChanged { + updated[rootRouterRelative] = rootRouterContent + originals[rootRouterRelative] = rootRouterSource + } + + created := make([]string, 0, len(rendered)) + for _, relative := range sortedByteMapKeys(rendered) { + content := rendered[relative] + path := filepath.Join(opts.Root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { // #nosec G301 -- generated Go source directories must be readable by build and deployment users. + cleanupCreatedFiles(opts.Root, created) + return nil, fmt.Errorf("create directory for %s: %w", relative, err) + } + if err := writeNewFile(path, content); err != nil { + cleanupCreatedFiles(opts.Root, created) + if os.IsExist(err) { + return nil, fmt.Errorf("module already exists: %s", relative) + } + return nil, fmt.Errorf("write %s: %w", relative, err) + } + created = append(created, relative) + } + updatedPaths := make([]string, 0, len(updated)) + for _, relative := range sortedByteMapKeys(updated) { + content := updated[relative] + if err := writeExistingFile(filepath.Join(opts.Root, relative), content); err != nil { + cleanupCreatedFiles(opts.Root, created) + if rollbackErr := rollbackUpdatedFiles(opts.Root, updatedPaths, originals); rollbackErr != nil { + return nil, fmt.Errorf("write %s: %v; rollback updated files: %w", relative, err, rollbackErr) + } + return nil, fmt.Errorf("write %s: %w", relative, err) + } + updatedPaths = append(updatedPaths, relative) + } + return &Result{Created: created, Updated: updatedPaths}, nil +} + +func sortedStringMapKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func sortedByteMapKeys(values map[string][]byte) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func writeNewFile(path string, content []byte) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) // #nosec G302,G304 -- path is generator-owned; generated Go source files require standard 0644 permissions. + if err != nil { + return err + } + if _, err := file.Write(content); err != nil { + _ = file.Close() + _ = os.Remove(path) + return err + } + if err := file.Close(); err != nil { + _ = os.Remove(path) + return err + } + return nil +} + +func writeExistingFileAtomically(path string, content []byte) error { + info, err := os.Stat(path) + if err != nil { + return err + } + + file, err := os.CreateTemp(filepath.Dir(path), ".modulegen-*") + if err != nil { + return err + } + temporaryPath := file.Name() + closed := false + defer func() { + if !closed { + _ = file.Close() + } + _ = os.Remove(temporaryPath) + }() + + if err := file.Chmod(info.Mode().Perm()); err != nil { + return err + } + if _, err := file.Write(content); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + closed = true + return os.Rename(temporaryPath, path) // #nosec G703 -- both paths are derived from the same explicit local project directory. +} + +func cleanupCreatedFiles(root string, relativePaths []string) { + for _, relative := range relativePaths { + _ = os.Remove(filepath.Join(root, relative)) + } +} + +func rollbackUpdatedFiles(root string, relativePaths []string, originals map[string][]byte) error { + var restoreErrors []error + for index := len(relativePaths) - 1; index >= 0; index-- { + relative := relativePaths[index] + if err := writeExistingFileAtomically(filepath.Join(root, relative), originals[relative]); err != nil { + restoreErrors = append(restoreErrors, fmt.Errorf("restore %s: %w", relative, err)) + } + } + return errors.Join(restoreErrors...) +} + +func readModulePath(root string) (string, error) { + content, err := os.ReadFile(filepath.Join(root, "go.mod")) // #nosec G304 -- root is the explicit local project directory supplied to this CLI. + if err != nil { + return "", fmt.Errorf("read go.mod: %w", err) + } + for _, rawLine := range strings.Split(string(content), "\n") { + line := strings.TrimSpace(rawLine) + if strings.HasPrefix(line, "module ") { + path := strings.TrimSpace(strings.TrimPrefix(line, "module ")) + if path != "" { + return path, nil + } + } + } + return "", fmt.Errorf("module path not found in go.mod") +} + +func exportedName(name string) string { + runes := []rune(name) + runes[0] = unicode.ToUpper(runes[0]) + return string(runes) +} diff --git a/internal/modulegen/generator_test.go b/internal/modulegen/generator_test.go new file mode 100644 index 0000000..6c37c75 --- /dev/null +++ b/internal/modulegen/generator_test.go @@ -0,0 +1,602 @@ +package modulegen + +import ( + "errors" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOptionsValidation(t *testing.T) { + tests := []struct { + name string + opts Options + wantErr string + }{ + {name: "default options", opts: Options{}}, + {name: "explicit domain", opts: Options{Domain: "business", Module: "order2"}}, + {name: "uppercase module", opts: Options{Module: "Order"}, wantErr: "invalid module"}, + {name: "hyphen", opts: Options{Module: "sales-order"}, wantErr: "invalid module"}, + {name: "path separator", opts: Options{Module: "sales/order"}, wantErr: "invalid module"}, + {name: "keyword", opts: Options{Module: "type"}, wantErr: "invalid module"}, + {name: "leading digit", opts: Options{Module: "2order"}, wantErr: "invalid module"}, + {name: "main module", opts: Options{Module: "main"}, wantErr: "reserved module"}, + {name: "main domain", opts: Options{Domain: "main", Module: "order"}, wantErr: "reserved domain"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts, err := normalizeOptions(tt.opts) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("normalizeOptions error: %v", err) + } + if tt.opts.Domain == "" && opts.Domain != "admin" { + t.Fatalf("domain = %q, want admin", opts.Domain) + } + if tt.opts.Module == "" && opts.Module != "demo" { + t.Fatalf("module = %q, want demo", opts.Module) + } + }) + } +} + +func TestGenerateModuleNamedGinUsesSafeAPIAlias(t *testing.T) { + root := newFixtureProject(t) + if _, err := Generate(Options{Root: root, Module: "gin"}); err != nil { + t.Fatalf("Generate error: %v", err) + } + + content := readFixtureFile(t, root, "internal/router/admin/gin_router.go") + for _, want := range []string{ + `"github.com/gin-gonic/gin"`, + `moduleAPI "snowgo/internal/api/admin/gin"`, + `ginGroup.GET("/:id", moduleAPI.GetGinInfo)`, + } { + if !strings.Contains(content, want) { + t.Errorf("gin router missing %q:\n%s", want, content) + } + } +} + +func TestGenerateFiles(t *testing.T) { + root := newFixtureProject(t) + result, err := Generate(Options{Root: root, Module: "order"}) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + want := []string{ + "internal/dao/admin/order/order.go", + "internal/service/admin/order/order.go", + "internal/api/admin/order/order.go", + "internal/router/admin/order_router.go", + } + for _, relative := range want { + path := filepath.Join(root, relative) + if _, err := os.Stat(path); err != nil { + t.Fatalf("generated file %s: %v", relative, err) + } + if _, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.AllErrors); err != nil { + t.Fatalf("parse generated file %s: %v", relative, err) + } + } + if len(result.Created) != len(want) { + t.Fatalf("created files = %v, want %v", result.Created, want) + } + + serviceContent := readFixtureFile(t, root, "internal/service/admin/order/order.go") + for _, fragment := range []string{ + "type OrderInfo struct", + "func (s *OrderService) GetOrderInfo(_ context.Context, id int32) (*OrderInfo, error)", + "return &OrderInfo{ID: id}, nil", + } { + if !strings.Contains(serviceContent, fragment) { + t.Errorf("service missing %q:\n%s", fragment, serviceContent) + } + } + + apiContent := readFixtureFile(t, root, "internal/api/admin/order/order.go") + for _, fragment := range []string{ + "type OrderInfo struct", + "func GetOrderInfo(c *gin.Context)", + "id := xgin.ParsePathID32(c)", + "container := di.GetOrderContainer(c)", + "container.OrderService.GetOrderInfo(ctx, id)", + "xresponse.FailByError(c, e.HttpBadRequest)", + "xresponse.FailByError(c, e.HttpInternalServerError)", + "xresponse.Success(c, &OrderInfo{ID: info.ID})", + } { + if !strings.Contains(apiContent, fragment) { + t.Errorf("api missing %q:\n%s", fragment, apiContent) + } + } + + routerContent := readFixtureFile(t, root, "internal/router/admin/order_router.go") + for _, fragment := range []string{ + `moduleAPI "snowgo/internal/api/admin/order"`, + `orderGroup.GET("/:id", moduleAPI.GetOrderInfo)`, + "详情示例(仅需 JWTAuth,正式业务按需增加 PermissionAuth)", + } { + if !strings.Contains(routerContent, fragment) { + t.Errorf("module router missing %q:\n%s", fragment, routerContent) + } + } +} + +func TestGenerateRegistersDI(t *testing.T) { + root := newFixtureProject(t) + result, err := Generate(Options{Root: root, Module: "order"}) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + content := readFixtureFile(t, root, "internal/di/container.go") + wants := []string{ + `adminOrderDao "snowgo/internal/dao/admin/order"`, + `adminOrderService "snowgo/internal/service/admin/order"`, + "SystemContainer\n\tOrderContainer", + `type OrderContainer struct`, + `OrderService *adminOrderService.OrderService`, + `adminOrderDao := adminOrderDao.NewOrderDao(repository)`, + `adminOrderService := adminOrderService.NewOrderService(repository, adminOrderDao)`, + "// order\n\tcontainer.OrderContainer", + `container.OrderContainer = OrderContainer{`, + `OrderService: adminOrderService`, + `// GetOrderContainer 获取注入的order service等`, + `func GetOrderContainer(c *gin.Context) *OrderContainer`, + `return &container.OrderContainer`, + } + for _, want := range wants { + if !strings.Contains(content, want) { + t.Errorf("container.go missing %q:\n%s", want, content) + } + } + if !containsString(result.Updated, "internal/di/container.go") { + t.Fatalf("updated files = %v, want internal/di/container.go", result.Updated) + } + assertBefore(t, content, "operationLogDao :=", "adminOrderDao :=") + assertBefore(t, content, "userService :=", "adminOrderService :=") + assertBefore(t, content, "container.SystemContainer =", "container.OrderContainer =") +} + +func TestGenerateRegistersAdminRouter(t *testing.T) { + root := newFixtureProject(t) + result, err := Generate(Options{Root: root, Module: "order"}) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + content := readFixtureFile(t, root, "internal/router/admin/router.go") + if !strings.Contains(content, "orderRouters(protected)") { + t.Fatalf("admin router missing order registration:\n%s", content) + } + assertBefore(t, content, "accountRouters(protected)", "systemRouters(protected)") + assertBefore(t, content, "systemRouters(protected)", "orderRouters(protected)") + if !strings.Contains(content, "orderRouters(protected) // order 相关") { + t.Fatalf("admin router missing order comment:\n%s", content) + } + if !containsString(result.Updated, "internal/router/admin/router.go") { + t.Fatalf("updated files = %v", result.Updated) + } + if containsString(result.Updated, "internal/router/router.go") { + t.Fatalf("root router should remain unchanged when admin is already registered: %v", result.Updated) + } + rootRouter := readFixtureFile(t, root, "internal/router/router.go") + if strings.Count(rootRouter, "admin.Register") != 1 { + t.Fatalf("admin registration should appear once:\n%s", rootRouter) + } +} + +func TestGenerateRegistersCustomDomainRouter(t *testing.T) { + root := newFixtureProject(t) + result, err := Generate(Options{Root: root, Domain: "business", Module: "order"}) + if err != nil { + t.Fatalf("Generate error: %v", err) + } + + domainRouter := readFixtureFile(t, root, "internal/router/business/router.go") + if !strings.Contains(domainRouter, "orderRouters(protected)") { + t.Fatalf("business router missing order registration:\n%s", domainRouter) + } + rootRouter := readFixtureFile(t, root, "internal/router/router.go") + if !strings.Contains(rootRouter, `businessRouter "snowgo/internal/router/business"`) { + t.Fatalf("root router missing business import:\n%s", rootRouter) + } + if !strings.Contains(rootRouter, "businessRouter.Register") { + t.Fatalf("root router missing business registration:\n%s", rootRouter) + } + for _, want := range []string{ + "admin.Register, // 后台管理相关路由", + "businessRouter.Register,", + "// 注册其他分组下的路由", + } { + if !strings.Contains(rootRouter, want) { + t.Errorf("root router should preserve %q:\n%s", want, rootRouter) + } + } + for _, path := range []string{"internal/router/business/router.go", "internal/router/router.go"} { + if !containsString(result.Created, path) && !containsString(result.Updated, path) { + t.Fatalf("result does not include %s: %+v", path, result) + } + } +} + +func TestGenerateRegistersExistingCustomDomainInRootRouter(t *testing.T) { + root := newFixtureProject(t) + writeFixtureFile(t, root, "internal/router/business/router.go", `package business + +import "github.com/gin-gonic/gin" + +func Register(r *gin.RouterGroup) { + business := r.Group("/business") + protected := business.Group("", middleware.JWTAuth()) + { + _ = protected + } +} +`) + + if _, err := Generate(Options{Root: root, Domain: "business", Module: "order"}); err != nil { + t.Fatalf("Generate error: %v", err) + } + + rootRouter := readFixtureFile(t, root, "internal/router/router.go") + if !strings.Contains(rootRouter, "businessRouter.Register") { + t.Fatalf("root router missing business registration:\n%s", rootRouter) + } +} + +func TestGenerateRejectsUnprotectedExistingDomainRouter(t *testing.T) { + root := newFixtureProject(t) + routerPath := "internal/router/business/router.go" + writeFixtureFile(t, root, routerPath, `package business + +import "github.com/gin-gonic/gin" + +func Register(r *gin.RouterGroup) { + business := r.Group("/business") + protected := business.Group("") + { + _ = protected + } +} +`) + originalDI := readFixtureFile(t, root, "internal/di/container.go") + + _, err := Generate(Options{Root: root, Domain: "business", Module: "order"}) + if err == nil || !strings.Contains(err.Error(), "middleware.JWTAuth") { + t.Fatalf("error = %v, want middleware.JWTAuth", err) + } + if got := readFixtureFile(t, root, "internal/di/container.go"); got != originalDI { + t.Fatal("DI changed after unprotected Router rejection") + } + if _, err := os.Stat(filepath.Join(root, "internal/api/business/order/order.go")); !os.IsNotExist(err) { + t.Fatalf("API should not be created, stat error = %v", err) + } +} + +func TestGenerateRefusesDuplicateRouterRegistration(t *testing.T) { + root := newFixtureProject(t) + routerPath := "internal/router/admin/router.go" + writeFixtureFile(t, root, routerPath, `package admin + +import "github.com/gin-gonic/gin" + +func Register(r *gin.RouterGroup) { + admin := r.Group("/admin") + protected := admin.Group("", middleware.JWTAuth()) + { + accountRouters(protected) + systemRouters(protected) + orderRouters(protected) + } +} +`) + originalDI := readFixtureFile(t, root, "internal/di/container.go") + originalRouter := readFixtureFile(t, root, routerPath) + + _, err := Generate(Options{Root: root, Module: "order"}) + if err == nil || !strings.Contains(err.Error(), "already registered") { + t.Fatalf("error = %v, want already registered", err) + } + if got := readFixtureFile(t, root, "internal/di/container.go"); got != originalDI { + t.Fatal("DI changed after duplicate Router rejection") + } + if got := readFixtureFile(t, root, routerPath); got != originalRouter { + t.Fatal("Router changed after duplicate Router rejection") + } + for _, relative := range []string{ + "internal/dao/admin/order/order.go", + "internal/service/admin/order/order.go", + "internal/api/admin/order/order.go", + "internal/router/admin/order_router.go", + } { + if _, err := os.Stat(filepath.Join(root, relative)); !os.IsNotExist(err) { + t.Fatalf("%s should not exist, stat error = %v", relative, err) + } + } +} + +func TestGenerateUsesDomainQualifiedDIAliases(t *testing.T) { + root := newFixtureProject(t) + if _, err := Generate(Options{Root: root, Domain: "business", Module: "system"}); err != nil { + t.Fatalf("Generate error: %v", err) + } + + content := readFixtureFile(t, root, "internal/di/container.go") + for _, want := range []string{ + `businessSystemDao "snowgo/internal/dao/business/system"`, + `businessSystemService "snowgo/internal/service/business/system"`, + `BusinessSystemContainer`, + `func GetBusinessSystemContainer(c *gin.Context) *BusinessSystemContainer`, + } { + if !strings.Contains(content, want) { + t.Errorf("container.go missing %q:\n%s", want, content) + } + } +} + +func TestGenerateRejectsMiddlewareDomain(t *testing.T) { + _, err := normalizeOptions(Options{Domain: "middleware", Module: "order"}) + if err == nil || !strings.Contains(err.Error(), "reserved domain") { + t.Fatalf("error = %v, want reserved domain", err) + } +} + +func TestGenerateRefusesOverwrite(t *testing.T) { + root := newFixtureProject(t) + writeFixtureFile(t, root, "internal/api/admin/order/order.go", "package order\n") + originalDI := readFixtureFile(t, root, "internal/di/container.go") + + _, err := Generate(Options{Root: root, Module: "order"}) + if err == nil || !strings.Contains(err.Error(), "module already exists") { + t.Fatalf("error = %v, want module already exists", err) + } + if got := readFixtureFile(t, root, "internal/di/container.go"); got != originalDI { + t.Fatal("DI changed after overwrite rejection") + } + if _, err := os.Stat(filepath.Join(root, "internal/dao/admin/order/order.go")); !os.IsNotExist(err) { + t.Fatalf("DAO should not be created, stat error = %v", err) + } +} + +func TestGeneratePreflightFailureWritesNothing(t *testing.T) { + root := newFixtureProject(t) + writeFixtureFile(t, root, "internal/di/container.go", "package di\nfunc broken(") + + _, err := Generate(Options{Root: root, Module: "order"}) + if err == nil || !strings.Contains(err.Error(), "parse container.go") { + t.Fatalf("error = %v, want parse container.go", err) + } + for _, relative := range []string{ + "internal/dao/admin/order/order.go", + "internal/service/admin/order/order.go", + "internal/api/admin/order/order.go", + "internal/router/admin/order_router.go", + } { + if _, err := os.Stat(filepath.Join(root, relative)); !os.IsNotExist(err) { + t.Fatalf("%s should not exist, stat error = %v", relative, err) + } + } +} + +func TestGenerateRollsBackUpdatedFilesAfterWriteFailure(t *testing.T) { + root := newFixtureProject(t) + diPath := "internal/di/container.go" + routerPath := filepath.Join(root, "internal/router/admin/router.go") + originalDI := readFixtureFile(t, root, diPath) + originalRouter := readFixtureFile(t, root, "internal/router/admin/router.go") + originalWriteExistingFile := writeExistingFile + writeExistingFile = func(path string, content []byte) error { + if path == routerPath { + return errors.New("injected Router write failure") + } + return originalWriteExistingFile(path, content) + } + t.Cleanup(func() { writeExistingFile = originalWriteExistingFile }) + + _, err := Generate(Options{Root: root, Module: "order"}) + if err == nil || !strings.Contains(err.Error(), "write internal/router/admin/router.go") { + t.Fatalf("error = %v, want Router write failure", err) + } + if got := readFixtureFile(t, root, diPath); got != originalDI { + t.Fatal("DI was not restored after Router write failure") + } + if got := readFixtureFile(t, root, "internal/router/admin/router.go"); got != originalRouter { + t.Fatal("Router changed after failed write") + } + for _, relative := range []string{ + "internal/dao/admin/order/order.go", + "internal/service/admin/order/order.go", + "internal/api/admin/order/order.go", + "internal/router/admin/order_router.go", + } { + if _, err := os.Stat(filepath.Join(root, relative)); !os.IsNotExist(err) { + t.Fatalf("%s should be cleaned up, stat error = %v", relative, err) + } + } +} + +func TestGenerateRefusesExistingRegistration(t *testing.T) { + root := newFixtureProject(t) + writeFixtureFile(t, root, "internal/di/container.go", `package di + +type Container struct { + OrderService int +} + +func NewContainer() (*Container, error) { + repository := struct{}{} + _ = repository + container := &Container{} + return container, nil +} +`) + + _, err := Generate(Options{Root: root, Module: "order"}) + if err == nil || !strings.Contains(err.Error(), "already registered") { + t.Fatalf("error = %v, want already registered", err) + } + if _, err := os.Stat(filepath.Join(root, "internal/dao/admin/order/order.go")); !os.IsNotExist(err) { + t.Fatalf("DAO should not be created, stat error = %v", err) + } +} + +func TestGenerateRefusesExistingDIConstructorVariable(t *testing.T) { + root := newFixtureProject(t) + diPath := "internal/di/container.go" + originalDI := readFixtureFile(t, root, diPath) + updatedDI := strings.Replace( + originalDI, + "operationLogDao := systemDao.NewOperationLogDao(repository)", + "operationLogDao := systemDao.NewOperationLogDao(repository)\n\tadminOrderDao := accountDao.NewUserDao(repository)\n\t_ = adminOrderDao", + 1, + ) + writeFixtureFile(t, root, diPath, updatedDI) + originalRouter := readFixtureFile(t, root, "internal/router/admin/router.go") + + _, err := Generate(Options{Root: root, Module: "order"}) + if err == nil || !strings.Contains(err.Error(), "already registered") { + t.Fatalf("error = %v, want already registered", err) + } + if got := readFixtureFile(t, root, diPath); got != updatedDI { + t.Fatal("DI changed after duplicate constructor variable rejection") + } + if got := readFixtureFile(t, root, "internal/router/admin/router.go"); got != originalRouter { + t.Fatal("Router changed after duplicate constructor variable rejection") + } + if _, err := os.Stat(filepath.Join(root, "internal/api/admin/order/order.go")); !os.IsNotExist(err) { + t.Fatalf("API should not be created, stat error = %v", err) + } +} + +func newFixtureProject(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeFixtureFile(t, root, "go.mod", "module snowgo\n\ngo 1.26.0\n") + writeFixtureFile(t, root, "internal/di/container.go", `package di + +type Container struct { + AccountContainer + SystemContainer +} + +type AccountContainer struct { + UserService any +} + +type SystemContainer struct { + OperationLogService any +} + +// NewContainer 构造所有依赖 +func NewContainer() (*Container, error) { + repository := struct{}{} + // 构造Dao + userDao := accountDao.NewUserDao(repository) + operationLogDao := systemDao.NewOperationLogDao(repository) + // 构造Service依赖 + operationLogService := systemService.NewOperationLogService(repository, operationLogDao) + userService := accountService.NewUserService(repository, userDao) + container := &Container{} + // account + container.AccountContainer = AccountContainer{UserService: userService} + // system + container.SystemContainer = SystemContainer{OperationLogService: operationLogService} + return container, nil +} + +func GetContainer(c *gin.Context) *Container { + return nil +} + +func GetAccountContainer(c *gin.Context) *AccountContainer { + container := GetContainer(c) + return &container.AccountContainer +} + +func GetSystemContainer(c *gin.Context) *SystemContainer { + container := GetContainer(c) + return &container.SystemContainer +} +`) + writeFixtureFile(t, root, "internal/router/admin/router.go", `package admin + +import "github.com/gin-gonic/gin" + +func Register(r *gin.RouterGroup) { + admin := r.Group("/admin") + protected := admin.Group("", middleware.JWTAuth()) + { + accountRouters(protected) // 账户相关 + systemRouters(protected) // 系统设置相关 + } +} +`) + writeFixtureFile(t, root, "internal/router/router.go", `package router + +import ( + "github.com/gin-gonic/gin" + "snowgo/internal/router/admin" +) + +type option func(*gin.RouterGroup) + +func loadRouter(apiGroup *gin.RouterGroup) { + options := []option{ // 服务分组路由 + admin.Register, // 后台管理相关路由 + } + // 注册其他分组下的路由 + for _, opt := range options { + opt(apiGroup) + } +} +`) + return root +} + +func writeFixtureFile(t *testing.T, root, relative, content string) { + t.Helper() + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", relative, err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", relative, err) + } +} + +func readFixtureFile(t *testing.T, root, relative string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join(root, relative)) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + return string(content) +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func assertBefore(t *testing.T, content, first, second string) { + t.Helper() + firstIndex := strings.Index(content, first) + secondIndex := strings.Index(content, second) + if firstIndex < 0 || secondIndex < 0 || firstIndex >= secondIndex { + t.Fatalf("want %q before %q:\n%s", first, second, content) + } +} diff --git a/internal/modulegen/templates.go b/internal/modulegen/templates.go new file mode 100644 index 0000000..70c6e38 --- /dev/null +++ b/internal/modulegen/templates.go @@ -0,0 +1,154 @@ +package modulegen + +import ( + "bytes" + "go/format" + "text/template" +) + +type templateData struct { + ModulePath string + Domain string + Module string + TypeName string + ContainerName string + DaoAlias string + ServiceAlias string + RouterAlias string +} + +var moduleTemplates = map[string]string{ + "dao": `package {{.Module}} + +import "{{.ModulePath}}/internal/dal/repo" + +type {{.TypeName}}Dao struct { + repo *repo.Repository +} + +func New{{.TypeName}}Dao(repository *repo.Repository) *{{.TypeName}}Dao { + return &{{.TypeName}}Dao{repo: repository} +} +`, + "service": `package {{.Module}} + +import ( + "context" + + "{{.ModulePath}}/internal/dal/repo" + {{.Module}}Dao "{{.ModulePath}}/internal/dao/{{.Domain}}/{{.Module}}" +) + +type {{.TypeName}}Service struct { + db *repo.Repository + {{.Module}}Dao *{{.Module}}Dao.{{.TypeName}}Dao +} + +type {{.TypeName}}Info struct { + ID int32 +} + +func New{{.TypeName}}Service(repository *repo.Repository, dao *{{.Module}}Dao.{{.TypeName}}Dao) *{{.TypeName}}Service { + return &{{.TypeName}}Service{db: repository, {{.Module}}Dao: dao} +} + +// Get{{.TypeName}}Info 获取{{.Module}}详情示例 +func (s *{{.TypeName}}Service) Get{{.TypeName}}Info(_ context.Context, id int32) (*{{.TypeName}}Info, error) { + return &{{.TypeName}}Info{ID: id}, nil +} +`, + "api": `package {{.Module}} + +import ( + "errors" + + "github.com/gin-gonic/gin" + "{{.ModulePath}}/internal/di" + e "{{.ModulePath}}/pkg/xerror" + "{{.ModulePath}}/pkg/xgin" + "{{.ModulePath}}/pkg/xlogger" + "{{.ModulePath}}/pkg/xresponse" +) + +type {{.TypeName}}Info struct { + ID int32 ` + "`json:\"id\"`" + ` +} + +// Get{{.TypeName}}Info 获取{{.Module}}详情示例 +func Get{{.TypeName}}Info(c *gin.Context) { + id := xgin.ParsePathID32(c) + if id < 1 { + xresponse.FailByError(c, e.HttpBadRequest) + return + } + ctx := c.Request.Context() + + container := di.Get{{.ContainerName}}(c) + info, err := container.{{.TypeName}}Service.Get{{.TypeName}}Info(ctx, id) + if err != nil { + var bizErr *e.BizError + if errors.As(err, &bizErr) { + xresponse.FailByError(c, bizErr.Code) + return + } + xlogger.ErrorfCtx(ctx, "get {{.Module}} info is err: %v", err) + xresponse.FailByError(c, e.HttpInternalServerError) + return + } + xresponse.Success(c, &{{.TypeName}}Info{ID: info.ID}) +} +`, + "router": `package {{.Domain}} + +import ( + "github.com/gin-gonic/gin" + moduleAPI "{{.ModulePath}}/internal/api/{{.Domain}}/{{.Module}}" +) + +func {{.Module}}Routers(r *gin.RouterGroup) { + {{.Module}}Group := r.Group("/{{.Module}}") + // 详情示例(仅需 JWTAuth,正式业务按需增加 PermissionAuth) + {{.Module}}Group.GET("/:id", moduleAPI.Get{{.TypeName}}Info) +} + `, +} + +const domainRouterTemplate = `package {{.Domain}} + +import ( + "github.com/gin-gonic/gin" + "{{.ModulePath}}/internal/router/middleware" +) + +func Register(r *gin.RouterGroup) { + {{.Domain}} := r.Group("/{{.Domain}}") + protected := {{.Domain}}.Group("", middleware.JWTAuth()) + { + {{.Module}}Routers(protected) // {{.Module}} 相关 + } +} +` + +func renderModuleTemplate(name string, data templateData) ([]byte, error) { + tmpl, err := template.New(name).Parse(moduleTemplates[name]) + if err != nil { + return nil, err + } + var output bytes.Buffer + if err := tmpl.Execute(&output, data); err != nil { + return nil, err + } + return format.Source(output.Bytes()) +} + +func renderDomainRouter(data templateData) ([]byte, error) { + tmpl, err := template.New("domain-router").Parse(domainRouterTemplate) + if err != nil { + return nil, err + } + var output bytes.Buffer + if err := tmpl.Execute(&output, data); err != nil { + return nil, err + } + return format.Source(output.Bytes()) +}