From e082bd2d982bfa9c8e7b6960631d33f3b95f5b51 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Wed, 19 Aug 2026 22:02:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E5=AE=9E=E7=8E=B0=E4=B8=89?= =?UTF-8?q?=E7=AB=AF=E7=BB=9F=E4=B8=80=E7=9A=84JSON=20API=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增`api_response.py`统一响应封装工具类,提供标准成功/错误响应构造方法 - 重构WonderQ-Admin全局异常处理器,将所有异常转换为标准响应格式 - 修改所有公共和管理端接口的返回逻辑,统一使用`code`(与HTTP状态码一致)、`msg`和`data`的三层结构 - 新增`api-response-contract.md`文档,定义完整的三端统一JSON响应规范 - 更新所有领域API文档,明确业务数据需位于`data`字段内,补充响应格式说明 - 为WonderQ-MiniAPP和WonderQ-Admin-UI新增响应解析逻辑和类型定义,自动完成协议校验和错误处理 - 更新所有测试用例,适配新的响应结构确保接口符合契约要求 - 新增`module-config-api.md`模块配置API文档,补充站点模块配置的接口约定 - 更新项目README文档,调整文档分类顺序将响应契约置于首位 --- WonderQ-Admin-UI/src/api.ts | 83 ++++++++++- WonderQ-Admin/app/api_response.py | 39 ++++++ WonderQ-Admin/app/main.py | 15 +- WonderQ-Admin/app/routers/admin.py | 153 +++++++++++---------- WonderQ-Admin/app/routers/public.py | 37 ++--- WonderQ-Admin/tests/test_api_contracts.py | 66 +++++---- WonderQ-Admin/tests/test_api_response.py | 65 +++++++++ WonderQ-Admin/tests/test_app.py | 6 +- WonderQ-Admin/tests/test_concierge.py | 20 ++- WonderQ-Admin/tests/test_details.py | 8 +- WonderQ-Admin/tests/test_home.py | 124 +++++++++-------- WonderQ-Admin/tests/test_public_wanfa.py | 36 ++--- WonderQ-Admin/tests/test_wanfa.py | 58 ++++---- WonderQ-MiniAPP/src/lib/api.ts | 68 +++++++-- WonderQ-MiniAPP/src/lib/types.ts | 16 +++ WonderQ-MiniAPP/tests/api-response.test.ts | 41 ++++++ docs/README.md | 40 +++--- docs/admin-api-requirements.md | 7 +- docs/api-response-contract.md | 116 ++++++++++++++++ docs/concierge-api.md | 60 ++++---- docs/detail-api.md | 66 +++++---- docs/home-api.md | 48 ++++--- docs/integration-workflow.md | 5 + docs/module-config-api.md | 58 ++++++++ docs/public-api.md | 17 ++- docs/team-building-api.md | 4 + docs/wanfa-api.md | 74 ++++++---- docs/wild-archives-api.md | 46 ++++--- 28 files changed, 993 insertions(+), 383 deletions(-) create mode 100644 WonderQ-Admin/app/api_response.py create mode 100644 WonderQ-Admin/tests/test_api_response.py create mode 100644 WonderQ-MiniAPP/tests/api-response.test.ts create mode 100644 docs/api-response-contract.md create mode 100644 docs/module-config-api.md diff --git a/WonderQ-Admin-UI/src/api.ts b/WonderQ-Admin-UI/src/api.ts index 0dbbd7d..d8e88fc 100644 --- a/WonderQ-Admin-UI/src/api.ts +++ b/WonderQ-Admin-UI/src/api.ts @@ -283,6 +283,22 @@ export type SiteItemPatch = { const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ""; const TOKEN_KEY = "miniapp_admin_token"; +export type ApiResponse = { + code: number; + msg: string; + data: T; + errorCode?: string; + details?: unknown; +}; + +export type ApiErrorResponse = { + code: number; + msg: string; + data: null; + errorCode?: string; + details?: unknown; +}; + export function getToken() { return window.localStorage.getItem(TOKEN_KEY); } @@ -300,6 +316,61 @@ function handleAuthExpired() { notifyAuthExpired(); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export class ApiRequestError extends Error { + readonly statusCode: number; + readonly errorCode?: string; + readonly details?: unknown; + + constructor(message: string, statusCode: number, errorCode?: string, details?: unknown) { + super(message); + this.name = "ApiRequestError"; + this.statusCode = statusCode; + this.errorCode = errorCode; + this.details = details; + } +} + +export class ApiProtocolError extends Error { + constructor(message = "接口响应格式不正确") { + super(message); + this.name = "ApiProtocolError"; + } +} + +export function parseApiResponse(payload: unknown, statusCode: number): T { + if (!isRecord(payload)) { + throw new ApiProtocolError(); + } + + const hasData = Object.prototype.hasOwnProperty.call(payload, "data"); + if (statusCode >= 200 && statusCode < 300) { + if (typeof payload.code !== "number" || payload.code !== statusCode || payload.msg !== "success" || !hasData) { + throw new ApiProtocolError(); + } + return payload.data as T; + } + + if (typeof payload.code !== "number" || payload.code !== statusCode || typeof payload.msg !== "string" || !hasData || payload.data !== null) { + throw new ApiProtocolError(); + } + + const body = payload as ApiErrorResponse; + throw new ApiRequestError( + body.msg, + statusCode, + body.errorCode, + body.details, + ); +} + +async function readApiResponse(response: Response): Promise { + return response.json().catch(() => null); +} + async function request(path: string, options: RequestInit = {}): Promise { const headers = new Headers(options.headers); headers.set("Content-Type", "application/json"); @@ -315,11 +386,11 @@ async function request(path: string, options: RequestInit = {}): Promise { if (path !== "/api/admin/auth/login" && shouldExpireAdminSession(response.status, Boolean(token))) { handleAuthExpired(); } - const body = await response.json().catch(() => ({})); - throw new Error(body.message ?? `请求失败:${response.status}`); + const body = await readApiResponse(response); + return parseApiResponse(body, response.status); } - return response.json() as Promise; + return parseApiResponse(await readApiResponse(response), response.status); } export async function login(email: string, password: string) { @@ -347,11 +418,11 @@ export async function uploadMediaAsset(file: File, group = "site-config") { if (shouldExpireAdminSession(response.status, Boolean(token))) { handleAuthExpired(); } - const body = await response.json().catch(() => ({})); - throw new Error(body.message ?? `请求失败:${response.status}`); + const body = await readApiResponse(response); + return parseApiResponse(body, response.status); } - return response.json() as Promise; + return parseApiResponse(await readApiResponse(response), response.status); } export async function getSiteConfig() { diff --git a/WonderQ-Admin/app/api_response.py b/WonderQ-Admin/app/api_response.py new file mode 100644 index 0000000..910ed94 --- /dev/null +++ b/WonderQ-Admin/app/api_response.py @@ -0,0 +1,39 @@ +from collections.abc import Mapping +from typing import Any + +from fastapi.responses import JSONResponse + + +def success_response(data: Any, *, status_code: int = 200) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={"code": status_code, "msg": "success", "data": data}, + ) + + +def error_response( + status_code: int, + message: str, + *, + error_code: str | None = None, + details: Any = None, +) -> JSONResponse: + content: dict[str, Any] = { + "code": status_code, + "msg": message, + "data": None, + } + if error_code: + content["errorCode"] = error_code + if details: + content["details"] = details + return JSONResponse(status_code=status_code, content=content) + + +def exception_parts(detail: Any) -> tuple[str, str | None, Any]: + if isinstance(detail, Mapping): + message = detail.get("msg") or detail.get("message") or "请求失败" + raw_code = detail.get("errorCode") or detail.get("code") + error_code = raw_code if isinstance(raw_code, str) else None + return str(message), error_code, detail.get("details") + return str(detail or "请求失败"), None, None diff --git a/WonderQ-Admin/app/main.py b/WonderQ-Admin/app/main.py index 1355808..37c1803 100644 --- a/WonderQ-Admin/app/main.py +++ b/WonderQ-Admin/app/main.py @@ -2,7 +2,7 @@ import logging from fastapi import FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from .api_response import error_response, exception_parts from .config import get_settings from .routers import admin, public @@ -24,18 +24,21 @@ def create_app() -> FastAPI: @app.exception_handler(RequestValidationError) async def validation_exception_handler(_request: Request, exc: RequestValidationError): first = exc.errors()[0] if exc.errors() else {} - return JSONResponse(status_code=400, content={"message": first.get("msg", "请求参数不正确")}) + return error_response( + 400, + str(first.get("msg", "请求参数不正确")), + error_code="VALIDATION_ERROR", + ) @app.exception_handler(HTTPException) async def http_exception_handler(_request: Request, exc: HTTPException): - if isinstance(exc.detail, dict) and "message" in exc.detail: - return JSONResponse(status_code=exc.status_code, content=exc.detail) - return JSONResponse(status_code=exc.status_code, content={"message": exc.detail}) + message, error_code, details = exception_parts(exc.detail) + return error_response(exc.status_code, message, error_code=error_code, details=details) @app.exception_handler(Exception) async def unhandled_exception_handler(request: Request, exc: Exception): request.app.logger.exception(exc) if hasattr(request.app, "logger") else logging.exception(exc) - return JSONResponse(status_code=500, content={"message": "服务暂时不可用"}) + return error_response(500, "服务暂时不可用", error_code="INTERNAL_SERVER_ERROR") app.include_router(public.router) app.include_router(admin.router) diff --git a/WonderQ-Admin/app/routers/admin.py b/WonderQ-Admin/app/routers/admin.py index f858215..153be90 100644 --- a/WonderQ-Admin/app/routers/admin.py +++ b/WonderQ-Admin/app/routers/admin.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Reques from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, selectinload from ..auth import create_token, get_actor_id, require_admin, verify_password +from ..api_response import success_response from ..config import get_settings from ..database import get_db from ..models import ( @@ -503,7 +504,7 @@ def create_home_item(db: Session, request: Request, body, model, entity: str): after = model_dict(item) audit(db, get_actor_id(request), "create", entity, item.id, after=after) db.commit() - return after + return success_response(after, status_code=status.HTTP_201_CREATED) def update_home_item(db: Session, request: Request, item_id: str, body, model, entity: str, label: str, code: str): @@ -515,7 +516,7 @@ def update_home_item(db: Session, request: Request, item_id: str, body, model, e after = model_dict(item) audit(db, get_actor_id(request), "update", entity, item.id, after=after, before=before) db.commit() - return after + return success_response(after) def delete_home_item(db: Session, request: Request, item_id: str, model, entity: str, label: str, code: str): @@ -528,7 +529,7 @@ def delete_home_item(db: Session, request: Request, item_id: str, model, entity: result = {"id": item_id} audit(db, get_actor_id(request), "delete", entity, item_id, before=before, after=result) db.commit() - return result + return success_response(result) def reorder_home_items(db: Session, request: Request, body: HomeReorderIn, model, entity: str): @@ -539,12 +540,12 @@ def reorder_home_items(db: Session, request: Request, body: HomeReorderIn, model after = [model_dict(item) for item in ordered] audit(db, get_actor_id(request), "reorder", entity, after=after) db.commit() - return {"items": after} + return success_response({"items": after}) @router.get("/home/experiences") def list_home_experiences(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - return {"items": [model_dict(item) for item in home_items(db, HomeExperience)]} + return success_response({"items": [model_dict(item) for item in home_items(db, HomeExperience)]}) @router.post("/home/experiences", status_code=status.HTTP_201_CREATED) @@ -590,7 +591,7 @@ def delete_home_experience( @router.get("/home/team-buildings") def list_home_team_buildings(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - return {"items": [model_dict(item) for item in home_items(db, HomeTeamBuilding)]} + return success_response({"items": [model_dict(item) for item in home_items(db, HomeTeamBuilding)]}) @router.post("/home/team-buildings", status_code=status.HTTP_201_CREATED) @@ -636,7 +637,7 @@ def delete_home_team_building( @router.get("/home/wild-archives") def list_home_wild_archives(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - return {"items": [model_dict(item) for item in home_items(db, HomeWildArchive)]} + return success_response({"items": [model_dict(item) for item in home_items(db, HomeWildArchive)]}) @router.post("/home/wild-archives", status_code=status.HTTP_201_CREATED) @@ -713,7 +714,7 @@ def ensure_home_wanfa_category_available( @router.get("/home/play-recommendations") def list_home_wanfa_recommendations(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - return {"items": [home_wanfa_recommendation_dict(item) for item in home_wanfa_recommendation_items(db)]} + return success_response({"items": [home_wanfa_recommendation_dict(item) for item in home_wanfa_recommendation_items(db)]}) @router.post("/home/play-recommendations", status_code=status.HTTP_201_CREATED) @@ -736,7 +737,7 @@ def create_home_wanfa_recommendation( after = home_wanfa_recommendation_dict(recommendation) audit(db, get_actor_id(request), "create", "home_wanfa_recommendation", recommendation.id, after=after) db.commit() - return after + return success_response(after, status_code=status.HTTP_201_CREATED) @router.patch("/home/play-recommendations/reorder") @@ -753,7 +754,7 @@ def reorder_home_wanfa_recommendations( after = [home_wanfa_recommendation_dict(item) for item in ordered] audit(db, get_actor_id(request), "reorder", "home_wanfa_recommendation", after=after) db.commit() - return {"items": after} + return success_response({"items": after}) @router.patch("/home/play-recommendations/{recommendation_id}") @@ -777,7 +778,7 @@ def update_home_wanfa_recommendation( after = home_wanfa_recommendation_dict(recommendation) audit(db, get_actor_id(request), "update", "home_wanfa_recommendation", recommendation.id, after=after, before=before) db.commit() - return after + return success_response(after) @router.delete("/home/play-recommendations/{recommendation_id}") @@ -796,7 +797,7 @@ def delete_home_wanfa_recommendation( result = {"id": recommendation_id} audit(db, get_actor_id(request), "delete", "home_wanfa_recommendation", recommendation_id, before=before, after=result) db.commit() - return result + return success_response(result) def concierge_error(status_code: int, message: str, code: str, details: dict | None = None) -> None: @@ -847,7 +848,7 @@ def validate_detail_order(item_ids: list[str], current_ids: list[str]) -> None: @router.get("/details") def list_details(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): details = db.scalars(select(DetailRecord).order_by(DetailRecord.sortOrder.asc())).all() - return {"details": [detail_record_dict(detail) for detail in details]} + return success_response({"details": [detail_record_dict(detail) for detail in details]}) @router.patch("/details/reorder") @@ -864,12 +865,12 @@ def reorder_details( after = [detail_record_dict(detail) for detail in ordered] audit(db, get_actor_id(request), "reorder", "detail_record", after=after) db.commit() - return {"items": after} + return success_response({"items": after}) @router.get("/details/{detail_id}") def get_detail(detail_id: str, _user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - return detail_record_dict(detail_or_error(db, detail_id)) + return success_response(detail_record_dict(detail_or_error(db, detail_id))) @router.post("/details", status_code=status.HTTP_201_CREATED) @@ -889,7 +890,7 @@ def create_detail( after = detail_record_dict(detail) audit(db, get_actor_id(request), "create", "detail_record", detail.id, after=after) db.commit() - return after + return success_response(after, status_code=status.HTTP_201_CREATED) @router.patch("/details/{detail_id}") @@ -910,7 +911,7 @@ def update_detail( after = detail_record_dict(detail) audit(db, get_actor_id(request), "update", "detail_record", detail.id, after=after, before=before) db.commit() - return after + return success_response(after) @router.delete("/details/{detail_id}") @@ -928,7 +929,7 @@ def delete_detail( item.sortOrder = index audit(db, get_actor_id(request), "delete", "detail_record", detail.id, before=before) db.commit() - return {"id": detail.id} + return success_response({"id": detail.id}) @router.get("/wanfa/categories") @@ -936,7 +937,7 @@ def list_wanfa_categories(_user: AdminUser = Depends(require_admin), db: Session categories = db.scalars( select(WanfaCategory).options(selectinload(WanfaCategory.routes)).order_by(WanfaCategory.sortOrder.asc()) ).all() - return {"categories": [wanfa_category_dict(category) for category in categories]} + return success_response({"categories": [wanfa_category_dict(category) for category in categories]}) @router.post("/wanfa/categories", status_code=status.HTTP_201_CREATED) @@ -952,7 +953,7 @@ def create_wanfa_category( after = wanfa_category_dict(category) audit(db, get_actor_id(request), "create", "wanfa_category", category.id, after=after) db.commit() - return after + return success_response(after, status_code=status.HTTP_201_CREATED) @router.patch("/wanfa/categories/reorder") @@ -969,7 +970,7 @@ def reorder_wanfa_categories( after = [wanfa_category_dict(category) for category in ordered] audit(db, get_actor_id(request), "reorder", "wanfa_category", after=after) db.commit() - return {"items": after} + return success_response({"items": after}) @router.patch("/wanfa/categories/{category_id}") @@ -988,7 +989,7 @@ def update_wanfa_category( after = wanfa_category_dict(category) audit(db, get_actor_id(request), "update", "wanfa_category", category.id, after=after, before=before) db.commit() - return after + return success_response(after) @router.delete("/wanfa/categories/{category_id}") @@ -1017,7 +1018,7 @@ def delete_wanfa_category( db.delete(category) audit(db, get_actor_id(request), "delete", "wanfa_category", category.id, before=before) db.commit() - return {"id": category.id} + return success_response({"id": category.id}) @router.post("/wanfa/categories/{category_id}/routes", status_code=status.HTTP_201_CREATED) @@ -1046,7 +1047,7 @@ def create_wanfa_route( after = wanfa_route_dict(route) audit(db, get_actor_id(request), "create", "wanfa_route", route.id, after=after) db.commit() - return after + return success_response(after, status_code=status.HTTP_201_CREATED) @router.patch("/wanfa/categories/{category_id}/routes/reorder") @@ -1065,7 +1066,7 @@ def reorder_wanfa_routes( after = [wanfa_route_dict(route) for route in ordered] audit(db, get_actor_id(request), "reorder", "wanfa_route", category.id, after=after) db.commit() - return {"items": after} + return success_response({"items": after}) @router.patch("/wanfa/categories/{category_id}/routes/{route_id}") @@ -1087,7 +1088,7 @@ def update_wanfa_route( after = wanfa_route_dict(route) audit(db, get_actor_id(request), "update", "wanfa_route", route.id, after=after, before=before) db.commit() - return after + return success_response(after) @router.delete("/wanfa/categories/{category_id}/routes/{route_id}") @@ -1104,13 +1105,13 @@ def delete_wanfa_route( db.delete(route) audit(db, get_actor_id(request), "delete", "wanfa_route", route.id, before=before) db.commit() - return {"id": route.id} + return success_response({"id": route.id}) @router.get("/concierge/advisors") def list_concierge_advisors(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): advisors = db.scalars(select(ConciergeAdvisor).order_by(ConciergeAdvisor.sortOrder.asc())).all() - return {"advisors": [concierge_advisor_dict(advisor) for advisor in advisors]} + return success_response({"advisors": [concierge_advisor_dict(advisor) for advisor in advisors]}) @router.post("/concierge/advisors", status_code=status.HTTP_201_CREATED) @@ -1130,7 +1131,7 @@ def create_concierge_advisor( after = concierge_advisor_dict(advisor) audit(db, get_actor_id(request), "create", "concierge_advisor", advisor.id, after=after) db.commit() - return after + return success_response(after, status_code=status.HTTP_201_CREATED) @router.patch("/concierge/advisors/reorder") @@ -1147,7 +1148,7 @@ def reorder_concierge_advisors( after = [concierge_advisor_dict(advisor) for advisor in ordered] audit(db, get_actor_id(request), "reorder", "concierge_advisor", after=after) db.commit() - return {"items": after} + return success_response({"items": after}) @router.patch("/concierge/advisors/{advisor_id}") @@ -1169,7 +1170,7 @@ def update_concierge_advisor( after = concierge_advisor_dict(advisor) audit(db, get_actor_id(request), "update", "concierge_advisor", advisor.id, after=after, before=before) db.commit() - return after + return success_response(after) @router.delete("/concierge/advisors/{advisor_id}") @@ -1189,7 +1190,7 @@ def delete_concierge_advisor( result = {"id": advisor_id} audit(db, get_actor_id(request), "delete", "concierge_advisor", advisor_id, before=before, after=result) db.commit() - return result + return success_response(result) @router.post("/auth/login") @@ -1197,15 +1198,17 @@ def login(body: LoginIn, db: Session = Depends(get_db)): user = db.scalar(select(AdminUser).where(AdminUser.email == body.email)) if not user or not user.isActive or not verify_password(body.password, user.passwordHash): raise HTTPException(status_code=401, detail="账号或密码错误") - return { - "token": create_token(user), - "user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role}, - } + return success_response( + { + "token": create_token(user), + "user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role}, + } + ) @router.get("/me") def me(user: AdminUser = Depends(require_admin)): - return {"id": user.id, "email": user.email, "name": user.name, "role": user.role} + return success_response({"id": user.id, "email": user.email, "name": user.name, "role": user.role}) @router.get("/dashboard") @@ -1217,37 +1220,39 @@ def dashboard(_user: AdminUser = Depends(require_admin), db: Session = Depends(g recent = db.scalars( select(Lead).options(selectinload(Lead.assignedUser)).order_by(Lead.createdAt.desc()).limit(5) ).all() - return {"stats": stats, "recentLeads": [lead_dict(lead) for lead in recent]} + return success_response({"stats": stats, "recentLeads": [lead_dict(lead) for lead in recent]}) @router.get("/site-config") def admin_site_config(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): - return { - "heroSlides": [ - hero_slide_admin_dict(item) - for item in db.scalars(select(HeroSlide).order_by(HeroSlide.sortOrder.asc())).all() - ], - "destinationHero": [ - model_dict(item) - for item in db.scalars(select(DestinationHero).order_by(DestinationHero.sortOrder.asc())).all() - ], - "vehicleOptions": [ - model_dict(item) - for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all() - ], - "demandHero": [ - model_dict(item) - for item in db.scalars(select(DemandHero).order_by(DemandHero.sortOrder.asc())).all() - ], - "demandFeatureCards": [ - model_dict(item) - for item in db.scalars(select(DemandFeatureCard).order_by(DemandFeatureCard.sortOrder.asc())).all() - ], - "demandForm": [ - model_dict(item) - for item in db.scalars(select(DemandForm).order_by(DemandForm.createdAt.asc())).all() - ], - } + return success_response( + { + "heroSlides": [ + hero_slide_admin_dict(item) + for item in db.scalars(select(HeroSlide).order_by(HeroSlide.sortOrder.asc())).all() + ], + "destinationHero": [ + model_dict(item) + for item in db.scalars(select(DestinationHero).order_by(DestinationHero.sortOrder.asc())).all() + ], + "vehicleOptions": [ + model_dict(item) + for item in db.scalars(select(VehicleOption).order_by(VehicleOption.sortOrder.asc())).all() + ], + "demandHero": [ + model_dict(item) + for item in db.scalars(select(DemandHero).order_by(DemandHero.sortOrder.asc())).all() + ], + "demandFeatureCards": [ + model_dict(item) + for item in db.scalars(select(DemandFeatureCard).order_by(DemandFeatureCard.sortOrder.asc())).all() + ], + "demandForm": [ + model_dict(item) + for item in db.scalars(select(DemandForm).order_by(DemandForm.createdAt.asc())).all() + ], + } + ) @router.post("/site-config/{module}", status_code=status.HTTP_201_CREATED) @@ -1269,7 +1274,7 @@ def create_site_config( after = site_item_dict(module, item) audit(db, get_actor_id(request), "create", config["entity"], item.id, after) db.commit() - return after + return success_response(after, status_code=status.HTTP_201_CREATED) @router.patch("/site-config/{module}/reorder") @@ -1300,7 +1305,7 @@ def reorder_site_config( after = [site_item_dict(module, item) for item in ordered_items] audit(db, get_actor_id(request), "reorder", config["entity"], module, {"items": after}, {"items": before}) db.commit() - return {"items": after} + return success_response({"items": after}) @router.patch("/site-config/{module}/{item_id}") @@ -1322,7 +1327,7 @@ def update_site_config( after = site_item_dict(module, item) audit(db, get_actor_id(request), "update", config["entity"], item.id, after, before) db.commit() - return after + return success_response(after) @router.delete("/site-config/{module}/{item_id}") @@ -1347,7 +1352,7 @@ def delete_site_config( result = {"id": item_id} audit(db, get_actor_id(request), "delete", config["entity"], item_id, result, before) db.commit() - return result + return success_response(result) @router.get("/leads") @@ -1393,7 +1398,7 @@ def list_leads( ) ).distinct() leads = db.scalars(stmt).all() - return {"items": [lead_dict(lead) for lead in leads]} + return success_response({"items": [lead_dict(lead) for lead in leads]}) @router.patch("/leads/{lead_id}/status") @@ -1406,13 +1411,13 @@ def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user db.flush() audit(db, get_actor_id(request), "update_status", "lead", lead.id, model_dict(lead), before) db.commit() - return model_dict(lead) + return success_response(model_dict(lead)) @router.get("/media-assets") def list_media_assets(_user: AdminUser = Depends(require_admin), db: Session = Depends(get_db)): assets = db.scalars(select(MediaAsset).order_by(MediaAsset.createdAt.desc()).limit(200)).all() - return {"items": [model_dict(asset) for asset in assets]} + return success_response({"items": [model_dict(asset) for asset in assets]}) @router.post("/media-assets/upload", status_code=status.HTTP_201_CREATED) @@ -1439,7 +1444,7 @@ def upload_media_asset( after = model_dict(asset) audit(db, get_actor_id(request), "upload", "media_asset", asset.id, after) db.commit() - return after + return success_response(after, status_code=status.HTTP_201_CREATED) @router.post("/reset-guizhou-content") @@ -1447,7 +1452,7 @@ def reset_content(request: Request, _user: AdminUser = Depends(require_admin), d result = reset_guizhou_content(db) audit(db, get_actor_id(request), "reset_guizhou_content", "site_content", after=result) db.commit() - return result + return success_response(result) @router.post("/publish", status_code=status.HTTP_201_CREATED) @@ -1458,4 +1463,4 @@ def publish(request: Request, _user: AdminUser = Depends(require_admin), db: Ses db.flush() audit(db, get_actor_id(request), "publish", "site_version", version.id, model_dict(version)) db.commit() - return model_dict(version) + return success_response(model_dict(version), status_code=status.HTTP_201_CREATED) diff --git a/WonderQ-Admin/app/routers/public.py b/WonderQ-Admin/app/routers/public.py index 2f0aaeb..dcc93ad 100644 --- a/WonderQ-Admin/app/routers/public.py +++ b/WonderQ-Admin/app/routers/public.py @@ -3,6 +3,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from .shared import site_config from ..auth import create_customer_token, require_customer +from ..api_response import success_response from ..database import get_db from ..models import ConciergeAdvisor, Customer, DetailRecord, HomeExperience, HomeTeamBuilding, HomeWanfaRecommendation, HomeWildArchive, Lead, WanfaCategory from ..schemas import LeadCreateIn, PhoneLoginIn @@ -36,12 +37,12 @@ def public_customer_dict(customer: Customer) -> dict: @router.get("/health") def health(): - return {"ok": True, "service": "miniapp-api"} + return success_response({"ok": True, "service": "miniapp-api"}) @router.get("/api/public/site-config") def get_site_config(db: Session = Depends(get_db)): - return site_config(db, active_only=True) + return success_response(site_config(db, active_only=True)) @router.get("/api/public/wanfa/categories") @@ -51,7 +52,7 @@ def get_public_wanfa_categories(db: Session = Depends(get_db)): .options(selectinload(WanfaCategory.routes)) .order_by(WanfaCategory.sortOrder.asc()) ).all() - return {"categories": [public_wanfa_category_dict(category) for category in categories]} + return success_response({"categories": [public_wanfa_category_dict(category) for category in categories]}) @router.get("/api/public/details/{detail_key}") @@ -64,7 +65,7 @@ def get_public_detail(detail_key: str, db: Session = Depends(get_db)): ) if not detail: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="详情不存在") - return public_detail_dict(detail) + return success_response(public_detail_dict(detail)) @router.get("/api/public/concierge/advisors") @@ -74,7 +75,7 @@ def get_public_concierge_advisors(db: Session = Depends(get_db)): .where(ConciergeAdvisor.isActive.is_(True)) .order_by(ConciergeAdvisor.sortOrder.asc()) ).all() - return {"advisors": [public_concierge_advisor_dict(advisor) for advisor in advisors]} + return success_response({"advisors": [public_concierge_advisor_dict(advisor) for advisor in advisors]}) @router.get("/api/public/home") @@ -100,12 +101,14 @@ def get_public_home(db: Session = Depends(get_db)): .where(HomeWanfaRecommendation.isActive.is_(True)) .order_by(HomeWanfaRecommendation.sortOrder.asc()) ).all() - return { - "experiences": [public_home_experience_dict(item) for item in experiences], - "teamBuildings": [public_home_team_building_dict(item) for item in team_buildings], - "wildArchives": [public_home_wild_archive_dict(item) for item in wild_archives], - "playRecommendations": [public_home_wanfa_recommendation_dict(item) for item in play_recommendations], - } + return success_response( + { + "experiences": [public_home_experience_dict(item) for item in experiences], + "teamBuildings": [public_home_team_building_dict(item) for item in team_buildings], + "wildArchives": [public_home_wild_archive_dict(item) for item in wild_archives], + "playRecommendations": [public_home_wanfa_recommendation_dict(item) for item in play_recommendations], + } + ) @router.get("/api/public/home/team-buildings/{team_building_id}") @@ -118,7 +121,7 @@ def get_public_team_building(team_building_id: str, db: Session = Depends(get_db ) if not team_building: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="团队共创不存在") - return public_home_team_building_detail_dict(team_building) + return success_response(public_home_team_building_detail_dict(team_building)) @router.get("/api/public/home/wild-archives") @@ -128,7 +131,7 @@ def list_public_wild_archives(db: Session = Depends(get_db)): .where(HomeWildArchive.isActive.is_(True)) .order_by(HomeWildArchive.sortOrder.asc()) ).all() - return {"items": [public_home_wild_archive_dict(item) for item in archives]} + return success_response({"items": [public_home_wild_archive_dict(item) for item in archives]}) @router.get("/api/public/home/wild-archives/{archive_id}") @@ -141,7 +144,7 @@ def get_public_wild_archive(archive_id: str, db: Session = Depends(get_db)): ) if not archive: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="客片案例不存在") - return public_home_wild_archive_detail_dict(archive) + return success_response(public_home_wild_archive_detail_dict(archive)) @router.post("/api/public/auth/phone-login") @@ -160,12 +163,12 @@ def phone_login(body: PhoneLoginIn, db: Session = Depends(get_db)): db.flush() db.commit() db.refresh(customer) - return {"token": create_customer_token(customer), "customer": public_customer_dict(customer)} + return success_response({"token": create_customer_token(customer), "customer": public_customer_dict(customer)}) @router.get("/api/public/auth/me") def current_customer(customer: Customer = Depends(require_customer)): - return public_customer_dict(customer) + return success_response(public_customer_dict(customer)) @router.post("/api/public/leads", status_code=status.HTTP_201_CREATED) @@ -174,4 +177,4 @@ def create_lead(body: LeadCreateIn, db: Session = Depends(get_db)): db.add(lead) db.commit() db.refresh(lead) - return {"id": lead.id, "status": lead.status} + return success_response({"id": lead.id, "status": lead.status}, status_code=status.HTTP_201_CREATED) diff --git a/WonderQ-Admin/tests/test_api_contracts.py b/WonderQ-Admin/tests/test_api_contracts.py index 670c9e6..9c1c526 100644 --- a/WonderQ-Admin/tests/test_api_contracts.py +++ b/WonderQ-Admin/tests/test_api_contracts.py @@ -230,7 +230,11 @@ def test_public_leads_endpoint_accepts_date_only_and_returns_minimal_response(): app.dependency_overrides.clear() assert response.status_code == 201 - assert response.json() == {"id": "lead-test-id", "status": "new"} + assert response.json() == { + "code": 201, + "msg": "success", + "data": {"id": "lead-test-id", "status": "new"}, + } assert fake_db.added[0].phone == "contact handle" assert fake_db.added[0].travelDate == datetime(2027, 1, 1) @@ -247,7 +251,7 @@ def test_public_phone_login_creates_customer_and_returns_masked_session(monkeypa app.dependency_overrides.clear() assert response.status_code == 200 - body = response.json() + body = response.json()["data"] assert body["token"] assert body["customer"] == {"id": "customer-test-id", "phoneMasked": "100****0000"} assert fake_db.added[0].phone == "10000000000" @@ -268,7 +272,11 @@ def test_public_phone_login_requires_wechat_configuration(monkeypatch): app.dependency_overrides.clear() assert response.status_code == 503 - assert response.json() == {"message": "微信小程序登录未配置"} + assert response.json() == { + "code": 503, + "msg": "微信小程序登录未配置", + "data": None, + } def test_public_me_returns_customer_for_customer_token(): @@ -284,21 +292,25 @@ def test_public_me_returns_customer_for_customer_token(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json() == {"id": "customer-test", "phoneMasked": "100****0000"} + assert response.json() == { + "code": 200, + "msg": "success", + "data": {"id": "customer-test", "phoneMasked": "100****0000"}, + } def test_public_me_requires_customer_token(): response = TestClient(create_app()).get("/api/public/auth/me") assert response.status_code == 401 - assert response.json() == {"message": "请先登录"} + assert response.json() == {"code": 401, "msg": "请先登录", "data": None} def test_admin_requires_auth_for_protected_endpoint(): response = TestClient(create_app()).get("/api/admin/dashboard") assert response.status_code == 401 - assert response.json() == {"message": "请先登录后台"} + assert response.json() == {"code": 401, "msg": "请先登录后台", "data": None} def test_admin_login_returns_token_and_user(): @@ -323,7 +335,7 @@ def test_admin_login_returns_token_and_user(): app.dependency_overrides.clear() assert response.status_code == 200 - body = response.json() + body = response.json()["data"] assert body["token"] assert body["user"] == { "id": "admin-test", @@ -366,7 +378,7 @@ def test_site_config_create_modules_defaults_fields_and_audits(module, payload, app.dependency_overrides.clear() assert response.status_code == 201 - body = response.json() + body = response.json()["data"] for key, value in expected.items(): assert body[key] == value assert body["isActive"] is True @@ -389,8 +401,10 @@ def test_site_config_invalid_module_returns_structured_error(): assert response.status_code == 400 assert response.json() == { - "message": "模块不存在或无权限操作", - "code": "MODULE_CONFIG_FORBIDDEN", + "code": 400, + "msg": "模块不存在或无权限操作", + "data": None, + "errorCode": "MODULE_CONFIG_FORBIDDEN", "details": {"module": "unknown"}, } @@ -413,7 +427,7 @@ def test_site_config_create_rejects_removed_module(module, payload): app.dependency_overrides.clear() assert response.status_code == 400 - assert response.json()["code"] == "MODULE_CONFIG_FORBIDDEN" + assert response.json()["errorCode"] == "MODULE_CONFIG_FORBIDDEN" def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets(): @@ -427,7 +441,7 @@ def test_admin_site_config_hero_slides_use_dedicated_contract_without_targets(): app.dependency_overrides.clear() assert response.status_code == 200 - hero = response.json()["heroSlides"][0] + hero = response.json()["data"]["heroSlides"][0] assert hero["title"] == "测试轮播" assert hero["image"] == "/assets/slide.jpg" assert "targetType" not in hero @@ -444,7 +458,7 @@ def test_admin_site_config_includes_destination_page_modules(): app.dependency_overrides.clear() assert response.status_code == 200 - body = response.json() + body = response.json()["data"] assert "destinations" not in body assert "ctaBanners" not in body assert "campaigns" not in body @@ -465,7 +479,7 @@ def test_admin_site_config_includes_demand_page_modules(): app.dependency_overrides.clear() assert response.status_code == 200 - body = response.json() + body = response.json()["data"] assert body["demandHero"] == [] assert body["demandFeatureCards"] == [] assert body["demandForm"] == [] @@ -517,7 +531,7 @@ def test_site_config_create_hero_slide_ignores_target_fields_and_returns_dedicat app.dependency_overrides.clear() assert response.status_code == 201 - body = response.json() + body = response.json()["data"] assert body["title"] == "新轮播" assert body["image"] is None assert "targetType" not in body @@ -537,7 +551,7 @@ def test_site_config_create_demand_form_rejects_duplicate_singleton(): app.dependency_overrides.clear() assert response.status_code == 409 - assert response.json()["code"] == "MODULE_CONFIG_SINGLETON_EXISTS" + assert response.json()["errorCode"] == "MODULE_CONFIG_SINGLETON_EXISTS" assert not fake_db.added assert not fake_db.committed @@ -561,7 +575,7 @@ def test_site_config_patch_hero_slide_ignores_target_fields_and_returns_dedicate app.dependency_overrides.clear() assert response.status_code == 200 - body = response.json() + body = response.json()["data"] assert body["title"] == "夏日贵州小包团" assert body["image"] is None assert "targetType" not in body @@ -590,8 +604,8 @@ def test_site_config_reorder_reassigns_sort_order_and_returns_items(): assert response.status_code == 200 assert [item.id for item in items] == ["slide-1", "slide-2", "slide-3"] assert {item.id: item.sortOrder for item in items} == {"slide-2": 0, "slide-1": 1, "slide-3": 2} - assert [item["id"] for item in response.json()["items"]] == ["slide-2", "slide-1", "slide-3"] - assert [item["sortOrder"] for item in response.json()["items"]] == [0, 1, 2] + assert [item["id"] for item in response.json()["data"]["items"]] == ["slide-2", "slide-1", "slide-3"] + assert [item["sortOrder"] for item in response.json()["data"]["items"]] == [0, 1, 2] assert fake_db.committed assert fake_db.added[-1].action == "reorder" @@ -619,7 +633,7 @@ def test_site_config_reorder_rejects_duplicate_missing_and_unknown_ids(item_ids) app.dependency_overrides.clear() assert response.status_code == 400 - assert response.json()["code"] == "MODULE_CONFIG_REORDER_INVALID" + assert response.json()["errorCode"] == "MODULE_CONFIG_REORDER_INVALID" assert not fake_db.committed @@ -648,7 +662,7 @@ def test_admin_media_upload_streams_image_to_oss_records_asset_and_audits(monkey app.dependency_overrides.clear() assert response.status_code == 201 - body = response.json() + body = response.json()["data"] assert body["url"].startswith("https://cdn.example.test/admin/heroSlides/") assert body["name"] == "hero.png" assert body["mimeType"] == "image/png" @@ -675,7 +689,7 @@ def test_admin_media_upload_rejects_non_image_file(): app.dependency_overrides.clear() assert response.status_code == 400 - assert response.json()["code"] == "MEDIA_UPLOAD_INVALID_TYPE" + assert response.json()["errorCode"] == "MEDIA_UPLOAD_INVALID_TYPE" assert not fake_db.added assert not fake_db.committed @@ -700,7 +714,11 @@ def test_admin_leads_endpoint_accepts_source_keyword_and_created_range_filters() app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json() == {"items": []} + assert response.json() == { + "code": 200, + "msg": "success", + "data": {"items": []}, + } stmt = str(fake_db.scalar_statements[0]) assert '"Lead".status' in stmt assert '"Lead"."sourcePage"' in stmt @@ -737,7 +755,7 @@ def test_admin_lead_status_update_returns_updated_status_and_audits(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json()["status"] == "contacted" + assert response.json()["data"]["status"] == "contacted" assert lead.status == "contacted" assert fake_db.committed assert fake_db.added[-1].entity == "lead" diff --git a/WonderQ-Admin/tests/test_api_response.py b/WonderQ-Admin/tests/test_api_response.py new file mode 100644 index 0000000..4b86883 --- /dev/null +++ b/WonderQ-Admin/tests/test_api_response.py @@ -0,0 +1,65 @@ +import json + +import pytest +from fastapi.testclient import TestClient + +from app.api_response import error_response +from app.database import get_db +from app.main import create_app + + +class EmptyDb: + def scalar(self, _statement): + return None + + +def test_health_uses_the_standard_success_envelope(): + response = TestClient(create_app()).get("/health") + + assert response.status_code == 200 + assert response.json() == { + "code": 200, + "msg": "success", + "data": {"ok": True, "service": "miniapp-api"}, + } + + +def test_public_not_found_uses_the_standard_error_envelope(): + app = create_app() + app.dependency_overrides[get_db] = lambda: EmptyDb() + + try: + response = TestClient(app).get("/api/public/details/missing-detail") + finally: + app.dependency_overrides.clear() + + assert response.status_code == 404 + assert response.json() == { + "code": 404, + "msg": "详情不存在", + "data": None, + } + + +def test_request_validation_uses_the_standard_error_envelope(): + response = TestClient(create_app()).post("/api/public/leads", json={}) + + assert response.status_code == 400 + body = response.json() + assert body["code"] == 400 + assert body["data"] is None + assert body["errorCode"] == "VALIDATION_ERROR" + assert body["msg"] + + +@pytest.mark.parametrize("status_code", [400, 401, 404, 409, 422, 500]) +def test_supported_error_statuses_keep_the_same_envelope(status_code): + response = error_response(status_code, "示例错误", error_code="EXAMPLE_ERROR") + + assert response.status_code == status_code + assert json.loads(response.body) == { + "code": status_code, + "msg": "示例错误", + "data": None, + "errorCode": "EXAMPLE_ERROR", + } diff --git a/WonderQ-Admin/tests/test_app.py b/WonderQ-Admin/tests/test_app.py index edfc76d..d1cc9e4 100644 --- a/WonderQ-Admin/tests/test_app.py +++ b/WonderQ-Admin/tests/test_app.py @@ -6,4 +6,8 @@ def test_health_endpoint(): client = TestClient(create_app()) response = client.get("/health") assert response.status_code == 200 - assert response.json() == {"ok": True, "service": "miniapp-api"} + assert response.json() == { + "code": 200, + "msg": "success", + "data": {"ok": True, "service": "miniapp-api"}, + } diff --git a/WonderQ-Admin/tests/test_concierge.py b/WonderQ-Admin/tests/test_concierge.py index cd4ff6e..fc8c8c5 100644 --- a/WonderQ-Admin/tests/test_concierge.py +++ b/WonderQ-Admin/tests/test_concierge.py @@ -143,7 +143,7 @@ def test_admin_concierge_list_returns_full_advisor_record(): app.dependency_overrides.clear() assert response.status_code == 200 - advisor = response.json()["advisors"][0] + advisor = response.json()["data"]["advisors"][0] assert advisor["id"] == "advisor-amanda" assert advisor["isActive"] is True assert advisor["sortOrder"] == 0 @@ -170,7 +170,7 @@ def test_admin_concierge_create_appends_to_the_end(): app.dependency_overrides.clear() assert response.status_code == 201 - assert response.json()["id"] == "concierge-advisor-test-id" + assert response.json()["data"]["id"] == "concierge-advisor-test-id" assert fake_db.added[0].name == "Mia" assert fake_db.added[0].sortOrder == 3 assert fake_db.committed @@ -190,8 +190,8 @@ def test_admin_concierge_patch_updates_details_and_active_state(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json()["details"] == [{"icon": "navigate", "label": "擅长领域:亲子与自然探索"}] - assert response.json()["isActive"] is False + assert response.json()["data"]["details"] == [{"icon": "navigate", "label": "擅长领域:亲子与自然探索"}] + assert response.json()["data"]["isActive"] is False assert advisor.isActive is False assert fake_db.committed @@ -208,7 +208,11 @@ def test_admin_concierge_delete_normalizes_remaining_order(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json() == {"id": "advisor-1"} + assert response.json() == { + "code": 200, + "msg": "success", + "data": {"id": "advisor-1"}, + } assert second.sortOrder == 0 assert fake_db.deleted == [first] assert fake_db.committed @@ -227,7 +231,7 @@ def test_admin_concierge_reorder_rejects_incomplete_ids(): app.dependency_overrides.clear() assert response.status_code == 400 - assert response.json()["code"] == "CONCIERGE_REORDER_INVALID" + assert response.json()["errorCode"] == "CONCIERGE_REORDER_INVALID" assert not fake_db.committed @@ -243,6 +247,9 @@ def test_public_concierge_returns_only_frontend_fields_for_active_advisors(): assert response.status_code == 200 assert response.json() == { + "code": 200, + "msg": "success", + "data": { "advisors": [ { "avatar": "https://example.test/amanda.jpg", @@ -255,4 +262,5 @@ def test_public_concierge_returns_only_frontend_fields_for_active_advisors(): "qrImage": "https://example.test/amanda-qr.png", } ] + }, } diff --git a/WonderQ-Admin/tests/test_details.py b/WonderQ-Admin/tests/test_details.py index 308ce0b..1ce710d 100644 --- a/WonderQ-Admin/tests/test_details.py +++ b/WonderQ-Admin/tests/test_details.py @@ -142,6 +142,9 @@ def test_public_detail_returns_frontend_contract_without_auth(): assert response.status_code == 200 assert response.json() == { + "code": 200, + "msg": "success", + "data": { "key": "family-water", "eyebrow": "玩法推荐", "duration": "5天4晚", @@ -153,6 +156,7 @@ def test_public_detail_returns_frontend_contract_without_auth(): "excluded": ["往返大交通"], "notes": ["请准备轻便雨具。"], "gallery": ["https://example.test/family-water.jpg"], + }, } @@ -193,7 +197,7 @@ def test_admin_detail_list_includes_inactive_records(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json()["details"][0]["isActive"] is False + assert response.json()["data"]["details"][0]["isActive"] is False def test_admin_detail_create_normalizes_key_and_appends(): @@ -221,5 +225,5 @@ def test_admin_detail_create_normalizes_key_and_appends(): app.dependency_overrides.clear() assert response.status_code == 201 - assert response.json()["key"] == "family-water" + assert response.json()["data"]["key"] == "family-water" assert fake_db.added[0].sortOrder == 3 diff --git a/WonderQ-Admin/tests/test_home.py b/WonderQ-Admin/tests/test_home.py index 035251f..813a79f 100644 --- a/WonderQ-Admin/tests/test_home.py +++ b/WonderQ-Admin/tests/test_home.py @@ -226,7 +226,7 @@ def test_admin_home_experience_list_returns_full_record(): app.dependency_overrides.clear() assert response.status_code == 200 - item = response.json()["items"][0] + item = response.json()["data"]["items"][0] assert item["id"] == "waterfall-descent" assert item["isActive"] is True assert item["createdAt"] == "2026-01-01T00:00:00" @@ -272,8 +272,8 @@ def test_admin_home_wild_patch_updates_record(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json()["title"] == "百米自降体验" - assert response.json()["isActive"] is False + assert response.json()["data"]["title"] == "百米自降体验" + assert response.json()["data"]["isActive"] is False assert fake_db.committed @@ -332,7 +332,11 @@ def test_admin_home_delete_normalizes_remaining_order(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json() == {"id": "experience-1"} + assert response.json() == { + "code": 200, + "msg": "success", + "data": {"id": "experience-1"}, + } assert second.sortOrder == 0 assert fake_db.committed @@ -349,7 +353,7 @@ def test_admin_home_reorder_rejects_incomplete_ids(): app.dependency_overrides.clear() assert response.status_code == 400 - assert response.json()["code"] == "HOME_REORDER_INVALID" + assert response.json()["errorCode"] == "HOME_REORDER_INVALID" assert not fake_db.committed @@ -372,8 +376,8 @@ def test_admin_home_wanfa_recommendation_create_links_existing_category(): app.dependency_overrides.clear() assert response.status_code == 201 - assert response.json()["categoryId"] == "family-route" - assert response.json()["categoryLabel"] == "亲子路线" + assert response.json()["data"]["categoryId"] == "family-route" + assert response.json()["data"]["categoryLabel"] == "亲子路线" assert fake_db.added[0].categoryId == "family-route" @@ -395,7 +399,7 @@ def test_public_home_returns_active_wanfa_category_recommendations(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json()["playRecommendations"] == [ + assert response.json()["data"]["playRecommendations"] == [ { "id": "recommendation-1", "categoryId": "family-route", @@ -431,37 +435,41 @@ def test_public_home_returns_rendering_fields_for_all_sections(): assert response.status_code == 200 assert response.json() == { - "experiences": [ - { - "id": "waterfall-descent", - "badge": "玩过推荐", - "category": "瀑降体验", - "title": "悬崖瀑降", - "englishTitle": "WATERFALL DESCENT", - "image": "https://example.test/waterfall.jpg", - "demandKeyword": "悬崖瀑降", - } - ], - "teamBuildings": [ - { - "id": "wild-challenge", - "tag": "户外挑战", - "title": "山野挑战,共创极境", - "description": "洞穴、瀑降与协作,适合 10-30 人。", - "image": "https://example.test/team.jpg", - "demandKeyword": "户外团建", - } - ], - "wildArchives": [ - { - "id": "hundred-meter-descent", - "title": "百米自降", - "image": "https://example.test/archive.jpg", - "demandKeyword": "悬崖瀑降", - "photoCount": 2, - } - ], - "playRecommendations": [], + "code": 200, + "msg": "success", + "data": { + "experiences": [ + { + "id": "waterfall-descent", + "badge": "玩过推荐", + "category": "瀑降体验", + "title": "悬崖瀑降", + "englishTitle": "WATERFALL DESCENT", + "image": "https://example.test/waterfall.jpg", + "demandKeyword": "悬崖瀑降", + } + ], + "teamBuildings": [ + { + "id": "wild-challenge", + "tag": "户外挑战", + "title": "山野挑战,共创极境", + "description": "洞穴、瀑降与协作,适合 10-30 人。", + "image": "https://example.test/team.jpg", + "demandKeyword": "户外团建", + } + ], + "wildArchives": [ + { + "id": "hundred-meter-descent", + "title": "百米自降", + "image": "https://example.test/archive.jpg", + "demandKeyword": "悬崖瀑降", + "photoCount": 2, + } + ], + "playRecommendations": [], + }, } @@ -476,7 +484,7 @@ def test_public_wild_archive_list_returns_photo_count(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json()["items"][0] == { + assert response.json()["data"]["items"][0] == { "id": "hundred-meter-descent", "title": "百米自降", "image": "https://example.test/archive.jpg", @@ -496,8 +504,8 @@ def test_public_wild_archive_detail_returns_all_images(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json()["title"] == "百米自降" - assert response.json()["images"] == [ + assert response.json()["data"]["title"] == "百米自降" + assert response.json()["data"]["images"] == [ "https://example.test/archive.jpg", "https://example.test/archive-2.jpg", ] @@ -515,17 +523,21 @@ def test_public_team_building_detail_returns_long_form_content(): assert response.status_code == 200 assert response.json() == { - "id": "wild-challenge", - "tag": "户外挑战", - "title": "山野挑战,共创极境", - "description": "洞穴、瀑降与协作,适合 10-30 人。", - "image": "https://example.test/team.jpg", - "demandKeyword": "户外团建", - "detailSubtitle": "越过山丘,向来处去", - "detailParagraphs": [ - "真正的贵州,从未被写进攻略里的山野故事。", - "我们用一次次出发,把团队重新带回自然。", - ], + "code": 200, + "msg": "success", + "data": { + "id": "wild-challenge", + "tag": "户外挑战", + "title": "山野挑战,共创极境", + "description": "洞穴、瀑降与协作,适合 10-30 人。", + "image": "https://example.test/team.jpg", + "demandKeyword": "户外团建", + "detailSubtitle": "越过山丘,向来处去", + "detailParagraphs": [ + "真正的贵州,从未被写进攻略里的山野故事。", + "我们用一次次出发,把团队重新带回自然。", + ], + }, } @@ -538,7 +550,7 @@ def test_public_team_building_detail_returns_not_found_for_missing_item(): app.dependency_overrides.clear() assert response.status_code == 404 - assert response.json()["message"] == "团队共创不存在" + assert response.json()["msg"] == "团队共创不存在" def test_public_team_building_detail_falls_back_to_legacy_description(): @@ -552,5 +564,5 @@ def test_public_team_building_detail_falls_back_to_legacy_description(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json()["detailSubtitle"] == team.description - assert response.json()["detailParagraphs"] == [team.description] + assert response.json()["data"]["detailSubtitle"] == team.description + assert response.json()["data"]["detailParagraphs"] == [team.description] diff --git a/WonderQ-Admin/tests/test_public_wanfa.py b/WonderQ-Admin/tests/test_public_wanfa.py index 2caba09..a6d5e94 100644 --- a/WonderQ-Admin/tests/test_public_wanfa.py +++ b/WonderQ-Admin/tests/test_public_wanfa.py @@ -57,20 +57,24 @@ def test_public_wanfa_categories_returns_frontend_display_contract_without_auth( assert response.status_code == 200 assert response.json() == { - "categories": [ - { - "id": "family-route", - "label": "亲子路线", - "routes": [ - { - "id": "family-water", - "title": "亲子玩水", - "subtitle": "贵州·轻松节奏与自然课堂", - "image": "https://example.test/family-water.jpg", - "routeCount": 4, - "demandKeyword": "亲子玩水", - } - ], - } - ] + "code": 200, + "msg": "success", + "data": { + "categories": [ + { + "id": "family-route", + "label": "亲子路线", + "routes": [ + { + "id": "family-water", + "title": "亲子玩水", + "subtitle": "贵州·轻松节奏与自然课堂", + "image": "https://example.test/family-water.jpg", + "routeCount": 4, + "demandKeyword": "亲子玩水", + } + ], + } + ] + }, } diff --git a/WonderQ-Admin/tests/test_wanfa.py b/WonderQ-Admin/tests/test_wanfa.py index 2041eb6..d2652d2 100644 --- a/WonderQ-Admin/tests/test_wanfa.py +++ b/WonderQ-Admin/tests/test_wanfa.py @@ -138,22 +138,26 @@ def test_admin_wanfa_list_returns_nested_categories_and_routes(): assert response.status_code == 200 assert response.json() == { - "categories": [ - { - "id": "family-route", - "label": "亲子路线", - "routes": [ - { - "id": "family-water", - "title": "亲子玩水", - "subtitle": "贵州·轻松节奏与自然课堂", - "image": "https://example.test/family-water.jpg", - "routeCount": 4, - "demandKeyword": "亲子玩水", - } - ], - } - ] + "code": 200, + "msg": "success", + "data": { + "categories": [ + { + "id": "family-route", + "label": "亲子路线", + "routes": [ + { + "id": "family-water", + "title": "亲子玩水", + "subtitle": "贵州·轻松节奏与自然课堂", + "image": "https://example.test/family-water.jpg", + "routeCount": 4, + "demandKeyword": "亲子玩水", + } + ], + } + ] + }, } @@ -167,7 +171,7 @@ def test_admin_wanfa_create_category_appends_to_the_end(): app.dependency_overrides.clear() assert response.status_code == 201 - assert response.json()["label"] == "新玩法" + assert response.json()["data"]["label"] == "新玩法" assert fake_db.added[0].label == "新玩法" assert fake_db.added[0].sortOrder == 3 assert fake_db.committed @@ -193,7 +197,7 @@ def test_admin_wanfa_create_route_uses_category_sort_order(): app.dependency_overrides.clear() assert response.status_code == 201 - assert response.json()["id"] == "wanfaroute-test-id" + assert response.json()["data"]["id"] == "wanfaroute-test-id" assert fake_db.added[0].categoryId == "family-route" assert fake_db.added[0].sortOrder == 2 @@ -209,7 +213,7 @@ def test_admin_wanfa_delete_rejects_non_empty_category(): app.dependency_overrides.clear() assert response.status_code == 409 - assert response.json()["code"] == "WANFA_CATEGORY_NOT_EMPTY" + assert response.json()["errorCode"] == "WANFA_CATEGORY_NOT_EMPTY" assert not fake_db.deleted assert not fake_db.committed @@ -225,7 +229,7 @@ def test_admin_wanfa_delete_rejects_category_linked_to_home_recommendation(): app.dependency_overrides.clear() assert response.status_code == 409 - assert response.json()["code"] == "WANFA_CATEGORY_RECOMMENDED" + assert response.json()["errorCode"] == "WANFA_CATEGORY_RECOMMENDED" assert not fake_db.deleted assert not fake_db.committed @@ -250,8 +254,8 @@ def test_admin_wanfa_route_update_returns_updated_route(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json()["title"] == "亲子溯溪" - assert response.json()["routeCount"] == 5 + assert response.json()["data"]["title"] == "亲子溯溪" + assert response.json()["data"]["routeCount"] == 5 assert route.title == "亲子溯溪" assert route.routeCount == 5 assert fake_db.committed @@ -276,7 +280,11 @@ def test_admin_wanfa_route_delete_returns_route_id(): app.dependency_overrides.clear() assert response.status_code == 200 - assert response.json() == {"id": "family-water"} + assert response.json() == { + "code": 200, + "msg": "success", + "data": {"id": "family-water"}, + } assert fake_db.deleted == [route] assert fake_db.committed @@ -295,7 +303,7 @@ def test_admin_wanfa_category_reorder_rejects_incomplete_ids(): app.dependency_overrides.clear() assert response.status_code == 400 - assert response.json()["code"] == "WANFA_CATEGORY_REORDER_INVALID" + assert response.json()["errorCode"] == "WANFA_CATEGORY_REORDER_INVALID" assert not fake_db.committed @@ -317,6 +325,6 @@ def test_admin_wanfa_route_reorder_reassigns_sort_order(): app.dependency_overrides.clear() assert response.status_code == 200 - assert [item["id"] for item in response.json()["items"]] == ["route-2", "route-1"] + assert [item["id"] for item in response.json()["data"]["items"]] == ["route-2", "route-1"] assert {route.id: route.sortOrder for route in routes} == {"route-2": 0, "route-1": 1} assert fake_db.committed diff --git a/WonderQ-MiniAPP/src/lib/api.ts b/WonderQ-MiniAPP/src/lib/api.ts index 04e201b..8cf5a8e 100644 --- a/WonderQ-MiniAPP/src/lib/api.ts +++ b/WonderQ-MiniAPP/src/lib/api.ts @@ -1,6 +1,7 @@ import type { AuthCustomer, AuthSession, + ApiErrorResponse, PhoneLoginPayload, PublicConciergeResponse, PublicDetail, @@ -29,15 +30,66 @@ type RequestOptions = { headers?: Record; }; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function responseMessage(data: unknown, fallback: string) { if (typeof data === "string") return data || fallback; - if (data && typeof data === "object" && "message" in data) { - const message = (data as { message?: unknown }).message; + if (isRecord(data)) { + const message = data.msg ?? data.message; if (typeof message === "string" && message.trim()) return message; } return fallback; } +export class ApiRequestError extends Error { + readonly statusCode: number; + readonly errorCode?: string; + readonly details?: unknown; + + constructor(message: string, statusCode: number, errorCode?: string, details?: unknown) { + super(message); + this.name = "ApiRequestError"; + this.statusCode = statusCode; + this.errorCode = errorCode; + this.details = details; + } +} + +export class ApiProtocolError extends Error { + constructor(message = "接口响应格式不正确") { + super(message); + this.name = "ApiProtocolError"; + } +} + +export function parseApiResponse(payload: unknown, statusCode: number): T { + if (!isRecord(payload)) { + throw new ApiProtocolError(); + } + + const hasData = Object.prototype.hasOwnProperty.call(payload, "data"); + if (statusCode >= 200 && statusCode < 300) { + if (typeof payload.code !== "number" || payload.code !== statusCode || payload.msg !== "success" || !hasData) { + throw new ApiProtocolError(); + } + return payload.data as T; + } + + if (typeof payload.code !== "number" || payload.code !== statusCode || typeof payload.msg !== "string" || !hasData || payload.data !== null) { + throw new ApiProtocolError(); + } + + const body = payload as ApiErrorResponse; + throw new ApiRequestError( + responseMessage(body, `Request failed: ${statusCode}`), + statusCode, + typeof body.errorCode === "string" ? body.errorCode : undefined, + body.details, + ); +} + export function request(path: string, options: RequestOptions = {}) { return new Promise((resolve, reject) => { uni.request({ @@ -50,15 +102,11 @@ export function request(path: string, options: RequestOptions = {}) { }, success(result) { const statusCode = result.statusCode ?? 0; - if (statusCode >= 200 && statusCode < 300) { - resolve(result.data as T); - return; + try { + resolve(parseApiResponse(result.data, statusCode)); + } catch (error) { + reject(error); } - reject( - new Error( - responseMessage(result.data, `Request failed: ${statusCode}`), - ), - ); }, fail(error) { reject(new Error(error.errMsg || "Request failed")); diff --git a/WonderQ-MiniAPP/src/lib/types.ts b/WonderQ-MiniAPP/src/lib/types.ts index 4c1a158..dac5e48 100644 --- a/WonderQ-MiniAPP/src/lib/types.ts +++ b/WonderQ-MiniAPP/src/lib/types.ts @@ -4,6 +4,22 @@ export const BRAND_FULL_NAME = `${BRAND_NAME},${BRAND_TAGLINE}`; export const SUPPORT_PHONE = "18786174929"; export const AUTH_SESSION_KEY = "miniapp:auth-session"; +export type ApiResponse = { + code: number; + msg: string; + data: T; + errorCode?: string; + details?: unknown; +}; + +export type ApiErrorResponse = { + code: number; + msg: string; + data: null; + errorCode?: string; + details?: unknown; +}; + export type AuthCustomer = { id: string; phoneMasked: string }; export type AuthSession = { token: string; customer: AuthCustomer }; export type PhoneLoginPayload = { code: string }; diff --git a/WonderQ-MiniAPP/tests/api-response.test.ts b/WonderQ-MiniAPP/tests/api-response.test.ts new file mode 100644 index 0000000..7241e38 --- /dev/null +++ b/WonderQ-MiniAPP/tests/api-response.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { ApiProtocolError, ApiRequestError, parseApiResponse } from "@/lib/api"; + +describe("unified API response parser", () => { + it("returns data from a valid success envelope", () => { + expect( + parseApiResponse<{ items: string[] }>( + { code: 200, msg: "success", data: { items: ["one"] } }, + 200, + ), + ).toEqual({ items: ["one"] }); + }); + + it("exposes structured fields from an API error envelope", () => { + try { + parseApiResponse( + { + code: 404, + msg: "详情不存在", + data: null, + errorCode: "DETAIL_NOT_FOUND", + details: { key: "missing" }, + }, + 404, + ); + throw new Error("expected API error"); + } catch (error) { + expect(error).toBeInstanceOf(ApiRequestError); + expect((error as ApiRequestError).message).toBe("详情不存在"); + expect((error as ApiRequestError).errorCode).toBe("DETAIL_NOT_FOUND"); + expect((error as ApiRequestError).details).toEqual({ key: "missing" }); + } + }); + + it("rejects a malformed or mismatched envelope", () => { + expect(() => parseApiResponse({ code: 200, msg: "success" }, 200)).toThrow(ApiProtocolError); + expect(() => parseApiResponse({ code: 201, msg: "success", data: {} }, 200)).toThrow(ApiProtocolError); + expect(() => parseApiResponse({ code: 200, msg: "ok", data: {} }, 200)).toThrow(ApiProtocolError); + }); +}); diff --git a/docs/README.md b/docs/README.md index d553cc9..4227c1f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,38 +6,42 @@ 后端开发: -1. `admin-api-requirements.md` -2. `home-api.md` -3. `wanfa-api.md` -4. `concierge-api.md` -5. `detail-api.md` -6. `team-building-api.md` -7. `public-api.md` - -管理前端开发: - -1. `integration-workflow.md` +1. `api-response-contract.md` 2. `admin-api-requirements.md` 3. `home-api.md` 4. `wanfa-api.md` 5. `concierge-api.md` 6. `detail-api.md` 7. `team-building-api.md` -8. `module-config-api.md` +8. `public-api.md` + +管理前端开发: + +1. `api-response-contract.md` +2. `integration-workflow.md` +3. `admin-api-requirements.md` +4. `home-api.md` +5. `wanfa-api.md` +6. `concierge-api.md` +7. `detail-api.md` +8. `team-building-api.md` +9. `module-config-api.md` MiniAPP 前台开发: -1. `integration-workflow.md` -2. `wanfa-api.md` -3. `detail-api.md` -4. `team-building-api.md` -5. `public-api.md` -6. `development-status.md` +1. `api-response-contract.md` +2. `integration-workflow.md` +3. `wanfa-api.md` +4. `detail-api.md` +5. `team-building-api.md` +6. `public-api.md` +7. `development-status.md` ## 文档清单 | 文档 | 作用 | 主要读者 | | --------------------------- | --------------------------------------------------------- | -------------- | +| `api-response-contract.md` | 三端统一 JSON 响应包裹、错误和客户端解包规则 | 全部 | | `integration-workflow.md` | 三端本地启动、联调顺序、接口变更流程和验证命令 | 全部 | | `development-status.md` | 三端能力对接状态矩阵和优先联调路径 | 全部 | | `decisions.md` | 当前有效技术和文档决策 | 全部 | diff --git a/docs/admin-api-requirements.md b/docs/admin-api-requirements.md index 60c7b29..5949400 100644 --- a/docs/admin-api-requirements.md +++ b/docs/admin-api-requirements.md @@ -10,13 +10,16 @@ 首页体验、团队共创和极境视界内容的字段、图片、排序与商品领域隔离约束见 [home-api.md](./home-api.md)。该文档是本主契约的首页内容补充,适用端为 `WonderQ-Admin` 和 `WonderQ-Admin-UI`。 +所有 Admin JSON 接口遵循 [三端统一 API 响应契约](./api-response-contract.md)。 + ## 通用约定 - API 前缀:`/api/admin`。 - 除登录接口外均需 `Authorization: Bearer `。 - JSON 请求统一使用 camelCase 字段。 - 变更接口写入审计日志后再提交事务。 -- 失败响应统一包含 `message`、`code` 和可选 `details`。 +- 成功业务结果统一放在 `data`;创建成功为 HTTP/code `201`。 +- 失败统一返回数字 `code`、用户可读 `msg`、`data: null`,业务错误码放在可选的 `errorCode`。 ## 接口清单 @@ -74,7 +77,7 @@ type SiteModule = { "email": "admin@example.test", "password": "" } ``` -成功响应包含 `token` 和 `{ id, email, name, role }`。 +成功响应包裹为 `data: { token, user: { id, email, name, role } }`;具体字段结构保持现有登录接口约定。 ## 兼容边界 diff --git a/docs/api-response-contract.md b/docs/api-response-contract.md new file mode 100644 index 0000000..99ccc76 --- /dev/null +++ b/docs/api-response-contract.md @@ -0,0 +1,116 @@ +# 三端统一 API 响应契约 + +本文档是 `WonderQ-Admin`、`WonderQ-Admin-UI`、`WonderQ-MiniAPP` 的统一 JSON 响应规范。适用于 `/health`、`/api/public/**` 和 `/api/admin/**`,新接口必须直接遵守本契约。 + +## 基本结构 + +所有 JSON 响应都必须包含 `code`、`msg` 和 `data`: + +```json +{ + "code": 200, + "msg": "success", + "data": {} +} +``` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `code` | `number` | 与 HTTP 状态码一致;成功通常为 `200`,创建成功为 `201`。 | +| `msg` | `string` | 成功固定为 `success`;失败为用户可读的错误信息。 | +| `data` | `object \| array \| string \| number \| boolean \| null` | 成功时承载原接口业务结果;失败时必须为 `null`。 | +| `errorCode` | `string`,可选 | 稳定的业务错误码,使用大写蛇形命名。 | +| `details` | `unknown`,可选 | 面向客户端的结构化错误详情,不得包含 SQL、堆栈、Token 或环境变量。 | + +`data` 内的业务字段保持各领域文档原有结构不变。也就是说,列表的 `items`、首页的 `experiences`、玩法的 `categories` 等字段都位于响应的 `data` 内,而不是与 `code` 同级。 + +## 成功响应 + +### 查询、更新、排序 + +```json +{ + "code": 200, + "msg": "success", + "data": { + "items": [] + } +} +``` + +### 创建 + +创建接口返回 HTTP `201`,响应中的 `code` 也必须为 `201`: + +```json +{ + "code": 201, + "msg": "success", + "data": { + "id": "example-id" + } +} +``` + +### 删除 + +删除接口仍保留原有业务结果,只放入 `data`: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "id": "example-id" + } +} +``` + +## 失败响应 + +失败响应的 HTTP 状态码和 `code` 必须相同,`data` 必须为 `null`: + +```json +{ + "code": 404, + "msg": "未找到对应内容", + "data": null, + "errorCode": "RESOURCE_NOT_FOUND", + "details": { + "resource": "example" + } +} +``` + +`errorCode` 和 `details` 没有值时可以省略,但不能用空对象替代 `data: null`。常见错误码包括: + +| HTTP/code | 场景 | 推荐 `errorCode` | +| --- | --- | --- | +| `400` | 请求参数、排序列表或字段格式错误 | `VALIDATION_ERROR` 或领域错误码 | +| `401` | 未登录或 Token 无效 | `AUTH_REQUIRED` 或 `AUTH_INVALID` | +| `404` | 资源不存在、停用或未配置 | 领域 `*_NOT_FOUND` | +| `409` | 重复键、删除冲突或并发冲突 | 领域 `*_CONFLICT` | +| `422` | 仍由业务层使用的不可处理实体 | 领域错误码 | +| `500` | 未知服务端异常 | `INTERNAL_SERVER_ERROR` | + +参数校验错误由后端统一转换为 `400 + VALIDATION_ERROR`;如果某个既有业务场景仍返回 `422`,也必须按本契约包装,且 `code` 必须为数字 `422`。 + +## 客户端处理 + +- `WonderQ-Admin` 负责所有路由和全局异常处理,不能把内部异常、SQL、堆栈、Token 或环境配置写入 `msg`、`details` 或响应日志。 +- `WonderQ-Admin-UI` 的公共 `request` 校验包裹结构,成功只返回 `data`;失败使用 `msg` 提示,并保留 `errorCode`、`details`。 +- `WonderQ-MiniAPP` 的公共请求层执行同样校验,页面、Store 和归一化函数继续只接收业务类型,不重复读取 `data`。 +- HTTP 错误是服务端返回了合法错误包;网络错误是请求未获得 HTTP 响应;协议错误是响应缺少必填字段或字段类型不正确,三者应分别进入现有错误、重试和 fallback 流程。 +- 未包裹的旧响应不再兼容,客户端必须将其识别为协议错误。 + +## 联调检查 + +每次新增或修改接口时,至少检查: + +1. 成功查询返回 `200`,创建返回 `201`。 +2. `code` 为数字且等于 HTTP 状态码。 +3. 成功 `msg` 为 `success`,失败 `data` 为 `null`。 +4. 列表、详情、删除和排序的原业务字段只出现在 `data` 内。 +5. `400`、`401`、`404`、`409`、`422`、`500`(适用时)都保持相同包裹结构。 + +相关领域字段和路径以 [public-api.md](./public-api.md)、[admin-api-requirements.md](./admin-api-requirements.md)、[home-api.md](./home-api.md)、[wanfa-api.md](./wanfa-api.md)、[detail-api.md](./detail-api.md)、[concierge-api.md](./concierge-api.md)、[team-building-api.md](./team-building-api.md) 和 [wild-archives-api.md](./wild-archives-api.md) 为准。 diff --git a/docs/concierge-api.md b/docs/concierge-api.md index 099318d..b233635 100644 --- a/docs/concierge-api.md +++ b/docs/concierge-api.md @@ -4,6 +4,8 @@ > > 状态:已实现。本文档约定 `WonderQ-Admin`、`WonderQ-Admin-UI` 的管家管理接口,并记录 MiniAPP 使用的对应 Public API。 +所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 + ## 领域边界 管家管理只维护管家顾问卡片资料: @@ -48,7 +50,7 @@ MiniAPP Public API: - 变更接口返回最新顾问对象;排序接口返回排序后的 `items`。 - ID 由后端生成并作为非空字符串返回。 - 空列表返回 `[]`,不能返回 `null` 或省略字段。 -- 失败响应沿用 Admin API 约定,包含 `message`、`code` 和可选的 `details`。 +- 成功结果放入 `data`;失败返回数字 `code`、`msg`、`data: null`,可选 `errorCode` 和 `details`。 ## 数据类型 @@ -129,23 +131,27 @@ Authorization: Bearer ```json { - "advisors": [ - { - "id": "advisor-001", - "avatar": "https://example.test/assets/advisor-avatar.jpg", - "name": "示例顾问", - "role": "SENIOR TRAVEL ADVISOR", - "details": [ - { "icon": "calendar", "label": "服务经验:8年" }, - { "icon": "navigate", "label": "擅长领域:自然探索" } - ], - "qrImage": "https://example.test/assets/advisor-qr.png", - "isActive": true, - "sortOrder": 0, - "createdAt": "2026-01-01T00:00:00Z", - "updatedAt": "2026-01-01T00:00:00Z" - } - ] + "code": 200, + "msg": "success", + "data": { + "advisors": [ + { + "id": "advisor-001", + "avatar": "https://example.test/assets/advisor-avatar.jpg", + "name": "示例顾问", + "role": "SENIOR TRAVEL ADVISOR", + "details": [ + { "icon": "calendar", "label": "服务经验:8年" }, + { "icon": "navigate", "label": "擅长领域:自然探索" } + ], + "qrImage": "https://example.test/assets/advisor-qr.png", + "isActive": true, + "sortOrder": 0, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ] + } } ``` @@ -173,7 +179,7 @@ Content-Type: application/json } ``` -成功返回 `201` 和新建的 `ConciergeAdvisorRecord`。未传 `sortOrder` 时追加到当前最大顺序之后。 +成功返回 `201` 和 `data` 内新建的 `ConciergeAdvisorRecord`。未传 `sortOrder` 时追加到当前最大顺序之后。 ### 编辑顾问 @@ -194,7 +200,7 @@ Content-Type: application/json } ``` -成功返回更新后的 `ConciergeAdvisorRecord`。不存在的顾问返回 `404 CONCIERGE_ADVISOR_NOT_FOUND`。 +成功返回 `data` 内更新后的 `ConciergeAdvisorRecord`。不存在的顾问返回 `404`,业务码为 `CONCIERGE_ADVISOR_NOT_FOUND`。 ### 删除顾问 @@ -206,7 +212,11 @@ Authorization: Bearer 删除成功返回: ```json -{ "id": "advisor-001" } +{ + "code": 200, + "msg": "success", + "data": { "id": "advisor-001" } +} ``` 删除后应重新规范化剩余顾问的 `sortOrder`,从 `0` 开始连续编号。若业务要求至少保留一名启用顾问,服务端在删除最后一名启用顾问时返回 `409 CONCIERGE_LAST_ACTIVE_ADVISOR`,否则允许删除并由前台处理空状态。 @@ -228,7 +238,11 @@ Content-Type: application/json 成功响应: ```json -{ "items": [] } +{ + "code": 200, + "msg": "success", + "data": { "items": [] } +} ``` 其中 `items` 为更新 `sortOrder` 后的 `ConciergeAdvisorRecord[]`。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400 CONCIERGE_REORDER_INVALID`。 @@ -247,7 +261,7 @@ type PublicConciergeResponse = { }; ``` -顾问列表为空时返回 `{ "advisors": [] }`。MiniAPP 应在请求期间展示 loading,失败时展示错误和重试入口,响应字段缺失时通过归一化函数过滤无效顾问。 +顾问列表为空时返回 `data: { "advisors": [] }`。MiniAPP 应在请求期间展示 loading,失败时展示错误和重试入口,响应字段缺失时通过归一化函数过滤无效顾问。 ## 图片与素材 diff --git a/docs/detail-api.md b/docs/detail-api.md index 34b8520..9c738c0 100644 --- a/docs/detail-api.md +++ b/docs/detail-api.md @@ -22,6 +22,8 @@ `key` 固定使用玩法路线 ID(即 `WanfaRoute.id`,例如 `family-water`),但 `DetailRecord` 不建立数据库外键。详情页通过 `/pages/detail/index?routeId={key}` 定位内容;详情记录删除不会跨领域级联删除路线或其他数据。 +所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 + ## 与 `detailPresentation.ts` 的关系 `detailPresentation.ts` 是前台展示适配器,不是持久化模型: @@ -54,7 +56,7 @@ API 前缀为 `/api/admin`,除登录接口外均需要后台 JWT。 - 创建和编辑返回最新详情对象;排序接口返回排序后的 `items`。 - ID 由后端生成并作为非空字符串返回;`key` 由调用方提供并保持稳定。 - 空列表返回 `[]`,不能返回 `null` 或省略字段。 -- 失败响应沿用 Admin API 约定,包含 `message`、`code` 和可选的 `details`。 +- 成功结果放入 `data`;失败返回数字 `code`、`msg`、`data: null`,可选 `errorCode` 和 `details`。 ## 数据类型 @@ -141,26 +143,30 @@ Authorization: Bearer ```json { - "details": [ - { - "id": "detail-001", - "key": "classic-panorama", - "eyebrow": "玩法推荐", - "duration": "5天4晚", - "title": "经典贵州全景", - "subtitle": "贵州·瀑布、苗寨、古城与山地风光", - "intro": "沿着贵州山地的自然纹理深入探索。", - "highlights": ["核心景观串联", "小团出行,按同行人节奏调整"], - "included": ["行程内用车与接送服务"], - "excluded": ["往返大交通及个人消费"], - "notes": ["贵州多山多雨,请准备防滑鞋和轻便雨具。"], - "gallery": ["https://example.test/assets/detail-01.jpg"], - "isActive": true, - "sortOrder": 0, - "createdAt": "2026-01-01T00:00:00Z", - "updatedAt": "2026-01-01T00:00:00Z" - } - ] + "code": 200, + "msg": "success", + "data": { + "details": [ + { + "id": "detail-001", + "key": "classic-panorama", + "eyebrow": "玩法推荐", + "duration": "5天4晚", + "title": "经典贵州全景", + "subtitle": "贵州·瀑布、苗寨、古城与山地风光", + "intro": "沿着贵州山地的自然纹理深入探索。", + "highlights": ["核心景观串联", "小团出行,按同行人节奏调整"], + "included": ["行程内用车与接送服务"], + "excluded": ["往返大交通及个人消费"], + "notes": ["贵州多山多雨,请准备防滑鞋和轻便雨具。"], + "gallery": ["https://example.test/assets/detail-01.jpg"], + "isActive": true, + "sortOrder": 0, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ] + } } ``` @@ -171,7 +177,7 @@ GET /api/admin/details/{detailId} Authorization: Bearer ``` -成功返回单个 `DetailRecord`。记录不存在返回 `404 DETAIL_NOT_FOUND`。 +成功返回 `data` 内的单个 `DetailRecord`。记录不存在返回 `404`,业务码为 `DETAIL_NOT_FOUND`。 ### 新增详情 @@ -181,7 +187,7 @@ Authorization: Bearer Content-Type: application/json ``` -请求体为 `DetailCreate`。成功返回 `201` 和新建的 `DetailRecord`;未传 `sortOrder` 时追加到当前列表末尾。 +请求体为 `DetailCreate`。成功返回 `201` 和 `data` 内新建的 `DetailRecord`;未传 `sortOrder` 时追加到当前列表末尾。 ### 编辑详情 @@ -191,7 +197,7 @@ Authorization: Bearer Content-Type: application/json ``` -请求体为 `DetailPatch`,只更新提交的字段。修改 `key` 时仍须保证全局唯一。成功返回更新后的 `DetailRecord`;记录不存在返回 `404 DETAIL_NOT_FOUND`,`key` 冲突返回 `409 DETAIL_KEY_EXISTS`。 +请求体为 `DetailPatch`,只更新提交的字段。修改 `key` 时仍须保证全局唯一。成功返回 `data` 内更新后的 `DetailRecord`;记录不存在返回 `404`,业务码为 `DETAIL_NOT_FOUND`,`key` 冲突返回 `409`,业务码为 `DETAIL_KEY_EXISTS`。 ### 删除详情 @@ -203,7 +209,11 @@ Authorization: Bearer 成功返回: ```json -{ "id": "detail-001" } +{ + "code": 200, + "msg": "success", + "data": { "id": "detail-001" } +} ``` 删除后应重新规范化剩余记录的 `sortOrder`,从 `0` 开始连续编号。详情记录没有 Product、订单或线索外键,因此删除不触发跨领域级联操作。 @@ -225,7 +235,11 @@ Content-Type: application/json 成功返回: ```json -{ "items": [] } +{ + "code": 200, + "msg": "success", + "data": { "items": [] } +} ``` 其中 `items` 为更新 `sortOrder` 后的 `DetailRecord[]`。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400 DETAIL_REORDER_INVALID`。 diff --git a/docs/home-api.md b/docs/home-api.md index 82dc952..4460f1f 100644 --- a/docs/home-api.md +++ b/docs/home-api.md @@ -23,6 +23,8 @@ 当前首页通过 `GET /api/public/home` 消费四类内容;三个卡片 mock 数组仍作为接口失败、空响应或字段缺失时的前台 fallback,玩法推荐无本地模拟数据时保持空态。Admin UI 通过本文件列出的 Admin API 维护正式数据。 +本文件所有 JSON 示例的业务对象均位于统一响应的 `data` 字段内,完整包裹格式见 [api-response-contract.md](./api-response-contract.md)。 + ## 接口清单 API 前缀为 `/api/admin`,除登录接口外均需要后台 JWT。 @@ -69,7 +71,7 @@ MiniAPP Public API: - ID 由后端生成并作为非空字符串返回。初始化迁移时应优先保留三个 mock 文件中的稳定 ID。 - 空集合返回 `[]`,不能返回 `null` 或省略字段。 - 所有资源的 `sortOrder` 从 `0` 开始,数值越小越靠前;新增未传排序时追加到末尾。 -- 失败响应沿用 Admin API 主契约,包含 `message`、`code` 和可选的 `details`。 +- 成功结果放入 `data`;失败返回数字 `code`、`msg`、`data: null`,可选 `errorCode` 和 `details`。 ## 数据类型 @@ -227,25 +229,29 @@ Authorization: Bearer 团队共创和极境视界分别使用 `/api/admin/home/team-buildings`、`/api/admin/home/wild-archives`。 -成功响应示例: +成功响应示例(统一响应包裹): ```json { - "items": [ - { - "id": "waterfall-descent", - "badge": "玩过推荐", - "category": "瀑降体验", - "title": "悬崖瀑降", - "englishTitle": "WATERFALL DESCENT", - "image": "https://example.test/assets/waterfall-descent.jpg", - "demandKeyword": "悬崖瀑降", - "isActive": true, - "sortOrder": 0, - "createdAt": "2026-01-01T00:00:00Z", - "updatedAt": "2026-01-01T00:00:00Z" - } - ] + "code": 200, + "msg": "success", + "data": { + "items": [ + { + "id": "waterfall-descent", + "badge": "玩过推荐", + "category": "瀑降体验", + "title": "悬崖瀑降", + "englishTitle": "WATERFALL DESCENT", + "image": "https://example.test/assets/waterfall-descent.jpg", + "demandKeyword": "悬崖瀑降", + "isActive": true, + "sortOrder": 0, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z" + } + ] + } } ``` @@ -253,9 +259,9 @@ Authorization: Bearer ### 新增、编辑与删除 -- `POST /api/admin/home/{resource}`:请求体为对应资源的 `Create` 类型,成功返回 `201` 和新建记录;未传 `sortOrder` 时追加到末尾。 -- `PATCH /api/admin/home/{resource}/{id}`:请求体为对应资源的 `Patch` 类型,只更新提交字段;成功返回更新后的记录,不存在返回 `404`。 -- `DELETE /api/admin/home/{resource}/{id}`:成功返回 `{ "id": "..." }`,删除后重新规范化同一资源剩余记录的 `sortOrder`。 +- `POST /api/admin/home/{resource}`:请求体为对应资源的 `Create` 类型,成功返回 `201` 和 `data` 内的新建记录;未传 `sortOrder` 时追加到末尾。 +- `PATCH /api/admin/home/{resource}/{id}`:请求体为对应资源的 `Patch` 类型,只更新提交字段;成功返回 `data` 内的更新记录,不存在返回 `404`。 +- `DELETE /api/admin/home/{resource}/{id}`:成功返回 `data: { "id": "..." }`,删除后重新规范化同一资源剩余记录的 `sortOrder`。 其中 `{resource}` 只能是 `experiences`、`team-buildings` 或 `wild-archives`;对应路径参数分别为 `experienceId`、`teamBuildingId`、`archiveId`。删除不触发商品、订单、预订或线索级联操作。 @@ -273,7 +279,7 @@ Content-Type: application/json { "itemIds": ["cave-exploration", "waterfall-descent"] } ``` -成功响应为 `{ "items": [] }`,其中 `items` 是更新 `sortOrder` 后的完整记录列表。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400 HOME_REORDER_INVALID`。 +成功响应为 `data: { "items": [] }`,其中 `items` 是更新 `sortOrder` 后的完整记录列表。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400`,业务码为 `HOME_REORDER_INVALID`。 ### MiniAPP Public API diff --git a/docs/integration-workflow.md b/docs/integration-workflow.md index 642d195..c0c5885 100644 --- a/docs/integration-workflow.md +++ b/docs/integration-workflow.md @@ -10,6 +10,8 @@ | 管理前端 | `WonderQ-Admin-UI` | 维护首页结构、目的地、线索和页面模块配置 | `admin-api-requirements.md`、`module-config-api.md` | | 前台 MiniAPP | `WonderQ-MiniAPP` | H5 与微信小程序前台展示、咨询和线索提交 | `public-api.md` | +三端所有 JSON 接口还必须遵守 [api-response-contract.md](./api-response-contract.md):成功业务数据位于 `data`,失败为 `data: null`,`code` 必须等于 HTTP 状态码。 + ## 本地启动顺序 1. 确认 Docker Desktop 已运行,然后启动后端依赖和数据库迁移。 @@ -72,6 +74,7 @@ yarn dev:mp-weixin - `GET /health` 返回服务健康状态。 - `GET /api/public/site-config` 返回前台所需数组字段。 - `POST /api/admin/auth/login` 能返回 token 和 user。 +- 使用客户端或 curl 检查响应顶层包含数字 `code`、字符串 `msg` 和 `data`;不能继续接受旧的未包裹响应。 管理端联调重点: @@ -84,6 +87,7 @@ MiniAPP 联调重点: - `site-config` 失败时仍能回退本地内容。 - 首页模块按 Public API 字段渲染,不依赖后台未发布或未启用数据。 - 线索提交调用 `POST /api/public/leads`,失败时显示可理解错误。 +- 首页、玩法、路线详情、管家、团队共创和客片案例请求都由公共 API 层解包 `data`,页面不重复解包。 ## 路线详情三端联调 @@ -101,6 +105,7 @@ MiniAPP 联调重点: ## 接口变更流程 1. 先更新契约文档。 + - 响应包裹、错误结构或状态码变化:更新 `api-response-contract.md`。 - Public API 变更:更新 `public-api.md`。 - Admin API 变更:更新 `admin-api-requirements.md`。 2. 后端实现或调整接口,并补充对应验证。 diff --git a/docs/module-config-api.md b/docs/module-config-api.md new file mode 100644 index 0000000..fd73b43 --- /dev/null +++ b/docs/module-config-api.md @@ -0,0 +1,58 @@ +# 页面模块配置 API 契约 + +本文档补充 `WonderQ-Admin` 和 `WonderQ-Admin-UI` 对站点页面模块的维护约定。接口路径保持现有实现不变,所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md)。 + +## 接口清单 + +除登录接口外,所有接口需要 `Authorization: Bearer `。 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| `GET` | `/api/admin/site-config` | 获取全部模块和停用记录 | +| `POST` | `/api/admin/site-config/{module}` | 新增模块项,成功 `201` | +| `PATCH` | `/api/admin/site-config/{module}/{id}` | 更新模块项 | +| `DELETE` | `/api/admin/site-config/{module}/{id}` | 删除模块项 | +| `PATCH` | `/api/admin/site-config/{module}/reorder` | 按完整 ID 列表排序 | + +允许的 `module`:`heroSlides`、`destinationHero`、`demandHero`、`demandFeatureCards`、`demandForm`、`vehicleOptions`。 + +## 响应约定 + +列表、详情、删除和排序的业务字段放在 `data` 内: + +```json +{ + "code": 200, + "msg": "success", + "data": { + "heroSlides": [], + "destinationHero": [], + "demandHero": [], + "demandFeatureCards": [], + "demandForm": [], + "vehicleOptions": [] + } +} +``` + +创建接口返回: + +```json +{ + "code": 201, + "msg": "success", + "data": { + "id": "module-item-001" + } +} +``` + +排序请求必须提交当前模块的完整 `itemIds`,不能重复;成功返回 `data: { "items": [] }`。删除成功返回 `data: { "id": "..." }`。参数错误、资源不存在和服务异常分别使用统一契约的 `400`、`404` 和 `500` 响应。 + +## 字段边界 + +- `GET` 返回启用和停用的完整记录,前端负责显示状态。 +- `sortOrder` 为从 `0` 开始的非负整数,后端负责重新规范化。 +- 图片字段保存最终 HTTP(S) URL,不接受 base64。 +- `demandForm` 为单例模块,不执行无意义的排序。 +- 站点模块只负责站点配置;首页内容、玩法、详情、管家和客片案例使用各自领域文档。 diff --git a/docs/public-api.md b/docs/public-api.md index 87be1ad..5a784ca 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -6,6 +6,7 @@ - API 前缀:`/api/public`。 - 响应使用 JSON;时间使用 ISO 8601 字符串。 +- 所有 `/health` 和 `/api/public/**` JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 - H5 本地开发通过 `/api` 代理访问后端。 - 内容接口失败时,MiniAPP 使用 `src/content.ts` 的本地兜底内容。 @@ -137,7 +138,7 @@ type PublicConciergeResponse = { } ``` -无可用顾问时返回 `{ "advisors": [] }`。MiniAPP 应处理 loading、错误、重试和空态,不能依赖固定顾问姓名或本地模拟数组。 +无可用顾问时返回 `data: { "advisors": [] }`。MiniAPP 应处理 loading、错误、重试和空态,不能依赖固定顾问姓名或本地模拟数组。 ## 首页内容 @@ -265,8 +266,12 @@ type DemandForm = { ```json { - "token": "", - "customer": { "id": "customer-id", "phoneMasked": "138****0000" } + "code": 200, + "msg": "success", + "data": { + "token": "", + "customer": { "id": "customer-id", "phoneMasked": "138****0000" } + } } ``` @@ -277,5 +282,9 @@ type DemandForm = { 成功响应: ```json -{ "id": "customer-id", "phoneMasked": "138****0000" } +{ + "code": 200, + "msg": "success", + "data": { "id": "customer-id", "phoneMasked": "138****0000" } +} ``` diff --git a/docs/team-building-api.md b/docs/team-building-api.md index 8698454..c5a5249 100644 --- a/docs/team-building-api.md +++ b/docs/team-building-api.md @@ -3,6 +3,8 @@ > 适用端:`WonderQ-Admin`、`WonderQ-Admin-UI`、`WonderQ-MiniAPP`。 > 目标:复用 `HomeTeamBuilding` 表,维护首页团队共创卡片和沉浸式详情页内容。 +所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 + ## 领域边界 - 团队共创卡片和详情共用一条 `HomeTeamBuilding` 记录。 @@ -121,6 +123,8 @@ type PublicHomeTeamBuildingDetail = PublicHomeTeamBuildingSummary & { }; ``` +上面的 `PublicHomeTeamBuildingDetail` 是统一响应 `data` 内的业务对象,不是完整 HTTP 响应包。失败响应使用 `code`、`msg`、`data: null`,可选 `errorCode` 和 `details`。 + 错误响应: | 状态码 | 场景 | diff --git a/docs/wanfa-api.md b/docs/wanfa-api.md index a526c22..1297fed 100644 --- a/docs/wanfa-api.md +++ b/docs/wanfa-api.md @@ -6,6 +6,8 @@ 当前实现:`WonderQ-Admin` 通过迁移 `0015_wanfa` 创建 `WanfaCategory`、`WanfaRoute` 表并导入稳定初始 ID;`WonderQ-Admin-UI` 已接入分类和路线的查询、新增、编辑、删除及排序操作。 +所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 + 路线接口只维护分类和路线摘要字段。路线详情不写入 `WanfaRoute`,由独立 `DetailRecord` 通过 `docs/detail-api.md` 管理,详情记录的 `key` 等于路线 ID。首页玩法推荐和玩法页路线点击后统一跳转 `/pages/detail/index?routeId={route.id}`;无关联路线时才回退到需求页。 ## 领域边界 @@ -51,7 +53,7 @@ API 前缀为 `/api/admin`,除登录接口外均需要后台 JWT。 - ID 由后端生成并作为非空字符串返回。初始化迁移时应优先保留 `playData.ts` 中已有的稳定 ID。 - 分类和路线的数组顺序就是管理端和前台的展示顺序,接口不要求前端依赖 `sortOrder` 字段。 - 空集合返回 `[]`,不能返回 `null` 或省略字段。 -- 失败响应沿用 Admin API 约定,包含 `message`、`code` 和可选的 `details`。 +- 成功结果放入 `data`;失败返回数字 `code`、`msg`、`data: null`,可选 `errorCode` 和 `details`。 - 被首页玩法推荐关联的分类不能直接删除;需先调用首页内容域的移除关联接口,否则返回 `409 WANFA_CATEGORY_RECOMMENDED`。 ## 数据类型 @@ -130,22 +132,26 @@ Authorization: Bearer ```json { - "categories": [ - { - "id": "family-route", - "label": "亲子路线", - "routes": [ - { - "id": "family-water", - "title": "亲子玩水", - "subtitle": "贵州·轻松节奏与自然课堂", - "image": "https://example.test/assets/family-water.jpg", - "routeCount": 4, - "demandKeyword": "亲子玩水" - } - ] - } - ] + "code": 200, + "msg": "success", + "data": { + "categories": [ + { + "id": "family-route", + "label": "亲子路线", + "routes": [ + { + "id": "family-water", + "title": "亲子玩水", + "subtitle": "贵州·轻松节奏与自然课堂", + "image": "https://example.test/assets/family-water.jpg", + "routeCount": 4, + "demandKeyword": "亲子玩水" + } + ] + } + ] + } } ``` @@ -163,7 +169,7 @@ Content-Type: application/json { "label": "亲子路线" } ``` -成功返回 `201` 和新分类对象,初始 `routes` 为 `[]`,并追加到分类列表末尾。 +成功返回 `201` 和 `data` 内的新分类对象,初始 `routes` 为 `[]`,并追加到分类列表末尾。 ### 编辑分类 @@ -179,7 +185,7 @@ Content-Type: application/json { "label": "家庭路线" } ``` -成功返回更新后的 `WanfaCategory`。不存在的分类返回 `404 WANFA_CATEGORY_NOT_FOUND`。 +成功返回 `data` 内的更新后 `WanfaCategory`。不存在的分类返回 `404`,业务码为 `WANFA_CATEGORY_NOT_FOUND`。 ### 删除分类 @@ -188,10 +194,14 @@ DELETE /api/admin/wanfa/categories/{categoryId} Authorization: Bearer ``` -为避免误删路线,分类仍包含路线时不得级联删除,应返回 `409 WANFA_CATEGORY_NOT_EMPTY`。删除前由 Admin UI 提示先移除分类内路线。删除成功返回: +为避免误删路线,分类仍包含路线时不得级联删除,应返回 `409`,业务码为 `WANFA_CATEGORY_NOT_EMPTY`。删除前由 Admin UI 提示先移除分类内路线。删除成功返回: ```json -{ "id": "family-route" } +{ + "code": 200, + "msg": "success", + "data": { "id": "family-route" } +} ``` ### 分类排序 @@ -211,10 +221,14 @@ Content-Type: application/json 成功返回: ```json -{ "items": [] } +{ + "code": 200, + "msg": "success", + "data": { "items": [] } +} ``` -其中 `items` 为排序后的 `WanfaCategory[]`。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400 WANFA_CATEGORY_REORDER_INVALID`。 +其中 `items` 为排序后的 `WanfaCategory[]`。缺少 ID、包含未知 ID 或出现重复 ID 时返回 `400`,业务码为 `WANFA_CATEGORY_REORDER_INVALID`。 ### 新增路线 @@ -236,7 +250,7 @@ Content-Type: application/json } ``` -成功返回 `201` 和新建的 `WanfaRoute`,并追加到对应分类路线末尾。分类不存在返回 `404 WANFA_CATEGORY_NOT_FOUND`。 +成功返回 `201` 和 `data` 内新建的 `WanfaRoute`,并追加到对应分类路线末尾。分类不存在返回 `404`,业务码为 `WANFA_CATEGORY_NOT_FOUND`。 ### 编辑路线 @@ -246,7 +260,7 @@ Authorization: Bearer Content-Type: application/json ``` -请求体为 `WanfaRoutePatch`,只更新提交的字段。成功返回更新后的 `WanfaRoute`;分类或路线不存在时分别返回 `404 WANFA_CATEGORY_NOT_FOUND` 或 `404 WANFA_ROUTE_NOT_FOUND`。 +请求体为 `WanfaRoutePatch`,只更新提交的字段。成功返回 `data` 内更新后的 `WanfaRoute`;分类或路线不存在时分别返回 `404`,业务码为 `WANFA_CATEGORY_NOT_FOUND` 或 `WANFA_ROUTE_NOT_FOUND`。 ### 删除路线 @@ -255,7 +269,7 @@ DELETE /api/admin/wanfa/categories/{categoryId}/routes/{routeId} Authorization: Bearer ``` -成功返回 `{ "id": "..." }`。删除后,分类内路线保持原有相对顺序。 +成功返回 `data: { "id": "..." }`。删除后,分类内路线保持原有相对顺序。 ### 分类内路线排序 @@ -274,10 +288,14 @@ Content-Type: application/json 成功返回排序后的路线: ```json -{ "items": [] } +{ + "code": 200, + "msg": "success", + "data": { "items": [] } +} ``` -分类不存在返回 `404 WANFA_CATEGORY_NOT_FOUND`;路线 ID 不完整、重复或不属于该分类时返回 `400 WANFA_ROUTE_REORDER_INVALID`。 +分类不存在返回 `404`,业务码为 `WANFA_CATEGORY_NOT_FOUND`;路线 ID 不完整、重复或不属于该分类时返回 `400`,业务码为 `WANFA_ROUTE_REORDER_INVALID`。 ## 当前本地数据映射 diff --git a/docs/wild-archives-api.md b/docs/wild-archives-api.md index 36393d3..e019aa5 100644 --- a/docs/wild-archives-api.md +++ b/docs/wild-archives-api.md @@ -4,6 +4,8 @@ > > 目标:维护“极境视界”首页卡片,并提供客片案例更多列表和案例详情页面。 +所有 JSON 响应遵循 [三端统一 API 响应契约](./api-response-contract.md),成功业务对象位于 `data`,失败时 `data` 为 `null`。 + ## 领域边界 客片案例是首页内容领域的一类展示内容,不属于商品、Product、ProductImage、订单或预订领域。 @@ -95,15 +97,19 @@ GET /api/public/home/wild-archives ```json { - "items": [ - { - "id": "shilong-cave", - "title": "石龙洞——客片案例", - "image": "https://example.test/cover.jpg", - "demandKeyword": "地心探险", - "photoCount": 5 - } - ] + "code": 200, + "msg": "success", + "data": { + "items": [ + { + "id": "shilong-cave", + "title": "石龙洞——客片案例", + "image": "https://example.test/cover.jpg", + "demandKeyword": "地心探险", + "photoCount": 5 + } + ] + } } ``` @@ -117,15 +123,19 @@ GET /api/public/home/wild-archives/{archiveId} ```json { - "id": "shilong-cave", - "title": "石龙洞——客片案例", - "image": "https://example.test/cover.jpg", - "demandKeyword": "地心探险", - "photoCount": 5, - "images": [ - "https://example.test/cover.jpg", - "https://example.test/cave-1.jpg" - ] + "code": 200, + "msg": "success", + "data": { + "id": "shilong-cave", + "title": "石龙洞——客片案例", + "image": "https://example.test/cover.jpg", + "demandKeyword": "地心探险", + "photoCount": 5, + "images": [ + "https://example.test/cover.jpg", + "https://example.test/cave-1.jpg" + ] + } } ```