diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index 3cb508c..5bb0031 100644 --- a/app/api/v1/meituan.py +++ b/app/api/v1/meituan.py @@ -34,7 +34,7 @@ router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"]) @router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)") def list_coupons(req: CouponListRequest) -> CouponListResponse: if not settings.mt_cps_configured: - return CouponListResponse(items=[], has_next=False, search_id=None) + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id) try: raw = query_coupon( @@ -50,14 +50,21 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse: page_size=req.page_size, ) except MeituanCpsError as e: - logger.error("query_coupon failed: %s", e) - raise HTTPException(status_code=502, detail=f"meituan: {e}") from e + # 软降级:不再抛 502(避免前端弹报错 toast),返空 + degraded,前端显示「服务繁忙」。 + logger.warning("[coupons] query_coupon 失败,降级返空: %s", e) + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") - items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])] + items: list[CouponCard] = [] + for it in (raw.get("data") or []): + try: + items.append(CouponCard.from_raw(it)) + except Exception: # noqa: BLE001 + continue return CouponListResponse( items=items, has_next=raw.get("hasNext", False), search_id=raw.get("searchId"), + status="ok" if items else "empty", ) @@ -94,21 +101,26 @@ def _commission_pct(card: CouponCard) -> float: @router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近") def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: - if not settings.mt_cps_configured: - return FeedResponse(items=[], has_next=False, page=req.page) lon, lat = req.longitude, req.latitude tab = (req.tab or "").strip() logger.info("[feed] tab=%s page=%s lon=%.6f lat=%.6f", tab or "(default)", req.page, lon, lat) - def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]: + # rec 是纯离线库查询,不依赖 MT 凭证、不实时打美团;其余 tab(distance / 默认)要实时打美团, + # 未配凭证直接降级返空(degraded),让前端区分「服务暂不可用」而非「暂无」。 + if tab != "rec" and not settings.mt_cps_configured: + return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") + + def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> tuple[list[dict], bool]: + """返回(items, 是否调用失败)。失败标志用于把默认 feed 整页标 degraded。""" try: - return (query_coupon( + data = (query_coupon( longitude=lon, latitude=lat, platform=platform, biz_line=biz_line, list_topic_id=topic, page_size=20, ).get("data") or []) + return data, False except MeituanCpsError: - return [] + return [], True # 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。 # 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。 @@ -116,8 +128,8 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: if tab == "distance": lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000) - def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool]: - """顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页)。""" + def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]: + """顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。""" sid: str | None = None data: list[dict] = [] has_next = False @@ -135,48 +147,57 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: try: r = _call("/cps_open/common/api/v1/query_coupon", body) except MeituanCpsError: - return [], False + return [], False, True # 调用失败(用于标 degraded) data = r.get("data") or [] sid = r.get("searchId") has_next = bool(r.get("hasNext")) and bool(data) if not data or (not has_next and pg < n): - return [], False # 没那么多页了 - return data, has_next + return [], False, False # 没那么多页了(非错误) + return data, has_next, False with ThreadPoolExecutor(max_workers=2) as pool: f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page) f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page) - (wm_data, wm_hn), (dd_data, dd_hn) = f_wm.result(), f_dd.result() + (wm_data, wm_hn, wm_fail), (dd_data, dd_hn, dd_fail) = f_wm.result(), f_dd.result() seen: set[str] = set() cards: list[CouponCard] = [] for it in wm_data + dd_data: - card = CouponCard.from_raw(it) + try: + card = CouponCard.from_raw(it) + except Exception: # noqa: BLE001 + continue if card.product_view_sign and card.product_view_sign not in seen: seen.add(card.product_view_sign) cards.append(card) cards.sort(key=lambda c: c.distance_meters if c.distance_meters is not None else float("inf")) - return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page) + # 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty + status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty") + return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status) # 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。 # 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单" # 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。 if tab == "rec": PAGE = 20 - base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0) - deduped = base.distinct(MeituanCoupon.dedup_key).order_by( - MeituanCoupon.dedup_key, - MeituanCoupon.commission_percent.desc(), - ).subquery() - m = aliased(MeituanCoupon, deduped) - start = (req.page - 1) * PAGE - rows = db.execute( - select(m) - # 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页 - .order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id) - .offset(start) - .limit(PAGE + 1) - ).scalars().all() + try: + base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0) + deduped = base.distinct(MeituanCoupon.dedup_key).order_by( + MeituanCoupon.dedup_key, + MeituanCoupon.commission_percent.desc(), + ).subquery() + m = aliased(MeituanCoupon, deduped) + start = (req.page - 1) * PAGE + rows = db.execute( + select(m) + # 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页 + .order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id) + .offset(start) + .limit(PAGE + 1) + ).scalars().all() + except Exception: # noqa: BLE001 + logger.exception("[feed] rec 库查询失败,降级返空") + return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") has_next = len(rows) > PAGE cards: list[CouponCard] = [] for row in rows[:PAGE]: @@ -190,22 +211,25 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: card.distance_text = None card.distance_meters = None cards.append(card) - return FeedResponse(items=cards, has_next=has_next, page=req.page) + return FeedResponse(items=cards, has_next=has_next, page=req.page, + status="ok" if cards else "empty") # 默认(老客户端不传 tab):沿用逐轮分页的混合 feed,不筛。 page_idx = req.page - 1 if page_idx >= len(_TOPIC_ROUNDS): - return FeedResponse(items=[], has_next=False, page=req.page) + return FeedResponse(items=[], has_next=False, page=req.page, status="empty") wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx] with ThreadPoolExecutor(max_workers=2) as pool: f_wm = pool.submit(_fetch_topic, 1, None, wm_topic) f_dd = pool.submit(_fetch_topic, 2, 1, dd_topic) - waimai, daodian = f_wm.result(), f_dd.result() + (waimai, wm_fail), (daodian, dd_fail) = f_wm.result(), f_dd.result() items = _interleave(waimai, daodian) has_next = page_idx + 1 < len(_TOPIC_ROUNDS) - return FeedResponse(items=items, has_next=has_next, page=req.page) + # 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty + status = "degraded" if (not items and wm_fail and dd_fail) else ("ok" if items else "empty") + return FeedResponse(items=items, has_next=has_next, page=req.page, status=status) @router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)") @@ -234,26 +258,31 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse: def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse: # 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。 # (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。) - # 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) - base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None)) - if req.platform is not None: - base = base.where(MeituanCoupon.platform == req.platform) - deduped = base.distinct(MeituanCoupon.dedup_key).order_by( - MeituanCoupon.dedup_key, - MeituanCoupon.sale_volume_num.desc(), - MeituanCoupon.commission_percent.desc(), - ).subquery() + # 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。 + try: + # 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) + base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None)) + if req.platform is not None: + base = base.where(MeituanCoupon.platform == req.platform) + deduped = base.distinct(MeituanCoupon.dedup_key).order_by( + MeituanCoupon.dedup_key, + MeituanCoupon.sale_volume_num.desc(), + MeituanCoupon.commission_percent.desc(), + ).subquery() - # 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw - m = aliased(MeituanCoupon, deduped) - start = (req.page - 1) * req.page_size - rows = db.execute( - select(m) - # 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项 - .order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id) - .offset(start) - .limit(req.page_size + 1) - ).scalars().all() + # 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw + m = aliased(MeituanCoupon, deduped) + start = (req.page - 1) * req.page_size + rows = db.execute( + select(m) + # 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项 + .order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id) + .offset(start) + .limit(req.page_size + 1) + ).scalars().all() + except Exception: # noqa: BLE001 + logger.exception("[top-sales] 库查询失败,降级返空") + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") has_next = len(rows) > req.page_size cards: list[CouponCard] = [] @@ -264,4 +293,5 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList continue if card.product_view_sign: cards.append(card) - return CouponListResponse(items=cards, has_next=has_next, search_id=None) + return CouponListResponse(items=cards, has_next=has_next, search_id=None, + status="ok" if cards else "empty") diff --git a/app/schemas/meituan.py b/app/schemas/meituan.py index 67cf36c..14ce264 100644 --- a/app/schemas/meituan.py +++ b/app/schemas/meituan.py @@ -1,10 +1,17 @@ """美团 CPS 券列表 / 换链相关 schemas。""" from __future__ import annotations -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, Field +# feed / 列表类接口的结果状态,供前端区分占位文案: +# ok = 正常有数据 +# empty = 后端正常、查询成功但确实没有数据(显示「暂无优惠券」) +# degraded = 上游(美团)或库查询失败,已降级返空(显示「服务繁忙,下拉重试」) +# 老客户端不读该字段,默认 ok,向后兼容。 +FeedStatus = Literal["ok", "empty", "degraded"] + # ───────────────── 券卡片(归一化后给客户端) ───────────────── @@ -127,6 +134,7 @@ class CouponListResponse(BaseModel): items: list[CouponCard] has_next: bool = False search_id: str | None = None + status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级") class FeedRequest(BaseModel): @@ -146,6 +154,7 @@ class FeedResponse(BaseModel): items: list[CouponCard] has_next: bool = False page: int = 1 + status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级") class TopSalesRequest(BaseModel): diff --git a/docs/api/README.md b/docs/api/README.md index 6556d39..e95bab4 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -21,7 +21,8 @@ | 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) | | 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md) | | 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) | -| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) | +| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md)(tab=rec/distance;含 `status` 兜底) | +| 10a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan-top-sales.md)(销量最高,离线库) | | 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) | | **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend) ||| | 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) | diff --git a/docs/api/meituan-coupons.md b/docs/api/meituan-coupons.md index 582708e..c38966b 100644 --- a/docs/api/meituan-coupons.md +++ b/docs/api/meituan-coupons.md @@ -20,11 +20,11 @@ | `page_size` | int | ❌ | 20 | 1–20 | ## 出参 -响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。 +响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。 ## 错误码 -- `502` 美团接口失败(仅在**已配置 `MT_CPS_APP_KEY/APP_SECRET` 但调用失败**时;未配凭证场景见下) +无业务级错误码:未配凭证 / 美团调用失败都返 `200` + 空 `items` + `status=degraded`(**2026-06-10 起不再抛 502**,避免前端弹报错 toast)。 ## 说明 - 当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整 -- **未配置 MT_CPS 凭证时降级**:`settings.mt_cps_configured == false` 时直接返 `{ items: [], has_next: false, search_id: null }`,**不报 502**。设计目的是让新开发机首屏不炸——后端业务层先不依赖 CPS。已配凭证但调美团失败时才走 502 +- **统一软降级(2026-06-10)**:未配 `MT_CPS_APP_KEY/SECRET`、或已配但调美团失败,都返空 + `status=degraded`(此前后者抛 502);成功但无结果 → `status=empty`。前端按 `status` 显示「服务繁忙」/「暂无」,不再弹错误 diff --git a/docs/api/meituan-feed.md b/docs/api/meituan-feed.md index 5c44bd5..9377ab8 100644 --- a/docs/api/meituan-feed.md +++ b/docs/api/meituan-feed.md @@ -1,31 +1,41 @@ -# POST /api/v1/meituan/feed — 首页混合推荐流 +# POST /api/v1/meituan/feed — 首页推荐流(多 tab) -> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) > -> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。 +> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑);离线库见 [database/meituan_coupon](../database/meituan_coupon.md)。 ## 入参 - | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| | `longitude` | float | ✅ | — | 经度 | | `latitude` | float | ✅ | — | 纬度 | | `page` | int | ❌ | 1 | ≥1 | | `page_size` | int | ❌ | 20 | 1–20 | +| `tab` | string | ❌ | `""` | `rec` 智能推荐 / `distance` 距离最近 / 空=默认混合 feed(老客户端兼容) | + +> 销量最高(sales)tab **不在本接口**,走 [`/top-sales`](./meituan-top-sales.md)。 ## 出参 -响应 `200`:`{ items: CouponCard[], has_next: bool, page: int }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。 +响应 `200`:`{ items: CouponCard[], has_next: bool, page: int, status: "ok"|"empty"|"degraded" }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。 + +### `status` 字段(前端据此显示占位) +| 值 | 含义 | 前端建议 | +|---|---|---| +| `ok` | 有数据 | 正常渲染 | +| `empty` | 后端正常、查询成功但确实没数据 | 显示「暂无优惠券」 | +| `degraded` | 上游(美团)或库查询失败,已降级返空 | 显示「服务繁忙,下拉重试」 | + +> 老客户端不读 `status`(字段缺省即兼容),仍可只按 `items` 是否为空展示。 + +## 各 tab 行为 +- **`rec` 智能推荐**:走【离线库 `meituan_coupon`】筛佣金率 ≥ 3%,`DISTINCT ON(dedup_key)` 去重后按销量降序分页。**纯库查询、不打美团、不依赖 MT 凭证**。库为空(prod 刚部署 / ETL 未跑完)→ `empty`;库异常 → `degraded`。**不显示距离**(库里距离相对城市默认点,对用户无意义)。 + - 为何不实时:实测同城热销榜中位佣金 ~0.8%,筛佣金≥3% 后每页剩 0–1 条,既撞 402 又填不满,故从库出;库空时也**不回退实时**(回退同样填不满)。 +- **`distance` 距离最近**:外卖搜「外卖」+ 到店搜「美食」(均 `sortField=6`),两路并行顺序翻页,实时按你坐标算距离、由近及远。两路**都失败**且无结果 → `degraded`;否则有结果 `ok` / 无结果 `empty`。 +- **默认(空 tab)**:逐轮分页的混合 feed(2 外卖 + 1 到店交叉,写死 3 页:爆款 / 今日必推 / 精选+限时),第 4 页返空。两路榜单都失败 → `degraded`。 ## 错误码 -无(任何失败场景都返 200 + 空 items,见"说明"中的可观测性盲区) +无业务级错误码:任何失败场景都返 `200` + 空 `items` + `status=degraded`(不再抛 5xx,避免前端弹报错 toast)。 -## 说明 -后端在 `coupons` 之上封装的"伪推荐"——每页并发拉一次外卖、一次到店(到餐)榜单,按 **2 外卖 + 1 到店** 交叉去重。榜单组合写死 3 页(第1页爆款、第2页今日必推、第3页外卖精选+到店限时),**第 3 页起 `has_next=false`、第 4 页返空**。 - -**两种空结果路径**: -- **未配置 `MT_CPS_APP_KEY/APP_SECRET`**:开头 `settings.mt_cps_configured` 判定后**直接返空**,完全不调美团 -- **已配置但美团调用失败**:`_fetch_topic` 的 `except MeituanCpsError` **静默返空**(不报 502、不打日志) - -⚠️ **可观测性盲区**:两种路径在响应上**无法区分**。若 `items` 为空想区分: -- 看 `settings.mt_cps_configured`(`/health` 接口有暴露此字段)→ 区分"配置缺失" vs "调用失败" -- 改调 `coupons` 接口——它在"已配凭证但调用失败"时会 502 暴露错误细节(未配凭证它也降级返空,跟 feed 一致) +## 兜底说明(1.0 内测) +- rec / sales 是库查询:**prod 刚部署、ETL 首灌未完成**时库为空 → 返 `empty`。最佳实践是**开内测前先手动跑一次 ETL 灌满库**(见 `scripts/pull_meituan_coupons.py` 与 `deploy/meituan-etl.{service,timer}`)。 +- 未配 `MT_CPS_APP_KEY/SECRET` 时:`distance` / 默认 直接 `degraded`;`rec` 仍走库(配置开关不挡纯库查询)。 diff --git a/docs/api/meituan-top-sales.md b/docs/api/meituan-top-sales.md new file mode 100644 index 0000000..3f91f66 --- /dev/null +++ b/docs/api/meituan-top-sales.md @@ -0,0 +1,23 @@ +# POST /api/v1/meituan/top-sales — 销量最高(离线库) + +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> +> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。 + +## 入参 +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `page` | int | ❌ | 1 | ≥1 | +| `page_size` | int | ❌ | 20 | 1–50 | +| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部(全城销量) | + +## 出参 +响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。 + +## 说明 +- 从 `meituan_coupon` 取 `sale_volume_num` 非空的券,`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。 +- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。 +- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。 + +## 错误码 +无业务级错误码:库空 / 异常都返 `200` + 空 `items` + 对应 `status`。