初始化:多比比Android客户端(占坑版)

This commit is contained in:
zzhyyyyy
2026-05-29 13:27:41 +08:00
commit 31429e12e0
198 changed files with 15533 additions and 0 deletions
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
venv/
.env
*.egg-info/
.coverage
htmlcov/
.idea/
.vscode/
dist/
build/
@@ -0,0 +1,230 @@
# duobibi-server
多比比(占坑版)后端 — 比价 / 估价服务。
> 与傻瓜比价后端**同一套代码**,部署到独立域名 `api.duobibi.com`(端口 8766,与旧 8765 并存)。
> 新增两个多比比专属端点:`/arena-quote`(比价擂台)、`/worth-buy`(AI 值不值得买)。
> 占坑期 LLM key 与极光 key 仍硬编码,上线前迁移到环境变量。
## 技术栈
- Python 3.11+
- FastAPI 0.115+ / uvicorn (standard) / pydantic 2.9+
- 智谱 zai-sdk(`glm-5-turbo`,温度 0.0,确定性输出)
- SQLite(WAL 模式,单文件)
- cryptography(极光 RSA 解密手机号)
- pytest + pytest-asyncio + httpx(测试)
## 工程结构
```
duobibi-server/
├── app/
│ ├── main.py # FastAPI 应用入口,所有 router 注册,启动时建表
│ ├── schemas.py # 全部请求/响应 Pydantic DTO
│ ├── db.py # SQLite 连接管理 + WAL + 表初始化
│ ├── llm_client.py # 智谱客户端,DUOBIBI_MOCK_LLM 开关
│ ├── extractor.py # Android 控件树 → LLM → title/price 提取
│ ├── mock_extractor.py # 占坑期预置商品池(确定性哈希选)
│ └── api/
│ ├── parse.py # POST /api/v1/parse Android 无障碍树识别
│ ├── parse_image.py # POST /api/v1/parse-image iOS 截图 OCR
│ ├── parse_text.py # POST /api/v1/parse-text iOS 文本新建
│ ├── parse_ocr.py # POST /api/v1/parse-ocr OCR 文本识别(补齐缺失)
│ ├── quick_quote.py # POST /api/v1/quick-quote 用户主动比价查询
│ ├── arena.py # POST /api/v1/arena-quote 比价擂台(多比比新增)
│ ├── worth_buy.py # POST /api/v1/worth-buy AI 值不值(多比比新增)
│ ├── protect.py # POST /api/v1/track-price 价保哨兵
│ ├── auth.py # POST /api/v1/auth/jverify-login 极光一键登录
│ └── wish.py # POST /api/v1/wish/{list,upsert} 心愿单同步
├── tests/ # test_health / test_arena_worth / test_parse_image
├── deploy/
│ ├── duobibi-mock.service # systemd unit(生产)
│ └── nginx/api.duobibi.com.conf
├── pyproject.toml
├── run-backend.ps1 # Windows 一键启动脚本
└── data.db # SQLite(开发本地,生产在 /opt/duobibi-server/data.db)
```
## 接口
### `GET /health`
```json
{ "status": "ok" }
```
### `POST /api/v1/arena-quote` — 比价擂台(多比比新增)
请求:
```json
{ "title": "iPhone 15 Pro 256GB", "platforms": ["淘宝","京东","拼多多","抖音"] }
```
`platforms` 可省略(默认 4 大电商)。响应:
```json
{
"title": "iPhone 15 Pro 256GB",
"typical_price": 8299.0,
"quotes": [
{"platform": "淘宝", "price": 8099.0, "note": ""},
{"platform": "京东", "price": 8299.0, "note": "自营"},
{"platform": "拼多多", "price": 7888.0, "note": "百亿补贴"},
{"platform": "抖音", "price": 8190.0, "note": ""}
],
"lowest_platform": "拼多多"
}
```
- LLM 缺平台报价时,按 `(title, platform)` 哈希确定性合成,保证 4 条都齐
- LLM 整体失败返回 422 `llm_no_quote`,不抛 5xx
-`DUOBIBI_MOCK_LLM=1` 可离线测试
### `POST /api/v1/worth-buy` — AI 值不值得买(多比比新增)
请求:
```json
{ "title": "iPhone 15 Pro 256GB", "price": 8099.0, "platform": "京东" }
```
响应:
```json
{
"score": 78, "verdict": "buy", "headline": "现在入手划算",
"reasons": ["低于市场常见价约 2%", "距下次大促还有一段时间"],
"typical_price": 8299.0, "best_time": "现在就合适"
}
```
- `score`: 0100,80+ → `buy` / 4079 → `neutral` / <40 → `wait`
- LLM 抽风 / JSON 解析失败也会返回中性结论,不抛 5xx
- Mock 模式按"当前价 / 市场常见价"水位确定性算分
### 沿用傻瓜比价的端点
| 端点 | 方法 | 用途 |
|---|---|---|
| `/api/v1/parse` | POST | Android 无障碍树 → title / price / cluster_id |
| `/api/v1/parse-image` | POST(multipart)| iOS 截图 OCR(图片 ≤ 5MB) |
| `/api/v1/parse-text` | POST | iOS 文本新建 |
| `/api/v1/parse-ocr` | POST | OCR 文本识别(本工程补齐 mock 实现) |
| `/api/v1/quick-quote` | POST | 用户主动比价查询 |
| `/api/v1/track-price` | POST | 价保哨兵(确定性日抖动模拟降价) |
| `/api/v1/auth/jverify-login` | POST | 极光 loginToken → 真手机号(RSA 解密) |
| `/api/v1/wish/list` | POST | 拉取心愿单(含软删) |
| `/api/v1/wish/upsert` | POST | 批量上传心愿单修改(LWW) |
> 修复:旧后端 `main.py` 引用 `app.api.parse_ocr` 但文件缺失会导致启动 `ImportError`;
> 本工程补齐了 `app/api/parse_ocr.py`(mock 实现),服务可正常启动。
## 包名 → 品牌映射
| 包名 | 品牌 |
|---|---|
| com.taobao.taobao | 淘宝 |
| com.jingdong.app.mall | 京东 |
| com.xunmeng.pinduoduo | 拼多多 |
| com.ss.android.ugc.aweme | 抖音 |
| com.sankuai.meituan | 美团 |
| com.sankuai.meituan.takeoutnew | 美团外卖 |
| me.ele | 饿了么 |
非白名单包名返回 `source_app: "未知"`
## 环境变量
| 变量 | 用途 | 默认值 |
|---|---|---|
| `DUOBIBI_DB_PATH` | SQLite 数据库路径 | `/opt/duobibi-server/data.db`(生产)/ `./data.db`(本地) |
| `DUOBIBI_MOCK_LLM` | 设为非空值 → 跳过真实 LLM,返回确定性 mock 数据 | 空(生产用真实 LLM) |
| `JG_PRIVATE_KEY_PATH` | 极光一键登录 RSA 私钥路径 | `/opt/duobibi-server/secrets/jverify_rsa_private.pem` |
| `PYTHONUTF8` | Windows 控制台 UTF-8(`run-backend.ps1` 自动设) | 空 |
**硬编码待迁移**(`app/llm_client.py` / `app/api/auth.py`):智谱 API key、极光 App key、极光 Master Secret。
## 数据存储
SQLite 单文件 + WAL,启动时 `IF NOT EXISTS` 建表,无 migration 工具。
**`user`** — 占坑期登录用户
| 字段 | 类型 | 说明 |
|---|---|---|
| `phone` | TEXT PK | 用户手机号(占坑期主键) |
| `created_at` | INTEGER | 创建时间(ms epoch) |
| `last_seen_at` | INTEGER | 最近一次登录时间(ms epoch) |
**`wish_item`** — 心愿单(跨设备同步)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK AUTO | 自增 |
| `phone` | TEXT FK | 用户手机号 |
| `title` / `target_price` / `note` / `notify_enabled` | — | 业务字段 |
| `created_at` / `updated_at` | INTEGER | ms epoch |
| `deleted_at` | INTEGER | NULL = 未删,非空 = 软删,防跨设备"复活" |
索引:`idx_wish_phone_updated` on `(phone, updated_at DESC)`
## 本地启动
**环境要求**
- Python 3.11+
- 默认 mock 模式无需任何 API key 即可启动(不调真实 LLM,返回确定性占位数据)
**方式一:PowerShell 脚本(Windows 推荐)**
```powershell
.\run-backend.ps1 # mock 模式,监听 127.0.0.1:8766
.\run-backend.ps1 -Lan # 监听 0.0.0.0,供真机/局域网访问
.\run-backend.ps1 -Real # 连真实智谱 LLM(需先配好 key)
.\run-backend.ps1 -Reload # 热重载,代码改动自动重启
.\run-backend.ps1 -Port 8888 # 改端口
```
**方式二:手动 pip + uvicorn**
```bash
cd duobibi-server
pip install -e ".[dev]"
uvicorn app.main:app --reload --port 8766
```
首次启动会自动在当前目录创建 `data.db`(SQLite,WAL 模式),无需手动建表。
**验证启动**
```bash
curl http://localhost:8766/health
# {"status":"ok"}
```
**测试**
```bash
pytest -q
```
**烟测核心端点**
```bash
curl -X POST http://localhost:8766/api/v1/arena-quote \
-H 'Content-Type: application/json' \
-d '{"title":"iPhone 15 Pro 256GB"}'
curl -X POST http://localhost:8766/api/v1/worth-buy \
-H 'Content-Type: application/json' \
-d '{"title":"iPhone 15 Pro 256GB","price":8099}'
```
## 部署(生产)
- 域名: `api.duobibi.com` (HTTPS)
- 进程: `uvicorn app.main:app --host 127.0.0.1 --port 8766 --workers 1`,systemd 守护
(`deploy/duobibi-mock.service`)
- 反代: nginx → 127.0.0.1:8766(`deploy/nginx/api.duobibi.com.conf`,`client_max_body_size 6MB`)
- SQLite 单文件(`DUOBIBI_DB_PATH` env 可覆盖,默认 `/opt/duobibi-server/data.db`)
- 极光 RSA 私钥不入 git,部署时单独 scp 到 `/opt/duobibi-server/secrets/jverify_rsa_private.pem`
## 待办(占坑期遗留)
- `app/llm_client.py` 的智谱 API key 仍硬编码,上线前迁移到环境变量并替换为多比比自己的 key
- `app/api/auth.py` 的极光 App key / Master Secret 同样硬编码,需迁移到 env
- 接口无鉴权 / 无限流,后续加 `X-Client-Token` 头校验 + IP 限流
- 无 Dockerfile / docker-compose,目前只支持 systemd 部署
@@ -0,0 +1,191 @@
"""POST /api/v1/arena-quote —— 多比比"比价擂台"接口。
用户输入一个商品名,后端用 LLM 估出该商品在主流电商各平台的「到手价」
以及「市场常见价」(中位)。客户端把估价铺成"多平台对比表",再用本机
真实记录覆盖对应平台,最低价高亮。
application/json:
{"title": "iPhone 15 Pro 256GB", "platforms": ["淘宝","京东",...](可选)}
响应:
{"title": "...(归一化)", "typical_price": 8299.0,
"quotes": [{"platform":"淘宝","price":8099.0,"note":"..."}, ...],
"lowest_platform": "拼多多"}
兜底:LLM 给了 typical 但缺平台报价 → 围绕 typical 确定性合成,保证表格每行有值;
完全估不出价格 → 422 llm_no_quote。
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from typing import Optional
from fastapi import APIRouter, HTTPException
from app.llm_client import MOCK_LLM, chat
from app.schemas import ArenaPlatformQuote, ArenaQuoteRequest, ArenaQuoteResponse
logger = logging.getLogger("app.arena")
router = APIRouter(prefix="/api/v1")
# 比价擂台默认对比 4 大主流电商(同一实体商品可跨平台买)。
# 美团 / 饿了么是本地生活 O2O,不进默认对比池,但客户端可显式传入 platforms 覆盖。
DEFAULT_PLATFORMS = ["淘宝", "京东", "拼多多", "抖音"]
SYSTEM_PROMPT = """你是一个电商多平台价格估算助手。
# 任务
用户给你一个商品标题和一组电商平台。请估算该商品在每个平台的「日常到手价」
(实付价,不含双 11 / 618 等大促),以及该商品的「市场常见价」(各平台中位值)。
# 估算原则
- 同一商品在不同平台价格通常有差异:拼多多 / 百亿补贴常偏低,京东自营偏高且稳,
淘宝居中,抖音随直播波动。请给出**有合理区分度**的价格,不要每个平台都一样。
- 不熟悉的商品按品类 / 品牌 / 规格常识估算。完全陌生时给出符合常识的合理估值。
- 所有价格为正数(元),可带小数。
# 输出格式
严格 JSON,无 markdown,无解释:
{"title": "归一化后的商品标题", "typical_price": 8299.0,
"quotes": [{"platform": "淘宝", "price": 8099.0, "note": "一句话(可空)"},
{"platform": "京东", "price": 8299.0, "note": ""}]}
字段:
- title: 整理后的标题(纠错 / 统一规格写法,保留原意)
- typical_price: 市场常见价(正数,必填)
- quotes: 对**给定的每个平台**各给一条;price 正数必填,note 可空
"""
def _extract_json(s: str) -> Optional[dict]:
s = s.strip()
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
try:
data = json.loads(s)
except json.JSONDecodeError:
m = re.search(r"\{.*\}", s, re.DOTALL)
if not m:
return None
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def _num(v) -> Optional[float]:
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
try:
return float(v.strip())
except ValueError:
return None
return None
def _synth_price(title: str, platform: str, typical: float) -> float:
"""给 LLM 没给报价的平台合成一个确定性估价(围绕 typical 的 0.93~1.06 倍)。"""
seed = int(hashlib.sha256(f"{title}|{platform}".encode("utf-8")).hexdigest()[:6], 16)
factor = 0.93 + (seed % 14) / 100.0
return round(typical * factor, 2)
def _mock_raw(title: str) -> str:
"""占坑期 mock(DUOBIBI_MOCK_LLM):基于标题确定性造一个市场常见价,
各平台报价交给下游 _synth_price 围绕 typical 合成。返回 LLM 同格式 JSON。"""
seed = int(hashlib.sha256(title.encode("utf-8")).hexdigest()[:8], 16)
typical = round(49 + seed % 9950 + (seed % 100) / 100.0, 2)
return json.dumps({"title": title, "typical_price": typical}, ensure_ascii=False)
@router.post("/arena-quote", response_model=ArenaQuoteResponse)
def arena_quote(req: ArenaQuoteRequest) -> ArenaQuoteResponse:
title_in = req.title.strip()
if not title_in:
raise HTTPException(status_code=422, detail="empty_title")
platforms = [p.strip() for p in (req.platforms or DEFAULT_PLATFORMS) if p and p.strip()]
if not platforms:
platforms = list(DEFAULT_PLATFORMS)
user_msg = f"商品标题: {title_in}\n平台: {', '.join(platforms)}"
if MOCK_LLM:
raw = _mock_raw(title_in)
else:
try:
raw = chat(
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
)
logger.debug("arena-quote LLM raw: %s", raw[:400])
except Exception as e:
# LLM 不可用(限流/网络/超时等):降级为空响应,下方走 422 llm_no_quote 而非 500。
logger.warning("arena-quote LLM call failed: %s", e)
raw = ""
data = _extract_json(raw) or {}
title_out = data.get("title")
title_out = title_out.strip() if isinstance(title_out, str) and title_out.strip() else title_in
typical = _num(data.get("typical_price"))
# 解析 quotes,仅保留请求的平台,price 必须 > 0
by_platform: dict[str, ArenaPlatformQuote] = {}
raw_quotes = data.get("quotes")
if isinstance(raw_quotes, list):
for q in raw_quotes:
if not isinstance(q, dict):
continue
plat = q.get("platform")
price = _num(q.get("price"))
if not isinstance(plat, str) or plat.strip() not in platforms:
continue
if price is None or price <= 0:
continue
note = q.get("note")
by_platform[plat.strip()] = ArenaPlatformQuote(
platform=plat.strip(),
price=round(price, 2),
note=note.strip() if isinstance(note, str) else "",
)
# typical 兜底:用已解析报价的中位值
if (typical is None or typical <= 0) and by_platform:
prices = sorted(p.price for p in by_platform.values())
typical = prices[len(prices) // 2]
if typical is None or typical <= 0:
logger.info("arena-quote no usable price for %r", title_in)
raise HTTPException(status_code=422, detail="llm_no_quote")
# 给缺失平台合成估价,保证表格每行都有值
for plat in platforms:
if plat not in by_platform:
by_platform[plat] = ArenaPlatformQuote(
platform=plat,
price=_synth_price(title_out, plat, typical),
note="估算",
)
quotes = [by_platform[p] for p in platforms]
lowest = min(quotes, key=lambda q: q.price).platform if quotes else None
logger.info(
"arena-quote title=%r typical=%.2f platforms=%d lowest=%s",
title_out, typical, len(quotes), lowest,
)
return ArenaQuoteResponse(
title=title_out,
typical_price=round(typical, 2),
quotes=quotes,
lowest_platform=lowest,
)
@@ -0,0 +1,187 @@
"""极光一键登录验证 endpoint。
链路:
Android 客户端 SDK loginAuth → 拿到 loginToken
→ POST 本 endpoint
→ 调极光 REST /v1/web/loginTokenVerify(Basic Auth = AppKey:MasterSecret)
→ 极光返回 RSA 公钥加密的手机号(base64)
→ 用部署在服务器上的对应 RSA 私钥解密
→ 返回明文手机号给客户端
验通路阶段只返回手机号,不入库、不签 session、不做 CPS 归因。
跑通后再补持久化和绑定逻辑。
RSA 私钥位置:`$JG_PRIVATE_KEY_PATH` 环境变量,默认 /opt/duobibi-server/secrets/jverify_rsa_private.pem。
私钥不入 git,部署时单独 scp 到服务器。
"""
from __future__ import annotations
import base64
import json
import logging
import os
import time
import urllib.error
import urllib.request
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app import db
logger = logging.getLogger("shagua.auth")
router = APIRouter(prefix="/api/v1/auth")
# 占坑期硬编码,跟 llm_client.py 的 key 风格一致(后续统一挪到 env)
JG_APP_KEY = "966b451a8d9cfe12d173ea9d"
JG_MASTER_SECRET = "dae6d856c7556ac1b8f2deda"
JG_VERIFY_ENDPOINT = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
JG_REQUEST_TIMEOUT_SEC = 15
JG_PRIVATE_KEY_PATH = os.environ.get(
"JG_PRIVATE_KEY_PATH",
"/opt/duobibi-server/secrets/jverify_rsa_private.pem",
)
_private_key_cache = None
def _get_private_key():
"""懒加载 RSA 私钥并缓存。首次调用时如果文件不存在会抛 FileNotFoundError,
让请求 500 + 日志,而不是 import 时炸进程。"""
global _private_key_cache
if _private_key_cache is None:
with open(JG_PRIVATE_KEY_PATH, "rb") as f:
_private_key_cache = serialization.load_pem_private_key(f.read(), password=None)
return _private_key_cache
class JverifyLoginRequest(BaseModel):
login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken")
operator: str = Field(..., description="CM/CU/CT,客户端透传过来用于日志")
class JverifyLoginResponse(BaseModel):
phone: str
def _call_jg_login_token_verify(login_token: str) -> str:
"""调极光 REST,返回 RSA 加密后的 base64 手机号字符串。"""
body = json.dumps({"loginToken": login_token, "exID": ""}).encode("utf-8")
auth = base64.b64encode(f"{JG_APP_KEY}:{JG_MASTER_SECRET}".encode()).decode()
req = urllib.request.Request(
JG_VERIFY_ENDPOINT,
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Basic {auth}",
},
)
try:
with urllib.request.urlopen(req, timeout=JG_REQUEST_TIMEOUT_SEC) as resp:
raw = resp.read()
except urllib.error.HTTPError as e:
body_text = ""
try:
body_text = e.read().decode("utf-8", errors="replace")
except Exception:
pass
logger.error("[JG] HTTP %s %s body=%s", e.code, e.reason, body_text[:500])
raise HTTPException(status_code=502, detail=f"jg http error: {e.code}")
data = json.loads(raw)
code = data.get("code")
if code != 8000:
logger.error("[JG] verify failed code=%s content=%s", code, data.get("content"))
raise HTTPException(status_code=400, detail=f"jg verify failed code={code}")
# JG 返回里加密手机号在 "phone" 字段(已验证)
encrypted = data.get("phone") or ""
if not encrypted:
logger.error("[JG] response missing phone field: %s", data)
raise HTTPException(status_code=500, detail="jg response missing phone")
return encrypted
def _decrypt_phone(encrypted_b64: str) -> str:
"""RSA 解密 base64 密文。
极光文档没明说 padding。实测 PKCS1v15 在 OAEP 密文上会"假成功"返回垃圾
(UnicodeDecodeError 0x8e),因此按 OAEP-SHA1 → OAEP-SHA256 → PKCS1v15 顺序
尝试,谁能解出 11 位纯数字手机号就用谁。
base64 padding 兼容:JG 各运营商可能返回不带 `=` 的 base64,先按 4 字节对齐补齐。
"""
private_key = _get_private_key()
padded_b64 = encrypted_b64 + "=" * (-len(encrypted_b64) % 4)
ciphertext = base64.b64decode(padded_b64)
paddings = [
("OAEP-SHA1", padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA1()),
algorithm=hashes.SHA1(),
label=None,
)),
("OAEP-SHA256", padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
)),
("PKCS1v15", padding.PKCS1v15()),
]
last_err: Exception | None = None
for name, pad in paddings:
try:
pt = private_key.decrypt(ciphertext, pad)
except Exception as e:
last_err = e
logger.debug("[JG] decrypt padding=%s failed: %s", name, e)
continue
try:
phone = pt.decode("utf-8")
except UnicodeDecodeError:
# decrypt 在 PKCS1v15 上对错误密文会"假成功"返回垃圾,跳到下一种 padding
logger.debug("[JG] decrypt padding=%s yielded non-UTF8 bytes head=%s", name, pt[:4].hex())
continue
# 验证是不是合法手机号(中国大陆 11 位纯数字 / 或带 + 前缀)
if phone.isdigit() and len(phone) == 11:
logger.info("[JG] decrypted with padding=%s", name)
return phone
# 不是 11 位纯数字但是 ASCII 可打印,也接受(留个口子,后续看实际格式)
if phone.isascii() and phone.isprintable():
logger.warning("[JG] decrypted with padding=%s, non-standard format: %r", name, phone)
return phone
logger.debug("[JG] decrypt padding=%s yielded non-phone string: %r", name, phone[:20])
raise RuntimeError(f"all RSA paddings failed; last error: {last_err}")
@router.post("/jverify-login", response_model=JverifyLoginResponse)
def jverify_login(req: JverifyLoginRequest) -> JverifyLoginResponse:
logger.info(
"jverify_login operator=%s token_len=%d",
req.operator,
len(req.login_token),
)
encrypted = _call_jg_login_token_verify(req.login_token)
try:
phone = _decrypt_phone(encrypted)
except Exception as e:
logger.error("[JG] decrypt failed: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="phone decrypt failed")
# upsert user(占坑期账号体系:phone 当主键)。失败不阻塞登录响应,只 log
try:
db.upsert_user(phone, int(time.time() * 1000))
except Exception as e:
logger.error("[JG] upsert_user failed (login still succeeds): %s", e)
# 日志里只打掩码,明文只返回客户端
masked = (phone[:3] + "****" + phone[-2:]) if len(phone) >= 5 else "***"
logger.info("jverify_login ok operator=%s phone=%s", req.operator, masked)
return JverifyLoginResponse(phone=phone)
@@ -0,0 +1,38 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from app.extractor import extract
from app.schemas import ParseRequest, ParseResponse
logger = logging.getLogger("shagua.parse")
router = APIRouter(prefix="/api/v1")
@router.post("/parse", response_model=ParseResponse)
def parse(req: ParseRequest) -> ParseResponse:
logger.info(
"parse request package=%s tree=%s clusters=%d",
req.package_name,
bool(req.tree),
len(req.clusters),
)
result = extract(req.package_name, req.tree, req.clusters)
if not result.get("success"):
logger.info("parse failed: reason=%s raw=%s", result.get("reason"), result.get("raw"))
raise HTTPException(
status_code=422,
detail=result.get("reason", "extract_failed"),
)
return ParseResponse(
title=result["title"],
price=result["price"],
source_app=result["source_app"],
cluster_id=result.get("cluster_id"),
typical_price=result["typical_price"],
)
@@ -0,0 +1,73 @@
"""POST /api/v1/parse-image —— iOS 端的截图识别接口。
multipart/form-data:
- image: file (JPEG/PNG/HEIC,客户端已压到 < 5MB)
- clusters: str (JSON 序列化的 [{"id":"<uuid>","title":"..."}, ...])
- package_name: str (可选,iOS 通常不传或传 null;Mock 实现不使用)
占坑期实现接 mock_extractor;后续接真模型时只改这里调 extractor_vision。
"""
from __future__ import annotations
import json
import logging
from typing import Optional
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from pydantic import ValidationError
from app.mock_extractor import mock_extract_image
from app.schemas import ClusterDtoStr, ParseStrResponse
logger = logging.getLogger("shagua.parse_image")
router = APIRouter(prefix="/api/v1")
# 单张图片上限 5MB。客户端正常压完短边 1024 的 JPEG 通常 < 500KB,
# 超过 5MB 是异常情况(原图 / 客户端压缩 bug),直接拒掉省 LLM 钱。
MAX_IMAGE_BYTES = 5 * 1024 * 1024
@router.post("/parse-image", response_model=ParseStrResponse)
async def parse_image(
image: UploadFile = File(...),
clusters: str = Form("[]"),
package_name: Optional[str] = Form(None),
) -> ParseStrResponse:
image_bytes = await image.read()
if not image_bytes:
logger.info("parse_image rejected: no_image bytes=0")
raise HTTPException(status_code=422, detail="no_image")
if len(image_bytes) > MAX_IMAGE_BYTES:
logger.info("parse_image rejected: image_too_large bytes=%d", len(image_bytes))
raise HTTPException(status_code=422, detail="image_too_large")
try:
clusters_raw = json.loads(clusters) if clusters else []
if not isinstance(clusters_raw, list):
raise ValueError("clusters must be a JSON array")
cluster_dtos = [ClusterDtoStr(**c) for c in clusters_raw]
except (json.JSONDecodeError, ValueError, TypeError, ValidationError) as e:
# ValidationError 不是 ValueError 子类(pydantic v2),必须显式 catch,
# 否则客户端发字段不全的 cluster 会让接口冒 500
logger.info("parse_image rejected: invalid_clusters err=%s", e)
raise HTTPException(status_code=422, detail="invalid_clusters")
logger.info(
"parse_image request bytes=%d package=%s clusters=%d",
len(image_bytes),
package_name,
len(cluster_dtos),
)
result = await mock_extract_image(image_bytes, cluster_dtos)
if not result.get("success"):
raise HTTPException(status_code=422, detail=result.get("reason", "extract_failed"))
return ParseStrResponse(
title=result["title"],
price=result["price"],
source_app=result["source_app"],
cluster_id=result.get("cluster_id"),
typical_price=result["typical_price"],
)
@@ -0,0 +1,44 @@
"""POST /api/v1/parse-ocr —— OCR 文本识别接口(占坑期 mock 实现)。
application/json:
{"ocr_text": "...", "clusters": [{"id":"<uuid|long-str>","title":"..."}, ...]}
客户端在本地 OCR(或截图)后把整段文本发来,后端从文本里抠出
title/price/source_app + 归簇 + 估常见价。占坑期接 mock_extractor;
接真模型时改这里调真实 extractor_ocr。
注:此前 main.py 引用了本 router 但文件缺失,会在启动时 ImportError —— 本文件补齐。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from app.mock_extractor import mock_extract_ocr
from app.schemas import ParseOcrRequest, ParseStrResponse
logger = logging.getLogger("app.parse_ocr")
router = APIRouter(prefix="/api/v1")
@router.post("/parse-ocr", response_model=ParseStrResponse)
async def parse_ocr(req: ParseOcrRequest) -> ParseStrResponse:
text = req.ocr_text.strip()
if not text:
raise HTTPException(status_code=422, detail="empty_ocr_text")
logger.info("parse_ocr request chars=%d clusters=%d", len(text), len(req.clusters))
result = await mock_extract_ocr(text, req.clusters)
if not result.get("success"):
raise HTTPException(status_code=422, detail=result.get("reason", "extract_failed"))
return ParseStrResponse(
title=result["title"],
price=result["price"],
source_app=result["source_app"],
cluster_id=result.get("cluster_id"),
typical_price=result["typical_price"],
)
@@ -0,0 +1,52 @@
"""POST /api/v1/parse-text —— iOS 端的"已知商品/价格,只需归簇 + 估常见价"接口。
application/json:
{"title": "...", "price": 99.0, "source_app": "淘宝",
"clusters": [{"id":"<uuid>","title":"..."}, ...]}
使用场景:
- iOS 主 App 内"手动新建记录":用户手填了 title/price/source_app
- Share Extension 收到的是纯文本(分享链接的标题文案)而非图片
占坑期实现接 mock_extractor;后续接真模型时改这里调真实 extractor_text。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException
from app.mock_extractor import mock_extract_text
from app.schemas import ParseStrResponse, ParseTextRequest
logger = logging.getLogger("shagua.parse_text")
router = APIRouter(prefix="/api/v1")
@router.post("/parse-text", response_model=ParseStrResponse)
async def parse_text(req: ParseTextRequest) -> ParseStrResponse:
if not req.title.strip():
raise HTTPException(status_code=422, detail="empty_title")
if req.price <= 0:
raise HTTPException(status_code=422, detail="invalid_price")
logger.info(
"parse_text request title=%r price=%.2f source=%s clusters=%d",
req.title,
req.price,
req.source_app,
len(req.clusters),
)
result = await mock_extract_text(req.title, req.price, req.source_app, req.clusters)
if not result.get("success"):
raise HTTPException(status_code=422, detail=result.get("reason", "extract_failed"))
return ParseStrResponse(
title=result["title"],
price=result["price"],
source_app=result["source_app"],
cluster_id=result.get("cluster_id"),
typical_price=result["typical_price"],
)
@@ -0,0 +1,220 @@
"""POST /api/v1/track-price —— 价保哨兵的价格查询接口(LLM mock 实现)。
## 场景
用户在 App 内告知"我在 X 平台 ¥XXX 买了 Y 商品",客户端每天后台调一次此接口
"该商品当前在该平台的合理价"。客户端按 `savings > 0` 弹"该申请价保了"通知。
## 为什么是 mock + LLM
占坑期没有真实平台价 API。爬虫法规风险大。OCR 用户主动截图比较被动 — 不适合
"每天自动监控"场景。
折中方案:LLM 估"该商品在该平台的日常常见价"(锚价),再用哈希抖动模拟"今天降价
/ 持平 / 涨价"。抖动是**确定性的**(seed = title+platform+date),同一商品同一天
结果一致,跨天才会变化,不会让用户看到"通知反复抖动"
未来切真 API 时客户端 0 改动(接口签名/响应固定)。
## 抖动设计
seed = sha256(title + platform + YYYY-MM-DD).hexdigest()[:8]
h = int(seed, 16) % 100
- h ∈ [0, 60) → 降价 3-8%, trend="down" (60% 概率,让通知活跃)
- h ∈ [60, 90) → 持平 ±2%, trend="flat"
- h ∈ [90, 100)→ 涨价 3-8%, trend="up"
60% 降价概率是策略选择:demo 体验里用户经常能看到"该申请价保了"通知,
功能""得多。真实降价频率比这低很多,但 mock 阶段优先体验。
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import time
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException
from app.llm_client import MOCK_LLM, chat
from app.schemas import TrackPriceRequest, TrackPriceResponse
logger = logging.getLogger("shagua.protect")
router = APIRouter(prefix="/api/v1")
SYSTEM_PROMPT = """你是一个商品当前价格估算助手。
# 输入
用户会告诉你:
- 平台: 例如 京东 / 淘宝 / 拼多多 / 抖音
- 商品标题: 商品完整名称
- 用户购买价: 参考用,不一定是当前价
- 购买距今天数: 例如 7天
# 任务
估算该商品在该平台目前的**日常常见售价**(不含特殊大促),作为"基础价"输出。
# 估算思路
- 考虑该平台的常态价位区间(例:京东自营常比拼多多百亿补贴高 5-10%)
- 考虑时间维度:新品 3 个月内可能略涨,旧品逐渐降
- 考虑大促节奏:618 / 双11 前后日常价会下浮
- 不熟悉的商品 → 根据品类 + 品牌常识估算合理日常价
- 完全陌生 → 取用户购买价的 ±5% 区间合理值
# 输出格式
严格 JSON,无 markdown,无解释,无任何其他文字:
{"base_price": 数字, "reasoning": "一句话"}
字段:
- base_price: 商品在该平台的日常常见价数字,**必须正数,不允许 null**
- reasoning: 一句话理由(给排查 LLM 行为用,客户端不展示)
"""
def _parse_llm_output(s: str) -> tuple[Optional[float], str]:
"""从 LLM 输出剥出 base_price + reasoning。返回 (base_price | None, reasoning)。"""
s = s.strip()
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
try:
data = json.loads(s)
except json.JSONDecodeError:
m = re.search(r"\{[^{}]*\}", s)
if not m:
return None, ""
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return None, ""
if not isinstance(data, dict):
return None, ""
bp_raw = data.get("base_price")
if isinstance(bp_raw, bool):
base_price = None
elif isinstance(bp_raw, (int, float)):
base_price = float(bp_raw)
elif isinstance(bp_raw, str):
try:
base_price = float(bp_raw.strip())
except ValueError:
base_price = None
else:
base_price = None
reasoning = data.get("reasoning")
if not isinstance(reasoning, str):
reasoning = ""
return base_price, reasoning
def _compute_jitter(title: str, platform: str, today_str: str) -> tuple[float, str]:
"""确定性抖动 — 同 title+platform+date 结果一致,跨天才变。
返回 (factor, trend),factor 是乘到 base_price 上的系数。
"""
seed_str = f"{title}|{platform}|{today_str}"
digest = hashlib.sha256(seed_str.encode("utf-8")).hexdigest()
h = int(digest[:8], 16) % 100
if h < 60:
# 降价 3-8%。在 [0, 60) 内线性插值到 [0.92, 0.97]
factor = 0.92 + (h / 60.0) * 0.05
trend = "down"
elif h < 90:
# 持平 ±2%。在 [60, 90) 内线性插值到 [0.98, 1.02]
factor = 0.98 + ((h - 60) / 30.0) * 0.04
trend = "flat"
else:
# 涨价 3-8%。在 [90, 100) 内线性插值到 [1.03, 1.08]
factor = 1.03 + ((h - 90) / 10.0) * 0.05
trend = "up"
return factor, trend
@router.post("/track-price", response_model=TrackPriceResponse)
def track_price(req: TrackPriceRequest) -> TrackPriceResponse:
title = req.product_title.strip()
platform = req.platform.strip()
if not title or not platform:
raise HTTPException(status_code=422, detail="empty_title_or_platform")
if req.purchase_price <= 0:
raise HTTPException(status_code=422, detail="invalid_purchase_price")
# 计算"购买距今多少天",给 LLM 当上下文
now_ts = int(time.time())
days_since = max(0, (now_ts - req.purchase_at) // 86400)
user_msg = (
f"平台: {platform}\n"
f"商品标题: {title}\n"
f"用户购买价: ¥{req.purchase_price:.2f}\n"
f"购买距今: {days_since}"
)
if MOCK_LLM:
# mock:跳过 LLM,下方 base_price 回退到 purchase_price 锚 + 确定性抖动。
raw = ""
else:
try:
raw = chat(
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
)
logger.debug("track-price LLM raw: %s", raw[:300])
except Exception as e:
# LLM 不可用(限流/网络/超时):不 500,用 purchase_price 作锚。
logger.warning("track-price LLM call failed, using purchase_price anchor: %s", e)
raw = ""
base_price, reasoning = _parse_llm_output(raw)
# base_price 兜底:LLM 不听话或给 <=0,用 purchase_price 作为锚
# (occurence 极少;占坑期 mock 不能让接口因 LLM 抽风就 500)
if base_price is None or base_price <= 0:
logger.warning(
"LLM did not return valid base_price for %r, fallback to purchase_price",
title,
)
base_price = req.purchase_price
# 抖动 — seed = title + platform + 服务器当前日期(UTC,日切清晰)
today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
factor, trend = _compute_jitter(title, platform, today_str)
current_price = round(base_price * factor, 2)
savings = round(req.purchase_price - current_price, 2)
logger.info(
"track-price: platform=%s title=%r purchase=¥%.2f base=¥%.2f current=¥%.2f"
" trend=%s savings=¥%.2f reasoning=%r",
platform,
title,
req.purchase_price,
base_price,
current_price,
trend,
savings,
reasoning[:80],
)
return TrackPriceResponse(
current_price=current_price,
base_price=base_price,
trend=trend,
savings=savings,
checked_at=now_ts,
)
@@ -0,0 +1,189 @@
"""POST /api/v1/quick-quote —— Android 端主动比价查询接口。
application/json:
{"title": "iPhone 15 Pro Max 1TB", "clusters": [{"id":1,"title":"..."}, ...]}
使用场景:用户在 App 内"快速比价"输入框输入商品名查询 — 不需要先去
购物 App 浮窗记账,直接得到"市场常见价 + 是否命中已记过的同款"
返回:
{"title": "...", "typical_price": 13999.0, "cluster_id": 1 | null}
LLM 行为:
- title 标准化 (修正错别字 / 统一规格表达)
- typical_price 必须给数字 (与 /parse 一致的兜底逻辑)
- cluster_id 按现有 簇匹配规则 判断
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from typing import Optional
from fastapi import APIRouter, HTTPException
from app.llm_client import MOCK_LLM, chat
from app.schemas import ClusterDto, QuickQuoteRequest, QuickQuoteResponse
logger = logging.getLogger("shagua.quick_quote")
router = APIRouter(prefix="/api/v1")
SYSTEM_PROMPT = """你是商品归簇与市场参考价估算助手。
# 任务
用户会发给你两部分输入:
1. 一个商品标题字符串
2. 用户已有的"商品簇"列表(每个簇用一个代表标题描述)
请你完成两件事:
A. 估算该商品的「市场常见价」(typical_price):主流电商平台常见售价区间
的中位值,不含双 11 / 618 等特殊促销价
B. 判断该商品是否归属于已有簇中的某一个,若是返回该簇 id,若否返回 null
# 簇匹配规则
两件商品视为「同一簇」,核心商品名一致即可,无视规格、颜色、容量、装数、
性别、码数等差异。
例:
- "iPhone 15 Pro 256GB""iPhone 15 Pro 1TB 暮光紫" → 同簇 ✓
- "iPhone 15 Pro""iPhone 14 Pro" → 不同簇 ✗ (型号不同)
- "海尔保温杯 500ml""九阳保温杯 500ml" → 不同簇 ✗ (品牌不同)
# 市场常见价估算
- 取主流电商(淘宝/京东/拼多多/抖音电商)常见售价区间的中位值
- 不含双 11、618、年货节、品牌大促等特殊促销价
- 单位:元,可以有小数,必须为正数
- **必须给出一个数字,不允许 null**。即使你不熟悉,也要根据品类、品牌、
规格的常识给出合理估算
- 估算思路:
* 知名品牌 → 该品牌该品类的官方建议零售价或主流电商常见价
* 冷门品牌 → 同品类的市场常见价区间中位值
* 完全陌生 → 给出符合常识的合理估值
# 输出格式
严格 JSON,无任何额外文字、不要 markdown 代码块、不要解释:
{"title": "标准化后的商品标题", "typical_price": 99.9, "cluster_id": 3}
字段说明:
- title:整理后的标题(可纠错 / 统一规格写法,但保留原意)
- typical_price:市场常见价数字(正数),**必须返回数字,不允许 null**
- cluster_id:命中已有簇返回其 id (整数);未命中返回 null
"""
def _format_clusters(clusters: list[ClusterDto]) -> str:
if not clusters:
return "(无,这是用户记录的第一件商品)"
return "\n".join(f"- id={c.id}: {c.title}" for c in clusters)
def _mock_raw(title: str) -> str:
"""占坑期 mock(DUOBIBI_MOCK_LLM):基于标题确定性造市场常见价,不归簇(由客户端新建)。"""
seed = int(hashlib.sha256(title.encode("utf-8")).hexdigest()[:8], 16)
typical = round(49 + seed % 9950 + (seed % 100) / 100.0, 2)
return json.dumps({"title": title, "typical_price": typical, "cluster_id": None}, ensure_ascii=False)
def _parse_llm_output(s: str) -> tuple[Optional[str], Optional[float], Optional[int]]:
s = s.strip()
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
try:
data = json.loads(s)
except json.JSONDecodeError:
m = re.search(r"\{[^{}]*\}", s)
if not m:
return None, None, None
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return None, None, None
if not isinstance(data, dict):
return None, None, None
title = data.get("title")
title = title.strip() if isinstance(title, str) and title.strip() else None
tp_raw = data.get("typical_price")
if isinstance(tp_raw, bool):
typical_price = None
elif isinstance(tp_raw, (int, float)):
typical_price = float(tp_raw)
elif isinstance(tp_raw, str):
try:
typical_price = float(tp_raw.strip())
except ValueError:
typical_price = None
else:
typical_price = None
cid_raw = data.get("cluster_id")
if isinstance(cid_raw, bool):
cluster_id = None
elif isinstance(cid_raw, int):
cluster_id = cid_raw
elif isinstance(cid_raw, float) and cid_raw.is_integer():
cluster_id = int(cid_raw)
else:
cluster_id = None
return title, typical_price, cluster_id
@router.post("/quick-quote", response_model=QuickQuoteResponse)
def quick_quote(req: QuickQuoteRequest) -> QuickQuoteResponse:
title_in = req.title.strip()
if not title_in:
raise HTTPException(status_code=422, detail="empty_title")
user_msg = (
f"商品标题: {title_in}\n\n"
f"已有商品簇:\n{_format_clusters(req.clusters)}"
)
if MOCK_LLM:
raw = _mock_raw(title_in)
else:
try:
raw = chat(
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
)
logger.debug("quick-quote LLM raw: %s", raw[:300])
except Exception as e:
# LLM 不可用:不 500,下方 typical_price 走兜底。
logger.warning("quick-quote LLM call failed: %s", e)
raw = ""
title_out, typical_price, cluster_id = _parse_llm_output(raw)
# title 兜底:LLM 没整理就用原值
if title_out is None:
title_out = title_in
# cluster_id 校验:防 LLM 编造
valid_ids = {c.id for c in req.clusters}
if cluster_id is not None and cluster_id not in valid_ids:
logger.warning("LLM returned unknown cluster_id=%s, treating as null", cluster_id)
cluster_id = None
# typical_price 兜底:LLM 不听话或给 <=0,用一个保守占位(实际极少触发)
if typical_price is None or typical_price <= 0:
logger.warning("LLM did not return valid typical_price, fallback to 0.0")
typical_price = 0.0
return QuickQuoteResponse(
title=title_out,
typical_price=typical_price,
cluster_id=cluster_id,
)
@@ -0,0 +1,195 @@
"""心愿单同步 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 "***"
@@ -0,0 +1,194 @@
"""POST /api/v1/worth-buy —— 多比比"AI 值不值得买"接口。
用户给一个商品标题 + 当前到手价,LLM 综合市场常见价、价格水位、大促节奏,
给出 0-100 的"现在值不值得买"评分 + 建议 + 理由 + 更划算的时机。
application/json:
{"title": "...", "price": 8099.0, "platform": "京东"(可选)}
响应:
{"score": 78, "verdict": "buy", "headline": "现在入手划算",
"reasons": ["...","..."], "typical_price": 8299.0, "best_time": "..."}
verdict: buy(建议买) / wait(再等等) / neutral(看个人需求)
兜底:LLM 抽风/解析失败也总能给中性结论,不抛 5xx(决策类接口应该永远有答案)。
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from typing import Optional
from fastapi import APIRouter, HTTPException
from app.llm_client import MOCK_LLM, chat
from app.schemas import WorthBuyRequest, WorthBuyResponse
logger = logging.getLogger("app.worth_buy")
router = APIRouter(prefix="/api/v1")
SYSTEM_PROMPT = """你是一个理性购物决策助手。
# 任务
用户给你一个商品标题 + 当前到手价(可选平台)。判断「现在是不是入手的好时机」,
综合考虑:市场常见价水位、该价格在历史区间的位置、临近的大促节奏(618 / 双11 / 年货节)、
品类贬值速度(数码贬值快、日用品稳定)。
# 评分口径(score 0-100)
- 越高 = 越值得现在买(价格够低 / 临近无更优时机)。
- 80-100 建议买(verdict=buy);40-79 看个人需求(verdict=neutral);0-39 再等等(verdict=wait)。
# 输出格式
严格 JSON,无 markdown,无解释:
{"score": 78, "verdict": "buy", "headline": "一句话结论",
"reasons": ["理由1", "理由2"], "typical_price": 8299.0, "best_time": "更划算的时机"}
字段:
- score: 0-100 整数
- verdict: "buy" / "wait" / "neutral"
- headline: 一句话结论(尽量 ≤ 14 字)
- reasons: 2-4 条短理由
- typical_price: 市场常见价(正数,必填)
- best_time: 若现在不是最佳给出更划算时机;若现在就值,可写"现在就合适"
"""
def _extract_json(s: str) -> Optional[dict]:
s = s.strip()
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
try:
data = json.loads(s)
except json.JSONDecodeError:
m = re.search(r"\{.*\}", s, re.DOTALL)
if not m:
return None
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return None
return data if isinstance(data, dict) else None
def _num(v) -> Optional[float]:
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
return float(v)
if isinstance(v, str):
try:
return float(v.strip())
except ValueError:
return None
return None
def _mock_raw(title: str, price: float, platform: str) -> str:
"""占坑期 mock(DUOBIBI_MOCK_LLM):不调 LLM,按"当前价相对常见价的水位"
确定性算分(价越低分越高),造 LLM 同格式 JSON,走下游解析/兜底。"""
seed = int(hashlib.sha256(f"{title}|{platform}".encode("utf-8")).hexdigest()[:8], 16)
typical = round(price * (0.90 + (seed % 25) / 100.0), 2) # 常见价:price 的 0.90~1.14 倍
ratio = price / typical if typical > 0 else 1.0
score = int(round(max(0.0, min(100.0, (1.15 - ratio) / 0.30 * 100.0))))
if score >= 80:
verdict, headline, best_time = "buy", "现在入手划算", "现在就合适"
elif score < 40:
verdict, headline, best_time = "wait", "建议再等等", "下次大促(如 618 / 双11)"
else:
verdict, headline, best_time = "neutral", "看个人需求", "可关注大促节点"
pct = round((typical - price) / typical * 100, 1) if typical > 0 else 0.0
if pct > 0:
reasons = [f"当前价低于市场常见价约 {pct}%", "近期价格处于偏低水位"]
elif pct < 0:
reasons = [f"当前价高于市场常见价约 {abs(pct)}%", "可等促销或比价后再入手"]
else:
reasons = ["当前价与市场常见价基本持平", "建议结合自身需求判断"]
return json.dumps(
{"score": score, "verdict": verdict, "headline": headline,
"reasons": reasons, "typical_price": typical, "best_time": best_time},
ensure_ascii=False,
)
@router.post("/worth-buy", response_model=WorthBuyResponse)
def worth_buy(req: WorthBuyRequest) -> WorthBuyResponse:
title = req.title.strip()
if not title:
raise HTTPException(status_code=422, detail="empty_title")
if req.price <= 0:
raise HTTPException(status_code=422, detail="invalid_price")
plat = (req.platform or "").strip()
user_msg = (
f"商品标题: {title}\n"
f"当前到手价: ¥{req.price:.2f}\n"
+ (f"平台: {plat}\n" if plat else "")
)
if MOCK_LLM:
raw = _mock_raw(title, req.price, plat)
else:
try:
raw = chat(
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
)
logger.debug("worth-buy LLM raw: %s", raw[:400])
except Exception as e:
# LLM 不可用(限流/网络/超时等):决策类接口不抛 5xx,降级走下方中性兜底。
logger.warning("worth-buy LLM call failed, falling back to neutral: %s", e)
raw = ""
data = _extract_json(raw) or {}
# score → clamp 0..100,解析失败给 50(中性)
score_raw = data.get("score")
if isinstance(score_raw, bool):
score = 50
else:
n = _num(score_raw)
score = int(round(n)) if n is not None else 50
score = max(0, min(100, score))
verdict = data.get("verdict")
if verdict not in {"buy", "wait", "neutral"}:
verdict = "buy" if score >= 80 else "wait" if score < 40 else "neutral"
headline = data.get("headline")
if not isinstance(headline, str) or not headline.strip():
headline = {"buy": "现在入手划算", "wait": "建议再等等", "neutral": "看个人需求"}[verdict]
headline = headline.strip()
reasons_raw = data.get("reasons")
if isinstance(reasons_raw, list):
reasons = [r.strip() for r in reasons_raw if isinstance(r, str) and r.strip()][:4]
else:
reasons = []
if not reasons:
reasons = ["暂无足够信息,建议结合自身需求判断"]
typical = _num(data.get("typical_price"))
if typical is None or typical <= 0:
typical = req.price
best_time = data.get("best_time")
if not isinstance(best_time, str) or not best_time.strip():
best_time = "现在就合适" if verdict == "buy" else "下次大促(如 618 / 双11)"
best_time = best_time.strip()
logger.info(
"worth-buy title=%r price=%.2f score=%d verdict=%s", title, req.price, score, verdict
)
return WorthBuyResponse(
score=score,
verdict=verdict,
headline=headline,
reasons=reasons,
typical_price=round(typical, 2),
best_time=best_time,
)
@@ -0,0 +1,82 @@
"""SQLite 持久化层。占坑期方案,够用即可。
- 单文件 sqlite3,默认 `/opt/duobibi-server/data.db`(env `DUOBIBI_DB_PATH` 可覆盖)
- stdlib sqlite3,不引入 SQLAlchemy(占坑期不需要 ORM)
- 表结构:
- `user`:phone 当主键(占坑期账号体系)
- `wish_item`:用户的心愿单,phone 关联 user,deleted_at 软删,updated_at 用于同步冲突解决
并发说明:sqlite3 默认每个连接独占,FastAPI thread pool sync handler
每次请求新开 connection + use as ctx manager,简单可靠
"""
from __future__ import annotations
import os
import sqlite3
from contextlib import contextmanager
from typing import Iterator
DB_PATH = os.environ.get("DUOBIBI_DB_PATH", "/opt/duobibi-server/data.db")
def _connect() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, timeout=10.0, isolation_level=None)
conn.row_factory = sqlite3.Row
# WAL 提升并发读写;FastAPI thread pool 下多线程访问同一文件时有用
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
@contextmanager
def get_conn() -> Iterator[sqlite3.Connection]:
"""每次请求开一个新 connection,with 块结束自动 close。"""
conn = _connect()
try:
yield conn
finally:
conn.close()
def init_schema() -> None:
"""启动时调用,IF NOT EXISTS 建表。改 schema 需手动 migration(写额外 SQL)。"""
with get_conn() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS user (
phone TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS wish_item (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
title TEXT NOT NULL,
target_price REAL,
note TEXT,
notify_enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
FOREIGN KEY (phone) REFERENCES user(phone) ON DELETE CASCADE
);
-- 按用户拉取 / 增量同步主索引
CREATE INDEX IF NOT EXISTS idx_wish_phone_updated
ON wish_item(phone, updated_at DESC);
"""
)
def upsert_user(phone: str, now_ms: int) -> None:
"""登录成功时调:有则更新 last_seen_at,无则插入。"""
with get_conn() as conn:
conn.execute(
"""
INSERT INTO user (phone, created_at, last_seen_at)
VALUES (?, ?, ?)
ON CONFLICT(phone) DO UPDATE SET last_seen_at = excluded.last_seen_at
""",
(phone, now_ms, now_ms),
)
@@ -0,0 +1,243 @@
"""把无障碍树扁平化 + 候选簇喂给 LLM,一次出 商品标题、到手价、归属簇。"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from typing import Optional
from app.llm_client import MOCK_LLM, chat
from app.schemas import ClusterDto, NodeDto
logger = logging.getLogger("shagua.extractor")
PKG_TO_BRAND: dict[str, str] = {
"com.taobao.taobao": "淘宝",
"com.jingdong.app.mall": "京东",
"com.xunmeng.pinduoduo": "拼多多",
"com.ss.android.ugc.aweme": "抖音",
"com.sankuai.meituan": "美团",
"com.sankuai.meituan.takeoutnew": "美团",
"me.ele": "饿了么",
}
MAX_FLAT_CHARS = 12000
SYSTEM_PROMPT = """你是一个商品页结构化提取、商品归簇与市场参考价估算助手。
# 任务
用户会发给你两部分输入:
1. 一段从 Android 无障碍树取出的某个购物 / 外卖 / 团购 App 页面的可见文本与控件信息
2. 用户已有的"商品簇"列表(每个簇用一个代表标题描述)
请你完成三件事:
A. 从页面信息中识别当前商品的标题到手价(单位:,数字)
B. 判断当前商品是否归属于已有簇中的某一个,若是返回该簇 id,若否返回 null(由客户端新建簇)
C. 估算该商品的市场常见价(typical_price):主流电商平台常见售价区间的中位值,不含双 11 / 618 等特殊促销价
# 簇匹配规则
两件商品视为同一簇,核心商品名一致即可,无视规格颜色容量装数性别码数等差异
:
- "iPhone 15 Pro 256GB" "iPhone 15 Pro 1TB 暮光紫" 同簇
- "iPhone 15 Pro" "iPhone 14 Pro" 不同簇 (型号不同)
- "海尔保温杯 500ml" "九阳保温杯 500ml" 不同簇 (品牌不同)
- "PaulFrank 卫衣 男款 L" "PaulFrank 卫衣 女款 M" 同簇 (性别码数算规格)
# 市场常见价估算
- 取主流电商(淘宝/京东/拼多多/抖音电商)常见售价区间的中位值
- 不含双 11618年货节品牌大促等特殊促销价
- 单位:,可以有小数,必须为正数
- **必须给出一个数字,不允许 null**即使你对该商品不熟悉,也要根据**品类**(如矿泉水卫衣手机咖啡等)**品牌**(如有)**规格**(数量/重量/容量/型号)的常识给出合理估算
- 估算思路:
* 知名品牌 该品牌该品类的官方建议零售价或主流电商常见价
* 冷门品牌 同品类(品类无关品牌)的市场常见价区间中位值
* 完全陌生 参考给定到手价 ±20% 的合理区间
# 输出格式
严格 JSON,无任何额外文字不要 markdown 代码块不要解释:
{"title": "完整商品标题", "price": 99.9, "cluster_id": 3, "typical_price": 120.0}
字段说明:
- title:商品标题字符串(最显眼最完整那段,不截断不拼规格)
- price:到手价数字(优先级:实付到手价 > 优惠后价 > 标价)
- cluster_id:命中已有簇返回其 id (整数);未命中返回 null
- typical_price:市场常见价数字(正数),**必须返回数字,不允许 null**
# 失败处理
页面不是商品/团购/外卖详情页,或无法提取标题/价格时:
{"title": null, "price": null, "cluster_id": null, "typical_price": null}
# 价格细则
到手价选择优先级:实付 > 优惠后 > 单品标价
"""
def flatten_tree(node: Optional[NodeDto]) -> str:
if node is None:
return ""
lines: list[str] = []
_walk(node, depth=0, out=lines)
text = "\n".join(lines)
if len(text) > MAX_FLAT_CHARS:
text = text[:MAX_FLAT_CHARS] + "\n...(truncated)"
return text
def _walk(node: NodeDto, depth: int, out: list[str]) -> None:
parts: list[str] = []
if node.view_id:
parts.append(f"id={node.view_id}")
if node.text:
parts.append(f'text="{node.text}"')
if node.desc:
parts.append(f'desc="{node.desc}"')
if parts:
out.append(" " * depth + " | ".join(parts))
for child in node.children or []:
_walk(child, depth + 1, out)
def _format_clusters(clusters: list[ClusterDto]) -> str:
if not clusters:
return "(无,这是用户记录的第一件商品)"
return "\n".join(f"- id={c.id}: {c.title}" for c in clusters)
def _mock_raw(flat: str) -> str:
"""占坑期 mock(DUOBIBI_MOCK_LLM):按页面文本哈希从预置商品池挑一件,
LLM 同格式 JSON(不归簇,由客户端按 cluster 规则新建)"""
from app.mock_extractor import MOCK_PRODUCTS
h = hashlib.sha256(flat.encode("utf-8")).digest()
p = MOCK_PRODUCTS[h[0] % len(MOCK_PRODUCTS)]
return json.dumps(
{"title": p["title"], "price": p["price"], "cluster_id": None,
"typical_price": p["typical_price"]},
ensure_ascii=False,
)
def extract(
package_name: str,
tree: Optional[NodeDto],
clusters: list[ClusterDto],
) -> dict:
"""返回 {success, title?, price?, source_app, cluster_id?, reason?}。"""
brand = PKG_TO_BRAND.get(package_name, "未知")
if tree is None:
return {"success": False, "reason": "no_tree", "source_app": brand}
flat = flatten_tree(tree)
if not flat.strip():
return {"success": False, "reason": "empty_tree", "source_app": brand}
user_msg = (
f"App: {brand}\n\n"
f"页面信息:\n{flat}\n\n"
f"已有商品簇:\n{_format_clusters(clusters)}"
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
]
if MOCK_LLM:
raw = _mock_raw(flat)
else:
try:
raw = chat(messages)
logger.debug("LLM raw output: %s", raw[:500])
except Exception as e:
# LLM 不可用(限流/网络/超时):不 500,返回提取失败让客户端提示重试。
logger.warning("extract LLM call failed: %s", e)
raw = ""
title, price, cluster_id, typical_price = _parse_llm_output(raw)
if title is None or price is None:
return {
"success": False,
"reason": "llm_no_extract",
"source_app": brand,
"raw": raw[:200],
}
# 校验 cluster_id 是不是用户提供过的(防 LLM 编造)
valid_ids = {c.id for c in clusters}
if cluster_id is not None and cluster_id not in valid_ids:
logger.warning("LLM returned unknown cluster_id=%s, treating as new cluster", cluster_id)
cluster_id = None
# typical_price 兜底: prompt 已要求 LLM 必须给数字,但仍可能不听话或给负数。
# 这种极少数情况下用当前价兜底(客户端会显示"持平"),保证字段总有值。
if typical_price is None or typical_price <= 0:
typical_price = price
return {
"success": True,
"title": title,
"price": price,
"source_app": brand,
"cluster_id": cluster_id,
"typical_price": typical_price,
}
def _parse_number(raw) -> Optional[float]:
if isinstance(raw, bool):
return None
if isinstance(raw, (int, float)):
return float(raw)
if isinstance(raw, str):
try:
return float(raw.strip())
except ValueError:
return None
return None
def _parse_llm_output(s: str) -> tuple[Optional[str], Optional[float], Optional[int], Optional[float]]:
s = s.strip()
s = re.sub(r"^```(?:json)?\s*", "", s)
s = re.sub(r"\s*```$", "", s)
data: Optional[dict] = None
try:
data = json.loads(s)
except json.JSONDecodeError:
m = re.search(r'\{[^{}]*"title"\s*:\s*[^{}]+\}', s)
if m:
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
pass
if not isinstance(data, dict):
return None, None, None, None
title = data.get("title")
title = title.strip() if isinstance(title, str) and title.strip() else None
price = _parse_number(data.get("price"))
typical_price = _parse_number(data.get("typical_price"))
cid_raw = data.get("cluster_id")
if isinstance(cid_raw, bool):
cluster_id = None
elif isinstance(cid_raw, int):
cluster_id = cid_raw
elif isinstance(cid_raw, float) and cid_raw.is_integer():
cluster_id = int(cid_raw)
else:
cluster_id = None
return title, price, cluster_id, typical_price
@@ -0,0 +1,56 @@
"""精简版 LLM 客户端 — 只保留智谱 GLM-5-turbo-nothinking,占坑期够用。"""
from __future__ import annotations
import logging
import os
import time
from typing import Any
from zai import ZhipuAiClient
logger = logging.getLogger("shagua.llm")
# 占坑期硬编码,与 pricebot-backend 同 key
ZHIPU_API_KEY = "c298ebdfda044e0387a3dc571f98ceed.QDY8lVCcGt04C6BD"
MODEL = "glm-5-turbo"
# 占坑期联调开关:DUOBIBI_MOCK_LLM=1 时各端点跳过真实 LLM 调用,改用本地确定性
# 算法造数据(不烧额度 / 不依赖外网,见 app/api/arena.py、app/api/worth_buy.py)。
MOCK_LLM = os.environ.get("DUOBIBI_MOCK_LLM", "").strip().lower() in ("1", "true", "yes", "on")
EXTRA_PARAMS: dict[str, Any] = {"thinking": {"type": "disabled"}}
DEFAULT_TEMPERATURE = 0.0
DEFAULT_MAX_TOKENS = 1024
_client = ZhipuAiClient(api_key=ZHIPU_API_KEY)
def chat(
messages: list[dict[str, Any]],
temperature: float = DEFAULT_TEMPERATURE,
max_tokens: int = DEFAULT_MAX_TOKENS,
) -> str:
start = time.time()
response = _client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
**EXTRA_PARAMS,
)
latency_ms = int((time.time() - start) * 1000)
content = (response.choices[0].message.content or "").strip()
usage = response.usage
if usage is not None:
logger.info(
"[LLM] model=%s tokens in=%s out=%s total=%s latency=%sms",
MODEL,
usage.prompt_tokens,
usage.completion_tokens,
usage.total_tokens,
latency_ms,
)
else:
logger.info("[LLM] model=%s latency=%sms (no usage)", MODEL, latency_ms)
return content
@@ -0,0 +1,52 @@
from __future__ import annotations
import logging
import sys
from fastapi import FastAPI
# 让应用自己的 logger(app.*)在 INFO 级别输出到 stdout/journal,
# uvicorn 自带 logging config 只管它自己的 access log
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stdout,
)
from app import db
from app.api.arena import router as arena_router
from app.api.auth import router as auth_router
from app.api.parse import router as parse_router
from app.api.parse_image import router as parse_image_router
from app.api.parse_ocr import router as parse_ocr_router
from app.api.parse_text import router as parse_text_router
from app.api.protect import router as protect_router
from app.api.quick_quote import router as quick_quote_router
from app.api.wish import router as wish_router
from app.api.worth_buy import router as worth_buy_router
app = FastAPI(title="DuoBiBi Mock", version="1.0.0")
@app.on_event("startup")
def _startup() -> None:
# IF NOT EXISTS 建表,首次启动建库 + 后续启动无害
db.init_schema()
app.include_router(parse_router)
app.include_router(parse_image_router)
app.include_router(parse_ocr_router)
app.include_router(parse_text_router)
app.include_router(protect_router)
app.include_router(quick_quote_router)
app.include_router(auth_router)
app.include_router(wish_router)
# 多比比新增端点
app.include_router(arena_router)
app.include_router(worth_buy_router)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@@ -0,0 +1,180 @@
"""Mock 模式的"提取/归簇/估常见价"实现。
用于占坑期 iOS 端开发与审核演示:不调真实 LLM,直接按图片字节哈希在
预置商品池中轮询返回,稳定可重放,且能自然制造"归簇命中""新建簇"两种路径
接真模型时:
- routes 里调用 mock_extract_image / mock_extract_text 的位置切换到调用真实 extractor
- 或在外层加 USE_MOCK 环境变量分流(本占坑期方案不引入开关,接模型时直接换实现)
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import random
from typing import Optional
from app.schemas import ClusterDtoStr
logger = logging.getLogger("shagua.mock")
# 10 件预置商品。挑选原则:覆盖 7 个白名单平台 + 3C/食品/服饰/小家电 多品类,
# 价格分布从 28 元(快餐)到 9999 元(手机)跨度大,便于测试统计页/趋势图视觉效果。
MOCK_PRODUCTS: list[dict] = [
{"title": "iPhone 15 Pro Max 256GB 暮光紫", "price": 9999.0, "source_app": "淘宝", "typical_price": 13999.0},
{"title": "海底捞外卖经典套餐 2-3 人餐", "price": 158.0, "source_app": "美团", "typical_price": 199.0},
{"title": "元气森林白桃味气泡水 480ml*15", "price": 49.9, "source_app": "京东", "typical_price": 65.0},
{"title": "Nike Air Force 1 三色刺绣男款 41", "price": 599.0, "source_app": "拼多多", "typical_price": 799.0},
{"title": "海尔不锈钢真空保温杯 500ml", "price": 89.0, "source_app": "京东", "typical_price": 129.0},
{"title": "麦当劳麦辣鸡腿堡套餐(大份)", "price": 28.0, "source_app": "美团", "typical_price": 35.0},
{"title": "飞利浦多功能旋转剃须刀 S5586", "price": 469.0, "source_app": "淘宝", "typical_price": 599.0},
{"title": "Lululemon Align 高腰瑜伽裤 25", "price": 750.0, "source_app": "抖音", "typical_price": 980.0},
{"title": "蒙牛特仑苏纯牛奶 250ml*16 礼盒", "price": 65.0, "source_app": "京东", "typical_price": 88.0},
{"title": "戴森 V12 Detect Slim 无线吸尘器", "price": 3699.0, "source_app": "淘宝", "typical_price": 4490.0},
]
# 让"同一张图触发同一个商品"可复现,但同一个 mock 商品在不同 record 里,
# 价格上下浮动 ±5%(模拟用户在不同时间记到的不同到手价),便于看趋势图效果。
def _picked_with_jitter(base: dict, jitter_seed: int) -> dict:
rnd = random.Random(jitter_seed)
delta = rnd.uniform(-0.05, 0.05)
price = round(base["price"] * (1 + delta), 2)
return {
"title": base["title"],
"price": price,
"source_app": base["source_app"],
"typical_price": base["typical_price"],
}
def _pick_by_bytes(blob: bytes) -> dict:
"""字节稳定哈希到预置商品。同样字节 → 同样商品,便于真机重放。"""
h = hashlib.sha256(blob).digest()
idx = h[0] % len(MOCK_PRODUCTS)
# 用第二个字节作为价格抖动种子,让"同一商品"在多次记账时价格略有差异
jitter_seed = h[1]
return _picked_with_jitter(MOCK_PRODUCTS[idx], jitter_seed)
def _pick_by_text(title: str, price: float) -> dict:
"""文本场景: 客户端已知 title/price,后端只决定 typical_price。
根据 title 哈希挑一个 mock 商品的 typical_price 作为参考价"""
h = hashlib.sha256(title.encode("utf-8")).digest()
idx = h[0] % len(MOCK_PRODUCTS)
return MOCK_PRODUCTS[idx]
def _match_cluster(picked_title: str, clusters: list[ClusterDtoStr]) -> Optional[str]:
"""简易归簇: 取 picked title 前 4 个字符,在 clusters 里找包含该子串的;
找到就返回该 cluster.id (字符串)
这不是真实 LLM 的语义归簇能力,只是为了让客户端在测试中能稳定触发"归簇命中"路径:
用户第一次记某商品 新建簇;再次记同 hash 的图片 命中已有簇
"""
if not clusters:
return None
needle = picked_title[:4] if len(picked_title) >= 4 else picked_title
for c in clusters:
if needle and needle in c.title:
return c.id
return None
async def _simulate_latency() -> None:
"""模拟真实 LLM 延迟 1.5-2.5 秒,让 iOS 端 loading UI 测起来真实。
**必须用 asyncio.sleep 而非 time.sleep**:本模块被 async 路由 (parse_image) 调用,
同步 sleep 会阻塞整个 event loop 任何一个请求 sleep 期间,server 无法处理
任何其他请求(健康检查其他识别请求都会卡)占坑期 QPS 低也会因为并发审核
被串行化,严重影响体验
"""
await asyncio.sleep(random.uniform(1.5, 2.5))
async def mock_extract_image(image_bytes: bytes, clusters: list[ClusterDtoStr]) -> dict:
"""图片版 mock 提取。返回与真实 extractor 同形状的 dict。
入参:
- image_bytes: 客户端上传的截图原始字节(已被客户端降采样)
- clusters: 客户端发来的已有商品簇列表
返回(success):
{success, title, price, source_app, cluster_id, typical_price}
"""
await _simulate_latency()
picked = _pick_by_bytes(image_bytes)
cluster_id = _match_cluster(picked["title"], clusters)
logger.info(
"mock image: bytes=%d picked=%r cluster_id=%s",
len(image_bytes),
picked["title"],
cluster_id,
)
return {
"success": True,
"title": picked["title"],
"price": picked["price"],
"source_app": picked["source_app"],
"cluster_id": cluster_id,
"typical_price": picked["typical_price"],
}
async def mock_extract_text(
title: str,
price: float,
source_app: str,
clusters: list[ClusterDtoStr],
) -> dict:
"""文本版 mock: 客户端已知 title/price/source_app,后端只决定归簇 + 估常见价。"""
await _simulate_latency()
picked = _pick_by_text(title, price)
typical_price = picked["typical_price"]
cluster_id = _match_cluster(title, clusters)
logger.info(
"mock text: title=%r price=%.2f source=%s cluster_id=%s typical=%.2f",
title,
price,
source_app,
cluster_id,
typical_price,
)
return {
"success": True,
"title": title,
"price": price,
"source_app": source_app,
"cluster_id": cluster_id,
"typical_price": typical_price,
}
async def mock_extract_ocr(ocr_text: str, clusters: list[ClusterDtoStr]) -> dict:
"""OCR 文本版 mock:按整段 OCR 文本的哈希挑预置商品,稳定可重放。
image / text 版同形状返回,客户端复用同一套结果处理逻辑
(此前 main.py 引用了 parse_ocr router 但实现缺失会导致启动 ImportError,这里补齐对应 mock)
"""
await _simulate_latency()
h = hashlib.sha256(ocr_text.encode("utf-8")).digest()
picked = _picked_with_jitter(MOCK_PRODUCTS[h[0] % len(MOCK_PRODUCTS)], h[1])
cluster_id = _match_cluster(picked["title"], clusters)
logger.info(
"mock ocr: chars=%d picked=%r cluster_id=%s",
len(ocr_text),
picked["title"],
cluster_id,
)
return {
"success": True,
"title": picked["title"],
"price": picked["price"],
"source_app": picked["source_app"],
"cluster_id": cluster_id,
"typical_price": picked["typical_price"],
}
@@ -0,0 +1,165 @@
from __future__ import annotations
from typing import List, Optional
from pydantic import BaseModel, Field
class NodeDto(BaseModel):
"""客户端无障碍序列化的节点。占坑期后端不解析,字段保留为兼容未来真实解析。"""
view_id: Optional[str] = None
text: Optional[str] = None
desc: Optional[str] = None
children: List["NodeDto"] = Field(default_factory=list)
class ClusterDto(BaseModel):
"""Android `/parse` 接口用,id 是 Room 自增整数。"""
id: int
title: str
class ParseRequest(BaseModel):
package_name: str
tree: Optional[NodeDto] = None
clusters: List[ClusterDto] = Field(default_factory=list)
class ParseResponse(BaseModel):
title: str
price: float
source_app: str
cluster_id: Optional[int] = None
typical_price: float
NodeDto.model_rebuild()
class QuickQuoteRequest(BaseModel):
"""Android `/api/v1/quick-quote` 接口用:用户主动输入商品名查询比价。
无需 price(用户来查的就是不知道价),只需 title + 已有簇用于归簇判断
"""
title: str
clusters: List[ClusterDto] = Field(default_factory=list)
class QuickQuoteResponse(BaseModel):
"""quick-quote 响应:LLM 估的市场常见价 + 是否命中已有簇。"""
title: str # 规范化后的标题(LLM 可能整理一下用户输入)
typical_price: float # 市场常见价,LLM 必须给数字
cluster_id: Optional[int] = None # 命中已有簇的 id;null 表示用户没记过同款
# ---------- iOS 新接口用的 DTO (cluster id 用字符串以兼容 UUID) ---------- #
class ClusterDtoStr(BaseModel):
"""iOS `/parse-image` / `/parse-text` 接口用,id 是 SwiftData 的 UUID 字符串。"""
id: str
title: str
class ParseTextRequest(BaseModel):
"""iOS 手动新建记录时调用:客户端已知 title/price/source_app,
后端只做归簇 + 估市场常见价"""
title: str
price: float
source_app: str
clusters: List[ClusterDtoStr] = Field(default_factory=list)
class ParseStrResponse(BaseModel):
"""iOS 三个接口的统一响应。结构同 ParseResponse,但 cluster_id 是字符串。"""
title: str
price: float
source_app: str
cluster_id: Optional[str] = None
typical_price: float
class ParseOcrRequest(BaseModel):
"""iOS 截图 OCR 后调用:客户端只发 OCR 文本 + 已有簇,
由后端 LLM 抠出 title/price/source_app/cluster_id/typical_price 全部 5 """
ocr_text: str
clusters: List[ClusterDtoStr] = Field(default_factory=list)
# ---------- 价保哨兵 ---------- #
class TrackPriceRequest(BaseModel):
"""Android `/api/v1/track-price`:用户告知"我在 X 平台 ¥XXX 买了 Y" → 服务端
LLM 估当前合理价 + 哈希抖动模拟降价,返给客户端做"该申请价保了"通知判断
占坑期实现 LLM mock(无真实平台价 API)未来接真 API 时客户端 0 改动
"""
platform: str # "京东" / "淘宝" / "拼多多" / "抖音" 等中文平台名
product_title: str # 商品完整标题
purchase_price: float # 用户购买时支付的价格(参考用,实际抖动以 LLM 给的基础价为锚)
purchase_at: int # 购买时间(秒级 epoch),用于"距今 N 天"提示 + 抖动种子
class TrackPriceResponse(BaseModel):
"""track-price 响应:当前价 + 跌涨趋势 + 可省金额。
客户端按 `savings > 0` 决定是否发"该申请价保了"通知
"""
current_price: float # 当前估算价(抖动后的最终输出)
base_price: float # LLM 估的"日常常见价",抖动前的锚
trend: str # "down" / "flat" / "up"
savings: float # purchase_price - current_price;> 0 = 可申请价保
checked_at: int # 服务端响应生成时间(秒级 epoch)
# ---------- 多比比新增:比价擂台 ---------- #
class ArenaQuoteRequest(BaseModel):
"""多比比 `/api/v1/arena-quote`:用户输入商品名,后端 LLM 估各平台到手价 + 市场常见价。"""
title: str
platforms: Optional[List[str]] = None # 不传则用后端默认 4 大电商
class ArenaPlatformQuote(BaseModel):
platform: str
price: float
note: str = ""
class ArenaQuoteResponse(BaseModel):
title: str # 归一化后的标题
typical_price: float # 市场常见价(各平台中位)
quotes: List[ArenaPlatformQuote]
lowest_platform: Optional[str] = None
# ---------- 多比比新增:AI 值不值得买 ---------- #
class WorthBuyRequest(BaseModel):
"""多比比 `/api/v1/worth-buy`:给商品 + 当前到手价,LLM 评估值不值得现在买。"""
title: str
price: float
platform: Optional[str] = None
class WorthBuyResponse(BaseModel):
score: int # 0-100,越高越值得现在买
verdict: str # "buy" / "wait" / "neutral"
headline: str # 一句话结论
reasons: List[str]
typical_price: float
best_time: str
Binary file not shown.
@@ -0,0 +1,22 @@
[Unit]
Description=DuoBiBi Mock Backend (FastAPI / uvicorn)
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/duobibi-server
Environment="PATH=/opt/duobibi-server/.venv/bin:/usr/bin:/bin"
ExecStart=/opt/duobibi-server/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8766 --workers 1 --log-level info
Restart=on-failure
RestartSec=3
# 安全收紧
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/duobibi-server
ProtectHome=true
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,36 @@
# 80 端口: 强制跳转到 443
server {
listen 80;
listen [::]:80;
server_name api.duobibi.com;
return 301 https://$host$request_uri;
}
# 443 端口: 反代到本地 uvicorn (多比比独立端口 8766,与傻瓜比价 8765 并存)
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.duobibi.com;
ssl_certificate /etc/nginx/ssl/api.duobibi.com.pem;
ssl_certificate_key /etc/nginx/ssl/api.duobibi.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Android 控件树最大上限 ~3MB;截图客户端兜底 5MB + multipart overhead → 6MB
client_max_body_size 6m;
location / {
proxy_pass http://127.0.0.1:8766;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}
}
@@ -0,0 +1,27 @@
[project]
name = "duobibi-server"
version = "1.0.0"
description = "Mock + LLM backend for 多比比 (placeholder release)"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"pydantic>=2.9.0",
"zai-sdk>=0.0.4",
"python-multipart>=0.0.9",
# 极光一键登录:RSA 解密极光返回的加密手机号(PKCS#1 v1.5 padding)
"cryptography>=42.0.0",
]
[project.optional-dependencies]
dev = [
"httpx>=0.27.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
]
[tool.setuptools.packages.find]
include = ["app*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
@@ -0,0 +1,70 @@
<#
.SYNOPSIS
本机启动多比比后端 (FastAPI / uvicorn)
.DESCRIPTION
默认离线 mock 模式:不调智谱 LLM不烧额度不依赖外网
自动用 conda 环境 pricebot Python,并设好必需的环境变量
(DUOBIBI_DB_PATH / PYTHONUTF8 / DUOBIBI_MOCK_LLM),无需先 conda activate
.EXAMPLE
.\run-backend.ps1 # 默认:mock 模式, 监听 127.0.0.1:8766
.\run-backend.ps1 -Reload # 改代码自动热重载 (开发用)
.\run-backend.ps1 -Lan # 监听 0.0.0.0,供真机/同局域网设备连
.\run-backend.ps1 -Real # 连真实智谱 LLM (需可用 key,否则 429)
.\run-backend.ps1 -Port 8888 # 换端口 (App 的 BASE_URL 也要同步改)
#>
param(
[int]$Port = 8766,
[switch]$Lan, # 监听 0.0.0.0 (真机联调); 默认仅本机 127.0.0.1
[switch]$Real, # 连真实 LLM; 默认离线 mock
[switch]$Reload # uvicorn 热重载 (改代码自动重启,开发用)
)
$ErrorActionPreference = "Stop"
# 脚本所在目录就是 duobibi-server/,用它定位项目与 DB,不写死项目路径
$ServerDir = $PSScriptRoot
# pricebot 环境的 Python。若你的 conda 装在别处,改这一行即可。
$Python = "C:\Users\muzhiyuan\anaconda3\envs\pricebot\python.exe"
if (-not (Test-Path $Python)) {
Write-Error "找不到 pricebot 环境的 Python:`n $Python`n请确认 conda 环境存在 (conda env list),或修改本脚本里的 `$Python 路径。"
exit 1
}
# --- 必需的环境变量 ---
# DB 路径:不设的话代码默认 /opt/duobibi-server/data.db,Windows 上建库会失败
$env:DUOBIBI_DB_PATH = Join-Path $ServerDir "data.db"
# 让日志里的 ¥ / 中文不触发 Windows GBK 控制台的 UnicodeEncodeError
$env:PYTHONUTF8 = "1"
if ($Real) {
Remove-Item Env:\DUOBIBI_MOCK_LLM -ErrorAction SilentlyContinue
Write-Host "[mode ] 真实 LLM (需可用智谱 key,当前硬编码 key 可能 429)" -ForegroundColor Yellow
} else {
$env:DUOBIBI_MOCK_LLM = "1"
Write-Host "[mode ] 离线 mock (默认,不烧额度/不连外网)" -ForegroundColor Green
}
$BindHost = if ($Lan) { "0.0.0.0" } else { "127.0.0.1" }
Write-Host "[serve] http://${BindHost}:$Port" -ForegroundColor Cyan
Write-Host "[db ] $($env:DUOBIBI_DB_PATH)" -ForegroundColor DarkGray
if ($Lan) {
Write-Host "[lan ] 真机请连这台电脑的局域网 IP:$Port (需同一 WiFi + 防火墙放行该端口)" -ForegroundColor Yellow
}
Write-Host "[check] 另开一个窗口验证: Invoke-RestMethod http://127.0.0.1:$Port/health" -ForegroundColor DarkGray
Write-Host "[stop ] Ctrl+C 停止" -ForegroundColor DarkGray
Write-Host ""
# --- 启动 uvicorn (用 --app-dir 让 app.main 可解析,避免受当前工作目录影响) ---
$uvArgs = @(
"-m", "uvicorn", "app.main:app",
"--app-dir", $ServerDir,
"--host", $BindHost,
"--port", "$Port"
)
if ($Reload) { $uvArgs += "--reload" }
& $Python @uvArgs
@@ -0,0 +1,95 @@
"""多比比新增端点的冒烟测试。LLM 调用被 monkeypatch 成固定 JSON,避免真打智谱。"""
from __future__ import annotations
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
ARENA_JSON = (
'{"title":"iPhone 15 Pro 256GB","typical_price":8299.0,'
'"quotes":[{"platform":"淘宝","price":8099.0,"note":""},'
'{"platform":"京东","price":8299.0,"note":"自营"},'
'{"platform":"拼多多","price":7888.0,"note":"百亿补贴"},'
'{"platform":"抖音","price":8190.0,"note":""}]}'
)
WORTH_JSON = (
'{"score":78,"verdict":"buy","headline":"现在入手划算",'
'"reasons":["低于市场常见价约 2%","距下次大促还有一段时间"],'
'"typical_price":8299.0,"best_time":"现在就合适"}'
)
def test_arena_quote_basic() -> None:
with patch("app.api.arena.chat", return_value=ARENA_JSON):
r = client.post("/api/v1/arena-quote", json={"title": "iPhone 15 Pro 256GB"})
assert r.status_code == 200, r.text
body = r.json()
assert body["typical_price"] > 0
assert len(body["quotes"]) == 4
assert body["lowest_platform"] == "拼多多" # 7888 最低
def test_arena_quote_synthesizes_missing_platforms() -> None:
"""LLM 只给 1 个平台,其余平台应被确定性合成,保证每行有值。"""
only_one = '{"title":"x","typical_price":100.0,"quotes":[{"platform":"淘宝","price":95.0}]}'
with patch("app.api.arena.chat", return_value=only_one):
r = client.post(
"/api/v1/arena-quote",
json={"title": "x", "platforms": ["淘宝", "京东", "拼多多"]},
)
assert r.status_code == 200, r.text
quotes = r.json()["quotes"]
assert len(quotes) == 3
assert {q["platform"] for q in quotes} == {"淘宝", "京东", "拼多多"}
def test_arena_quote_empty_title() -> None:
r = client.post("/api/v1/arena-quote", json={"title": " "})
assert r.status_code == 422
def test_worth_buy_basic() -> None:
with patch("app.api.worth_buy.chat", return_value=WORTH_JSON):
r = client.post(
"/api/v1/worth-buy",
json={"title": "iPhone 15 Pro 256GB", "price": 8099.0},
)
assert r.status_code == 200, r.text
body = r.json()
assert 0 <= body["score"] <= 100
assert body["verdict"] in {"buy", "wait", "neutral"}
assert isinstance(body["reasons"], list) and body["reasons"]
def test_worth_buy_fallback_on_garbage() -> None:
"""LLM 返回非 JSON 垃圾时,接口仍给中性结论而非 5xx。"""
with patch("app.api.worth_buy.chat", return_value="不是 JSON 的废话"):
r = client.post("/api/v1/worth-buy", json={"title": "x", "price": 50.0})
assert r.status_code == 200, r.text
body = r.json()
assert body["typical_price"] > 0
assert body["verdict"] in {"buy", "wait", "neutral"}
def test_worth_buy_invalid_price() -> None:
r = client.post("/api/v1/worth-buy", json={"title": "x", "price": 0})
assert r.status_code == 422
def test_parse_ocr_boots_and_works() -> None:
"""parse-ocr 端点存在(修复了缺失 import),mock 延迟被关掉后能正常返回。"""
async def _instant(_seconds):
return None
with patch("app.mock_extractor.asyncio.sleep", _instant):
r = client.post(
"/api/v1/parse-ocr",
json={"ocr_text": "iPhone 15 Pro Max 256GB ¥8099 京东自营", "clusters": []},
)
assert r.status_code == 200, r.text
assert r.json()["price"] > 0
@@ -0,0 +1,13 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health() -> None:
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
@@ -0,0 +1,198 @@
"""新接口的端到端测试。会真实跑到 mock_extractor,因此每个 case 会 sleep 1.5-2.5s。
我们 monkey-patch sleep 拿掉,加快测试"""
from __future__ import annotations
import io
import json
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
@pytest.fixture(autouse=True)
def _no_sleep():
"""禁用 mock_extractor 的真实异步延迟,测试运行时间从 ~10s 降到 < 1s。"""
async def _instant(_seconds):
return None
with patch("app.mock_extractor.asyncio.sleep", _instant):
yield
def _fake_image(payload: bytes = b"fake jpeg bytes 0123456789") -> tuple:
return ("image", ("test.jpg", io.BytesIO(payload), "image/jpeg"))
def test_parse_image_basic() -> None:
r = client.post(
"/api/v1/parse-image",
files=[_fake_image()],
data={"clusters": "[]"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["title"]
assert body["price"] > 0
assert body["typical_price"] > 0
assert body["source_app"] in {"淘宝", "京东", "拼多多", "抖音", "美团", "饿了么"}
assert body["cluster_id"] is None # 空 clusters 必然新建
def test_parse_image_same_bytes_same_product() -> None:
"""同样字节 → 同样商品(便于真机重放)。允许价格因抖动小幅不同。"""
r1 = client.post("/api/v1/parse-image", files=[_fake_image(b"AAAA")], data={"clusters": "[]"})
r2 = client.post("/api/v1/parse-image", files=[_fake_image(b"AAAA")], data={"clusters": "[]"})
assert r1.status_code == 200
assert r2.status_code == 200
assert r1.json()["title"] == r2.json()["title"]
assert r1.json()["source_app"] == r2.json()["source_app"]
def test_parse_image_cluster_match() -> None:
"""如果 clusters 里有 title 与 mock 选中商品共享前 4 字符,应命中该 cluster。"""
# 先发一次拿到 picked title
r1 = client.post("/api/v1/parse-image", files=[_fake_image(b"BBBB")], data={"clusters": "[]"})
picked_title = r1.json()["title"]
cluster_id = "uuid-fake-1"
clusters = [{"id": cluster_id, "title": picked_title}]
r2 = client.post(
"/api/v1/parse-image",
files=[_fake_image(b"BBBB")],
data={"clusters": json.dumps(clusters)},
)
assert r2.status_code == 200
assert r2.json()["cluster_id"] == cluster_id
def test_parse_image_no_image() -> None:
r = client.post(
"/api/v1/parse-image",
files=[("image", ("empty.jpg", io.BytesIO(b""), "image/jpeg"))],
data={"clusters": "[]"},
)
assert r.status_code == 422
assert r.json()["detail"] == "no_image"
def test_parse_image_missing_image_field() -> None:
r = client.post("/api/v1/parse-image", data={"clusters": "[]"})
assert r.status_code == 422 # FastAPI 字段校验失败也返回 422
def test_parse_image_invalid_clusters_json() -> None:
r = client.post(
"/api/v1/parse-image",
files=[_fake_image()],
data={"clusters": "not a json"},
)
assert r.status_code == 422
assert r.json()["detail"] == "invalid_clusters"
def test_parse_image_clusters_not_array() -> None:
r = client.post(
"/api/v1/parse-image",
files=[_fake_image()],
data={"clusters": '{"id":"x","title":"y"}'},
)
assert r.status_code == 422
assert r.json()["detail"] == "invalid_clusters"
def test_parse_image_too_large() -> None:
big = b"x" * (5 * 1024 * 1024 + 1)
r = client.post(
"/api/v1/parse-image",
files=[("image", ("big.jpg", io.BytesIO(big), "image/jpeg"))],
data={"clusters": "[]"},
)
assert r.status_code == 422
assert r.json()["detail"] == "image_too_large"
def test_parse_text_basic() -> None:
r = client.post(
"/api/v1/parse-text",
json={
"title": "iPhone 15 Pro Max 256GB",
"price": 9999.0,
"source_app": "淘宝",
"clusters": [],
},
)
assert r.status_code == 200
body = r.json()
assert body["title"] == "iPhone 15 Pro Max 256GB"
assert body["price"] == 9999.0
assert body["source_app"] == "淘宝"
assert body["typical_price"] > 0
assert body["cluster_id"] is None
def test_parse_text_cluster_match() -> None:
"""同样 title 前 4 字符的 cluster 应命中。"""
cluster_id = "uuid-text-1"
r = client.post(
"/api/v1/parse-text",
json={
"title": "iPhone 15 Pro Max 1TB 暮光紫",
"price": 11999.0,
"source_app": "京东",
"clusters": [{"id": cluster_id, "title": "iPhone 15 Pro 256GB"}],
},
)
assert r.status_code == 200
assert r.json()["cluster_id"] == cluster_id
def test_parse_text_empty_title() -> None:
r = client.post(
"/api/v1/parse-text",
json={"title": " ", "price": 9.9, "source_app": "淘宝", "clusters": []},
)
assert r.status_code == 422
assert r.json()["detail"] == "empty_title"
def test_parse_text_invalid_price() -> None:
r = client.post(
"/api/v1/parse-text",
json={"title": "x", "price": 0, "source_app": "淘宝", "clusters": []},
)
assert r.status_code == 422
assert r.json()["detail"] == "invalid_price"
def test_parse_text_negative_price() -> None:
r = client.post(
"/api/v1/parse-text",
json={"title": "x", "price": -1, "source_app": "淘宝", "clusters": []},
)
assert r.status_code == 422
def test_parse_image_invalid_cluster_schema() -> None:
"""clusters 数组里某个对象缺 id/title 字段 → Pydantic ValidationError 必须返回 422 而非 500"""
r = client.post(
"/api/v1/parse-image",
files=[_fake_image()],
data={"clusters": '[{"id":"only-id-no-title"}]'},
)
assert r.status_code == 422
assert r.json()["detail"] == "invalid_clusters"
def test_concurrent_parse_image_does_not_block_event_loop(_no_sleep) -> None:
"""禁用 sleep 后并发应该极快;若 event loop 被阻塞会显著退化。
主要保护 mock_extract_image 永远是 async + await"""
import time as _t
start = _t.time()
for _ in range(10):
r = client.post("/api/v1/parse-image", files=[_fake_image()], data={"clusters": "[]"})
assert r.status_code == 200
# 10 次串行(TestClient 是同步客户端)且 sleep 被 mock 掉,应远 < 1s
assert _t.time() - start < 2.0