feat: P1 profile/portrait API and freeze local-dev environment
Add host-first environment contracts (Local vs CI vs Prod), deps-only compose, and the Profile → Portrait → deep-access mock payment slice with device identity and auto-migrate on API startup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-4
@@ -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
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
|
||||
+51
-39
@@ -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`。
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+18
-9
@@ -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` 出现的表名(先改文档)
|
||||
|
||||
+76
-23
@@ -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 |
|
||||
|
||||
禁止把右栏流程套到左栏日常开发。
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
- 演示「一键依赖+应用」给非开发角色(仍非日常编码默认)
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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 清单(合并前必过)
|
||||
|
||||
|
||||
@@ -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 — <note>
|
||||
- Docs: PASS | FAIL — <note>
|
||||
- Design: PASS | FAIL | N/A — <note>
|
||||
- Environment: PASS | FAIL | N/A — <note>
|
||||
- Scope: PASS | FAIL — <note>
|
||||
```
|
||||
|
||||
|
||||
+33
-18
@@ -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`
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.ai/development.md
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.ai/docker.md
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.ai/environment.md
|
||||
@@ -12,6 +12,7 @@ dist/
|
||||
|
||||
# Go
|
||||
apps/api/bin/
|
||||
apps/api/tmp/
|
||||
*.exe
|
||||
|
||||
# IDE
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+23
-14
@@ -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」写进新文档。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+15
-5
@@ -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
|
||||
)
|
||||
|
||||
+27
-7
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -2,31 +2,113 @@
|
||||
<main class="page">
|
||||
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
||||
<h1>个人画像</h1>
|
||||
<p class="sub" v-if="y">生日:{{ y }}-{{ m }}-{{ d }}</p>
|
||||
<div class="card">
|
||||
基础画像将由 API 生成。免费可见基础结论;完整分析为「深度版」或会员权益(见 PRD)。
|
||||
</div>
|
||||
<p class="disc">
|
||||
本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。
|
||||
</p>
|
||||
<p v-if="loading" class="sub">正在生成…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<template v-else-if="report">
|
||||
<p class="sub">{{ headline }}</p>
|
||||
<div class="card">
|
||||
<p class="line">{{ oneLiner }}</p>
|
||||
<p class="tip">生活建议:{{ lifeTip }}</p>
|
||||
<div v-if="keywords.length" class="tags">
|
||||
<span v-for="k in keywords" :key="k">{{ k }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="report.has_deep_access && detail" class="card deep">
|
||||
<h2>完整分析</h2>
|
||||
<p><strong>行为模式</strong> {{ detail.behavior_pattern }}</p>
|
||||
<p><strong>关系特点</strong> {{ detail.relation_style }}</p>
|
||||
<p><strong>成长方向</strong> {{ detail.growth_direction }}</p>
|
||||
</div>
|
||||
<div v-else class="card lock">
|
||||
<p>完整分析、行为模式与成长方向可在深度版或成长会员中查看。</p>
|
||||
<button type="button" :disabled="paying" @click="buyDeep">查看深度版(模拟支付)</button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="disc">本内容为自我探索与生活方式参考,不构成医疗建议,亦非占卜预测。</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { GrowthReport } from '@yuxingu/types'
|
||||
import { api } from '../api/client'
|
||||
|
||||
const route = useRoute()
|
||||
const y = computed(() => String(route.query.y || ''))
|
||||
const m = computed(() => String(route.query.m || ''))
|
||||
const d = computed(() => String(route.query.d || ''))
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const report = ref<GrowthReport | null>(null)
|
||||
const paying = ref(false)
|
||||
|
||||
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||
const headline = computed(() => String(summary.value.headline || ''))
|
||||
const oneLiner = computed(() => String(summary.value.one_liner || ''))
|
||||
const lifeTip = computed(() => String(summary.value.life_tip || ''))
|
||||
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? summary.value.keywords as string[] : []))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const reportId = String(route.query.report_id || '')
|
||||
if (reportId) {
|
||||
report.value = await api.getReport(reportId)
|
||||
return
|
||||
}
|
||||
const y = Number(route.query.y)
|
||||
const m = Number(route.query.m)
|
||||
const d = Number(route.query.d)
|
||||
if (!y || !m || !d) {
|
||||
error.value = '请从首页填写生日后进入'
|
||||
return
|
||||
}
|
||||
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
const profile = await api.createProfile({ relation: 'self', birth_date: birth, display_name: '我' })
|
||||
report.value = await api.createPortrait(profile.id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function buyDeep() {
|
||||
if (!report.value) return
|
||||
paying.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
|
||||
await api.payMock(order_id)
|
||||
report.value = await api.getReport(report.value.id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '支付失败'
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:16px}
|
||||
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
||||
h1{font-size:22px}
|
||||
h2{font-size:16px;margin-bottom:8px}
|
||||
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555}
|
||||
.err{color:var(--yxg-pri);font-size:13px;margin:8px 0}
|
||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555;margin-bottom:12px}
|
||||
.line{font-size:15px;color:#333}
|
||||
.tip{margin-top:8px;color:#666}
|
||||
.tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
|
||||
.tags span{background:var(--yxg-bg-start,#ffe4e4);color:var(--yxg-pri);padding:4px 10px;border-radius:999px;font-size:12px}
|
||||
.lock button{
|
||||
margin-top:12px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
|
||||
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
|
||||
}
|
||||
.lock button:disabled{opacity:.6}
|
||||
.disc{font-size:11px;color:#bbb;margin-top:16px;line-height:1.5}
|
||||
.deep p{margin-top:8px}
|
||||
</style>
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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`。
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
export interface ClientAdapters {
|
||||
/** Perform HTTP and return parsed JSON envelope. */
|
||||
request: <T>(opts: RequestOptions) => Promise<ApiResponse<T>>
|
||||
request: <T>(opts: RequestOptions) => Promise<ApiResponse<T> & { headers?: Headers }>
|
||||
getToken?: () => string | null | Promise<string | null>
|
||||
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<T>(path: string, init?: Omit<RequestOptions, 'path'>): Promise<T> {
|
||||
const token = adapters.getToken ? await adapters.getToken() : null
|
||||
const deviceKey = adapters.getDeviceKey ? adapters.getDeviceKey() : null
|
||||
const headers: Record<string, string> = { ...(init?.headers || {}) }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
if (deviceKey) headers['X-Device-Key'] = deviceKey
|
||||
|
||||
const res = await adapters.request<T>({
|
||||
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<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
||||
createPortrait: (profile_id: string) =>
|
||||
call<GrowthReport>('/api/v1/reports/portrait', { method: 'POST', body: { profile_id } }),
|
||||
getReport: (id: string) => call<GrowthReport>(`/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 <T>({ 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<T>
|
||||
const json = (await res.json()) as ApiResponse<T>
|
||||
return Object.assign(json, { headers: res.headers })
|
||||
},
|
||||
getToken: () => localStorage.getItem('yxg_token'),
|
||||
getDeviceKey: () => localStorage.getItem(deviceKeyStorage),
|
||||
setDeviceKey: (key: string) => localStorage.setItem(deviceKeyStorage, key),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,30 @@ export interface ApiResponse<T = unknown> {
|
||||
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<string, unknown>
|
||||
detail?: Record<string, unknown> | null
|
||||
has_deep_access: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 探索测试列表项 */
|
||||
export interface ScaleSummary {
|
||||
slug: string
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user