implement Phase 1-2

This commit is contained in:
Suphonchai Phoonsawat 2026-08-19 10:54:55 +07:00
parent 41573b3c4f
commit b976cf3124
23 changed files with 1291 additions and 79 deletions

View file

@ -169,18 +169,27 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**เป้า:** รัน infra ทั้งชุดด้วยคำสั่งเดียว — ตรง spec ส่วน 11 (backend ข้อ 6, 8)
- [ ] `docker-compose.yml` กำหนด services:
- [x] `docker-compose.yml` กำหนด services:
- `db` — `postgres:16` (volume สำหรับข้อมูล)
- `minio` — `minio/minio` (console เปิดด้วย) + init service สร้าง bucket **`docx-versions`** และ **`docx-templates`** แบบ **private** ทั้งคู่
- `keycloak` — `quay.io/keycloak/keycloak` start-dev + import realm อัตโนมัติ
- `api` — build จาก `/backend` (รอ Phase 2 เสร็จค่อย enable ก็ได้)
- [ ] `/keycloak/realm-export.json`:
- [x] `/keycloak/realm-export.json`:
- realm `word-versioning`
- client `word-addin-officejs` — public, Standard Flow, **PKCE S256 บังคับ**, ไม่มี secret, redirect URI `https://localhost:3000/auth-callback.html`
- client `word-versioning-web` — public, Standard Flow, PKCE S256, redirect URI `http://localhost:5173/*` (web ทุก route เพราะใช้ redirect ปกติ)
- client `word-addin-vsto` — public + PKCE (pre-register ตาม spec)
- สร้าง test user แรกไว้ใน realm export เลยก็ได้
- [ ] กำหนด env defaults (เช่น ใน `.env.example`): MinIO endpoint/keys, Keycloak admin password, DB password
- [x] กำหนด env defaults (เช่น ใน `.env.example`): MinIO endpoint/keys, Keycloak admin password, DB password
หมายเหตุจากการทำจริง (2026-08-19):
- Keycloak = `26.2.5` (tag `26.2`); admin bootstrap ใช้ env `KC_BOOTSTRAP_ADMIN_USERNAME/PASSWORD` (ชุดใหม่แทน `KEYCLOAK_ADMIN` ที่ deprecated)
- Realm import strategy ของ Keycloak = `IGNORE_EXISTING` และ compose **ไม่ mount volume เก็บข้อมูล realm โดยตั้งใจ** → หลัง `docker compose down` realm จะถูก import ใหม่จาก `realm-export.json` เสมอ (ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev — แก้ realm ให้แก้ที่ไฟล์นี้)
- ทุก client ตั้ง `webOrigins` ด้วย (add-in: `https://localhost:3000`, web: `http://localhost:5173`) — จำเป็นเพื่อให้ browser เรียก token endpoint ผ่าน CORS จาก task pane / เว็บได้ (ไม่ใช่แค่ redirectUris)
- ปิด `directAccessGrantsEnabled` ทุก client ตาม spec (บังคับ PKCE); ตอน dev อยากได้ token สำหรับทดสอบใช้ client `admin-cli` ของ realm (ROPC) หรือเดิน auth code flow จริง
- redirect URI ของ `word-addin-vsto` ใช้ loopback `http://localhost/*` + `http://127.0.0.1/*` ตาม RFC 8252 (spec-vsto.md ยังไม่มี — pre-register ไว้ก่อน)
- `api` service ใส่ไว้ใน compose แบบ `profiles: ["api"]` — ไม่ start ตอน `docker compose up` ปกติ จนกว่าจะมี Dockerfile ใน Phase 2 (`docker compose --profile api up -d --build`)
- ตรวจ DoD ผ่านหมด: realm + 3 clients (public/standardFlow/PKCE S256/redirect ถูกทุกตัว ผ่าน admin REST), `testuser/Test1234!` ขอ token ได้, bucket 2 ตัวเป็น private (`mc anonymous get` = private, anonymous GET = 403)
**DoD:** `docker compose up` แล้ว (1) เข้า Keycloak admin console เห็น realm `word-versioning` + clients ครบ 3 ตัว (2) เข้า MinIO console เห็น bucket `docx-versions` + `docx-templates` ที่เป็น private
@ -191,32 +200,44 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**เป้า:** API ครบทุก endpoint ของ spec เดิม พร้อม auth + storage จริง — ตรง spec ส่วน 11 (backend ข้อ 1–5)
### 2.1 Data layer
- [ ] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
- [ ] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
- [ ] EF migration แรก
- [x] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
- [x] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
- [x] EF migration แรก
### 2.2 Services
- [ ] `IObjectStorageService` + `ObjectStorageService` — `AmazonS3Client` ตั้ง `ServiceURL` = MinIO endpoint, **`ForcePathStyle = true`** (จำเป็นสำหรับ MinIO), methods: `PutAsync(bucket, key, stream, contentType)`, `GetStreamAsync(bucket, key)`, config (`Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`) จาก `appsettings.json`/env; `BucketName` แยกเป็น `VersionsBucket` + `TemplatesBucket`
- [ ] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
- [x] `IObjectStorageService` + `ObjectStorageService` — `AmazonS3Client` ตั้ง `ServiceURL` = MinIO endpoint, **`ForcePathStyle = true`** (จำเป็นสำหรับ MinIO), methods: `PutAsync(bucket, key, stream, contentType)`, `GetStreamAsync(bucket, key)`, config (`Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`) จาก `appsettings.json`/env; `BucketName` แยกเป็น `VersionsBucket` + `TemplatesBucket`
- [x] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
### 2.3 Auth (FR-8 ฝั่ง backend)
- [ ] `AddAuthentication(JwtBearer)` config `Authority` = Keycloak realm URL, `Audience` ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), `RequireHttpsMetadata = false` (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
- [ ] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
- [ ] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
- [x] `AddAuthentication(JwtBearer)` config `Authority` = Keycloak realm URL, `Audience` ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), `RequireHttpsMetadata = false` (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
- [x] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
- [x] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
### 2.4 Endpoints (`DocumentsController`)
- [ ] `POST /api/documents/{documentId}/versions` (FR-5) — multipart: `file` + `clientVersionId` + **`fields` (JSON map tag→text, เตรียมทางไว้ให้ FR-11)**; auto-create `Document` ถ้ายังไม่มี; versionNumber = max+1 ต่อ document (มี unique index รองรับ concurrency); object key `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`; คำนวณ + เก็บ SHA256; response `{ versionId, versionNumber }`
- [ ] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
- [ ] `GET /api/documents/{documentId}/versions/{versionId}/download` (FR-7) — **proxy stream** จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
- [ ] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
- [x] `POST /api/documents/{documentId}/versions` (FR-5) — multipart: `file` + `clientVersionId` + **`fields` (JSON map tag→text, เตรียมทางไว้ให้ FR-11)**; auto-create `Document` ถ้ายังไม่มี; versionNumber = max+1 ต่อ document (มี unique index รองรับ concurrency); object key `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`; คำนวณ + เก็บ SHA256; response `{ versionId, versionNumber }`
- [x] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
- [x] `GET /api/documents/{documentId}/versions/{versionId}/download` (FR-7) — **proxy stream** จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
- [x] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
### 2.5 Cross-cutting
- [ ] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
- [ ] Logging ทุก upload/download/generate: documentId, versionId, userId
- [ ] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak)
- [x] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
- [x] Logging ทุก upload/download/generate: documentId, versionId, userId
- [x] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak)
หมายเหตุจากการทำจริง (2026-08-19):
- **Keycloak public client ไม่ใส่ claim `aud` ใน access token อัตโนมัติ** (ทดสอบจริงด้วย PKCE flow) → เพิ่ม `oidc-audience-mapper` ("audience-self") ให้ทุก client ใน `realm-export.json` เพื่อให้ token มี `aud` = clientId ตัวเอง แล้ว backend จึง validate `ValidAudiences = [word-addin-officejs, word-versioning-web]` ได้จริง (realm ต้อง re-import: `docker compose rm -sf keycloak && docker compose up -d`)
- ตรวจ token ได้โดยไม่ต้องเปิด browser: PKCE auth-code flow ทั้งเส้นด้วย curl (GET login form → POST credentials ตาม cookie → ตาม 302 เอา `code` → exchange กับ verifier) — ใช้ซ้ำได้ตอน dev/test
- `MapInboundClaims = false` เพื่ออ่าน claim ชื่อดิบ `sub` / `preferred_username` (ไม่โดนแปลงเป็น `http://schemas.xmlsoap.org/...`)
- download ใช้ stream wrapper ที่ครอบ `GetObjectResponse` (dispose stream = dispose response) — controller คืน `File(stream, ...)` ได้เลยไม่ต้องโหลดทั้งไฟล์ใน memory ไม่ต้องจัดการ lifetime ใน controller
- upload คำนวณ SHA256 จาก stream pass แรกแล้ว `Seek(0)` ส่ง MinIO pass สอง (IFormFile ของ Kestrel seek ได้เพราะ spool ลง disk แล้ว); ถ้า MinIO ล้มเหลวหลัง insert row จะถอน row กลับ (ไม่ทิ้ง record ชี้ไฟล์ที่ไม่มีจริง)
- content-type ยอมรับ docx mime ตรงๆ และ `application/octet-stream`/`application/zip` (client อย่าง curl ไม่ set ชนิดให้) แต่ชื่อไฟล์ต้องลงท้าย `.docx` เสมอ
- เลข version จองผ่าน insert + unique index — ชน constraint (request พร้อมกัน) แล้ว detach แล้วคำนวณใหม่สูงสุด 5 ครั้ง
- API dev URL = `http://localhost:5075` (run แบบ `dotnet run --no-launch-profile` + `ASPNETCORE_URLS` เพื่อไม่เปิด browser)
**DoD:** `dotnet run` + Swagger: (1) เรียก endpoint ไหนก็ได้โดยไม่แนบ token → **401** (2) ขอ token จาก Keycloak ด้วย test user แล้ว upload ไฟล์ docx จริง → มี record ใน Postgres + object ใน MinIO + download กลับมาเปิดได้ (AC-3 ผ่าน)
> ✅ ตรวจผ่านจริง (2026-08-19): 401 ทั้ง 3 endpoints; token จาก PKCE flow จริง (client `word-versioning-web`); upload v1 (พร้อม `fields`) + v2 → 201 versionNumber 1, 2; Postgres มี Document + 2 DocumentVersions (ContentHash ตรง sha256 ต้นทาง, CreatedByUserId = `sub`); MinIO มี object 2 ไฟล์ตาม key format; download กลับมาตรง byte-for-byte + content-type ถูก; GET history เรียงใหม่→เก่าพร้อม fields ที่ parse แล้ว; .txt → 400; CORS preflight จาก `https://localhost:3000` → 204 + allow-origin ถูก
---
## Phase 3: Backend template & generation — FR-9, FR-10 (ใหม่)