# 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 部署
