From 332d00db751fa43938772303049336acf0a737ae Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Mon, 17 Aug 2026 11:26:13 +0800 Subject: [PATCH] feat: 7.2 --- app/citizen-frontend/src/api/auth.ts | 43 +++++++- app/citizen-frontend/src/api/client.ts | 2 + app/citizen-frontend/src/api/course.ts | 8 +- app/citizen-frontend/src/api/profile.ts | 12 +++ .../src/api/self-assessment.ts | 52 +++++++++ app/citizen-frontend/src/api/types.ts | 102 ++++++++++++++++-- app/citizen-frontend/src/stores/app.ts | 10 +- app/citizen-frontend/src/stores/course.ts | 9 +- 8 files changed, 210 insertions(+), 28 deletions(-) create mode 100644 app/citizen-frontend/src/api/profile.ts create mode 100644 app/citizen-frontend/src/api/self-assessment.ts diff --git a/app/citizen-frontend/src/api/auth.ts b/app/citizen-frontend/src/api/auth.ts index 3b82c5f..fa08e5e 100644 --- a/app/citizen-frontend/src/api/auth.ts +++ b/app/citizen-frontend/src/api/auth.ts @@ -1,8 +1,41 @@ -import type { LoginRequest, LoginResponse } from "./types"; import { api } from "./client"; +import type { + AuthResponse, + LoginRequest, + LogoutResponse, + RegisterRequest, +} from "./types"; -/** 使用手機門號登入,取得後續查詢課程建議所需的 userID 與 group */ -export function login(phone: string): Promise { - const body: LoginRequest = { phone }; - return api.post("v1/auth/login", { json: body }).json(); +/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */ +export function login(request: LoginRequest): Promise; +export function login(phone: string, password: string): Promise; +export function login( + requestOrPhone: LoginRequest | string, + password?: string, +): Promise { + if (typeof requestOrPhone === "string") { + if (!password) { + throw new Error("登入需要密碼"); + } + + return api + .post("v1/auth/login", { + json: { phone: requestOrPhone, password }, + }) + .json(); + } + + return api + .post("v1/auth/login", { json: requestOrPhone }) + .json(); +} + +/** 註冊帳號並建立完整個人資料。 */ +export function register(request: RegisterRequest): Promise { + return api.post("v1/auth/register", { json: request }).json(); +} + +/** 呼叫後端登出並讓後端清除登入 Cookie。 */ +export function logout(): Promise { + return api.post("v1/auth/logout").json(); } diff --git a/app/citizen-frontend/src/api/client.ts b/app/citizen-frontend/src/api/client.ts index ebc5643..cf877bf 100644 --- a/app/citizen-frontend/src/api/client.ts +++ b/app/citizen-frontend/src/api/client.ts @@ -7,6 +7,8 @@ import ky from "ky"; export const api = ky.create({ // prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋 prefix: import.meta.env.VITE_API_BASE_URL || "/api", + // 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。 + credentials: "include", timeout: 10_000, retry: { limit: 2 }, }); diff --git a/app/citizen-frontend/src/api/course.ts b/app/citizen-frontend/src/api/course.ts index 136302b..9d3d6f5 100644 --- a/app/citizen-frontend/src/api/course.ts +++ b/app/citizen-frontend/src/api/course.ts @@ -40,11 +40,9 @@ function toCourseRow(raw: CourseListApiRow): CourseRow { }; } -/** 依登入取得的 userID 查詢體適能組別 */ -export function fetchGroup(userID: string): Promise { - return api - .get("course/group", { searchParams: { userID } }) - .json(); +/** 依登入 Cookie 取得目前使用者的體適能組別。 */ +export function fetchGroup(): Promise { + return api.get("course/group").json(); } /** 取得是否納入早上時段 */ diff --git a/app/citizen-frontend/src/api/profile.ts b/app/citizen-frontend/src/api/profile.ts new file mode 100644 index 0000000..db513b8 --- /dev/null +++ b/app/citizen-frontend/src/api/profile.ts @@ -0,0 +1,12 @@ +import { api } from "./client"; +import type { Profile, ProfileRequest } from "./types"; + +/** 讀取目前登入使用者的個人資料。 */ +export function getProfile(): Promise { + return api.get("v1/me/profile").json(); +} + +/** 建立或完整更新目前登入使用者的個人資料。 */ +export function updateProfile(request: ProfileRequest): Promise { + return api.put("v1/me/profile", { json: request }).json(); +} diff --git a/app/citizen-frontend/src/api/self-assessment.ts b/app/citizen-frontend/src/api/self-assessment.ts new file mode 100644 index 0000000..e14dff0 --- /dev/null +++ b/app/citizen-frontend/src/api/self-assessment.ts @@ -0,0 +1,52 @@ +import { api } from "./client"; +import type { SelfAssessmentRequest, SelfAssessmentResponse } from "./types"; + +/** + * 產生一個 UUID v4,供同一份自評草稿的所有重試共用。 + * 優先使用瀏覽器的密碼學亂數 API;舊環境才退回 Math.random。 + */ +export function createIdempotencyKey(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (value) => { + const random = Math.floor(Math.random() * 16); + const digit = value === "x" ? random : (random & 0x3) | 0x8; + return digit.toString(16); + }); +} + +/** + * 讀取目前登入使用者最近一次已保存的自評。 + * 訪客自評流程不應呼叫此 API,應先使用 sessionStorage 草稿。 + */ +export function getLatestSelfAssessment(): Promise { + return api + .get("v1/me/self-assessments/latest") + .json(); +} + +/** + * 建立一筆正式自評。 + * + * ky 預設不重試 POST;此處明確啟用 POST 的網路、逾時與暫時性錯誤重試。 + * 每次重試都沿用同一份 headers,因此 Idempotency-Key 不會改變。 + * 若呼叫端在 Promise 失敗後再次呼叫,也必須傳入同一個 key。 + */ +export function createSelfAssessment( + request: SelfAssessmentRequest, + idempotencyKey = createIdempotencyKey(), +): Promise { + return api + .post("v1/me/self-assessments", { + json: request, + headers: { "Idempotency-Key": idempotencyKey }, + retry: { + limit: 2, + methods: ["post"], + retryOnTimeout: true, + }, + }) + .json(); +} diff --git a/app/citizen-frontend/src/api/types.ts b/app/citizen-frontend/src/api/types.ts index c122964..684c224 100644 --- a/app/citizen-frontend/src/api/types.ts +++ b/app/citizen-frontend/src/api/types.ts @@ -1,11 +1,40 @@ -/** - * 課程建議相關的 API 契約。 - * 後端尚未開發,這份型別即為前後端約定的介面。 - */ +/** 共用個資授權條款版本;不可讓使用者自行輸入或修改。 */ +export const CONSENT_VERSION = "v1" as const; +export type ConsentVersion = typeof CONSENT_VERSION; /** 體適能組別 */ export type FitnessGroup = "low" | "medium" | "high"; +/** 性別 */ +export type Gender = "male" | "female"; + +/** 最近 7 天每週大運動頻率 */ +export type ExerciseFrequency = "none" | "oneToTwo" | "threeOrMore"; + +/** 想改善的體適能項目 */ +export type WantedImprovement = + | "strength" + | "balance" + | "cardio" + | "flexibility" + | "agility" + | "bodyComposition"; + +/** 自評疼痛程度 */ +export type PainLevel = "none" | "mild" | "moderate" | "severe"; + +/** 慢性病項目 */ +export type ChronicDisease = + | "hypertension" + | "diabetes" + | "hyperlipidemia" + | "heartDisease" + | "osteoarthritis" + | "osteoporosis"; + +/** 後端計算的正式風險分級 */ +export type RiskLevel = "low" | "followUp" | "consultation"; + /** 後端以 Y/N 字串表示布林值 */ export type YesNo = "Y" | "N"; @@ -31,15 +60,70 @@ export interface CourseListParams { night: YesNo; } -/** 登入請求:僅需手機門號 */ -export interface LoginRequest { - phone: string; +/** 個人資料的可寫入欄位;手機與更新時間由後端管理。 */ +export interface ProfileRequest { + name: string; + birthday: string; + gender: Gender; + heightCm: number; + weightKg: number; + email: string | null; + emergencyContact: string | null; + emergencyPhone: string | null; + consent: boolean; + consentVersion: ConsentVersion; } -/** 登入成功回應,userID 供後續課程 API 查詢使用 */ -export interface LoginResponse { +/** 個人資料 API 回應。 */ +export interface Profile extends ProfileRequest { + phone: string; + updatedAt: string; +} + +/** 註冊請求:建立帳號並同時建立完整個人資料。 */ +export interface RegisterRequest extends ProfileRequest { + phone: string; + password: string; +} + +/** 登入請求。 */ +export interface LoginRequest { + phone: string; + password: string; +} + +/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */ +export interface AuthResponse { userID: string; userName: string; phone: string; group: FitnessGroup; + profile: Profile | null; } + +/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */ +export type LoginResponse = AuthResponse; + +/** 登出成功回應。 */ +export interface LogoutResponse { + messageCode: string; +} + +/** 建立自評時可送出的欄位;riskLevel 由後端計算。 */ +export interface SelfAssessmentRequest { + exerciseFrequency: ExerciseFrequency; + wantedImprovements: WantedImprovement[]; + hasFalls: boolean; + painLevel: PainLevel; + chronicDiseases: ChronicDisease[]; +} + +/** 自評 API 回應,包含後端計算的正式風險分級。 */ +export interface SelfAssessmentResponse extends SelfAssessmentRequest { + assessmentId: string; + riskLevel: RiskLevel; + completedAt: string; +} + +/** 最近一次自評與建立自評 API 使用相同的回應模型。 */ +export type SelfAssessment = SelfAssessmentResponse; diff --git a/app/citizen-frontend/src/stores/app.ts b/app/citizen-frontend/src/stores/app.ts index 5fd6d3c..dd09817 100644 --- a/app/citizen-frontend/src/stores/app.ts +++ b/app/citizen-frontend/src/stores/app.ts @@ -236,8 +236,8 @@ export const useAppStore = defineStore("app", () => { return Math.round((done / keys.length) * 100); }); - /** 手機門號登入;後端未完成時走模擬資料,完成後設 VITE_USE_MOCK=false 改打真實 API */ - async function login(phone: string) { + /** 手機門號登入;密碼欄位將由登入頁在第 7.3 項補上。 */ + async function login(phone: string, password = "") { if (USE_MOCK) { isLoggedIn.value = true; userID.value = "MOCK-USER"; @@ -245,7 +245,11 @@ export const useAppStore = defineStore("app", () => { return; } - const response = await apiLogin(phone); + if (!password) { + throw new Error("請輸入登入密碼"); + } + + const response = await apiLogin({ phone, password }); isLoggedIn.value = true; userID.value = response.userID; diff --git a/app/citizen-frontend/src/stores/course.ts b/app/citizen-frontend/src/stores/course.ts index 168ed56..e5b7b32 100644 --- a/app/citizen-frontend/src/stores/course.ts +++ b/app/citizen-frontend/src/stores/course.ts @@ -15,7 +15,6 @@ import { fetchNight, fetchNoon, } from "@/api/course"; -import { useAppStore } from "@/stores/app"; /** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */ const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false"; @@ -253,11 +252,9 @@ export const useCourseStore = defineStore("course", () => { return; } - // 真實後端:course/group 需要 userID,course/morning、noon、night 需要 group, - // 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同, - // 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準) - const { userID } = useAppStore(); - const groupValue = await fetchGroup(userID); + // 真實後端:course/group 由登入 Cookie 識別使用者; + // course/morning、noon、night 需要 group,兩者有相依順序。 + const groupValue = await fetchGroup(); const [morningValue, noonValue, nightValue] = await Promise.all([ fetchMorning(groupValue),