From f7a3ef2e0bd2f8bc80377732761d4a6eca78d032 Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 20 Jun 2026 13:39:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(internal):=20=E5=90=AF=E5=8A=A8=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E7=AA=97=20agent=20=E5=85=9C=E5=BA=95=E6=A0=B7?= =?UTF-8?q?=E6=9C=AC=E8=90=BD=E7=9B=98(pricebot=20=E5=9B=9E=E5=86=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增内部端点 POST /internal/launch-confirm-sample(X-Internal-Secret 鉴权): pricebot 对没见过文案的启动确认窗用 LLM 兜底放行后,把样本(host 包 / 弹窗树 / LLM plan / 设备 locale 机型)回写落 launch_confirm_sample 表,供研发人工沉淀回 pricebot 规则 yaml - 配套: model LaunchConfirmSample + schema + repo(都上报不去重) + alembic 迁移 launch_confirm_sample_table(down_revision=cps_wx_user) + main 注册 router + models/__init__ 登记 - docs: 后端技术实现.md 加 §6.5 pricebot 回写内部端点说明 Co-Authored-By: Claude Opus 4.8 --- .../versions/launch_confirm_sample_table.py | 53 ++++++++++++++++ app/api/internal/launch_confirm.py | 63 +++++++++++++++++++ app/main.py | 2 + app/models/__init__.py | 1 + app/models/launch_confirm_sample.py | 61 ++++++++++++++++++ app/repositories/launch_confirm_sample.py | 35 +++++++++++ app/schemas/launch_confirm_sample.py | 26 ++++++++ docs/后端技术实现.md | 10 +++ 8 files changed, 251 insertions(+) create mode 100644 alembic/versions/launch_confirm_sample_table.py create mode 100644 app/api/internal/launch_confirm.py create mode 100644 app/models/launch_confirm_sample.py create mode 100644 app/repositories/launch_confirm_sample.py create mode 100644 app/schemas/launch_confirm_sample.py diff --git a/alembic/versions/launch_confirm_sample_table.py b/alembic/versions/launch_confirm_sample_table.py new file mode 100644 index 0000000..d9ff6fe --- /dev/null +++ b/alembic/versions/launch_confirm_sample_table.py @@ -0,0 +1,53 @@ +"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀) + +Revision ID: launch_confirm_sample_table +Revises: cps_wx_user +Create Date: 2026-06-20 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "launch_confirm_sample_table" +down_revision: Union[str, Sequence[str], None] = "cps_wx_user" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "launch_confirm_sample", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), + sa.Column("trace_id", sa.String(length=64), nullable=True), + sa.Column("device_id", sa.String(length=64), nullable=True), + sa.Column("host_package", sa.String(length=128), nullable=True), + sa.Column("target_app", sa.String(length=128), nullable=True), + sa.Column("system_locale", sa.String(length=32), nullable=True), + sa.Column("exec_success", sa.Boolean(), nullable=False), + sa.Column("dialog_title", sa.String(length=256), nullable=True), + # PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐 + sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False) + batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id")) + batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at")) + + op.drop_table("launch_confirm_sample") diff --git a/app/api/internal/launch_confirm.py b/app/api/internal/launch_confirm.py new file mode 100644 index 0000000..5f24d3f --- /dev/null +++ b/app/api/internal/launch_confirm.py @@ -0,0 +1,63 @@ +"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。 + +pricebot 的 launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App +启动确认窗时,把完整样本 POST 到这里落库。**不是给客户端的接口**:不走用户 JWT,靠 +server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。密钥未配置 +(默认空)时直接 503,避免裸奔写端点。 + +研发定期人工把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES。 +""" +from __future__ import annotations + +import hmac +import logging +from typing import Annotated + +from fastapi import APIRouter, Header, HTTPException, status + +from app.api.deps import DbSession +from app.core.config import settings +from app.repositories import launch_confirm_sample as repo +from app.schemas.launch_confirm_sample import ( + LaunchConfirmSampleIn, + LaunchConfirmSampleOut, +) + +logger = logging.getLogger("shagua.internal.launch_confirm") + +router = APIRouter(prefix="/internal", tags=["internal"]) + + +def _check_secret(x_internal_secret: str | None) -> None: + """共享密钥校验。未配置 → 503(挡裸奔写端点);不匹配 → 401(常量时间比较)。""" + configured = settings.INTERNAL_API_SECRET + if not configured: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="internal api not configured", + ) + if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid internal secret", + ) + + +@router.post( + "/launch-confirm-sample", + response_model=LaunchConfirmSampleOut, + summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)", +) +def report_launch_confirm_sample( + payload: LaunchConfirmSampleIn, + db: DbSession, + x_internal_secret: Annotated[str | None, Header()] = None, +) -> LaunchConfirmSampleOut: + _check_secret(x_internal_secret) + sid = repo.insert_sample(db, payload) + logger.info( + "launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s", + sid, payload.host_package, payload.system_locale, + payload.exec_success, payload.trace_id, + ) + return LaunchConfirmSampleOut(id=sid) diff --git a/app/main.py b/app/main.py index 702f19b..17ff332 100644 --- a/app/main.py +++ b/app/main.py @@ -22,6 +22,7 @@ from app.api.v1.compare_record import router as compare_record_router from app.api.v1.coupon import router as coupon_router from app.api.v1.cps_redirect import router as cps_redirect_router from app.api.internal.app_version import router as internal_app_version_router +from app.api.internal.launch_confirm import router as internal_launch_confirm_router from app.api.internal.price import router as internal_price_router from app.api.internal.store import router as internal_store_router from app.api.v1.feedback import router as feedback_router @@ -109,6 +110,7 @@ app.include_router(report_router) app.include_router(internal_price_router) app.include_router(internal_store_router) app.include_router(internal_app_version_router) +app.include_router(internal_launch_confirm_router) app.include_router(platform_router) # CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团) app.include_router(cps_redirect_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index 768d192..d88882f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -20,6 +20,7 @@ from app.models.coupon_state import ( # noqa: F401 from app.models.feedback import Feedback # noqa: F401 from app.models.invite import InviteRelation # noqa: F401 from app.models.invite_fingerprint import InviteFingerprint # noqa: F401 +from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401 from app.models.onboarding import OnboardingCompletion # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 diff --git a/app/models/launch_confirm_sample.py b/app/models/launch_confirm_sample.py new file mode 100644 index 0000000..de225b0 --- /dev/null +++ b/app/models/launch_confirm_sample.py @@ -0,0 +1,61 @@ +"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。 + +pricebot 的 launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App +启动确认窗(繁体 / 英文 / 小语种 / ROM 改版)时,把完整样本 server→server 落这里。研发定期 +**人工**把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES(静态快路径), +让以后同款窗不必再调 LLM。 + +设计: +- 与登录无关、不鉴权(server→server 共享密钥头 X-Internal-Secret)。 +- **都上报、不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale + 聚合后人工筛。 +- 大字段(弹窗树快照 + LLM plan + 设备信息)塞 payload(JSONB)兜底,免得加字段就迁移 schema。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 price_observation)。 +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class LaunchConfirmSample(Base): + __tablename__ = "launch_confirm_sample" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True, nullable=False + ) + + # pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。 + trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) + + # 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。 + host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True) + # 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。 + target_app: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。 + # ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。 + system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True) + + # 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。 + exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。 + dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True) + + # 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree, + # device_info: {screen, locale, brand, model, android_version, rom_version}} + payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/repositories/launch_confirm_sample.py b/app/repositories/launch_confirm_sample.py new file mode 100644 index 0000000..4d0b226 --- /dev/null +++ b/app/repositories/launch_confirm_sample.py @@ -0,0 +1,35 @@ +"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。""" +from __future__ import annotations + +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +from app.models.launch_confirm_sample import LaunchConfirmSample +from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn + +logger = logging.getLogger("shagua.launch_confirm") + + +def _trunc(s: Optional[str], n: int) -> Optional[str]: + """截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。""" + return s[:n] if s else None + + +def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int: + """写入一条样本,返回新行 id。""" + row = LaunchConfirmSample( + trace_id=_trunc(payload.trace_id, 64), + device_id=_trunc(payload.device_id, 64), + host_package=_trunc(payload.host_package, 128), + target_app=_trunc(payload.target_app, 128), + system_locale=_trunc(payload.system_locale, 32), + exec_success=bool(payload.exec_success), + dialog_title=_trunc(payload.dialog_title, 256), + payload=payload.payload, + ) + db.add(row) + db.commit() + db.refresh(row) + return row.id diff --git a/app/schemas/launch_confirm_sample.py b/app/schemas/launch_confirm_sample.py new file mode 100644 index 0000000..e2ed223 --- /dev/null +++ b/app/schemas/launch_confirm_sample.py @@ -0,0 +1,26 @@ +"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。""" +from __future__ import annotations + +from pydantic import BaseModel + + +class LaunchConfirmSampleIn(BaseModel): + """一次 agent 兜底的完整样本。 + + 字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)。 + """ + + trace_id: str | None = None + device_id: str | None = None + host_package: str | None = None + target_app: str | None = None + system_locale: str | None = None + exec_success: bool = False + dialog_title: str | None = None + payload: dict | None = None + + +class LaunchConfirmSampleOut(BaseModel): + """落库结果。""" + + id: int diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index 8828ed7..e106985 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -222,6 +222,16 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert --- +## 6.5 pricebot 回写:内部端点(server→server) + +§6 是 app-server 透传给 pricebot;反过来 pricebot 也会把少量数据回写 app-server 落库,走 `app/api/internal/*` —— **不走用户 JWT**,靠共享密钥头 `X-Internal-Secret`(== `INTERNAL_API_SECRET`,未配则 503,防裸奔写端点)校验。 + +**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。 + +> 同类内部回写端点还有 `/internal/price-observation`(价格观测)、`/internal/store-mapping`(跨平台店铺映射)等 pricebot 比价资产沉淀。 + +--- + ## 7. 数据模型 业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。