diff --git a/.ai/README.md b/.ai/README.md index 5563a37..2e8beae 100644 --- a/.ai/README.md +++ b/.ai/README.md @@ -37,10 +37,10 @@ Also always: `ai-contract.md`, `forbidden.md`, `file-map.md`, `workflow.md`, `co ├── database.md ├── ui.md # IA pointer → design/ ├── design/ # AI Design System Contract -│ ├── design-system.md -│ ├── component-catalog.md -│ └── platform/ -├── deployment.md +├── environment.md # Local / CI / Prod 分离 +├── development.md # 本地本机开发 +├── docker.md # Docker 用途边界 +├── deployment.md # 生产 / CI 部署 ├── testing.md ├── security.md ├── review.md diff --git a/.ai/ROADMAP.md b/.ai/ROADMAP.md index ccfd7ec..86535c8 100644 --- a/.ai/ROADMAP.md +++ b/.ai/ROADMAP.md @@ -5,12 +5,19 @@ Do **not** build everything at once. Rules have maintenance cost. ## Phase 1 — Now (shipped) - Rules: constitution, architecture, domain, coding, api, database, ui, … +- **Environment layer (FROZEN):** `environment.md` · `development.md` · `docker.md` · `deployment.md` · `commands.md` - ADR, patterns, examples, playbooks -- Review / DoD / checklists / forbidden / commands / workflow / file-map +- Review / DoD / checklists / forbidden / workflow / file-map - `ai-contract.md` - Cursor runtime: `.cursor/*` → symlink → `.ai/` (no content copy) -**Invest next engineering time in:** polishing `patterns/`, `examples/`, `playbooks/` while building real features — not more rule files. +**Invest next time in product implementation, not more meta-rules:** + +``` +产品冻结 → Domain → ERD → OpenAPI → 页面路由 → P1 编码 +``` + +Do **not** expand the engineering-spec layer unless a repeated AI failure demands it. ## Phase 2 — When the codebase grows diff --git a/.ai/ai-contract.md b/.ai/ai-contract.md index 336d755..39e34c0 100644 --- a/.ai/ai-contract.md +++ b/.ai/ai-contract.md @@ -11,8 +11,9 @@ MUST read: 3. `.ai/domain.md` (+ `domain/domain-map.md` when touching models/API) 4. `.ai/coding.md` 5. Task-relevant: `api.md` / `database.md` / `ui.md` / `security.md` / `product/lexicon.md` + `product/feature-map.md` -6. UI tasks: `design/design-system.md` + `design/component-catalog.md` + `design/platform/*` -7. Relevant ADR under `.ai/adr/` if changing stack or style +6. Run/dev tasks: `environment.md` + `development.md`(部署/镜像任务才读 `deployment.md` / `docker.md`) +7. UI tasks: `design/design-system.md` + `design/component-catalog.md` + `design/platform/*` +8. Relevant ADR under `.ai/adr/` if changing stack or style ## After coding @@ -30,6 +31,7 @@ MUST: - Guess product requirement - Invent endpoints, tables, or domain words not in `.ai/domain.md` / OpenAPI / task - Silently reverse an Accepted ADR +- Default local workflow to full-stack Docker / rebuild app images on every code change ## If unclear diff --git a/.ai/commands.md b/.ai/commands.md index 65b5087..9da953c 100644 --- a/.ai/commands.md +++ b/.ai/commands.md @@ -1,67 +1,79 @@ # Commands — Do Not Guess -Run from repo root unless noted. +Run from **repo root** unless noted. +Policy: [environment.md](environment.md) · [development.md](development.md) -## Go API (`apps/api`) +本仓库用 **npm workspaces**(`npm run …`);等价于文档中的 pnpm 习惯用法。 + +--- + +## Local Development — First time + +1. Install host toolchains: Go 1.22+ · Node 20+ · Docker Desktop(仅依赖服务) +2. `npm install` +3. `npm run deps:up`(或 `docker compose -f docker-compose.dev.yml up -d`) +4. Start API once so migrations apply: `cd apps/api && go run ./cmd/server` +5. Verify: `curl -s http://127.0.0.1:8080/api/v1/healthz` +6. Start H5: `npm run dev:h5` → http://127.0.0.1:5173 + +Optional CN Go proxy: `export GOPROXY=https://goproxy.cn,direct` +Optional API hot reload: install `air`, then `cd apps/api && air`(见 `.air.toml`) + +--- + +## Local Development — Daily ```bash -export GOPROXY=https://goproxy.cn,direct # if download timeout +# Terminal 1 — infrastructure only +npm run deps:up +# Terminal 2 — backend (host) +cd apps/api +air +# 或: go run ./cmd/server + +# Terminal 3 — frontend (host) +npm run dev:h5 +``` + +Stop deps: `npm run deps:down` + +--- + +## Go API + +```bash cd apps/api go mod tidy -go build ./... go test ./... +go build ./... go run ./cmd/server ``` -Health: +Env defaults / template: `deploy/.env.example` +Migrate: applied on API startup (`apps/api/migrations/*.up.sql`) ```bash curl -s http://127.0.0.1:8080/api/v1/healthz curl -s http://127.0.0.1:8080/api/v1/ping ``` +**Do not** `docker build` the API for everyday coding. + +--- + ## User H5 ```bash -npm install npm run dev:h5 npm run build:h5 ``` -## Docker / DB +**Do not** dockerize Vite for everyday coding. -```bash -docker compose -f deploy/docker-compose.yml up -d -docker compose -f deploy/docker-compose.yml down -``` +--- -## Lint (when configured) +## CI / production -```bash -# Go — after golangci-lint is added: -# cd apps/api && golangci-lint run - -# H5 — after eslint is added: -# npm run lint -w @yuxingu/user-h5 -``` - -## Migrations (when make targets exist) - -```bash -# Preferred once Makefile lands: -# make migrate -# make migrate-down - -# Until then: document the goose/migrate command used in the PR. -``` - -## Dev all (local) - -```bash -# terminal 1 -cd apps/api && go run ./cmd/server - -# terminal 2 -npm run dev:h5 -``` +见 [deployment.md](deployment.md) · [docker.md](docker.md)。 +仅在发布/CI 任务使用镜像与 `deploy/docker-compose.prod.yml`。 diff --git a/.ai/constitution.md b/.ai/constitution.md index badd1cd..68aa5d2 100644 --- a/.ai/constitution.md +++ b/.ai/constitution.md @@ -14,6 +14,7 @@ 8. Every feature deployable. 9. Every API documented (OpenAPI or apps/docs). 10. Every database change versioned (migration required). +11. **Local feedback speed > local environment purity.** 本地开发追求分钟级反馈;生产/CI 追求环境一致。二者禁止混用工作流(见 `.ai/environment.md`)。 ## Product Laws (YuXinGu) diff --git a/.ai/database.md b/.ai/database.md index 0664502..da3b7c5 100644 --- a/.ai/database.md +++ b/.ai/database.md @@ -1,9 +1,10 @@ # Database — Golden Rules +Schema source of truth for P1 tables: [domain/erd.md](domain/erd.md). + ## Naming - `snake_case` only for tables and columns. -- Good: `user_profile`, `payment_order`, `user_session`. - Forbidden prefixes: `tbl_`, `t_`. ## Required columns on business tables @@ -17,9 +18,8 @@ deleted_at ## Types -- Never `varchar(5000)` as a habit. - Prefer bounded `varchar(n)`. -- `TEXT` only when necessary (long content bodies). +- `TEXT` only when necessary. - Timestamps: `timestamptz`. ## Keys & indexes @@ -27,13 +27,22 @@ deleted_at - Every foreign key indexed. - Unique business keys enforced with UNIQUE. -## Migrations +## Migration Rules(强制) -- Schema change without migration = incomplete feature. -- Migrations live in `apps/api/migrations/`. -- Provide Up and Down when tool supports it. +1. **Every schema change requires a migration** under `apps/api/migrations/`. +2. **Never** modify production (or shared) databases by hand (`ALTER TABLE` in psql as a substitute for migration). +3. **Migration files are immutable** after merge to `main` — fix forward with a new migration; do not rewrite history on shared branches. +4. **Destructive changes** (drop column/table, type narrowing) require an explicit rollback/forward strategy in the same change set (Down file or follow-up migration + note). +5. App code must not query columns that are not yet migrated. +6. Local apply: start API(auto-migrate)or documented migrate command in [commands.md](commands.md). ## Soft delete -- Default: set `deleted_at`, do not hard delete user content in MVP. -- Queries must filter `deleted_at IS NULL` unless explicitly including deleted. +- Default: set `deleted_at`; do not hard-delete user content in P1. +- Queries filter `deleted_at IS NULL` unless the task says otherwise. + +## AI MUST NOT + +- 「先改库再补 migration」 +- 在 handler 里拼临时 DDL +- 发明未在 `domain.md` / `erd.md` 出现的表名(先改文档) diff --git a/.ai/deployment.md b/.ai/deployment.md index bee3573..63524f2 100644 --- a/.ai/deployment.md +++ b/.ai/deployment.md @@ -1,33 +1,86 @@ -# Deployment — Golden Rules +# Production & CI Deployment — AI Contract -## Containers +本文件只约束 **CI** 与 **Production**。 +本地编码见 [development.md](development.md);Docker 角色见 [docker.md](docker.md)。 -- Every runnable service that ships to prod must have a Dockerfile. -- Never use image tag `latest` in production. -- Always semantic version tags: `v0.1.0` or git SHA. +--- -## Health +## Production Rules -- Liveness: `GET /api/v1/healthz` required. -- When DB is required for traffic: add readiness endpoint (e.g. `/api/v1/readyz`) that checks DB. -- Alias names `/health` `/readiness` `/liveness` may map to the above; keep one canonical path documented in OpenAPI. +Production **MUST** use immutable images. -## Config +### MUST NOT(生产) -- Secrets via env only. Commit `.env.example`, never `.env`. -- Required: `APP_ENV`, `HTTP_ADDR`, `DATABASE_URL`. +- 服务器上 `git pull` 后直接编译运行当主发布路径 +- 手工在机器上 `npm install` / `go mod download` 当发布步骤 +- 登录容器改代码或改依赖冒充发布 +- 使用镜像 tag `latest` +- 把本地 `.env` / 开发密钥打进镜像 -## CI +### Happy path -- No manual prod deploy as the happy path — CI builds and tags. -- Minimum gates: `go test ./...` (api), `npm run build:h5`. - -## Local - -```bash -docker compose -f deploy/docker-compose.yml up -d -cd apps/api && go run ./cmd/server -npm run dev:h5 +``` +Git Push → CI → Test → Build Image → Registry → Deploy → Health Check ``` -If Go module download times out in CN: `export GOPROXY=https://goproxy.cn,direct`. +### Health + +- Liveness: `GET /api/v1/healthz`(必须) +- Readiness(有 DB 流量时): `GET /api/v1/readyz`(检查 DB;可后补) +- Canonical paths 写入 OpenAPI + +### Config + +- Secrets **仅**环境变量 / 密钥管理系统 +- 提交 `deploy/.env.example`,永不提交 `.env` +- 生产必备示例:`APP_ENV=prod` `HTTP_ADDR` `DATABASE_URL`(及日后支付/JWT 密钥) + +### Containers + +- 可运行的生产服务最终应有 Dockerfile(`deploy/Dockerfile.*`) +- Tag:`v0.x.y` 或 git SHA + +### Deployment Target(预留,勿提前复杂化) + +| Phase | Target | When | +|---|---|---| +| **1** | Docker Compose on a VPS | 首版上线默认 | +| **2** | Managed containers(云厂商容器服务) | 运维成本上去时 | +| **3** | Kubernetes | 明确有多服务/扩缩容需求时 | + +P1 **不要**引入 K8s。缺的 Dockerfile / prod compose **按发布任务再加**,不为本地写代码先造全套镜像工作流。 + +### Topology(Phase 1 目标) + +``` +Docker Compose (VPS) +├── Go API container +├── Nginx(user-h5 静态或反代) +├── PostgreSQL +├── Redis(需要时) +└── Object storage(需要时) +``` + +--- + +## CI Rules + +CI 优先 **一致性**: + +- `go test ./...`(`apps/api`) +- `npm run build:h5` +- (有 Dockerfile 后)build 镜像 smoke +- 不在 CI 里要求开发者本机 Docker Desktop 才能合并文档/纯前端 PR(按 job 需要) + +--- + +## Local vs Prod(对照) + +| Topic | Local | Production | +|---|---|---| +| Go / Vue | 本机热更 | 镜像 | +| Postgres | `docker-compose.dev.yml` | 托管或 compose/k8s | +| 反馈 | 分钟级 | 发布质量与回滚 | +| 配置 | `.env.local` / dev | 密钥系统 / prod env | + +禁止把右栏流程套到左栏日常开发。 diff --git a/.ai/development.md b/.ai/development.md new file mode 100644 index 0000000..bbfc18c --- /dev/null +++ b/.ai/development.md @@ -0,0 +1,105 @@ +# Local Development Rules — AI Contract + +**目标:** 快速修改、快速验证、快速调试(分钟级反馈)。 + +**不是目标:** 把笔记本变成迷你生产集群。 + +总原则见 [environment.md](environment.md)。 + +--- + +## MUST + +1. Go API:本机 `go run` / **air** 热更新。 +2. user-h5:本机 **Node + Vite**(`npm run dev:h5`)。 +3. 依赖数据服务(Postgres;日后 Redis 等):`docker compose -f docker-compose.dev.yml up -d`。 +4. 配置:根目录或 `apps/api` 使用 `.env` / `.env.local`(不提交);模板见 `deploy/.env.example`。 +5. H5 通过 Vite proxy 访问本机 API(`/api` → `127.0.0.1:8080`),无需把前端放进容器。 +6. 验证优先:`go test`、`curl healthz`、浏览器 / Vite,而不是先写 Dockerfile。 + +## MUST NOT + +- 每次改 Go/Vue 代码就 `docker build` 应用镜像再跑。 +- 用 production image / `APP_ENV=prod` 做日常开发。 +- `docker compose` 启动 **api + web** 作为默认本地工作流(除非任务明确是「验证 compose 集成」)。 +- 将本地环境完全等同生产(密钥、域名、副本数、对象存储真集群等)。 +- 把密钥写进 compose 或镜像层。 + +--- + +## Recommended local topology + +``` +Host (macOS / Linux) +├── Go 1.22+ +│ ├── go run ./cmd/server 或 air +│ └── delve(可选调试) +├── Node 20+(npm workspaces;可选 pnpm) +│ └── Vite → :5173 +└── Docker Desktop(仅服务) + ├── PostgreSQL :5432 + ├── Redis(需要时再加) + └── Object storage(需要时再加) +``` + +--- + +## Go Backend + +| Item | Local | +|---|---| +| Runtime | 本机 Go | +| Entry | `apps/api` | +| Hot reload | 推荐 [air](https://github.com/air-verse/air);配置 `.air.toml`(可选) | +| Env | `HTTP_ADDR=:8080` `DATABASE_URL=postgres://yuxingu:yuxingu@127.0.0.1:5432/yuxingu?sslmode=disable` `APP_ENV=dev` | +| Migrate | API 启动时自动应用 `apps/api/migrations/*.up.sql` | +| CN proxy | `export GOPROXY=https://goproxy.cn,direct` | + +```bash +# terminal — deps +docker compose -f docker-compose.dev.yml up -d + +# terminal — API +cd apps/api +go run ./cmd/server +# 或: air +``` + +## Vue H5 + +| Item | Local | +|---|---| +| Runtime | 本机 Node | +| Dev | `npm run dev:h5` → http://127.0.0.1:5173 | +| Build check | `npm run build:h5` | + +```bash +npm install +npm run dev:h5 +``` + +## Database / deps only + +```bash +docker compose -f docker-compose.dev.yml up -d # postgres +docker compose -f docker-compose.dev.yml down +docker compose -f docker-compose.dev.yml logs -f postgres +``` + +只起依赖,**不起** api / web / worker。 + +--- + +## Auth note (P1 local) + +访客身份:`X-Device-Key`(H5 存 `localStorage.yxg_device_key`)。 +无需为本地开发先搭完整 JWT,除非任务是登录竖切。 + +--- + +## Before claiming Done (local) + +- [ ] 未引入「Docker-only 改代码」流程 +- [ ] API 本机可 `healthz` +- [ ] H5 本机 Vite 可打开 +- [ ] DB 用 compose.dev,而非把业务进程塞进同一默认 compose diff --git a/.ai/docker.md b/.ai/docker.md new file mode 100644 index 0000000..55c627e --- /dev/null +++ b/.ai/docker.md @@ -0,0 +1,70 @@ +# Docker Policy — AI Contract + +## Docker SHOULD be used for + +| ✓ | Examples | +|---|---| +| 依赖数据服务 | PostgreSQL | +| 缓存 | Redis(需要时) | +| 消息队列 | 需要时再加 | +| 对象存储 | MinIO / S3 兼容(需要时) | +| 第三方依赖模拟 | mailhog 等(需要时) | +| 生产/CI 应用交付 | 不可变 api / web 镜像 | + +## Docker SHOULD NOT be used for + +| ✗ | Why | +|---|---| +| Go API 日常开发 | 拖慢反馈;用本机 `go run` / `air` | +| Vue H5 日常开发 | 拖慢 HMR;用本机 Vite | +| Hot reload 工作流 | 禁止「改代码 → docker build → run」 | +| 把本机当成迷你 K8s | 违反 constitution:本地反馈速度优先 | + +见 [development.md](development.md) · [environment.md](environment.md)。 + +--- + +## Local compose(deps only) + +| File | Role | +|---|---| +| `docker-compose.dev.yml`(仓库根) | **默认本地**:仅依赖服务 | +| `deploy/docker-compose.yml` | 与 dev 对齐的依赖入口(兼容旧命令);生产见 prod 文件 | + +```bash +docker compose -f docker-compose.dev.yml up -d +``` + +**允许的服务(local):** `postgres`(+ 需要时 `redis` / `minio`) +**禁止默认加入(local):** `api` · `user-h5` · `worker` · `nginx` + +--- + +## Production / CI images + +| Artifact | Location (target) | +|---|---| +| Backend Dockerfile | `deploy/Dockerfile.api`(按需新增) | +| H5/Nginx Dockerfile | `deploy/Dockerfile.user-h5`(按需新增) | +| Prod compose / K8s | `deploy/docker-compose.prod.yml` 等 | + +生产镜像规则见 [deployment.md](deployment.md):禁止 `latest`;语义化版本或 git SHA。 + +--- + +## AI MUST NOT + +- 把「本地开发」文档写成只能 `docker compose up` 全栈。 +- 在业务 PR 里强制同事每次改代码都 build 应用镜像。 +- 将 `.env` 秘密 `COPY` 进镜像。 +- 混用 `docker-compose.dev.yml` 与 prod 环境变量文件。 + +--- + +## When full-stack compose is OK + +仅当任务明确是: + +- 验证 prod compose / 镜像入口 +- CI integration job +- 演示「一键依赖+应用」给非开发角色(仍非日常编码默认) diff --git a/.ai/environment.md b/.ai/environment.md new file mode 100644 index 0000000..6956df9 --- /dev/null +++ b/.ai/environment.md @@ -0,0 +1,62 @@ +# Environment Policy — AI Contract + +项目环境分为三类,**禁止混用配置与工作流**: + +| Environment | Goal | Typical tools | +|---|---|---| +| **1. Local Development** | 分钟级反馈、快速改代码 | 本机 Go / Node + Docker **仅依赖服务** | +| **2. CI** | 一致性、可重复验证 | 容器构建、单测、集成测 | +| **3. Production Deployment** | 稳定、不可变交付 | 镜像 + Compose/K8s | + +## Principles + +1. **Local 优先开发效率** — 改一行代码应秒级/十秒级可见,禁止「每次改代码都 docker build」。 +2. **CI 优先一致性** — 用干净环境证明可构建、可测试。 +3. **Production 优先稳定性** — 不可变镜像;禁止服务器上 `git pull && go build` 当主路径。 + +## AI MUST + +- 本地写 Go / Vue 时默认 **本机 runtime**(见 [development.md](development.md))。 +- Docker 本地用途默认 = **依赖服务**(Postgres 等),见 [docker.md](docker.md)。 +- 部署相关只改 [deployment.md](deployment.md) / `deploy/*`,不把 prod 流程套到本地编码。 + +## AI MUST NOT + +- 假设「有 Docker = 本地必须 compose 起 api/web」。 +- 为改一行业务代码要求 rebuild application image。 +- 把 `.env` / 生产密钥写进镜像或提交进库。 +- 用 production `APP_ENV` / 生产 `DATABASE_URL` 跑本地热更。 + +--- + +## Configuration Ownership + +| File | Owner | Committed? | +|---|---|---| +| `deploy/.env.example`(或根 `.env.example`) | 模板:键名 + 无秘密默认值 | **Yes** | +| `.env.local` | 本地覆盖 | **No** | +| `.env.test` / CI secrets | CI 注入或加密变量 | 密钥 **No** | +| `.env.production` / 平台密钥 | 部署系统注入 | **No** | + +### Rules + +- Code **never** contains environment-specific secret values. +- Local values stay in `.env.local`(已被 `.gitignore` 的 `*.local` / `.env` 覆盖)。 +- Production values are injected by the deployment system — not copied from a laptop. +- `.env*` files containing secrets must **never** be committed. +- Do not load `.env.production` in local `air` / Vite. +- Prefer one template (`.env.example`);按环境注入,勿把三套真密钥放进仓库。 + +--- + +## Freeze + +环境层(本文件 + `development.md` + `docker.md` + `deployment.md` + `commands.md` 相关部分)**已冻结**。 +除非真实踩坑,否则不再横向扩工程规范;下一投入点 = P1 领域实现(schema / API / 页面)。 + +## Related + +- Local: [development.md](development.md) +- Docker role: [docker.md](docker.md) +- Prod/CI deploy: [deployment.md](deployment.md) +- Commands: [commands.md](commands.md) diff --git a/.ai/file-map.md b/.ai/file-map.md index ba6a47b..4f8dd42 100644 --- a/.ai/file-map.md +++ b/.ai/file-map.md @@ -54,6 +54,18 @@ No raw `fetch` in `pages/`. Rules, ADR, patterns, examples, playbooks, checklists. Not runtime code. +### Environment docs + +| Path | Owns | +|---|---| +| `environment.md` | Local vs CI vs Prod policy | +| `development.md` | Host Go / Vite local rules | +| `docker.md` | When Docker is / is not used | +| `deployment.md` | Prod immutable images / CI | +| `commands.md` | Copy-paste commands | + +Runtime compose for **deps only**: repo-root `docker-compose.dev.yml`. + ### .ai/product/ & .ai/domain/ | Path | Owns | diff --git a/.ai/forbidden.md b/.ai/forbidden.md index 37cd39b..6510565 100644 --- a/.ai/forbidden.md +++ b/.ai/forbidden.md @@ -19,14 +19,21 @@ AI obeys NEVER rules strictly. - Never invent response shapes other than `{code,message,data}`. - Never use POST for pure read/query. - Never skip migration when schema changes. +- Never hand-apply schema changes on shared/prod DB instead of migrations. +- Never edit already-merged migration files on `main` (fix forward). - Never hard-delete user PII without explicit task (use soft delete). -## Security / deploy +## Security / deploy / environment - Never hardcode passwords, tokens, or secrets. - Never commit `.env` or private keys. - Never use Docker image tag `latest` in production. - Never log full birthday + answers payloads casually. +- Never introduce a **Docker-only** local coding workflow for Go/Vue. +- Never require application **image rebuild** after every local code change. +- Never put local secrets into Docker images. +- Never mix local/dev config with production config or prod compose as daily default. +- Never assume `docker compose up` must start api + web for development. ## Product / process diff --git a/.ai/product/ENGINEERING-FREEZE.md b/.ai/product/ENGINEERING-FREEZE.md index 84d10f6..9c531b3 100644 --- a/.ai/product/ENGINEERING-FREEZE.md +++ b/.ai/product/ENGINEERING-FREEZE.md @@ -15,7 +15,9 @@ | 9 | Go 服务边界 | `architecture/go-services.md` | | 10 | Design System | `design/design-system.md` | -下一步:**按 P1 竖切实现**(Profile → Portrait → RelationInsight → Scale → Commerce mock → Ask 壳),不再横向加功能。 +环境规范层已冻结(`.ai/environment.md` 等)— **停止扩展工程 meta 文档**。 + +下一步:**按 P1 竖切实现**(Profile → Portrait → RelationInsight → Scale → Commerce mock → Ask 壳),不再横向加规范或加功能面。 ## Review 清单(合并前必过) diff --git a/.ai/review.md b/.ai/review.md index 6b47231..0fdbefc 100644 --- a/.ai/review.md +++ b/.ai/review.md @@ -17,6 +17,7 @@ After coding, check every item. Output a Review block. | Deployment | Migration / health / env noted if needed? | | Docs | OpenAPI / PRD touch if public behavior changed? | | Design | UI change? Tokens + catalog + platform contract followed? | +| Environment | Local workflow still host Go/Vite + deps-only compose? No Docker-only coding? | | Scope | No unrelated files modified? | ## Output format (required) @@ -34,6 +35,7 @@ After coding, check every item. Output a Review block. - Deployment: PASS | FAIL — - Docs: PASS | FAIL — - Design: PASS | FAIL | N/A — +- Environment: PASS | FAIL | N/A — - Scope: PASS | FAIL — ``` diff --git a/.ai/workflow.md b/.ai/workflow.md index bb337f3..0e6d962 100644 --- a/.ai/workflow.md +++ b/.ai/workflow.md @@ -1,33 +1,48 @@ -# Workflow — Default Development Loop +# Workflow — Feature Development Flow + +默认循环。环境:本机 Go/Vite + deps-only Docker(见 [development.md](development.md))。 ``` Receive task ↓ -Read AI Contract + constitution + architecture + domain +1. Understand + - feature-map / user-journey / lexicon(产品) + - domain-map / erd / OpenAPI(数据与契约) + - page-tree(路由) ↓ -Architecture Check (ADR + file-map + layers) +2. Design(若有缺口) + - Update OpenAPI / erd / migration 草案 + - ADR if stack/API style changes ↓ -Choose playbook (add-api / new-page / new-table / …) +3. Implement + - Backend(service 竖切)→ Frontend + - Follow patterns + playbooks ↓ -Coding (follow patterns + examples) +4. Verify + - commands(test / health / build:h5) + - review.md + - DoD + checklist ↓ -Commands (build / test / health) +5. Document + - OpenAPI / erd / feature 标记若行为变化 ↓ -Review (.ai/review.md) - ↓ -DoD + checklist - ↓ -Commit (Conventional Commits, one concern) +Commit(Conventional Commits,one concern) ``` -## Architecture Check questions +## Architecture Check -1. Does this change reverse an Accepted ADR? If yes → ASK / new ADR. -2. Correct layer? Handler / Service / Repository? -3. Domain words match `.ai/domain.md`? -4. API envelope still `{code,message,data}`? +1. Reverse Accepted ADR? → ASK / new ADR. +2. Correct layer? Handler / Service / Repository? +3. Domain words = `.ai/domain.md` + lexicon for UI? +4. Envelope still `{code,message,data}`? +5. Local workflow still host runtime(非 Docker-only 编码)? ## Stop conditions -- Unclear requirement → ASK FIRST -- Need GraphQL / new DB / new auth scheme → write or cite ADR before coding +- Unclear requirement → **ASK FIRST** +- Need GraphQL / new auth scheme / new datastore → ADR before coding +- Urge to “just ALTER TABLE” → write migration instead + +## Playbooks + +Prefer: `add-api` · `new-page` · `new-table` · `payment` · `login` diff --git a/.cursor/rules/development.md b/.cursor/rules/development.md new file mode 120000 index 0000000..05339e3 --- /dev/null +++ b/.cursor/rules/development.md @@ -0,0 +1 @@ +../../.ai/development.md \ No newline at end of file diff --git a/.cursor/rules/docker.md b/.cursor/rules/docker.md new file mode 120000 index 0000000..d546cd5 --- /dev/null +++ b/.cursor/rules/docker.md @@ -0,0 +1 @@ +../../.ai/docker.md \ No newline at end of file diff --git a/.cursor/rules/environment.md b/.cursor/rules/environment.md new file mode 120000 index 0000000..360b1eb --- /dev/null +++ b/.cursor/rules/environment.md @@ -0,0 +1 @@ +../../.ai/environment.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index ee362fd..0c977d3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist/ # Go apps/api/bin/ +apps/api/tmp/ *.exe # IDE diff --git a/AGENTS.md b/AGENTS.md index 378d732..487cfea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,15 +11,16 @@ This file is for AI agents. 3. `.ai/architecture.md` 4. `.ai/domain.md` (+ product/API tasks: `domain/domain-map.md`) 5. `.ai/forbidden.md` -6. `.ai/file-map.md` + `.ai/workflow.md` -7. Task rules: `.ai/coding.md` / `api.md` / `database.md` / `ui.md` / `security.md` / … -8. **Product scope:** `.ai/product/lexicon.md` + `feature-map.md` + `user-journey.md` + `page-tree.md`(冻结见 `ENGINEERING-FREEZE.md`) -9. **Any UI work:** `.ai/design/design-system.md` + `component-catalog.md` + matching `design/platform/*.md` (H5 default) -10. Matching **ADR** in `.ai/adr/` before changing stack or API style -11. Prefer **patterns/** + **examples/** + **playbooks/** over inventing structure -12. Before Done: `.ai/review.md` + `.ai/definition-of-done.md` + `.ai/checklists/*` -13. Verify via `.ai/commands.md` -14. `prompts/` are optional helpers — not a substitute for rules above +6. `.ai/environment.md` + `.ai/development.md`(本地)· 部署任务再读 `deployment.md` / `docker.md` +7. `.ai/file-map.md` + `.ai/workflow.md` +8. Task rules: `.ai/coding.md` / `api.md` / `database.md` / `ui.md` / `security.md` / … +9. **Product scope:** `.ai/product/lexicon.md` + `feature-map.md` + `user-journey.md` + `page-tree.md`(冻结见 `ENGINEERING-FREEZE.md`) +10. **Any UI work:** `.ai/design/design-system.md` + `component-catalog.md` + matching `design/platform/*.md` (H5 default) +11. Matching **ADR** in `.ai/adr/` before changing stack or API style +12. Prefer **patterns/** + **examples/** + **playbooks/** over inventing structure +13. Before Done: `.ai/review.md` + `.ai/definition-of-done.md` + `.ai/checklists/*` +14. Verify via `.ai/commands.md`(本地默认本机 Go/Vite + compose.dev 仅 DB) +15. `prompts/` are optional helpers — not a substitute for rules above ## Hard constraints @@ -30,6 +31,7 @@ This file is for AI agents. - New code only under `apps/` and `packages/` (unless legacy migration task). - One concern per change set. Function ≤50 lines. File ≤400 lines. - Print the Review block when a coding task finishes. +- **Never** make Docker-only local coding the default (see `.ai/environment.md`). ## Cursor diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ccb6a8c..65b0a60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,30 +2,39 @@ ## AI-first -This project is driven by the **AI Engineering System** under [`.ai/`](.ai/). -Agents must load [AGENTS.md](AGENTS.md) and `.ai/*` before coding. +本仓库由 [`.ai/`](.ai/) 约束。入口:[AGENTS.md](AGENTS.md)。 -Human product docs (PRD, business) stay in `apps/docs/`. -Do **not** treat `apps/docs/standards/` as the source of truth — it only points here for history. +环境分流(必读): -## Local +- [`.ai/environment.md`](.ai/environment.md) — Local / CI / Prod 分离 +- [`.ai/development.md`](.ai/development.md) — 本地本机 Go + Vite +- [`.ai/docker.md`](.ai/docker.md) — Docker 只做依赖与部署 +- [`.ai/deployment.md`](.ai/deployment.md) — 生产不可变镜像 + +## Local(默认) ```bash -# optional CN Go proxy -export GOPROXY=https://goproxy.cn,direct +# 1) 依赖服务 only +docker compose -f docker-compose.dev.yml up -d -cd apps/api && go mod tidy && go run ./cmd/server +# 2) API — 本机 Go(不要 docker build api) +export GOPROXY=https://goproxy.cn,direct # 如需要 +cd apps/api && go run ./cmd/server +# 可选热更: air + +# 3) H5 — 本机 Vite(不要 docker build web) npm install && npm run dev:h5 -docker compose -f deploy/docker-compose.yml up -d # postgres ``` +- API: http://127.0.0.1:8080/api/v1/healthz +- H5: http://127.0.0.1:5173 + ## Git -Trunk-Based: short `feature/*` / `fix/*` into `main`. -Commits: Conventional Commits (`feat:`, `fix:`, `docs:`, …). +Trunk-Based;Conventional Commits(`feat:` / `fix:` / `docs:` …)。 ## PR -- One feature point. -- Include Review checklist results when AI-assisted. -- API changes update `proto/openapi.yaml`. +- 一个功能点。 +- API 变更同步 `proto/openapi.yaml`。 +- 勿把「本地必须全量 compose 起 api/web」写进新文档。 diff --git a/README.md b/README.md index 22a1cda..bd30d7b 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,50 @@ # 愈心谷(YuXinGu) -数字性格 × 中医体质。Monorepo:Go API + Vue3 H5,预留小程序。 +个人成长平台 · Monorepo:Go API + Vue3 H5(预留小程序)。 -## AI Engineering System(规范真相源) +## AI Engineering System -本仓库是 **AI-first**:不用长篇《开发手册》当主约束,而用可执行的 **`.ai/`**: +规范真源在 [`.ai/`](.ai/)。入口:[AGENTS.md](AGENTS.md)。 -``` -.ai/ - ai-contract.md · constitution · architecture · domain - file-map · workflow · forbidden · commands - coding / api / database / ui / deployment / testing / security - review · definition-of-done - adr/ patterns/ examples/ playbooks/ checklists/ - prompts/ # 辅助,非核心 -``` +**环境分流(重要):** -优先级:constitution → architecture → domain → patterns/examples/playbooks → review/DoD → prompts。 -Cursor 运行时:[.cursor/](.cursor/)(软链到 `.ai/`,不复制内容)。 -演进节奏:[.ai/ROADMAP.md](.ai/ROADMAP.md)(二/三阶段:anti-patterns、metrics、graph、mcp、skills…按需再加)。 -入口:[AGENTS.md](AGENTS.md) · [CLAUDE.md](CLAUDE.md) · [.ai/README.md](.ai/README.md) +| 文档 | 用途 | +|---|---| +| [.ai/environment.md](.ai/environment.md) | Local / CI / Prod 总原则 | +| [.ai/development.md](.ai/development.md) | 本地:本机 Go + Vite | +| [.ai/docker.md](.ai/docker.md) | Docker:依赖服务 + 部署,非日常写代码 | +| [.ai/deployment.md](.ai/deployment.md) | 生产:不可变镜像 | -产品文档(给人看):[prd-mvp](apps/docs/prd-mvp.md) · [business-model](apps/docs/business-model.md) +本地追求**分钟级反馈**;生产追求**环境一致**。二者禁止混用。 -## 快速开始 +## 快速开始(Local) ```bash -export GOPROXY=https://goproxy.cn,direct # 若需要 +# 依赖(仅 Postgres) +docker compose -f docker-compose.dev.yml up -d -docker compose -f deploy/docker-compose.yml up -d # 可选 -cd apps/api && go mod tidy && go run ./cmd/server +# API — 本机 +export GOPROXY=https://goproxy.cn,direct # 如需要 +cd apps/api && go run ./cmd/server # → http://127.0.0.1:8080/api/v1/healthz +# H5 — 本机(另开终端) npm install && npm run dev:h5 # → http://127.0.0.1:5173 ``` +不要用 `docker build` 跑日常 Go/Vue 开发。命令详表:[.ai/commands.md](.ai/commands.md)。 + ## 目录 | 路径 | 说明 | |---|---| -| `.ai/` | AI 工程系统(必须遵守) | +| `.ai/` | AI 工程系统 | | `apps/api` | Go 后端 | | `apps/user-h5` | 用户 H5 | -| `apps/mini-program` | 小程序脚手架 | | `packages/*` | sdk / types / utils / ui | -| 根目录 HTML / `server.py` | Legacy,见 [LEGACY.md](LEGACY.md) | +| `docker-compose.dev.yml` | 本地依赖服务 | +| `deploy/` | 部署与 env 模板 | +| 根目录 Legacy HTML | 见 [LEGACY.md](LEGACY.md) | + +产品文档:[apps/docs](apps/docs/) · 语言契约:[.ai/product/lexicon.md](.ai/product/lexicon.md) diff --git a/apps/api/.air.toml b/apps/api/.air.toml new file mode 100644 index 0000000..95b038a --- /dev/null +++ b/apps/api/.air.toml @@ -0,0 +1,21 @@ +# Optional hot reload: install `air`, then run from apps/api: air +# https://github.com/air-verse/air +# Local only — never use as production process manager. +# Policy: .ai/development.md + +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go build -o ./tmp/main ./cmd/server" + bin = "./tmp/main" + include_ext = ["go", "tpl", "tmpl", "html"] + exclude_dir = ["tmp", "vendor", "testdata"] + delay = 800 + stop_on_error = true + +[log] + time = false + +[misc] + clean_on_exit = true diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index fb1a0a4..8425c46 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -2,14 +2,21 @@ package main import ( + "context" "log" "os" + "path/filepath" + "time" "github.com/gin-gonic/gin" "github.com/yuxingu/digital-psychology/apps/api/internal/config" + "github.com/yuxingu/digital-psychology/apps/api/internal/db" "github.com/yuxingu/digital-psychology/apps/api/internal/handler" "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/profile" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/report" "github.com/yuxingu/digital-psychology/apps/api/pkg/response" ) @@ -19,20 +26,52 @@ func main() { gin.SetMode(gin.ReleaseMode) } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + pool, err := db.Connect(ctx, cfg.DatabaseURL) + if err != nil { + log.Printf("database unavailable: %v", err) + log.Printf("hint: docker compose -f deploy/docker-compose.yml up -d") + os.Exit(1) + } + defer pool.Close() + + migDir := os.Getenv("MIGRATIONS_DIR") + if migDir == "" { + migDir = filepath.Join("migrations") + } + if err := db.Migrate(ctx, pool, migDir); err != nil { + log.Printf("migrate: %v", err) + os.Exit(1) + } + + profileSvc := &profile.Service{Repo: &repository.ProfileRepo{Pool: pool}} + reportSvc := &report.Service{ + Profiles: &repository.ProfileRepo{Pool: pool}, + Reports: &repository.ReportRepo{Pool: pool}, + } + r := gin.New() r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID()) + r.Use(func(c *gin.Context) { + c.Header("Access-Control-Expose-Headers", "X-Device-Key, X-Request-Id") + c.Next() + }) api := r.Group("/api/v1") handler.NewHealthHandler().Register(api) - - // Placeholder route to demonstrate unified envelope. api.GET("/ping", func(c *gin.Context) { response.OK(c, gin.H{"pong": true}) }) - addr := cfg.HTTPAddr - log.Printf("yuxingu api listening on %s env=%s", addr, cfg.AppEnv) - if err := r.Run(addr); err != nil { + authed := api.Group("") + authed.Use(middleware.DeviceAuth(pool)) + (&handler.ProfileHandler{Svc: profileSvc}).Register(authed) + (&handler.ReportHandler{Svc: reportSvc}).Register(authed) + + log.Printf("yuxingu api listening on %s env=%s", cfg.HTTPAddr, cfg.AppEnv) + if err := r.Run(cfg.HTTPAddr); err != nil { log.Printf("server stopped: %v", err) os.Exit(1) } diff --git a/apps/api/go.mod b/apps/api/go.mod index 7c8513a..5d829a4 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -1,8 +1,12 @@ module github.com/yuxingu/digital-psychology/apps/api -go 1.22 +go 1.25 -require github.com/gin-gonic/gin v1.10.0 +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.2 +) require ( github.com/bytedance/sonic v1.11.6 // indirect @@ -15,20 +19,26 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/api/go.sum b/apps/api/go.sum index 7f08abb..e9780eb 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -6,6 +6,7 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -28,12 +29,26 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -47,6 +62,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -66,22 +83,25 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/apps/api/internal/db/db.go b/apps/api/internal/db/db.go new file mode 100644 index 0000000..7198783 --- /dev/null +++ b/apps/api/internal/db/db.go @@ -0,0 +1,77 @@ +// Package db opens Postgres and runs SQL migrations. +package db + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Connect opens a pgx pool using DATABASE_URL-style DSN. +func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("pgxpool: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping: %w", err) + } + return pool, nil +} + +// Migrate applies *.up.sql files under dir that are not yet recorded. +func Migrate(ctx context.Context, pool *pgxpool.Pool, dir string) error { + if _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + )`); err != nil { + return fmt.Errorf("schema_migrations: %w", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + var ups []string + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, ".up.sql") { + ups = append(ups, name) + } + } + sort.Strings(ups) + + for _, name := range ups { + version := strings.TrimSuffix(name, ".up.sql") + var exists bool + if err := pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)`, version, + ).Scan(&exists); err != nil { + return err + } + if exists { + continue + } + body, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return err + } + // DDL (incl. CREATE EXTENSION) may not run inside a transaction. + if _, err := pool.Exec(ctx, string(body)); err != nil { + return fmt.Errorf("migrate %s: %w", name, err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO schema_migrations(version) VALUES ($1)`, version, + ); err != nil { + return err + } + } + return nil +} diff --git a/apps/api/internal/handler/profile.go b/apps/api/internal/handler/profile.go new file mode 100644 index 0000000..1255d84 --- /dev/null +++ b/apps/api/internal/handler/profile.go @@ -0,0 +1,72 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/profile" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// ProfileHandler exposes personal archive APIs. +type ProfileHandler struct { + Svc *profile.Service +} + +// Register mounts profile routes (requires device auth on group). +func (h *ProfileHandler) Register(rg *gin.RouterGroup) { + rg.GET("/profiles", h.List) + rg.POST("/profiles", h.Create) +} + +type createProfileReq struct { + Relation string `json:"relation" binding:"required"` + DisplayName string `json:"display_name"` + BirthDate string `json:"birth_date" binding:"required"` + RelationType *string `json:"relation_type"` +} + +// Create handles POST /profiles. +func (h *ProfileHandler) Create(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req createProfileReq + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + birth, err := time.Parse("2006-01-02", req.BirthDate) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "birth_date must be YYYY-MM-DD") + return + } + p, err := h.Svc.Create(c.Request.Context(), userID, profile.CreateInput{ + Relation: req.Relation, DisplayName: req.DisplayName, BirthDate: birth, RelationType: req.RelationType, + }) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30001, err.Error()) + return + } + response.OK(c, p) +} + +// List handles GET /profiles. +func (h *ProfileHandler) List(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + list, err := h.Svc.List(c.Request.Context(), userID) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50002, "list failed") + return + } + response.OK(c, gin.H{"items": list}) +} diff --git a/apps/api/internal/handler/report.go b/apps/api/internal/handler/report.go new file mode 100644 index 0000000..fb4e3ec --- /dev/null +++ b/apps/api/internal/handler/report.go @@ -0,0 +1,126 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/report" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// ReportHandler exposes portrait reports and commerce mock. +type ReportHandler struct { + Svc *report.Service +} + +// Register mounts report/commerce routes. +func (h *ReportHandler) Register(rg *gin.RouterGroup) { + rg.POST("/reports/portrait", h.CreatePortrait) + rg.GET("/reports/:id", h.Get) + rg.POST("/orders", h.CreateOrder) + rg.POST("/orders/:id/pay-mock", h.PayMock) +} + +// CreatePortrait handles POST /reports/portrait. +func (h *ReportHandler) CreatePortrait(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + ProfileID string `json:"profile_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + pid, err := uuid.Parse(req.ProfileID) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id") + return + } + rep, err := h.Svc.CreatePortrait(c.Request.Context(), userID, pid) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30002, err.Error()) + return + } + response.OK(c, rep) +} + +// Get handles GET /reports/:id. +func (h *ReportHandler) Get(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + rid, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid id") + return + } + rep, err := h.Svc.Get(c.Request.Context(), userID, rid) + if err != nil { + response.Fail(c, http.StatusNotFound, 40401, err.Error()) + return + } + response.OK(c, rep) +} + +// CreateOrder handles POST /orders. +func (h *ReportHandler) CreateOrder(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var req struct { + Kind string `json:"kind" binding:"required"` + Plan string `json:"plan"` + ReportID *string `json:"report_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + var rid *uuid.UUID + if req.ReportID != nil && *req.ReportID != "" { + id, err := uuid.Parse(*req.ReportID) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid report_id") + return + } + rid = &id + } + oid, err := h.Svc.CreateOrder(c.Request.Context(), userID, report.CreateOrderInput{ + Kind: req.Kind, Plan: req.Plan, ReportID: rid, + }) + if err != nil { + response.Fail(c, http.StatusBadRequest, 30003, err.Error()) + return + } + response.OK(c, gin.H{"order_id": oid.String()}) +} + +// PayMock handles POST /orders/:id/pay-mock. +func (h *ReportHandler) PayMock(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + oid, err := uuid.Parse(c.Param("id")) + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid id") + return + } + if err := h.Svc.PayMock(c.Request.Context(), userID, oid); err != nil { + response.Fail(c, http.StatusBadRequest, 30004, err.Error()) + return + } + response.OK(c, gin.H{"paid": true}) +} diff --git a/apps/api/internal/middleware/device.go b/apps/api/internal/middleware/device.go new file mode 100644 index 0000000..2ffc3c4 --- /dev/null +++ b/apps/api/internal/middleware/device.go @@ -0,0 +1,96 @@ +package middleware + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +type ctxKey string + +const UserIDKey ctxKey = "user_id" +const DeviceKeyHeader = "X-Device-Key" + +// DeviceAuth resolves or creates a Visitor→User via device key. +func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc { + return func(c *gin.Context) { + key := c.GetHeader(DeviceKeyHeader) + if key == "" { + key = newDeviceKey() + c.Header(DeviceKeyHeader, key) + } + userID, err := ensureUser(c.Request.Context(), pool, key) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable") + c.Abort() + return + } + c.Set(string(UserIDKey), userID.String()) + c.Header(DeviceKeyHeader, key) + c.Next() + } +} + +// UserIDFromContext returns the authenticated user id. +func UserIDFromContext(c *gin.Context) (uuid.UUID, bool) { + v, ok := c.Get(string(UserIDKey)) + if !ok { + return uuid.Nil, false + } + id, err := uuid.Parse(v.(string)) + return id, err == nil +} + +func ensureUser(ctx context.Context, pool *pgxpool.Pool, deviceKey string) (uuid.UUID, error) { + var userID *uuid.UUID + err := pool.QueryRow(ctx, ` + SELECT user_id FROM device_identities + WHERE device_key=$1 AND deleted_at IS NULL`, deviceKey, + ).Scan(&userID) + if err == nil && userID != nil { + return *userID, nil + } + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, err + } + + tx, err := pool.Begin(ctx) + if err != nil { + return uuid.Nil, err + } + defer tx.Rollback(ctx) + + var uid uuid.UUID + if err := tx.QueryRow(ctx, + `INSERT INTO users DEFAULT VALUES RETURNING id`, + ).Scan(&uid); err != nil { + return uuid.Nil, err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO device_identities(device_key, user_id) + VALUES ($1,$2) + ON CONFLICT (device_key) DO UPDATE SET user_id=EXCLUDED.user_id, updated_at=now()`, + deviceKey, uid, + ); err != nil { + return uuid.Nil, err + } + if err := tx.Commit(ctx); err != nil { + return uuid.Nil, err + } + return uid, nil +} + +func newDeviceKey() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return "dev_" + hex.EncodeToString(b) +} diff --git a/apps/api/internal/model/profile.go b/apps/api/internal/model/profile.go new file mode 100644 index 0000000..307e09b --- /dev/null +++ b/apps/api/internal/model/profile.go @@ -0,0 +1,21 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// Profile is a personal archive (self or other). +type Profile struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Relation string `json:"relation"` + DisplayName string `json:"display_name"` + BirthDate time.Time `json:"birth_date"` + BirthTime *string `json:"birth_time,omitempty"` + BirthPlace *string `json:"birth_place,omitempty"` + Gender *string `json:"gender,omitempty"` + RelationType *string `json:"relation_type,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/apps/api/internal/model/report.go b/apps/api/internal/model/report.go new file mode 100644 index 0000000..dd688aa --- /dev/null +++ b/apps/api/internal/model/report.go @@ -0,0 +1,20 @@ +package model + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// GrowthReport is a deliverable with free summary and gated detail. +type GrowthReport struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + ProfileID uuid.UUID `json:"profile_id"` + Type string `json:"type"` + Summary json.RawMessage `json:"summary"` + Detail json.RawMessage `json:"detail,omitempty"` + HasDeep bool `json:"has_deep_access"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/apps/api/internal/portrait/engine.go b/apps/api/internal/portrait/engine.go new file mode 100644 index 0000000..fffde1b --- /dev/null +++ b/apps/api/internal/portrait/engine.go @@ -0,0 +1,146 @@ +// Package portrait builds deterministic personal-portrait content from birth date. +// Copy follows .ai/product/lexicon.md — exploration / analysis, not fortune-telling. +package portrait + +import ( + "fmt" + "time" +) + +// Output is free summary + gated detail for a GrowthReport. +type Output struct { + Summary map[string]any `json:"summary"` + Detail map[string]any `json:"detail"` +} + +// Build generates 个人画像 content from a birth date (deterministic). +func Build(birth time.Time, displayName string) Output { + y, m, d := birth.Date() + num := reduce(y) + reduce(int(m)) + reduce(d) + for num > 9 { + num = reduce(num) + } + trait := traits[num%len(traits)] + name := displayName + if name == "" { + name = "你" + } + + summary := map[string]any{ + "title": "基础画像", + "headline": fmt.Sprintf("%s更偏「%s」的互动风格", name, trait.Label), + "keywords": trait.Keywords, + "one_liner": trait.OneLiner, + "life_tip": trait.LifeTip, + "pattern_key": num, + } + detail := map[string]any{ + "title": "完整分析", + "behavior_pattern": trait.Behavior, + "relation_style": trait.Relation, + "growth_direction": trait.Growth, + "daily_suggestions": []string{ + trait.LifeTip, + "遇到分歧时,先复述对方观点再表达自己的需要。", + "用一周记录情绪与精力高峰,找到更适合自己的节奏。", + }, + } + return Output{Summary: summary, Detail: detail} +} + +type trait struct { + Label string + Keywords []string + OneLiner string + LifeTip string + Behavior string + Relation string + Growth string +} + +var traits = []trait{ + { + Label: "稳健探索", Keywords: []string{"条理", "耐心", "观察"}, + OneLiner: "你习惯先理解再行动,适合把复杂事情拆成小步。", + LifeTip: "今天给自己一段不被打断的专注时间。", + Behavior: "决策偏审慎,信息充分时执行力更强;压力下可能拖延。", + Relation: "更愿意用行动表达关心,需要对方给予明确反馈。", + Growth: "练习在信息不完整时做小步试验,积累行动信心。", + }, + { + Label: "热情连接", Keywords: []string{"表达", "共鸣", "主动"}, + OneLiner: "你容易带动气氛,也需要被真诚回应。", + LifeTip: "把想说的话写下来,再选择合适的时机分享。", + Behavior: "行动快、反馈敏感;情绪起伏会影响专注时长。", + Relation: "重视即时沟通,冷处理容易让你感到不安。", + Growth: "学会区分「被回应」与「被认同」,减少过度解读。", + }, + { + Label: "理性澄清", Keywords: []string{"分析", "边界", "清晰"}, + OneLiner: "你擅长把模糊感受变成可讨论的问题。", + LifeTip: "睡前做一次简短复盘:今天最有价值的一件事是什么。", + Behavior: "偏好逻辑框架;在情绪场域可能显得抽离。", + Relation: "沟通时需要结构和具体例子,忌空泛安慰。", + Growth: "在分析之外,练习先接纳情绪再谈方案。", + }, + { + Label: "柔韧调节", Keywords: []string{"适应", "体察", "平衡"}, + OneLiner: "你善于照顾氛围,也别忘了照顾自己的节奏。", + LifeTip: "安排一次轻度活动,帮助身心回到平稳状态。", + Behavior: "弹性强,容易迁就;长期可能积压需求。", + Relation: "更在意关系和谐,冲突时倾向先安抚场面。", + Growth: "练习用「我需要…」表达边界,而不是只做协调者。", + }, + { + Label: "目标推进", Keywords: []string{"决断", "效率", "成果"}, + OneLiner: "你推动事情落地的能力突出,记得留白给休息。", + LifeTip: "把今日目标收束到一件最重要的事。", + Behavior: "结果导向,节奏偏快;对低效协作耐心有限。", + Relation: "欣赏直接沟通的人,含糊表态会消耗信任。", + Growth: "把「效率」与「关系维护」列为同等优先级的周目标。", + }, + { + Label: "内观沉淀", Keywords: []string{"深度", "独立", "洞察"}, + OneLiner: "你习惯向内理解世界,适合深度思考类任务。", + LifeTip: "留出安静独处时间,整理近期的想法与感受。", + Behavior: "思考深入,对外表达可能滞后于内心结论。", + Relation: "需要安全感和节奏感,突然施压会让你退回内在。", + Growth: "把洞察翻译成可分享的语言,让重要的人跟上你。", + }, + { + Label: "创意发散", Keywords: []string{"想象", "灵感", "可能"}, + OneLiner: "你容易看到多种可能性,适合用原型验证想法。", + LifeTip: "用纸笔画出今天最想尝试的一个小实验。", + Behavior: "点子多、切换快;收尾与复盘是短板。", + Relation: "喜欢有趣的对话,重复与僵化会让你疏离。", + Growth: "为每个灵感设定「最小完成定义」,提高闭环率。", + }, + { + Label: "责任担当", Keywords: []string{"可靠", "承诺", "稳定"}, + OneLiner: "你重视承诺与秩序,是团队里让人安心的存在。", + LifeTip: "检查一下是否把别人的期待误当成自己的必须。", + Behavior: "可靠且自律;过度负责时容易耗竭。", + Relation: "用持续在场表达在乎,需要被看见付出。", + Growth: "练习委托与求助,让支持系统真正运转起来。", + }, + { + Label: "敏锐觉察", Keywords: []string{"细腻", "直觉", "体贴"}, + OneLiner: "你对情绪与细节敏感,适合需要同理的场景。", + LifeTip: "觉察身体信号:紧张时先放慢呼吸再回应。", + Behavior: "感知力强;信息过载时容易内耗。", + Relation: "能很快读到对方状态,也易被情绪感染。", + Growth: "建立「感受—事实—选择」三步,减少被情绪牵着走。", + }, +} + +func reduce(n int) int { + if n < 0 { + n = -n + } + sum := 0 + for n > 0 { + sum += n % 10 + n /= 10 + } + return sum +} diff --git a/apps/api/internal/portrait/engine_test.go b/apps/api/internal/portrait/engine_test.go new file mode 100644 index 0000000..9a11ff3 --- /dev/null +++ b/apps/api/internal/portrait/engine_test.go @@ -0,0 +1,25 @@ +package portrait + +import ( + "strings" + "testing" + "time" +) + +func TestBuildDeterministic(t *testing.T) { + birth := time.Date(1990, 1, 15, 0, 0, 0, 0, time.UTC) + a := Build(birth, "小愈") + b := Build(birth, "小愈") + if a.Summary["headline"] != b.Summary["headline"] { + t.Fatalf("expected deterministic headline") + } + if a.Summary["one_liner"] == nil || a.Detail["growth_direction"] == nil { + t.Fatalf("missing summary/detail fields") + } + for _, kw := range []string{"运势", "吉凶", "算命"} { + s := a.Summary["one_liner"].(string) + a.Detail["behavior_pattern"].(string) + if strings.Contains(s, kw) { + t.Fatalf("forbidden word %q in portrait copy", kw) + } + } +} diff --git a/apps/api/internal/repository/profile_repo.go b/apps/api/internal/repository/profile_repo.go new file mode 100644 index 0000000..6f35d11 --- /dev/null +++ b/apps/api/internal/repository/profile_repo.go @@ -0,0 +1,67 @@ +package repository + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yuxingu/digital-psychology/apps/api/internal/model" +) + +// ProfileRepo persists profiles. +type ProfileRepo struct { + Pool *pgxpool.Pool +} + +// Create inserts a profile. +func (r *ProfileRepo) Create(ctx context.Context, userID uuid.UUID, relation, name string, birth time.Time, relationType *string) (*model.Profile, error) { + p := &model.Profile{} + err := r.Pool.QueryRow(ctx, ` + INSERT INTO profiles(user_id, relation, display_name, birth_date, relation_type) + VALUES ($1,$2,$3,$4,$5) + RETURNING id, user_id, relation, display_name, birth_date, created_at`, + userID, relation, name, birth, relationType, + ).Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.CreatedAt) + if err != nil { + return nil, err + } + p.RelationType = relationType + return p, nil +} + +// ListByUser returns non-deleted profiles. +func (r *ProfileRepo) ListByUser(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) { + rows, err := r.Pool.Query(ctx, ` + SELECT id, user_id, relation, display_name, birth_date, relation_type, created_at + FROM profiles WHERE user_id=$1 AND deleted_at IS NULL + ORDER BY created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []model.Profile + for rows.Next() { + var p model.Profile + if err := rows.Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &p.CreatedAt); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// GetForUser loads a profile owned by user. +func (r *ProfileRepo) GetForUser(ctx context.Context, userID, profileID uuid.UUID) (*model.Profile, error) { + p := &model.Profile{} + err := r.Pool.QueryRow(ctx, ` + SELECT id, user_id, relation, display_name, birth_date, relation_type, created_at + FROM profiles WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, + profileID, userID, + ).Scan(&p.ID, &p.UserID, &p.Relation, &p.DisplayName, &p.BirthDate, &p.RelationType, &p.CreatedAt) + if err != nil { + return nil, err + } + return p, nil +} diff --git a/apps/api/internal/repository/report_repo.go b/apps/api/internal/repository/report_repo.go new file mode 100644 index 0000000..0a9f9ec --- /dev/null +++ b/apps/api/internal/repository/report_repo.go @@ -0,0 +1,146 @@ +package repository + +import ( + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yuxingu/digital-psychology/apps/api/internal/model" +) + +// ReportRepo persists growth reports and access checks. +type ReportRepo struct { + Pool *pgxpool.Pool +} + +// Create inserts a growth report. +func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) { + rep := &model.GrowthReport{} + err := r.Pool.QueryRow(ctx, ` + INSERT INTO growth_reports(user_id, profile_id, type, summary, detail) + VALUES ($1,$2,$3,$4,$5) + RETURNING id, user_id, profile_id, type, summary, detail, created_at`, + userID, profileID, typ, summary, detail, + ).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt) + return rep, err +} + +// GetForUser loads a report owned by user. +func (r *ReportRepo) GetForUser(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) { + rep := &model.GrowthReport{} + err := r.Pool.QueryRow(ctx, ` + SELECT id, user_id, profile_id, type, summary, detail, created_at + FROM growth_reports WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, + reportID, userID, + ).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt) + return rep, err +} + +// HasDeepAccess reports whether user purchased deep access for report. +func (r *ReportRepo) HasDeepAccess(ctx context.Context, userID, reportID uuid.UUID) (bool, error) { + var ok bool + err := r.Pool.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM deep_accesses + WHERE user_id=$1 AND report_id=$2 AND deleted_at IS NULL + )`, userID, reportID).Scan(&ok) + return ok, err +} + +// HasActiveMembership checks growth membership. +func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID) (bool, error) { + var ok bool + err := r.Pool.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM memberships + WHERE user_id=$1 AND status='active' AND expires_at > now() AND deleted_at IS NULL + )`, userID).Scan(&ok) + return ok, err +} + +// CreateOrder inserts an order. +func (r *ReportRepo) CreateOrder(ctx context.Context, userID uuid.UUID, kind, plan string, reportID *uuid.UUID, amount int) (uuid.UUID, error) { + var id uuid.UUID + err := r.Pool.QueryRow(ctx, ` + INSERT INTO orders(user_id, kind, plan, report_id, amount_cents, status) + VALUES ($1,$2,$3,$4,$5,'created') RETURNING id`, + userID, kind, nullIfEmpty(plan), reportID, amount, + ).Scan(&id) + return id, err +} + +// PayMock marks order paid and grants entitlement. +func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) error { + tx, err := r.Pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var kind string + var reportID *uuid.UUID + var plan *string + err = tx.QueryRow(ctx, ` + SELECT kind, report_id, plan FROM orders + WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL FOR UPDATE`, + orderID, userID, + ).Scan(&kind, &reportID, &plan) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE orders SET status='paid', updated_at=now() WHERE id=$1`, orderID); err != nil { + return err + } + if _, err := tx.Exec(ctx, ` + INSERT INTO payments(order_id, channel, status) VALUES ($1,'mock','paid')`, orderID); err != nil { + return err + } + switch kind { + case "deep_access": + if reportID == nil { + return errMissingReport + } + if _, err := tx.Exec(ctx, ` + INSERT INTO deep_accesses(user_id, report_id, order_id) + VALUES ($1,$2,$3) + ON CONFLICT (user_id, report_id) DO NOTHING`, userID, *reportID, orderID); err != nil { + return err + } + case "membership": + p := "month" + if plan != nil && *plan != "" { + p = *plan + } + days := 31 + if p == "quarter" { + days = 92 + } else if p == "year" { + days = 366 + } + if _, err := tx.Exec(ctx, ` + INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left) + VALUES ($1,$2,'active', now() + ($3::text || ' days')::interval, 100) + ON CONFLICT (user_id) DO UPDATE SET + plan=EXCLUDED.plan, status='active', + expires_at=EXCLUDED.expires_at, ask_quota_left=100, updated_at=now()`, + userID, p, days); err != nil { + return err + } + } + return tx.Commit(ctx) +} + +var errMissingReport = errString("report_id required for deep_access") + +type errString string + +func (e errString) Error() string { return string(e) } + +func nullIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/apps/api/internal/service/profile/service.go b/apps/api/internal/service/profile/service.go new file mode 100644 index 0000000..807e200 --- /dev/null +++ b/apps/api/internal/service/profile/service.go @@ -0,0 +1,49 @@ +package profile + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/model" + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +// Service manages personal archives. +type Service struct { + Repo *repository.ProfileRepo +} + +// CreateInput is validated create payload. +type CreateInput struct { + Relation string + DisplayName string + BirthDate time.Time + RelationType *string +} + +// Create stores a profile for the user. +func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput) (*model.Profile, error) { + if in.Relation != "self" && in.Relation != "other" { + return nil, errors.New("relation must be self or other") + } + if in.BirthDate.IsZero() { + return nil, errors.New("birth_date required") + } + name := in.DisplayName + if name == "" { + if in.Relation == "self" { + name = "我" + } else { + name = "TA" + } + } + return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType) +} + +// List returns user's profiles. +func (s *Service) List(ctx context.Context, userID uuid.UUID) ([]model.Profile, error) { + return s.Repo.ListByUser(ctx, userID) +} diff --git a/apps/api/internal/service/report/service.go b/apps/api/internal/service/report/service.go new file mode 100644 index 0000000..d27b8b1 --- /dev/null +++ b/apps/api/internal/service/report/service.go @@ -0,0 +1,87 @@ +package report + +import ( + "context" + "encoding/json" + "errors" + + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/model" + "github.com/yuxingu/digital-psychology/apps/api/internal/portrait" + "github.com/yuxingu/digital-psychology/apps/api/internal/repository" +) + +// Service creates and reads growth reports with entitlement trimming. +type Service struct { + Profiles *repository.ProfileRepo + Reports *repository.ReportRepo +} + +// CreatePortrait builds and stores a portrait report. +func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUID) (*model.GrowthReport, error) { + p, err := s.Profiles.GetForUser(ctx, userID, profileID) + if err != nil { + return nil, errors.New("profile not found") + } + out := portrait.Build(p.BirthDate, p.DisplayName) + sum, _ := json.Marshal(out.Summary) + det, _ := json.Marshal(out.Detail) + rep, err := s.Reports.Create(ctx, userID, profileID, "portrait", sum, det) + if err != nil { + return nil, err + } + return s.applyEntitlement(ctx, userID, rep) +} + +// Get returns a report with detail gated. +func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) { + rep, err := s.Reports.GetForUser(ctx, userID, reportID) + if err != nil { + return nil, errors.New("report not found") + } + return s.applyEntitlement(ctx, userID, rep) +} + +func (s *Service) applyEntitlement(ctx context.Context, userID uuid.UUID, rep *model.GrowthReport) (*model.GrowthReport, error) { + deep, err := s.Reports.HasDeepAccess(ctx, userID, rep.ID) + if err != nil { + return nil, err + } + vip, err := s.Reports.HasActiveMembership(ctx, userID) + if err != nil { + return nil, err + } + rep.HasDeep = deep || vip + if !rep.HasDeep { + rep.Detail = nil + } + return rep, nil +} + +// CreateOrderInput for commerce. +type CreateOrderInput struct { + Kind string + Plan string + ReportID *uuid.UUID +} + +// CreateOrder starts membership or deep_access order. +func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOrderInput) (uuid.UUID, error) { + if in.Kind != "membership" && in.Kind != "deep_access" { + return uuid.Nil, errors.New("invalid kind") + } + if in.Kind == "deep_access" && in.ReportID == nil { + return uuid.Nil, errors.New("report_id required") + } + amount := 990 + if in.Kind == "membership" { + amount = 2500 + } + return s.Reports.CreateOrder(ctx, userID, in.Kind, in.Plan, in.ReportID, amount) +} + +// PayMock completes mock payment. +func (s *Service) PayMock(ctx context.Context, userID, orderID uuid.UUID) error { + return s.Reports.PayMock(ctx, userID, orderID) +} diff --git a/apps/api/migrations/000001_init.down.sql b/apps/api/migrations/000001_init.down.sql new file mode 100644 index 0000000..d79a141 --- /dev/null +++ b/apps/api/migrations/000001_init.down.sql @@ -0,0 +1,9 @@ +DROP TABLE IF EXISTS deep_accesses; +DROP TABLE IF EXISTS payments; +DROP TABLE IF EXISTS orders; +DROP TABLE IF EXISTS memberships; +DROP TABLE IF EXISTS growth_reports; +DROP TABLE IF EXISTS profiles; +DROP TABLE IF EXISTS device_identities; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS schema_migrations; diff --git a/apps/api/migrations/000001_init.up.sql b/apps/api/migrations/000001_init.up.sql new file mode 100644 index 0000000..73a5108 --- /dev/null +++ b/apps/api/migrations/000001_init.up.sql @@ -0,0 +1,105 @@ +-- P1 core schema (see .ai/domain/erd.md) + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + status varchar(32) NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); + +CREATE TABLE IF NOT EXISTS device_identities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_key varchar(128) NOT NULL UNIQUE, + user_id uuid NULL REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_device_identities_user_id ON device_identities(user_id); + +CREATE TABLE IF NOT EXISTS profiles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + relation varchar(16) NOT NULL CHECK (relation IN ('self', 'other')), + display_name varchar(64) NOT NULL DEFAULT '', + birth_date date NOT NULL, + birth_time time NULL, + birth_place varchar(128) NULL, + gender varchar(32) NULL, + relation_type varchar(32) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id); + +CREATE TABLE IF NOT EXISTS growth_reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + profile_id uuid NOT NULL REFERENCES profiles(id), + type varchar(32) NOT NULL, + summary jsonb NOT NULL DEFAULT '{}', + detail jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_growth_reports_user_id ON growth_reports(user_id); +CREATE INDEX IF NOT EXISTS idx_growth_reports_profile_id ON growth_reports(profile_id); + +CREATE TABLE IF NOT EXISTS memberships ( + user_id uuid PRIMARY KEY REFERENCES users(id), + plan varchar(32) NOT NULL DEFAULT 'month', + status varchar(32) NOT NULL DEFAULT 'expired', + expires_at timestamptz NULL, + ask_quota_left int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); + +CREATE TABLE IF NOT EXISTS orders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + kind varchar(32) NOT NULL, + plan varchar(32) NULL, + report_id uuid NULL REFERENCES growth_reports(id), + amount_cents int NOT NULL DEFAULT 0, + status varchar(32) NOT NULL DEFAULT 'created', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); + +CREATE TABLE IF NOT EXISTS payments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + order_id uuid NOT NULL REFERENCES orders(id), + channel varchar(32) NOT NULL DEFAULT 'mock', + status varchar(32) NOT NULL DEFAULT 'created', + raw jsonb NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL +); +CREATE INDEX IF NOT EXISTS idx_payments_order_id ON payments(order_id); + +CREATE TABLE IF NOT EXISTS deep_accesses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + report_id uuid NOT NULL REFERENCES growth_reports(id), + order_id uuid NOT NULL REFERENCES orders(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + UNIQUE (user_id, report_id) +); +CREATE INDEX IF NOT EXISTS idx_deep_accesses_report_id ON deep_accesses(report_id); diff --git a/apps/user-h5/src/pages/PortraitPage.vue b/apps/user-h5/src/pages/PortraitPage.vue index d10760b..58e7462 100644 --- a/apps/user-h5/src/pages/PortraitPage.vue +++ b/apps/user-h5/src/pages/PortraitPage.vue @@ -2,31 +2,113 @@

个人画像

-

生日:{{ y }}-{{ m }}-{{ d }}

-
- 基础画像将由 API 生成。免费可见基础结论;完整分析为「深度版」或会员权益(见 PRD)。 -
-

- 本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。 -

+

正在生成…

+

{{ error }}

+ +

本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。

diff --git a/deploy/.env.example b/deploy/.env.example index dda740a..55aea2e 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,4 +1,10 @@ +# Template only — copy to apps/api/.env.local or export in shell. +# Never commit real secrets. See .ai/environment.md → Configuration Ownership. + APP_ENV=dev HTTP_ADDR=:8080 DATABASE_URL=postgres://yuxingu:yuxingu@127.0.0.1:5432/yuxingu?sslmode=disable -JWT_SECRET=change-me-in-production + +# Optional +# MIGRATIONS_DIR=./migrations +# JWT_SECRET=change-me-in-production diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..c1a5a95 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,11 @@ +# Deploy & local deps + +| File | Use | +|---|---| +| `../docker-compose.dev.yml` | **Local**:Postgres(及日后 Redis) | +| `docker-compose.yml` | 兼容入口 → include dev compose | +| `docker-compose.prod.yml` | 生产(待发布任务添加) | +| `Dockerfile.*` | 生产/CI 镜像(待发布任务添加) | +| `.env.example` | 环境变量模板 | + +日常写代码:本机 Go + Vite;Docker 只起依赖。见 `.ai/development.md`。 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..50fe85b --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,25 @@ +# Production compose skeleton — NOT for daily local coding. +# Activate only when images/Dockerfiles exist for a release. +# Policy: .ai/deployment.md · .ai/docker.md +# +# Example (uncomment and set registry tags when shipping): +# +# services: +# api: +# image: registry.example.com/yuxingu-api:${TAG:-v0.0.0} +# env_file: [.env.prod] +# ports: ["8080:8080"] +# web: +# image: registry.example.com/yuxingu-user-h5:${TAG:-v0.0.0} +# ports: ["80:80"] + +services: + # Keeps file valid; profile "prod" must be enabled explicitly — never default local. + postgres: + profiles: ["prod"] + image: postgres:16-alpine + environment: + POSTGRES_USER: yuxingu + POSTGRES_PASSWORD: change-me + POSTGRES_DB: yuxingu + # Do not expose 5432 publicly in real prod without network policy. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 87afb38..0943b93 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,3 +1,9 @@ +# Compatibility alias for local dependency services. +# Prefer from repo root: +# docker compose -f docker-compose.dev.yml up -d +# +# Does NOT run api or user-h5. Policy: .ai/docker.md + services: postgres: image: postgres:16-alpine @@ -9,7 +15,12 @@ services: ports: - "5432:5432" volumes: - - yuxingu_pg:/var/lib/postgresql/data + - yuxingu_pg_dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U yuxingu -d yuxingu"] + interval: 5s + timeout: 5s + retries: 10 volumes: - yuxingu_pg: + yuxingu_pg_dev: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..98e0e7a --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,30 @@ +# Local dependency services ONLY — not for building/running api or user-h5. +# Policy: .ai/docker.md · .ai/development.md +# +# docker compose -f docker-compose.dev.yml up -d +# docker compose -f docker-compose.dev.yml down + +services: + postgres: + image: postgres:16-alpine + container_name: yuxingu-postgres + environment: + POSTGRES_USER: yuxingu + POSTGRES_PASSWORD: yuxingu + POSTGRES_DB: yuxingu + ports: + - "5432:5432" + volumes: + - yuxingu_pg_dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U yuxingu -d yuxingu"] + interval: 5s + timeout: 5s + retries: 10 + + # redis: + # image: redis:7-alpine + # ports: ["6379:6379"] + +volumes: + yuxingu_pg_dev: diff --git a/package.json b/package.json index 14afa38..1ddf3f0 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "scripts": { "dev:h5": "npm run dev -w @yuxingu/user-h5", "build:h5": "npm run build -w @yuxingu/user-h5", - "dev:api": "cd apps/api && go run ./cmd/server" + "dev:api": "cd apps/api && go run ./cmd/server", + "deps:up": "docker compose -f docker-compose.dev.yml up -d", + "deps:down": "docker compose -f docker-compose.dev.yml down", + "deps:ps": "docker compose -f docker-compose.dev.yml ps" } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b7b954f..7768ddb 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,17 +1,18 @@ -import type { ApiResponse } from '@yuxingu/types' +import type { ApiResponse, GrowthReport, Profile } from '@yuxingu/types' /** Platform adapters so H5 and mini-program share one client. */ export interface RequestOptions { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE' + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' path: string body?: unknown headers?: Record } export interface ClientAdapters { - /** Perform HTTP and return parsed JSON envelope. */ - request: (opts: RequestOptions) => Promise> + request: (opts: RequestOptions) => Promise & { headers?: Headers }> getToken?: () => string | null | Promise + getDeviceKey?: () => string | null + setDeviceKey?: (key: string) => void } export interface CreateClientOptions { @@ -21,15 +22,16 @@ export interface CreateClientOptions { /** * createClient builds a typed API facade. - * Side effect: network via adapters.request. */ export function createClient(opts: CreateClientOptions) { const { baseURL, adapters } = opts async function call(path: string, init?: Omit): Promise { const token = adapters.getToken ? await adapters.getToken() : null + const deviceKey = adapters.getDeviceKey ? adapters.getDeviceKey() : null const headers: Record = { ...(init?.headers || {}) } if (token) headers.Authorization = `Bearer ${token}` + if (deviceKey) headers['X-Device-Key'] = deviceKey const res = await adapters.request({ method: init?.method || 'GET', @@ -37,6 +39,9 @@ export function createClient(opts: CreateClientOptions) { body: init?.body, headers, }) + const newKey = res.headers?.get?.('X-Device-Key') || res.headers?.get?.('x-device-key') + if (newKey && adapters.setDeviceKey) adapters.setDeviceKey(newKey) + if (res.code !== 0) { throw new Error(res.message || `api error ${res.code}`) } @@ -46,11 +51,26 @@ export function createClient(opts: CreateClientOptions) { return { healthz: () => call<{ status: string }>('/api/v1/healthz'), ping: () => call<{ pong: boolean }>('/api/v1/ping'), + listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'), + createProfile: (body: { + relation: 'self' | 'other' + birth_date: string + display_name?: string + relation_type?: string + }) => call('/api/v1/profiles', { method: 'POST', body }), + createPortrait: (profile_id: string) => + call('/api/v1/reports/portrait', { method: 'POST', body: { profile_id } }), + getReport: (id: string) => call(`/api/v1/reports/${id}`), + createOrder: (body: { kind: 'membership' | 'deep_access'; plan?: string; report_id?: string }) => + call<{ order_id: string }>('/api/v1/orders', { method: 'POST', body }), + payMock: (orderId: string) => + call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }), } } /** Browser fetch adapter for user-h5. */ export function createBrowserAdapters(): ClientAdapters { + const deviceKeyStorage = 'yxg_device_key' return { request: async ({ method = 'GET', path, body, headers }) => { const res = await fetch(path, { @@ -61,9 +81,12 @@ export function createBrowserAdapters(): ClientAdapters { }, body: body === undefined ? undefined : JSON.stringify(body), }) - return (await res.json()) as ApiResponse + const json = (await res.json()) as ApiResponse + return Object.assign(json, { headers: res.headers }) }, getToken: () => localStorage.getItem('yxg_token'), + getDeviceKey: () => localStorage.getItem(deviceKeyStorage), + setDeviceKey: (key: string) => localStorage.setItem(deviceKeyStorage, key), } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8eec291..7a533bf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -5,17 +5,30 @@ export interface ApiResponse { data?: T } -/** User profile used by decode / relation features. */ +/** 个人档案 */ export interface Profile { id: string - label: string - year: number - month: number - day: number + user_id: string relation: 'self' | 'other' + display_name: string + birth_date: string + relation_type?: string | null + created_at: string } -/** Minimal scale list item. */ +/** 成长报告(detail 可能因权益被剥离) */ +export interface GrowthReport { + id: string + user_id: string + profile_id: string + type: string + summary: Record + detail?: Record | null + has_deep_access: boolean + created_at: string +} + +/** 探索测试列表项 */ export interface ScaleSummary { slug: string name: string