初始化:多比比Android客户端(占坑版)
This commit is contained in:
@@ -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`: 0–100,80+ → `buy` / 40–79 → `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" → 同簇 ✓ (性别码数算规格)
|
||||
|
||||
# 市场常见价估算
|
||||
|
||||
- 取主流电商(淘宝/京东/拼多多/抖音电商)常见售价区间的中位值
|
||||
- 不含双 11、618、年货节、品牌大促等特殊促销价
|
||||
- 单位:元,可以有小数,必须为正数
|
||||
- **必须给出一个数字,不允许 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
|
||||
Reference in New Issue
Block a user