diff --git a/.env.example b/.env.example
index 1f53db8..367226a 100644
--- a/.env.example
+++ b/.env.example
@@ -11,7 +11,7 @@ DATABASE_URL=sqlite:///./data/app.db
# ===== JWT =====
# 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))"
-JWT_SECRET_KEY=
+JWT_SECRET_KEY=change-me-in-prod-please-use-a-long-random-string
JWT_ALGORITHM=HS256
# access token 有效期(分钟),默认 2 小时
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=120
@@ -27,6 +27,18 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
JG_REQUEST_TIMEOUT_SEC=15
+# ===== 极光推送 JPush(无障碍保护掉线告警)=====
+# 客户端 push appkey 已知 = 966b451a8d9cfe12d173ea9d。若推送与一键登录/短信是同一个极光应用,
+# JPUSH_* 留空即自动回退到上面的 JG_APP_KEY/JG_MASTER_SECRET;否则单独填那个 push 应用的密钥。
+# 运维清单(厂商通道等)见 spec/accessibility-liveness-push.md §7。
+JPUSH_APP_KEY=
+JPUSH_MASTER_SECRET=
+JPUSH_PUSH_ENDPOINT=https://api.jpush.cn/v3/push
+# 无障碍保护存活监控后台任务
+HEARTBEAT_MONITOR_ENABLED=true
+HEARTBEAT_TIMEOUT_MINUTES=10
+HEARTBEAT_SCAN_INTERVAL_SEC=60
+
# ===== 短信 (mock 模式) =====
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
diff --git a/alembic/versions/3a8fa058789c_merge_cps_wx_user_与_device_kill_alert_.py b/alembic/versions/3a8fa058789c_merge_cps_wx_user_与_device_kill_alert_.py
new file mode 100644
index 0000000..7d25e70
--- /dev/null
+++ b/alembic/versions/3a8fa058789c_merge_cps_wx_user_与_device_kill_alert_.py
@@ -0,0 +1,26 @@
+"""merge cps_wx_user 与 device_kill_alert_pending 双 head
+
+Revision ID: 3a8fa058789c
+Revises: cps_wx_user, device_kill_alert_pending
+Create Date: 2026-06-20 10:27:01.193201
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '3a8fa058789c'
+down_revision: Union[str, Sequence[str], None] = ('cps_wx_user', 'device_kill_alert_pending')
+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/cps_wx_user.py b/alembic/versions/cps_wx_user.py
new file mode 100644
index 0000000..0e0db37
--- /dev/null
+++ b/alembic/versions/cps_wx_user.py
@@ -0,0 +1,48 @@
+"""cps 微信用户表 cps_wx_user + cps_click 加 openid
+
+CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。
+
+Revision ID: cps_wx_user
+Revises: comparison_debug_fields
+Create Date: 2026-06-19 12:00:00.000000
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "cps_wx_user"
+down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "cps_wx_user",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("openid", sa.String(64), nullable=False),
+ sa.Column("unionid", sa.String(64), nullable=True),
+ sa.Column("nickname", sa.String(128), nullable=True),
+ sa.Column("headimgurl", sa.String(512), nullable=True),
+ sa.Column("first_code", sa.String(16), nullable=True),
+ sa.Column("first_group_id", sa.Integer(), nullable=True),
+ sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ )
+ op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True)
+ op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"])
+ op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"])
+
+ op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True))
+ op.create_index("ix_cps_click_openid", "cps_click", ["openid"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_cps_click_openid", table_name="cps_click")
+ op.drop_column("cps_click", "openid")
+ op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user")
+ op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user")
+ op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user")
+ op.drop_table("cps_wx_user")
diff --git a/alembic/versions/device_kill_alert_pending.py b/alembic/versions/device_kill_alert_pending.py
new file mode 100644
index 0000000..f4bc6fc
--- /dev/null
+++ b/alembic/versions/device_kill_alert_pending.py
@@ -0,0 +1,39 @@
+"""device.kill_alert_pending (无障碍掉线「后置检测」待提醒标记)
+
+Revision ID: device_kill_alert_pending
+Revises: merge_cps_device_liveness
+Create Date: 2026-06-19
+
+后置检测 pull 版(spec/accessibility-liveness-pull-prompt.md):worker 检出心跳掉线即置此标记,
+客户端下次进 App 拉取到 → 弹「开启自启动」引导,交互后 ack 清回。与 liveness_state 解耦,故单列。
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'device_kill_alert_pending'
+down_revision: Union[str, Sequence[str], None] = 'merge_cps_device_liveness'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # server_default=sa.false() 跨方言: SQLite 渲染 0 / PG 渲染 false(别用 sa.text("0"),PG 布尔严格会报错),
+ # 给存量行回填 False。
+ with op.batch_alter_table('device', schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column(
+ 'kill_alert_pending',
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ )
+ )
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('device', schema=None) as batch_op:
+ batch_op.drop_column('kill_alert_pending')
diff --git a/alembic/versions/device_table.py b/alembic/versions/device_table.py
new file mode 100644
index 0000000..795864c
--- /dev/null
+++ b/alembic/versions/device_table.py
@@ -0,0 +1,53 @@
+"""device table (无障碍保护存活检测 + 极光推送)
+
+Revision ID: device_liveness_table
+Revises: store_mapping_tb_dl_invalid
+Create Date: 2026-06-15 12:00:00.000000
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'device_liveness_table'
+down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ 'device',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('device_id', sa.String(length=128), nullable=False),
+ sa.Column('registration_id', sa.String(length=64), nullable=True),
+ sa.Column('platform', sa.String(length=16), nullable=False),
+ sa.Column('app_version', sa.String(length=32), nullable=True),
+ sa.Column('ever_protected', sa.Boolean(), nullable=False),
+ sa.Column('last_heartbeat_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('last_report_protection_on', sa.Boolean(), nullable=False),
+ sa.Column('liveness_state', sa.String(length=16), nullable=False),
+ sa.Column('notified_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('user_id', 'device_id', name='uq_device_user_device'),
+ )
+ with op.batch_alter_table('device', schema=None) as batch_op:
+ batch_op.create_index(batch_op.f('ix_device_user_id'), ['user_id'], unique=False)
+ batch_op.create_index(batch_op.f('ix_device_device_id'), ['device_id'], unique=False)
+ batch_op.create_index(batch_op.f('ix_device_last_heartbeat_at'), ['last_heartbeat_at'], unique=False)
+
+
+def downgrade() -> None:
+ with op.batch_alter_table('device', schema=None) as batch_op:
+ batch_op.drop_index(batch_op.f('ix_device_last_heartbeat_at'))
+ batch_op.drop_index(batch_op.f('ix_device_device_id'))
+ batch_op.drop_index(batch_op.f('ix_device_user_id'))
+
+ op.drop_table('device')
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/alembic/versions/merge_cps_link_and_device_liveness_heads.py b/alembic/versions/merge_cps_link_and_device_liveness_heads.py
new file mode 100644
index 0000000..1346b50
--- /dev/null
+++ b/alembic/versions/merge_cps_link_and_device_liveness_heads.py
@@ -0,0 +1,31 @@
+"""merge cps_link_tables and device_liveness_table heads
+
+Revision ID: merge_cps_device_liveness
+Revises: cps_link_tables, device_liveness_table
+Create Date: 2026-06-17
+
+并行分支双 head 合并(团队常态:不同 feature 分支各自基于同一父 revision 加迁移,
+集成时就出现多 head,`alembic upgrade head` 报 Multiple head revisions):
+- cps_link_tables —— main 的 CPS 链路表
+- device_liveness_table —— feat/a11y-liveness-heartbeat 的设备存活心跳表
+两条链彼此独立、无 schema 冲突,这里合并成单 head。纯合并节点,不改表结构。
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'merge_cps_device_liveness'
+down_revision: Union[str, Sequence[str], None] = ('cps_link_tables', 'device_liveness_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/app/admin/main.py b/app/admin/main.py
index b718441..eb46a7c 100644
--- a/app/admin/main.py
+++ b/app/admin/main.py
@@ -14,6 +14,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.ad_audit import router as ad_audit_router
+from app.admin.routers.ad_config import router as ad_config_router
from app.admin.routers.ad_revenue import router as ad_revenue_router
from app.admin.routers.admins import router as admins_router
from app.admin.routers.audit import router as audit_router
@@ -95,4 +96,5 @@ admin_app.include_router(config_router)
admin_app.include_router(comparison_router)
admin_app.include_router(cps_router)
admin_app.include_router(ad_audit_router)
+admin_app.include_router(ad_config_router)
admin_app.include_router(ad_revenue_router)
diff --git a/app/admin/repositories/cps.py b/app/admin/repositories/cps.py
index 3271a74..4b1c7c3 100644
--- a/app/admin/repositories/cps.py
+++ b/app/admin/repositories/cps.py
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from uuid import uuid4
-from sqlalchemy import desc, select
+from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
@@ -19,6 +19,7 @@ from app.models.cps_activity import CpsActivity
from app.models.cps_group import CpsGroup
from app.models.cps_link import CpsClick
from app.models.cps_order import CpsOrder
+from app.models.cps_wx_user import CpsWxUser
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
_INVALID_STATUS = {"4", "5"}
@@ -470,3 +471,47 @@ def group_order_daily(
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
}
return out
+
+
+def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]:
+ """群内微信用户(首次来源 first_group_id=group_id)+ 每人在该群的 visit/copy 次数(领券画像)。
+
+ 按首次进入倒序。copy 次数 = 该用户在该群点了几次「复制口令」(领券意向),visit = 进落地页次数。
+ """
+ users = (
+ db.execute(
+ select(CpsWxUser)
+ .where(CpsWxUser.first_group_id == group_id)
+ .order_by(desc(CpsWxUser.first_seen))
+ .limit(limit)
+ )
+ .scalars()
+ .all()
+ )
+ if not users:
+ return []
+ openids = [u.openid for u in users]
+ stat: dict[str, dict] = {}
+ rows = db.execute(
+ select(CpsClick.openid, CpsClick.event_type, func.count())
+ .where(CpsClick.group_id == group_id)
+ .where(CpsClick.openid.in_(openids))
+ .group_by(CpsClick.openid, CpsClick.event_type)
+ ).all()
+ for openid, event_type, cnt in rows:
+ s = stat.setdefault(openid, {"visit": 0, "copy": 0})
+ if event_type == "copy":
+ s["copy"] = cnt
+ else:
+ s["visit"] = cnt
+ return [
+ {
+ "openid": u.openid,
+ "nickname": u.nickname,
+ "headimgurl": u.headimgurl,
+ "first_seen": u.first_seen,
+ "visit_count": stat.get(u.openid, {}).get("visit", 0),
+ "copy_count": stat.get(u.openid, {}).get("copy", 0),
+ }
+ for u in users
+ ]
diff --git a/app/admin/routers/ad_config.py b/app/admin/routers/ad_config.py
new file mode 100644
index 0000000..c46068c
--- /dev/null
+++ b/app/admin/routers/ad_config.py
@@ -0,0 +1,45 @@
+"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
+
+存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。
+客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。
+"""
+from __future__ import annotations
+
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, Request
+
+from app.admin.audit import write_audit
+from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
+from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate
+from app.models.admin import AdminUser
+from app.repositories import app_config
+
+router = APIRouter(
+ prefix="/admin/api/ad-config",
+ tags=["admin-ad-config"],
+ dependencies=[Depends(get_current_admin)],
+)
+
+
+@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)")
+def get_ad_config(db: AdminDb) -> AdConfigOut:
+ return AdConfigOut(**app_config.get_ad_config(db))
+
+
+@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)")
+def update_ad_config(
+ body: AdConfigUpdate,
+ request: Request,
+ admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
+ db: AdminDb,
+) -> AdConfigOut:
+ data = body.model_dump(exclude_none=True)
+ app_config.set_ad_config(db, data, admin_id=admin.id, commit=False)
+ # 审计只记改了哪些字段,不记 mkey 明文(机密)
+ write_audit(
+ db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config",
+ detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False,
+ )
+ db.commit()
+ return AdConfigOut(**app_config.get_ad_config(db))
diff --git a/app/admin/routers/cps.py b/app/admin/routers/cps.py
index 6f892dd..a1fd267 100644
--- a/app/admin/routers/cps.py
+++ b/app/admin/routers/cps.py
@@ -488,3 +488,11 @@ def group_daily(
"days": days,
"rows": rows,
}
+
+
+@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)")
+def group_wx_users(group_id: int, db: AdminDb) -> dict:
+ group = cps_repo.get_group(db, group_id)
+ if group is None:
+ raise HTTPException(status_code=404, detail="群不存在")
+ return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
diff --git a/app/admin/schemas/ad_config.py b/app/admin/schemas/ad_config.py
new file mode 100644
index 0000000..3f5c033
--- /dev/null
+++ b/app/admin/schemas/ad_config.py
@@ -0,0 +1,30 @@
+"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。"""
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+
+class AdConfigOut(BaseModel):
+ """admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。"""
+
+ app_id: str
+ reward_code_id: str
+ compare_feed_code_id: str
+ coupon_feed_code_id: str
+ reward_mkey: str
+ reward_enabled: bool
+ compare_ad_enabled: bool
+ coupon_ad_enabled: bool
+
+
+class AdConfigUpdate(BaseModel):
+ """部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。"""
+
+ app_id: str | None = None
+ reward_code_id: str | None = None
+ compare_feed_code_id: str | None = None
+ coupon_feed_code_id: str | None = None
+ reward_mkey: str | None = None
+ reward_enabled: bool | None = None
+ compare_ad_enabled: bool | None = None
+ coupon_ad_enabled: bool | None = None
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/api/v1/ad.py b/app/api/v1/ad.py
index bb05891..cf866b2 100644
--- a/app/api/v1/ad.py
+++ b/app/api/v1/ad.py
@@ -24,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
from app.repositories import ad_watch as crud_watch
+from app.repositories import app_config
from app.repositories import signin as crud_signin
from app.schemas.ad import (
AdRewardStatusOut,
@@ -80,14 +81,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
granted / capped → is_verify=true + reason=0。
"""
- if not settings.pangle_callback_configured:
+ if not settings.PANGLE_CALLBACK_ENABLED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
params = dict(request.query_params)
- if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets):
+ # 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
+ # 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
+ ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
+ secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
+ if not secrets:
+ raise HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
+ )
+ if not pangle.verify_callback_sign_any(params, secrets):
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
diff --git a/app/api/v1/cps_redirect.py b/app/api/v1/cps_redirect.py
index 49a061d..ee7aa0b 100644
--- a/app/api/v1/cps_redirect.py
+++ b/app/api/v1/cps_redirect.py
@@ -8,16 +8,22 @@ from __future__ import annotations
import html
import json
+import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.core import media
+from app.core.config import settings
from app.db.session import get_db
+from app.integrations import wx_oauth
from app.models.cps_activity import CpsActivity
from app.models.cps_link import CpsLink
from app.repositories import cps_link as cps_link_repo
+from app.repositories import cps_wx_user as cps_wx_user_repo
+
+logger = logging.getLogger("shagua.cps_redirect")
router = APIRouter(tags=["cps-redirect"])
@@ -27,6 +33,16 @@ _FALLBACK_URL = "https://www.meituan.com/"
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
+# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权)
+_WX_OPENID_COOKIE = "wx_openid"
+_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权
+_COOKIE_MAX_AGE = 30 * 86400
+
+
+def _is_wechat(request: Request) -> bool:
+ """是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。"""
+ return "micromessenger" in (request.headers.get("user-agent", "").lower())
+
def _client_ip(request: Request) -> str | None:
xff = request.headers.get("x-forwarded-for")
@@ -35,11 +51,13 @@ def _client_ip(request: Request) -> str | None:
return request.client.host if request.client else None
-def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> None:
+def _record(
+ db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None
+) -> None:
try:
cps_link_repo.record_click(
db, link=link, ip=_client_ip(request),
- ua=request.headers.get("user-agent"), event_type=event_type,
+ ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
)
except Exception:
pass # 记点击失败不阻断
@@ -53,38 +71,89 @@ def wx_mp_domain_verify() -> PlainTextResponse:
return PlainTextResponse("F7wnRQ7xPbhVOWC8")
-@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)")
+@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)")
def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
link = cps_link_repo.get_by_code(db, code)
if link is None:
return RedirectResponse(_FALLBACK_URL, status_code=302)
- _record(db, link, request, "visit")
+ openid = request.cookies.get(_WX_OPENID_COOKIE)
+ # 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。
+ # 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。
+ if openid is None and settings.wx_oauth_active and _is_wechat(request):
+ redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
+ auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}")
+ return RedirectResponse(auth_url, status_code=302)
+ _record(db, link, request, "visit", openid=openid)
if link.platform == "taobao":
activity = db.get(CpsActivity, link.activity_id)
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
- return HTMLResponse(_taobao_landing_html(link.target_url, image_url))
+ has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1"
+ return HTMLResponse(
+ _taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
+ )
# 美团短链 / 京东链接:直接 302 跳
return RedirectResponse(link.target_url, status_code=302)
-@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)")
+@router.get("/wx/oauth/cb", include_in_schema=False)
+def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
+ """微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
+ 换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页,
+ 绝不阻断用户领券。"""
+ kind, _, orig_code = state.partition(":")
+ try:
+ token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
+ openid = token["openid"]
+ link = cps_link_repo.get_by_code(db, orig_code)
+ group_id = link.group_id if link else None
+ nickname = headimgurl = unionid = None
+ if kind == "uinfo" and "userinfo" in (token.get("scope") or ""):
+ info = wx_oauth.get_userinfo(token["access_token"], openid)
+ nickname, headimgurl, unionid = (
+ info.get("nickname"), info.get("headimgurl"), info.get("unionid"),
+ )
+ cps_wx_user_repo.upsert(
+ db, openid=openid, code=orig_code, group_id=group_id,
+ nickname=nickname, headimgurl=headimgurl, unionid=unionid,
+ )
+ except Exception:
+ logger.exception("[wx_oauth] callback failed state=%s", state)
+ return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302)
+ # userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步)
+ target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "")
+ resp = RedirectResponse(target, status_code=302)
+ resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax")
+ if nickname:
+ resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax")
+ return resp
+
+
+@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)")
def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict:
link = cps_link_repo.get_by_code(db, code)
if link is not None:
- _record(db, link, request, "copy")
+ _record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE))
return {"ok": True}
-def _taobao_landing_html(token: str, image_url: str) -> str:
+def _taobao_landing_html(
+ token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
+) -> str:
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
- image_url 转绝对(落地页 coupon 域,相对也可达,绝对更稳)后经 html.escape 嵌入
- (防属性注入);token 经 json.dumps 安全嵌入 JS。
+ image_url 经 html.escape 嵌入
(防注入);token 经 json.dumps 安全嵌入 JS。
+ uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 —— 用户点「领券」
+ 时先跳它(交互触发,避免微信快照页),授权回来自动复制。已拿过 / 无 openid 则为空,直接复制。
"""
safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True)
+ uinfo_url = ""
+ if openid and not has_uinfo and settings.wx_oauth_active:
+ redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
+ uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
return (
_TAOBAO_HTML
.replace("__IMAGE_URL__", safe_img)
+ .replace("__UINFO_URL__", json.dumps(uinfo_url))
.replace("__TOKEN_JS__", json.dumps(token))
)
@@ -112,6 +181,7 @@ body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color