196 lines
7.1 KiB
Python
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 "***"
|