From 4ee6de2548a1e6e7206230b268ff92d45f8875dc Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 22:13:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(cps):=20=E6=B4=BB=E5=8A=A8=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E7=BC=96=E8=BE=91(PATCH=20/activities/{id})?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CpsActivityUpdate schema + repo update_activity(部分更新,非 None 覆盖) - router 按「合并后最终值」校验平台必填项(同新建口径),写审计 cps.activity.update - 可改名/平台/对应字段/落地页图/备注/状态 Co-Authored-By: Claude Opus 4.8 --- app/admin/repositories/cps.py | 31 ++++++++++++ app/admin/routers/cps.py | 42 ++++++++++++++++ app/admin/schemas/cps.py | 13 +++++ app/models/ad_ecpm.py | 4 +- app/models/comparison.py | 27 +++++++++++ app/models/signin.py | 3 +- app/schemas/compare_record.py | 17 +++++++ docs/README.md | 24 ++++++---- docs/api/compare-intent-recognize.md | 2 +- docs/api/compare-record-report.md | 2 +- docs/api/coupon-step.md | 2 +- docs/database/OVERVIEW.md | 48 +++++++++++++++++-- docs/database/README.md | 29 +++++++++-- docs/database/ad_feed_reward_record.md | 2 + docs/database/ad_reward_record.md | 2 +- docs/database/app_config.md | 4 +- docs/database/cash_transaction.md | 1 + docs/database/coin_transaction.md | 7 +-- docs/database/comparison_milestone_claim.md | 2 +- docs/database/comparison_record.md | 6 ++- docs/database/coupon_state.md | 27 ++++++----- docs/{ => database}/postgres-migration.md | 4 +- docs/database/signin_boost_record.md | 10 ++-- docs/database/signin_record.md | 8 ++-- docs/database/user.md | 9 ++-- docs/database/user_task.md | 7 +-- docs/database/withdraw_order.md | 1 + docs/{ => database}/数据库迁移.md | 2 +- docs/{ => guides}/待办与技术债.md | 6 +-- docs/{ => guides}/看广告赚金币上线清单.md | 2 +- .../邀请功能-实现原理与本地测试.md | 0 docs/integrations/meituan.md | 2 +- docs/integrations/sms.md | 2 +- docs/后端技术实现.md | 16 +++---- 34 files changed, 288 insertions(+), 76 deletions(-) rename docs/{ => database}/postgres-migration.md (97%) rename docs/{ => database}/数据库迁移.md (98%) rename docs/{ => guides}/待办与技术债.md (97%) rename docs/{ => guides}/看广告赚金币上线清单.md (99%) rename docs/{ => guides}/邀请功能-实现原理与本地测试.md (100%) diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py index e94fee1..f1803b0 100644 --- a/app/admin/repositories/cps.py +++ b/app/admin/repositories/cps.py @@ -179,6 +179,37 @@ def create_activity( return activity +def update_activity( + db: Session, activity: CpsActivity, *, name: str | None = None, + platform: str | None = None, act_id: str | None = None, + product_view_sign: str | None = None, payload: str | None = None, + image_url: str | None = None, remark: str | None = None, + status: str | None = None, commit: bool = True, +) -> CpsActivity: + if name is not None: + activity.name = name + if platform is not None: + activity.platform = platform + if act_id is not None: + activity.act_id = act_id + if product_view_sign is not None: + activity.product_view_sign = product_view_sign + if payload is not None: + activity.payload = payload + if image_url is not None: + activity.image_url = image_url + if remark is not None: + activity.remark = remark + if status is not None: + activity.status = status + if commit: + db.commit() + db.refresh(activity) + else: + db.flush() + return activity + + def delete_activity(db: Session, activity: CpsActivity, *, commit: bool = True) -> None: """硬删活动。不级联删 cps_link(已发链接继续可用;淘宝落地页缺活动时取图走兜底默认图)。""" db.delete(activity) diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py index 08980c1..bdcd00c 100644 --- a/app/admin/routers/cps.py +++ b/app/admin/routers/cps.py @@ -19,6 +19,7 @@ from app.admin.schemas.common import CursorPage from app.admin.schemas.cps import ( CpsActivityCreate, CpsActivityOut, + CpsActivityUpdate, CpsGroupCreate, CpsGroupOut, CpsGroupStat, @@ -192,6 +193,47 @@ def create_activity( return CpsActivityOut.model_validate(activity) +@router.patch("/activities/{activity_id}", response_model=CpsActivityOut, summary="编辑活动") +def update_activity( + activity_id: int, + body: CpsActivityUpdate, + request: Request, + admin: Annotated[AdminUser, Depends(require_role("operator"))], + db: AdminDb, +) -> CpsActivityOut: + activity = cps_repo.get_activity(db, activity_id) + if activity is None: + raise HTTPException(status_code=404, detail="活动不存在") + # 用「合并后最终值」校验平台必填项(同新建口径),允许只改部分字段 + platform = body.platform or activity.platform + act_id = body.act_id if body.act_id is not None else activity.act_id + pvs = body.product_view_sign if body.product_view_sign is not None else activity.product_view_sign + payload = body.payload if body.payload is not None else activity.payload + image_url = body.image_url if body.image_url is not None else activity.image_url + if platform == "meituan": + if not act_id and not pvs: + raise HTTPException(status_code=400, detail="美团活动需填 actId 或 productViewSign") + else: # taobao / jd + if not payload: + label = "淘口令" if platform == "taobao" else "推广链接" + raise HTTPException(status_code=400, detail=f"{platform} 活动需填{label}") + if platform == "taobao" and not image_url: + raise HTTPException(status_code=400, detail="淘宝活动需提供落地页图(上传或选已有)") + cps_repo.update_activity( + db, activity, name=body.name, platform=body.platform, act_id=body.act_id, + product_view_sign=body.product_view_sign, payload=body.payload, + image_url=media.to_abs_media_url(body.image_url) if body.image_url else None, + remark=body.remark, status=body.status, commit=False, + ) + write_audit( + db, admin, action="cps.activity.update", target_type="cps_activity", target_id=activity_id, + detail=body.model_dump(exclude_none=True), ip=get_client_ip(request), commit=False, + ) + db.commit() + db.refresh(activity) + return CpsActivityOut.model_validate(activity) + + @router.post("/upload-image", summary="上传活动落地页图(返回绝对 URL)") async def upload_activity_image( admin: Annotated[AdminUser, Depends(require_role("operator"))], diff --git a/app/admin/schemas/cps.py b/app/admin/schemas/cps.py index 4e57c6c..e7c3f7e 100644 --- a/app/admin/schemas/cps.py +++ b/app/admin/schemas/cps.py @@ -71,6 +71,19 @@ class CpsActivityCreate(BaseModel): remark: str | None = Field(default=None, max_length=256) +class CpsActivityUpdate(BaseModel): + """编辑活动(PATCH):全字段可选,只更新传入的非 None 项;最终平台对应字段由 router 校验。""" + + name: str | None = Field(default=None, min_length=1, max_length=128) + platform: str | None = Field(default=None, pattern=r"^(meituan|taobao|jd)$") + act_id: str | None = Field(default=None, max_length=64) + product_view_sign: str | None = Field(default=None, max_length=128) + payload: str | None = Field(default=None, max_length=4096) + image_url: str | None = Field(default=None, max_length=512) + remark: str | None = Field(default=None, max_length=256) + status: str | None = Field(default=None, pattern=r"^(active|archived)$") + + # ───────────── 生成链接(批量) ───────────── class CpsReferralLinksRequest(BaseModel): group_id: int diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index c1aa029..fc10a2f 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -4,8 +4,8 @@ 由客户端生成,并通过激励视频 extra 透传给 S2S 回调,用于把"展示 eCPM"与 "奖励完成"绑定。没有 session id 的旧上报仍可作为按天对账补充。 -⚠️ `ecpm_raw` 原样存客户端上报的字符串——eCPM 单位(分 / 元)截至 2026-05-31 尚未最终确认, -确认后再加一列解析好的数值;在此之前对账按"待定单位"处理。 +`ecpm_raw` 原样存客户端上报的字符串——单位是穿山甲 getEcpm 官方口径【分/千次展示】 +(非元!),对账由 `core/rewards.parse_ecpm_fen` 解析、`parse_ecpm_yuan` ÷100 转元;本列只存原值。 """ from __future__ import annotations diff --git a/app/models/comparison.py b/app/models/comparison.py index f521bc8..6924510 100644 --- a/app/models/comparison.py +++ b/app/models/comparison.py @@ -18,6 +18,7 @@ from sqlalchemy import ( JSON, Boolean, DateTime, + Float, ForeignKey, Index, Integer, @@ -101,6 +102,32 @@ class ComparisonRecord(Base): # 客户端上报的原始 payload(calibration + done.params 全量),未来取数兜底 raw_payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + # ===== debug 维度(客户端上报;旧记录 / 未发版客户端为 None)===== + # 设备环境:无障碍比价高度依赖机型/ROM,这是排"某机型跑不通"类问题的头号线索 + device_model: Mapped[str | None] = mapped_column(String(64), nullable=True) + device_manufacturer: Mapped[str | None] = mapped_column(String(64), nullable=True) + rom_vendor: Mapped[str | None] = mapped_column(String(32), nullable=True) # vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung + rom_name: Mapped[str | None] = mapped_column(String(32), nullable=True) # OriginOS/ColorOS/HyperOS/MIUI/EMUI/HarmonyOS... + rom_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + android_version: Mapped[str | None] = mapped_column(String(16), nullable=True) # Build.VERSION.RELEASE + android_sdk: Mapped[int | None] = mapped_column(Integer, nullable=True) # Build.VERSION.SDK_INT + app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 我们 app versionName + app_version_code: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 被操控的源平台 App 版本(美团/淘宝/京东),排"平台 App 改版导致 UI 适配失效"用 + source_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 比价时设备定位(影响淘宝 deeplink / 距离匹配) + longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + + # ===== 性能 / 过程统计(debug 用)===== + total_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端整场比价墙钟耗时 + step_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端 agent loop 总步数 + llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 本次 LLM 调用次数(server 从 llm_calls 算) + retry_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # LLM 失败重试次数(server 从 llm_calls error 算) + # 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}]; + # server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。 + llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), index=True, nullable=False ) diff --git a/app/models/signin.py b/app/models/signin.py index 63a9043..0a94ea0 100644 --- a/app/models/signin.py +++ b/app/models/signin.py @@ -1,7 +1,8 @@ """签到记录表。 每次签到一行,(user_id, signin_date) 唯一,天然防一天签两次。 - - cycle_day: 1..14,14 天循环里今天落在第几档,决定发多少金币;断签后重置回 1。 + - cycle_day: 1..7,7 天循环里今天落在第几档,决定发多少金币;断签后重置回 1 + (周期长度 = rewards.SIGNIN_CYCLE_LEN,2026-06 由 14 天改 7 天一轮)。 - streak: 连续签到天数(不封顶),用于"已连续签到 N 天"展示;断签后重置回 1。 """ from __future__ import annotations diff --git a/app/schemas/compare_record.py b/app/schemas/compare_record.py index ce8a4a0..1ddede8 100644 --- a/app/schemas/compare_record.py +++ b/app/schemas/compare_record.py @@ -82,6 +82,23 @@ class ComparisonRecordIn(BaseModel): # 时分秒前端拼不出,必须由后端透传)。 trace_url: str | None = Field(None, description="本次比价公网调试链接") + # ===== debug 维度(客户端采集上报;旧客户端不带 → None。仅 admin 比价记录页用)===== + # 必须显式声明,否则 model_dump() 落 raw_payload 时被 pydantic 静默丢弃(同上面 coupon_saved 的坑)。 + device_model: str | None = None + device_manufacturer: str | None = None + rom_vendor: str | None = Field(None, description="vivo/OPPO/Xiaomi/HUAWEI/HONOR/samsung") + rom_name: str | None = Field(None, description="OriginOS/ColorOS/HyperOS/MIUI/HarmonyOS...") + rom_version: int | None = None + android_version: str | None = None + android_sdk: int | None = None + app_version: str | None = None + app_version_code: int | None = None + source_app_version: str | None = Field(None, description="被操控的源平台 App 版本") + longitude: float | None = None + latitude: float | None = None + total_ms: int | None = Field(None, description="整场比价墙钟耗时(ms)") + step_count: int | None = Field(None, description="agent loop 总步数") + # ===== 读取出参 ===== diff --git a/docs/README.md b/docs/README.md index 7e9f966..f14588f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,16 +1,22 @@ # 后端文档库(docs/) -`shaguabijia-app-server` 的文档都在这里。各文档作用: +`shaguabijia-app-server` 的文档都在这里。结构:**根目录放总览,其余按领域/用途分目录**。 -| 文档 / 目录 | 作用 | +## 根目录 + +| 文档 | 作用 | |---|---| -| [后端技术实现.md](./后端技术实现.md) | **后端技术方案**:业务概览、分层架构与目录、登录链路、美团 CPS、领券透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"看这份。 | -| [api/](./api/) | **API 接口文档**(目录),采用"索引 + 一接口一文件",结构见下。想查"某个接口的协议"看这里。 | -| [integrations/](./integrations/) | **集成层实现文档**(目录):穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 | -| [数据库迁移.md](./数据库迁移.md) | **Alembic 迁移指南**:clone 后如何建表、日常升级、新增迁移、迁移文件命名约定。想"把数据库跑起来 / 改表结构"看这份。 | -| [待办与技术债.md](./待办与技术债.md) | **待办与技术债账本**:记"现在先简化、以后要补"的事 + 跨前后端技术债(P1 鉴权/用户绑定、引擎移植待办等),免遗忘。想知道"还欠什么、以后要补什么"看这份。 | -| [看广告赚金币上线清单.md](./看广告赚金币上线清单.md) | **看广告发奖上线 checklist**:跨前后端,记上线前必做(GroMore 回调配置、清理调试脚手架、端到端验收)。上线"看广告赚金币"前对照这份。 | -| [邀请功能-实现原理与本地测试.md](./邀请功能-实现原理与本地测试.md) | **好友邀请(invite-mvp)实现原理 + 跨前后端具体实现 + 本地内网测试方法**:剪贴板 deferred-deeplink 归因、双方各发 1 万金币、落地页 dl.html、起本地后端 + 编 debug 包 + 两台真机走全链路 + 上线前还差什么。要本地测 / 接手邀请功能看这份。 | +| [后端技术实现.md](./后端技术实现.md) | **后端技术方案总览**:业务概览、分层架构与目录、登录链路、美团 CPS、领券/比价透传、数据模型、配置与部署、已知问题。想了解"整个后端怎么回事"先看这份。 | +| README.md | 本文件:文档库索引/传送门。 | + +## 子目录 + +| 目录 | 作用 | +|---|---| +| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 | +| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 | +| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 | +| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试)。 | ## api/ 目录是怎么组织的(传送门式) diff --git a/docs/api/compare-intent-recognize.md b/docs/api/compare-intent-recognize.md index 1b58626..51cb30e 100644 --- a/docs/api/compare-intent-recognize.md +++ b/docs/api/compare-intent-recognize.md @@ -18,7 +18,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。典型含 `result` 外卖比价由客户端无障碍引擎在源平台(淘宝闪购 / 美团 / 京东外卖)购物车页点悬浮球触发 → 调本接口拿 `query` + `calibration` → 进入 `/price/step` 循环。 -⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../待办与技术债.md) P1。 +⚠️ **MVP 阶段不鉴权**(同 `coupon/step`):`device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 行为暂绑不到登录用户。待补 JWT,见 [待办与技术债.md](../guides/待办与技术债.md) P1。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) diff --git a/docs/api/compare-record-report.md b/docs/api/compare-record-report.md index 162cf2b..21085a4 100644 --- a/docs/api/compare-record-report.md +++ b/docs/api/compare-record-report.md @@ -5,7 +5,7 @@ 比价 `done` 帧后,客户端用**带 JWT 的通道**上报一条比价结果,落 `comparison_record` 表,作为「我的比价记录」数据源 + 用户级行为画像。 > ⚠️ 与不鉴权的透传端点 [`/api/v1/price/step`](./compare-price-step.md) 不同:那是转发壳,本接口按用户维度落库,**必须鉴权**。 -> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../待办与技术债.md) P1)。 +> 本轮只做 server 端;客户端在 done 帧后调本接口的改动另起一轮(见 [待办与技术债.md](../guides/待办与技术债.md) P1)。 ## 入参(JSON body) diff --git a/docs/api/coupon-step.md b/docs/api/coupon-step.md index 333aae5..809183d 100644 --- a/docs/api/coupon-step.md +++ b/docs/api/coupon-step.md @@ -17,7 +17,7 @@ pricebot-backend 的响应**原样返回**(JSON object)。 是产品"一键领券"的接入点,**前端已接通**:首页「去领取」→ `CouponPromptDialog` 确认 → 权限检查 → 无障碍引擎 `PriceBotService.startCouponClaim()` → 循环调本接口,后端逐张券下发 launch/wait/done。 -⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer ` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../待办与技术债.md) P1。 +⚠️ **客户端契约 vs Server 实现**:Android 客户端通过 `AuthInterceptor` 已自动带 `Authorization: Bearer ` 头(契约层 OK);Server MVP 阶段**暂未启用强校验**——[coupon.py](../../app/api/v1/coupon.py) 没接 `Depends(get_current_user)`,只读 `device_id` 透传给 pricebot 区分设备,后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补强校验 + `device_id↔user_id` 绑定,详见 [待办与技术债.md](../guides/待办与技术债.md) P1。 **相关配置**: - `PRICEBOT_BASE_URL`(默认 `http://localhost:8000`) diff --git a/docs/database/OVERVIEW.md b/docs/database/OVERVIEW.md index 04ae153..281d20f 100644 --- a/docs/database/OVERVIEW.md +++ b/docs/database/OVERVIEW.md @@ -2,7 +2,7 @@ > 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。 > 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。 -> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **28 张业务表** + `alembic_version`(框架的迁移版本指针)。领券联动的「今日状态」三张表(`coupon_*`)同理:领券过程在 pricebot 内存态跑、**不落库**,只有结果回到 app-server 才落这三张表。 +> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **37 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 5 张)是两个独立子系统。 --- @@ -16,6 +16,8 @@ | 比价战绩里程碑(逐档领金币) | [`comparison_milestone_claim`](./comparison_milestone_claim.md) | 累计成功比价 N 次解锁;进度读 `comparison_record` 计数 | | profile「累计省了 / 省钱战绩 / 省钱明细」 | [`savings_record`](./savings_record.md) | 真实下单归因(source=compare)+ 无真实数据时 demo 兜底 | | 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 | +| (无 App UI)比价 done 后客观价格沉淀 | [`price_observation`](./price_observation.md) | **pricebot server→server 内部上报**;平台/门店视角的到手价事实,匿名也记,与 `comparison_record`(用户视角)互补 | +| (无 App UI)跨平台门店身份映射 | [`store_mapping`](./store_mapping.md) | **pricebot 内部上报**;同店在淘宝/美团/京东的 id/名/deeplink,供下次比价 `lookup` 反查省掉现场搜店 | ### 领券(每日领券联动 · 今日状态) | App 位置 / 动作 | 表 | 说明 | @@ -31,7 +33,7 @@ | 资产卡 / 钱包余额 | [`coin_account`](./coin_account.md) | 一用户一行的金币+现金余额快照 | | 金币明细 | [`coin_transaction`](./coin_transaction.md) | 每次金币变动一笔流水 | | 现金明细 | [`cash_transaction`](./cash_transaction.md) | 每次现金变动一笔流水(分) | -| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 14 天循环发币;签到后看广告可膨胀一次 | +| 每日签到 | [`signin_record`](./signin_record.md) + [`signin_boost_record`](./signin_boost_record.md) | 7 天循环发币;签到后看广告可膨胀一次 | | 一次性任务(开消息提醒等) | [`user_task`](./user_task.md) | 领一次发币 | | 看激励视频赚金币 | [`ad_reward_record`](./ad_reward_record.md) + [`ad_watch_log`](./ad_watch_log.md) + [`ad_ecpm_record`](./ad_ecpm_record.md) | 独立数据流:发奖 / 旧版观看时长 / 收益对账 | | 信息流广告结算 | [`ad_feed_reward_record`](./ad_feed_reward_record.md) | 每展示满 10 秒累计一份奖励,完成后一次性入账 | @@ -46,6 +48,21 @@ | 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 | | 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 | +### 好友邀请(注册增长) +| App 位置 / 动作 | 表 | 说明 | +|---|---|---| +| 输入/剪贴板邀请码绑定 | [`invite_relation`](./invite_relation.md) | 注册即生效,邀请人+被邀请人各发 1 万金币;`invitee_user_id` 唯一=幂等防重复发奖 | +| 落地页访问指纹(剪贴板归因兜底) | [`invite_fingerprint`](./invite_fingerprint.md) | 剪贴板没拿到码时,用 (ip+机型+屏幕) 7 天内反查邀请人 | + +### 美团 CPS 群发联盟(私域社群比价,运营后台驱动 · 群发选品→点击→对账漏斗) +| 后台/用户动作 | 表 | 说明 | +|---|---|---| +| 运营建可推广活动 | [`cps_activity`](./cps_activity.md) | 预存券/物料(美团 actId / 淘宝淘口令 / 京东链接) | +| 运营建微信推广群 | [`cps_group`](./cps_group.md) | 一群一行,`sid`=美团二级渠道追踪位 | +| 后台批量生成群发短链 | [`cps_link`](./cps_link.md) | 群×活动 → `/c/{code}`,运营复制到微信群 | +| 用户点短链(落地/复制口令) | [`cps_click`](./cps_click.md) | 记 visit/copy 事件,统计 PV/UV | +| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 | + ### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权) | 后台模块 | 表 | 说明 | |---|---|---| @@ -84,7 +101,9 @@ | 首次进 profile 省钱页且无真实记录 | `savings_record`(C `source=demo`) | 懒种子,`ensure_seeded` 按 user 幂等 | | 上报更低价 `POST /report` | `price_report`(C) | 读 `comparison_record.best_price_cents` 校验 | | 提交反馈 `POST /feedback` | `feedback`(C) | | -| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, 北京日)` 幂等;best-effort | +| 绑定邀请 `POST /invite/bind` | `invite_relation`(C `effective`) + `coin_account`(U×2) + `coin_transaction`(C `invite_inviter` + `invite_invitee`) | 同事务;`invitee_user_id` 唯一幂等,双方各发 1 万金币 | +| 落地页归因 `POST /invite/landing-track` | `invite_fingerprint`(C) | 剪贴板归因兜底线索,登录后用 (ip+机型+屏幕) 反查 | +| 领券首帧 `POST /api/v1/coupon/step`(step=0) | `coupon_prompt_engagement`(C/U `claim_started`) | `(device_id, package, 北京日)` 幂等;best-effort | | 领券每帧结果 `POST /api/v1/coupon/step` | `coupon_claim_record`(C/U) | `(device_id, coupon_id, 北京日)` 幂等;best-effort | | 领券跑完 `POST /api/v1/coupon/step`(action.command=done) | `coupon_daily_completion`(C/U) | `(device_id, 北京日)` 幂等;best-effort | | 拒绝领券引导窗 `POST /api/v1/coupon/prompt/dismiss` | `coupon_prompt_engagement`(C/U `dismissed`) | 同上;客户端通知(透传链路看不到拒绝) | @@ -103,7 +122,19 @@ | 改运营配置 | `app_config`(C/U)+`admin_audit_log`(C) | 同事务 | | 任意写操作 | `admin_audit_log`(C,**永不 U/D**) | | -> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U。 +> 没有任何表会被业务流程物理 DELETE。注销是软删(改 user 行),其余只 C/U(领券 `/prompt/reset`、`/completed-today/reset` 是开发用删除,非业务流程)。 + +### CPS 群发联盟 & pricebot 内部上报(非 C 端用户、非 admin 常规管理触发) +| 触发 | 写入 | 操作 | +|---|---|---| +| 运营后台建活动 / 建群 | `cps_activity`(C/U) / `cps_group`(C/U) | `app/admin/routers/cps.py`;群含美团平台才分配 `sid` | +| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) | +| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join | +| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order` 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) | +| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) | +| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) | + +> CPS 5 表与本 App `user` **无关**(被推广群的用户在美团下单,不是本 App 注册用户);`price_observation`/`store_mapping` 的 `source_user_id` 是可空旁路(链路不鉴权,匿名也记)。 --- @@ -113,6 +144,8 @@ - **17 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`。 - `admin_audit_log.admin_id` → `admin_user.id`。 - `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。 +- **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。 +- **CPS 5 表 + 比价沉淀 2 表均无硬 FK**(全靠 `sid`/`trace_id` 语义对齐,见下)。 ### 语义 join key(无 FK 约束,靠业务字段对齐 —— 排障/对账必看) - **`coin_transaction.ref_id` 指向随 `biz_type` 变**: @@ -139,6 +172,8 @@ - **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。 - **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。 - **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。 +- **CPS 群发 5 表全靠 `sid` / id 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。 +- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。 ### ER 关系(文字版) ``` @@ -154,6 +189,11 @@ admin_user ─1:N─ admin_audit_log app_config (独立, 无外键, key 为主键) coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record (独立, 无硬 FK; 维度=device_id+北京日, user_id/trace_id 仅软关联) +user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK) +user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK) +cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click + (CPS 5 表自成子系统; cps_order 经 sid 归群对账, 与本 App user 无关) +price_observation / store_mapping (独立, 无硬 FK; 维度=trace_id, source_user/device 仅软关联; pricebot 内部上报) ``` --- diff --git a/docs/database/README.md b/docs/database/README.md index 24f91ad..20364ca 100644 --- a/docs/database/README.md +++ b/docs/database/README.md @@ -3,13 +3,13 @@ > 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。 > ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。 > 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。 -> 最后更新:2026-06-11(合并:新增 3 张领券今日状态表 `coupon_*` + `onboarding_completion` 新手引导完成表;含 [OVERVIEW 总览](./OVERVIEW.md)) +> 最后更新:2026-06-17(补全 9 张此前缺文档的表:CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping`;并全表 review 对齐 model——`signin_record` 改 7 天循环、`coupon_prompt_engagement` 加 `package` 列按 App 频控、`user` 补 `username`/`invite_code`/`debug_trace_enabled` 等;含 [OVERVIEW 总览](./OVERVIEW.md)) > 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。 --- -## 表总览(28 张业务表 + `alembic_version` 框架表) +## 表总览(37 张业务表 + `alembic_version` 框架表) ### 账号 / 反馈 | 表 | 用途 | 模型 | 文档 | @@ -18,6 +18,12 @@ | `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) | | `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) | +### 好友邀请(注册增长) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `invite_relation` | 邀请绑定关系(注册即生效,双方各发1万金币;`invitee_user_id` 唯一=幂等防重复发奖) | `models/invite.py` | [详情](./invite_relation.md) | +| `invite_fingerprint` | 剪贴板归因失败时的指纹兜底(落地页记 ip+屏幕+机型,登录后反查邀请人) | `models/invite_fingerprint.py` | [详情](./invite_fingerprint.md) | + ### 钱包 / 福利(看广告赚钱闭环) | 表 | 用途 | 模型 | 文档 | |---|---|---|---| @@ -26,7 +32,7 @@ | `cash_transaction` | 现金流水账本(分) | `models/wallet.py` | [详情](./cash_transaction.md) | | `withdraw_order` | 提现单(现金→微信零钱,含人工审核态) | `models/wallet.py` | [详情](./withdraw_order.md) | | `wechat_transfer_authorization` | 微信免确认转账授权(一用户一行) | `models/wallet.py` | [详情](./wechat_transfer_authorization.md) | -| `signin_record` | 签到记录(14 天循环) | `models/signin.py` | [详情](./signin_record.md) | +| `signin_record` | 签到记录(7 天循环) | `models/signin.py` | [详情](./signin_record.md) | | `signin_boost_record` | 签到后看广告膨胀记录 | `models/signin.py` | [详情](./signin_boost_record.md) | | `user_task` | 一次性任务领取去重 | `models/task.py` | [详情](./user_task.md) | | `ad_reward_record` | 看激励视频发奖记录(S2S 回调,trans_id 幂等) | `models/ad_reward.py` | [详情](./ad_reward_record.md) | @@ -42,10 +48,16 @@ | `savings_record` | 省钱记录(profile 省钱战绩源;真实下单归因 + demo) | `models/savings.py` | [详情](./savings_record.md) | | `price_report` | 上报更低价(众包纠偏,人工审核发奖) | `models/price_report.py` | [详情](./price_report.md) | +### 比价数据沉淀(pricebot server→server 内部上报,不走用户鉴权) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `price_observation` | 价格观测(平台/门店视角的到手价事实,匿名也记;`comparison_record` 的客观对照) | `models/price_observation.py` | [详情](./price_observation.md) | +| `store_mapping` | 跨平台「同一家店」id/名/deeplink 身份映射(淘宝/美团/京东,供 lookup 反查省现场搜店) | `models/store_mapping.py` | [详情](./store_mapping.md) | + ### 领券(每日领券联动 · 今日状态) | 表 | 用途 | 模型 | 文档 | |---|---|---|---| -| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+日) | `models/coupon_state.py` | [详情](./coupon_state.md) | +| `coupon_prompt_engagement` | 领券引导窗频控源(今日是否已 engage,按 device+package+日) | `models/coupon_state.py` | [详情](./coupon_state.md) | | `coupon_daily_completion` | 首页「去领取」置灰源(今日是否已跑完整轮) | `models/coupon_state.py` | [详情](./coupon_state.md) | | `coupon_claim_record` | 每张券领取结果沉淀(资产/画像/排查,不参与判断) | `models/coupon_state.py` | [详情](./coupon_state.md) | @@ -54,6 +66,15 @@ |---|---|---|---| | `meituan_coupon` | 美团 CPS 券本地缓存(智能推荐/销量榜从库出,定时 ETL 灌入) | `models/meituan_coupon.py` | [详情](./meituan_coupon.md) | +### 美团 CPS 群发联盟(私域社群比价 · 群发选品→点击→对账漏斗) +| 表 | 用途 | 模型 | 文档 | +|---|---|---|---| +| `cps_activity` | 可推广活动池(运营预存的券/物料:美团 actId / 淘宝淘口令 / 京东链接) | `models/cps_activity.py` | [详情](./cps_activity.md) | +| `cps_group` | 推广微信群(`sid`=美团二级渠道追踪位,订单按 sid 归群对账) | `models/cps_group.py` | [详情](./cps_group.md) | +| `cps_link` | 群发短链 `/c/{code}`(群×活动生成,302 跳/淘宝落地页) | `models/cps_link.py` | [详情](./cps_link.md) | +| `cps_click` | 短链点击事件流(visit/copy,PV/UV 漏斗源) | `models/cps_link.py` | [详情](./cps_click.md) | +| `cps_order` | 美团联盟 query_order 拉回的对账订单(按 sid 归群算佣金) | `models/cps_order.py` | [详情](./cps_order.md) | + ### 首页门面数据 | 表 | 用途 | 模型 | 文档 | |---|---|---|---| diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md index 99cccb4..f6edbfb 100644 --- a/docs/database/ad_feed_reward_record.md +++ b/docs/database/ad_feed_reward_record.md @@ -1,5 +1,7 @@ # ad_feed_reward_record — 信息流广告奖励记录 +> 模型 `app/models/ad_feed_reward.py` · 仓库 `app/repositories/ad_feed_reward.py` · 接口 `POST /api/v1/ad/feed-reward`(信息流广告结算) · [← 索引](./README.md) · [总览](./OVERVIEW.md) + 点位 2 的奖励记录。每条记录对应客户端完成的一次信息流广告展示/播放事件。 ## 字段 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index 07c08be..dc6eaf9 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -21,7 +21,7 @@ | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 | | `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) | | `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day` | +| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day`/ `unknown_scene`(回调 `reward_scene` 不在支持集合,只留痕不发) | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) | diff --git a/docs/database/app_config.md b/docs/database/app_config.md index e5b787d..95e7aac 100644 --- a/docs/database/app_config.md +++ b/docs/database/app_config.md @@ -14,8 +14,8 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` | -| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled) | +| `key` | String(64) | **PK** | 配置标识,取值见 `config_schema.CONFIG_DEFS`:`signin_rewards` / `min_exchange_coin` / `withdraw_min_cents` / `withdraw_max_cents` / `task_rewards` / `record_milestones` / `ad_reward_coin` / `ad_daily_limit` / `ad_max_coin` / `ad_round_count` / `ad_cooldown_sec` / `signin_boost_coin` / `withdraw_auto_reconcile_enabled` / `comparing_ad_enabled` | +| `value` | JSON(PG: JSONB) | NOT NULL | 配置值,类型随 key(`int` / `int_list` 如签到 14 档 / `dict_str_int` 如 task_rewards / `bool` 如 withdraw_auto_reconcile_enabled / comparing_ad_enabled) | | `updated_by_admin_id` | Integer | nullable | 最后修改的管理员 id(= `admin_user.id`,软引用,无 FK) | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最后修改时间 | diff --git a/docs/database/cash_transaction.md b/docs/database/cash_transaction.md index d379fee..f782184 100644 --- a/docs/database/cash_transaction.md +++ b/docs/database/cash_transaction.md @@ -34,6 +34,7 @@ ## 索引与约束 - PK `id`;index `user_id`、`created_at`。 +- 部分唯一索引 `ux_cash_transaction_withdraw_refund_ref`(`ref_id`),条件 `biz_type = 'withdraw_refund' AND ref_id IS NOT NULL`:一笔提现单只能退款一次(按 `out_bill_no` 去重),挡重复退款。 ## 注意 - 退款流水 `remark` 是用户可见文案(区分"未成功自动退"vs"审核未通过退");技术原因记在 `withdraw_order.fail_reason`,不外露。 diff --git a/docs/database/coin_transaction.md b/docs/database/coin_transaction.md index e1289a1..f29958d 100644 --- a/docs/database/coin_transaction.md +++ b/docs/database/coin_transaction.md @@ -10,8 +10,8 @@ | 动作 / endpoint | `biz_type` | `amount` | `ref_id` 指向 | |---|---|---|---| | 签到 `POST /signin/do` | `signin` | + | 当天日期串(= `signin_record.signin_date` ISO) | - | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 当天日期串(= `signin_boost_record.signin_date` ISO) | - | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | `user_task.task_key` | + | 签到后看广告膨胀 `POST /signin/boost` | `signin_boost` | + | 广告 `trans_id`(= `signin_boost_record.ad_ref_id`);无 ad_ref_id 时回退当天日期 ISO 串 | + | 领任务 `POST /tasks/claim` | `task_`(如 `task_enable_notification`) | + | 一次性任务=`user_task.task_key`;可重复任务(`enable_notification`)=带序号 `task_key:N` | | 普通激励视频 S2S 回调 `POST /ad/pangle-callback` | `reward_video`(历史兼容:`ad_reward`) | + | `ad_reward_record.trans_id` | | 信息流广告结算 `POST /ad/feed-reward` | `feed_ad_reward` | + | `ad_feed_reward_record.client_event_id` | | 金币兑现金 `POST /wallet/exchange` | `exchange_out` | − | null(配套 `cash_transaction.exchange_in`) | @@ -36,10 +36,11 @@ ## 关系 / Join Key - `user_id` → `user.id`(多对一)。 -- `ref_id` 是**软关联**(无 FK),目标表随 `biz_type`:`signin`→签到日 / `signin_boost`→`ad_reward_record.trans_id` / `task_`→`user_task.task_key` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 +- `ref_id` 是**软关联**(无 FK),目标随 `biz_type`:`signin`→签到日(`signin_record.signin_date` ISO) / `signin_boost`→`signin_boost_record.ad_ref_id`(无则当天日期) / `task_`→一次性任务=`user_task.task_key`、可重复任务=`task_key:N` / `reward_video`/`ad_reward`→`ad_reward_record.trans_id` / `feed_ad_reward`→`ad_feed_reward_record.client_event_id` / 其余 null。 ## 索引与约束 - PK `id`;index `user_id`、`created_at`。 +- 部分唯一索引 `ux_coin_transaction_task_ref`(`user_id`, `biz_type`, `ref_id`),条件 `biz_type LIKE 'task%' AND ref_id IS NOT NULL`:可重复任务(如打开消息提醒)按 `ref_id` 序号(`task_key:N`)去重,挡并发/连点重复发放;仅 `task_*` 且 `ref_id` 非空生效,不影响 signin/exchange/withdraw 等其它 `biz_type`。 ## 注意 - 流水与余额快照(`coin_account`)、与对应业务记录(signin/task/ad_reward/feed_ad_reward 等)在**同一事务**写,不会只发币不留痕。 diff --git a/docs/database/comparison_milestone_claim.md b/docs/database/comparison_milestone_claim.md index a9d6c39..7079b6c 100644 --- a/docs/database/comparison_milestone_claim.md +++ b/docs/database/comparison_milestone_claim.md @@ -5,7 +5,7 @@ App「记录比价战绩」每档(第 1~6 次)只能领一次,领取后写一行去重。解锁进度**不存本表**——由 `comparison_record` 里 `status='success'` 的条数实时算(第 N 档在"成功比价 ≥ N 次"时解锁)。档位金额来自 `rewards.RECORD_MILESTONES`(默认 `(120,180,300,500,800,1200)`,运营后台 `app_config.record_milestones` 可改)。仿 `user_task` 的一次性领取模型。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /compare/milestone/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。 +- **C(插入)**:`POST /compare/milestones/{milestone}/claim`(`claim`)。档位合法 + 已解锁(`count_success >= milestone`)+ 未领过 → 写一行(`coin_awarded=0`)。越界 `UnknownMilestoneError`(404)、未解锁 `MilestoneLockedError`(409)、已领 `AlreadyClaimedError`(409)。 - **U / D**:无。 - **R**:`GET /compare/milestones`(战绩页:各档 claimed/active/locked + 成功次数),`get_status` 读本表已领集合 + `comparison_record` 成功计数对照。 diff --git a/docs/database/comparison_record.md b/docs/database/comparison_record.md index ba47da1..b49cf8e 100644 --- a/docs/database/comparison_record.md +++ b/docs/database/comparison_record.md @@ -5,6 +5,7 @@ 每完成一次比价(外卖/电商/领券),客户端在 done 帧后用**带 JWT** 的通道上报一条。App「我的比价记录」列表/详情的数据源,也是比价战绩里程碑解锁进度的计数源(`status='success'` 条数),还被「上报更低价」反查原最低价。 > 与 `savings_record` 的区别:本表是「每一次**比价行为**的完整明细」(不省钱、甚至失败也记);`savings_record` 是「真正**下单成交**省了多少」。两表独立、互不喂数据。 +> 与 [`price_observation`](./price_observation.md) / `store_mapping` 的区别:本表是**用户视角**(登录后按 `user_id` 存「我的比价记录」);后两张是 server 侧无条件沉淀的**平台/门店视角客观事实**(价格事实 / 跨平台店铺身份映射),与本表 `trace_id` 同源但不互相 join,各存各的视角。 ## 用在哪 / 增删改查 - **C / U(upsert,幂等)**:`POST /compare/record`(`upsert_record`)。按 `(user_id, trace_id)` 查:不存在→新建;已存在→整行覆盖(客户端重试/重复上报时,**更完整的那次胜出**)。`best_*`/`saved_amount_cents`/`is_source_best`/`status` 由 `_derive` 从 `comparison_results` 算出(协议已按 price 升序、rank=1 最便宜),不信客户端自算。 @@ -19,6 +20,7 @@ | `device_id` | String(64) | nullable | 设备号(多设备区分 / 与不鉴权期对账) | | `business_type` | String(16) | NOT NULL, default `food`, index | 取值:`food`(当前唯一接通)/ `ecom` / `coupon` | | `trace_id` | String(64) | NOT NULL | pricebot 侧 trace_id(关联调试落盘 + 幂等键) | +| `trace_url` | String(512) | nullable | 本次比价公网调试链接(`price.shaguabijia.com/traces/{dir}/`);dir 名含 pricebot 落盘时分秒,前端/server 拼不出必须存。查看接口按 `user.debug_trace_enabled`(或本机 agent 调试 `include_trace`)决定返不返回。旧记录 / 未开上云为 null | | `source_platform_id` / `_name` | String(32) | nullable | 源平台代号 / 中文名 | | `source_package` | String(128) | nullable | 源平台 Android 包名 | | `source_price_cents` | Integer | nullable | 源平台到手价(分) | @@ -32,7 +34,7 @@ | `status` | String(16) | NOT NULL, default `success` | 取值:`success`(有非源且有价的目标结果)/ `failed`(出错/没采到目标价)。**里程碑只数 success** | | `information` | String(256) | nullable | done 帧文案;成功=摘要,失败=具体原因(前端失败时当原因展示) | | `items` | JSON(PG: JSONB) | NOT NULL, default [] | 下单菜品 `[{name, qty, specs?}]` | -| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用) | +| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 | | `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 | | `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | @@ -46,7 +48,7 @@ - 被 `comparison_milestone_claim` 间接依赖:解锁进度 = 本表 `status='success'` 计数。 ## 索引与约束 -- PK `id`;index `user_id`、`business_type`、`created_at`;UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。 +- PK `id`;index `user_id`、`business_type`、`created_at`;复合 index `ix_comparison_status_created`(`status`, `created_at`)(按 `status='success'` 过滤 + 近期排序的聚合/轮播,避免随数据量退化为全表扫);UNIQUE(`user_id`, `trace_id`) = `uq_comparison_user_trace`(幂等覆盖)。 ## 注意 - 4 个 JSON 列用 `JSON().with_variant(JSONB(),"postgresql")`(SQLite 退化 JSON)。结构化金额列存「分」,`comparison_results.price`/`coupon_saved` 原样存「元」。 diff --git a/docs/database/coupon_state.md b/docs/database/coupon_state.md index 956f227..c6559b8 100644 --- a/docs/database/coupon_state.md +++ b/docs/database/coupon_state.md @@ -4,7 +4,7 @@ 领券(优惠券自动化)联动产生的三张「今日状态」表,都挂在领券透传端点 `POST /api/v1/coupon/step` 这条链路上(pricebot 跑领券,结果回 app-server 落库;**领券过程本身在 pricebot 内存态跑、不落库**)。三表各管一件事: -- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, 自然日)` 记「今天是否对领券引导窗表达过**意向**」(点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天 engage 过就不再弹。 +- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。 - **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。 - **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。 @@ -19,35 +19,38 @@ --- -## coupon_prompt_engagement — 弹窗频控(今日是否已对引导窗表达意向) +## coupon_prompt_engagement — 弹窗频控(今日这个 App 是否已对引导窗表达意向) -`(device_id, engage_date)` 唯一,一台设备一天一条;今天 engage 过(领或拒)就不再弹。 +`(device_id, package, engage_date)` 唯一,一台设备、一个 App、一天一条;今天**这个 App** engage 过(弹/领/拒)就不再弹该 App。各 App 独立:美团弹过不压淘宝/京东。 ### 用在哪 / 增删改查 -- **C / U(幂等 upsert)**:`mark_engagement`。两条触发: +- **C / U(幂等 upsert)**:`mark_engagement`。三条触发: + - `POST /api/v1/coupon/prompt/shown`(引导窗弹出那刻上报)→ 记 `shown`(频控主判据:弹出即占用今天这个 App 的「一次」,领/拒/无视都算用掉); - `POST /api/v1/coupon/step` 且 `step==0`(领券首帧=用户已发起领券)→ 记 `claim_started`; - `POST /api/v1/coupon/prompt/dismiss`(用户点关闭引导窗;server 在透传链路看不到「拒绝」,必须客户端通知)→ 记 `dismissed`。 - - 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。 -- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天那条,开发设置「重置今日领券弹窗状态」按钮调,测频控用;删后今天又能弹。 -- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…`(`has_engaged_today`)→ `should_show = not 今天已 engage`。客户端切外卖 App 前查,纯后台判据。 + - 已有今天那条则覆盖 `engage_type`(并补 `user_id`),否则插入。`step` 帧的 `package` 取 step body 的 `package`,缺/空则退化为 `""` 占位(全局态)。 +- **D**:`POST /api/v1/coupon/prompt/reset`(`reset_today_engagement`)—— 删这台设备今天**所有 App** 的 engagement(不按 package 过滤:重置=从头测,清全部 App 最符预期),开发设置「重置今日领券弹窗状态」按钮调;删后今天又都能弹。 +- **R**:`GET /api/v1/coupon/prompt/should-show?device_id=…&package=…`(`has_engaged_today`)→ `should_show = not 今天该 App 已 engage`。客户端切外卖 App 前带 `package` 查(老客户端不带 → `""` 全局态)。 ### 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `device_id` | String(64) | NOT NULL | 判断/聚合维度;客户端 `getOrCreateDeviceId`,重装会变 | +| `package` | String(64) | NOT NULL, server_default `""` | 触发弹窗的目标 App 包名(`com.sankuai.meituan` / `com.taobao.taobao` / `com.jingdong.app.mall`)。频控维度,各 App 独立。旧行(2026-06-14 改造前)无此值 → 迁移用占位 `""` 填 | | `user_id` | Integer | index, 可空 | 登录态有就记(资产);不进唯一键、不阻塞判断 | | `engage_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`) | -| `engage_type` | String(16) | NOT NULL | `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天有没有这条」,type 不影响弹不弹** | +| `engage_type` | String(16) | NOT NULL | `shown`(自动弹出即记)/ `claim_started`(点一键领取)/ `dismissed`(点拒绝关闭);仅记录区分,**判断只看「今天这个 App 有没有这条」,type 不影响弹不弹** | | `created_at` | DateTime(tz) | server_default now() | | | `updated_at` | DateTime(tz) | server_default now(), onupdate now() | | ### 索引与约束 -- PK `id`;index `user_id`;UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`(一台设备一天一条)。 +- PK `id`;index `user_id`;UNIQUE(`device_id`, `package`, `engage_date`) = `uq_coupon_engage_device_pkg_date`(一台设备、一个 App、一天一条)。 + - 历史:原为 UNIQUE(`device_id`, `engage_date`) = `uq_coupon_engage_device_date`,2026-06-14 迁移 `coupon_engage_per_package` 加入 `package` 维度改成现状(修「任一 App 弹过就把整台设备当天标记、压住其余 App」的 bug)。 ### 注意 - `device_id` 重装会变 → 重装当新设备,今天重新弹一次(产品预期)。 -- 判断只看「今天这台设备有没有这条」,不看 `engage_type`(领或拒都算 engage 过、都不再弹)。 +- 判断只看「今天这台设备这个 App 有没有这条」,不看 `engage_type`(弹/领/拒都算 engage 过、都不再弹该 App)。 --- @@ -57,7 +60,7 @@ ### 用在哪 / 增删改查 - **C / U(幂等 upsert)**:`mark_completed_today`,由 `POST /api/v1/coupon/step` 在 pricebot 返回 `action.command == "done"` 那帧调。pricebot 把中途单券 done 改写成 `wait+continue=true`,只有整套全跑完那帧才保留 `command=="done"`,故 **done 已等价「整轮完成」**。已有今天那条则补 `user_id`/`trace_id`,否则插入。 -- **U / D**:无业务删除。 +- **D**:`POST /api/v1/coupon/completed-today/reset`(`reset_today_completion`)—— 删这台设备今天那条,首页「去领取」卡恢复可点。与 `/prompt/reset` 配套,开发设置「重置今日领券弹窗状态」一键把今日状态全清;MVP 不鉴权。 - **R**:`GET /api/v1/coupon/completed-today?device_id=…`(`has_completed_today`)→ `completed`。客户端据此把首页「去领取」卡置灰、不可点。 ### 字段 @@ -119,5 +122,5 @@ ## 三表共性小结 - 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。 -- 唯一键都含 `device_id` + 某个北京自然日列;`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 +- 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。 - 无硬外键:`user_id` 软指 `user.id`、`trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。 diff --git a/docs/postgres-migration.md b/docs/database/postgres-migration.md similarity index 97% rename from docs/postgres-migration.md rename to docs/database/postgres-migration.md index 8d1ee58..84f6567 100644 --- a/docs/postgres-migration.md +++ b/docs/database/postgres-migration.md @@ -5,7 +5,7 @@ > **相关文档**: > - 这份文档讲 **切引擎的完整步骤**(本机装 PG / 建库 / `scripts/init_postgres.py` 用法 / 生产切换) > - [数据库迁移.md](./数据库迁移.md) 讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查/迁移链 12 条) -> - [后端技术实现.md §10](./后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug) +> - [后端技术实现.md §10](../后端技术实现.md) 列待办与已知问题(含 `init_postgres.py` 已知小 bug) > > **背景假设**:当前生产**无真实用户数据**(MVP 阶段),所以整个迁移本质是"切引擎",不涉及数据搬迁。如果将来已有真实数据,本文档不适用,需补充 `pgloader` 演练 + 钱表金额逐行核对 + 停服窗口 + 回滚预案。 > @@ -248,7 +248,7 @@ sudo systemctl restart shaguabijia-app-server ## 6. 顺便建议:同时装上 Redis -PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](./待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署: +PG 上线后,Redis 是下一个明确要补的基础设施(已在 [待办与技术债.md](../guides/待办与技术债.md) 里列了用途)。**建议本次维护窗口顺手把 Redis 也装上**,不开始用、只装服务,免得下次还要单独申请部署: ```bash sudo apt install redis-server diff --git a/docs/database/signin_boost_record.md b/docs/database/signin_boost_record.md index 718a21c..ea71341 100644 --- a/docs/database/signin_boost_record.md +++ b/docs/database/signin_boost_record.md @@ -1,6 +1,6 @@ # signin_boost_record — 签到膨胀记录 -App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 金币一次。Day14 不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。 +App 用户当天签到后,看完激励视频可固定膨胀一次(默认 3000 金币,`rewards.SIGNIN_BOOST_COIN`,运营后台 `app_config.signin_boost_coin` 可改)。循环最后一天(`cycle_day == SIGNIN_CYCLE_LEN`,即 7 天循环的第 7 天)不展示也不允许膨胀。本表记录膨胀动作,并用唯一约束防重复补发。 ## 字段 @@ -9,14 +9,14 @@ App 用户 Day1-Day13 当天签到后,看完激励视频可固定膨胀 2000 | `id` | Integer | PK | 自增主键 | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `signin_date` | Date | NOT NULL | 被膨胀的签到日期,北京时间 | -| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 2000 | +| `coin_awarded` | Integer | NOT NULL | 本次补发金币,默认固定 3000(`rewards.get_signin_boost_coin`) | | `ad_ref_id` | String(64) | nullable | 穿山甲 S2S 回调 `trans_id` | -| `created_at` | DateTime | NOT NULL | 创建时间 | +| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间 | ## 约束 -- `UNIQUE(user_id, signin_date)`:同一用户同一天只能膨胀一次。 +- `UNIQUE(user_id, signin_date)` = `uq_signin_boost_user_date`:同一用户同一天只能膨胀一次。 ## 关联 -- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id=ad_ref_id`。 +- 膨胀成功时写 `coin_transaction.biz_type=signin_boost`,`ref_id = ad_ref_id`(无 ad_ref_id 时回退当天日期 ISO 串)。 diff --git a/docs/database/signin_record.md b/docs/database/signin_record.md index 786beab..0f89b21 100644 --- a/docs/database/signin_record.md +++ b/docs/database/signin_record.md @@ -1,13 +1,13 @@ -# signin_record — 签到记录(14 天循环) +# signin_record — 签到记录(7 天循环) > 模型 `app/models/signin.py` · 仓库 `app/repositories/signin.py` · 接口 [signin-status](../api/signin-status.md) / [signin-do](../api/signin-do.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..14)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,120,150,180,200,250,500,200,250,300,350,400,500,3000)`,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 15 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。 +App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天然防一天签两次。`cycle_day`(1..7)决定发多少金币(档位 `rewards.SIGNIN_REWARDS`,默认 `(200,200,300,200,400,400,800)`,一轮 7 天累计 2500 金币,运营后台 `app_config.signin_rewards` 可改),断签重置回 1;第 8 天回到第 1 天;`streak` 是连续天数(不封顶),给"已连续签到 N 天"展示。 ## 用在哪 / 增删改查 - **C(插入)**:`POST /signin/do`(`do_signin`)。今天未签且校验通过 → 算出 `cycle_day`/`streak`(昨天签过则在上次基础上 +1、否则重置 1)→ 写一行 + `grant_coins(biz_type='signin', ref_id=今天日期)` 加币,**同事务 commit**。今天已签再调 → 抛 `AlreadySignedError`(409),不重复写。 - **U / D**:无。每天一行,历史不改。 -- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、14 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。 +- **R**:`GET /signin/status`(签到页:今天签没签、连续天数、7 档状态/金币、能否签),内部 `get_status` 读最近一条 + 推算今天档位。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | @@ -15,7 +15,7 @@ App「每日签到」每签一次写一行,`(user_id, signin_date)` 唯一,天 | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | | `signin_date` | **Date** | NOT NULL | 签到日期(**北京时间** `cn_today()` 的 date)。其 ISO 串被 `coin_transaction.ref_id` 引用(biz_type=signin) | -| `cycle_day` | Integer | NOT NULL | 取值 `1..14`:14 天循环里今天第几档,决定发币额;断签重置回 1 | +| `cycle_day` | Integer | NOT NULL | 取值 `1..7`:7 天循环里今天第几档,决定发币额;断签重置回 1 | | `streak` | Integer | NOT NULL | 连续签到天数(≥1,不封顶);断签重置回 1 | | `coin_awarded` | Integer | NOT NULL | 本次实发金币(= 当时档位金额) | | `created_at` | DateTime(tz) | server_default now() | 时间 | diff --git a/docs/database/user.md b/docs/database/user.md index 04b5adb..ce33696 100644 --- a/docs/database/user.md +++ b/docs/database/user.md @@ -13,24 +13,27 @@ ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| -| `id` | Integer | PK, autoincrement | 用户主键;被 15 张表的 `user_id` 外键引用 | +| `id` | Integer | PK, autoincrement | 用户主键;被多张业务表的 `user_id` 外键引用 | | `phone` | String(20) | UNIQUE, index, NOT NULL | 手机号(登录主键)。注销后置 `deleted_` 占位释放唯一约束 | +| `username` | String(11) | UNIQUE, index, NOT NULL | 对外展示账号 ID:11 位纯数字、首位非 1(与手机号天然区分)、全局唯一、创建时随机生成、不可变、**不参与登录**(登录仍走 phone)。生成见 `repositories/user._gen_username` | | `register_channel` | String(20) | NOT NULL, default `jverify` | 取值:`jverify`(极光一键)/ `sms`(短信);`savings_record` 的对比人群另有种子用户用 `seed` | | `nickname` | String(64) | nullable | 通用昵称(`PATCH /user/profile` 写) | | `avatar_url` | String(512) | nullable | 通用头像相对 URL `/media/avatars/...`(`POST /user/avatar` 写) | +| `invite_code` | String(16) | UNIQUE, index, nullable | 邀请码:每用户一个稳定短码(懒生成,见 `repositories/invite.ensure_code`),分享链接/二维码里带它。UNIQUE 允许多个 NULL(未生成的用户) | | `wechat_openid` | String(64) | UNIQUE, index, nullable | 微信 openid(提现转账用);**一微信一账号**,多个 NULL 允许(多个未绑用户) | | `wechat_nickname` | String(64) | nullable | 微信昵称(绑定时拉,展示在提现绑定卡;与通用 nickname 分开存) | | `wechat_avatar_url` | String(512) | nullable | 微信头像 URL | | `status` | String(20) | NOT NULL, default `active` | 取值:`active`(可登录/鉴权)/ `disabled`(admin 禁用)/ `deleted`(已注销);仅 active 能通过鉴权 | +| `debug_trace_enabled` | Boolean | NOT NULL, default false, server_default false | 调试链接权限:开了的用户在比价完成弹窗 + 比价记录页能看到「复制调试链接」按钮(运营后台按用户配置);`/me` 与登录响应带出给前端做条件渲染 | | `created_at` | DateTime(tz) | server_default now() | 注册时间 | | `last_login_at` | DateTime(tz) | 应用层 default utcnow | 最近登录时间(每次登录更新) | ## 关系 / Join Key -- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`。 +- **被引用方(本表是 1,对方是 N/1)**:`coin_account`、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback` 的 `user_id` 均 → `user.id`;`invite_relation` 的 `inviter_user_id` / `invitee_user_id` 均 → `user.id`。 - 与 `admin_user` **无任何关联**(C 端用户 vs 后台管理员,两套体系)。 ## 索引与约束 -- PK `id`;UNIQUE+index `phone`;UNIQUE+index `wechat_openid`(允许多 NULL)。 +- PK `id`;UNIQUE+index `phone`、`username`、`invite_code`(允许多 NULL)、`wechat_openid`(允许多 NULL)。 ## 注意 - `nickname/avatar_url`(通用)与 `wechat_nickname/wechat_avatar_url`(微信)**分开存,不互相覆盖**。 diff --git a/docs/database/user_task.md b/docs/database/user_task.md index c2acc22..26eb190 100644 --- a/docs/database/user_task.md +++ b/docs/database/user_task.md @@ -2,10 +2,11 @@ > 模型 `app/models/task.py` · 仓库 `app/repositories/task.py` · 接口 [tasks-list](../api/tasks-list.md) / [tasks-claim](../api/tasks-claim.md) · [← 索引](./README.md) · [总览](./OVERVIEW.md) -像"打开消息提醒"这类**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 1000}`,运营后台 `app_config.task_rewards` 可改)。 +**只能领一次**的任务,领奖后写一行,`(user_id, task_key)` 唯一防重复领。可循环领取的任务(签到)不走这张表,有专表 `signin_record`。任务字典与奖励来自 `rewards.TASK_REWARDS`(默认 `{enable_notification: 750}`,运营后台 `app_config.task_rewards` 可改)。 +> ⚠️ 当前 `TASK_REWARDS` 里唯一的 `enable_notification`(打开消息提醒)已改为**可重复领取**任务(每次减半,见 `rewards.notification_reward`),它**不写本表**——领取次数靠数 `coin_transaction` 的正向流水条数(`biz_type='task_enable_notification'`),`ref_id` 带序号 `task_key:N`。因此本表目前实际**没有被任何已定义任务写入**;待新增真正的一次性任务后才会落行。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /tasks/claim`(`claim_task`)。`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。 +- **C(插入)**:`POST /tasks/claim`(`claim_task`),**仅一次性任务**走这条路:`task_key` 合法 + 未领过 → 写一行(`status='completed'`)+ `grant_coins(biz_type='task_', ref_id=task_key)` 加币,**同事务**。未知 key 抛 `UnknownTaskError`(404);已领抛 `AlreadyClaimedError`(409)。可重复任务(`enable_notification`)在 `claim_task` 里走另一分支:**不写本表**,只发币(`ref_id=task_key:N`),减半到底(`notification_max_claims`)后再领抛 `AlreadyClaimedError`。 - **U / D**:无。领过即终态。 - **R**:`GET /tasks/list`(任务页:列出所有已知任务 + 是否已领),`list_tasks` 把本表已领的 `task_key` 集合与任务字典对照。 @@ -14,7 +15,7 @@ |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(当前仅 `enable_notification`)。**被 `coin_transaction.ref_id` 引用**(biz_type=`task_`) | +| `task_key` | String(48) | NOT NULL | 任务标识,取值见 `rewards.TASK_REWARDS`(`enable_notification` 是可重复任务,不落本表)。一次性任务领取时其 `task_key` = 配套 `coin_transaction.ref_id`(biz_type=`task_`) | | `status` | String(16) | NOT NULL, default `completed` | 当前恒为 `completed`(领即完成) | | `coin_awarded` | Integer | NOT NULL, default 0 | 该任务发放金币(= `TASK_REWARDS[task_key]`) | | `completed_at` | DateTime(tz) | server_default now() | 领取时间 | diff --git a/docs/database/withdraw_order.md b/docs/database/withdraw_order.md index edb454c..9b34897 100644 --- a/docs/database/withdraw_order.md +++ b/docs/database/withdraw_order.md @@ -44,6 +44,7 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款) ## 索引与约束 - PK `id`;UNIQUE+index `out_bill_no`;index `user_id`、`created_at`。 +- 部分唯一索引 `ux_withdraw_order_user_active`(`user_id`),条件 `status IN ('reviewing', 'pending')`:每个用户同时只能有一笔在途(待审核 / 打款中)提现单,DB 层挡并发重复提现。 ## 注意 - **资金安全**:原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。 diff --git a/docs/数据库迁移.md b/docs/database/数据库迁移.md similarity index 98% rename from docs/数据库迁移.md rename to docs/database/数据库迁移.md index 599f313..9a9c6ce 100644 --- a/docs/数据库迁移.md +++ b/docs/database/数据库迁移.md @@ -6,7 +6,7 @@ > **相关文档**: > - 这份文档讲 **alembic 操作**(如何建表/升级/新增迁移/多 head 排查) > - [postgres-migration.md](./postgres-migration.md) 讲 **从 SQLite 切到 PostgreSQL** 的完整步骤(本机安装/建库/`scripts/init_postgres.py` 用法) -> - 配合 [后端技术实现.md §10](./后端技术实现.md) 看待办与已知问题 +> - 配合 [后端技术实现.md §10](../后端技术实现.md) 看待办与已知问题 --- diff --git a/docs/待办与技术债.md b/docs/guides/待办与技术债.md similarity index 97% rename from docs/待办与技术债.md rename to docs/guides/待办与技术债.md index 14a3a7f..76a38d8 100644 --- a/docs/待办与技术债.md +++ b/docs/guides/待办与技术债.md @@ -40,7 +40,7 @@ ## 技术债(暂不处理,知情即可) - **agent 接口公网裸奔**:不鉴权 = 任何人可调,烧 pricebot 的 LLM 算力。与美团 3 接口现状同级,记账后随 P1 一起补。 -- **既有债(已记录在别处)**:美团 `referral-link` 的 `sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token;`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](./后端技术实现.md) 与 [api/](./api/)。 +- **既有债(已记录在别处)**:美团 `referral-link` 的 `sid` 客户端可覆盖刷分佣;`logout` 占位不吊销 token;`SMS_MOCK=true` 全站免密登录;`JWT_SECRET_KEY` 默认值可伪造 token。详见 [后端技术实现.md](../后端技术实现.md) 与 [api/](./api/)。 --- @@ -87,7 +87,7 @@ ## 已解决 -- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](./integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。 +- ✅ **短信验证码接入极光(real 模式,2026-06-03)**:`/sms/send`+`/sms/login` 从 mock 升级为真实可用。`integrations/sms.py` 走**自定义验证码模式**(本服务 `secrets` 生成 6 位码 + 进程内存存储 + 极光 `/v1/messages` 只负责发 + 本地校验一次性/防爆破);鉴权**复用极光一键登录的 `JG_APP_KEY`/`JG_MASTER_SECRET`**(同一极光应用,**上线只需 `SMS_MOCK=false`**,无需额外凭证;`sign_id=31729`/`temp_id=1` 已审核就绪)。防刷四层(单号冷却 + 单号每日上限 + 单 IP `rate_limit` + 单码失败次数上限);`SmsError.status_code` 让发送失败按类型返回 429/503/400。**mock 保留"任意6位通过"**(不动其他 11 个测试文件的 `_login` helper);`tests/test_auth.py` 加 real 模式单测(极光协议 / 一次性 / 防爆破 / 余额回滚)。文档 [integrations/sms.md](../integrations/sms.md)。**遗留**:验证码进程内存,多 worker 需迁 DB/Redis(下方技术债)。 - ✅ **比价记录落库(server + client,2026-05-31)**:每次比价 done 后客户端上报、按 user 落库,作「我的比价记录」数据源 + 用户级画像沉淀。 - **server**(本仓):新表 `comparison_record`(独立于 `savings_record`;结构化列 + `items`/`comparison_results`/`skipped_dish_names`/`raw_payload` 四个 JSON(B) 列)+ 3 个**鉴权**端点 `POST /api/v1/compare/record`(`(user_id,trace_id)` 幂等 upsert,best/saved/is_source_best/status 服务端从 comparison_results 派生)/ `GET /api/v1/compare/records`(游标分页)/ `GET /records/{id}`(含 raw_payload);`models/comparison.py` + `repositories/comparison.py` + `schemas/compare_record.py` + `api/v1/compare_record.py` + 迁移 `comparison_record_table`(head `b2c3d4e5f6a7`)+ `tests/test_compare_record.py`(8 例全过)+ `docs/api/compare-record-*.md`。 - **client**(android 仓):`PriceBotService.runTask()` 比价 done 后(`lastDoneParams!=null`,成功/引擎失败都报)用独立 IO 协程尽力上报;`Protocol.CompareRecordRequest.fromComparison()` 从 calibration+done.params 零翻译组装;`ApiClient.reportCompareRecord()` 走新建的 `authedClient`(复用 `AuthInterceptor`+`RefreshAuthenticator`,自动 Bearer+401 刷新)。领券不报(非价格对比,本期范围只外卖)。 @@ -99,7 +99,7 @@ - ✅ **无障碍身份统一傻瓜比价**:服务名/图标继承 app;`accessibility_service_description`/前台通知/`GuideCopy`/引导 Activity 里的 "PriceBot" 全改「傻瓜比价」;app 图标换成完整 `logo1`(adaptive 居中+黄边,不裁切)。 - ✅ **GuideOverlayService 组件名 bug**:批量 sed 改包名误把无障碍服务 ComponentName 的 packageName 改成 `…shaguabijia.agent`(应是 applicationId `…shaguabijia`)→ 无障碍开启检测永远 false;改用 `PermissionHelper.isAccessibilityEnabled` 动态构造。 - ✅ **BuildConfig 包名**:`com.pricebot.app.BuildConfig` import 改本 app 包名。 -- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](./postgres-migration.md)。 +- ✅ **SQLite → PostgreSQL 切换**(2026-05-29,PR #6 + alembic 合并迁移):新增 `scripts/init_postgres.py` 一键脚本、`psycopg[binary]>=3.1` 依赖、`savings_record.dishes` 列改 JSONB、连接池参数(`pool_size=10/overflow=20/recycle=3600`)。SQLite 仍可作开发兜底(只改 `DATABASE_URL`)。详见 [postgres-migration.md](../database/postgres-migration.md)。 - ✅ **外卖比价透传 2 端点**(2026-05-27,`feat(compare)`):`/api/v1/intent/recognize` + `/api/v1/price/step` 在 `compare.py`,纯 body 透传到 pricebot-backend,`PRICEBOT_COMPARE_TIMEOUT_SEC=60` 独立超时。电商 2 个待接(`compare.py` 加两行即可)。 - ✅ **用户资料 + 帮助反馈接口**(2026-05-28,PR #4):`PATCH /api/v1/user/profile` 改昵称、`POST /api/v1/user/avatar` 上传头像(魔数嗅探)、`DELETE /api/v1/user` 注销账号(软删+匿名化)、`POST /api/v1/feedback` 提交反馈(含截图);新建 `feedback` 表 + `core/media.py` + `/media` 静态服务。 - ✅ **美团 3 端点未配凭证降级**(2026-05-28,`feat(meituan)`):未配 `MT_CPS_APP_KEY` 时 `coupons`/`feed`/`referral-link` 直接返空(不再 502),解决新开发机首屏炸的问题。**坏副作用**:`/feed` 跟"已配但调用失败"路径在响应上无法区分(都是 200 + 空 items),靠 `/health` 的 `mt_cps_configured` 字段区分。 diff --git a/docs/看广告赚金币上线清单.md b/docs/guides/看广告赚金币上线清单.md similarity index 99% rename from docs/看广告赚金币上线清单.md rename to docs/guides/看广告赚金币上线清单.md index 42d9448..2ef65a4 100644 --- a/docs/看广告赚金币上线清单.md +++ b/docs/guides/看广告赚金币上线清单.md @@ -9,7 +9,7 @@ 客户端**不发奖**,`onRewardArrived` 只触发"去后端刷余额"。客户端被破解也刷不到钱。 - 后端发奖**幂等**(按 `trans_id` 去重)+ **每日上限**(`DAILY_AD_REWARD_LIMIT`,按北京时间)。 - 关键常量:`app/core/rewards.py` → `AD_REWARD_COIN=100`、`DAILY_AD_REWARD_LIMIT=500`、`VIDEO_ROUND_REQUIRED_COUNT=1`、`VIDEO_ROUND_COOLDOWN_SECONDS=3`(每次广告关闭后 3 秒短冷却)。旧的观看时长闸 `DAILY_AD_WATCH_SECONDS_LIMIT=0` 表示停用。 -- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](./api/ad-reward-status.md)。 +- **4 态 CTA**(2026-05-29 PR #5):客户端任务行按钮在 Normal / Loading / Capped / CoolingDown 之间切,**全由后端 `reward-status` 的 `round_count` + `cooldown_until` 派生**(权威源),跨设备/重装/杀进程一致。详见 [api/ad-reward-status](../api/ad-reward-status.md)。 --- diff --git a/docs/邀请功能-实现原理与本地测试.md b/docs/guides/邀请功能-实现原理与本地测试.md similarity index 100% rename from docs/邀请功能-实现原理与本地测试.md rename to docs/guides/邀请功能-实现原理与本地测试.md diff --git a/docs/integrations/meituan.md b/docs/integrations/meituan.md index e3259bf..ed4a05b 100644 --- a/docs/integrations/meituan.md +++ b/docs/integrations/meituan.md @@ -47,4 +47,4 @@ api 层 3 个端点都在入口处 `if not settings.mt_cps_configured:` 早返 ## 备注 - 美团接口当前**无鉴权**,且换链的 `sid` 允许客户端传值覆盖默认渠道——见对应 api 文档「备注」 -- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../待办与技术债.md) 已记账 +- 未配凭证降级、feed 静默吞错都在 [待办与技术债.md](../guides/待办与技术债.md) 已记账 diff --git a/docs/integrations/sms.md b/docs/integrations/sms.md index 6e0b15b..7086643 100644 --- a/docs/integrations/sms.md +++ b/docs/integrations/sms.md @@ -56,4 +56,4 @@ 3. 真机发一条验证:收到短信 + 能登录 ## 已知局限 -**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../待办与技术债.md)。 +**验证码存进程内存**:单 worker uvicorn 够用;重启丢码(用户重发即可);**多 worker / 多机不共享 → 冷却 / 每日上限 / 校验失效**,扩 worker 前迁移到 DB/Redis。见 [待办与技术债](../guides/待办与技术债.md)。 diff --git a/docs/后端技术实现.md b/docs/后端技术实现.md index 9d6eeff..8828ed7 100644 --- a/docs/后端技术实现.md +++ b/docs/后端技术实现.md @@ -36,7 +36,7 @@ | ASGI | uvicorn[standard] | 生产 `--host 127.0.0.1 --port 8770` | | ORM | SQLAlchemy 2.0(Mapped 风格) | | | 迁移 | Alembic | | -| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/postgres-migration.md](./postgres-migration.md) | +| DB | **PostgreSQL 16(生产)/ SQLite(开发兜底)** | 一键脚本 `scripts/init_postgres.py` 建库 + 写 `.env` + 跑迁移;开发环境改 `DATABASE_URL=sqlite:///./data/app.db` 即回退。无 Redis。详见 [docs/database/postgres-migration.md](./database/postgres-migration.md) | | DB 驱动 | psycopg3(`psycopg[binary]>=3.1`) | `DATABASE_URL=postgresql+psycopg://...`,**不要装 psycopg2**——SQLAlchemy 2.0 时代默认 psycopg3 | | Auth | PyJWT(HS256) | access + refresh | | 极光 | httpx + cryptography | REST 验 token + RSA 解密手机号 | @@ -125,8 +125,8 @@ tests/ # pytest(auth / health / welfare / withdraw / ad_re run.sh # 本地启动脚本(自动先跑迁移再起服务) docs/api/ # API 接口文档(索引 README + 一接口一文件) docs/integrations/ # 集成层实现文档(SDK 签名/加解密/协议细节) -docs/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移) -docs/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py) +docs/database/数据库迁移.md # Alembic 迁移指南(如何建表/升级/新增迁移) +docs/database/postgres-migration.md # SQLite → PostgreSQL 切换指南(配套 scripts/init_postgres.py) ``` > **命名说明**:`api/v1/` 的 `v1` 用于 URL 版本化(移动端无法强制即时升级,需新旧版本并存能力);`integrations` 装外部 SDK 集成、`repositories` 装数据访问、`core` 装基础设施,三者分离。**数据访问层统一在 `repositories/`**(早期叫 `crud/`,2026-05 已整体并入,`crud/` 不再存在)。`coupon.py` 是领券透传,勿与 `meituan.py` 里的 `coupons`(券列表)混淆。 @@ -255,7 +255,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert `upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。 -**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./数据库迁移.md)。 +**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./database/数据库迁移.md)。 --- @@ -277,7 +277,7 @@ scp secrets/jverify_rsa_private.pem server:/opt/shaguabijia-app-server/secrets/ ssh server "cd /opt/shaguabijia-app-server && .venv/bin/alembic upgrade head && systemctl restart shaguabijia-app-server" ``` -完整 PG 切换流程见 [docs/postgres-migration.md](./postgres-migration.md)。 +完整 PG 切换流程见 [docs/database/postgres-migration.md](./database/postgres-migration.md)。 **生产 checklist(均为上线必查)**: @@ -316,11 +316,11 @@ conda activate price # 首次:pip install -e . | 美团接口无鉴权 + sid 可覆盖 | 评估加鉴权/锁定 sid(注意首页要求未登录可见) | | 美团接口未配凭证降级 | 未配 `MT_CPS_APP_KEY` 时 3 端点返空(不报 502),`/feed` 跟"已配但调用失败"路径无法区分——见 [integrations/meituan](./integrations/meituan.md) | | 领券/比价依赖 pricebot | `coupon/step` / `intent/recognize` / `price/step` 仅透传,真正逻辑在 pricebot-backend;前端已接通领券链路,比价 food MVP 也已接通 | -| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./待办与技术债.md) P1 | +| agent 系列接口 MVP 不鉴权 | 拿不到 user_id → 无法采集"哪个用户领了/买了什么"用户级画像(商业模式核心资产)。见 [待办与技术债.md](./guides/待办与技术债.md) P1 | | SMS 已接极光(2026-06-03) | real 模式自定义验证码,上线只需 `SMS_MOCK=false`(复用极光凭证)。详见 [integrations/sms](./integrations/sms.md) | | 短信冷却存内存 | 扩 worker 前需迁移到 Redis | | `MEDIA_ROOT` 进程内 serve | 头像/反馈截图当前用 FastAPI StaticFiles,生产建议 nginx 直 serve 该目录 | -| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./待办与技术债.md) | +| `init_postgres.py` 已知小 bug | 5 条小坑,见 [待办与技术债.md](./guides/待办与技术债.md) | | Alembic 多 head 风险 | 跨分支并行开发要在 PR 合并前 `alembic heads` 检查只有一个;漏检会出现 "Multiple head revisions",需要 `alembic merge` 合并 | -> 完整接口协议见 [`docs/api/`](./api/)(38 个端点 + `/health` + `/media` 静态服务),以代码为准。 +> 完整接口协议见 [`docs/api/`](./api/)(70+ 接口,一接口一文件 + `/health` + `/media` 静态服务),以 `docs/api/README.md` 索引和代码为准。