Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87ed7b7486 | |||
| 0ae19dc49c | |||
| 389ece9052 | |||
| 455884401f | |||
| dacb37e3e1 |
@@ -0,0 +1,39 @@
|
||||
"""onboarding_completion table (新手引导完成标记,按 user+device 去重)
|
||||
|
||||
Revision ID: onboarding_completion
|
||||
Revises: coupon_daily_completion
|
||||
Create Date: 2026-06-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'onboarding_completion'
|
||||
down_revision: Union[str, Sequence[str], None] = 'coupon_daily_completion'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# (账号, 设备) 一条完成标记;device_id = 客户端硬件级 ANDROID_ID(跨卸载重装稳定)。
|
||||
op.create_table(
|
||||
'onboarding_completion',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('device_id', sa.String(length=64), nullable=False),
|
||||
sa.Column('completed_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'device_id', name='uq_onboarding_user_device'),
|
||||
)
|
||||
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_onboarding_completion_user_id'), ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('onboarding_completion', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_onboarding_completion_user_id'))
|
||||
op.drop_table('onboarding_completion')
|
||||
+10
-6
@@ -19,6 +19,7 @@ from app.core.ratelimit import rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import (
|
||||
JverifyLoginRequest,
|
||||
@@ -37,12 +38,13 @@ logger = logging.getLogger("shagua.auth")
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _login_response(db, user) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。"""
|
||||
def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
|
||||
tokens = issue_token_pair(user.id)
|
||||
return TokenWithUser(
|
||||
**tokens,
|
||||
user=UserOut.model_validate(user),
|
||||
onboarding_completed=onboarding_completed,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,8 +68,9 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
logger.info("jverify_login ok user_id=%d phone=%s", user.id, mask_phone(phone))
|
||||
return _login_response(db, user)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("jverify_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(phone), completed)
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== 短信登录 =====================
|
||||
@@ -103,8 +106,9 @@ def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
logger.info("sms_login ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
||||
return _login_response(db, user)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("sms_login ok user_id=%d phone=%s onboarded=%s", user.id, mask_phone(req.phone), completed)
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== Refresh =====================
|
||||
|
||||
@@ -102,6 +102,17 @@ async def intent_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/intent/step")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/intent/precoupon/step",
|
||||
summary="外卖比价 Phase 0 意图识别前先用券 (透传到 pricebot, 仅美团源)",
|
||||
)
|
||||
async def intent_precoupon_step(request: Request) -> dict[str, Any]:
|
||||
# 美团源平台『意图识别前先用券』多帧循环: 客户端在调 /intent/recognize 之前先循环
|
||||
# 调本端点到 done(continue=false)。订单页底部有『点击使用X红包』就自动选最大免费券
|
||||
# 用上, 已用券/无券则首帧秒过。与 /intent/step 同属 intent 域, 复用同一透传壳。
|
||||
return await _passthrough(request, "/api/intent/precoupon/step")
|
||||
|
||||
|
||||
@router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传到 pricebot)")
|
||||
async def price_step(request: Request) -> dict[str, Any]:
|
||||
return await _passthrough(request, "/api/price/step")
|
||||
|
||||
+13
-1
@@ -16,9 +16,10 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import UserOut
|
||||
from app.schemas.user import OkResponse, ProfileUpdateRequest
|
||||
from app.schemas.user import OkResponse, OnboardingCompleteRequest, ProfileUpdateRequest
|
||||
|
||||
logger = logging.getLogger("shagua.user")
|
||||
|
||||
@@ -51,6 +52,17 @@ async def upload_avatar(
|
||||
return UserOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.post("/onboarding/complete", response_model=OkResponse, summary="标记新手引导完成(按 设备+账号)")
|
||||
def complete_onboarding(
|
||||
req: OnboardingCompleteRequest, user: CurrentUser, db: DbSession
|
||||
) -> OkResponse:
|
||||
"""走完新手引导时调一次。按 (当前账号, device_id) 落一条完成标记,跨卸载重装持久。
|
||||
幂等:重复调用不报错。device_id 取客户端硬件级 ANDROID_ID,与登录请求一致。"""
|
||||
onboarding_repo.mark_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("onboarding complete user_id=%d device_len=%d", user.id, len(req.device_id))
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
|
||||
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
|
||||
media.delete_avatar(user.avatar_url)
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
|
||||
from app.models.onboarding import OnboardingCompletion # noqa: F401
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
|
||||
from app.models.ops_stat_config import OpsStatConfig # noqa: F401
|
||||
from app.models.price_observation import PriceObservation # noqa: F401
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""新手引导完成标记(按 设备 + 账号 去重)。
|
||||
|
||||
产品规则:同一台设备 + 同一个账号,新手引导只跑一次;卸载重装(同设备同账号)
|
||||
不再触发。本地标记(SharedPreferences)卸载即丢,所以"完成"必须落后端,按
|
||||
(user_id, device_id) 唯一一条。
|
||||
|
||||
⚠️ device_id 这里用客户端的**硬件级稳定标识**(Android `Settings.Secure.ANDROID_ID`),
|
||||
同签名 app 卸载重装不变、仅恢复出厂才重置——区别于领券/比价用的 per-install device_id
|
||||
(见 coupon_state,存 SP、重装会变)。换新设备 → 无此行 → 重新走引导。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class OnboardingCompletion(Base):
|
||||
"""一台设备 + 一个账号 一条:走完新手引导即写入,登录时据此跳过引导。"""
|
||||
|
||||
__tablename__ = "onboarding_completion"
|
||||
__table_args__ = (
|
||||
# 同账号、同设备只一条:重复 mark / 并发提交靠它幂等。
|
||||
UniqueConstraint("user_id", "device_id", name="uq_onboarding_user_device"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# 登录态用户。引导是登录后才走的,故必填(非空)、进唯一键。
|
||||
user_id: Mapped[int] = mapped_column(Integer, index=True, nullable=False)
|
||||
# 硬件级稳定设备标识(ANDROID_ID),卸载重装不变。
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
completed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"<OnboardingCompletion user={self.user_id} device={self.device_id}>"
|
||||
@@ -0,0 +1,35 @@
|
||||
"""新手引导完成标记的读写(按 user_id + device_id 去重)。
|
||||
|
||||
登录时 [is_completed] 判断该 (账号, 设备) 是否走过引导;走完引导时 [mark_completed] 写一条。
|
||||
device_id 为空(老客户端 / 取不到 ANDROID_ID)一律按"未完成"处理,即照常走引导,不误跳过。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.onboarding import OnboardingCompletion
|
||||
|
||||
|
||||
def is_completed(db: Session, *, user_id: int, device_id: str) -> bool:
|
||||
"""该 (账号, 设备) 是否已完成新手引导。device_id 为空 → 一律 False。"""
|
||||
if not device_id:
|
||||
return False
|
||||
stmt = select(OnboardingCompletion.id).where(
|
||||
OnboardingCompletion.user_id == user_id,
|
||||
OnboardingCompletion.device_id == device_id,
|
||||
)
|
||||
return db.execute(stmt).first() is not None
|
||||
|
||||
|
||||
def mark_completed(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
"""走完引导时写入。device_id 为空忽略;同 (账号, 设备) 已存在则幂等(靠唯一约束兜并发)。"""
|
||||
if not device_id:
|
||||
return
|
||||
db.add(OnboardingCompletion(user_id=user_id, device_id=device_id))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# 并发 / 重复提交撞唯一约束:已有行即视为成功。
|
||||
db.rollback()
|
||||
@@ -41,6 +41,11 @@ class TokenPair(BaseModel):
|
||||
|
||||
class TokenWithUser(TokenPair):
|
||||
user: UserOut
|
||||
onboarding_completed: bool = Field(
|
||||
False,
|
||||
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
|
||||
"true → 客户端登录后直接进首页,跳过引导。",
|
||||
)
|
||||
|
||||
|
||||
# ===== 极光一键登录 =====
|
||||
@@ -48,6 +53,10 @@ class TokenWithUser(TokenPair):
|
||||
class JverifyLoginRequest(BaseModel):
|
||||
login_token: str = Field(..., description="客户端 loginAuth 拿到的 loginToken", min_length=1)
|
||||
operator: str = Field("", description="CM/CU/CT,用于日志,可选")
|
||||
device_id: str = Field(
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
|
||||
|
||||
# ===== 短信验证码 =====
|
||||
@@ -65,6 +74,10 @@ class SmsSendResponse(BaseModel):
|
||||
class SmsLoginRequest(BaseModel):
|
||||
phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$")
|
||||
code: str = Field(..., min_length=4, max_length=8)
|
||||
device_id: str = Field(
|
||||
"", max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理",
|
||||
)
|
||||
|
||||
|
||||
# ===== Refresh =====
|
||||
|
||||
@@ -16,5 +16,12 @@ class ProfileUpdateRequest(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class OnboardingCompleteRequest(BaseModel):
|
||||
device_id: str = Field(
|
||||
..., min_length=1, max_length=64,
|
||||
description="硬件级设备标识(Android ANDROID_ID),与登录时一致,用于按 设备+账号 标记引导完成",
|
||||
)
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 美团券 ETL 定时任务 — 运维手册
|
||||
|
||||
> 对象:维护「美团 CPS 券抓取入库」这套定时任务的同事。
|
||||
> 🔒 登录信息(服务器 IP / 账号密码 / DB 密码)见**私密交接清单**,**不入库**。
|
||||
> 运行账号 `cps` **无 sudo**,本手册所有操作都不需要 root。
|
||||
|
||||
## 它是什么
|
||||
每小时全量抓美团 CPS 券(北京)→ upsert 进 PostgreSQL 的 `meituan_coupon` 表,作为 App「销量最高 / 智能推荐」两个 tab 的数据源。一轮约 140 秒、~2700 条;靠 `cps` 用户的 crontab 常驻,关终端也照跑。
|
||||
|
||||
## 代码 / 文件在哪(都在生产服务器上)
|
||||
| 项 | 路径 |
|
||||
|---|---|
|
||||
| 代码目录 | `/opt/shaguabijia-app-server` |
|
||||
| 抓取脚本 | `scripts/pull_meituan_coupons.py` |
|
||||
| Python(venv) | `/opt/shaguabijia-app-server/.venv/bin/python` |
|
||||
| 配置(美团凭证 / DB 连接串) | `/opt/shaguabijia-app-server/.env`(`MT_CPS_*` / `DATABASE_URL`)|
|
||||
| 日志 | `/home/cps/meituan_etl.log` |
|
||||
| 运行锁 | `/tmp/meituan_etl.lock`(根治版)或 `~/data/.meituan_etl.lock`(过渡版,见文末)|
|
||||
| 定时配置 | `cps` 用户 crontab(`crontab -l`)|
|
||||
| 入库表 | PostgreSQL `shaguabijia` 库的 `meituan_coupon` |
|
||||
|
||||
## 怎么看在不在跑 / 健康
|
||||
```bash
|
||||
crontab -l # 看定时任务(应有每小时第 17 分那条)
|
||||
tail -n 20 ~/meituan_etl.log # 看最近几轮:本轮完成 / 入库条数 / 有无报错
|
||||
# 查库存量与新鲜度(走 .env 连接串,不用输 DB 密码):
|
||||
cd /opt/shaguabijia-app-server && .venv/bin/python -c "from sqlalchemy import text; from app.db.session import engine; print(engine.connect().execute(text('select count(*) total, count(*) filter (where sale_volume_num is not null) sales, max(last_seen) last_run from meituan_coupon')).one())"
|
||||
```
|
||||
**健康标准**:`last_run` 在最近 1–2 小时内、`total` 两三千、日志最近一轮是 `本轮完成`(不是 `Permission denied` / Traceback)。
|
||||
|
||||
## 怎么启动
|
||||
定时任务已在 `cps` 的 crontab 里常驻,**随系统 cron 服务自动运行,无需手动启动**。若 crontab 被清空、要重新装回:`crontab -e`,粘下面这行存盘。
|
||||
|
||||
```cron
|
||||
# 根治版(锁文件已改到 /tmp 后用这条,推荐):
|
||||
17 * * * * cd /opt/shaguabijia-app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24 >> /home/cps/meituan_etl.log 2>&1
|
||||
```
|
||||
|
||||
**手动立即跑一轮**(不等下一个整点,比如刚发现库空):
|
||||
```bash
|
||||
cd /opt/shaguabijia-app-server && .venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24
|
||||
```
|
||||
前台跑、约 140 秒,出现 `本轮完成` 即成功。
|
||||
|
||||
## 怎么关闭(停用定时任务)
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
在那一行最前面加 `#` 注释掉(**推荐**,保留备查),存盘即停;要彻底删就删那一行。
|
||||
> ⚠️ 别用 `crontab -r`——它会**清空你所有 cron**,不只这一条。
|
||||
|
||||
## 怎么终止(杀掉正在跑的那一轮)
|
||||
```bash
|
||||
pgrep -af pull_meituan_coupons # 查在跑的进程和 PID
|
||||
pkill -f pull_meituan_coupons # 杀掉它(或 kill <PID>)
|
||||
rm -f /tmp/meituan_etl.lock ~/data/.meituan_etl.lock # 清掉残留锁,否则下一轮可能"跳过本轮"
|
||||
```
|
||||
> 脚本有 **30 分钟陈旧锁自动接管**机制:即使没手动清锁,30 分钟后下一轮也会自愈;急着立刻重跑才需手动 `rm` 锁。
|
||||
|
||||
## 调整频率
|
||||
`crontab -e` 改那行最前面的 cron 表达式:
|
||||
| 频率 | 表达式 |
|
||||
|---|---|
|
||||
| 每小时第 17 分(当前) | `17 * * * *` |
|
||||
| 每 30 分钟 | `*/30 * * * *` |
|
||||
| 每 2 小时 | `17 */2 * * *` |
|
||||
> ⚠️ 别调太密:一轮 ~140 秒,且要守美团限流(脚本已配速到 ~2–3 req/s,远低于 ~15 req/s 红线;402 内置退避重试)。**每小时一轮足够**,勿并行、勿调小页间 sleep。
|
||||
|
||||
## 脚本参数
|
||||
- `--once`:跑一轮(定时任务用这个)
|
||||
- `--loop --interval 600`:常驻循环,每 600 秒一轮(调试用)
|
||||
- `--prune-hours N`:清理超过 N 小时没再出现的旧券(默认 24;`0`=不清)
|
||||
- 锁文件位置可用环境变量 `MEITUAN_ETL_LOCK=/path/to.lock` 覆盖
|
||||
> 详见脚本头部注释:`head -30 scripts/pull_meituan_coupons.py`
|
||||
|
||||
## 已知问题与根治(运行锁权限)
|
||||
- **现象**:cron 报 `PermissionError: [Errno 13] ... 'data/.meituan_etl.lock'`,每轮抓不进数据、表为空。
|
||||
- **根因**:旧版脚本把运行锁建在代码目录的 `data/` 下,而 `data/` 属 root、`cps` 不可写(交接清单里"已给写权限"实际不成立)。
|
||||
- **根治(已在代码层修复)**:脚本把锁默认改到 `/tmp/meituan_etl.lock`(任何账号可写),并支持 `MEITUAN_ETL_LOCK` 覆盖。**部署该版本后,用上面「根治版」那条干净 crontab 即可。**
|
||||
- **过渡写法(根治版部署前的临时方案)**:crontab 里 `cd /home/cps` 把锁落到家目录、再用 `PYTHONPATH` 让代码仍能 import:
|
||||
```cron
|
||||
17 * * * * cd /home/cps && PYTHONPATH=/opt/shaguabijia-app-server /opt/shaguabijia-app-server/.venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24 >> /home/cps/meituan_etl.log 2>&1
|
||||
```
|
||||
根治版上线后,把这条换回上面的「根治版」标准写法即可。
|
||||
|
||||
## 注意事项
|
||||
- **只抓北京**:脚本里 `city_id` 硬编码,扩城市需改脚本。
|
||||
- **表行数 > 单轮抓取数**属正常:美团 `product_view_sign` 会轮换,跨轮累积;查询侧有 `DISTINCT ON(dedup_key)` 按「品牌\|名\|价」去重,用户看到的不重复;旧券 24h 后被 prune 清掉。
|
||||
- **别和 systemd 双跑**:仓库 `deploy/meituan-etl.{service,timer}` 是**未来由 root 纳入 systemd 正规部署**的备选,和本 user cron **二选一、别同时开**(否则重复抓、浪费限流额度)。当前生产用 user cron。
|
||||
- **改脚本 / 改部署**:走 git + PR,让有 root 的人 `/opt/deploy.sh` 部署(`git reset` + `alembic upgrade` + 重启);`cps` 账号改不了 `/opt` 下的代码。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 美团 CPS 券 ETL —— 单轮抓取入库,由 meituan-etl.timer 每小时触发(Linux 服务器用)。
|
||||
#
|
||||
# 仅用于 mentor 的 Linux 服务器;本机 Windows 开发无 systemd,直接手动跑脚本即可:
|
||||
# .venv\Scripts\python -m scripts.pull_meituan_coupons --once --prune-hours 24
|
||||
#
|
||||
# 部署(服务器):
|
||||
# sudo cp deploy/meituan-etl.{service,timer} /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload && sudo systemctl enable --now meituan-etl.timer
|
||||
#
|
||||
# 前置:DATABASE_URL 必须是 postgresql+psycopg://(ETL 的 upsert 是 PG 专用);
|
||||
# MT_CPS_APP_KEY/SECRET 已配;服务器上 MT_CPS_PROXY 留空(直连)。
|
||||
[Unit]
|
||||
Description=Meituan CPS coupon ETL (one-shot, driven by timer)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
WorkingDirectory=/opt/shaguabijia-app-server
|
||||
Environment="PATH=/opt/shaguabijia-app-server/.venv/bin:/usr/bin:/bin"
|
||||
EnvironmentFile=/opt/shaguabijia-app-server/.env
|
||||
ExecStart=/opt/shaguabijia-app-server/.venv/bin/python -m scripts.pull_meituan_coupons --once --prune-hours 24
|
||||
SyslogIdentifier=meituan-etl
|
||||
# 脚本自带 30min 文件锁;给 20min 硬超时,防卡死轮次长期占锁。
|
||||
TimeoutStartSec=1200
|
||||
|
||||
# 与主服务 shaguabijia-app-server.service 同款加固。
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/shaguabijia-app-server
|
||||
ProtectHome=true
|
||||
@@ -0,0 +1,14 @@
|
||||
# 每小时触发一次美团 CPS 券 ETL(Linux 服务器用)。
|
||||
# 见 meituan-etl.service 顶部注释的部署步骤。
|
||||
[Unit]
|
||||
Description=Run Meituan CPS coupon ETL hourly
|
||||
|
||||
[Timer]
|
||||
# 每小时第 5 分钟跑,避开整点其它定时任务扎堆。
|
||||
OnCalendar=*-*-* *:05:00
|
||||
# 服务器宕机/重启后,补跑错过的那一轮(而不是干等下一个整点)。
|
||||
Persistent=true
|
||||
AccuracySec=1min
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
|
||||
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **22 张业务表** + `alembic_version`(框架的迁移版本指针)。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **23 张业务表** + `alembic_version`(框架的迁移版本指针)。
|
||||
|
||||
---
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
|---|---|---|
|
||||
| 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 |
|
||||
| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 |
|
||||
| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 |
|
||||
|
||||
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
|
||||
@@ -54,7 +55,8 @@
|
||||
### C 端(App 用户触发)
|
||||
| 触发(用户动作 / endpoint / 回调) | 写入 | 操作 |
|
||||
|---|---|---|
|
||||
| 登录 `POST /auth/jverify-login`、`/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`) |
|
||||
| 登录 `POST /auth/jverify-login`、`/auth/sms/login` | `user` | C(首次=注册)/ U(`last_login_at`);并**读** `onboarding_completion` 回 `onboarding_completed` |
|
||||
| 走完新手引导 `POST /user/onboarding/complete` | `onboarding_completion`(C) | `(user_id, device_id)` 幂等,撞唯一约束即忽略 |
|
||||
| 改昵称 `PATCH /user/profile`、传头像 `POST /user/avatar` | `user` | U |
|
||||
| 注销 `DELETE /user` | `user` | U(软删:`phone→deleted_<id>`、`status=deleted`) |
|
||||
| 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U |
|
||||
@@ -120,6 +122,7 @@
|
||||
- **`comparison_record.store_name` ≈ `savings_record.shop_name`**:无 id 关联,按**店名字符串相等**给比价记录打「已下单」标记(瞬态,不写库)。两边店名同源 = 比价意图识别阶段的门店 query,语义=**店级**(同店比价多次会一并标已下单)。
|
||||
- **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。
|
||||
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。
|
||||
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
|
||||
|
||||
### ER 关系(文字版)
|
||||
```
|
||||
@@ -129,6 +132,7 @@ user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_reco
|
||||
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
|
||||
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
|
||||
price_report, feedback }
|
||||
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
|
||||
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
|
||||
admin_user ─1:N─ admin_audit_log
|
||||
app_config (独立, 无外键, key 为主键)
|
||||
|
||||
+11
-2
@@ -3,18 +3,22 @@
|
||||
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
|
||||
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
|
||||
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
|
||||
> 最后更新:2026-06-07(补全 22 张业务表 + 新增 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
|
||||
> 最后更新:2026-06-10(新增 `onboarding_completion` 新手引导完成表 → 23 张业务表)
|
||||
=======
|
||||
|
||||
|
||||
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
|
||||
|
||||
---
|
||||
|
||||
## 表总览(22 张业务表 + `alembic_version` 框架表)
|
||||
## 表总览(23 张业务表 + `alembic_version` 框架表)
|
||||
|
||||
### 账号 / 反馈
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) |
|
||||
| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) |
|
||||
| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) |
|
||||
|
||||
### 钱包 / 福利(看广告赚钱闭环)
|
||||
@@ -41,6 +45,11 @@
|
||||
| `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) |
|
||||
| `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) |
|
||||
|
||||
### 美团 CPS 券缓存
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
| `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) |
|
||||
|
||||
### 首页门面数据
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
|---|---|---|---|
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# meituan_coupon — 美团 CPS 券本地缓存
|
||||
|
||||
> 模型 `app/models/meituan_coupon.py` · ETL `scripts/pull_meituan_coupons.py` · 接口 [meituan-feed](../api/meituan-feed.md)(rec/distance)/ [meituan-top-sales](../api/meituan-top-sales.md)(sales)· [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
把美团联盟 CPS 的券**定时抓进本地库**,供「智能推荐(佣金≥3%)」「销量最高」从库里捞、本地去重排序,不每次实时打美团(美团对销量排序支持差、有 402 限流和召回上限)。北京单城试点。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C/U(ETL upsert)**:`scripts/pull_meituan_coupons.py` 每小时全量抓 3 路(`search_waimai` 搜「外卖」/ `search_meishi` 搜「美食」/ `store_supply` 到店多业务线供给),按 `(source, product_view_sign)` upsert(PG `ON CONFLICT DO UPDATE`),`last_seen` 每轮刷新。
|
||||
- **R(读)**:
|
||||
- rec(智能推荐):`WHERE commission_percent>=3.0` → `DISTINCT ON(dedup_key)` 取佣金最高 → 按 `sale_volume_num` 降序分页。
|
||||
- sales(销量最高):`WHERE sale_volume_num IS NOT NULL` → `DISTINCT ON(dedup_key)` 取销量最高 → 按销量降序分页。
|
||||
- **D(清理)**:见下「过期清理策略」。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `source` | String(16) | index, NOT NULL | 召回来源:`search_waimai` / `search_meishi` / `store_supply` |
|
||||
| `platform` | Integer | NOT NULL | 1 外卖/到家, 2 到店 |
|
||||
| `biz_line` | Integer | nullable | 到店子类:1到餐 2到综 3酒店 4门票 |
|
||||
| `city_id` | String(32) | index, NOT NULL | 城市 id(当前恒为北京 `WKV2HMXUEK634WP64CUCUQGM64`) |
|
||||
| `product_view_sign` | String(128) | NOT NULL | 换链主键;**按召回渠道生成、跨渠道会变**,不能当商品全局 id |
|
||||
| `sku_view_id` | String(128) | nullable | |
|
||||
| `name` | String(256) | nullable | 商品名(解析截断 `[:256]`) |
|
||||
| `brand_name` | String(128) | index, nullable | 品牌名(`[:128]`) |
|
||||
| `sell_price_cents` | Integer | nullable | 现价(分) |
|
||||
| `original_price_cents` | Integer | nullable | 原价(分) |
|
||||
| `head_url` | String(512) | nullable | 头图(去 `@` 后缀,`[:512]`) |
|
||||
| `sale_volume` | String(32) | nullable | 原始销量档:如「热销1w+」 |
|
||||
| `sale_volume_num` | Integer | index, nullable | 销量排序数值(取下界):`1w+` → 10000 |
|
||||
| `commission_percent` | Float | index, nullable | 佣金比例,**数值即百分数**(1.4 = 1.4%);美团原值 140 ÷ 100 |
|
||||
| `commission_amount_cents` | Integer | nullable | 预估佣金(分) |
|
||||
| `poi_name` | String(128) | nullable | 最近门店名(`[:128]`) |
|
||||
| `available_poi_num` | Integer | nullable | 可用门店数 |
|
||||
| `delivery_distance_m` | Float | nullable | 距离(米);**相对抓取时的城市默认点**,对用户位置无意义,rec 接口置空不返 |
|
||||
| `dedup_key` | String(64) | index, NOT NULL | 跨源去重键 = `md5(brand_name\|name\|sell_price_cents)` |
|
||||
| `raw` | JSON / JSONB | NOT NULL, default `{}` | 整条原始返回(避免漏字段重抓);PG 上为 JSONB |
|
||||
| `first_seen` | DateTime(tz) | server_default now() | 首次抓到 |
|
||||
| `last_seen` | DateTime(tz) | index, server_default now() | 最近一轮抓到(**过期清理依据**) |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
## 索引与约束
|
||||
- 唯一约束 `uq_meituan_coupon_source_sign`(`source`, `product_view_sign`)—— ETL upsert 的冲突目标。
|
||||
- 单列 index:`source`、`city_id`、`brand_name`、`sale_volume_num`、`commission_percent`、`dedup_key`、`last_seen`。
|
||||
- **字段核对(2026-06-10)**:模型 ↔ 迁移(`meituan_coupon_table`)↔ ETL 解析三处一致;解析截断宽度均 ≤ 列宽(name256 / brand128 / poi128 / head512 / sign128,`dedup_key` = md5 32 字符 ≤ 64)。
|
||||
- **索引评估**:`rec` / `sales` 查询的理想索引是复合 `(dedup_key, sale_volume_num DESC)` 与 `(dedup_key, commission_percent DESC)`(匹配 `DISTINCT ON`);但当前**单城几千行,seq scan 即亚毫秒**,现有单列索引已够用,复合索引留作多城 / 放量时再加,避免过早优化。
|
||||
|
||||
## 过期清理策略
|
||||
- ETL 每轮(每小时)末尾按 **`last_seen` 的 TTL** 清理:`DELETE WHERE last_seen < now() - prune_hours`(默认 24h,`--prune-hours` 可调,0=不清)。
|
||||
- 仍在架的券每小时被刷新 `last_seen`,**永不被清**;只有下架 / `sign` 轮换的残留超 24h 宽限才删。宽限期用于吸收几次抓取失败。
|
||||
- 券自身到期**无需单独处理**:到期后美团不再返回 → 自然 `last_seen` 老化被清。
|
||||
- **护栏(2026-06-10)**:prune 仅在**本轮确有入库(`total>0`)**时执行。美团整体故障时本轮可能 0 入库(脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 `last_seen` **把全表删空**;加 `total>0` 护栏后,0 入库轮跳过清理并打日志。
|
||||
- 表只增量 upsert + 少量 delete,PG autovacuum 足以回收死元组,当前规模无需手动 VACUUM。
|
||||
|
||||
## 注意
|
||||
- **仅 PostgreSQL**:ETL upsert(`postgresql.insert(...).on_conflict_do_update`)与读取的 `DISTINCT ON` 都是 PG 专用语法;库若是 sqlite,ETL 灌不进、rec/sales 接口报错。prod `DATABASE_URL` 必须 `postgresql+psycopg://`。
|
||||
- **`dedup_key` 不含城市**:`md5(brand|name|price)`,单城没问题;将来多城会把异城同品合并,需把 `city_id` 纳入 key。
|
||||
- `product_view_sign` 跨渠道会变,**不能当商品全局唯一 id**:存储按 `(source, sign)`、查询按 `dedup_key`。
|
||||
@@ -0,0 +1,44 @@
|
||||
# onboarding_completion — 新手引导完成标记(按 设备 + 账号 去重)
|
||||
|
||||
> 模型 `app/models/onboarding.py` · 仓库 `app/repositories/onboarding.py` · 迁移 `alembic/versions/onboarding_completion_table.py`(revision `onboarding_completion`) · 读 `POST /api/v1/auth/jverify-login`·`/sms/login` · 写 `POST /api/v1/user/onboarding/complete` · 测试 `tests/test_onboarding.py` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
记录「**某账号在某台设备上已走完新手引导**」,一条 `(user_id, device_id)`。用途单一:决定**登录后要不要再展示新手引导教程**——本表有这条 → 跳过引导直接进首页;没有 → 走引导。
|
||||
|
||||
**为什么要落后端**:此前「引导只跑一次」只存客户端本地 `OnboardingPrefs`(SharedPreferences),**卸载重装即丢** → 重装会重弹引导。产品需求是「同设备 + 同账号只触发一次,且跨卸载重装」,本地存不住,故把"真相"放到服务端,按 **账号 + 硬件设备号** 去重。
|
||||
|
||||
**去重维度 = 设备 + 账号**(产品决议):
|
||||
|
||||
| 场景 | 命中本表? | 是否再展示引导 |
|
||||
|---|---|---|
|
||||
| 新账号(从未走过) | 否 | ✅ 展示 |
|
||||
| 同账号 + **同设备**(卸载重装 / 退登重登) | 是 | ❌ 跳过 |
|
||||
| 同账号 + **换新设备** | 否 | ✅ 展示(新机需重新授权悬浮窗/无障碍等) |
|
||||
|
||||
> ⚠️ `device_id` 用客户端**硬件级稳定标识** `Settings.Secure.ANDROID_ID`(`util/DeviceId.hardwareId()`,同签名 app 卸载重装不变、仅恢复出厂重置),**不是**领券/比价用的 per-install `DeviceId.get()`(存 SP、重装会变)。两套 device_id 不互通,别混用。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **R(读)**:登录 `POST /auth/jverify-login`、`/auth/sms/login` —— 客户端在登录请求体里带 `device_id`,`onboarding_repo.is_completed(user_id, device_id)` 查本表有无行,结果塞进登录响应 `TokenWithUser.onboarding_completed`(true=已完成)。客户端 `AuthRepository.persist` 据此回写本地 `OnboardingPrefs`(只回写 true),既有 gating(`OnboardingHost` / `AppNavHost`)逻辑不变。
|
||||
- **C(插入)**:走完引导最后一步(后台耗电屏)时,客户端 `POST /api/v1/user/onboarding/complete`(带 Bearer + `device_id`)→ `onboarding_repo.mark_completed` 插一行。**幂等**:同 `(user_id, device_id)` 已存在则撞唯一约束,`IntegrityError` 被 rollback 吞掉、视为成功;`device_id` 为空一律忽略不写。best-effort:客户端上报失败不阻断进首页(本地已先标记)。
|
||||
- **U / D**:无。一台设备 + 一账号一条,完成即永久;账号注销(软删 `user`)**不清本表**(留着无害——user_id 指向的是已 `deleted` 的行;同手机号重新注册是新 user.id,自然重新走引导)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | index, NOT NULL | 归属账号 → `user.id`(语义外键,未建 DB 级 FK,同 `coupon_*` 设备表) |
|
||||
| `device_id` | String(64) | NOT NULL | 硬件级设备标识 = 客户端 `Settings.Secure.ANDROID_ID`;卸载重装不变 |
|
||||
| `completed_at` | DateTime(tz) | server_default now() | 标记完成的时间 |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一:一账号在 N 台设备各一条)。**无硬 FK 约束**(同 `coupon_claim_record` / `coupon_prompt_engagement`),靠业务字段对齐。
|
||||
- 判断维度 = `(user_id, device_id)` 组合等值查。
|
||||
- `device_id` 与客户端 `DeviceId.hardwareId()`(ANDROID_ID)同源;**不**与领券侧 per-install `device_id` 互通。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`(`ix_onboarding_completion_user_id`);UNIQUE(`user_id`, `device_id`) = `uq_onboarding_user_device`(防同账号同设备重复写 + 兜并发 upsert)。
|
||||
|
||||
## 注意
|
||||
- **去重维度是「设备 + 账号」**,不是纯账号:同一个人在新手机上登录会重新看引导(产品预期:新机要重新授权权限)。若日后想改「纯账号、换机也不弹」,把 `is_completed`/`mark_completed` 去掉 `device_id` 维度即可。
|
||||
- `device_id` 取不到 / 为已知脏值(`9774d56d682e549c`)时客户端退回 per-install id,这类极少数设备重装会重弹一次(尽力而为)。
|
||||
- 本地 `OnboardingPrefs` 现仅作**同安装快速跳过缓存**;真相以本表为准,每次登录响应同步。
|
||||
- 客户端实现:`ui/onboarding/OnboardingViewModel.kt`(上报)、`data/auth/AuthRepository.kt`(读 + 同步本地)、`util/DeviceId.kt`(ANDROID_ID)。
|
||||
@@ -0,0 +1,35 @@
|
||||
@echo off
|
||||
REM Local dev startup script (Windows) - mirror of run.sh
|
||||
REM
|
||||
REM Usage:
|
||||
REM cd shaguabijia-app-server
|
||||
REM run.bat
|
||||
REM
|
||||
REM Listens on 0.0.0.0:8770 (works for both real device via adb reverse
|
||||
REM and LAN). Auto-reload on code change.
|
||||
REM
|
||||
REM Prerequisite (first time):
|
||||
REM conda activate pricebot ^&^& pip install -e .
|
||||
REM copy .env.example .env ^&^& fill JWT_SECRET_KEY
|
||||
REM
|
||||
REM This file is the Windows-native peer of run.sh. Keeping run.sh untouched
|
||||
REM avoids CRLF/encoding fights every time someone bash-runs the .sh on Win.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
if not exist .env (
|
||||
echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^)
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist data mkdir data
|
||||
|
||||
REM Build/upgrade SQLite schema (idempotent; no-op if already at head)
|
||||
call alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo [X] alembic upgrade head failed
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Long-running foreground process. Ctrl+C to stop.
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8770 --reload
|
||||
@@ -22,6 +22,7 @@ import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -44,7 +45,10 @@ PAGE_SIZE = 20
|
||||
MAX_PAGES = 80 # 单路安全上限(搜索 ~52 页、供给 ~70 页)
|
||||
PAGE_SLEEP = 0.35 # 页间配速,缓解 402
|
||||
RETRY = 7
|
||||
LOCK_FILE = "data/.meituan_etl.lock"
|
||||
# 运行锁默认放系统临时目录(任何账号可写),不依赖代码目录 data/ 的写权限——
|
||||
# 无 sudo 部署时 data/ 常属 root,cps 写不了会导致每轮 PermissionError、cron 抓不进数据。
|
||||
# 需要指定位置时用环境变量 MEITUAN_ETL_LOCK 覆盖。
|
||||
LOCK_FILE = os.environ.get("MEITUAN_ETL_LOCK") or os.path.join(tempfile.gettempdir(), "meituan_etl.lock")
|
||||
LOCK_STALE_SEC = 30 * 60 # 锁超过 30min 视为陈旧(进程异常退出残留),自动接管
|
||||
|
||||
SOURCES = [
|
||||
@@ -288,8 +292,10 @@ def run_once(prune_hours: int = 24) -> None:
|
||||
total += up
|
||||
print(f" {src['label']:18} 抓{len(items):5} 解析{len(parsed):5} "
|
||||
f"入库{up:5} (本源去重{dup:3}) {time.time() - ts:4.0f}s")
|
||||
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限
|
||||
if prune_hours and prune_hours > 0:
|
||||
# 清理长期未再出现的陈旧券(美团 sign 轮换 / 券下架后的残留),默认 24h 宽限。
|
||||
# 护栏:仅在本轮确有入库(total>0)时才清理。美团整体故障时本轮可能 0 入库
|
||||
# (脚本不抛异常、只是抓回空),若仍照常 prune,连续故障会按 last_seen 把全表删空。
|
||||
if prune_hours and prune_hours > 0 and total > 0:
|
||||
cutoff = now - timedelta(hours=prune_hours)
|
||||
pruned = db.execute(
|
||||
delete(MeituanCoupon).where(MeituanCoupon.last_seen < cutoff)
|
||||
@@ -297,6 +303,8 @@ def run_once(prune_hours: int = 24) -> None:
|
||||
db.commit()
|
||||
if pruned:
|
||||
print(f" 清理陈旧券(>{prune_hours}h 未再出现): {pruned} 条")
|
||||
elif prune_hours and prune_hours > 0 and total == 0:
|
||||
print(" 本轮 0 入库(疑似上游故障),跳过清理以防误删全表")
|
||||
cnt = db.execute(select(func.count()).select_from(MeituanCoupon)).scalar()
|
||||
print(f"[{datetime.now():%H:%M:%S}] 本轮完成: 入库 {total} 条, 表总计 {cnt} 行, "
|
||||
f"用时 {time.time() - t0:.0f}s")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""新手引导"按 设备+账号 只触发一次"的后端闭环测试。
|
||||
|
||||
覆盖:
|
||||
- 全新 (账号, 设备) 登录 → onboarding_completed=False(照常走引导)
|
||||
- 标记完成后,同 (账号, 设备) 再登录 → True(跳过引导,跨"重装"= 同 device_id 重新登录)
|
||||
- 同账号换设备(device_id 不同)→ False(新设备重新引导)
|
||||
- 标记幂等;缺 device_id 时按未完成处理
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _login(client, phone: str, device_id: str | None):
|
||||
body = {"phone": phone, "code": "123456"}
|
||||
if device_id is not None:
|
||||
body["device_id"] = device_id
|
||||
r = client.post("/api/v1/auth/sms/login", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _mark_complete(client, access: str, device_id: str):
|
||||
return client.post(
|
||||
"/api/v1/user/onboarding/complete",
|
||||
json={"device_id": device_id},
|
||||
headers={"Authorization": f"Bearer {access}"},
|
||||
)
|
||||
|
||||
|
||||
def test_onboarding_once_per_device_and_account(client) -> None:
|
||||
phone = "13800138001"
|
||||
dev_a = "android-id-aaaa"
|
||||
dev_b = "android-id-bbbb"
|
||||
|
||||
# ① 全新账号+设备:未完成
|
||||
data = _login(client, phone, dev_a)
|
||||
assert data["onboarding_completed"] is False
|
||||
access = data["access_token"]
|
||||
|
||||
# ② 走完引导 → 标记完成(幂等:再标一次也 200)
|
||||
assert _mark_complete(client, access, dev_a).status_code == 200
|
||||
assert _mark_complete(client, access, dev_a).json()["ok"] is True
|
||||
|
||||
# ③ 同账号 + 同设备(模拟卸载重装后重新登录)→ 已完成,跳过引导
|
||||
assert _login(client, phone, dev_a)["onboarding_completed"] is True
|
||||
|
||||
# ④ 同账号 + 新设备 → 仍未完成,新设备重新走引导
|
||||
assert _login(client, phone, dev_b)["onboarding_completed"] is False
|
||||
|
||||
|
||||
def test_onboarding_mark_on_one_device_does_not_leak_to_other(client) -> None:
|
||||
"""B 设备标记完成,不影响 A 设备(逐设备独立)。"""
|
||||
phone = "13800138002"
|
||||
data_a = _login(client, phone, "devA")
|
||||
assert data_a["onboarding_completed"] is False
|
||||
|
||||
data_b = _login(client, phone, "devB")
|
||||
assert _mark_complete(client, data_b["access_token"], "devB").status_code == 200
|
||||
|
||||
assert _login(client, phone, "devB")["onboarding_completed"] is True
|
||||
assert _login(client, phone, "devA")["onboarding_completed"] is False
|
||||
|
||||
|
||||
def test_onboarding_missing_device_id_treated_as_incomplete(client) -> None:
|
||||
"""老客户端不传 device_id:按未完成处理,不报错、不误跳过。"""
|
||||
phone = "13800138003"
|
||||
data = _login(client, phone, device_id=None)
|
||||
assert data["onboarding_completed"] is False
|
||||
Reference in New Issue
Block a user