diff --git a/alembic/versions/9b894f5fff05_merge_user_force_onboarding_and_.py b/alembic/versions/9b894f5fff05_merge_user_force_onboarding_and_.py new file mode 100644 index 0000000..f1044b9 --- /dev/null +++ b/alembic/versions/9b894f5fff05_merge_user_force_onboarding_and_.py @@ -0,0 +1,26 @@ +"""merge user_force_onboarding and comparison_idx_coupon_daily heads + +Revision ID: 9b894f5fff05 +Revises: d4b87c5847de, user_force_onboarding +Create Date: 2026-06-10 22:49:02.248506 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '9b894f5fff05' +down_revision: Union[str, Sequence[str], None] = ('d4b87c5847de', 'user_force_onboarding') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/comparison_status_created_idx.py b/alembic/versions/comparison_status_created_idx.py new file mode 100644 index 0000000..9be8951 --- /dev/null +++ b/alembic/versions/comparison_status_created_idx.py @@ -0,0 +1,48 @@ +"""add composite index (status, created_at) on comparison_record + +首页轮播 feed(按 status='success' 过滤 + created_at 近期排序)与省钱战绩聚合 +都吃这个过滤+排序;复合索引避免随数据量增大退化成全表扫。 + +Revision ID: comparison_status_created_idx +Revises: a8c47fc4dc39 +Create Date: 2026-06-10 +""" + +from __future__ import annotations + +from alembic import op + +revision = "comparison_status_created_idx" +down_revision = "a8c47fc4dc39" +branch_labels = None +depends_on = None + +INDEX_NAME = "ix_comparison_status_created" + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + # PG 上 comparison_record 已有数据量, 普通 CREATE INDEX 持表写锁会阻塞线上写入; + # 用 CONCURRENTLY 不锁表(须脱离事务, autocommit_block 切到自动提交)。 + with op.get_context().autocommit_block(): + op.create_index( + INDEX_NAME, "comparison_record", ["status", "created_at"], + unique=False, postgresql_concurrently=True, + ) + else: + op.create_index( + INDEX_NAME, "comparison_record", ["status", "created_at"], unique=False + ) + + +def downgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + with op.get_context().autocommit_block(): + op.drop_index( + INDEX_NAME, table_name="comparison_record", + postgresql_concurrently=True, + ) + else: + op.drop_index(INDEX_NAME, table_name="comparison_record") diff --git a/alembic/versions/d4b87c5847de_merge_comparison_idx_and_coupon_daily_.py b/alembic/versions/d4b87c5847de_merge_comparison_idx_and_coupon_daily_.py new file mode 100644 index 0000000..6eb0aea --- /dev/null +++ b/alembic/versions/d4b87c5847de_merge_comparison_idx_and_coupon_daily_.py @@ -0,0 +1,26 @@ +"""merge comparison idx and coupon daily completion heads + +Revision ID: d4b87c5847de +Revises: comparison_status_created_idx, onboarding_completion +Create Date: 2026-06-10 16:45:50.285917 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd4b87c5847de' +down_revision: Union[str, Sequence[str], None] = ('comparison_status_created_idx', 'onboarding_completion') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/alembic/versions/onboarding_completion_table.py b/alembic/versions/onboarding_completion_table.py new file mode 100644 index 0000000..bd6f725 --- /dev/null +++ b/alembic/versions/onboarding_completion_table.py @@ -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') diff --git a/alembic/versions/user_force_onboarding.py b/alembic/versions/user_force_onboarding.py new file mode 100644 index 0000000..3ed812f --- /dev/null +++ b/alembic/versions/user_force_onboarding.py @@ -0,0 +1,39 @@ +"""add user.force_onboarding + +运营后台「一键开启新手引导」:置 true → 用户下次启动 App 被强制重走引导教程(即便本地已完成), +走完引导(/api/v1/user/onboarding/complete)后端自动清回 false,不无限循环。 +见 app/models/user.py force_onboarding 列、admin POST /admin/api/users/{id}/force-onboarding。 + +Revision ID: user_force_onboarding +Revises: onboarding_completion +Create Date: 2026-06-10 21:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "user_force_onboarding" +down_revision: Union[str, Sequence[str], None] = "onboarding_completion" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # sa.false() 渲染成 PG 的 false / SQLite 的 0,两端兼容(同 debug_trace_enabled)。 + op.add_column( + "user", + sa.Column( + "force_onboarding", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + ) + + +def downgrade() -> None: + op.drop_column("user", "force_onboarding") diff --git a/app/admin/repositories/mutations.py b/app/admin/repositories/mutations.py index 1f7d230..e1a2e91 100644 --- a/app/admin/repositories/mutations.py +++ b/app/admin/repositories/mutations.py @@ -42,6 +42,21 @@ def set_user_debug_trace( return user +def set_user_force_onboarding( + db: Session, user: User, *, enabled: bool, commit: bool = True +) -> User: + """运营「一键开启/取消新手引导」:置 user.force_onboarding。开启后该用户下次启动 App 被强制 + 重走引导教程,走完即由 /onboarding/complete 自动清回 false。同 set_user_debug_trace:支持 + commit=False 让 router 把业务写 + 审计写放进同一事务。""" + user.force_onboarding = enabled + if commit: + db.commit() + db.refresh(user) + else: + db.flush() + return user + + def update_feedback_status( db: Session, feedback: Feedback, *, status: str, commit: bool = True ) -> Feedback: diff --git a/app/admin/routers/ops_marquee_seed.py b/app/admin/routers/ops_marquee_seed.py index 53084ff..4c7c153 100644 --- a/app/admin/routers/ops_marquee_seed.py +++ b/app/admin/routers/ops_marquee_seed.py @@ -12,6 +12,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from app.admin.audit import write_audit from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role from app.admin.schemas.ops_marquee_seed import ( + OpsMarqueeSeedBatchDelete, + OpsMarqueeSeedBatchEnable, OpsMarqueeSeedBulkCreate, OpsMarqueeSeedCreate, OpsMarqueeSeedOut, @@ -111,6 +113,40 @@ def bulk_generate( return [_out(s) for s in seeds] +@router.post("/batch-delete", summary="批量删除种子(带审计)") +def batch_delete_seeds( + body: OpsMarqueeSeedBatchDelete, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + n = ops_marquee.batch_delete(db, body.ids, commit=False) + write_audit( + db, admin, action="ops_marquee_seed.batch_delete", target_type="ops_marquee_seed", + target_id=None, detail={"ids": body.ids, "deleted": n}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + return {"deleted": n} + + +@router.post("/batch-enable", summary="批量启用 / 停用种子(带审计)") +def batch_enable_seeds( + body: OpsMarqueeSeedBatchEnable, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> dict: + n = ops_marquee.batch_set_enabled(db, body.ids, body.enabled, commit=False) + write_audit( + db, admin, action="ops_marquee_seed.batch_enable", target_type="ops_marquee_seed", + target_id=None, detail={"ids": body.ids, "enabled": body.enabled, "updated": n}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + return {"updated": n} + + @router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)") def update_seed( seed_id: int, diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index 6da09b9..08d951c 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -12,8 +12,10 @@ from app.admin.schemas.common import CursorPage, OkResponse from app.admin.schemas.user import ( AdminUserListItem, AdminUserOverview, + GrantCashRequest, GrantCoinsRequest, SetDebugTraceRequest, + SetForceOnboardingRequest, SetUserStatusRequest, ) from app.models.admin import AdminUser @@ -100,6 +102,33 @@ def set_user_debug_trace( return OkResponse() +@router.post("/{user_id}/force-onboarding", response_model=OkResponse, summary="一键开启/取消新手引导") +def set_user_force_onboarding( + user_id: int, + body: SetForceOnboardingRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> OkResponse: + """运营「一键开启新手引导」:置 user.force_onboarding。开启后该用户下次启动 App(/me 带出此字段) + 会被强制重走引导教程——即便此前已看完;走完引导后端自动清回 false,不无限循环。 + 仅对支持 force_onboarding 的 App 版本生效(老版本忽略该字段)。""" + user = user_repo.get_user_by_id(db, user_id) + if user is None: + raise HTTPException(status_code=404, detail="用户不存在") + if user.status == "deleted": + raise HTTPException(status_code=400, detail="已注销账号不可操作") + before = user.force_onboarding + # 业务写 + 审计写同一事务(commit=False),最后一起 commit(同 set_user_status / debug-trace) + mutations.set_user_force_onboarding(db, user, enabled=body.enabled, commit=False) + write_audit( + db, admin, action="user.force_onboarding.set", target_type="user", target_id=user_id, + detail={"before": before, "after": body.enabled}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() + + @router.post("/{user_id}/coins", response_model=OkResponse, summary="手动增减金币(带审计)") def grant_user_coins( user_id: int, @@ -132,3 +161,42 @@ def grant_user_coins( ) db.commit() return OkResponse() + + +@router.post("/{user_id}/cash", response_model=OkResponse, summary="手动增减现金(带审计)") +def grant_user_cash( + user_id: int, + body: GrantCashRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("finance"))], + db: AdminDb, +) -> OkResponse: + """给指定用户增/减现金(分)。正=发放、负=扣减;主要用于让无现金用户直接测试提现。""" + if body.amount_cents == 0: + raise HTTPException(status_code=400, detail="amount_cents 不能为 0") + user = user_repo.get_user_by_id(db, user_id) + if user is None: + raise HTTPException(status_code=404, detail="用户不存在") + # 负数扣减时不允许扣成负余额(运营误操作保护) + if body.amount_cents < 0: + acc_now = wallet_repo.get_or_create_account(db, user_id, commit=False) + if acc_now.cash_balance_cents + body.amount_cents < 0: + raise HTTPException( + status_code=400, detail=f"扣减后现金为负(当前余额 {acc_now.cash_balance_cents} 分)" + ) + biz_type = "admin_grant" if body.amount_cents > 0 else "admin_deduct" + # grant_cash 只 flush 不 commit;审计同 commit=False;最后一起 commit → 原子(改钱+留痕) + acc, _ = wallet_repo.grant_cash( + db, user_id, body.amount_cents, biz_type=biz_type, remark=f"admin:{body.reason}"[:128], + ) + write_audit( + db, admin, action="user.cash.grant", target_type="user", target_id=user_id, + detail={ + "amount_cents": body.amount_cents, + "balance_after_cents": acc.cash_balance_cents, + "reason": body.reason, + }, + ip=get_client_ip(request), commit=False, + ) + db.commit() + return OkResponse() diff --git a/app/admin/schemas/ops_marquee_seed.py b/app/admin/schemas/ops_marquee_seed.py index 6095318..db8c56d 100644 --- a/app/admin/schemas/ops_marquee_seed.py +++ b/app/admin/schemas/ops_marquee_seed.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from pydantic import BaseModel +from pydantic import BaseModel, Field class OpsMarqueeSeedOut(BaseModel): @@ -42,6 +42,17 @@ class OpsMarqueeSeedBulkCreate(BaseModel): enabled: bool = True +class OpsMarqueeSeedBatchDelete(BaseModel): + """批量删除选中的种子。""" + ids: list[int] = Field(..., max_length=500) # 上限防超大列表(审计行/IN 子句过大) + + +class OpsMarqueeSeedBatchEnable(BaseModel): + """批量启用 / 停用选中的种子。""" + ids: list[int] = Field(..., max_length=500) + enabled: bool + + class OpsSavingsFeedPreviewItem(BaseModel): masked_user: str saved_amount_cents: int diff --git a/app/admin/schemas/ops_stat_config.py b/app/admin/schemas/ops_stat_config.py index 0a3a473..5172f3e 100644 --- a/app/admin/schemas/ops_stat_config.py +++ b/app/admin/schemas/ops_stat_config.py @@ -20,7 +20,7 @@ class OpsStatItemOut(BaseModel): random_kind: str # mult(×倍率) / add(+绝对增量) random_step_min: int # 绝对增量区间(基础单位;total_saved 为分) random_step_max: int - real_offset: int # 真实值模式基数偏移(基础单位) + real_offset: int # 真实值模式保底值(基础单位):展示=max(真实,保底)。列名沿用 real_offset allow_decrease: bool # 是否允许展示值下降(默认 false = 只增不减) random_current: int | None = None random_last_tick_at: str | None = None diff --git a/app/admin/schemas/user.py b/app/admin/schemas/user.py index 304c8b7..c615a9e 100644 --- a/app/admin/schemas/user.py +++ b/app/admin/schemas/user.py @@ -16,6 +16,8 @@ class AdminUserListItem(BaseModel): register_channel: str status: str debug_trace_enabled: bool = False + # 运营「一键开启新手引导」:true=该用户下次启动 App 被强制重走引导(走完自动清回 false) + force_onboarding: bool = False wechat_openid: str | None = None created_at: datetime last_login_at: datetime @@ -42,6 +44,11 @@ class GrantCoinsRequest(BaseModel): reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") +class GrantCashRequest(BaseModel): + amount_cents: int = Field(..., description="现金变动(分):正=增加,负=扣减(不可为 0)") + reason: str = Field(..., min_length=1, max_length=128, description="操作原因(必填,入审计)") + + class SetUserStatusRequest(BaseModel): status: Literal["active", "disabled"] = Field( ..., description="active=解封 / disabled=封禁(注销 deleted 不走此接口)" @@ -50,3 +57,9 @@ class SetUserStatusRequest(BaseModel): class SetDebugTraceRequest(BaseModel): enabled: bool = Field(..., description="是否给该用户开「复制调试链接」权限") + + +class SetForceOnboardingRequest(BaseModel): + enabled: bool = Field( + ..., description="true=一键开启(强制该用户下次启动 App 重走新手引导)/ false=取消" + ) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index fd31641..2ba8521 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -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 ===================== diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index 42bcddd..93279b3 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -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") diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index 9d7510e..5bb0031 100644 --- a/app/api/v1/meituan.py +++ b/app/api/v1/meituan.py @@ -8,12 +8,12 @@ import logging from concurrent.futures import ThreadPoolExecutor, as_completed from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy import nullslast, select +from sqlalchemy.orm import Session, aliased from app.core.config import settings from app.db.session import get_db -from app.integrations.meituan import MeituanCpsError, get_referral_link, query_coupon +from app.integrations.meituan import MeituanCpsError, _call, get_referral_link, query_coupon from app.models.meituan_coupon import MeituanCoupon from app.schemas.meituan import ( CouponCard, @@ -34,7 +34,7 @@ router = APIRouter(prefix="/api/v1/meituan", tags=["meituan-cps"]) @router.post("/coupons", response_model=CouponListResponse, summary="券列表(为您推荐)") def list_coupons(req: CouponListRequest) -> CouponListResponse: if not settings.mt_cps_configured: - return CouponListResponse(items=[], has_next=False, search_id=None) + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") logger.info("[coupons] lon=%.6f lat=%.6f topic=%s", req.longitude, req.latitude, req.list_topic_id) try: raw = query_coupon( @@ -50,14 +50,21 @@ def list_coupons(req: CouponListRequest) -> CouponListResponse: page_size=req.page_size, ) except MeituanCpsError as e: - logger.error("query_coupon failed: %s", e) - raise HTTPException(status_code=502, detail=f"meituan: {e}") from e + # 软降级:不再抛 502(避免前端弹报错 toast),返空 + degraded,前端显示「服务繁忙」。 + logger.warning("[coupons] query_coupon 失败,降级返空: %s", e) + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") - items = [CouponCard.from_raw(it) for it in (raw.get("data") or [])] + items: list[CouponCard] = [] + for it in (raw.get("data") or []): + try: + items.append(CouponCard.from_raw(it)) + except Exception: # noqa: BLE001 + continue return CouponListResponse( items=items, has_next=raw.get("hasNext", False), search_id=raw.get("searchId"), + status="ok" if items else "empty", ) @@ -93,59 +100,136 @@ def _commission_pct(card: CouponCard) -> float: @router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近") -def feed(req: FeedRequest) -> FeedResponse: - if not settings.mt_cps_configured: - return FeedResponse(items=[], has_next=False, page=req.page) +def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: lon, lat = req.longitude, req.latitude tab = (req.tab or "").strip() logger.info("[feed] tab=%s page=%s lon=%.6f lat=%.6f", tab or "(default)", req.page, lon, lat) - def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> list[dict]: + # rec 是纯离线库查询,不依赖 MT 凭证、不实时打美团;其余 tab(distance / 默认)要实时打美团, + # 未配凭证直接降级返空(degraded),让前端区分「服务暂不可用」而非「暂无」。 + if tab != "rec" and not settings.mt_cps_configured: + return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") + + def _fetch_topic(platform: int, biz_line: int | None, topic: int) -> tuple[list[dict], bool]: + """返回(items, 是否调用失败)。失败标志用于把默认 feed 整页标 degraded。""" try: - return (query_coupon( + data = (query_coupon( longitude=lon, latitude=lat, platform=platform, biz_line=biz_line, list_topic_id=topic, page_size=20, ).get("data") or []) + return data, False except MeituanCpsError: - return [] + return [], True - # 距离最近:拉齐全部榜单轮次,合并去重,后端在【完整池子】上全局按距离由近及远排,一次性返回。 - # (距离排序必须在完整池上做、不能逐页排——这正是之前放前端不合理的根因。) + # 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。 + # 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。 + # 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。 if tab == "distance": - with ThreadPoolExecutor(max_workers=len(_TOPIC_ROUNDS) * 2) as pool: - futs = [] - for wm_topic, dd_topic in _TOPIC_ROUNDS: - futs.append(pool.submit(_fetch_topic, 1, None, wm_topic)) - futs.append(pool.submit(_fetch_topic, 2, 1, dd_topic)) - raws = [f.result() for f in futs] + lon_i, lat_i = int(lon * 1_000_000), int(lat * 1_000_000) + + def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]: + """顺序翻到第 n 页(搜索须 searchId 续页),返回(第 n 页 items, 是否还有下一页, 是否调用失败)。""" + sid: str | None = None + data: list[dict] = [] + has_next = False + for pg in range(1, n + 1): + body: dict = { + "platform": platform, "searchText": keyword, "sortField": 6, + "longitude": lon_i, "latitude": lat_i, "pageSize": 20, + } + if biz_line is not None: + body["bizLine"] = biz_line + if sid: + body["searchId"] = sid + else: + body["pageNo"] = 1 + try: + r = _call("/cps_open/common/api/v1/query_coupon", body) + except MeituanCpsError: + return [], False, True # 调用失败(用于标 degraded) + data = r.get("data") or [] + sid = r.get("searchId") + has_next = bool(r.get("hasNext")) and bool(data) + if not data or (not has_next and pg < n): + return [], False, False # 没那么多页了(非错误) + return data, has_next, False + + with ThreadPoolExecutor(max_workers=2) as pool: + f_wm = pool.submit(_search_page_n, 1, None, "外卖", req.page) + f_dd = pool.submit(_search_page_n, 2, 1, "美食", req.page) + (wm_data, wm_hn, wm_fail), (dd_data, dd_hn, dd_fail) = f_wm.result(), f_dd.result() + seen: set[str] = set() cards: list[CouponCard] = [] - for raw_list in raws: - for it in raw_list: + for it in wm_data + dd_data: + try: card = CouponCard.from_raw(it) - if card.product_view_sign and card.product_view_sign not in seen: - seen.add(card.product_view_sign) - cards.append(card) + except Exception: # noqa: BLE001 + continue + if card.product_view_sign and card.product_view_sign not in seen: + seen.add(card.product_view_sign) + cards.append(card) cards.sort(key=lambda c: c.distance_meters if c.distance_meters is not None else float("inf")) - return FeedResponse(items=cards, has_next=False, page=1) + # 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty + status = "degraded" if (not cards and wm_fail and dd_fail) else ("ok" if cards else "empty") + return FeedResponse(items=cards, has_next=wm_hn or dd_hn, page=req.page, status=status) - # 智能推荐(rec,默认):沿用逐轮分页的混合 feed,后端过滤掉佣金率 < 3%。 + # 智能推荐(rec):走【离线库】筛佣金率 ≥ 3%,分页返回(SQL 侧去重+排序+分页,秒级、不打美团)。 + # 实测库里佣金≥3% 去重后仅 ~578 条(几乎全是外卖;到店团购佣金普遍 <3%):实时按"同城热销榜单" + # 拉既撞限流、又填不满(该榜单中位佣金 ~0.8%,筛完每页剩 0-1 条),故从库出。佣金阈值逻辑不变。 + if tab == "rec": + PAGE = 20 + try: + base = select(MeituanCoupon).where(MeituanCoupon.commission_percent >= 3.0) + deduped = base.distinct(MeituanCoupon.dedup_key).order_by( + MeituanCoupon.dedup_key, + MeituanCoupon.commission_percent.desc(), + ).subquery() + m = aliased(MeituanCoupon, deduped) + start = (req.page - 1) * PAGE + rows = db.execute( + select(m) + # 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页 + .order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id) + .offset(start) + .limit(PAGE + 1) + ).scalars().all() + except Exception: # noqa: BLE001 + logger.exception("[feed] rec 库查询失败,降级返空") + return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") + has_next = len(rows) > PAGE + cards: list[CouponCard] = [] + for row in rows[:PAGE]: + try: + card = CouponCard.from_raw(row.raw or {}) + except Exception: # noqa: BLE001 + continue + if card.product_view_sign: + # 智能推荐不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导)。 + # 置空后前端"距离 店名"那行只剩店名、自动顶到最左(店名移到原距离的位置)。 + card.distance_text = None + card.distance_meters = None + cards.append(card) + return FeedResponse(items=cards, has_next=has_next, page=req.page, + status="ok" if cards else "empty") + + # 默认(老客户端不传 tab):沿用逐轮分页的混合 feed,不筛。 page_idx = req.page - 1 if page_idx >= len(_TOPIC_ROUNDS): - return FeedResponse(items=[], has_next=False, page=req.page) + return FeedResponse(items=[], has_next=False, page=req.page, status="empty") wm_topic, dd_topic = _TOPIC_ROUNDS[page_idx] with ThreadPoolExecutor(max_workers=2) as pool: f_wm = pool.submit(_fetch_topic, 1, None, wm_topic) f_dd = pool.submit(_fetch_topic, 2, 1, dd_topic) - waimai, daodian = f_wm.result(), f_dd.result() + (waimai, wm_fail), (daodian, dd_fail) = f_wm.result(), f_dd.result() items = _interleave(waimai, daodian) - if tab == "rec": - items = [c for c in items if _commission_pct(c) >= 3.0] has_next = page_idx + 1 < len(_TOPIC_ROUNDS) - return FeedResponse(items=items, has_next=has_next, page=req.page) + # 两路都失败且无数据 → degraded;否则有数据 ok / 无数据 empty + status = "degraded" if (not items and wm_fail and dd_fail) else ("ok" if items else "empty") + return FeedResponse(items=items, has_next=has_next, page=req.page, status=status) @router.post("/referral-link", response_model=ReferralLinkResponse, summary="换取推广链接(点抢时调)") @@ -172,32 +256,42 @@ def referral_link(req: ReferralLinkRequest) -> ReferralLinkResponse: @router.post("/top-sales", response_model=CouponListResponse, summary="销量最高(从离线库 meituan_coupon 按销量降序 + 跨源去重,不实时打美团)") def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponListResponse: - # 只取有销量档的(美团很多券没销量,排不了);按销量降序、同销量再按佣金降序 - stmt = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None)) - if req.platform is not None: - stmt = stmt.where(MeituanCoupon.platform == req.platform) - stmt = stmt.order_by( - MeituanCoupon.sale_volume_num.desc(), - MeituanCoupon.commission_percent.desc(), - ) - rows = db.execute(stmt).scalars().all() + # 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。 + # (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。) + # 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。 + try: + # 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) + base = select(MeituanCoupon).where(MeituanCoupon.sale_volume_num.isnot(None)) + if req.platform is not None: + base = base.where(MeituanCoupon.platform == req.platform) + deduped = base.distinct(MeituanCoupon.dedup_key).order_by( + MeituanCoupon.dedup_key, + MeituanCoupon.sale_volume_num.desc(), + MeituanCoupon.commission_percent.desc(), + ).subquery() - # 跨源去重(dedup_key = 品牌|名|价):按销量降序遍历,每个 dedup_key 只留第一条(=销量最高那条)。 - # 用 raw(整条原始返回)重建 CouponCard,字段与实时接口完全一致,前端无需改渲染。 - seen: set[str] = set() + # 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw + m = aliased(MeituanCoupon, deduped) + start = (req.page - 1) * req.page_size + rows = db.execute( + select(m) + # 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项 + .order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id) + .offset(start) + .limit(req.page_size + 1) + ).scalars().all() + except Exception: # noqa: BLE001 + logger.exception("[top-sales] 库查询失败,降级返空") + return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") + + has_next = len(rows) > req.page_size cards: list[CouponCard] = [] - for row in rows: - if row.dedup_key in seen: - continue - seen.add(row.dedup_key) + for row in rows[:req.page_size]: try: card = CouponCard.from_raw(row.raw or {}) except Exception: # noqa: BLE001 continue if card.product_view_sign: cards.append(card) - - start = (req.page - 1) * req.page_size - page_items = cards[start:start + req.page_size] - has_next = start + req.page_size < len(cards) - return CouponListResponse(items=page_items, has_next=has_next, search_id=None) + return CouponListResponse(items=cards, has_next=has_next, search_id=None, + status="ok" if cards else "empty") diff --git a/app/api/v1/user.py b/app/api/v1/user.py index d9cb31b..c7125f5 100644 --- a/app/api/v1/user.py +++ b/app/api/v1/user.py @@ -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,19 @@ 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) + # 若该用户被运营「一键开启新手引导」强制拉回引导,走完即清除强制标记(否则下次启动还会再触发)。 + user_repo.clear_force_onboarding(db, user) + 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) diff --git a/app/models/__init__.py b/app/models/__init__.py index a9d0488..4ed0d1b 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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 diff --git a/app/models/comparison.py b/app/models/comparison.py index 12f91a8..f521bc8 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -19,6 +19,7 @@ from sqlalchemy import ( Boolean, DateTime, ForeignKey, + Index, Integer, String, UniqueConstraint, @@ -39,6 +40,9 @@ class ComparisonRecord(Base): __table_args__ = ( # 同一用户同一次比价(trace_id)只存一条:客户端重试/误点重复上报时幂等覆盖。 UniqueConstraint("user_id", "trace_id", name="uq_comparison_user_trace"), + # 首页轮播 / 省钱战绩聚合都按 status='success' 过滤 + created_at 近期排序; + # 复合索引避免随数据量增大退化成全表扫(单列 created_at 索引不含 status)。 + Index("ix_comparison_status_created", "status", "created_at"), ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) diff --git a/app/models/onboarding.py b/app/models/onboarding.py new file mode 100644 index 0000000..9862d93 --- /dev/null +++ b/app/models/onboarding.py @@ -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"" diff --git a/app/models/ops_marquee_seed.py b/app/models/ops_marquee_seed.py index 2e64eb8..9eeb076 100644 --- a/app/models/ops_marquee_seed.py +++ b/app/models/ops_marquee_seed.py @@ -25,7 +25,8 @@ class OpsMarqueeSeed(Base): __tablename__ = "ops_marquee_seed" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - # 脱敏用户名,如「用户********a52」;留空(NULL)→ feed 展示时按脱敏格式随机合成(避开撞名) + # 脱敏用户名(手机尾号 / 中文昵称风格,如「138****5678」「省钱**」);留空(NULL)或旧「用户****xxx」 + # 模板名 → feed 展示时随机合成混合风格名(避开撞名、自愈历史种子) masked_user: Mapped[str | None] = mapped_column(String(64), nullable=True) # 节省金额区间(分);feed 每次在 [min,max] 随机取一个值。固定金额则 min==max。客户端 ÷100 显示「x.xx 元」 min_cents: Mapped[int] = mapped_column(Integer, nullable=False) diff --git a/app/models/ops_stat_config.py b/app/models/ops_stat_config.py index 1681de9..8ef17d5 100644 --- a/app/models/ops_stat_config.py +++ b/app/models/ops_stat_config.py @@ -53,7 +53,8 @@ class OpsStatConfig(Base): random_kind: Mapped[str] = mapped_column(String(8), nullable=False, default="mult") random_step_min: Mapped[int] = mapped_column(Integer, nullable=False, default=0) random_step_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - # real 模式基数偏移(基础单位;total_saved 为分):展示值 = 真实值 + 偏移(冷启动期既真实又体面) + # real 模式保底值(基础单位;total_saved 为分):展示值 = max(真实值, 保底值)。冷启动显示保底撑 + # 场面,真实值涨过保底后显示纯真实、零差距。列名沿用 real_offset(语义已从"偏移"改为"保底") real_offset: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # 门面数字默认「只增不减」;运营明确要下调时才开此开关(real/manual 刷新都受护栏约束) allow_decrease: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/app/models/user.py b/app/models/user.py index 67d6600..b883215 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -58,6 +58,13 @@ class User(Base): Boolean, nullable=False, default=False, server_default=false() ) + # 运营后台「一键开启新手引导」:置 true 后,该用户下次启动 App(/me 与登录响应带出此字段)会被 + # 强制重走新手引导教程——即便本地早已标记完成。走完引导(/onboarding/complete)即自动清回 false, + # 不会无限循环。默认 false。 + force_onboarding: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default=false() + ) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) diff --git a/app/repositories/onboarding.py b/app/repositories/onboarding.py new file mode 100644 index 0000000..03e51d3 --- /dev/null +++ b/app/repositories/onboarding.py @@ -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() diff --git a/app/repositories/ops_marquee.py b/app/repositories/ops_marquee.py index 6eb0c27..688652e 100644 --- a/app/repositories/ops_marquee.py +++ b/app/repositories/ops_marquee.py @@ -5,14 +5,17 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条 种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、 feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。 + +脱敏名风格:手机尾号(138****5678)+ 中文昵称(省钱**、小张**、吃货**达人)混合,看起来像真实 +异质用户群。真实条按 user_id 确定性合成(同用户恒定);种子留空 / 旧「用户****xxx」模板名一律随机 +合成(自愈历史种子,无需迁移)。 """ from __future__ import annotations -import hashlib import random from datetime import datetime, timedelta -from sqlalchemy import func, select +from sqlalchemy import delete, func, select, update from sqlalchemy.orm import Session from app.core.rewards import CN_TZ @@ -22,34 +25,70 @@ from app.models.ops_marquee_seed import OpsMarqueeSeed # feed 运行时随机源(每次请求结果不同 = 轮播想要的「鲜活感」) _rng = random.Random() -_SUFFIX_HEX = 3 # 脱敏用户名后缀位数,与真实条 md5[:3] 对齐 -_HEX = "0123456789abcdef" # 真实条金额上限(300 元):超过视为异常 / bug 值,不进轮播(防「节省 999 元」穿帮) _REAL_MAX_CENTS = 30000 # 种子金额防呆上限(1000 元):运营手滑填超大值时拦下 _SEED_MAX_CENTS = 100000 +# 真实记录查询缓存:首页人人都看、真实数据变化慢 → 缓存原始行 ~30s,省掉高频 DB 往返。 +# 展示层随机(抽样/金额/时间/名字合成)仍每次重算,缓存只省查询;新记录最多晚 30s 进轮播,可接受。 +_REAL_ROWS_TTL_SECONDS = 30 +_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用 +_real_rows_cache: dict = {"at": None, "rows": None} + +# ===== 脱敏名生成:手机尾号 + 中文昵称 混合,看起来像真实异质用户群(替代旧的「用户********xxx」统一模板)===== +_PHONE_2ND = "3456789" # 手机号第 2 位(13x~19x) +_SURNAMES = "王李张刘陈杨黄赵周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘蒋蔡余杜叶程苏魏吕丁任沈姚卢" +_NICK_WORDS = [ + "省钱", "吃货", "羊毛", "薅羊毛", "爱比价", "精打细算", "持家", "干饭", + "美食控", "外卖", "剁手", "捡漏", "搞钱", "打工人", "摸鱼", "养生", + "佛系", "躺平", "暴富", "锦鲤", "夜宵", "奶茶", "热爱生活", "会过日子", +] +_NICK_PREFIX = ["小", "大", "老", "阿", "一只", "爱吃的", "快乐的"] +_NICK_SUFFIX = ["达人", "小能手", "侠", "党", "一族", "星人", "鸭", "酱"] +_LETTER_U = "ABCDEFGHJKLMNPQRSTUVWXYZ" +_LETTER_L = "abcdefghijkmnpqrstuvwxyz" + + +def _phone_name(rng: random.Random) -> str: + """手机尾号式:1[3-9]X****XXXX(合成、非真实号)。""" + head = "1" + rng.choice(_PHONE_2ND) + str(rng.randint(0, 9)) + return f"{head}****{rng.randint(0, 9999):04d}" + + +def _nickname(rng: random.Random) -> str: + """昵称打码式,中文为主(约 11/12),掺少量中英混 / 纯字母。""" + p = rng.randint(0, 11) + if p in (0, 1, 2): + return rng.choice(_NICK_WORDS) + "**" # 省钱** + if p in (3, 4): + return rng.choice(_NICK_WORDS) + "**" + rng.choice(_NICK_SUFFIX) # 吃货**达人 + if p in (5, 6): + return rng.choice(_SURNAMES) + "**" # 张** + if p in (7, 8): + return rng.choice(_NICK_PREFIX) + rng.choice(_SURNAMES) + "**" # 小张** + if p in (9, 10): + return rng.choice(_NICK_WORDS) + rng.choice(_LETTER_U) + "**" # 中英混 省钱A** + return rng.choice(_LETTER_U) + "**" + rng.choice(_LETTER_L) # 纯字母(约 1/12) + + +def _realistic_name(rng: random.Random) -> str: + """~45% 手机尾号 + ~55% 中文昵称,混合出真实异质感。""" + return _phone_name(rng) if rng.random() < 0.45 else _nickname(rng) + def _mask_user(user_id: int) -> str: - """真实用户脱敏:用户********+3 位哈希后缀(稳定、不暴露真实信息)。""" - suffix = hashlib.md5(str(user_id).encode()).hexdigest()[:_SUFFIX_HEX] - return f"用户********{suffix}" + """真实用户脱敏:按 user_id 确定性合成一个名(同用户恒定、不暴露真实信息)。""" + return _realistic_name(random.Random(user_id)) -def _random_suffix(used: set[str]) -> str: - """随机 3 位 hex 后缀,避开 used(真实条 + 已生成种子),防同屏撞名。""" +def _unique_name(used: set[str]) -> str: + """随机合成一个不与 used 撞的脱敏名(种子留空 / 旧模板名用),防同屏撞名。""" for _ in range(60): - s = "".join(_rng.choice(_HEX) for _ in range(_SUFFIX_HEX)) - if s not in used: - used.add(s) - return s - # 兜底(几乎不会到):线性扫一个空位 - for n in range(16 ** _SUFFIX_HEX): - s = format(n, f"0{_SUFFIX_HEX}x") - if s not in used: - used.add(s) - return s - return "000" + n = _realistic_name(_rng) + if n not in used: + return n + return _realistic_name(_rng) + str(_rng.randint(0, 99)) # 兜底(几乎不会到) def _validate_amount(min_cents: int, max_cents: int) -> None: @@ -61,17 +100,30 @@ def _validate_amount(min_cents: int, max_cents: int) -> None: raise ValueError(f"金额上限过大(≤ {_SEED_MAX_CENTS // 100} 元)") -# ===== 用户侧:轮播 feed(真实 + 种子混播) ===== -def get_feed(db: Session, limit: int = 8) -> list[dict]: - """返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。 - - 真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。 - 不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出; - 种子用户名留空则随机合成(避开撞名),金额在 [min,max] 区间随机取值。 - 展示时间统一「刷新」成相对现在的最近时刻(从 now 往前递减),保证轮播永远像刚发生 - (真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时/未来时间)。 +def _skewed_amount(lo: int, hi: int) -> int: + """长尾金额:多数贴近下限、偶尔接近上限(比均匀随机更像真实省钱分布)。 + 用 u**2.2 把 [0,1) 均匀值压向 0 → 小额居多;偶尔 u 接近 1 给出大额。 """ - # 真实条:取较多近期记录后按 user 去重;金额超上限的异常值剔除(防穿帮)。 + if hi <= lo: + return max(0, lo) + u = _rng.random() ** 2.2 + return lo + int(round((hi - lo) * u)) + + +# 兜底金额区间(真实 + 种子都凑不满 limit 时,内置合成条用):3~35 元 +_FALLBACK_MIN_CENTS = 300 +_FALLBACK_MAX_CENTS = 3500 + + +# ===== 用户侧:轮播 feed(真实 + 种子混播) ===== +def _recent_real_rows(db: Session) -> list[tuple[int, int]]: + """近期 success 且省到钱的 (user_id, saved_cents),按 created_at desc;带 ~30s 进程内缓存。 + 返回纯元组(脱离 session),可安全跨请求复用。极端并发下偶尔多查一次(无锁、幂等),纯门面无副作用。 + """ + now = datetime.now(CN_TZ) + cached, at = _real_rows_cache["rows"], _real_rows_cache["at"] + if cached is not None and at is not None and (now - at).total_seconds() < _REAL_ROWS_TTL_SECONDS: + return cached rows = db.execute( select(ComparisonRecord.user_id, ComparisonRecord.saved_amount_cents) .where( @@ -80,18 +132,35 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS, ) .order_by(ComparisonRecord.created_at.desc()) - .limit(max(limit * 20, 100)) + .limit(_REAL_ROWS_FETCH_CAP) ).all() + out = [(int(uid), int(sc)) for uid, sc in rows] + _real_rows_cache["rows"], _real_rows_cache["at"] = out, now + return out + + +def get_feed(db: Session, limit: int = 8) -> list[dict]: + """返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。 + + 真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。 + 不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出; + 种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。 + 真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。 + 展示时间统一「刷新」成相对现在的最近时刻(从 now 往前**随机抖动**递减),保证轮播永远像刚发生、 + 节奏自然不机械(真实用户/金额不变,只换展示时间——避免旧测试数据 / 低谷期记录显示成过时时间)。 + """ + # 真实条:取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。 + rows = _recent_real_rows(db) items: list[dict] = [] - used_suffixes: set[str] = set() + used_names: set[str] = set() seen_users: set[int] = set() for uid, sc in rows: if uid in seen_users: continue seen_users.add(uid) name = _mask_user(uid) - used_suffixes.add(name[-_SUFFIX_HEX:]) + used_names.add(name) # 真实名按 user_id 稳定;偶发撞名可接受 items.append({"masked_user": name, "saved_amount_cents": int(sc)}) if len(items) >= limit: break @@ -105,22 +174,41 @@ def get_feed(db: Session, limit: int = 8) -> list[dict]: chosen = _rng.sample(seeds, need) if len(seeds) > need else list(seeds) _rng.shuffle(chosen) for s in chosen: - # 用户名:填了固定名优先;留空则按脱敏格式随机合成(避开撞名)。 - if s.masked_user and s.masked_user.strip(): - name = s.masked_user.strip() - used_suffixes.add(name[-_SUFFIX_HEX:]) - else: - name = f"用户********{_random_suffix(used_suffixes)}" - # 金额:在 [min,max] 随机一个(固定金额则 min==max)。 + # 用户名:旧的「用户****xxx」统一模板名 / 留空 → 一律走新混合合成(避开同屏撞名、自愈历史种子); + # 仅运营手动设的非模板真名才原样用。 + fixed = (s.masked_user or "").strip() + name = fixed if fixed and not fixed.startswith("用户****") else _unique_name(used_names) + used_names.add(name) + # 金额:在 [min,max] 取长尾随机值(小额居多、偶尔大额;固定金额则 min==max)。 lo = max(0, int(s.min_cents)) hi = max(lo, int(s.max_cents)) - cents = lo if lo >= hi else _rng.randint(lo, hi) - items.append({"masked_user": name, "saved_amount_cents": cents}) + items.append({"masked_user": name, "saved_amount_cents": _skewed_amount(lo, hi)}) - # 统一赋「最近」时间:首条约 20 秒前,其余每条往前 2~6 分钟,降序、节奏自然。 + # 兜底:真实 + 种子仍凑不满 limit(种子被全停用 / 数量太少)→ 用内置合成补满, + # 保证轮播既不空也不稀疏(稀疏的几条循环同样像假)。 + while len(items) < limit: + name = _unique_name(used_names) + used_names.add(name) + items.append({ + "masked_user": name, + "saved_amount_cents": _skewed_amount(_FALLBACK_MIN_CENTS, _FALLBACK_MAX_CENTS), + }) + + # 统一赋「最近」时间:从 now 往前**随机抖动**递减,避免固定节奏被看出规律。 + # 首条几十秒前;其余多数 1~5 分钟,偶尔扎堆(20~55s)或较长(5~9 分钟),降序、像真实流水。 cursor = datetime.now(CN_TZ) for i, it in enumerate(items): - cursor = cursor - timedelta(seconds=20 if i == 0 else 60 * (2 + (i * 3) % 5)) + if i == 0: + gap = _rng.randint(8, 40) + else: + r = _rng.random() + if r < 0.2: + gap = _rng.randint(20, 55) # 偶尔扎堆 + elif r < 0.85: + gap = _rng.randint(60, 300) # 常规 1~5 分钟 + else: + gap = _rng.randint(300, 540) # 偶尔较长 5~9 分钟 + cursor = cursor - timedelta(seconds=gap) it["time"] = cursor.strftime("%H:%M:%S") return items @@ -244,3 +332,29 @@ def delete_seed(db: Session, seed_id: int, *, commit: bool = True) -> bool: else: db.flush() return True + + +def batch_delete(db: Session, ids: list[int], *, commit: bool = True) -> int: + """批量删除选中的种子,返回实际删除条数(不存在的 id 自动跳过)。""" + if not ids: + return 0 + n = db.execute(delete(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids))).rowcount + if commit: + db.commit() + else: + db.flush() + return int(n or 0) + + +def batch_set_enabled(db: Session, ids: list[int], enabled: bool, *, commit: bool = True) -> int: + """批量启用 / 停用选中的种子,返回实际更新条数。""" + if not ids: + return 0 + n = db.execute( + update(OpsMarqueeSeed).where(OpsMarqueeSeed.id.in_(ids)).values(enabled=enabled) + ).rowcount + if commit: + db.commit() + else: + db.flush() + return int(n or 0) diff --git a/app/repositories/ops_stat.py b/app/repositories/ops_stat.py index 5dfd7f9..c4593b8 100644 --- a/app/repositories/ops_stat.py +++ b/app/repositories/ops_stat.py @@ -7,7 +7,8 @@ random 自增长支持两种方式(random_kind): - mult:×随机倍率([min,max]/1000,恒 ≥1.0)——复利、指数增长 - add :+随机绝对增量([step_min,step_max])——线性、可控,更像真实平台日增 -真实值(real)模式展示 = 真实统计 + real_offset(基数偏移),冷启动期既真实又体面。 +真实值(real)模式展示 = max(真实统计, real_offset 保底值):冷启动显示保底撑场面,真实值涨过 +保底后显示纯真实、零差距(real_offset 列名沿用,语义从"偏移"改为"保底")。 「只增不减」护栏(allow_decrease=False,默认):real/manual 刷新时若新目标值低于当前展示值, 保持当前值不回退——门面数字最忌当众缩水。random 天然只增(倍率≥1 / 增量≥0)。 @@ -116,8 +117,9 @@ def _current_target(db: Session, row: OpsStatConfig) -> int: if row.mode == "manual": return max(0, int(row.manual_value or 0)) if row.mode == "real": - # 真实值 + 基数偏移 - return max(0, int(_real_value(db, row.metric)) + int(row.real_offset or 0)) + # 真实值保底:展示 = max(真实值, 保底值)。冷启动真实值小→显示保底撑场面; + # 真实值涨过保底后显示纯真实、零差距(real_offset 列名沿用,语义已从"偏移"改为"保底")。 + return max(0, int(_real_value(db, row.metric)), int(row.real_offset or 0)) # random:无显式初始基数时用真实值(纯真实,不含偏移)播种 return max(0, int(_real_value(db, row.metric))) @@ -302,7 +304,7 @@ def update_config( raise ValueError("增量上限不得小于下限") if real_offset is not None: if real_offset < 0: - raise ValueError("基数偏移需为非负整数") + raise ValueError("保底值需为非负整数") row.real_offset = real_offset if allow_decrease is not None: row.allow_decrease = allow_decrease @@ -311,7 +313,9 @@ def update_config( if random_initial is not None: if random_initial < 0: raise ValueError("初始基数需为非负整数") - row.random_current = random_initial # 设起点是运营明确意图,不受护栏约束 + # 初始基数受「只增不减」护栏(与 manual/real 一致):未勾 allow_decrease 且低于 + # 当前展示值时保持现值。首次无值(random_current is None)则护栏放行、直接设。 + row.random_current = _monotonic(row, random_initial) row.random_last_tick_at = now elif apply_now: # 立即更新:不等钟点,马上刷新一次 diff --git a/app/repositories/user.py b/app/repositories/user.py index 697d7e8..5f7dfe7 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -62,6 +62,16 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User: return user +def clear_force_onboarding(db: Session, user: User) -> None: + """用户走完(被运营强制开启的)新手引导后,清除强制标记,避免下次启动再被拉回引导。 + + 幂等:未置位时直接返回,不空 commit。由 /onboarding/complete 在标记完成后调用。 + """ + if user.force_onboarding: + user.force_onboarding = False + db.commit() + + def soft_delete_account(db: Session, user: User) -> None: """注销账号:软删除 + 匿名化。 diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 8d73dab..606e422 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -129,6 +129,37 @@ def grant_coins( return acc, txn +def grant_cash( + db: Session, + user_id: int, + amount_cents: int, + *, + biz_type: str, + ref_id: str | None = None, + remark: str | None = None, +) -> tuple[CoinAccount, CashTransaction]: + """现金变动入口(正数入账 / 负数出账)。更新现金余额 + 写流水,不 commit。 + + 与 [grant_coins] 同模式(运营手动调现金 / 测试发现金用)。返回 (account, transaction), + 调用方负责 commit。不在此校验扣成负——由调用方(admin router)按业务保护。 + """ + acc = get_or_create_account(db, user_id, commit=False) + acc.cash_balance_cents += amount_cents + + txn = CashTransaction( + user_id=user_id, + amount_cents=amount_cents, + balance_after_cents=acc.cash_balance_cents, + biz_type=biz_type, + ref_id=ref_id, + remark=remark, + created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock + ) + db.add(txn) + db.flush() + return acc, txn + + def list_coin_transactions( db: Session, user_id: int, diff --git a/app/schemas/auth.py b/app/schemas/auth.py index b878a91..82609ca 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -27,6 +27,9 @@ class UserOut(BaseModel): last_login_at: datetime # 调试链接权限:前端据此在比价结果弹窗/记录页显示「复制调试链接」按钮。默认 false。 debug_trace_enabled: bool = False + # 运营后台「一键开启新手引导」:true → 客户端下次启动强制重走引导(即便本地已完成); + # 走完引导后端自动清回 false。默认 false 兼容老客户端。 + force_onboarding: bool = False # ===== Token 通用结构 ===== @@ -41,6 +44,11 @@ class TokenPair(BaseModel): class TokenWithUser(TokenPair): user: UserOut + onboarding_completed: bool = Field( + False, + description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。" + "true → 客户端登录后直接进首页,跳过引导。", + ) # ===== 极光一键登录 ===== @@ -48,6 +56,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 +77,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 ===== diff --git a/app/schemas/meituan.py b/app/schemas/meituan.py index 67cf36c..14ce264 100644 --- a/app/schemas/meituan.py +++ b/app/schemas/meituan.py @@ -1,10 +1,17 @@ """美团 CPS 券列表 / 换链相关 schemas。""" from __future__ import annotations -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, Field +# feed / 列表类接口的结果状态,供前端区分占位文案: +# ok = 正常有数据 +# empty = 后端正常、查询成功但确实没有数据(显示「暂无优惠券」) +# degraded = 上游(美团)或库查询失败,已降级返空(显示「服务繁忙,下拉重试」) +# 老客户端不读该字段,默认 ok,向后兼容。 +FeedStatus = Literal["ok", "empty", "degraded"] + # ───────────────── 券卡片(归一化后给客户端) ───────────────── @@ -127,6 +134,7 @@ class CouponListResponse(BaseModel): items: list[CouponCard] has_next: bool = False search_id: str | None = None + status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级") class FeedRequest(BaseModel): @@ -146,6 +154,7 @@ class FeedResponse(BaseModel): items: list[CouponCard] has_next: bool = False page: int = 1 + status: FeedStatus = Field("ok", description="ok 有数据 / empty 暂无 / degraded 上游失败已降级") class TopSalesRequest(BaseModel): diff --git a/app/schemas/platform.py b/app/schemas/platform.py index c49cd08..ec8619c 100644 --- a/app/schemas/platform.py +++ b/app/schemas/platform.py @@ -15,7 +15,7 @@ class PlatformStatsOut(BaseModel): class SavingsFeedItem(BaseModel): """首页轮播一条。time 为北京时间 HH:MM:SS。""" - masked_user: str # 脱敏用户名,如「用户********a52」 + masked_user: str # 脱敏用户名,手机尾号(138****5678)或中文昵称(省钱**)混合风格 saved_amount_cents: int # 节省(分),客户端 ÷100 显示「x.xx 元」 time: str diff --git a/app/schemas/user.py b/app/schemas/user.py index ebaba24..cbe6469 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -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 diff --git a/deploy/meituan-etl.md b/deploy/meituan-etl.md new file mode 100644 index 0000000..977e6c7 --- /dev/null +++ b/deploy/meituan-etl.md @@ -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 ) +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` 下的代码。 diff --git a/deploy/meituan-etl.service b/deploy/meituan-etl.service new file mode 100644 index 0000000..8d17bd2 --- /dev/null +++ b/deploy/meituan-etl.service @@ -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 diff --git a/deploy/meituan-etl.timer b/deploy/meituan-etl.timer new file mode 100644 index 0000000..5085df2 --- /dev/null +++ b/deploy/meituan-etl.timer @@ -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 diff --git a/docs/api/admin-dashboard-display.md b/docs/api/admin-dashboard-display.md index 70463b2..93eec20 100644 --- a/docs/api/admin-dashboard-display.md +++ b/docs/api/admin-dashboard-display.md @@ -27,7 +27,7 @@ | `random_kind` | string | 自增长方式:`mult` ×倍率 / `add` +绝对增量 | | `random_step_min` | int | add 模式增量下限(基础单位) | | `random_step_max` | int | add 模式增量上限(基础单位) | -| `real_offset` | int | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 | +| `real_offset` | int | real 模式保底值(基础单位):展示 = max(真实值, 保底值)。字段名沿用 real_offset(语义已改为"保底") | | `allow_decrease` | bool | 是否允许展示值下降(默认 false = 只增不减) | | `random_current` | int \| null | 当前展示值(所有模式,基础单位) | | `random_last_tick_at` | string \| null | 上次 tick 时刻(ISO) | @@ -52,7 +52,7 @@ | `random_kind` | string | 自增长方式:`mult` / `add` | | `random_step_min` | int | add 增量下限(基础单位,≥0) | | `random_step_max` | int | add 增量上限(基础单位,≥下限) | -| `real_offset` | int | real 基数偏移(基础单位,≥0) | +| `real_offset` | int | real 保底值(基础单位,≥0):展示 = max(真实值, 保底值) | | `allow_decrease` | bool | 允许展示值下降(默认 false=只增不减) | | `random_initial` | int | 切 random 的初始基数(基础单位);**留空则用当前真实值播种** | | `apply_now` | bool | 立即更新:不等更新钟点,保存后马上刷新一次(random 走一档,real/manual 取应有值,均经护栏) | diff --git a/docs/api/admin-marquee-seeds.md b/docs/api/admin-marquee-seeds.md index a0b9566..cc1e8dd 100644 --- a/docs/api/admin-marquee-seeds.md +++ b/docs/api/admin-marquee-seeds.md @@ -31,6 +31,16 @@ - 入参 `OpsMarqueeSeedBulkCreate`:`count`(1~200)、`min_cents`、`max_cents`、`enabled`(默认 true) - 出参 `200`:`list[OpsMarqueeSeedOut]`(新建的 count 条)。`400`=数量 / 金额非法。 +## POST /admin/api/marquee-seeds/batch-delete — 批量删除(带审计) +对选中的多条种子一次删除(一个事务 + 一条审计)。 +- 入参 `OpsMarqueeSeedBatchDelete`:`ids`(int 数组;空数组 = no-op;不存在的 id 自动跳过) +- 出参 `200`:`{"deleted": <实际删除条数>}` + +## POST /admin/api/marquee-seeds/batch-enable — 批量启用 / 停用(带审计) +对选中的多条种子一次置启用态。 +- 入参 `OpsMarqueeSeedBatchEnable`:`ids`(int 数组)、`enabled`(bool) +- 出参 `200`:`{"updated": <实际更新条数>}` + ## PATCH /admin/api/marquee-seeds/{seed_id} — 改(带审计) 入参 `OpsMarqueeSeedUpdate`(均可选):`masked_user` / `min_cents` / `max_cents` / `enabled` / `sort_order`。 - `masked_user` 约定:**不传**=不改;**传空串**=清空(改回随机合成);**传值**=固定该名。 @@ -41,5 +51,5 @@ 出参 `200`:`{"deleted": true}`。`404`=种子不存在。 ## 说明 -- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate`)。预览为只读,任意已登录 admin 可用。 +- 写操作需 operator(super 恒可),均写 `admin_audit_log`(action=`ops_marquee_seed.create` / `update` / `delete` / `bulk_generate` / `batch_delete` / `batch_enable`)。预览为只读,任意已登录 admin 可用。 - 改动客户端**进首页 / 回前台**重拉 feed 时生效。 diff --git a/docs/api/meituan-coupons.md b/docs/api/meituan-coupons.md index 582708e..c38966b 100644 --- a/docs/api/meituan-coupons.md +++ b/docs/api/meituan-coupons.md @@ -20,11 +20,11 @@ | `page_size` | int | ❌ | 20 | 1–20 | ## 出参 -响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。 +响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: string|null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。 ## 错误码 -- `502` 美团接口失败(仅在**已配置 `MT_CPS_APP_KEY/APP_SECRET` 但调用失败**时;未配凭证场景见下) +无业务级错误码:未配凭证 / 美团调用失败都返 `200` + 空 `items` + `status=degraded`(**2026-06-10 起不再抛 502**,避免前端弹报错 toast)。 ## 说明 - 当前 Android 客户端**未调用**此接口(搜索功能尚未实现),仅后端实现完整 -- **未配置 MT_CPS 凭证时降级**:`settings.mt_cps_configured == false` 时直接返 `{ items: [], has_next: false, search_id: null }`,**不报 502**。设计目的是让新开发机首屏不炸——后端业务层先不依赖 CPS。已配凭证但调美团失败时才走 502 +- **统一软降级(2026-06-10)**:未配 `MT_CPS_APP_KEY/SECRET`、或已配但调美团失败,都返空 + `status=degraded`(此前后者抛 502);成功但无结果 → `status=empty`。前端按 `status` 显示「服务繁忙」/「暂无」,不再弹错误 diff --git a/docs/api/meituan-feed.md b/docs/api/meituan-feed.md index 5c44bd5..9377ab8 100644 --- a/docs/api/meituan-feed.md +++ b/docs/api/meituan-feed.md @@ -1,31 +1,41 @@ -# POST /api/v1/meituan/feed — 首页混合推荐流 +# POST /api/v1/meituan/feed — 首页推荐流(多 tab) -> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) > -> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑)。 +> 集成实现:见 [integrations/meituan](../integrations/meituan.md)(CPS S-Ca 签名、入参换算坑);离线库见 [database/meituan_coupon](../database/meituan_coupon.md)。 ## 入参 - | 字段 | 类型 | 必填 | 默认 | 说明 | |---|---|---|---|---| | `longitude` | float | ✅ | — | 经度 | | `latitude` | float | ✅ | — | 纬度 | | `page` | int | ❌ | 1 | ≥1 | | `page_size` | int | ❌ | 20 | 1–20 | +| `tab` | string | ❌ | `""` | `rec` 智能推荐 / `distance` 距离最近 / 空=默认混合 feed(老客户端兼容) | + +> 销量最高(sales)tab **不在本接口**,走 [`/top-sales`](./meituan-top-sales.md)。 ## 出参 -响应 `200`:`{ items: CouponCard[], has_next: bool, page: int }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。 +响应 `200`:`{ items: CouponCard[], has_next: bool, page: int, status: "ok"|"empty"|"degraded" }`。`CouponCard` 结构见 [API 索引](./README.md#复用数据结构)。 + +### `status` 字段(前端据此显示占位) +| 值 | 含义 | 前端建议 | +|---|---|---| +| `ok` | 有数据 | 正常渲染 | +| `empty` | 后端正常、查询成功但确实没数据 | 显示「暂无优惠券」 | +| `degraded` | 上游(美团)或库查询失败,已降级返空 | 显示「服务繁忙,下拉重试」 | + +> 老客户端不读 `status`(字段缺省即兼容),仍可只按 `items` 是否为空展示。 + +## 各 tab 行为 +- **`rec` 智能推荐**:走【离线库 `meituan_coupon`】筛佣金率 ≥ 3%,`DISTINCT ON(dedup_key)` 去重后按销量降序分页。**纯库查询、不打美团、不依赖 MT 凭证**。库为空(prod 刚部署 / ETL 未跑完)→ `empty`;库异常 → `degraded`。**不显示距离**(库里距离相对城市默认点,对用户无意义)。 + - 为何不实时:实测同城热销榜中位佣金 ~0.8%,筛佣金≥3% 后每页剩 0–1 条,既撞 402 又填不满,故从库出;库空时也**不回退实时**(回退同样填不满)。 +- **`distance` 距离最近**:外卖搜「外卖」+ 到店搜「美食」(均 `sortField=6`),两路并行顺序翻页,实时按你坐标算距离、由近及远。两路**都失败**且无结果 → `degraded`;否则有结果 `ok` / 无结果 `empty`。 +- **默认(空 tab)**:逐轮分页的混合 feed(2 外卖 + 1 到店交叉,写死 3 页:爆款 / 今日必推 / 精选+限时),第 4 页返空。两路榜单都失败 → `degraded`。 ## 错误码 -无(任何失败场景都返 200 + 空 items,见"说明"中的可观测性盲区) +无业务级错误码:任何失败场景都返 `200` + 空 `items` + `status=degraded`(不再抛 5xx,避免前端弹报错 toast)。 -## 说明 -后端在 `coupons` 之上封装的"伪推荐"——每页并发拉一次外卖、一次到店(到餐)榜单,按 **2 外卖 + 1 到店** 交叉去重。榜单组合写死 3 页(第1页爆款、第2页今日必推、第3页外卖精选+到店限时),**第 3 页起 `has_next=false`、第 4 页返空**。 - -**两种空结果路径**: -- **未配置 `MT_CPS_APP_KEY/APP_SECRET`**:开头 `settings.mt_cps_configured` 判定后**直接返空**,完全不调美团 -- **已配置但美团调用失败**:`_fetch_topic` 的 `except MeituanCpsError` **静默返空**(不报 502、不打日志) - -⚠️ **可观测性盲区**:两种路径在响应上**无法区分**。若 `items` 为空想区分: -- 看 `settings.mt_cps_configured`(`/health` 接口有暴露此字段)→ 区分"配置缺失" vs "调用失败" -- 改调 `coupons` 接口——它在"已配凭证但调用失败"时会 502 暴露错误细节(未配凭证它也降级返空,跟 feed 一致) +## 兜底说明(1.0 内测) +- rec / sales 是库查询:**prod 刚部署、ETL 首灌未完成**时库为空 → 返 `empty`。最佳实践是**开内测前先手动跑一次 ETL 灌满库**(见 `scripts/pull_meituan_coupons.py` 与 `deploy/meituan-etl.{service,timer}`)。 +- 未配 `MT_CPS_APP_KEY/SECRET` 时:`distance` / 默认 直接 `degraded`;`rec` 仍走库(配置开关不挡纯库查询)。 diff --git a/docs/api/meituan-top-sales.md b/docs/api/meituan-top-sales.md new file mode 100644 index 0000000..3f91f66 --- /dev/null +++ b/docs/api/meituan-top-sales.md @@ -0,0 +1,23 @@ +# POST /api/v1/meituan/top-sales — 销量最高(离线库) + +> 所属:美团 CPS 组(前缀 `/api/v1/meituan`,**全部无鉴权**) | 鉴权:无 | [← 返回 API 索引](./README.md) +> +> 数据来自离线库 [database/meituan_coupon](../database/meituan_coupon.md);**不实时打美团**(美团搜索对销量排序支持差、且有 402 限流)。 + +## 入参 +| 字段 | 类型 | 必填 | 默认 | 说明 | +|---|---|---|---|---| +| `page` | int | ❌ | 1 | ≥1 | +| `page_size` | int | ❌ | 20 | 1–50 | +| `platform` | int \| null | ❌ | null | 1 只外卖 / 2 只到店 / 不填=全部(全城销量) | + +## 出参 +响应 `200`:`{ items: CouponCard[], has_next: bool, search_id: null, status: "ok"|"empty"|"degraded" }`。`CouponCard` 见 [API 索引](./README.md#复用数据结构);`status` 语义见 [feed 接口](./meituan-feed.md#status-字段前端据此显示占位)。 + +## 说明 +- 从 `meituan_coupon` 取 `sale_volume_num` 非空的券,`DISTINCT ON(dedup_key)` 跨源去重(每个「品牌|名|价」只留销量最高一条,同销量再按佣金),按销量降序分页;每页只对当前 ~20 条做 `from_raw` 解析(翻页快,不全表拉取)。 +- **不依赖 MT 凭证**(纯库查询)。库为空(prod 刚部署 / ETL 未跑完)→ `status=empty`;库查询异常 → `status=degraded`。均返 `200`、不抛 5xx。 +- **仅 PostgreSQL**(`DISTINCT ON` 为 PG 专用)。 + +## 错误码 +无业务级错误码:库空 / 异常都返 `200` + 空 `items` + 对应 `status`。 diff --git a/docs/api/platform-savings-feed.md b/docs/api/platform-savings-feed.md index 156fa1e..321dc69 100644 --- a/docs/api/platform-savings-feed.md +++ b/docs/api/platform-savings-feed.md @@ -15,13 +15,15 @@ | 字段 | 类型 | 说明 | |---|---|---| | `items` | list | 轮播条目数组(最多 `limit` 条) | -| `items[].masked_user` | string | 脱敏用户名,如 `用户********a52` | +| `items[].masked_user` | string | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)混合风格 | | `items[].saved_amount_cents` | int | 节省(分),客户端 ÷100 显示「x.xx 元」 | | `items[].time` | string | 北京时间 `HH:MM:SS` | ## 说明 -- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` 哈希脱敏(`用户********`+3 位)。 -- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空则按脱敏格式**随机合成并避开同屏撞名**,金额在 `[min_cents, max_cents]` 区间**随机取值**(固定金额则 min==max)。 -- **`time` 为合成的「最近」时间**(从当前北京时间往前递减,首条约 20 秒前,其余每条 2~6 分钟):社会证明轮播保证永远像刚发生,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 +- 真实条:`comparison_record` 中 `status='success'` 且 `0 < saved_amount_cents ≤ 300 元` 的近期记录(金额超 300 元视为异常 / bug 值剔除,防「节省 999 元」穿帮),**按 `user_id` 去重**(同一用户只取最新一条,避免单人刷屏);用户名按 `user_id` **确定性合成**手机尾号 / 中文昵称混合脱敏名(同用户恒定)。 +- 种子条:`ops_marquee_seed`(`enabled=true`),仅在真实去重后不足 `limit` 时补齐;**从启用种子中公平随机抽取**(不再固定取前 N,所有种子都有机会露出)。每条种子:用户名留空或旧 `用户****xxx` 模板名则**随机合成混合风格名并避开同屏撞名**(自愈历史种子),金额在 `[min_cents, max_cents]` 取**长尾随机值**(小额居多、偶尔大额,固定金额则 min==max)。 +- 兜底:真实 + 种子仍凑不满 `limit`(种子被全停用 / 太少)时,用**内置合成条补满**,保证轮播既不空也不稀疏。 +- **`time` 为合成的「最近」时间**(从当前北京时间往前**随机抖动**递减,首条几十秒前,其余多数 1~5 分钟、偶尔扎堆或较长):社会证明轮播保证永远像刚发生且节奏自然不机械,不受旧测试数据 / 低谷期记录影响。真实的用户/金额不变,只换展示时间。 - 因含随机(抽取 / 金额 / 合成名),**每次请求结果都不同**——这是轮播想要的鲜活感。 +- **性能**:首页人人都看、真实数据变化慢,真实记录原始行带 **~30s 进程内缓存**(展示层随机仍每次重算,只省 DB 往返;新记录最多晚 30s 进轮播)。查询走复合索引 `ix_comparison_status_created (status, created_at)`,避免随数据量增大全表扫。 - 逻辑见 `app/repositories/ops_marquee.py`;运营管理种子见 [admin-marquee-seeds](./admin-marquee-seeds.md)。 diff --git a/docs/api/platform-stats.md b/docs/api/platform-stats.md index 25ced25..9ddaef6 100644 --- a/docs/api/platform-stats.md +++ b/docs/api/platform-stats.md @@ -18,7 +18,7 @@ ## 说明 三指标各自独立选模式,且**统一「定时刷新」**:客户端看到的展示值只在「更新时间(按更新间隔)」对齐的北京钟点(`anchor + k*间隔`)刷新一次,平时不变。每次刷新按模式算新值: -- **real**:重新快照查库 + 基数偏移。`help_users`=有过 `status='success'` 比价记录的去重用户数;`total_compares`=成功比价记录数;`total_saved_cents`=成功记录 `saved_amount_cents` 求和(**只计 `0 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 -> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **25 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **28 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。 --- @@ -43,6 +43,7 @@ | App 位置 / 动作 | 表 | 说明 | |---|---|---| | 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 | +| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 | | 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 | ### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) @@ -62,7 +63,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_`、`status=deleted`) | | 绑/解绑微信 `POST /wallet/bind-wechat`、`/unbind-wechat` | `user`.wechat_* | U | @@ -136,6 +138,7 @@ - **广告流会话关联**:`ad_reward_record.ad_session_id` 可与 `ad_ecpm_record.ad_session_id` 对齐;`ad_watch_log` 仍是旧版兼容统计,不逐条参与发奖。 - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。 - **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。 +- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。 ### ER 关系(文字版) ``` @@ -145,6 +148,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 为主键) diff --git a/docs/database/README.md b/docs/database/README.md index e4e6668..24f91ad 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,18 +3,19 @@ > 数据库: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-10(新增 3 张领券今日状态表 `coupon_*`;前补全 22 张业务表 + [OVERVIEW 总览](./OVERVIEW.md)) +> 最后更新:2026-06-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md)) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(25 张业务表 + `alembic_version` 框架表) +## 表总览(28 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| | `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) | +| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) | | `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) | ### 钱包 / 福利(看广告赚钱闭环) @@ -48,6 +49,11 @@ | `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) | | `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) | +### 美团 CPS 券缓存 +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) | + ### 首页门面数据 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| diff --git a/docs/database/meituan_coupon.md b/docs/database/meituan_coupon.md new file mode 100644 index 0000000..fe763a5 --- /dev/null +++ b/docs/database/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`。 diff --git a/docs/database/onboarding_completion.md b/docs/database/onboarding_completion.md new file mode 100644 index 0000000..8e450c1 --- /dev/null +++ b/docs/database/onboarding_completion.md @@ -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)。 diff --git a/docs/database/ops_marquee_seed.md b/docs/database/ops_marquee_seed.md index 639ec2e..7de9bdb 100644 --- a/docs/database/ops_marquee_seed.md +++ b/docs/database/ops_marquee_seed.md @@ -2,13 +2,13 @@ > 模型 `app/models/ops_marquee_seed.py` | 关联接口 [platform-savings-feed](../api/platform-savings-feed.md) / [admin-marquee-seeds](../api/admin-marquee-seeds.md) | [← 表索引](./README.md) -首页「用户****xxx 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/ops_marquee.py`。 +首页「XXX 比价后节省 xx 元」轮播的兜底假数据。轮播真实数据源是全平台比价记录,真实不足 N 条时用本表启用的种子补齐「混播」,保证轮播不空。种子是「生成规则」而非死记录:用户名可空(→随机合成手机尾号/中文昵称混合名)、金额是区间(→随机取值)。运营后台增删改。逻辑见 `app/repositories/ops_marquee.py`。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明 | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,如 `用户********a52`(整串存)。**留空 → feed 展示时按脱敏格式随机合成**(`用户********`+3 位 hex,避开同屏撞名),风格与真实条一致、永不穿帮 | +| `masked_user` | String(64) | NULL 可空 | 脱敏用户名,手机尾号(`138****5678`)/ 中文昵称(`省钱**`)风格(整串存)。**留空或旧 `用户****xxx` 模板名 → feed 展示时随机合成混合风格名**(避开同屏撞名、自愈历史种子),风格与真实条一致、永不穿帮 | | `min_cents` | Integer | NOT NULL | 节省金额区间下限(分) | | `max_cents` | Integer | NOT NULL | 节省金额区间上限(分);feed 每次在 `[min,max]` 随机取值,**固定金额则 min==max** | | `enabled` | Boolean | NOT NULL, default true | 停用的不参与混播 | diff --git a/docs/database/ops_stat_config.md b/docs/database/ops_stat_config.md index fe738be..d719be4 100644 --- a/docs/database/ops_stat_config.md +++ b/docs/database/ops_stat_config.md @@ -21,7 +21,7 @@ | `random_kind` | String(8) | NOT NULL, default `mult` | 自增长方式:`mult` ×倍率 / `add` +绝对增量 | | `random_step_min` | Integer | NOT NULL, default 0 | add 模式增量下限(基础单位) | | `random_step_max` | Integer | NOT NULL, default 0 | add 模式增量上限(基础单位) | -| `real_offset` | Integer | NOT NULL, default 0 | real 模式基数偏移(基础单位):展示 = 真实值 + 偏移 | +| `real_offset` | Integer | NOT NULL, default 0 | real 模式保底值(基础单位):展示 = max(真实值, 保底值)。列名沿用 real_offset(语义已从"偏移"改为"保底") | | `allow_decrease` | Boolean | NOT NULL, default false | 是否允许展示值下降(默认 false = 只增不减) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 更新时间 | @@ -39,7 +39,7 @@ `random_current` 现是**所有模式**的「当前展示值」(不止 random),用户侧 `/stats` 直接返回它。 - 触发边界 = 北京时间 `anchor + k*interval`(interval=`random_tick_seconds`,anchor=`random_anchor_minutes*60`,sub-day 间隔取 `anchor % interval` 作相位)。例:每天 09:00 / 每小时 :30 / 每 5 分刻度。 - 读取(`get_display_values`/`get_config`)时跑 `_refresh`:统计 `random_last_tick_at`→`now` 跨过几个边界 N,N≥1 才刷新: - - **real** → `random_current` = 重新查库的真实值 + `real_offset`(跨多少边界都只取最新),**经只增不减护栏**。 + - **real** → `random_current` = `max(重新查库的真实值, real_offset 保底值)`(跨多少边界都只取最新),**经只增不减护栏**。 - **manual** → `random_current` = 当前 `manual_value`,**经只增不减护栏**。 - **random** → 按 `random_kind` 走 N 个周期:`mult` 连乘随机倍率(`randint(min,max)/1000`,恒 ≥1.0)/ `add` 连加随机增量(`randint(step_min,step_max)`,≥0)。天然只增。 - **只增不减护栏**(`allow_decrease=false`,默认):real/manual 刷新时若新目标值 < 当前 `random_current`,保持当前值不回退(门面忌缩水)。开 `allow_decrease` 才允许下降。 diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..1a83f75 --- /dev/null +++ b/run.bat @@ -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 diff --git a/scripts/pull_meituan_coupons.py b/scripts/pull_meituan_coupons.py index 7bd6837..aaefdc2 100644 --- a/scripts/pull_meituan_coupons.py +++ b/scripts/pull_meituan_coupons.py @@ -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") diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index 67bc719..9fd7546 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -160,6 +160,48 @@ def test_set_user_status_and_audit(admin_client: TestClient, operator_token: str ).status_code == 200 +# ===== 一键开启新手引导 ===== + +def test_set_force_onboarding_and_audit(admin_client: TestClient, operator_token: str) -> None: + uid = _seed_user("13900000009") + r = admin_client.post( + f"/admin/api/users/{uid}/force-onboarding", json={"enabled": True}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + db = SessionLocal() + try: + assert db.get(User, uid).force_onboarding is True + logs = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "user.force_onboarding.set", + AdminAuditLog.target_id == str(uid), + ) + ).scalars().all() + assert logs[0].detail == {"before": False, "after": True} + finally: + db.close() + # 取消(回到 false) + assert admin_client.post( + f"/admin/api/users/{uid}/force-onboarding", json={"enabled": False}, + headers=_auth(operator_token), + ).status_code == 200 + db = SessionLocal() + try: + assert db.get(User, uid).force_onboarding is False + finally: + db.close() + + +def test_finance_cannot_force_onboarding(admin_client: TestClient, finance_token: str) -> None: + uid = _seed_user("13900000010") + r = admin_client.post( + f"/admin/api/users/{uid}/force-onboarding", json={"enabled": True}, + headers=_auth(finance_token), + ) + assert r.status_code == 403 + + # ===== 角色守卫 ===== def test_operator_cannot_grant_coins(admin_client: TestClient, operator_token: str) -> None: diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py new file mode 100644 index 0000000..46fd73a --- /dev/null +++ b/tests/test_onboarding.py @@ -0,0 +1,97 @@ +"""新手引导"按 设备+账号 只触发一次"的后端闭环测试。 + +覆盖: +- 全新 (账号, 设备) 登录 → 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 + + +def test_complete_clears_force_onboarding(client) -> None: + """运营「一键开启新手引导」(force_onboarding=True)后,用户走完引导即自动清除该标记, + /me 随之回到 false——否则下次启动会被无限拉回引导。""" + from app.db.session import SessionLocal + from app.models.user import User + + phone = "13800138004" + dev = "android-id-force" + data = _login(client, phone, dev) + access = data["access_token"] + uid = data["user"]["id"] + + # 模拟运营在后台一键开启(admin 接口本身另在 test_admin_write 覆盖) + db = SessionLocal() + try: + db.get(User, uid).force_onboarding = True + db.commit() + finally: + db.close() + + def _me() -> dict: + return client.get( + "/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"} + ).json() + + assert _me()["force_onboarding"] is True + assert _mark_complete(client, access, dev).status_code == 200 + assert _me()["force_onboarding"] is False