Files
2026-05-29 13:27:41 +08:00

196 lines
7.1 KiB
Python

"""心愿单同步 endpoint。
两个 endpoint:
- POST /api/v1/wish/list 拉取某 phone 的全部心愿单(含 soft-deleted)
- POST /api/v1/wish/upsert 批量推送本地修改(新增 / 更新 / 软删)
同步模型:
- phone 作为账号主键(占坑期)
- 每条 wish 有 updated_at,冲突解决用 last-write-wins
- 软删通过 deleted_at != NULL,避免 A 设备删了 B 设备再 push 上来 "复活"
- 客户端用 client_temp_id 关联自家本地行,服务端在响应里回带 server_id
不做的:
- 不做 since 增量,占坑期心愿单条目数估计 < 100,全量同步成本低且简单
- 不做 user_id 单独主键,phone 当主键(后续升级 user_id 时迁移 1 张表)
"""
from __future__ import annotations
import logging
import time
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app import db
logger = logging.getLogger("shagua.wish")
router = APIRouter(prefix="/api/v1/wish")
class WishItem(BaseModel):
"""跟客户端 sync 用的精简 schema。
matchedClusterId / matchCount / 等本地命中状态不同步(跨设备无意义)。"""
server_id: Optional[int] = None # 新增本地条目时为 null
client_temp_id: Optional[str] = None # 客户端用于关联响应里的 server_id 回填
title: str
target_price: Optional[float] = None
note: Optional[str] = None
notify_enabled: bool = True
created_at: int # 客户端创建时间(ms)
updated_at: int # 最后修改时间(ms),冲突解决用
deleted_at: Optional[int] = None # 软删,非 NULL 表示已删
class WishListRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=15)
class WishListResponse(BaseModel):
items: list[WishItem]
class WishUpsertRequest(BaseModel):
phone: str = Field(..., min_length=11, max_length=15)
items: list[WishItem]
class WishUpsertItemAck(BaseModel):
"""每条上传 item 的 server-side 处理结果。
client_temp_id 透传,用于客户端按它找到对应本地行回填 server_id。"""
client_temp_id: Optional[str]
server_id: int
updated_at: int # 服务端落库后的 updated_at(可能因冲突解决用了 client 提供的也可能用了 server 已有的)
accepted: bool # false=服务端有更新的版本,拒绝了本次更新(客户端应保留服务端版本)
class WishUpsertResponse(BaseModel):
items: list[WishUpsertItemAck]
@router.post("/list", response_model=WishListResponse)
def wish_list(req: WishListRequest) -> WishListResponse:
"""拉取该 phone 的所有 wish(含软删),客户端按 deleted_at 自行过滤显示。"""
with db.get_conn() as conn:
rows = conn.execute(
"""
SELECT id, title, target_price, note, notify_enabled,
created_at, updated_at, deleted_at
FROM wish_item
WHERE phone = ?
ORDER BY updated_at DESC
""",
(req.phone,),
).fetchall()
items = [
WishItem(
server_id=r["id"],
client_temp_id=None,
title=r["title"],
target_price=r["target_price"],
note=r["note"],
notify_enabled=bool(r["notify_enabled"]),
created_at=r["created_at"],
updated_at=r["updated_at"],
deleted_at=r["deleted_at"],
)
for r in rows
]
logger.info("wish_list phone=%s count=%d", _mask(req.phone), len(items))
return WishListResponse(items=items)
@router.post("/upsert", response_model=WishUpsertResponse)
def wish_upsert(req: WishUpsertRequest) -> WishUpsertResponse:
"""批量 upsert。每条:
- server_id 为 null → INSERT,返回新 id
- server_id 不为 null → 检查 phone 归属 + 比较 updated_at
- 客户端 updated_at > 服务端 → UPDATE,accepted=true
- 否则 → 拒绝,返回服务端最新数据(accepted=false)
"""
acks: list[WishUpsertItemAck] = []
with db.get_conn() as conn:
for it in req.items:
if it.server_id is None:
cur = conn.execute(
"""
INSERT INTO wish_item
(phone, title, target_price, note, notify_enabled,
created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
req.phone, it.title, it.target_price, it.note,
1 if it.notify_enabled else 0,
it.created_at, it.updated_at, it.deleted_at,
),
)
acks.append(WishUpsertItemAck(
client_temp_id=it.client_temp_id,
server_id=int(cur.lastrowid),
updated_at=it.updated_at,
accepted=True,
))
continue
# server_id 非空:先查现状,做 last-write-wins
row = conn.execute(
"SELECT phone, updated_at FROM wish_item WHERE id = ?",
(it.server_id,),
).fetchone()
if row is None or row["phone"] != req.phone:
# 没找到 / 不属于该 phone:拒绝(防越权)
logger.warning(
"wish_upsert reject phone=%s server_id=%s (not found or phone mismatch)",
_mask(req.phone), it.server_id,
)
acks.append(WishUpsertItemAck(
client_temp_id=it.client_temp_id,
server_id=it.server_id,
updated_at=row["updated_at"] if row else it.updated_at,
accepted=False,
))
continue
if it.updated_at <= row["updated_at"]:
# 服务端有更新或同样的版本,拒绝;客户端应该按服务端版本同步过去
acks.append(WishUpsertItemAck(
client_temp_id=it.client_temp_id,
server_id=it.server_id,
updated_at=row["updated_at"],
accepted=False,
))
continue
conn.execute(
"""
UPDATE wish_item SET
title = ?, target_price = ?, note = ?, notify_enabled = ?,
updated_at = ?, deleted_at = ?
WHERE id = ? AND phone = ?
""",
(
it.title, it.target_price, it.note,
1 if it.notify_enabled else 0,
it.updated_at, it.deleted_at,
it.server_id, req.phone,
),
)
acks.append(WishUpsertItemAck(
client_temp_id=it.client_temp_id,
server_id=it.server_id,
updated_at=it.updated_at,
accepted=True,
))
logger.info(
"wish_upsert phone=%s in=%d accepted=%d",
_mask(req.phone), len(req.items), sum(1 for a in acks if a.accepted),
)
return WishUpsertResponse(items=acks)
def _mask(phone: str) -> str:
return (phone[:3] + "****" + phone[-2:]) if len(phone) >= 5 else "***"