From b4a2a8c31ddc850b1ce458d060bcfee20c99449a Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 11:14:31 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E9=99=90?= =?UTF-8?q?=E5=88=B6=E6=AF=8F=E4=BD=8D=E7=94=A8=E6=88=B7=E6=AF=8F=E5=A4=A9?= =?UTF-8?q?=E6=9C=80=E5=A4=9A=E5=8F=91=E8=B5=B7=20100=20=E6=AC=A1=E6=AF=94?= =?UTF-8?q?=E4=BB=B7=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 变更内容 - 登录用户按北京时间自然日计算比价发起次数,每人每天最多 100 次。 - 第 101 次起返回 HTTP 429,并提示“今日已比价超过100次,请明天再试”。 - 同一 trace_id 的网络重试按幂等处理,不会重复计数。 - 使用用户行锁串行化同一账号的并发请求,避免并发突破上限。 - 复用现有 comparison_record 的 running 记录,无需新增数据库迁移。 ## 本地验证 - 比价额度专项测试 11 项通过。 - 本次修改涉及文件的 Ruff 检查通过。 - 已覆盖未登录、重复 trace_id、跨自然日、第 100 次放行及第 101 次拒绝。 --------- Co-authored-by: CodexSandboxOffline <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/165 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/api/v1/compare_record.py | 37 +++++++++ app/repositories/comparison.py | 85 ++++++++++++++++++++- app/schemas/compare_record.py | 15 +++- tests/test_compare_daily_limit.py | 120 ++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 tests/test_compare_daily_limit.py diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index f1d67d6..5afc8ac 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -20,6 +20,8 @@ from app.db.session import SessionLocal from app.models.comparison import ComparisonRecord from app.repositories import comparison as crud_compare from app.schemas.compare_record import ( + CompareStartReserveIn, + CompareStartReserveOut, CompareStatsOut, ComparisonRecordCreatedOut, ComparisonRecordDetailOut, @@ -35,6 +37,41 @@ logger = logging.getLogger("shagua.compare_record") router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"]) +@router.post( + "/start", + response_model=CompareStartReserveOut, + summary="预占一次当日比价发起次数(每人每天最多100次)", +) +def reserve_compare_start( + payload: CompareStartReserveIn, + user: CurrentUser, + db: DbSession, +) -> CompareStartReserveOut: + try: + _, used = crud_compare.reserve_daily_start( + db, + user_id=user.id, + trace_id=payload.trace_id, + business_type=payload.business_type, + device_id=payload.device_id, + ) + except crud_compare.DailyCompareStartLimitExceeded: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="今日已比价超过100次,请明天再试", + ) from None + except crud_compare.ComparisonTraceOwnershipError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="比价任务标识冲突,请重新发起", + ) from None + return CompareStartReserveOut( + limit=crud_compare.DAILY_COMPARE_START_LIMIT, + used=used, + remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0), + ) + + @router.post( "/record", response_model=ComparisonRecordCreatedOut, diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 7dceeab..53602de 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -5,7 +5,7 @@ """ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, defer @@ -14,8 +14,19 @@ from app.core.rewards import CN_TZ from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.comparison import ComparisonRecord from app.models.savings import SavingsRecord +from app.models.user import User from app.schemas.compare_record import ComparisonRecordIn +DAILY_COMPARE_START_LIMIT = 100 + + +class DailyCompareStartLimitExceeded(Exception): + """The authenticated user has consumed today's comparison-start quota.""" + + +class ComparisonTraceOwnershipError(Exception): + """A trace id already belongs to a different authenticated user.""" + def _yuan_to_cents(yuan: float | None) -> int | None: """元(float)→ 分(int)。None 透传。""" @@ -243,6 +254,78 @@ def _get_by_trace(db: Session, trace_id: str) -> ComparisonRecord | None: ).scalar_one_or_none() +def reserve_daily_start( + db: Session, + *, + user_id: int, + trace_id: str, + business_type: str = "food", + device_id: str | None = None, + now: datetime | None = None, +) -> tuple[ComparisonRecord, int]: + """Atomically reserve one of a user's 100 Beijing-day comparison starts. + + ``trace_id`` makes client retries idempotent. Locking the user row serializes + concurrent starts for one account, so parallel requests cannot both consume + the final available slot. The reservation is the existing ``running`` + comparison row; later result reporting updates that same row. + """ + db.execute(select(User.id).where(User.id == user_id).with_for_update()).scalar_one() + + existing = _get_by_trace(db, trace_id) + if existing is not None: + if existing.user_id not in (None, user_id): + raise ComparisonTraceOwnershipError + if existing.user_id is None: + existing.user_id = user_id + if existing.device_id is None and device_id: + existing.device_id = device_id + db.commit() + db.refresh(existing) + + existing_at = existing.created_at + if existing_at.tzinfo is not None: + existing_at = existing_at.astimezone(CN_TZ).replace(tzinfo=None) + day_start = existing_at.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + used = db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.user_id == user_id, + ComparisonRecord.created_at >= day_start, + ComparisonRecord.created_at < day_end, + ) + ) or 0 + return existing, int(used) + + current = now or datetime.now(CN_TZ) + if current.tzinfo is not None: + current = current.astimezone(CN_TZ).replace(tzinfo=None) + day_start = current.replace(hour=0, minute=0, second=0, microsecond=0) + day_end = day_start + timedelta(days=1) + used = db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.user_id == user_id, + ComparisonRecord.created_at >= day_start, + ComparisonRecord.created_at < day_end, + ) + ) or 0 + if used >= DAILY_COMPARE_START_LIMIT: + raise DailyCompareStartLimitExceeded + + rec = ComparisonRecord( + trace_id=trace_id, + user_id=user_id, + business_type=business_type or "food", + device_id=device_id, + status="running", + created_at=current, + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec, int(used) + 1 + + def harvest_running( db: Session, *, diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index 91ccd36..3887bf0 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -13,7 +13,6 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, field_validator - # ===== 上报请求 ===== class ComparisonItemIn(BaseModel): @@ -198,6 +197,20 @@ class ComparisonRecordCreatedOut(BaseModel): id: int = Field(..., description="写入(或已存在)的记录 id") +class CompareStartReserveIn(BaseModel): + """Reserve one authenticated comparison start before the agent begins.""" + + trace_id: str = Field(..., min_length=1, max_length=64) + business_type: str = Field(default="food", min_length=1, max_length=16) + device_id: str | None = Field(default=None, max_length=64) + + +class CompareStartReserveOut(BaseModel): + limit: int + used: int + remaining: int + + class CompareStatsOut(BaseModel): """「我的」页省钱战绩卡(比价口径)聚合。""" diff --git a/tests/test_compare_daily_limit.py b/tests/test_compare_daily_limit.py new file mode 100644 index 0000000..a10afec --- /dev/null +++ b/tests/test_compare_daily_limit.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import time +from datetime import datetime, timedelta + +from sqlalchemy import func, select + +from app.core.rewards import CN_TZ +from app.core.security import decode_token +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord + + +def _login(client) -> tuple[str, int]: + phone = f"137{int(time.time() * 1000) % 100000000:08d}" + sent = client.post("/api/v1/auth/sms/send", json={"phone": phone}) + assert sent.status_code == 200, sent.text + logged_in = client.post( + "/api/v1/auth/sms/login", + json={"phone": phone, "code": "123456"}, + ) + assert logged_in.status_code == 200, logged_in.text + token = logged_in.json()["access_token"] + return token, int(decode_token(token, expected_type="access")["sub"]) + + +def _headers(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def test_compare_start_requires_login(client) -> None: + response = client.post( + "/api/v1/compare/start", + json={"trace_id": "quota-no-auth", "business_type": "food"}, + ) + assert response.status_code == 401 + + +def test_compare_start_is_idempotent_by_trace_id(client) -> None: + token, user_id = _login(client) + payload = { + "trace_id": f"quota-idempotent-{user_id}", + "business_type": "ecom", + "device_id": "quota-device", + } + + first = client.post("/api/v1/compare/start", json=payload, headers=_headers(token)) + retry = client.post("/api/v1/compare/start", json=payload, headers=_headers(token)) + + assert first.status_code == 200, first.text + assert first.json() == {"limit": 100, "used": 1, "remaining": 99} + assert retry.status_code == 200, retry.text + assert retry.json() == first.json() + with SessionLocal() as db: + count = db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.trace_id == payload["trace_id"] + ) + ) + record = db.execute( + select(ComparisonRecord).where( + ComparisonRecord.trace_id == payload["trace_id"] + ) + ).scalar_one() + assert count == 1 + assert record.user_id == user_id + assert record.status == "running" + assert record.business_type == "ecom" + assert record.device_id == "quota-device" + + +def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None: + token, user_id = _login(client) + now = datetime.now(CN_TZ).replace(tzinfo=None) + with SessionLocal() as db: + db.add_all( + [ + ComparisonRecord( + user_id=user_id, + trace_id=f"quota-full-{user_id}-{index}", + status="failed", + created_at=now, + ) + for index in range(99) + ] + ) + db.add( + ComparisonRecord( + user_id=user_id, + trace_id=f"quota-yesterday-{user_id}", + status="success", + created_at=now - timedelta(days=1), + ) + ) + db.commit() + + final_allowed_trace = f"quota-final-allowed-{user_id}" + allowed = client.post( + "/api/v1/compare/start", + json={"trace_id": final_allowed_trace, "business_type": "food"}, + headers=_headers(token), + ) + assert allowed.status_code == 200, allowed.text + assert allowed.json() == {"limit": 100, "used": 100, "remaining": 0} + + rejected_trace = f"quota-rejected-{user_id}" + response = client.post( + "/api/v1/compare/start", + json={"trace_id": rejected_trace, "business_type": "food"}, + headers=_headers(token), + ) + + assert response.status_code == 429 + assert response.json()["detail"] == "今日已比价超过100次,请明天再试" + with SessionLocal() as db: + assert db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.trace_id == rejected_trace + ) + ) == 0 From 66527f6cdc63a13a3627574779a728b8a19aea1a Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 11:15:09 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E9=A2=86?= =?UTF-8?q?=E5=88=B8=E5=8D=95=E5=88=B8=E6=88=90=E5=8A=9F=E7=8E=87=E6=8C=89?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=8B=AC=E7=AB=8B=E7=BB=9F=E8=AE=A1=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修改内容 - 新增按 `trace_id + coupon_id` 幂等的逐次单券事件表。 - 保留每日资产记录,同时独立保存每次领券事件,避免同设备同日重跑串场。 - 后台逐场成功率和单券明细改为读取逐次事件。 - 增加历史数据回填迁移、本地 mock 脚本和回归测试。 ## 验证结果 - 领券相关测试:22 项通过。 - 全新 SQLite 数据库执行 `alembic upgrade head`:通过。 - 全量测试:508 项通过;另外 8 项为现有无关失败。 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/166 Co-authored-by: linkeyu Co-committed-by: linkeyu --- alembic/versions/coupon_claim_event.py | 93 ++++++++++++ app/admin/repositories/coupon_data.py | 24 +-- app/admin/schemas/coupon_data.py | 4 +- app/api/v1/coupon.py | 4 +- app/models/__init__.py | 1 + app/models/coupon_state.py | 34 +++++ app/repositories/coupon_state.py | 41 ++++- scripts/seed_coupon_session_mock.py | 197 +++++++++++++++++++++++++ tests/test_coupon_claim_event.py | 54 +++++++ tests/test_coupon_point_score.py | 20 +-- tests/test_coupon_slots.py | 3 +- 11 files changed, 446 insertions(+), 29 deletions(-) create mode 100644 alembic/versions/coupon_claim_event.py create mode 100644 scripts/seed_coupon_session_mock.py create mode 100644 tests/test_coupon_claim_event.py diff --git a/alembic/versions/coupon_claim_event.py b/alembic/versions/coupon_claim_event.py new file mode 100644 index 0000000..8161330 --- /dev/null +++ b/alembic/versions/coupon_claim_event.py @@ -0,0 +1,93 @@ +"""add per-session coupon claim event table + +Revision ID: coupon_claim_event +Revises: 8e04cc13a211 +Create Date: 2026-07-23 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "coupon_claim_event" +down_revision: str | Sequence[str] | None = "8e04cc13a211" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") + + +def upgrade() -> None: + op.create_table( + "coupon_claim_event", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("trace_id", sa.String(length=64), nullable=False), + sa.Column("device_id", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("coupon_id", sa.String(length=64), nullable=False), + sa.Column("claim_date", sa.Date(), nullable=False), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("app_env", sa.String(length=16), nullable=True), + sa.Column("vendor", sa.String(length=48), nullable=True), + sa.Column("coupon_name", sa.String(length=128), nullable=True), + sa.Column("claimed_count", sa.Integer(), nullable=True), + sa.Column("reason", sa.String(length=255), nullable=True), + sa.Column("extra", _JSON, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "trace_id", "coupon_id", + name="uq_coupon_claim_event_trace_coupon", + ), + ) + op.create_index( + "ix_coupon_claim_event_date_env", + "coupon_claim_event", + ["claim_date", "app_env"], + unique=False, + ) + op.create_index( + op.f("ix_coupon_claim_event_app_env"), + "coupon_claim_event", + ["app_env"], + unique=False, + ) + op.create_index( + op.f("ix_coupon_claim_event_trace_id"), + "coupon_claim_event", + ["trace_id"], + unique=False, + ) + op.create_index( + op.f("ix_coupon_claim_event_user_id"), + "coupon_claim_event", + ["user_id"], + unique=False, + ) + + # 旧表只能回填当前仍保留的 trace;历史上已被每日去重覆盖的关联无法恢复。 + op.execute( + """ + INSERT INTO coupon_claim_event ( + trace_id, device_id, user_id, coupon_id, claim_date, status, app_env, + vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at + ) + SELECT + trace_id, device_id, user_id, coupon_id, claim_date, status, app_env, + vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at + FROM coupon_claim_record + WHERE trace_id IS NOT NULL + """ + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_coupon_claim_event_user_id"), table_name="coupon_claim_event") + op.drop_index(op.f("ix_coupon_claim_event_trace_id"), table_name="coupon_claim_event") + op.drop_index(op.f("ix_coupon_claim_event_app_env"), table_name="coupon_claim_event") + op.drop_index("ix_coupon_claim_event_date_env", table_name="coupon_claim_event") + op.drop_table("coupon_claim_event") diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 5ae22d4..f7f8179 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -16,7 +16,7 @@ from sqlalchemy import case, func, or_, select from sqlalchemy.orm import Session from app.core import rewards -from app.models.coupon_state import CouponClaimRecord, CouponSession +from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord, CouponSession from app.models.user import User from app.repositories import ad_ecpm as crud_ecpm from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform @@ -193,18 +193,18 @@ def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[ """聚合查询批量返回逐场点位分数,不加载逐券明细。""" if not trace_ids: return {} - succeeded = func.sum(case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)) + succeeded = func.sum(case((CouponClaimEvent.status.in_(_SLOT_OK), 1), else_=0)) rows = db.execute( select( - CouponClaimRecord.trace_id, + CouponClaimEvent.trace_id, succeeded.label("succeeded"), func.count().label("tried"), ) .where( - CouponClaimRecord.trace_id.in_(trace_ids), - CouponClaimRecord.status.in_(_SLOT_TRIED), + CouponClaimEvent.trace_id.in_(trace_ids), + CouponClaimEvent.status.in_(_SLOT_TRIED), ) - .group_by(CouponClaimRecord.trace_id) + .group_by(CouponClaimEvent.trace_id) ).all() return { trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)} @@ -217,13 +217,13 @@ def coupon_point_details(db: Session, *, trace_id: str) -> list[dict]: """按单个 trace 查询逐券结果;仅在后台用户点击分数时调用。""" rows = db.execute( select( - CouponClaimRecord.coupon_id, - CouponClaimRecord.coupon_name, - CouponClaimRecord.status, - CouponClaimRecord.reason, + CouponClaimEvent.coupon_id, + CouponClaimEvent.coupon_name, + CouponClaimEvent.status, + CouponClaimEvent.reason, ) - .where(CouponClaimRecord.trace_id == trace_id) - .order_by(CouponClaimRecord.id) + .where(CouponClaimEvent.trace_id == trace_id) + .order_by(CouponClaimEvent.id) ).all() return [ { diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index f295650..6b19fd7 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -79,10 +79,10 @@ class CouponDataRow(BaseModel): started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None point_success_count: int | None = Field( - None, description="本次成功券点位数(success+already_claimed);无逐券埋点为空" + None, description="本次成功单券数(success+already_claimed);无逐券事件为空" ) point_total_count: int | None = Field( - None, description="本次尝试券点位数(success+already_claimed+failed,不含 skipped);无逐券埋点为空" + None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空" ) trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") ad_revenue_yuan: float = Field( diff --git a/app/api/v1/coupon.py b/app/api/v1/coupon.py index bd2127a..ecd40a8 100644 --- a/app/api/v1/coupon.py +++ b/app/api/v1/coupon.py @@ -81,7 +81,7 @@ def _record_claims_blocking( device_id: str, user_id: int | None, trace_id: str | None, results: list[dict] ) -> None: with SessionLocal() as db: - # 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。 + # 取本次 session 环境,给每日资产和逐次事件同时打环境标。 app_env = coupon_repo.session_app_env(db, trace_id) coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env) # 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率; @@ -176,7 +176,7 @@ async def coupon_step( resp_json = resp.json() - # 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。 + # 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。 # 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。 if device_id: results = _extract_coupon_results(resp_json) diff --git a/app/models/__init__.py b/app/models/__init__.py index 5ed8a6b..dd4f609 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -21,6 +21,7 @@ from app.models.cps_wx_user import CpsWxUser # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 from app.models.device import DeviceLiveness # noqa: F401 from app.models.coupon_state import ( # noqa: F401 + CouponClaimEvent, CouponClaimRecord, CouponDailyCompletion, CouponPromptEngagement, diff --git a/app/models/coupon_state.py b/app/models/coupon_state.py index d461613..6d52c53 100644 --- a/app/models/coupon_state.py +++ b/app/models/coupon_state.py @@ -96,6 +96,40 @@ class CouponClaimRecord(Base): ) +class CouponClaimEvent(Base): + """一次领券任务中的单券结果,按 ``(trace_id, coupon_id)`` 幂等。""" + + __tablename__ = "coupon_claim_event" + __table_args__ = ( + UniqueConstraint( + "trace_id", "coupon_id", + name="uq_coupon_claim_event_trace_coupon", + ), + Index("ix_coupon_claim_event_date_env", "claim_date", "app_env"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + trace_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True) + coupon_id: Mapped[str] = mapped_column(String(64), nullable=False) + claim_date: Mapped[date] = mapped_column(Date, nullable=False) + status: Mapped[str] = mapped_column(String(24), nullable=False) + app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True) + vendor: Mapped[str | None] = mapped_column(String(48), nullable=True) + coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + extra: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), + nullable=False, + ) + + class CouponDailyCompletion(Base): """按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。 diff --git a/app/repositories/coupon_state.py b/app/repositories/coupon_state.py index 508da74..04ce173 100644 --- a/app/repositories/coupon_state.py +++ b/app/repositories/coupon_state.py @@ -14,6 +14,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models.coupon_state import ( + CouponClaimEvent, CouponClaimRecord, CouponDailyCompletion, CouponPromptEngagement, @@ -164,11 +165,12 @@ def record_claims( results: list[dict], app_env: str | None = None, ) -> int: - """一批券领取结果幂等写入,返回写入(新增 + 更新)条数。 + """一批券领取结果同时写入每日资产表和逐次事件表。 results 单项取自 pricebot 的 last_coupon_result / done.coupon_results,识别字段: coupon_id(必需)/ status(必需)/ name / vendor / reason /(display_count)。 - (device, coupon_id, 今天) 唯一:重复上报同张券走更新(status 以最后一次为准)。 + - CouponClaimRecord 按 (device, coupon_id, 今天) 幂等,供每日资产口径使用。 + - CouponClaimEvent 按 (trace_id, coupon_id) 幂等,供 admin 逐场统计使用。 """ today = today_cn() written = 0 @@ -208,6 +210,41 @@ def record_claims( claimed_count=count, trace_id=trace_id, reason=r.get("reason"), extra=r, )) + if trace_id: + event = db.execute( + select(CouponClaimEvent).where( + CouponClaimEvent.trace_id == trace_id, + CouponClaimEvent.coupon_id == coupon_id, + ) + ).scalar_one_or_none() + if event is not None: + event.device_id = device_id + event.status = status + event.reason = r.get("reason") + event.vendor = r.get("vendor") + event.coupon_name = r.get("name") + event.extra = r + if user_id is not None: + event.user_id = user_id + if count is not None: + event.claimed_count = count + if app_env is not None: + event.app_env = app_env + else: + db.add(CouponClaimEvent( + trace_id=trace_id, + device_id=device_id, + user_id=user_id, + coupon_id=coupon_id, + claim_date=today, + status=status, + app_env=app_env, + vendor=r.get("vendor"), + coupon_name=r.get("name"), + claimed_count=count, + reason=r.get("reason"), + extra=r, + )) written += 1 if written == 0: return 0 diff --git a/scripts/seed_coupon_session_mock.py b/scripts/seed_coupon_session_mock.py new file mode 100644 index 0000000..7ce614b --- /dev/null +++ b/scripts/seed_coupon_session_mock.py @@ -0,0 +1,197 @@ +"""生成 admin「领券记录」本地联调数据。 + +用法: + python scripts/seed_coupon_session_mock.py + +脚本只清理 ``mock-coupon-repeat-*`` 前缀的数据并重新生成。打开后台「领券记录」, +日期选今天;分别切换 prod/dev,可验证同一设备同一天多次领券仍各自显示正确分数。 +""" + +from __future__ import annotations + +import sys +from datetime import UTC, datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +from sqlalchemy import delete, select + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.db.session import SessionLocal, engine # noqa: E402 +from app.models.coupon_state import ( # noqa: E402 + CouponClaimEvent, + CouponClaimRecord, + CouponSession, +) +from app.models.user import User # noqa: E402 +from app.repositories.coupon_state import record_claims, today_cn # noqa: E402 + +PREFIX = "mock-coupon-repeat-" +CN_TZ = ZoneInfo("Asia/Shanghai") +DEVICE_REPEAT = f"{PREFIX}device" +DEVICE_CONTROL = f"{PREFIX}control-device" +PHONE = "19900009001" +USERNAME = "80000009001" +PLATFORM_ELAPSED = { + "meituan-waimai": 46_800, + "taobao-shanguang": 19_500, + "jd-waimai": 24_000, +} + +FIRST_RESULTS = [ + {"coupon_id": "mt_mock_1", "name": "美团模拟券1", "vendor": "meituan", "status": "success"}, + {"coupon_id": "mt_mock_2", "name": "美团模拟券2", "vendor": "meituan", "status": "success"}, + {"coupon_id": "mt_mock_3", "name": "美团模拟券3", "vendor": "meituan", "status": "success"}, + {"coupon_id": "mt_mock_4", "name": "美团模拟券4", "vendor": "meituan", "status": "failed", "reason": "模拟失败"}, + {"coupon_id": "tb_mock_1", "name": "淘宝模拟券1", "vendor": "taobao", "status": "success"}, + {"coupon_id": "tb_mock_2", "name": "淘宝模拟券2", "vendor": "taobao", "status": "success"}, + {"coupon_id": "tb_mock_3", "name": "淘宝模拟券3", "vendor": "taobao", "status": "success"}, + {"coupon_id": "jd_mock_1", "name": "京东模拟券", "vendor": "jingdong", "status": "success"}, +] + +SECOND_RESULTS = [ + {"coupon_id": "mt_mock_1", "name": "美团模拟券1", "vendor": "meituan", "status": "skipped", "reason": "模拟跳过,不计分母"}, + {"coupon_id": "mt_mock_2", "name": "美团模拟券2", "vendor": "meituan", "status": "already_claimed"}, + {"coupon_id": "mt_mock_3", "name": "美团模拟券3", "vendor": "meituan", "status": "success"}, + {"coupon_id": "mt_mock_4", "name": "美团模拟券4", "vendor": "meituan", "status": "success"}, + {"coupon_id": "tb_mock_1", "name": "淘宝模拟券1", "vendor": "taobao", "status": "success"}, + {"coupon_id": "tb_mock_2", "name": "淘宝模拟券2", "vendor": "taobao", "status": "success"}, + {"coupon_id": "tb_mock_3", "name": "淘宝模拟券3", "vendor": "taobao", "status": "already_claimed"}, + {"coupon_id": "jd_mock_1", "name": "京东模拟券", "vendor": "jingdong", "status": "success"}, +] + + +def _started_at(hour: int, minute: int) -> datetime: + local = datetime.combine(today_cn(), datetime.min.time()).replace( + hour=hour, minute=minute, tzinfo=CN_TZ + ) + return local.astimezone(UTC) + + +def _session( + *, + trace_id: str, + device_id: str, + user_id: int, + app_env: str, + hour: int, + minute: int, + status: str = "completed", + elapsed_ms: int | None = 91_900, + platform_elapsed: dict[str, int] | None = None, +) -> CouponSession: + started_at = _started_at(hour, minute) + return CouponSession( + trace_id=trace_id, + device_id=device_id, + user_id=user_id, + status=status, + app_env=app_env, + platforms=[], + origin_package=None, + device_model="Mock Phone", + rom="MockOS 1", + started_at=started_at, + started_date=today_cn(), + finished_at=started_at if status != "started" else None, + elapsed_ms=elapsed_ms, + platform_elapsed=platform_elapsed, + platform_success=( + ["meituan-waimai", "taobao-shanguang", "jd-waimai"] + if status == "completed" else None + ), + claimed_count=7 if status == "completed" else 0, + ) + + +def main() -> None: + # 本地旧库 Alembic 版本链可能未同步;仅为联调补建新事件表,正式环境仍走 migration。 + CouponClaimEvent.__table__.create(bind=engine, checkfirst=True) + with SessionLocal() as db: + db.execute(delete(CouponClaimEvent).where( + CouponClaimEvent.trace_id.startswith(PREFIX) + )) + db.execute(delete(CouponClaimRecord).where( + CouponClaimRecord.device_id.startswith(PREFIX) + )) + db.execute(delete(CouponSession).where( + CouponSession.trace_id.startswith(PREFIX) + )) + user = db.execute(select(User).where(User.phone == PHONE)).scalar_one_or_none() + if user is None: + user = User( + phone=PHONE, + username=USERNAME, + register_channel="sms", + nickname="领券重复测试", + ) + db.add(user) + db.flush() + + first_trace = f"{PREFIX}dev-first" + second_trace = f"{PREFIX}prod-second" + abandoned_trace = f"{PREFIX}prod-abandoned" + control_trace = f"{PREFIX}prod-control" + db.add_all([ + _session( + trace_id=first_trace, + device_id=DEVICE_REPEAT, + user_id=user.id, + app_env="dev", + hour=10, + minute=0, + platform_elapsed=PLATFORM_ELAPSED, + ), + _session( + trace_id=second_trace, + device_id=DEVICE_REPEAT, + user_id=user.id, + app_env="prod", + hour=15, + minute=0, + platform_elapsed=PLATFORM_ELAPSED, + ), + _session( + trace_id=abandoned_trace, + device_id=DEVICE_REPEAT, + user_id=user.id, + app_env="prod", + hour=16, + minute=0, + status="abandoned", + elapsed_ms=21_500, + platform_elapsed={"meituan-waimai": 20_500}, + ), + _session( + trace_id=control_trace, + device_id=DEVICE_CONTROL, + user_id=user.id, + app_env="prod", + hour=17, + minute=0, + platform_elapsed=PLATFORM_ELAPSED, + ), + ]) + db.commit() + + record_claims( + db, DEVICE_REPEAT, user.id, first_trace, FIRST_RESULTS, app_env="dev" + ) + record_claims( + db, DEVICE_REPEAT, user.id, second_trace, SECOND_RESULTS, app_env="prod" + ) + record_claims( + db, DEVICE_CONTROL, user.id, control_trace, FIRST_RESULTS, app_env="prod" + ) + + print(f"已生成 {today_cn()} 的领券 mock 数据。") + print("筛选用户 19900009001。") + print("prod 应有:7/7(100.0%)、-、7/8(87.5%)三条。") + print("dev 应有:7/8(87.5%)一条。") + + +if __name__ == "__main__": + main() diff --git a/tests/test_coupon_claim_event.py b/tests/test_coupon_claim_event.py new file mode 100644 index 0000000..98316eb --- /dev/null +++ b/tests/test_coupon_claim_event.py @@ -0,0 +1,54 @@ +"""逐次单券事件不能被同设备同日的每日去重记录串场。""" + +from sqlalchemy import delete, select + +from app.admin.repositories.coupon_data import _point_scores_by_trace, coupon_point_details +from app.db.session import SessionLocal +from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord +from app.repositories.coupon_state import record_claims + + +def test_same_device_same_day_keeps_scores_for_each_trace() -> None: + db = SessionLocal() + device = "event-repeat-device" + first_trace = "event-repeat-first" + second_trace = "event-repeat-second" + first_results = [ + {"coupon_id": "mt-repeat", "name": "美团测试券", "status": "success"}, + {"coupon_id": "tb-repeat", "name": "淘宝测试券", "status": "failed"}, + ] + second_results = [ + {"coupon_id": "mt-repeat", "name": "美团测试券", "status": "already_claimed"}, + {"coupon_id": "tb-repeat", "name": "淘宝测试券", "status": "success"}, + ] + try: + record_claims( + db, device, None, first_trace, first_results, app_env="dev" + ) + record_claims( + db, device, None, second_trace, second_results, app_env="prod" + ) + + assets = db.execute( + select(CouponClaimRecord).where(CouponClaimRecord.device_id == device) + ).scalars().all() + assert len(assets) == 2 + assert {row.trace_id for row in assets} == {first_trace} + + events = db.execute( + select(CouponClaimEvent).where(CouponClaimEvent.device_id == device) + ).scalars().all() + assert len(events) == 4 + assert {row.trace_id for row in events} == {first_trace, second_trace} + + scores = _point_scores_by_trace(db, [first_trace, second_trace]) + assert scores[first_trace] == {"succeeded": 1, "tried": 2} + assert scores[second_trace] == {"succeeded": 2, "tried": 2} + assert [row["status"] for row in coupon_point_details( + db, trace_id=second_trace + )] == ["already_claimed", "success"] + finally: + db.execute(delete(CouponClaimEvent).where(CouponClaimEvent.device_id == device)) + db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == device)) + db.commit() + db.close() diff --git a/tests/test_coupon_point_score.py b/tests/test_coupon_point_score.py index 5108fcf..e238f6d 100644 --- a/tests/test_coupon_point_score.py +++ b/tests/test_coupon_point_score.py @@ -13,7 +13,7 @@ from app.admin.repositories.coupon_data import ( ) from app.admin.security import create_admin_token from app.db.session import SessionLocal -from app.models.coupon_state import CouponClaimRecord, CouponSession +from app.models.coupon_state import CouponClaimEvent, CouponSession def test_point_scores_by_trace() -> None: @@ -22,14 +22,14 @@ def test_point_scores_by_trace() -> None: trace = "point-score-trace" try: db.add_all([ - CouponClaimRecord( + CouponClaimEvent( + trace_id=trace, device_id="score-device", coupon_id=f"mt-score-{status}", claim_date=date(2020, 1, 2), status=status, coupon_name=f"测试点位-{status}", reason="测试失败" if status == "failed" else None, - trace_id=trace, ) for status in ("success", "already_claimed", "failed", "skipped") ]) @@ -53,12 +53,12 @@ def test_skipped_detail_does_not_create_a_score() -> None: db = SessionLocal() trace = "point-score-skipped" try: - db.add(CouponClaimRecord( + db.add(CouponClaimEvent( + trace_id=trace, device_id="score-device-skipped", coupon_id="mt-score-skipped-only", claim_date=date(2020, 1, 2), status="skipped", - trace_id=trace, )) db.flush() @@ -87,12 +87,12 @@ def test_coupon_data_report_returns_scores_without_embedding_details() -> None: started_date=report_date, )) db.add_all([ - CouponClaimRecord( + CouponClaimEvent( + trace_id=trace, device_id="score-report-device", coupon_id=f"mt-report-{status}", claim_date=report_date, status=status, - trace_id=trace, ) for status in ("success", "failed") ]) @@ -128,14 +128,14 @@ def test_coupon_point_details_endpoint() -> None: role="super_admin", ) token, _expires_at = create_admin_token(admin_id=admin.id, role=admin.role) - db.add(CouponClaimRecord( + db.add(CouponClaimEvent( + trace_id=trace, device_id="point-details-endpoint-device", coupon_id="mt-point-details-endpoint", coupon_name="接口测试券", claim_date=date(2020, 1, 5), status="failed", reason="接口测试失败", - trace_id=trace, )) db.commit() @@ -156,6 +156,6 @@ def test_coupon_point_details_endpoint() -> None: } finally: db.rollback() - db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.trace_id == trace)) + db.execute(delete(CouponClaimEvent).where(CouponClaimEvent.trace_id == trace)) db.commit() db.close() diff --git a/tests/test_coupon_slots.py b/tests/test_coupon_slots.py index b4f2a5b..10ce6d3 100644 --- a/tests/test_coupon_slots.py +++ b/tests/test_coupon_slots.py @@ -7,7 +7,7 @@ from sqlalchemy import delete, select from app.admin.repositories.coupon_data import coupon_slot_report from app.db.session import SessionLocal -from app.models.coupon_state import CouponClaimRecord, CouponSession +from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord, CouponSession from app.repositories.coupon_state import record_claims, session_app_env @@ -49,6 +49,7 @@ def test_record_claims_stamps_app_env() -> None: assert row.app_env == "prod" assert row.status == "already_claimed" finally: + db.execute(delete(CouponClaimEvent).where(CouponClaimEvent.trace_id == "t-stamp")) db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == dev)) db.commit() db.close() From 71aef455f45689d204656fe32008f0f22cece0ee Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 11:16:38 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=AD=BE?= =?UTF-8?q?=E5=88=B0=E8=86=A8=E8=83=80=E9=87=91=E5=B8=81=E5=BD=92=E5=85=A5?= =?UTF-8?q?=E7=9C=8B=E8=A7=86=E9=A2=91=E5=88=86=E7=B1=BB=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修改内容 - 将历史 `signin_boost` 金币从“常规任务金币”分类移出。 - 将 `signin_boost` 与 `reward_video/ad_reward` 一起计入“看视频金币”。 - 保留独立的 `signin_boost_coin_total` 历史审计字段。 - 增加不重不漏回归测试,确认分类调整前后本期发放总额保持不变。 ## 本地验证 - `tests/test_admin_read.py`:16 项通过。 - Ruff 与 `git diff --check`:通过。 - 全量后端测试:505 项通过;8 项为 `main` 现有无关失败。 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/169 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/stats.py | 7 ++--- tests/test_admin_read.py | 50 ++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/app/admin/repositories/stats.py b/app/admin/repositories/stats.py index e82f5b6..26e13df 100644 --- a/app/admin/repositories/stats.py +++ b/app/admin/repositories/stats.py @@ -30,18 +30,17 @@ from app.models.user import User from app.models.wallet import CoinTransaction, WithdrawOrder _BEIJING = timezone(timedelta(hours=8)) -REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward") +REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward", "signin_boost") # 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景); # coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未 -# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/ -# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。 +# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。 +# reward_video/ad_reward 及历史 signin_boost 均归看视频桶,不再混进领券奖励或常规任务。 COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward") COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward") # 常规任务必须按明确来源相加;不能从全部正向流水反减排除项,否则新增广告/运营 # biz_type 时会在排除清单更新前自动混入该桶。task_ 前缀在查询处单独覆盖现有及未来任务。 REGULAR_TASK_EXACT_BIZ_TYPES = ( "signin", - "signin_boost", "price_report_reward", "feedback_reward", ) diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index d2b6526..a01ca96 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -468,13 +468,13 @@ def test_period_regular_task_coin_uses_explicit_allowlist( d = "2021-06-18" included = { "signin": 100, - "signin_boost": 200, "task_enable_notification": 300, "task_other": 400, "price_report_reward": 500, "feedback_reward": 600, } excluded = { + "signin_boost": 200, "feed_ad_reward_coupon": 700, "feed_ad_reward_comparison": 800, "feed_ad_reward": 900, @@ -514,3 +514,51 @@ def test_period_regular_task_coin_uses_explicit_allowlist( coins = response.json()["period"]["coins"] assert coins["regular_task_coin_total"] == sum(included.values()) assert coins["task_coin_total"] == 700 + assert coins["reward_video_coin_total"] == 1200 + + +def test_period_signin_boost_moves_to_reward_video_without_double_count( + admin_client: TestClient, admin_token: str +) -> None: + """历史签到膨胀归看视频桶,不再进常规任务;本期发放总额不变且不重复计算。""" + from datetime import datetime + + from app.models.wallet import CoinTransaction + + d = "2021-06-19" + rows = [ + ("signin_boost", 200), + ("reward_video", 100), + ("signin", 50), + ] + db = SessionLocal() + try: + uid = user_repo.upsert_user_for_login( + db, phone="13800008805", register_channel="sms" + ).id + balance = 0 + for index, (biz_type, amount) in enumerate(rows, start=1): + balance += amount + db.add(CoinTransaction( + user_id=uid, + amount=amount, + balance_after=balance, + biz_type=biz_type, + ref_id=f"signin-boost-route-{index}", + created_at=datetime(2021, 6, 19, 12, 0, index), + )) + db.commit() + finally: + db.close() + + response = admin_client.get( + "/admin/api/stats/overview", + params={"date_from": d, "date_to": d}, + headers=_auth(admin_token), + ) + assert response.status_code == 200, response.text + coins = response.json()["period"]["coins"] + assert coins["granted_total"] == 350 + assert coins["reward_video_coin_total"] == 300 + assert coins["regular_task_coin_total"] == 50 + assert coins["signin_boost_coin_total"] == 200 From f7a7a49281e1a8aa85a260def05dbc7133137f77 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 24 Jul 2026 11:17:09 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(withdraw):=20=E5=85=8D=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=8E=88=E6=9D=83=E5=B7=B2=E5=BC=80=E5=90=AF=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E5=8A=A0=20authorization=5Fid=20=E9=9D=9E=E7=A9=BA,?= =?UTF-8?q?=E4=B8=8E=E6=89=93=E6=AC=BE=E4=B8=80=E8=87=B4=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 免确认授权已开启判定加 authorization_id 非空,与打款一致 --------- Co-authored-by: guke Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/168 --- app/api/v1/wallet.py | 5 +- app/core/logging.py | 53 +++++- scripts/seed_mock_feedback.py | 277 +++++++++++++++++++++++++++++ scripts/seed_mock_price_reports.py | 4 +- scripts/seed_mock_withdraws.py | 6 +- tests/test_logging_rotation.py | 73 ++++++++ tests/test_withdraw.py | 61 ++++++- 7 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 scripts/seed_mock_feedback.py create mode 100644 tests/test_logging_rotation.py diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 454ff7c..7b0dac0 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -192,7 +192,7 @@ def withdraw_info( wechat_bound=bool(u and u.wechat_openid), wechat_nickname=u.wechat_nickname if u else None, wechat_avatar_url=u.wechat_avatar_url if u else None, - transfer_auth_enabled=bool(auth and auth.state == "active"), + transfer_auth_enabled=bool(auth and auth.state == "active" and auth.authorization_id), tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)], ) @@ -335,7 +335,8 @@ def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOu def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut: auth = crud_wallet.sync_transfer_auth(db, user.id) state = auth.state if auth else "none" - return TransferAuthStatusOut(state=state, enabled=(state == "active")) + enabled = bool(auth and auth.state == "active" and auth.authorization_id) + return TransferAuthStatusOut(state=state, enabled=enabled) @router.post( diff --git a/app/core/logging.py b/app/core/logging.py index cb91b5c..c3833bd 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -22,13 +22,13 @@ from __future__ import annotations import json import logging import os +import shutil import sys from contextvars import ContextVar from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path - # 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool # 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。 trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="") @@ -91,6 +91,55 @@ class TextFormatter(logging.Formatter): return f"{base} trace={tid}" if tid else base +class SafeRotatingFileHandler(RotatingFileHandler): + """Windows 下不会被外部句柄卡死的 RotatingFileHandler。 + + stdlib 轮转靠 rename 活动文件(app-server.log → .1);Windows 只要有别的句柄(IDE 索引、 + app.admin.main 第二进程、残留 --reload worker、杀软扫描)开着它, rename 就 WinError 32, + 轮转永久卡死——文件停在 maxBytes、之后每条日志被丢。这里 Windows 改用 copytruncate:把活动 + 文件拷进备份、再通过自己的句柄原地清空, 从不 rename 活动文件, 故外部句柄开着也能转。 + POSIX(生产 Linux)rename 打开中的文件本就合法, 保留 stdlib 的原子轮转不变。 + + 代价:copytruncate 在“拷贝→清空”极窄窗口内并发写可能丢几行(仅跨进程;同进程 emit 有 + handler 锁串行, 无此问题)。对本地开发日志可接受。 + """ + + def doRollover(self) -> None: + if os.name != "nt": + super().doRollover() + return + if self.stream is None: + self.stream = self._open() + else: + self.stream.flush() + try: + self._copytruncate_backups() + except OSError: + # 备份腾挪是尽力而为:任一备份被占用也绝不能挡住下面的清空, 否则活动文件继续涨、 + # 轮转又卡死——那就白改了。 + pass + # 通过自己独占的句柄原地清空:不涉及 rename, 外部只读句柄不受影响。 + self.stream.seek(0) + self.stream.truncate() + self.stream.flush() + + def _copytruncate_backups(self) -> None: + """把 .N-1→.N 逐级腾挪, 再把活动文件拷到 .1(不动活动文件本身)。""" + if self.backupCount <= 0: + return + for i in range(self.backupCount - 1, 0, -1): + sfn = self.rotation_filename(f"{self.baseFilename}.{i}") + dfn = self.rotation_filename(f"{self.baseFilename}.{i + 1}") + if os.path.exists(sfn): + if os.path.exists(dfn): + os.remove(dfn) + os.replace(sfn, dfn) + dfn = self.rotation_filename(f"{self.baseFilename}.1") + if os.path.exists(dfn): + os.remove(dfn) + shutil.copyfile(self.baseFilename, dfn) + + _CONFIGURED = False @@ -126,7 +175,7 @@ def setup_logging(debug: bool = False) -> None: Path(os.getenv("LOG_DIR", "logs")) / "app-server.log" ) Path(log_file).parent.mkdir(parents=True, exist_ok=True) - file_handler = RotatingFileHandler( + file_handler = SafeRotatingFileHandler( log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", ) file_handler.setFormatter(JsonFormatter(service)) diff --git a/scripts/seed_mock_feedback.py b/scripts/seed_mock_feedback.py new file mode 100644 index 0000000..2754054 --- /dev/null +++ b/scripts/seed_mock_feedback.py @@ -0,0 +1,277 @@ +"""一次性 mock:造几条不同状态的用户反馈,供运营后台「反馈工单」页联调验收。 + +覆盖: + - 三个状态 tab(待审核 pending / 已采纳 adopted / 未采纳 rejected),重点铺「待审核」; + - 两种反馈类型 source(普通反馈 profile /「我的」页入口、比价反馈 comparison / 比价结果页入口), + 比价反馈带「问题场景」scene(找错商品/优惠不对/比价太慢…); + - 提交端环境快照(app_version / device_model / rom_name / android_version)——新端反馈才有, + 另留 1~2 条历史反馈(env 全 NULL、contact 有值)测「旧数据」展示; + - 截图:在 data/media/feedback/ 生成真实可加载的纯色 PNG(手写字节,无需 Pillow), + 让审核抽屉的图能真加载出来(与 seed_mock_price_reports 同法)。 + - 已采纳条带 reward_coins + admin_reply + review_note;未采纳条带 reject_reason + admin_reply。 + +幂等:每次运行先按固定 mock 手机号清掉旧 mock 用户/反馈 + 删 mock 截图再重建。仅清理用 --clean-only。 + + .venv\\Scripts\\python.exe scripts\\seed_mock_feedback.py + .venv\\Scripts\\python.exe scripts\\seed_mock_feedback.py --clean-only + +看图:前端「反馈工单」页(http://localhost:3001 → 反馈)。图经 NEXT_PUBLIC_MEDIA_BASE +(本地 = http://localhost:8770)由 App 后端 /media 加载——改过 .env.local 后需重启 next dev, +且 App 后端(:8770)要在跑。 +""" +from __future__ import annotations + +import argparse +import struct +import sys +import zlib +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from sqlalchemy import delete, select + +from app.core.config import settings +from app.db.session import SessionLocal +from app.models.feedback import Feedback +from app.models.user import User +from app.repositories.user import _gen_unique_username + +# Windows GBK 控制台下也能正常打印中文/¥(避免 UnicodeEncodeError) +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +# 固定 mock 手机号:脚本只动这些号,便于幂等重建 / 清理。 +# 刻意与 seed_mock_withdraws / seed_mock_price_reports 的号段错开,互不干扰。 +MOCK_PHONES = [ + "13255550001", + "13255550002", + "13255550003", + "13255550004", + "13255550005", +] + +_FEEDBACK_DIR = Path(settings.MEDIA_ROOT) / "feedback" +_MOCK_IMG_GLOB = "mock_fb_*.png" # 本脚本生成的图前缀,清理时按此删 + + +def _naive_utc_now() -> datetime: + """与 func.now() 在 SQLite 的口径一致:naive UTC。反馈 created_at 走 server_default=func.now(), + 这里显式造数据也用 naive UTC,和真实反馈行同源,前端展示口径一致。""" + return datetime.now(UTC).replace(tzinfo=None) + + +def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes: + """生成一张纯色 PNG(truecolor RGB)的字节,无需 Pillow。颜色块即可肉眼判断「图加载出来了」。""" + def _chunk(typ: bytes, data: bytes) -> bytes: + body = typ + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB + row = b"\x00" + bytes(rgb) * width # 每行前缀 filter byte 0 + idat = zlib.compress(row * height, 9) + return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"") + + +def _write_mock_image(name: str, rgb: tuple[int, int, int]) -> str: + """写一张 mock 截图到 media/feedback/,返回其相对 URL(/media/feedback/)。""" + _FEEDBACK_DIR.mkdir(parents=True, exist_ok=True) + (_FEEDBACK_DIR / name).write_bytes(_solid_png(320, 320, rgb)) + return f"{settings.MEDIA_URL_PREFIX}/feedback/{name}" + + +def clean(db) -> int: + uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars()) + if uids: + db.execute(delete(Feedback).where(Feedback.user_id.in_(uids))) + db.execute(delete(User).where(User.id.in_(uids))) + db.commit() + # 删 mock 截图文件 + if _FEEDBACK_DIR.exists(): + for f in _FEEDBACK_DIR.glob(_MOCK_IMG_GLOB): + f.unlink(missing_ok=True) + return len(uids) + + +def seed(db) -> list[Feedback]: + now = _naive_utc_now() + + def ago(**kw) -> datetime: + return now - timedelta(**kw) + + # 1) 建 5 个 mock 用户(U5 昵称留空测 "-" 展示) + users_spec = [ + ("13255550001", "反馈小达人"), + ("13255550002", "比价挑刺王"), + ("13255550003", "热心用户阿明"), + ("13255550004", "老用户张姐"), + ("13255550005", None), + ] + users: dict[str, User] = {} + for phone, nickname in users_spec: + u = User( + phone=phone, + username=_gen_unique_username(db), # 列 NOT NULL + unique,仿真实注册生成 + nickname=nickname, + register_channel="sms", + status="active", + created_at=ago(days=15), + last_login_at=ago(hours=1), + ) + db.add(u) + users[phone] = u + db.flush() # 拿 user.id + + # 2) 生成 mock 截图(不同颜色块,便于肉眼区分「都加载出来了」) + palette = [ + (24, 144, 255), # 蓝 + (82, 196, 26), # 绿 + (250, 173, 20), # 橙 + (245, 34, 45), # 红 + ] + imgs = [_write_mock_image(f"mock_fb_{i}.png", palette[i]) for i in range(len(palette))] + + # 3) 造反馈记录 + def fb( + phone: str, + content: str, + *, + source: str = "profile", + scene: str | None = None, + images: list[str] | None = None, + contact: str = "", + status: str = "pending", + reject_reason: str | None = None, + reward_coins: int | None = None, + review_note: str | None = None, + admin_reply: str | None = None, + app_version: str | None = None, + device_model: str | None = None, + rom_name: str | None = None, + android_version: str | None = None, + created: datetime, + ) -> Feedback: + return Feedback( + user_id=users[phone].id, + content=content, + contact=contact, # 列 NOT NULL:新端存空串,历史数据有值 + source=source, + scene=scene, + images=images, + status=status, + reject_reason=reject_reason, + reward_coins=reward_coins, + review_note=review_note, + admin_reply=admin_reply, + app_version=app_version, + device_model=device_model, + rom_name=rom_name, + android_version=android_version, + reviewed_at=(created + timedelta(hours=2)) if status != "pending" else None, + created_at=created, + ) + + feedbacks = [ + # ===== 待审核 pending(默认 tab,重点铺量)===== + # 普通反馈 · 新端(带环境快照)· 无图 + fb("13255550001", "签到金币到账有时候会延迟一两分钟,能不能做成实时到账?", + source="profile", + app_version="2.3.1", device_model="PJA110", rom_name="ColorOS", android_version="14", + created=ago(minutes=6)), + # 比价反馈 · scene=优惠不对 · 新端 · 2 图 + fb("13255550002", "这家店京东外卖的到手价比你们算出来的最低价还低,截图为证,麻烦核实。", + source="comparison", scene="优惠不对", images=[imgs[0], imgs[1]], + app_version="2.3.1", device_model="M2012K11AC", rom_name="MIUI", android_version="13", + created=ago(minutes=22)), + # 比价反馈 · scene=找错商品 · 新端 · 无图 + fb("13255550002", "比价结果里的商品跟我搜的不是同一个规格,数量对不上。", + source="comparison", scene="找错商品", + app_version="2.3.0", device_model="V2309A", rom_name="OriginOS", android_version="14", + created=ago(hours=1)), + # 普通反馈 · 新端 · 1 图(表扬 + 小问题) + fb("13255550003", "提现秒到账,好评!顺手反馈个小 bug:金币记录页偶尔白屏,要退出去重进。", + source="profile", images=[imgs[2]], + app_version="2.3.1", device_model="23078RKD5C", rom_name="MIUI", android_version="14", + created=ago(hours=3)), + # 比价反馈 · scene=比价太慢 · 历史数据(env 全 NULL、contact 有值) + fb("13255550004", "比价转圈太久了,经常要等十几秒才出结果,体验不太好。", + source="comparison", scene="比价太慢", contact="微信 zhangjie_66", + created=ago(days=1, hours=2)), + # 普通反馈 · 历史数据(env 全 NULL、contact 有值)· 无昵称用户 + fb("13255550005", "希望能增加支付宝提现,微信零钱用不太习惯。", + source="profile", contact="QQ 100200300", + created=ago(days=1, hours=8)), + + # ===== 已采纳 adopted(发金币 + 回复)===== + fb("13255550003", "建议在比价结果页加个「一键复制口令」,分享给家人更方便。", + source="profile", images=[imgs[3]], + status="adopted", reward_coins=2000, + review_note="有效产品建议,已排期到 2.4.0", admin_reply="感谢反馈!该功能已在规划中,金币奖励已发放~", + app_version="2.2.8", device_model="PJA110", rom_name="ColorOS", android_version="13", + created=ago(days=2)), + + # ===== 未采纳 rejected(带原因 + 回复)===== + fb("13255550002", "你们算的价格不准,我看到的更便宜。", + source="comparison", scene="价格不准", + status="rejected", reject_reason="截图价格为限时活动价且已过期,不满足「长期可复现更低价」条件,暂不采纳。", + admin_reply="感谢参与,本次未通过,欢迎继续上报有效更低价~", + app_version="2.3.0", device_model="M2012K11AC", rom_name="MIUI", android_version="13", + created=ago(days=3)), + ] + db.add_all(feedbacks) + db.commit() + for f in feedbacks: + db.refresh(f) + return feedbacks + + +def main() -> None: + parser = argparse.ArgumentParser(description="造用户反馈 mock 数据(运营后台反馈工单页联调用)") + parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建") + args = parser.parse_args() + + db = SessionLocal() + try: + removed = clean(db) + if removed: + print(f"🧹 已清理旧 mock:{removed} 个用户及其反馈 + mock 截图") + if args.clean_only: + print("✅ 仅清理,已完成。") + return + + feedbacks = seed(db) + + status_label = {"pending": "待审核", "adopted": "已采纳", "rejected": "未采纳"} + source_label = {"profile": "普通反馈", "comparison": "比价反馈"} + by_status: dict[str, list[Feedback]] = {} + for f in feedbacks: + by_status.setdefault(f.status, []).append(f) + + print(f"\n✅ 已生成 {len(feedbacks)} 条反馈(截图落 {_FEEDBACK_DIR}),分布:") + for st in ("pending", "adopted", "rejected"): + lst = by_status.get(st, []) + print(f" {status_label[st]:<4} {len(lst)} 条") + + print("\n 明细(#id | 状态 | 类型/场景 | 图 | 内容):") + uid2phone = dict( + db.execute(select(User.id, User.phone).where(User.phone.in_(MOCK_PHONES))).all() + ) + for f in feedbacks: + src = source_label.get(f.source, f.source) + scene = f"·{f.scene}" if f.scene else "" + nimg = len(f.images or []) + snippet = f.content[:20] + ("…" if len(f.content) > 20 else "") + print( + f" #{f.id} [{status_label.get(f.status, f.status)}] " + f"{src}{scene} {nimg}图 {uid2phone.get(f.user_id, '?')} {snippet}" + ) + print( + "\n👉 打开 http://localhost:3001 → 反馈 查看(默认「待审核」tab)。" + "\n 图加载不出来时排查:① 是否重启过 next dev(读 .env.local 的 NEXT_PUBLIC_MEDIA_BASE)" + " ② App 后端(:8770)是否在跑(它托管 /media)。" + ) + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_mock_price_reports.py b/scripts/seed_mock_price_reports.py index 6ca04fe..5c70e4e 100644 --- a/scripts/seed_mock_price_reports.py +++ b/scripts/seed_mock_price_reports.py @@ -30,6 +30,7 @@ from app.core.config import settings from app.db.session import SessionLocal from app.models.price_report import PriceReport from app.models.user import User +from app.repositories.user import _gen_unique_username if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥ @@ -84,10 +85,11 @@ def seed(db) -> list[PriceReport]: # 1) 建 3 个 mock 用户 users: dict[str, User] = {} for i, (phone, nickname) in enumerate( - zip(MOCK_PHONES, ["省钱小王", "比价老李", "薅羊毛阿珍"]) + zip(MOCK_PHONES, ["省钱小王", "比价老李", "薅羊毛阿珍"], strict=True) ): u = User( phone=phone, + username=_gen_unique_username(db), # 列 NOT NULL + unique,仿真实注册生成 nickname=nickname, register_channel="sms", status="active", diff --git a/scripts/seed_mock_withdraws.py b/scripts/seed_mock_withdraws.py index ad45f29..4902bc8 100644 --- a/scripts/seed_mock_withdraws.py +++ b/scripts/seed_mock_withdraws.py @@ -21,7 +21,7 @@ import argparse import sys import uuid from collections import defaultdict -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from sqlalchemy import delete, select @@ -31,6 +31,7 @@ from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder +from app.repositories.user import _gen_unique_username # Windows GBK 控制台下也能正常打印中文(避免 UnicodeEncodeError) if hasattr(sys.stdout, "reconfigure"): @@ -51,7 +52,7 @@ REMARK = {"exchange_in": "金币兑入", "withdraw": "提现扣款", "withdraw_r def _naive_utc_now() -> datetime: """与 func.now() 在 SQLite 的口径一致:naive UTC。前端按 UTC 解析再转北京时间。""" - return datetime.now(timezone.utc).replace(tzinfo=None) + return datetime.now(UTC).replace(tzinfo=None) def _mock_transfer_no() -> str: @@ -96,6 +97,7 @@ def seed(db) -> list[WithdrawOrder]: for phone, nickname in users_spec: u = User( phone=phone, + username=_gen_unique_username(db), # 列 NOT NULL + unique,仿真实注册生成 nickname=nickname, register_channel="sms", status="active", diff --git a/tests/test_logging_rotation.py b/tests/test_logging_rotation.py new file mode 100644 index 0000000..0d53a45 --- /dev/null +++ b/tests/test_logging_rotation.py @@ -0,0 +1,73 @@ +"""SafeRotatingFileHandler:Windows 轮转不被外部句柄卡死(WinError 32)。 + +stdlib RotatingFileHandler 靠 rename 活动文件轮转;Windows 上只要有第二个句柄(admin +第二进程、残留 --reload worker、IDE 索引、杀软)开着它, rename 就 WinError 32、轮转永久 +卡死。Safe 版在 Windows 改走 copytruncate(拷贝→就地清空, 从不 rename 活动文件)。 + +这些用例用 monkeypatch 把 os.name 强制成 "nt", 使 copytruncate 分支在任何 OS 的 CI 上都被 +覆盖;在真实 Windows 上则天然命中。 +""" +from __future__ import annotations + +import logging +from pathlib import Path + +from app.core.logging import SafeRotatingFileHandler + + +def _emit(handler: logging.Handler, msg: str) -> None: + handler.emit(logging.LogRecord("t", logging.INFO, __file__, 0, msg, (), None)) + + +def test_rollover_survives_second_open_handle(tmp_path: Path, monkeypatch) -> None: + """第二个句柄开着活动文件时轮转:不抛异常, 且确实转了(生成 .1、活动文件就地清空)。""" + monkeypatch.setattr("app.core.logging.os.name", "nt") + + log_file = tmp_path / "app-server.log" + # maxBytes 放大, 避免 emit 期间自动轮转干扰;本用例手动触发 doRollover。 + handler = SafeRotatingFileHandler( + str(log_file), maxBytes=10**9, backupCount=3, encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + try: + for i in range(20): + _emit(handler, f"line-{i:03d}") + handler.flush() + before = log_file.stat().st_size + assert before > 0 + + # 正是 Windows 上 rename 失败的条件:另一个句柄开着活动文件。 + with open(log_file, "a", encoding="utf-8"): + handler.doRollover() # 不应抛 PermissionError / WinError 32 + + backup = tmp_path / "app-server.log.1" + assert backup.exists() + assert backup.stat().st_size == before # 轮转前内容完整进了备份 + assert log_file.stat().st_size == 0 # 活动文件就地清空(不是 rename) + + # 句柄没被 rename 破坏, 仍能继续写。 + _emit(handler, "after-rollover") + handler.flush() + assert log_file.stat().st_size > 0 + finally: + handler.close() + + +def test_backups_shift_and_capped(tmp_path: Path, monkeypatch) -> None: + """多次轮转:.1/.2 逐级腾挪, 超过 backupCount 的丢弃(不出现 .3)。""" + monkeypatch.setattr("app.core.logging.os.name", "nt") + + log_file = tmp_path / "app-server.log" + handler = SafeRotatingFileHandler( + str(log_file), maxBytes=10**9, backupCount=2, encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + try: + for _ in range(4): + _emit(handler, "x" * 50) + handler.doRollover() + assert (tmp_path / "app-server.log.1").exists() + assert (tmp_path / "app-server.log.2").exists() + assert not (tmp_path / "app-server.log.3").exists() + finally: + handler.close() diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index 55a978a..ac395a4 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -9,7 +9,7 @@ from sqlalchemy import select from app.db.session import SessionLocal from app.models.user import User -from app.models.wallet import CoinAccount, WithdrawOrder +from app.models.wallet import CoinAccount, WithdrawOrder, WechatTransferAuthorization from app.repositories import wallet as crud_wallet @@ -426,3 +426,62 @@ def test_withdraw_reject_refunds(client, monkeypatch) -> None: r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token)) assert r.json()["status"] == "rejected" assert r.json()["fail_reason"] == "测试拒绝" + + +# ===== §fix 免确认授权 enabled 判定收严:active+authorization_id 非空才算已授权 ===== + +def _seed_transfer_auth(phone: str, state: str, authorization_id: str | None) -> None: + """直接在库里写/改该用户的免确认授权记录(绕过微信,用于判定测试)。""" + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + auth = db.get(WechatTransferAuthorization, user.id) + if auth is None: + auth = WechatTransferAuthorization( + user_id=user.id, openid=user.wechat_openid or "openid_test_abc", + out_authorization_no=f"oan_{user.id}", + ) + db.add(auth) + auth.state = state + auth.authorization_id = authorization_id + db.commit() + finally: + db.close() + + +def test_withdraw_info_auth_enabled_requires_authorization_id(client, monkeypatch) -> None: + _patch_userinfo(monkeypatch) + token = _login(client, "13800002051") + # 触发建号 + 绑定微信(withdraw-info 读 openid) + client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c1"}, headers=_auth(token)) + + # active 但无 authorization_id → 视为未授权 + _seed_transfer_auth("13800002051", "active", None) + r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["transfer_auth_enabled"] is False + + # active 且有 authorization_id → 已授权 + _seed_transfer_auth("13800002051", "active", "wx_auth_123") + r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["transfer_auth_enabled"] is True + + +def test_transfer_auth_status_requires_authorization_id(client, monkeypatch) -> None: + _patch_userinfo(monkeypatch) + token = _login(client, "13800002052") + client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c2"}, headers=_auth(token)) + + _seed_transfer_auth("13800002052", "active", None) + r = client.get("/api/v1/wallet/transfer-auth/status", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["state"] == "active" + assert r.json()["enabled"] is False + + _seed_transfer_auth("13800002052", "active", "wx_auth_456") + r = client.get("/api/v1/wallet/transfer-auth/status", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["enabled"] is True From 21a4d0af5b2c451e9d21dabfcc1b584d74a6c800 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 12:04:41 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=89=B9=E9=87=8F=E5=A4=84=E7=90=86=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20(#164)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改动 - 新增低价审核与用户反馈的批量通过、批量拒绝接口 - 单条仍保持独立事务、审计、发奖和通知;单项失败不影响同批其它记录 - 批量响应返回每条记录的成功状态或失败原因,供前端保留失败项重试 - 反馈审核补充行锁,降低并发重复发奖风险 ## 验证 - `ruff check`(相关路由、Schema、测试) - `pytest tests/test_admin_write.py -q`:22 passed --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/164 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/routers/feedback.py | 252 ++++++++++++++++++++---------- app/admin/routers/price_report.py | 136 ++++++++++++---- app/admin/schemas/feedback.py | 50 +++++- app/admin/schemas/price_report.py | 36 +++++ tests/test_admin_write.py | 115 ++++++++++++++ 5 files changed, 470 insertions(+), 119 deletions(-) diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 987c76b..28b5b71 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -12,6 +12,10 @@ from app.admin.repositories import mutations, queries from app.admin.schemas.common import CursorPage, OkResponse from app.admin.schemas.feedback import ( FeedbackApproveRequest, + FeedbackBulkApproveRequest, + FeedbackBulkItemResult, + FeedbackBulkRejectRequest, + FeedbackBulkResult, FeedbackOut, FeedbackRejectRequest, FeedbackSummary, @@ -33,6 +37,123 @@ def _ensure_pending(fb: Feedback) -> None: raise HTTPException(status_code=400, detail="反馈已审核") +def _approve_feedback( + db: AdminDb, + admin: AdminUser, + feedback_id: int, + payload: FeedbackApproveRequest | FeedbackBulkApproveRequest, + ip: str, + *, + bulk: bool = False, +) -> FeedbackOut: + fb = db.get(Feedback, feedback_id, with_for_update=True) + if fb is None: + raise HTTPException(status_code=404, detail="反馈不存在") + _ensure_pending(fb) + + before = fb.status + mutations.review_feedback( + db, + fb, + status="adopted", + reward_coins=payload.reward_coins, + review_note=payload.note, + admin_reply=payload.reply, + reviewed_by_admin_id=admin.id, + commit=False, + ) + wallet_repo.grant_coins( + db, + fb.user_id, + payload.reward_coins, + biz_type="feedback_reward", + ref_id=str(fb.id), + remark="意见反馈被采纳", + ) + detail = { + "before": before, + "after": "adopted", + "reward_coins": payload.reward_coins, + "note": payload.note, + "reply": payload.reply, + } + if bulk: + detail["bulk"] = True + write_audit( + db, + admin, + action="feedback.approve", + target_type="feedback", + target_id=feedback_id, + detail=detail, + ip=ip, + commit=False, + ) + db.commit() + db.refresh(fb) + out = FeedbackOut.model_validate(fb) + notification_events.notify_feedback_reward(db, fb) + return out + + +def _reject_feedback( + db: AdminDb, + admin: AdminUser, + feedback_id: int, + payload: FeedbackRejectRequest | FeedbackBulkRejectRequest, + ip: str, + *, + bulk: bool = False, +) -> FeedbackOut: + fb = db.get(Feedback, feedback_id, with_for_update=True) + if fb is None: + raise HTTPException(status_code=404, detail="反馈不存在") + _ensure_pending(fb) + + before = fb.status + mutations.review_feedback( + db, + fb, + status="rejected", + reject_reason=payload.reason, + review_note=payload.note, + admin_reply=payload.reply, + reviewed_by_admin_id=admin.id, + commit=False, + ) + detail = { + "before": before, + "after": "rejected", + "reason": payload.reason, + "note": payload.note, + "reply": payload.reply, + } + if bulk: + detail["bulk"] = True + write_audit( + db, + admin, + action="feedback.reject", + target_type="feedback", + target_id=feedback_id, + detail=detail, + ip=ip, + commit=False, + ) + db.commit() + db.refresh(fb) + out = FeedbackOut.model_validate(fb) + notification_events.notify_feedback_reply(db, fb) + return out + + +def _bulk_result(items: list[FeedbackBulkItemResult]) -> FeedbackBulkResult: + success = sum(1 for item in items if item.ok) + return FeedbackBulkResult( + total=len(items), success=success, failed=len(items) - success, items=items, + ) + + @router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表") def list_feedbacks( db: AdminDb, @@ -73,6 +194,50 @@ def feedback_summary(db: AdminDb) -> FeedbackSummary: return FeedbackSummary.model_validate(queries.feedback_summary(db)) +@router.post("/bulk/approve", response_model=FeedbackBulkResult, summary="批量采纳反馈并发金币") +def bulk_approve_feedbacks( + body: FeedbackBulkApproveRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> FeedbackBulkResult: + results: list[FeedbackBulkItemResult] = [] + ip = get_client_ip(request) + for feedback_id in body.ids: + try: + out = _approve_feedback(db, admin, feedback_id, body, ip, bulk=True) + results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) + except HTTPException as exc: + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) + return _bulk_result(results) + + +@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈") +def bulk_reject_feedbacks( + body: FeedbackBulkRejectRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> FeedbackBulkResult: + results: list[FeedbackBulkItemResult] = [] + ip = get_client_ip(request) + for feedback_id in body.ids: + try: + out = _reject_feedback(db, admin, feedback_id, body, ip, bulk=True) + results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status)) + except HTTPException as exc: + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常")) + return _bulk_result(results) + + @router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理") def handle_feedback( feedback_id: int, @@ -93,53 +258,7 @@ def approve_feedback( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackOut: - fb = db.get(Feedback, feedback_id) - if fb is None: - raise HTTPException(status_code=404, detail="反馈不存在") - _ensure_pending(fb) - - before = fb.status - mutations.review_feedback( - db, - fb, - status="adopted", - reward_coins=payload.reward_coins, - review_note=payload.note, - admin_reply=payload.reply, - reviewed_by_admin_id=admin.id, - commit=False, - ) - wallet_repo.grant_coins( - db, - fb.user_id, - payload.reward_coins, - biz_type="feedback_reward", - ref_id=str(fb.id), - remark="意见反馈被采纳", - ) - write_audit( - db, - admin, - action="feedback.approve", - target_type="feedback", - target_id=feedback_id, - detail={ - "before": before, - "after": "adopted", - "reward_coins": payload.reward_coins, - "note": payload.note, - "reply": payload.reply, - }, - ip=get_client_ip(request), - commit=False, - ) - db.commit() - db.refresh(fb) - out = FeedbackOut.model_validate(fb) - # PRD #10 反馈奖励:采纳发金币后通知用户(站内 + push,必带官方留言)。 - # 业务已 commit,通知失败只 log 不影响审核结果。 - notification_events.notify_feedback_reward(db, fb) - return out + return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request)) @router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈") @@ -150,41 +269,4 @@ def reject_feedback( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> FeedbackOut: - fb = db.get(Feedback, feedback_id) - if fb is None: - raise HTTPException(status_code=404, detail="反馈不存在") - _ensure_pending(fb) - - before = fb.status - mutations.review_feedback( - db, - fb, - status="rejected", - reject_reason=payload.reason, - review_note=payload.note, - admin_reply=payload.reply, - reviewed_by_admin_id=admin.id, - commit=False, - ) - write_audit( - db, - admin, - action="feedback.reject", - target_type="feedback", - target_id=feedback_id, - detail={ - "before": before, - "after": "rejected", - "reason": payload.reason, - "note": payload.note, - "reply": payload.reply, - }, - ip=get_client_ip(request), - commit=False, - ) - db.commit() - db.refresh(fb) - out = FeedbackOut.model_validate(fb) - # PRD #9 官方回复:未采纳也回复了用户(原因/留言用户端可见),通知去反馈历史页查看。 - notification_events.notify_feedback_reply(db, fb) - return out + return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request)) diff --git a/app/admin/routers/price_report.py b/app/admin/routers/price_report.py index 9f65545..6f656a7 100644 --- a/app/admin/routers/price_report.py +++ b/app/admin/routers/price_report.py @@ -17,6 +17,10 @@ from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_ro from app.admin.repositories import mutations, queries from app.admin.schemas.common import CursorPage, OkResponse from app.admin.schemas.price_report import ( + PriceReportBulkItemResult, + PriceReportBulkRejectRequest, + PriceReportBulkRequest, + PriceReportBulkResult, PriceReportOut, PriceReportRejectRequest, PriceReportSummary, @@ -34,6 +38,59 @@ router = APIRouter( ) +def _approve_price_report( + db: AdminDb, admin: AdminUser, report_id: int, ip: str, *, bulk: bool = False +) -> PriceReport: + rep = db.get(PriceReport, report_id, with_for_update=True) + if rep is None: + raise HTTPException(status_code=404, detail="上报记录不存在") + if rep.status != "pending": + raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") + coins = PRICE_REPORT_REWARD_COINS + mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False) + wallet_repo.grant_coins( + db, rep.user_id, coins, + biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过", + ) + detail = {"reward_coins": coins, "user_id": rep.user_id} + if bulk: + detail["bulk"] = True + write_audit( + db, admin, action="price_report.approve", target_type="price_report", target_id=report_id, + detail=detail, ip=ip, commit=False, + ) + db.commit() + notification_events.notify_report_approved(db, rep) + return rep + + +def _reject_price_report( + db: AdminDb, admin: AdminUser, report_id: int, reason: str, ip: str, *, bulk: bool = False +) -> PriceReport: + rep = db.get(PriceReport, report_id, with_for_update=True) + if rep is None: + raise HTTPException(status_code=404, detail="上报记录不存在") + if rep.status != "pending": + raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") + mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False) + detail = {"reason": reason, "user_id": rep.user_id} + if bulk: + detail["bulk"] = True + write_audit( + db, admin, action="price_report.reject", target_type="price_report", target_id=report_id, + detail=detail, ip=ip, commit=False, + ) + db.commit() + return rep + + +def _bulk_result(items: list[PriceReportBulkItemResult]) -> PriceReportBulkResult: + success = sum(1 for item in items if item.ok) + return PriceReportBulkResult( + total=len(items), success=success, failed=len(items) - success, items=items, + ) + + @router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)") def list_price_reports( db: AdminDb, @@ -60,6 +117,50 @@ def price_report_summary(db: AdminDb) -> PriceReportSummary: return PriceReportSummary.model_validate(queries.price_report_summary(db)) +@router.post("/bulk/approve", response_model=PriceReportBulkResult, summary="批量通过上报(发固定金币)") +def bulk_approve_price_reports( + body: PriceReportBulkRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> PriceReportBulkResult: + results: list[PriceReportBulkItemResult] = [] + ip = get_client_ip(request) + for report_id in body.ids: + try: + rep = _approve_price_report(db, admin, report_id, ip, bulk=True) + results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status)) + except HTTPException as exc: + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常")) + return _bulk_result(results) + + +@router.post("/bulk/reject", response_model=PriceReportBulkResult, summary="批量拒绝上报") +def bulk_reject_price_reports( + body: PriceReportBulkRejectRequest, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> PriceReportBulkResult: + results: list[PriceReportBulkItemResult] = [] + ip = get_client_ip(request) + for report_id in body.ids: + try: + rep = _reject_price_report(db, admin, report_id, body.reason, ip, bulk=True) + results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status)) + except HTTPException as exc: + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail))) + except Exception: # noqa: BLE001 - 单笔失败不打断整批 + db.rollback() + results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常")) + return _bulk_result(results) + + @router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)") def approve_price_report( report_id: int, @@ -67,27 +168,7 @@ def approve_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - # 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖, - # 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。 - rep = db.get(PriceReport, report_id, with_for_update=True) - if rep is None: - raise HTTPException(status_code=404, detail="上报记录不存在") - if rep.status != "pending": - raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") - coins = PRICE_REPORT_REWARD_COINS - # 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账 - mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False) - wallet_repo.grant_coins( - db, rep.user_id, coins, - biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过", - ) - write_audit( - db, admin, action="price_report.approve", target_type="price_report", target_id=report_id, - detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False, - ) - db.commit() - # PRD #11 爆料审核通过:发金币后通知用户(站内 + push)。业务已 commit,通知失败只 log。 - notification_events.notify_report_approved(db, rep) + _approve_price_report(db, admin, report_id, get_client_ip(request)) return OkResponse() @@ -99,16 +180,5 @@ def reject_price_report( admin: Annotated[AdminUser, Depends(require_role("operator"))], db: AdminDb, ) -> OkResponse: - rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核) - if rep is None: - raise HTTPException(status_code=404, detail="上报记录不存在") - if rep.status != "pending": - raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作") - reason = body.reason.strip() - mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False) - write_audit( - db, admin, action="price_report.reject", target_type="price_report", target_id=report_id, - detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False, - ) - db.commit() + _reject_price_report(db, admin, report_id, body.reason, get_client_ip(request)) return OkResponse() diff --git a/app/admin/schemas/feedback.py b/app/admin/schemas/feedback.py index bb588cf..dde6229 100644 --- a/app/admin/schemas/feedback.py +++ b/app/admin/schemas/feedback.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from app.core.rewards import FEEDBACK_REWARD_MAX_COINS @@ -55,6 +55,54 @@ class FeedbackRejectRequest(BaseModel): reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见") +class FeedbackBulkRequest(BaseModel): + ids: list[int] = Field(min_length=1, max_length=50, description="待审核反馈 ID 列表") + + @field_validator("ids") + @classmethod + def _ids_must_be_unique(cls, ids: list[int]) -> list[int]: + if len(ids) != len(set(ids)): + raise ValueError("反馈 ID 不能重复") + return ids + + +class FeedbackBulkApproveRequest(FeedbackBulkRequest): + reward_coins: int = Field( + ge=1, + le=FEEDBACK_REWARD_MAX_COINS, + description="每条采纳反馈发放的金币数", + ) + note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)") + reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见") + + +class FeedbackBulkRejectRequest(FeedbackBulkRequest): + reason: str = Field(min_length=1, max_length=256, description="批量未采纳原因,用户端可见") + note: str | None = Field(default=None, max_length=256, description="运营内部审核备注") + reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见") + + @field_validator("reason") + @classmethod + def _reason_not_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("未采纳原因不能为空") + return value.strip() + + +class FeedbackBulkItemResult(BaseModel): + id: int + ok: bool + status: str | None = None + error: str | None = None + + +class FeedbackBulkResult(BaseModel): + total: int + success: int + failed: int + items: list[FeedbackBulkItemResult] + + class FeedbackSummary(BaseModel): """审核台顶部各状态计数(pending 含历史 new 态)。""" diff --git a/app/admin/schemas/price_report.py b/app/admin/schemas/price_report.py index abb89d9..d67208a 100644 --- a/app/admin/schemas/price_report.py +++ b/app/admin/schemas/price_report.py @@ -56,6 +56,42 @@ class PriceReportRejectRequest(BaseModel): return v.strip() +class PriceReportBulkRequest(BaseModel): + ids: list[int] = Field(min_length=1, max_length=50, description="待审核上报 ID 列表") + + @field_validator("ids") + @classmethod + def _ids_must_be_unique(cls, ids: list[int]) -> list[int]: + if len(ids) != len(set(ids)): + raise ValueError("上报 ID 不能重复") + return ids + + +class PriceReportBulkRejectRequest(PriceReportBulkRequest): + reason: str = Field(min_length=1, max_length=256, description="批量拒绝理由,用户端记录页会看到") + + @field_validator("reason") + @classmethod + def _reason_not_blank(cls, value: str) -> str: + if not value.strip(): + raise ValueError("拒绝理由不能为空") + return value.strip() + + +class PriceReportBulkItemResult(BaseModel): + id: int + ok: bool + status: str | None = None + error: str | None = None + + +class PriceReportBulkResult(BaseModel): + total: int + success: int + failed: int + items: list[PriceReportBulkItemResult] + + class PriceReportSummary(BaseModel): """审核台顶部各状态计数。""" diff --git a/tests/test_admin_write.py b/tests/test_admin_write.py index ec6d39d..fc6b73c 100644 --- a/tests/test_admin_write.py +++ b/tests/test_admin_write.py @@ -14,6 +14,7 @@ from app.core.security import hash_password from app.db.session import SessionLocal from app.models.admin import AdminAuditLog from app.models.feedback import Feedback +from app.models.price_report import PriceReport from app.models.user import User from app.models.wallet import CoinAccount, CoinTransaction, WithdrawOrder from app.repositories import user as user_repo @@ -82,6 +83,26 @@ def _seed_feedback(phone: str) -> int: db.close() +def _seed_price_report(phone: str) -> int: + uid = _seed_user(phone) + db = SessionLocal() + try: + report = PriceReport( + user_id=uid, + store_name="测试门店", + reported_platform_id="eleme", + reported_platform_name="饿了么", + reported_price_cents=2990, + images=[], + status="pending", + ) + db.add(report) + db.commit() + return report.id + finally: + db.close() + + # ===== 调金币 ===== def test_grant_coins_writes_txn_and_audit(admin_client: TestClient, finance_token: str) -> None: @@ -397,6 +418,100 @@ def test_feedback_review_stores_admin_reply( assert r.json()["admin_reply"] == "已收到,后续跟进" +def test_bulk_approve_feedbacks_returns_per_item_results( + admin_client: TestClient, operator_token: str +) -> None: + first_id = _seed_feedback("13900000031") + second_id = _seed_feedback("13900000032") + r = admin_client.post( + "/admin/api/feedbacks/bulk/approve", + json={"ids": [first_id, second_id, 999999], "reward_coins": 600, "note": "批量采纳"}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + payload = r.json() + assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1 + assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "反馈不存在"} + db = SessionLocal() + try: + for feedback_id in (first_id, second_id): + feedback = db.get(Feedback, feedback_id) + assert feedback is not None and feedback.status == "adopted" + assert db.get(CoinAccount, feedback.user_id).coin_balance == 600 + log = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "feedback.approve", + AdminAuditLog.target_id == str(feedback_id), + ) + ).scalar_one() + assert log.detail["bulk"] is True + finally: + db.close() + + +def test_bulk_approve_price_reports_returns_per_item_results( + admin_client: TestClient, operator_token: str +) -> None: + first_id = _seed_price_report("13900000041") + second_id = _seed_price_report("13900000042") + r = admin_client.post( + "/admin/api/price-reports/bulk/approve", + json={"ids": [first_id, second_id, 999999]}, + headers=_auth(operator_token), + ) + assert r.status_code == 200, r.text + payload = r.json() + assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1 + assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "上报记录不存在"} + db = SessionLocal() + try: + for report_id in (first_id, second_id): + report = db.get(PriceReport, report_id) + assert report is not None and report.status == "approved" + assert report.reward_coins == 1000 + assert db.get(CoinAccount, report.user_id).coin_balance == 1000 + log = db.execute( + select(AdminAuditLog).where( + AdminAuditLog.action == "price_report.approve", + AdminAuditLog.target_id == str(report_id), + ) + ).scalar_one() + assert log.detail["bulk"] is True + finally: + db.close() + + +def test_bulk_reject_review_requests_apply_shared_reason( + admin_client: TestClient, operator_token: str +) -> None: + feedback_id = _seed_feedback("13900000051") + report_id = _seed_price_report("13900000052") + feedback_response = admin_client.post( + "/admin/api/feedbacks/bulk/reject", + json={"ids": [feedback_id], "reason": "信息不足", "reply": "请补充完整截图"}, + headers=_auth(operator_token), + ) + report_response = admin_client.post( + "/admin/api/price-reports/bulk/reject", + json={"ids": [report_id], "reason": "截图无法核实"}, + headers=_auth(operator_token), + ) + assert feedback_response.status_code == 200, feedback_response.text + assert report_response.status_code == 200, report_response.text + assert feedback_response.json()["success"] == 1 + assert report_response.json()["success"] == 1 + db = SessionLocal() + try: + feedback = db.get(Feedback, feedback_id) + report = db.get(PriceReport, report_id) + assert feedback is not None and feedback.status == "rejected" + assert feedback.reject_reason == "信息不足" and feedback.admin_reply == "请补充完整截图" + assert report is not None and report.status == "rejected" + assert report.reject_reason == "截图无法核实" + finally: + db.close() + + # ===== admin 账号管理(super_admin) ===== def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None: From 9e88ca72d3937052b196407b05fc02c4743e2e01 Mon Sep 17 00:00:00 2001 From: linkeyu Date: Fri, 24 Jul 2026 14:04:04 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E8=AF=A6=E6=83=85=E7=82=B9=E5=87=BB=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=90=8E=E6=8F=90=E7=A4=BA=E6=93=8D=E4=BD=9C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=20(#171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题现象 在“提现审核”页点击用户所在行后,提现单主详情可以打开,但用户统计和金币记录区域为空,页面连续提示“操作失败”。 ## 原因说明 打开抽屉时前端会继续请求两个子接口: - `/admin/api/users/{user_id}/reward-stats` - `/admin/api/users/{user_id}/coin-records` 这两个接口原来都使用 `select(AdRewardRecord)` 加载完整 ORM 对象。SQLAlchemy 会把模型映射的所有列自动展开到 SQL 中,其中包括后来新增的 `boost_round_id`。当旧本地数据库或滚动发布中的数据库尚未补齐该列时,即使提现详情本身完全不使用这个字段,查询仍会报 `no such column: ad_reward_record.boost_round_id`,两个接口均返回 500。 前端的统一错误处理只会展示响应 JSON 中字符串类型的 `detail`;该 500 返回的是普通 `Internal Server Error`,因此最终回退成通用文案“操作失败”。本地前端开启了 React Strict Mode,初始化副作用在开发环境会执行两次,所以两个失败接口会形成截图中的四条“操作失败”提示。 ## 修复方案 - 用户奖励统计只查询实际需要的 `ecpm_raw`、`coin` 等字段。 - 金币记录只查询页面展示、排序所需字段。 - 同步缩小信息流广告和签到记录的字段投影,避免将来新增无关 ORM 列再次拖垮详情页。 - 增加 SQL 级回归测试:主动拦截任何包含 `ad_reward_record.boost_round_id` 的详情查询,并验证两个接口仍返回 200。 该改动不会改变统计口径或返回结构。数据库迁移仍应正常执行;这里增加的是旧库及滚动发布期间的向后兼容保护。 ## 验证结果 - `pytest tests/test_admin_read.py -q`:17 passed - 新增回归测试覆盖 `reward-stats` 与 `coin-records` - `git diff --check`:通过 - 本地实际提现用户接口验证:两个接口均返回 200 --------- Co-authored-by: unknown <798648091@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/171 Co-authored-by: linkeyu Co-committed-by: linkeyu --- app/admin/repositories/queries.py | 41 ++++++++++++++++++++++--------- tests/test_admin_read.py | 32 +++++++++++++++++++++++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 33b510d..75550e7 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -1171,24 +1171,30 @@ def user_reward_stats( acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口 cash_balance = acc.cash_balance_cents if acc else 0 - rv = list(db.execute( - select(AdRewardRecord).where( + # 只投影本统计实际使用的列。避免滚动发布或旧本地库尚未补齐无关新列时, + # SQLAlchemy 因 select(ORM) 自动展开整表字段而让提现详情整体 500。 + rv = db.execute( + select(AdRewardRecord.ecpm_raw, AdRewardRecord.coin).where( AdRewardRecord.user_id == user_id, AdRewardRecord.reward_scene == "reward_video", AdRewardRecord.status == "granted", *_window_conds(AdRewardRecord.created_at, date_from, date_to), ) - ).scalars()) + ).all() rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw] rv_coins = sum(r.coin for r in rv) - feed = list(db.execute( - select(AdFeedRewardRecord).where( + feed = db.execute( + select( + AdFeedRewardRecord.unit_count, + AdFeedRewardRecord.ecpm_raw, + AdFeedRewardRecord.coin, + ).where( AdFeedRewardRecord.user_id == user_id, AdFeedRewardRecord.status == "granted", *_window_conds(AdFeedRewardRecord.created_at, date_from, date_to), ) - ).scalars()) + ).all() feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw] feed_coins = sum(f.coin for f in feed) @@ -1246,8 +1252,14 @@ def user_coin_records( signin_from = date_from + timedelta(hours=8) if date_from is not None else None signin_to = date_to + timedelta(hours=8) if date_to is not None else None + # 三类来源都只取页面需要的列,避免无关 ORM 新列造成旧库查询失败。 for rec in db.execute( - select(AdRewardRecord) + select( + AdRewardRecord.reward_scene, + AdRewardRecord.created_at, + AdRewardRecord.ecpm_raw, + AdRewardRecord.coin, + ) .where( AdRewardRecord.user_id == user_id, AdRewardRecord.status == "granted", @@ -1255,7 +1267,7 @@ def user_coin_records( ) .order_by(AdRewardRecord.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): is_video = rec.reward_scene == "reward_video" rows.append({ "source": rec.reward_scene, @@ -1266,7 +1278,12 @@ def user_coin_records( }) for rec in db.execute( - select(AdFeedRewardRecord) + select( + AdFeedRewardRecord.feed_scene, + AdFeedRewardRecord.created_at, + AdFeedRewardRecord.ecpm_raw, + AdFeedRewardRecord.coin, + ) .where( AdFeedRewardRecord.user_id == user_id, AdFeedRewardRecord.status == "granted", @@ -1274,7 +1291,7 @@ def user_coin_records( ) .order_by(AdFeedRewardRecord.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): rows.append({ "source": "feed", "source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"), @@ -1284,7 +1301,7 @@ def user_coin_records( }) for rec in db.execute( - select(CoinTransaction) + select(CoinTransaction.created_at, CoinTransaction.amount) .where( CoinTransaction.user_id == user_id, CoinTransaction.biz_type == "signin", @@ -1292,7 +1309,7 @@ def user_coin_records( ) .order_by(CoinTransaction.created_at.desc()) .limit(fetch) - ).scalars(): + ).all(): rows.append({ "source": "signin", "source_label": "签到", diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index a01ca96..06b19aa 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -5,10 +5,11 @@ from datetime import datetime import pytest from fastapi.testclient import TestClient +from sqlalchemy import event from app.admin.main import admin_app from app.admin.repositories import admin_user as admin_repo -from app.db.session import SessionLocal +from app.db.session import SessionLocal, engine from app.models.comparison import ComparisonRecord from app.models.feedback import Feedback from app.models.wallet import CashTransaction, WithdrawOrder @@ -132,6 +133,35 @@ def test_user_list_and_detail(admin_client: TestClient, admin_token: str) -> Non assert admin_client.get("/admin/api/users/999999", headers=_auth(admin_token)).status_code == 404 +def test_user_reward_detail_does_not_select_unrelated_new_ad_columns( + admin_client: TestClient, admin_token: str +) -> None: + """旧库缺少无关新列时,提现详情的统计和金币记录仍应可读。""" + uid = _seed_user_with_data("13800000022") + + def reject_full_ad_reward_projection( + _conn, _cursor, statement: str, _parameters, _context, _executemany + ) -> None: + if "ad_reward_record.boost_round_id" in statement: + raise AssertionError("提现详情不应查询未使用的 boost_round_id") + + event.listen(engine, "before_cursor_execute", reject_full_ad_reward_projection) + try: + stats = admin_client.get( + f"/admin/api/users/{uid}/reward-stats", headers=_auth(admin_token) + ) + records = admin_client.get( + f"/admin/api/users/{uid}/coin-records", + params={"limit": 10, "cursor": 0}, + headers=_auth(admin_token), + ) + finally: + event.remove(engine, "before_cursor_execute", reject_full_ad_reward_projection) + + assert stats.status_code == 200, stats.text + assert records.status_code == 200, records.text + + def test_user_filter_by_status(admin_client: TestClient, admin_token: str) -> None: _seed_user_with_data("13800000003") r = admin_client.get("/admin/api/users", params={"status": "active"}, headers=_auth(admin_token)) From 3f7b5167faa9cc22d164fb3c5db70571ea5ca098 Mon Sep 17 00:00:00 2001 From: zuochenyong Date: Fri, 24 Jul 2026 14:48:40 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E6=89=8B=E5=BC=95=E5=AF=BC=E8=A7=86=E9=A2=91=20+=20=E7=BE=8E?= =?UTF-8?q?=E5=9B=A2=E5=88=B8=E9=A6=96=E9=A1=B5=E5=88=86=E9=A1=B5=E7=B4=A2?= =?UTF-8?q?=E5=BC=95=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: guke Co-authored-by: 左辰勇 Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/167 Co-authored-by: zuochenyong Co-committed-by: zuochenyong --- ...38_merge_guide_video_seq_uq_and_coupon_.py | 26 ++ ...erge_guide_video_and_main_alembic_heads.py | 26 ++ alembic/versions/guide_video_play_table.py | 50 +++ .../guide_video_play_user_seq_unique.py | 40 +++ .../versions/meituan_coupon_feed_indexes.py | 52 ++++ app/admin/main.py | 5 + app/admin/routers/guide_video.py | 101 ++++++ app/admin/schemas/guide_video.py | 25 ++ app/api/v1/guide_video.py | 64 ++++ app/api/v1/meituan.py | 229 +++++++++----- app/core/config.py | 3 + app/core/media.py | 34 ++ app/integrations/meituan.py | 45 ++- app/main.py | 8 + app/models/__init__.py | 1 + app/models/guide_video.py | 73 +++++ app/models/meituan_coupon.py | 22 +- app/repositories/guide_video.py | 290 ++++++++++++++++++ app/schemas/guide_video.py | 36 +++ app/utils/mt_search_cursor.py | 97 ++++++ docs/api/meituan/meituan-feed.md | 3 +- docs/api/meituan/meituan-top-sales.md | 1 + docs/database/meituan_coupon.md | 6 +- scripts/reset_guide_video.py | 216 +++++++++++++ scripts/seed_meituan_coupon_mock.py | 222 ++++++++++++++ tests/test_guide_video.py | 263 ++++++++++++++++ 26 files changed, 1855 insertions(+), 83 deletions(-) create mode 100644 alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py create mode 100644 alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py create mode 100644 alembic/versions/guide_video_play_table.py create mode 100644 alembic/versions/guide_video_play_user_seq_unique.py create mode 100644 alembic/versions/meituan_coupon_feed_indexes.py create mode 100644 app/admin/routers/guide_video.py create mode 100644 app/admin/schemas/guide_video.py create mode 100644 app/api/v1/guide_video.py create mode 100644 app/models/guide_video.py create mode 100644 app/repositories/guide_video.py create mode 100644 app/schemas/guide_video.py create mode 100644 app/utils/mt_search_cursor.py create mode 100644 scripts/reset_guide_video.py create mode 100644 scripts/seed_meituan_coupon_mock.py create mode 100644 tests/test_guide_video.py diff --git a/alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py b/alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py new file mode 100644 index 0000000..a8032c9 --- /dev/null +++ b/alembic/versions/d8dd2106e438_merge_guide_video_seq_uq_and_coupon_.py @@ -0,0 +1,26 @@ +"""merge guide_video seq_uq and coupon_claim_event heads + +Revision ID: d8dd2106e438 +Revises: coupon_claim_event, guide_video_user_seq_uq +Create Date: 2026-07-24 11:52:18.290731 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd8dd2106e438' +down_revision: Union[str, Sequence[str], None] = ('coupon_claim_event', 'guide_video_user_seq_uq') +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/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py b/alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py new file mode 100644 index 0000000..0834cae --- /dev/null +++ b/alembic/versions/d9c03cc3ea07_merge_guide_video_and_main_alembic_heads.py @@ -0,0 +1,26 @@ +"""merge guide_video and main alembic heads + +Revision ID: d9c03cc3ea07 +Revises: 8e04cc13a211, guide_video_play_table +Create Date: 2026-07-23 22:57:40.998161 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd9c03cc3ea07' +down_revision: Union[str, Sequence[str], None] = ('8e04cc13a211', 'guide_video_play_table') +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/guide_video_play_table.py b/alembic/versions/guide_video_play_table.py new file mode 100644 index 0000000..3c6a62e --- /dev/null +++ b/alembic/versions/guide_video_play_table.py @@ -0,0 +1,50 @@ +"""新手引导视频播放记录表(领券浮层前 N 次替代广告) + +见 app/models/guide_video.py:按账号计次(开播即计数)、play_token 幂等发币。 +配置(开关 / 视频地址 / 次数 / 金币)复用既有 app_config 表,无需建表。 + +Revision ID: guide_video_play_table +Revises: meituan_coupon_feed_indexes +Create Date: 2026-07-23 12:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "guide_video_play_table" +down_revision: Union[str, Sequence[str], None] = "meituan_coupon_feed_indexes" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "guide_video_play", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("play_token", sa.String(length=64), nullable=False), + sa.Column("scene", sa.String(length=16), nullable=False, server_default="coupon"), + sa.Column("seq", sa.Integer(), nullable=False, server_default="1"), + sa.Column("video_url", sa.String(length=512), nullable=True), + sa.Column("coin", sa.Integer(), nullable=False, server_default="0"), + sa.Column("status", sa.String(length=16), nullable=False, server_default="playing"), + sa.Column("completed", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "started_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column("granted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("play_token", name="uq_guide_video_play_token"), + ) + op.create_index("ix_guide_video_play_user_id", "guide_video_play", ["user_id"]) + op.create_index("ix_guide_video_play_started_at", "guide_video_play", ["started_at"]) + + +def downgrade() -> None: + op.drop_index("ix_guide_video_play_started_at", table_name="guide_video_play") + op.drop_index("ix_guide_video_play_user_id", table_name="guide_video_play") + op.drop_table("guide_video_play") diff --git a/alembic/versions/guide_video_play_user_seq_unique.py b/alembic/versions/guide_video_play_user_seq_unique.py new file mode 100644 index 0000000..e4c105e --- /dev/null +++ b/alembic/versions/guide_video_play_user_seq_unique.py @@ -0,0 +1,40 @@ +"""guide_video_play 加 (user_id, seq) 唯一约束:堵住并发 /start 绕过次数上限 + +start_play 是无锁 check-then-insert(读 COUNT(*) 算 seq=used+1 再插一行),N 个并发 +/start 会都读到同一个 used、算出同一个 seq、各插一行拿到各自的 play_token,于是 3 次 +上限被绕过、每个 token 都能换 120 金币。加唯一键后并发同 seq 必撞,start_play 捕获 +IntegrityError 降级为 should_play=false(客户端照旧放广告)。 + +用 unique index 而不是 batch_alter_table 加 UniqueConstraint:SQLite 加约束要整表重建, +而 CREATE UNIQUE INDEX 两边都原生支持,回滚也干净。 + +注:若库里已有并发产生的重复 (user_id, seq),建索引会失败 —— 本功能尚未上线,表通常是空的; +真撞上了先按 seq 去重(留 id 最小的一行,多发的金币按 scripts/reset_guide_video.py 的口径退)。 + +Revision ID: guide_video_user_seq_uq +Revises: d9c03cc3ea07 +Create Date: 2026-07-24 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "guide_video_user_seq_uq" +down_revision: Union[str, Sequence[str], None] = "d9c03cc3ea07" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_index( + "uq_guide_video_play_user_seq", + "guide_video_play", + ["user_id", "seq"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("uq_guide_video_play_user_seq", table_name="guide_video_play") diff --git a/alembic/versions/meituan_coupon_feed_indexes.py b/alembic/versions/meituan_coupon_feed_indexes.py new file mode 100644 index 0000000..c120ca6 --- /dev/null +++ b/alembic/versions/meituan_coupon_feed_indexes.py @@ -0,0 +1,52 @@ +"""meituan_coupon 首页 feed 分页复合索引(销量最高 / 智能推荐) + +「销量最高」「智能推荐」两个 tab 都是 + WHERE city_id = ? [+ 过滤] → DISTINCT ON (dedup_key) ORDER BY dedup_key, <排序键> DESC +的形状。列顺序对齐后 Postgres 可以顺着索引流式去重,免掉「每翻一页就把该城全部券重排一遍」, +这是首页下滑到底越来越慢的根因之一(另一半在 app 层:见 api/v1/meituan.py 的 _paged_dedup_ids)。 + +⚠️ 本文件同时是一个 **merge 迁移**:主干此前有 3 个并行 head +(comparison_user_created_idx / monitoring_audit_rbac / notification_table), +`alembic upgrade head` 会因 multiple heads 报错。这里一并收敛回单 head。 + +Revision ID: meituan_coupon_feed_indexes +Revises: comparison_user_created_idx, monitoring_audit_rbac, notification_table +Create Date: 2026-07-23 10:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "meituan_coupon_feed_indexes" +down_revision: Union[str, Sequence[str], None] = ( + "comparison_user_created_idx", + "monitoring_audit_rbac", + "notification_table", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 销量最高:WHERE city_id=? AND sale_volume_num IS NOT NULL + # ORDER BY dedup_key, sale_volume_num DESC, commission_percent DESC + op.create_index( + "ix_meituan_coupon_city_dedup_sales", + "meituan_coupon", + ["city_id", "dedup_key", sa.text("sale_volume_num DESC"), sa.text("commission_percent DESC")], + ) + # 智能推荐:WHERE city_id=? AND commission_percent>=3.0 + # ORDER BY dedup_key, commission_percent DESC + op.create_index( + "ix_meituan_coupon_city_dedup_comm", + "meituan_coupon", + ["city_id", "dedup_key", sa.text("commission_percent DESC")], + ) + + +def downgrade() -> None: + op.drop_index("ix_meituan_coupon_city_dedup_comm", table_name="meituan_coupon") + op.drop_index("ix_meituan_coupon_city_dedup_sales", table_name="meituan_coupon") diff --git a/app/admin/main.py b/app/admin/main.py index c869487..918b238 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -30,6 +30,7 @@ from app.admin.routers.analytics_health import router as analytics_health_router from app.admin.routers.event_logs import router as event_logs_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.feedback_qr import router as feedback_qr_router +from app.admin.routers.guide_video import router as guide_video_router from app.admin.routers.huawei_review import router as huawei_review_router from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router @@ -40,6 +41,7 @@ from app.admin.routers.wallet import router as wallet_router from app.admin.routers.withdraw import router as withdraw_router from app.core.config import settings from app.core.logging import setup_logging +from app.integrations import meituan as mt_meituan setup_logging(debug=settings.APP_DEBUG) logger = logging.getLogger("shagua.admin") @@ -53,6 +55,8 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: settings.DATABASE_URL.split("://", 1)[0], ) yield + # CPS 后台页会打美团(routers/cps.py),那条共享 client 若被建过要在这里关掉连接池 + mt_meituan.close_client() logger.info("admin app shutting down") @@ -101,6 +105,7 @@ admin_app.include_router(feedback_router) admin_app.include_router(event_logs_router) admin_app.include_router(analytics_health_router) admin_app.include_router(feedback_qr_router) +admin_app.include_router(guide_video_router) admin_app.include_router(admins_router) admin_app.include_router(roles_router) admin_app.include_router(audit_router) diff --git a/app/admin/routers/guide_video.py b/app/admin/routers/guide_video.py new file mode 100644 index 0000000..59f75ae --- /dev/null +++ b/app/admin/routers/guide_video.py @@ -0,0 +1,101 @@ +"""admin 新手引导视频配置:读 / 改开关次数金币 / 上传视频 / 删视频(带审计)。 + +整份配置存通用 app_config 表(见 app/repositories/guide_video.py),App 领券等候浮层 +每次展示前调 POST /api/v1/guide-video/start 同步。权限:operator 可改(运营维护), +super 恒可;读为只读(任意已登录 admin)。 + +⚠️ 视频上限 100MB(settings.GUIDE_VIDEO_MAX_BYTES),已在 admin nginx 为本接口单独放宽 +client_max_body_size,见 shaguabijia-admin-web/deploy/nginx/admin.shaguabijia.com.conf。 +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile + +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.guide_video import GuideVideoConfigOut, GuideVideoConfigUpdate +from app.core import media +from app.models.admin import AdminUser +from app.repositories import guide_video + +router = APIRouter( + prefix="/admin/api/guide-video", + tags=["admin-guide-video"], + dependencies=[Depends(get_current_admin)], +) + + +def _out(db: AdminDb) -> GuideVideoConfigOut: + """配置 + 播放统计合成响应(四个写接口都以最新状态返回,前端一次同步到位)。""" + return GuideVideoConfigOut(**guide_video.get_config(db), **guide_video.play_stats(db)) + + +@router.get("", response_model=GuideVideoConfigOut, summary="新手引导视频配置(领券浮层)") +def get_config(db: AdminDb) -> GuideVideoConfigOut: + return _out(db) + + +@router.patch("", response_model=GuideVideoConfigOut, summary="改开关/次数/金币(带审计)") +def update_config( + body: GuideVideoConfigUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> GuideVideoConfigOut: + before, after = guide_video.update_config( + db, + enabled=body.enabled, + max_plays=body.max_plays, + reward_coin=body.reward_coin, + admin_id=admin.id, + commit=False, + ) + write_audit( + db, admin, action="guide_video.update", target_type="guide_video", target_id=None, + detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False, + ) + db.commit() + return _out(db) + + +@router.post("/video", response_model=GuideVideoConfigOut, summary="上传新手引导视频(MP4,带审计)") +async def upload_video( + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, + file: UploadFile = File(...), +) -> GuideVideoConfigOut: + data = await file.read() + try: + url = media.save_guide_video(data) + except media.MediaError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + before, after = guide_video.set_video(db, url, admin_id=admin.id, commit=False) + write_audit( + db, admin, action="guide_video.set_video", target_type="guide_video", target_id=None, + detail={"before": before.get("video_url"), "after": url, "bytes": len(data)}, + ip=get_client_ip(request), commit=False, + ) + db.commit() + # 提交成功后再删旧片,避免新片没落库就把旧片丢了 + media.delete_guide_video(before.get("video_url")) + return _out(db) + + +@router.delete("/video", response_model=GuideVideoConfigOut, summary="移除新手引导视频(带审计)") +def delete_video( + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> GuideVideoConfigOut: + """移除后 /guide-video/start 一律返回 should_play=false,领券浮层回到「只放广告」。""" + before, after = guide_video.set_video(db, None, admin_id=admin.id, commit=False) + write_audit( + db, admin, action="guide_video.delete_video", target_type="guide_video", target_id=None, + detail={"before": before.get("video_url")}, ip=get_client_ip(request), commit=False, + ) + db.commit() + media.delete_guide_video(before.get("video_url")) + return _out(db) diff --git a/app/admin/schemas/guide_video.py b/app/admin/schemas/guide_video.py new file mode 100644 index 0000000..721f0a6 --- /dev/null +++ b/app/admin/schemas/guide_video.py @@ -0,0 +1,25 @@ +"""admin 新手引导视频配置 schemas(开关 / 视频地址 / 前几次 / 每次金币)。""" +from __future__ import annotations + +from pydantic import BaseModel, Field + +from app.repositories.guide_video import MAX_PLAYS_LIMIT, REWARD_COIN_LIMIT + + +class GuideVideoConfigOut(BaseModel): + enabled: bool + video_url: str | None = None # 相对地址 /media/guide_video/xxx.mp4;未配片 = None + max_plays: int + reward_coin: int + updated_at: str | None = None + # 只读统计,后台展示用:已有多少次播放、其中已发币多少次。 + total_plays: int = 0 + granted_plays: int = 0 + + +class GuideVideoConfigUpdate(BaseModel): + """部分更新:只改传入(非 None)字段。视频文件走 /video 上传接口。""" + + enabled: bool | None = None + max_plays: int | None = Field(default=None, ge=0, le=MAX_PLAYS_LIMIT) + reward_coin: int | None = Field(default=None, ge=0, le=REWARD_COIN_LIMIT) diff --git a/app/api/v1/guide_video.py b/app/api/v1/guide_video.py new file mode 100644 index 0000000..c38251b --- /dev/null +++ b/app/api/v1/guide_video.py @@ -0,0 +1,64 @@ +"""新手引导视频(领券等候浮层前 N 次替代广告)。 + +路由前缀 `/api/v1/guide-video`(均需 Bearer): + POST /start 这次浮层放引导视频还是放广告?命中则**当场计次**并下发 play_token + POST /reward 播完 / 中途关闭都调,按 play_token 幂等发固定金币 + +发币额度以**服务端配置**为准(运营后台可改),客户端只报"播完/关闭",报不了金额, +所以被破解也刷不到超额金币;次数上限由 guide_video_play 行数(按账号)硬卡。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends + +from app.api.deps import CurrentUser, DbSession +from app.core.ratelimit import rate_limit +from app.repositories import guide_video as crud_guide +from app.schemas.guide_video import ( + GuideVideoRewardIn, + GuideVideoRewardOut, + GuideVideoStartIn, + GuideVideoStartOut, +) + +logger = logging.getLogger("shagua.guide_video") + +router = APIRouter(prefix="/api/v1/guide-video", tags=["guide-video"]) + + +@router.post( + "/start", + response_model=GuideVideoStartOut, + summary="领券浮层是否放新手引导视频(命中即计次)", + dependencies=[Depends(rate_limit(60, 60, "guide-video-start"))], +) +def start(payload: GuideVideoStartIn, user: CurrentUser, db: DbSession) -> GuideVideoStartOut: + """开播即计数:返回 should_play=True 时服务端已写下这一次,客户端必须真的播。 + + 没配视频 / 开关关 / 次数用完 → should_play=False,客户端照旧走广告链路(行为不变)。 + """ + result = crud_guide.start_play(db, user.id, scene=payload.scene or "coupon") + logger.info( + "guide video start user_id=%d scene=%s should_play=%s seq=%d remaining=%d", + user.id, payload.scene, result["should_play"], result["seq"], result["remaining"], + ) + return GuideVideoStartOut(**result) + + +@router.post( + "/reward", + response_model=GuideVideoRewardOut, + summary="引导视频发金币(播完/中途关闭都发,play_token 幂等)", + dependencies=[Depends(rate_limit(60, 60, "guide-video-reward"))], +) +def reward(payload: GuideVideoRewardIn, user: CurrentUser, db: DbSession) -> GuideVideoRewardOut: + result = crud_guide.grant_play( + db, user.id, play_token=payload.play_token, completed=payload.completed + ) + logger.info( + "guide video reward user_id=%d token=%s completed=%s granted=%s coin=%d", + user.id, payload.play_token[:12], payload.completed, result["granted"], result["coin"], + ) + return GuideVideoRewardOut(**result) diff --git a/app/api/v1/meituan.py b/app/api/v1/meituan.py index c1d07a1..b3cf1c6 100644 --- a/app/api/v1/meituan.py +++ b/app/api/v1/meituan.py @@ -5,11 +5,12 @@ from __future__ import annotations import logging -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING, Any from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import nullslast, select -from sqlalchemy.orm import Session, aliased +from sqlalchemy.orm import Session from app.core.config import settings from app.db.session import get_db @@ -25,8 +26,14 @@ from app.schemas.meituan import ( ReferralLinkResponse, TopSalesRequest, ) +from app.utils import mt_search_cursor from app.utils.meituan_city import get_meituan_city +if TYPE_CHECKING: # 仅供类型标注(本模块已开 from __future__ import annotations) + from collections.abc import Callable + + from sqlalchemy import ColumnElement + logger = logging.getLogger("shagua.meituan") @@ -109,6 +116,86 @@ def _commission_pct(card: CouponCard) -> float: return 0.0 +# ────────────── 离线库分页(智能推荐 / 销量最高 共用) ────────────── +# 去重+排序阶段**只投影这几列**:够 DISTINCT ON 分组、够排序、够回表定位,且全是定长小字段。 +# ⚠️ 关键性能点:`raw` 是整条美团原始返回(JSONB,每行数 KB)。原实现用 select(MeituanCoupon) +# 做子查询,等于把整城几千行连 raw 一起塞进两次排序(DISTINCT ON 一次 + 分页一次), +# 体量轻松超过 work_mem → Postgres 落盘做外部归并排序,而且**每翻一页都要重来一遍**。 +# 拆成「先在小列上排出本页 id,再按 id 回表取 raw」后,排序数据量降到原来的百分之几, +# JSONB 只解析当前页 ~20 行。 +_DEDUP_COLS = ( + MeituanCoupon.id, + MeituanCoupon.dedup_key, + MeituanCoupon.sale_volume_num, + MeituanCoupon.commission_percent, +) + + +def _paged_dedup_ids( + db: Session, + *, + conds: list[ColumnElement[bool]], + dedup_order: list[ColumnElement], + page_order: Callable[[Any], list[ColumnElement]], + page: int, + page_size: int, +) -> tuple[list[int], bool]: + """DISTINCT ON(dedup_key) 跨源去重 → 整体排序 → 分页,返回 (本页 id 列表, 是否还有下一页)。 + + - `dedup_order`:同一个 dedup_key 的多条里留哪条(如销量最高/佣金最高)。 + - `page_order`:接收去重子查询的列集合(`sub.c`),返回去重后的整体排序。 + 多取 1 条用于判断 has_next。 + """ + deduped = ( + select(*_DEDUP_COLS) + .where(*conds) + .distinct(MeituanCoupon.dedup_key) + .order_by(MeituanCoupon.dedup_key, *dedup_order) + .subquery() + ) + ids = db.execute( + select(deduped.c.id) + .order_by(*page_order(deduped.c)) + .offset((page - 1) * page_size) + .limit(page_size + 1) + ).scalars().all() + return list(ids[:page_size]), len(ids) > page_size + + +def _load_raws(db: Session, ids: list[int]) -> list[dict]: + """按给定 id 顺序取 raw(只回表本页 ~20 行)。缺行(被 ETL 清掉)静默跳过。""" + if not ids: + return [] + raw_by_id = { + row_id: raw + for row_id, raw in db.execute( + select(MeituanCoupon.id, MeituanCoupon.raw).where(MeituanCoupon.id.in_(ids)) + ).all() + } + return [raw_by_id[i] for i in ids if i in raw_by_id] + + +def _cards_from_raws(raws: list[dict], *, hide_distance: bool) -> list[CouponCard]: + """raw → CouponCard;解析失败的单条跳过,不整页失败。 + + hide_distance:离线库里的距离是相对「城市默认点」算的,对用户无意义且误导 —— 智能推荐 / + 销量最高两个 tab 一律置空,前端「距离 店名」那行只剩店名、自动顶到最左。 + """ + cards: list[CouponCard] = [] + for raw in raws: + try: + card = CouponCard.from_raw(raw or {}) + except Exception: # noqa: BLE001 + continue + if not card.product_view_sign: + continue + if hide_distance: + card.distance_text = None + card.distance_meters = None + cards.append(card) + return cards + + @router.post("/feed", response_model=FeedResponse, summary="混合feed(外卖+到店交叉);tab=rec智能推荐/distance距离最近") def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: lon, lat = req.longitude, req.latitude @@ -133,17 +220,21 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: return [], True # 距离最近:搜索召回(外卖搜"外卖" + 到店搜"美食",都 sortField=6 离我最近)一页页拉。 - # 搜索翻页必须用 searchId(pageNo 翻不动),所以每个 feed 页顺序翻到第 N 页;两路并行、page 1 最快。 - # 无状态、不改 APP(传页码即可);按你位置实时算距离(库里没存 POI 经纬度,只能实时)。 + # 搜索翻页必须用 searchId(pageNo 翻不动),而接口是无状态的(客户端只传页码)—— 原实现因此 + # 每次都从第 1 页顺序重放到第 N 页,取第 N 页要向美团发 N 次请求,越往下滑越慢。 + # 现在把沿途 searchId 记进 [mt_search_cursor],稳态下每翻一页恒定 1 次请求;两路仍并行。 + # 按你位置实时算距离(库里没存 POI 经纬度,只能实时)。 if tab == "distance": 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 + def _replay( + platform: int, biz_line: int | None, keyword: str, + key: mt_search_cursor.RouteKey, start: int, sid: str | None, n: int, + ) -> tuple[list[dict], bool, bool]: + """从第 start 页(用 sid 取)顺序翻到第 n 页。start==1 时 sid 应为 None(走 pageNo=1)。""" data: list[dict] = [] has_next = False - for pg in range(1, n + 1): + for pg in range(start, n + 1): body: dict = { "platform": platform, "searchText": keyword, "sortField": 6, "longitude": lon_i, "latitude": lat_i, "pageSize": 20, @@ -161,10 +252,27 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: data = r.get("data") or [] sid = r.get("searchId") has_next = bool(r.get("hasNext")) and bool(data) + # 记下「下一页要用哪个 searchId」;没有下一页就别记,免得存进死游标。 + if sid and has_next: + mt_search_cursor.remember(key, pg + 1, sid) if not data or (not has_next and pg < n): return [], False, False # 没那么多页了(非错误) return data, has_next, False + def _search_page_n(platform: int, biz_line: int | None, keyword: str, n: int) -> tuple[list[dict], bool, bool]: + """取第 n 页,返回(第 n 页 items, 是否还有下一页, 是否调用失败)。 + + 优先用缓存游标一发直达;缓存未命中/过期才从最近的已知页往后重放,并把沿途游标补进缓存。 + """ + key = mt_search_cursor.route_key(lat, lon, platform, keyword) + start, sid = mt_search_cursor.lookup(key, n) + data, has_next, failed = _replay(platform, biz_line, keyword, key, start, sid, n) + # 用缓存游标却打不通,多半是上游 searchId 过期:作废整条路线,回到第 1 页重放一次。 + if failed and start > 1: + mt_search_cursor.drop(key) + data, has_next, failed = _replay(platform, biz_line, keyword, key, 1, None, n) + return data, has_next, failed + 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) @@ -194,39 +302,25 @@ def feed(req: FeedRequest, db: Session = Depends(get_db)) -> FeedResponse: return FeedResponse(items=[], has_next=False, page=req.page, status="degraded") PAGE = 20 try: - base = select(MeituanCoupon).where( - MeituanCoupon.commission_percent >= 3.0, - MeituanCoupon.city_id == city_id, - ) - 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) + ids, has_next = _paged_dedup_ids( + db, + conds=[ + MeituanCoupon.commission_percent >= 3.0, + MeituanCoupon.city_id == city_id, + ], + # 同一去重键留佣金最高那条 + dedup_order=[MeituanCoupon.commission_percent.desc()], # 销量高的优先(无销量档排后),同档佣金高优先,id 兜底稳定分页 - .order_by(nullslast(m.sale_volume_num.desc()), m.commission_percent.desc(), m.id) - .offset(start) - .limit(PAGE + 1) - ).scalars().all() + page_order=lambda c: [ + nullslast(c.sale_volume_num.desc()), c.commission_percent.desc(), c.id, + ], + page=req.page, page_size=PAGE, + ) + raws = _load_raws(db, ids) 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) + cards = _cards_from_raws(raws, hide_distance=True) if not cards and req.page == 1: # 命中城市却 0 券:该城确无 ≥3% 券,或 ETL 灌的 city_id 与 city_dict 口径不一致。 logger.info("[feed] rec city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id) @@ -282,51 +376,36 @@ def top_sales(req: TopSalesRequest, db: Session = Depends(get_db)) -> CouponList if not city_id: return CouponListResponse(items=[], has_next=False, search_id=None, status="degraded") - # 去重 + 排序 + 分页全在 SQL 做,每页只取并解析当前页 ~20 条。 - # (之前实现每翻一页都全表拉取 + 全量 from_raw 解析,翻页慢 → 客户端滑动卡顿/翻不动。) + # 去重 + 排序 + 分页全在 SQL 做,每页只回表并解析当前页 ~20 条(见 _paged_dedup_ids 的性能说明)。 # 库为空(prod 刚部署 / ETL 未跑完)时返空 + status=empty,不崩;库查询异常降级 degraded。 + conds = [ + MeituanCoupon.sale_volume_num.isnot(None), + MeituanCoupon.city_id == city_id, + ] + if req.platform is not None: + conds.append(MeituanCoupon.platform == req.platform) try: - # 1) DISTINCT ON (dedup_key):每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) - base = select(MeituanCoupon).where( - MeituanCoupon.sale_volume_num.isnot(None), - MeituanCoupon.city_id == city_id, - ) - 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() - - # 2) 对去重结果按销量降序分页;多取 1 条判断 has_next,只对本页做 from_raw - m = aliased(MeituanCoupon, deduped) - start = (req.page - 1) * req.page_size - rows = db.execute( - select(m) + ids, has_next = _paged_dedup_ids( + db, + conds=conds, + # 每个去重键(品牌|名|价)只留销量最高那条(同销量再按佣金) + dedup_order=[ + MeituanCoupon.sale_volume_num.desc(), + MeituanCoupon.commission_percent.desc(), + ], # 加 id 作稳定 tiebreaker:同销量同佣金的并列项排序确定,避免跨页重复/漏项 - .order_by(m.sale_volume_num.desc(), m.commission_percent.desc(), m.id) - .offset(start) - .limit(req.page_size + 1) - ).scalars().all() + page_order=lambda c: [ + c.sale_volume_num.desc(), c.commission_percent.desc(), c.id, + ], + page=req.page, page_size=req.page_size, + ) + raws = _load_raws(db, ids) 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[:req.page_size]: - 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) + # 不显示距离:库里的距离是相对城市默认点的(对用户无意义、且误导),与推荐流口径一致。 + cards = _cards_from_raws(raws, hide_distance=True) if not cards and req.page == 1: # 命中城市却 0 券:可能该城确无券,也可能 ETL 灌的 city_id 与 city_dict 口径不一致(静默降级的隐患)。 logger.info("[top-sales] city_id=%s 命中 0 券(该城确无券?或 ETL/city_dict 的 city_id 口径不一致)", city_id) diff --git a/app/core/config.py b/app/core/config.py index afae3ee..81149db 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -384,6 +384,9 @@ class Settings(BaseSettings): MEDIA_ROOT: str = "./data/media" MEDIA_URL_PREFIX: str = "/media" AVATAR_MAX_BYTES: int = 5 * 1024 * 1024 # 头像最大 5MB + # 运营后台上传的新手引导视频上限。视频比图片大一个量级,单独一档; + # ⚠️ 改大时同步放宽网关 client_max_body_size(实测 QA 4MiB / prod 32MiB),否则 nginx 先挡下。 + GUIDE_VIDEO_MAX_BYTES: int = 100 * 1024 * 1024 # 引导视频最大 100MB # ===== 邀请好友 ===== # 分享落地页(二维码 / 分享链接指向这里;扫码 → 落地页 → 引导浏览器下载 APK)。 diff --git a/app/core/media.py b/app/core/media.py index 192b186..a6e84e4 100644 --- a/app/core/media.py +++ b/app/core/media.py @@ -77,6 +77,35 @@ def save_feedback_qr(data: bytes) -> str: return _save_named("feedback_qr", "qr", data) +def _sniff_video_ext(data: bytes) -> str | None: + """按魔数判定视频类型,返回扩展名;非支持类型返回 None。 + + 只认 MP4 家族(ISO BMFF):`....ftyp` 在偏移 4。Android ExoPlayer 与浏览器