Compare commits

...

10 Commits

Author SHA1 Message Date
chenshuobo 52c57e19fc fix(alembic): merge 双 head(meituan_coupon_image_meta + 4dc2af7ebe74)
#71 的 meituan_coupon_image_meta 与并行合入的 4dc2af7ebe74(device_65/comparison_token 合并)
形成两个 alembic head,`alembic upgrade head` 报 "Multiple head revisions"。加一个空 merge 迁移
把两 head 合一,恢复 upgrade head 单 head 可用(线上已临时用 upgrade heads 应用两条)。
2026-06-23 21:33:56 +08:00
chenshuobo 4a9d3f1d1a 调整首页滚动条用户名称打码规则 (#68)
改了什么:将 savings-feed 用户名脱敏改为 PRD 规则;有昵称按长度打码,无昵称真实用户显示 用户*****后两位;种子和兜底继续合成多样化脱敏名。
为什么改:避免旧规则出现 用户********xxx,与产品要求的用户标识打码格式保持一致。
验证方式:本地请求 /api/v1/platform/savings-feed?limit=8,确认返回 用户*****xx、首字+**、首字+***+末 等新格式。

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #68
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-23 21:09:23 +08:00
chenshuobo b07ccb4bf5 feat(meituan-coupon): 采集记录头图大小/类型 + 读取侧缩放口子 + 回填脚本 (#71)
Reviewed-on: #71
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-23 21:09:10 +08:00
marco 2164155a23 docs(cps): 加 CPS 发券分发 + 微信授权 交接文档
设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计的
完整说明,含数据流/数据表/平台差异/端点清单/配置项/代码地图/部署/排障/已知技术债。
挂入 docs/README 的 guides 索引。供他人接手。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:02:08 +08:00
marco e81fd0a176 fix(alembic): 合并多 head(#65 device 迁移 + comparison_token_cols)
#65 的 device 迁移链与 comparison_token_cols 都从 ceb286289426 分叉成双 head,alembic upgrade head 报 Multiple head revisions;加 merge 迁移 4dc2af7ebe74 合并(空 DDL 仅接链)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:58:30 +08:00
chenshirui a6f55a00b8 feat(device): 无障碍保护存活心跳检测 + 掉线后置检测(pull) (#65)
Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #65
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-06-23 17:33:38 +08:00
marco a584e1f59f feat(comparison): 比价记录累计 LLM token(入/出)并在 admin 展示
server 从 llm_calls[].usage 累加 prompt/completion_tokens 落库,admin 比价记录列表/详情据此展示 token 数与估算成本;配套迁移 comparison_token_cols。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:24:54 +08:00
marco e13f0f7658 fix(cps): 群内微信用户按"本群点击"算,不用 first_group_id
first_group_id 只记首次授权群;同一用户(cookie 已存)再去别的群不再走授权回调,
会被漏算(实测:用户首次在群47授权,再去群52点击,群52详情显示0)。
改为:在本群有带 openid 点击的 distinct openid 即本群用户,关联 cps_wx_user 画像。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:36:57 +08:00
chenshuobo f868414966 feat(feedback): 补齐反馈历史审核发奖闭环 (#66)
- 新增反馈审核字段和迁移,支持 pending/adopted/rejected、未采纳原因和采纳金币

- 增加用户端反馈历史 records 接口和 admin 采纳/拒绝接口

- 采纳时同事务写状态、金币流水和审计日志,拦截重复审核

- 验证:pytest tests/test_feedback.py tests/test_admin_write.py tests/test_admin_read.py

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #66
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-22 23:18:42 +08:00
zhuzihao 900cc83d38 fix(ad): 广告配置默认信息流位 104090333 → 104142227 (#67)
_AD_CONFIG_DEFAULTS 里 compare/coupon_feed_code_id 的旧默认 104090333
不在穿山甲应用 5830519 名下(请求报 44406「配置为 null」、出不了广告);
2026-06-21 真机核对后台,5830519 名下真实信息流位是 104142227「信息流 1」。

客户端接入 /api/v1/platform/ad-config 下发后会以此默认为准(空库回退),
故更正默认值。注:若 DB 里已存过 104090333,仍需在 admin 广告管理页改存为 104142227。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #67
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-22 23:00:27 +08:00
48 changed files with 1787 additions and 121 deletions
+5
View File
@@ -27,6 +27,11 @@ 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
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
HEARTBEAT_MONITOR_ENABLED=true
HEARTBEAT_TIMEOUT_MINUTES=10
HEARTBEAT_SCAN_INTERVAL_SEC=60
# ===== 短信 (mock 模式) =====
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
@@ -0,0 +1,26 @@
"""merge device#65 and comparison_token_cols heads
Revision ID: 4dc2af7ebe74
Revises: comparison_token_cols, ad_feed_reward_trace_id
Create Date: 2026-06-23 17:57:54.083531
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4dc2af7ebe74'
down_revision: Union[str, Sequence[str], None] = ('comparison_token_cols', 'ad_feed_reward_trace_id')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,42 @@
"""ad_feed_reward_record.trace_id (比价看广告金币归属到比价记录)
Revision ID: ad_feed_reward_trace_id
Revises: device_kill_alert_pending
Create Date: 2026-06-21
比价等候期信息流广告(feed_ad_reward)结算时,客户端带上本场比价 trace_id 落此列;
比价记录页按 trace_id 聚合本场实发金币,显示「比价赚 N 金币」。领券/福利/旧客户端 = NULL。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ad_feed_reward_trace_id'
down_revision: Union[str, Sequence[str], None] = 'device_kill_alert_pending'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# SQLite 下 ADD COLUMN(可空) 与 CREATE INDEX 均原生支持,无需 batch_alter_table。
op.add_column(
'ad_feed_reward_record',
sa.Column('trace_id', sa.String(length=64), nullable=True),
)
op.create_index(
op.f('ix_ad_feed_reward_record_trace_id'),
'ad_feed_reward_record',
['trace_id'],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f('ix_ad_feed_reward_record_trace_id'),
table_name='ad_feed_reward_record',
)
op.drop_column('ad_feed_reward_record', 'trace_id')
+37
View File
@@ -0,0 +1,37 @@
"""add input_tokens / output_tokens to comparison_record
比价记录增加本次 LLM 累计 token(server 从 llm_calls[].usage 累加),
供 admin 比价记录页展示 token 数与估算成本。
Revision ID: comparison_token_cols
Revises: feedback_review_fields
Create Date: 2026-06-23 00:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "comparison_token_cols"
down_revision: Union[str, Sequence[str], None] = "feedback_review_fields"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"comparison_record",
sa.Column("input_tokens", sa.Integer(), nullable=True),
)
op.add_column(
"comparison_record",
sa.Column("output_tokens", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("comparison_record", "output_tokens")
op.drop_column("comparison_record", "input_tokens")
@@ -0,0 +1,39 @@
"""device.kill_alert_pending (无障碍掉线「后置检测」待提醒标记)
Revision ID: device_kill_alert_pending
Revises: device_liveness_table
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] = 'device_liveness_table'
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_liveness', 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_liveness', schema=None) as batch_op:
batch_op.drop_column('kill_alert_pending')
+53
View File
@@ -0,0 +1,53 @@
"""device table (无障碍保护存活检测 + 极光推送)
Revision ID: device_liveness_table
Revises: ceb286289426
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] = 'ceb286289426'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'device_liveness',
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_liveness_user_device'),
)
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_device_liveness_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_liveness_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_liveness_last_heartbeat_at'), ['last_heartbeat_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_device_liveness_last_heartbeat_at'))
batch_op.drop_index(batch_op.f('ix_device_liveness_device_id'))
batch_op.drop_index(batch_op.f('ix_device_liveness_user_id'))
op.drop_table('device_liveness')
@@ -0,0 +1,78 @@
"""feedback review fields
Revision ID: feedback_review_fields
Revises: ceb286289426
Create Date: 2026-06-22 20:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_review_fields"
down_revision: str | Sequence[str] | None = "ceb286289426"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.add_column(sa.Column("reject_reason", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reward_coins", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("review_note", sa.String(length=256), nullable=True))
batch_op.add_column(sa.Column("reviewed_by_admin_id", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
batch_op.create_index(batch_op.f("ix_feedback_status"), ["status"], unique=False)
batch_op.create_foreign_key(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
"admin_user",
["reviewed_by_admin_id"],
["id"],
)
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "new")
.values(status="pending")
)
op.execute(
feedback.update()
.where(feedback.c.status == "handled")
.values(status="adopted")
)
def downgrade() -> None:
feedback = sa.table(
"feedback",
sa.column("status", sa.String(length=16)),
)
op.execute(
feedback.update()
.where(feedback.c.status == "pending")
.values(status="new")
)
op.execute(
feedback.update()
.where(feedback.c.status == "adopted")
.values(status="handled")
)
with op.batch_alter_table("feedback", schema=None) as batch_op:
batch_op.drop_constraint(
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
type_="foreignkey",
)
batch_op.drop_index(batch_op.f("ix_feedback_status"))
batch_op.drop_column("reviewed_at")
batch_op.drop_column("reviewed_by_admin_id")
batch_op.drop_column("review_note")
batch_op.drop_column("reward_coins")
batch_op.drop_column("reject_reason")
@@ -0,0 +1,34 @@
"""meituan_coupon image size & type
给 meituan_coupon 增加 image_size(字节)/ image_type(MIME)两列,采集时 HEAD 头图得到,
供读取侧按图片大小决定是否拼缩放参数提速。两列均 nullable,不动存量行;下一轮全量 ETL
upsert 自动回填,无需手动 backfill。
Revision ID: meituan_coupon_image_meta
Revises: feedback_review_fields
Create Date: 2026-06-23 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "meituan_coupon_image_meta"
down_revision: str | Sequence[str] | None = "feedback_review_fields"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
batch_op.add_column(sa.Column("image_size", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("image_type", sa.String(length=32), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
batch_op.drop_column("image_type")
batch_op.drop_column("image_size")
@@ -0,0 +1,26 @@
"""merge meituan_coupon_image_meta + 4dc2af7ebe74(device/comparison) heads
Revision ID: merge_heads_image_meta_20260623
Revises: 4dc2af7ebe74, meituan_coupon_image_meta
Create Date: 2026-06-23 21:32:11.888579
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'merge_heads_image_meta_20260623'
down_revision: Union[str, Sequence[str], None] = ('4dc2af7ebe74', 'meituan_coupon_image_meta')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+34 -25
View File
@@ -474,44 +474,53 @@ def group_order_daily(
def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]:
"""群内微信用户(首次来源 first_group_id=group_id)+ 每人在该群的 visit/copy 次数(领券画像)
"""该群来过的微信用户:在本群有带 openid 点击的所有用户 + 本群 visit/copy 次数 + 画像。
按首次进入倒序。copy 次数 = 该用户在该群点了几次「复制口令」(领券意向),visit = 进落地页次数。
用「本群点击」判定归属,而非 cps_wx_user.first_group_id —— 同一用户会在多个群活动,
first_group_id 只记首次授权群,会漏掉"先在别处授权、cookie 已存、又来本群"的用户。
copy=本群点「复制口令」次数(领券意向),visit=进落地页次数。按本群首次点击倒序。
昵称/头像来自授权画像;美团/京东群只 302 跳转、无 userinfo 触发点,故多为空。
"""
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))
.where(CpsClick.openid.is_not(None))
.group_by(CpsClick.openid, CpsClick.event_type)
).all()
if not rows:
return []
stat: dict[str, dict] = {}
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 [
openids = list(stat.keys())
# 本群每个 openid 的首次点击时间(排序 + 展示"首次进入")
first_seen = dict(
db.execute(
select(CpsClick.openid, func.min(CpsClick.clicked_at))
.where(CpsClick.group_id == group_id)
.where(CpsClick.openid.is_not(None))
.group_by(CpsClick.openid)
).all()
)
# 关联画像(未授权 userinfo 的昵称/头像为空)
users = {
u.openid: u
for u in db.execute(select(CpsWxUser).where(CpsWxUser.openid.in_(openids))).scalars().all()
}
result = [
{
"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),
"openid": openid,
"nickname": users[openid].nickname if openid in users else None,
"headimgurl": users[openid].headimgurl if openid in users else None,
"first_seen": first_seen.get(openid),
"visit_count": stat[openid]["visit"],
"copy_count": stat[openid]["copy"],
}
for u in users
for openid in openids
]
result.sort(key=lambda x: x["first_seen"], reverse=True)
return result[:limit]
+29
View File
@@ -56,6 +56,35 @@ def update_feedback_status(
return feedback
def review_feedback(
db: Session,
feedback: Feedback,
*,
status: str,
reviewed_by_admin_id: int,
reward_coins: int | None = None,
reject_reason: str | None = None,
review_note: str | None = None,
commit: bool = True,
) -> Feedback:
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
发金币(wallet.grant_coins) router 在同一事务里调,确保状态金币流水审计一起提交
"""
feedback.status = status
feedback.reward_coins = reward_coins
feedback.reject_reason = reject_reason
feedback.review_note = review_note
feedback.reviewed_by_admin_id = reviewed_by_admin_id
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
if commit:
db.commit()
db.refresh(feedback)
else:
db.flush()
return feedback
def review_price_report(
db: Session,
report: PriceReport,
+1 -1
View File
@@ -119,7 +119,7 @@ def dashboard_overview(db: Session) -> dict:
"success": comparison_success,
"success_rate": success_rate,
},
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
}
+100 -8
View File
@@ -1,4 +1,4 @@
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
from __future__ import annotations
from datetime import datetime
@@ -10,9 +10,10 @@ from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.repositories import mutations, queries
from app.admin.schemas.common import CursorPage, OkResponse
from app.admin.schemas.feedback import FeedbackOut
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
from app.models.admin import AdminUser
from app.models.feedback import Feedback
from app.repositories import wallet as wallet_repo
router = APIRouter(
prefix="/admin/api/feedbacks",
@@ -21,6 +22,11 @@ router = APIRouter(
)
def _ensure_pending(fb: Feedback) -> None:
if fb.status not in {"pending", "new"}:
raise HTTPException(status_code=400, detail="反馈已审核")
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
def list_feedbacks(
db: AdminDb,
@@ -56,18 +62,104 @@ def list_feedbacks(
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
def handle_feedback(
feedback_id: int,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
_admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> OkResponse:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
raise HTTPException(status_code=400, detail="请使用采纳或拒绝接口审核反馈")
@router.post("/{feedback_id}/approve", response_model=FeedbackOut, summary="采纳反馈并发金币")
def approve_feedback(
feedback_id: int,
payload: FeedbackApproveRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.update_feedback_status(db, fb, status="handled", commit=False)
mutations.review_feedback(
db,
fb,
status="adopted",
reward_coins=payload.reward_coins,
review_note=payload.note,
reviewed_by_admin_id=admin.id,
commit=False,
)
wallet_repo.grant_coins(
db,
fb.user_id,
payload.reward_coins,
biz_type="feedback_reward",
ref_id=str(fb.id),
remark="意见反馈被采纳",
)
write_audit(
db, admin, action="feedback.handle", target_type="feedback", target_id=feedback_id,
detail={"before": before, "after": "handled"}, ip=get_client_ip(request), commit=False,
db,
admin,
action="feedback.approve",
target_type="feedback",
target_id=feedback_id,
detail={
"before": before,
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
return OkResponse()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
def reject_feedback(
feedback_id: int,
payload: FeedbackRejectRequest,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackOut:
fb = db.get(Feedback, feedback_id)
if fb is None:
raise HTTPException(status_code=404, detail="反馈不存在")
_ensure_pending(fb)
before = fb.status
mutations.review_feedback(
db,
fb,
status="rejected",
reject_reason=payload.reason,
review_note=payload.note,
reviewed_by_admin_id=admin.id,
commit=False,
)
write_audit(
db,
admin,
action="feedback.reject",
target_type="feedback",
target_id=feedback_id,
detail={
"before": before,
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
},
ip=get_client_ip(request),
commit=False,
)
db.commit()
db.refresh(fb)
return FeedbackOut.model_validate(fb)
+2
View File
@@ -33,6 +33,8 @@ class AdminComparisonListItem(BaseModel):
step_count: int | None = None
llm_call_count: int | None = None
retry_count: int | None = None
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
output_tokens: int | None = None # Σ usage.completion_tokens(server 派生)
device_model: str | None = None
rom_vendor: str | None = None
rom_name: str | None = None
+22 -1
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
class FeedbackOut(BaseModel):
@@ -15,4 +17,23 @@ class FeedbackOut(BaseModel):
contact: str
images: list[str] | None = None
status: str
reject_reason: str | None = None
reward_coins: int | None = None
review_note: str | None = None
reviewed_by_admin_id: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
class FeedbackApproveRequest(BaseModel):
reward_coins: int = Field(
ge=1,
le=FEEDBACK_REWARD_MAX_COINS,
description="采纳后发放金币数",
)
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
class FeedbackRejectRequest(BaseModel):
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
+17
View File
@@ -32,6 +32,7 @@ from app.schemas.ad import (
EcpmReportOut,
FeedRewardIn,
FeedRewardOut,
FeedRewardUnitsOut,
PangleCallbackOut,
RewardNoShowIn,
RewardNoShowOut,
@@ -373,6 +374,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
adn=payload.adn,
slot_id=payload.slot_id,
feed_scene=payload.feed_scene,
trace_id=payload.trace_id,
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
@@ -390,6 +392,21 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
)
@router.get(
"/feed-reward/units",
response_model=FeedRewardUnitsOut,
summary="查账号累计信息流发奖份数(前端算因子2 LT、做实时金币进度条用)",
dependencies=[Depends(rate_limit(120, 60, "ad-feed-units"))],
)
def feed_reward_units(user: CurrentUser, db: DbSession) -> FeedRewardUnitsOut:
"""返回账号累计已发奖份数(因子2 LT 基线)。
信息流金币进度条:前端 show 时拉一次,之后每满 10 秒一份逐份用 (granted_units + offset)
LT 因子,精确复刻发奖公式 进度条数值 实际到账
"""
return FeedRewardUnitsOut(granted_units=crud_feed.granted_unit_total(db, user.id))
@router.post(
"/reward-noshow",
response_model=RewardNoShowOut,
+8 -1
View File
@@ -77,8 +77,15 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
rec.llm_calls = calls
rec.llm_call_count = len(calls)
rec.retry_count = sum(1 for c in calls if c.get("error"))
# token 累加(usage 已被 pricebot llm_client 归一为 prompt/completion_tokens;
# error 的调用 usage 可能为 None,or {} 兜底)
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
db.commit()
logger.info("backfill llm_calls trace=%s n=%d", trace_id, len(calls))
logger.info(
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
trace_id, len(calls), rec.input_tokens, rec.output_tokens,
)
except Exception as e: # noqa: BLE001 best-effort
logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e)
finally:
+96
View File
@@ -0,0 +1,96 @@
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
路由前缀 /api/v1/device, Bearer 鉴权(设备绑登录用户)
POST /register 注册设备 / 更新 registration_id(App 前台拿到 push token 时调)
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警
spec: spec/accessibility-liveness-push.md
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from app.api.deps import CurrentUser, DbSession
from app.repositories import device as device_repo
from app.schemas.device import (
DeviceOut,
DeviceRegisterRequest,
HeartbeatRequest,
LivenessAckRequest,
LivenessOut,
OkResponse,
)
logger = logging.getLogger("shagua.device")
router = APIRouter(prefix="/api/v1/device", tags=["device"])
@router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token")
def register_device(
req: DeviceRegisterRequest,
user: CurrentUser,
db: DbSession,
) -> DeviceOut:
device = device_repo.register_or_update(
db,
user_id=user.id,
device_id=req.device_id,
registration_id=req.registration_id,
platform=req.platform,
app_version=req.app_version,
)
logger.info(
"device register user_id=%d device_id=%s reg=%s",
user.id,
req.device_id,
bool(req.registration_id),
)
return DeviceOut.model_validate(device)
@router.post("/heartbeat", response_model=OkResponse, summary="上报心跳")
def report_heartbeat(
req: HeartbeatRequest,
user: CurrentUser,
db: DbSession,
) -> OkResponse:
device_repo.touch_heartbeat(
db,
user_id=user.id,
device_id=req.device_id,
accessibility_enabled=req.accessibility_enabled,
registration_id=req.registration_id,
)
return OkResponse()
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
def get_liveness(
device_id: str,
user: CurrentUser,
db: DbSession,
) -> LivenessOut:
"""客户端进 App 时拉取: 本机是否被判定掉线过(kill_alert_pending)。
设备从未注册过 返回默认(无告警)命中 客户端弹开启自启动引导, 之后调 /liveness/ack
spec: spec/accessibility-liveness-pull-prompt.md
"""
device = device_repo.get_device(db, user_id=user.id, device_id=device_id)
if device is None:
return LivenessOut()
return LivenessOut.model_validate(device)
@router.post("/liveness/ack", response_model=OkResponse, summary="确认已提醒(清掉线告警)")
def ack_liveness(
req: LivenessAckRequest,
user: CurrentUser,
db: DbSession,
) -> OkResponse:
"""客户端已弹过「开启自启动」引导 → 清掉「待提醒」标记(下次真掉线 worker 再置)。"""
device_repo.ack_kill_alert(db, user_id=user.id, device_id=req.device_id)
return OkResponse()
+50 -2
View File
@@ -2,6 +2,7 @@
路由前缀 `/api/v1/feedback`, Bearer 鉴权(反馈绑到登录用户,便于回访)
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 6 )
GET /records 我的反馈历史(pending/adopted/rejected)
截图复用 [app.core.media] 落盘到 /media/feedback/
"""
@@ -9,13 +10,19 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import feedback as feedback_repo
from app.repositories import feedback_qr as feedback_qr_repo
from app.schemas.feedback import FeedbackOut, FeedbackQrConfigOut
from app.schemas.feedback import (
FeedbackOut,
FeedbackQrConfigOut,
FeedbackRecordCountsOut,
FeedbackRecordOut,
FeedbackRecordsOut,
)
logger = logging.getLogger("shagua.feedback")
@@ -24,6 +31,27 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 6
_CONTENT_MAX = 200
_CONTACT_MAX = 128
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
def _app_status(db_status: str) -> str:
return {
"new": "pending",
"handled": "adopted",
"approved": "adopted",
}.get(db_status, db_status)
def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
created_at=fb.created_at,
)
@router.post("", response_model=FeedbackOut, summary="提交反馈")
@@ -67,3 +95,23 @@ async def submit_feedback(
def feedback_config(user: CurrentUser, db: DbSession) -> FeedbackQrConfigOut:
"""运营后台配的反馈页「加群二维码」卡(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取。"""
return FeedbackQrConfigOut(**feedback_qr_repo.get_config(db))
@router.get("/records", response_model=FeedbackRecordsOut, summary="我的反馈历史")
def feedback_records(
user: CurrentUser,
db: DbSession,
status: str | None = Query(default=None),
) -> FeedbackRecordsOut:
if status is not None and status not in _VALID_RECORD_STATUS:
raise HTTPException(status_code=400, detail="invalid status")
all_records = [_record_out(fb) for fb in feedback_repo.list_feedback(db, user_id=user.id)]
counts = FeedbackRecordCountsOut(
all=len(all_records),
pending=sum(1 for r in all_records if r.status == "pending"),
adopted=sum(1 for r in all_records if r.status == "adopted"),
rejected=sum(1 for r in all_records if r.status == "rejected"),
)
records = [r for r in all_records if r.status == status] if status else all_records
return FeedbackRecordsOut(records=records, counts=counts)
+5
View File
@@ -66,6 +66,11 @@ class Settings(BaseSettings):
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
JG_REQUEST_TIMEOUT_SEC: int = 15
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
# ===== 短信 =====
SMS_MOCK: bool = True
SMS_CODE_TTL_SEC: int = 300
+148
View File
@@ -0,0 +1,148 @@
"""无障碍保护存活监控后台任务。
周期扫描曾经保护过当前 alive心跳超时的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)
spec: spec/accessibility-liveness-push.md
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
from app.db.session import SessionLocal
from app.repositories import device as device_repo
logger = logging.getLogger("shagua.heartbeat_monitor")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "heartbeat_monitor.lock"
def _touch_lock() -> None:
with contextlib.suppress(FileNotFoundError):
os.utime(_LOCK_PATH, None)
@contextlib.contextmanager
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
"""同机多进程保护:同一时间只允许一个监控 worker 运行。"""
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd: int | None = None
try:
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
try:
age = time.time() - _LOCK_PATH.stat().st_mtime
except FileNotFoundError:
age = stale_after_sec + 1
if age > stale_after_sec:
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
try:
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
fd = None
if fd is None:
yield False
return
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
yield True
finally:
if fd is not None:
os.close(fd)
with contextlib.suppress(FileNotFoundError):
_LOCK_PATH.unlink()
def _silent_seconds(last: datetime | None) -> int | None:
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
if last is None:
return None
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow()
return int((ref - last).total_seconds())
def _scan_once(timeout_minutes: int) -> dict:
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)
"""
notified = 0
with SessionLocal() as db:
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
for device in overdue:
silent = _silent_seconds(device.last_heartbeat_at)
logger.warning(
"[掉线检测] user_id=%s device_id=%s%s 秒无心跳(阈值 %d 分钟)"
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
device.user_id,
device.device_id,
silent if silent is not None else "?",
timeout_minutes,
)
device_repo.mark_notified(db, device_id_pk=device.id)
notified += 1
return {"checked": len(overdue), "notified": notified}
async def _run_loop() -> None:
interval = max(10, int(settings.HEARTBEAT_SCAN_INTERVAL_SEC))
timeout_minutes = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES))
lock_stale_after = max(interval * 3, 600)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("heartbeat monitor skipped: another worker owns lock")
return
await _run_locked_loop(interval, timeout_minutes)
async def _run_locked_loop(interval: int, timeout_minutes: int) -> None:
logger.info(
"heartbeat monitor started interval=%ss timeout=%sm",
interval,
timeout_minutes,
)
try:
while True:
try:
_touch_lock()
result = await asyncio.to_thread(_scan_once, timeout_minutes)
if result["notified"]:
logger.info("heartbeat monitor result=%s", result)
except SQLAlchemyError:
logger.exception("heartbeat monitor db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("heartbeat monitor unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("heartbeat monitor stopped")
raise
def start_heartbeat_monitor() -> asyncio.Task | None:
if not settings.HEARTBEAT_MONITOR_ENABLED:
logger.info("heartbeat monitor disabled")
return None
return asyncio.create_task(_run_loop(), name="heartbeat-monitor")
async def stop_heartbeat_monitor(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+4
View File
@@ -108,6 +108,10 @@ def record_milestone_reward(milestone: int) -> int:
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
PRICE_REPORT_REWARD_COINS: int = 1000
# ===== 意见反馈采纳奖励(人工审核按质量发放)=====
# 后台审核反馈时允许发放的单条金币上限。只做后端硬保护,具体档位由 admin-web 呈现。
FEEDBACK_REWARD_MAX_COINS: int = 10000
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
+8
View File
@@ -20,6 +20,7 @@ from app.api.v1.compare import router as compare_router
from app.api.v1.compare_milestone import router as compare_milestone_router
from app.api.v1.compare_record import router as compare_record_router
from app.api.v1.coupon import router as coupon_router
from app.api.v1.device import router as device_router
from app.api.v1.cps_redirect import router as cps_redirect_router
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
@@ -38,6 +39,10 @@ from app.api.v1.user import router as user_router
from app.api.v1.wallet import router as wallet_router
from app.api.v1.wxpay import router as wxpay_router
from app.core.config import settings
from app.core.heartbeat_monitor_worker import (
start_heartbeat_monitor,
stop_heartbeat_monitor,
)
from app.core.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
@@ -63,10 +68,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0],
)
reconcile_task = start_withdraw_reconcile_worker()
heartbeat_task = start_heartbeat_monitor()
daily_exchange_task = start_daily_exchange_worker()
try:
yield
finally:
await stop_heartbeat_monitor(heartbeat_task)
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
logger.info("shutting down")
@@ -100,6 +107,7 @@ app.include_router(user_router)
app.include_router(feedback_router)
app.include_router(invite_router)
app.include_router(coupon_router)
app.include_router(device_router)
app.include_router(compare_router)
app.include_router(compare_record_router)
app.include_router(compare_milestone_router)
+1
View File
@@ -12,6 +12,7 @@ from app.models.cps_link import CpsClick, CpsLink # noqa: F401
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.cps_wx_user import CpsWxUser # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.device import DeviceLiveness # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
+3
View File
@@ -33,6 +33,9 @@ class AdFeedRewardRecord(Base):
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
# 本次比价 trace_id(仅 comparison 场景由客户端带上):把这场广告金币归属到对应比价记录,
# 比价记录页按 trace_id 聚合本场实发金币显示「比价赚 N 金币」。领券/福利/旧客户端 = NULL。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+3
View File
@@ -124,6 +124,9 @@ class ComparisonRecord(Base):
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 累计 token(server 从 llm_calls[].usage 累加;旧记录/未采集为 None)
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) # Σ usage.prompt_tokens
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) # Σ usage.completion_tokens
# 每次 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)
+91
View File
@@ -0,0 +1,91 @@
"""设备表(无障碍保护存活检测 + 极光推送)。
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
registration_id(极光推送目标)后端 heartbeat_monitor_worker 扫描曾经保护过
现在心跳超时的设备,通过极光推送提醒用户重开无障碍
liveness_state 状态机(防刷屏,一次掉线只推一条):
unknown alive(收到 service 心跳) silent/notified(扫描发现超时并已推送)
心跳恢复时 handler 重置回 alive
spec: spec/accessibility-liveness-push.md
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class DeviceLiveness(Base):
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
__tablename__ = "device_liveness"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("user.id"), index=True, nullable=False
)
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)
ever_protected: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), index=True, nullable=True
)
# 最近一次上报的无障碍开关状态(观测用)
last_report_protection_on: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
# unknown / alive / silent / notified
liveness_state: Mapped[str] = mapped_column(
String(16), nullable=False, default="unknown"
)
# 最近一次推送告警时间
notified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# 掉线告警「待客户端提醒」标记(后置检测 pull 版, 见 spec accessibility-liveness-pull-prompt.md)。
# 与 liveness_state 解耦: worker 检出掉线即置 True; touch_heartbeat(心跳恢复)不动它
# → 规避「服务随 App 重启先发心跳、state 被重置回 alive → 客户端进 App 漏看」竞态; 只由客户端 ack 清。
kill_alert_pending: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<DeviceLiveness id={self.id} user_id={self.user_id} "
f"device_id={self.device_id} state={self.liveness_state}>"
)
+13 -3
View File
@@ -2,7 +2,7 @@
每条 = 用户一次提交content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
(/media/feedback/...,JSON )status: new(待处理)/ handled(处理)
(/media/feedback/...,JSON )status: pending(审核中)/adopted(采纳)/rejected(未采纳)
"""
from __future__ import annotations
@@ -25,8 +25,18 @@ class Feedback(Base):
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# new(待处理) / handled(已处理)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("admin_user.id"), nullable=True
)
reviewed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
+4
View File
@@ -55,6 +55,10 @@ class MeituanCoupon(Base):
sell_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
head_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 头图字节大小 / MIME 类型:采集时 HEAD head_url 得到(Content-Length / Content-Type)。
# 读取侧已统一压缩(不再按大小判定),这两列保留用于分析/监控(GIF 占比、体积分布等)。
image_size: Mapped[int | None] = mapped_column(Integer, nullable=True) # 字节
image_type: Mapped[str | None] = mapped_column(String(32), nullable=True) # 如 image/jpeg
# ===== 销量(美团只给粗档位:热销1w+;num=排序用的下界数值,如 1w+ → 10000) =====
sale_volume: Mapped[str | None] = mapped_column(String(32), nullable=True)
+31 -25
View File
@@ -42,21 +42,20 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
).scalar_one()
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int:
"""按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)。"""
if unit_count <= 0:
return 0
existing_units = db.execute(
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0))
.where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
total = 0
for offset in range(1, unit_count + 1):
total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset)
return total
def granted_unit_total(db: Session, user_id: int) -> int:
"""账号累计已发奖**条**数(COUNT status=granted),不按天重置 = 因子2(LT)基线。
口径:**一条广告 = 一份额度(一个单次公式值)**,"已发条数""已发份数"供前端拉取后精确复刻
金币公式做实时金币进度条(前端因子2 granted_unit_total + 本场已结算条数 + 1 取档,与本表发奖一致)
"""
return int(
db.execute(
select(func.count()).select_from(AdFeedRewardRecord).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
)
def grant_feed_reward(
@@ -70,20 +69,21 @@ def grant_feed_reward(
adn: str | None = None,
slot_id: str | None = None,
feed_scene: str | None = None,
trace_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
aborted: bool = False,
) -> AdFeedRewardRecord:
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
"""**每条**信息流广告(客户端每条各上报一次)结算奖励。client_event_id 幂等,同号重试不重复发。
发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份( 10 1 )
- aborted=True(用户中途 关闭):整场不发, status='closed_early' 留痕(原因可查)
- 总时长不足 10 (unit_count==0): status='too_short' 不发
发奖规则:**一条广告 = 一个单次公式值**(rewards.calculate_ad_reward_coin),因子2(LT)按账号累计
****数递进;看满一份时长(unit_count>=1, 10 )才发,**不逐份累加**
- aborted=True(用户中途 关闭这条):本条不发, status='closed_early'
- 时长不足 10 (unit_count==0): status='too_short' 不发
- 命中当日条数上限: status='capped' 不发
duration_seconds 整场累计秒数一期 eCPM/时长均由客户端上报,服务端两道硬闸防刷:时长钳到
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM rewards.calculate_ad_reward_coin 内钳到
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间
feed_scene:点位场景(comparison/coupon/welfare),仅做归类落库,不参与发奖计算
duration_seconds **这一条**的观看秒数服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS
eCPM calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限
feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算
"""
existing = _find_by_event(db, client_event_id)
if existing is not None:
@@ -107,6 +107,7 @@ def grant_feed_reward(
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
@@ -126,6 +127,7 @@ def grant_feed_reward(
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
@@ -146,6 +148,7 @@ def grant_feed_reward(
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
@@ -153,12 +156,14 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
# 一条广告 = 一个「单次公式值」(因子2 按账号累计**条**数, 即第 existing_ads+1 条);看满一份(unit_count>=1)即发,不逐份累加。
existing_ads = granted_unit_total(db, user_id)
coin = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
if coin > 0:
crud_wallet.grant_coins(
db, user_id, coin,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark=f"信息流广告奖励 {unit_count}",
remark="信息流广告奖励",
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
@@ -171,6 +176,7 @@ def grant_feed_reward(
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
coin=coin,
+4 -2
View File
@@ -101,8 +101,10 @@ AD_CONFIG_KEY = "ad_config"
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
"app_id": "5830519", # 穿山甲应用ID(正式)
"reward_code_id": "104099389", # 福利页激励视频位
"compare_feed_code_id": "104090333", # 比价信息流位
"coupon_feed_code_id": "104090333", # 领券信息流位(初始同比价,运营可拆)
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」;
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准。
"compare_feed_code_id": "104142227", # 比价信息流位
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆)
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
"reward_enabled": True, # 福利激励视频开关
"compare_ad_enabled": True, # 比价广告开关
+34 -4
View File
@@ -11,6 +11,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.comparison import ComparisonRecord
from app.models.savings import SavingsRecord
from app.schemas.compare_record import ComparisonRecordIn
@@ -155,6 +156,29 @@ def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
return {s for s in rows if s}
def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[str, int]:
"""各 trace_id「看广告赚的金币」:按 trace_id 聚合该用户已发放(granted)的信息流广告金币。
广告金币来自比价等候期信息流(biz_type=feed_ad_reward);客户端结算时把本场 trace_id 带上,
落到 ad_feed_reward_record.trace_id此处按 trace_id 求和 = 这次比价看广告实发的金币
trace_id 仅比价场景客户端带(领券/福利/旧客户端为 NULL),故按 trace_id 过滤天然只算比价广告
空集合直接返回(避免 IN () 非法)
"""
if not trace_ids:
return {}
rows = db.execute(
select(
AdFeedRewardRecord.trace_id,
func.coalesce(func.sum(AdFeedRewardRecord.coin), 0),
).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.trace_id.in_(trace_ids),
).group_by(AdFeedRewardRecord.trace_id)
).all()
return {tid: int(coin) for tid, coin in rows if tid}
def list_records(
db: Session,
user_id: int,
@@ -162,7 +186,7 @@ def list_records(
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[ComparisonRecord], int | None]:
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记(瞬态,不写库)。"""
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。"""
stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id)
if cursor is not None:
stmt = stmt.where(ComparisonRecord.id < cursor)
@@ -172,10 +196,13 @@ def list_records(
next_cursor = items[-1].id if len(items) == limit else None
# 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。
# ordered 非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
# ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
ordered_shops = _ordered_shop_names(db, user_id)
# 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。
ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items])
for it in items:
it.ordered = bool(it.store_name and it.store_name in ordered_shops)
it.ad_coins_earned = ad_coins.get(it.trace_id, 0)
return items, next_cursor
@@ -210,10 +237,13 @@ def get_stats(db: Session, user_id: int) -> tuple[int, int]:
def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None:
"""取单条(限本人,避免越权读他人记录)。"""
return db.execute(
"""取单条(限本人,避免越权读他人记录)。附「看广告赚的金币」瞬态属性(同 list_records)。"""
rec = db.execute(
select(ComparisonRecord).where(
ComparisonRecord.id == record_id,
ComparisonRecord.user_id == user_id,
)
).scalar_one_or_none()
if rec is not None:
rec.ad_coins_earned = _ad_coins_by_trace(db, user_id, [rec.trace_id]).get(rec.trace_id, 0)
return rec
+124
View File
@@ -0,0 +1,124 @@
"""device 表读写(设备注册 / 心跳 / 超时扫描)。"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.device import DeviceLiveness
def _get(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None:
stmt = select(DeviceLiveness).where(
DeviceLiveness.user_id == user_id, DeviceLiveness.device_id == device_id
)
return db.execute(stmt).scalar_one_or_none()
def register_or_update(
db: Session,
*,
user_id: int,
device_id: str,
registration_id: str | None,
platform: str = "android",
app_version: str | None = None,
) -> DeviceLiveness:
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
device = _get(db, user_id=user_id, device_id=device_id)
if device is None:
device = DeviceLiveness(
user_id=user_id,
device_id=device_id,
registration_id=registration_id,
platform=platform or "android",
app_version=app_version,
)
db.add(device)
else:
if registration_id:
device.registration_id = registration_id
if platform:
device.platform = platform
if app_version:
device.app_version = app_version
db.commit()
db.refresh(device)
return device
def touch_heartbeat(
db: Session,
*,
user_id: int,
device_id: str,
accessibility_enabled: bool,
registration_id: str | None,
) -> DeviceLiveness:
"""处理一次心跳(心跳也能自注册)。
service 心跳或 accessibility_enabled=true ,刷新存活并把状态机重置回 alive
清掉 notified_at(掉线恢复 下次再断才会再推一条)
"""
now = datetime.now(timezone.utc)
device = _get(db, user_id=user_id, device_id=device_id)
if device is None:
device = DeviceLiveness(user_id=user_id, device_id=device_id)
db.add(device)
if registration_id:
device.registration_id = registration_id
device.last_report_protection_on = accessibility_enabled
if accessibility_enabled:
device.last_heartbeat_at = now
device.ever_protected = True
device.liveness_state = "alive"
device.notified_at = None
db.commit()
db.refresh(device)
return device
def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
"""掉线设备:曾经保护过、当前 alive、心跳超时。
本期只做终端打印检测不推送 不再要求有 registration_id(没接极光 token 的设备也要检出)
"""
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
stmt = select(DeviceLiveness).where(
DeviceLiveness.ever_protected.is_(True),
DeviceLiveness.liveness_state == "alive",
DeviceLiveness.last_heartbeat_at.is_not(None),
DeviceLiveness.last_heartbeat_at < cutoff,
)
return list(db.execute(stmt).scalars().all())
def mark_notified(db: Session, *, device_id_pk: int) -> None:
"""标记掉线已检出(状态机进入 notified,避免每轮重复处理)+ 置「待客户端提醒」标记。
kill_alert_pending state 解耦( spec accessibility-liveness-pull-prompt.md):
心跳恢复(touch_heartbeat)只重置 state不动此标记,故客户端下次进 App 必能拉到这次掉线
"""
device = db.get(DeviceLiveness, device_id_pk)
if device is not None:
device.liveness_state = "notified"
device.notified_at = datetime.now(timezone.utc)
device.kill_alert_pending = True
db.commit()
def get_device(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None:
"""按 (user_id, device_id) 取设备(liveness 查询用)。"""
return _get(db, user_id=user_id, device_id=device_id)
def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None:
"""客户端已弹过「开启自启动」引导 → 清「待提醒」标记(幂等; 下次真掉线 worker 再置)。"""
device = _get(db, user_id=user_id, device_id=device_id)
if device is not None and device.kill_alert_pending:
device.kill_alert_pending = False
db.commit()
+16 -2
View File
@@ -1,8 +1,12 @@
"""feedback 表写"""
"""feedback 表写。"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.rewards import CN_TZ
from app.models.feedback import Feedback
@@ -19,9 +23,19 @@ def create_feedback(
content=content,
contact=contact,
images=images or None,
status="new",
status="pending",
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
)
db.add(fb)
db.commit()
db.refresh(fb)
return fb
def list_feedback(db: Session, *, user_id: int) -> list[Feedback]:
stmt = (
select(Feedback)
.where(Feedback.user_id == user_id)
.order_by(Feedback.created_at.desc(), Feedback.id.desc())
)
return list(db.execute(stmt).scalars().all())
+35 -34
View File
@@ -6,13 +6,13 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
种子是生成规则:用户名可空(随机合成脱敏名,避开同屏撞名)金额是区间(每次随机取值)
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出
脱敏名规则( feed 统一,按字符算中英文皆适用):
- 有昵称 昵称脱敏:3 +隐藏字数个星+(省钱小能手*** / SaveKingS******g),
2 +(阿强*),1 用户+该字(用户喵);
- 没昵称 合成一个假名(见下)再脱敏,少数露用户+id 3 (用户********618)
真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示用户****会清一色,故给它们合成
假名后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降种子 / 兜底同样合成避开同屏撞名;
种子留空 / 用户****xxx模板名一律重新合成(自愈历史种子,无需迁移)
脱敏名规则(对齐 PRD用户标识打码规则,按字符中英文皆适用):
- 有昵称 昵称脱敏:n5+***+(AAA省钱小能手A*** / Micky_ M***),
n=4+**+(你好不好**),n3+**(小确呀** / 阿强** / **);
- 没昵称(真实用户)用户+5+id 2 (用户*****08), user_id 稳定
真实条按上述 PRD 规则脱敏种子 / 兜底无真实用户(无昵称/ id,套不上用户*****+id后2位),
仍本地合成多样假名再走同一套昵称脱敏(避开同屏撞名),让横条不至清一色;种子留空 / 用户****xxx
模板名一律重新合成(自愈历史种子,无需迁移)
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
组合空间上万中文为主英文为辅,脱敏后像真实异质用户群**真实条按 `user_id` 播种确定性合成**(同一用户
每次展示恒定不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充不依赖外部库
@@ -44,12 +44,12 @@ _REAL_ROWS_TTL_SECONDS = 30
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
_real_rows_cache: dict = {"at": None, "rows": None}
# ===== 用户标识脱敏 + 没真实昵称时的假名合成 =====
# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 /
# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。
# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合真名 + 网络昵称语料(中/英)
# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降
_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx)
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
# 没昵称(真实用户)→「用户」+5星+id 后 2(用户*****08)
# 种子/兜底没有真实用户(无昵称/无 id),本地姓/名字池组合真名 + 网络昵称语料(中/英)合成假名,
# 再走同一套昵称脱敏,让横条像真实异质用户群、不清一色(PRD 只约束真实用户,种子是运营填充)
_ANON_STARS = "*" * 5 # 「用户」匿名串固定 5 星(用户*****xx,对齐 PRD)
# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。
# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。
@@ -86,19 +86,23 @@ _NICK_EN = [
def _mask_nickname(nick: str) -> str:
"""昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g);
2 +(阿强*);1 用户+该字(用户喵)调用方需保证 nick strip 且非空"""
"""昵称脱敏(按字符,中英文皆可,对齐 PRD「用户标识打码规则」):
n5 +***+(AAA省钱小能手A*** / Micky_ M***);
n=4 +**+(你好不好** / LadyL**y);
n3 +**(小确呀** / 阿强** / ** / LayL**)
调用方需保证 nick strip 且非空"""
n = len(nick)
if n >= 3:
return nick[0] + "*" * (n - 2) + nick[-1]
if n == 2:
return nick[0] + "*"
return "用户" + nick
if n >= 5:
return nick[0] + "***" + nick[-1]
if n == 4:
return nick[0] + "**" + nick[-1]
return nick[0] + "**"
def _anon_by_id(user_id: int) -> str:
"""没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。"""
return "用户" + _ANON_STARS + f"{user_id % 1000:03d}"
def _mask_anon(user_id: int) -> str:
"""没昵称(真实用户,对齐 PRD):「用户」+ 5 星 + id 后 2 位(不足 2 位前补 0):用户*****08。
:user_id 为整数,id 2 取末两位数字(PRD 示例用户*****x8 x 为占位符)"""
return "用户" + _ANON_STARS + f"{user_id % 100:02d}"
def _synth_full_name(rng: random.Random) -> str:
@@ -118,27 +122,24 @@ def _synth_full_name(rng: random.Random) -> str:
def _synth_masked_name(rng: random.Random) -> str:
"""合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。"""
"""合成一条「已脱敏」假名(供种子 / 兜底用,真实用户已改走 PRD 规则)。用传入 rng 决定一切随机。"""
full = _synth_full_name(rng).strip()
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
def _mask_real(nickname: str | None, user_id: int) -> str:
"""真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏
(同一用户每次展示恒定刷新不变脸,不同用户各异),避免无昵称用户清一色用户****xxx;
少数(~15%)用户+真实 id 3 当前多数用户没昵称,后期真实昵称多了占比下降"""
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
没昵称用户+5+id 2 (用户*****08), user_id 稳定刷新不变脸"""
nick = (nickname or "").strip()
if nick:
return _mask_nickname(nick)
# 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。
seeded = random.Random(user_id)
return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded)
return _mask_anon(user_id)
def _synth_name(rng: random.Random) -> str:
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。"""
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 2 位(用户*****08)。"""
if rng.random() < 0.15:
return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
return "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
return _synth_masked_name(rng)
@@ -148,7 +149,7 @@ def _unique_name(used: set[str]) -> str:
n = _synth_name(_rng)
if n not in used:
return n
return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重
return "用户" + _ANON_STARS + f"{_rng.randint(0, 99):02d}" # 兜底:数字尾号天然好去重
def _validate_amount(min_cents: int, max_cents: int) -> None:
+14
View File
@@ -141,6 +141,12 @@ class FeedRewardIn(BaseModel):
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
"比价与领券共用同一信息流代码位,需客户端在各调用点显式标注,缺省=未分类",
)
trace_id: str | None = Field(
None,
max_length=64,
description="本次比价 trace_id(比价场景必带):把这场广告金币归属到对应比价记录,"
"比价记录页据此显示「比价赚 N 金币」。领券/福利场景为空",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
@@ -160,6 +166,14 @@ class FeedRewardOut(BaseModel):
daily_limit: int = Field(..., description="每日信息流展示次数上限")
class FeedRewardUnitsOut(BaseModel):
granted_units: int = Field(
...,
description="账号累计已发奖的信息流**条**数(一条广告=一份额度),不按天重置;"
"前端据此算因子2(LT)做实时金币进度条,与后端发奖公式对齐",
)
class RewardNoShowIn(BaseModel):
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
+12
View File
@@ -51,6 +51,13 @@ class ComparisonResultIn(BaseModel):
# 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
applied_coupons: list[AppliedCouponIn] = Field(default_factory=list)
# 逐平台状态(客户端上报前把 done.params 的 comparison_results + platform_results.status 合并写入):
# success / below_minimum / store_closed / store_not_found / items_not_found / unsupported / failed。
# 「比价记录」页据此对无价平台显示对应文案(未满起送价/门店打烊/店家未入驻/比价失败)。
# 必须显式声明: 落库走 model_dump(), 不声明会被 pydantic 静默丢弃 → 记录页拿不到 status。
status: str | None = None
# 门店打烊原因(price 为 None 时带): 与 status="store_closed" 等价的更早信号, 一并落库供前端兜底判打烊。
store_closed: str | None = None
class ComparisonRecordIn(BaseModel):
@@ -73,6 +80,8 @@ class ComparisonRecordIn(BaseModel):
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
# 按平台 id 索引的 dict 而非 list,只作 debug 展示)。
# ⚠️ 早先误声明成 list → 每条成功记录 422 被拒、记录页只剩失败/终止
# (2026-06-17 引入 platform_results 上报后复现);改 dict 后修复。
platform_results: dict = Field(default_factory=dict)
skipped_dish_count: int | None = None
skipped_dish_names: list[str] = Field(default_factory=list)
@@ -138,6 +147,9 @@ class ComparisonRecordOut(BaseModel):
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
ordered: bool = False
# 「本次比价看广告赚的金币」(feed_ad_reward,按 trace_id 聚合 granted 金币;非 DB 列,
# list_records/get_record 动态挂在 ORM 实例上,from_attributes 读出)。0=没赚到 → 记录页不显示金币标。
ad_coins_earned: int = 0
created_at: datetime
+48
View File
@@ -0,0 +1,48 @@
"""设备注册 / 心跳相关 schema(无障碍保护存活检测)。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class DeviceRegisterRequest(BaseModel):
device_id: str
registration_id: str | None = None
platform: str = "android"
app_version: str | None = None
class HeartbeatRequest(BaseModel):
device_id: str
source: str = "service" # service | app
accessibility_enabled: bool = True
registration_id: str | None = None
class DeviceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
device_id: str
registration_id: str | None
ever_protected: bool
liveness_state: str
last_heartbeat_at: datetime | None
updated_at: datetime | None
class OkResponse(BaseModel):
ok: bool = True
class LivenessOut(BaseModel):
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state )从未注册过 默认 False(无告警)"""
model_config = ConfigDict(from_attributes=True)
kill_alert_pending: bool = False
class LivenessAckRequest(BaseModel):
device_id: str
+23 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
class FeedbackOut(BaseModel):
@@ -26,3 +26,25 @@ class FeedbackQrConfigOut(BaseModel):
group_name: str
subtitle: str
class FeedbackRecordOut(BaseModel):
id: int
content: str
images: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
created_at: datetime
class FeedbackRecordCountsOut(BaseModel):
all: int = 0
pending: int = 0
adopted: int = 0
rejected: int = 0
class FeedbackRecordsOut(BaseModel):
records: list[FeedbackRecordOut]
counts: FeedbackRecordCountsOut
+16 -1
View File
@@ -13,6 +13,21 @@ from pydantic import BaseModel, Field
FeedStatus = Literal["ok", "empty", "degraded"]
# ───────────────── 头图传输优化(统一缩放 + 转 WebP) ─────────────────
# 美团图片 CDN 支持在 URL 后拼 @<w>w_<h>h_1e_1c[.webp] 让服务端缩放/转码。feed 卡片只占
# 124dp(≈372px@3x),而库里 head_url 是原图(实测中位 137KB、长尾到 17MB、GIF 均 4.8MB),
# 是首页 feed 图片加载偏慢的来源。按产品要求,头图**统一**缩到 124dp + 转 WebP 再下发
# (全部压缩、不设大小阈值),由美团 CDN 服务端缩放/转码,实测省 76–99% 体积。
FEED_THUMB_PARAM = "@375w_375h_1e_1c.webp" # 124dp@3x 缩略 + 转 WebP
def feed_image_url(head_url: str) -> str:
"""统一给头图 URL 拼缩放参数(缩到 124dp + 转 WebP),让美团 CDN 出小图;空 URL 原样返回。"""
if not head_url:
return head_url
return head_url.split("@")[0] + FEED_THUMB_PARAM
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
class CouponCard(BaseModel):
@@ -94,7 +109,7 @@ class CouponCard(BaseModel):
platform=platform,
biz_line=biz_line,
name=cpd.get("name") or "",
head_image_url=head_url,
head_image_url=feed_image_url(head_url),
brand_name=brand.get("brandName"),
brand_logo_url=brand.get("brandLogoUrl"),
sell_price=sell,
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# 美团券头图大小/类型回填 —— 全量重跑一轮 ETL,把 meituan_coupon.image_size / image_type 灌满。
# 上线本 PR 后在服务器手动跑一次,立即回填存量券(不必等定时器逐轮 upsert 自然补齐)。
#
# 用法(服务器, root):
# cd /opt/shaguabijia-app-server
# sudo bash deploy/backfill_image_meta.sh
# # 全量一轮 ~70min,建议在 tmux/screen 里跑,防 SSH 断开中断
#
# 前提:已拉到含本 PR 的 main、已跑 `alembic upgrade head`(脚本会自检列是否存在)、
# 已 restart shaguabijia-app-server.service。服务器 MT_CPS_PROXY 留空(直连 CDN)。
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/shaguabijia-app-server}"
PY="$APP_DIR/.venv/bin/python"
cd "$APP_DIR"
mkdir -p data
LOG="data/etl_backfill_$(date +%Y%m%d_%H%M%S).log"
# 退出时务必恢复定时器(即使 ETL 中途失败,也不把 timer 留在停用态)
restore_timer() { systemctl start meituan-etl.timer 2>/dev/null || true; }
trap restore_timer EXIT
echo "[0/3] 自检 image_size/image_type 列(缺列说明没迁移,先跑 alembic upgrade head)..."
"$PY" -c "from sqlalchemy import inspect; from app.db.session import engine; \
cols=[c['name'] for c in inspect(engine).get_columns('meituan_coupon')]; \
import sys; (('image_size' in cols) and ('image_type' in cols)) or sys.exit(' ❌ 缺列,请先: sudo .venv/bin/python -m alembic upgrade head'); \
print(' ✓ 列已就绪')"
echo "[1/3] 停定时器,防与定时轮次并发占锁..."
systemctl stop meituan-etl.timer 2>/dev/null || true
echo "[2/3] 全量重跑 ETL(~70min;日志 $LOG)..."
# 不走 systemctl:meituan-etl.service 有 20min 硬超时,全量一轮跑不完会被掐断。
"$PY" -m scripts.pull_meituan_coupons --once --prune-hours 24 2>&1 | tee "$LOG"
echo "[3/3] 验证填充率..."
"$PY" - <<'PYEOF'
from sqlalchemy import text
from app.db.session import engine
engine.echo = False
with engine.connect() as c:
tot = c.execute(text("SELECT count(*) FROM meituan_coupon")).scalar()
enr = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_size IS NOT NULL")).scalar()
gif = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_type='image/gif'")).scalar()
print(f" 总 {tot} 行 | 有 image_size {enr} ({enr*100//max(tot,1)}%) | GIF {gif} 张")
PYEOF
echo "✅ 回填完成(定时器已恢复)。"
+1 -1
View File
@@ -16,7 +16,7 @@
| [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 实现原理 + 本地内网全链路测试)。 |
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试) + [CPS发券分发与微信授权.md](./guides/CPS发券分发与微信授权.md)(**CPS 发券系统交接文档**:设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计;含数据流/表/平台差异/端点/配置/代码地图/排障/技术债)。 |
## api/ 目录是怎么组织的(传送门式)
+1 -1
View File
@@ -173,7 +173,7 @@
| `platform` | int | `1`=外卖/到家, `2`=到店 |
| `biz_line` | int \| null | 到店子类:1到餐 2到综 3酒店 4门票 |
| `name` | string | 商品名 |
| `head_image_url` | string | 头图 |
| `head_image_url` | string | 头图;后端已**统一**拼缩放参数 `@375w_375h_1e_1c.webp`(美团 CDN 服务端缩到 124dp + 转 WebP,实测省 7699%),客户端直接用 |
| `brand_name` | string \| null | 品牌名 |
| `brand_logo_url` | string \| null | 品牌 logo |
| `sell_price` | string | 现价 |
@@ -0,0 +1,194 @@
# CPS 发券分发 + 微信授权 系统说明(交接文档)
> 给接手人。读完应能:看懂系统、带运营走完"设群→建活动→生成链接→看统计"、接微信授权、排障、知道边界。
> 代码引用一律用「文件:符号」(不写行号,重构后符号比行号稳)。
---
## 一、这是什么 / 业务目标
傻瓜比价的 **CPS 群发分发**系统:运营在 admin 后台建「群」、建「活动」(美团/淘宝/京东的券)、生成带渠道追踪的**落地页短链 `/c/{code}`** 发到各微信群;用户点链接 → 落地页 → 跳转下单或复制淘口令领券 → 后端记点击、(美团)拉订单对账、(微信内)拿 openid 做用户级统计。
**目标**:把券发出去 + 把「点击 → 下单 → 佣金」漏斗、「哪个群/哪个微信用户领了券」统计出来,指导投放。
---
## 二、两套入口 / 两个域名(先建立全局)
| | 域名 | 反代 | 代码 | 鉴权 |
|---|---|---|---|---|
| 运营后台 | admin-web.shaguabijia.com | admin 子应用 :8771 | `admin/routers/cps.py`,前缀 `/admin/api/cps` | admin JWT(operator/finance) |
| 用户落地页 | **coupon.shaguabijia.com** | app-server :8770 | `api/v1/cps_redirect.py`,**无前缀** | **公网无鉴权**(群里谁都能点) |
> 两个域名都全反代到 app-server/admin。落地页域名 `coupon.shaguabijia.com``settings.CPS_REDIRECT_BASE` 配置,生成短链时拼前缀。
---
## 三、数据流
```
[admin] 建群(cps_group) → 建活动(cps_activity) → 生成链接(cps_link, 得 /c/{code})
│ 复制发到微信群
[用户点 /c/{code}] cps_landing:
├─ (微信内+开关开+无cookie) 302 跳微信授权 → 回调换 openid → 存 cps_wx_user + 种 cookie → 跳回
├─ 记一条点击 cps_click(visit, 带 openid)
└─ 美团/京东:302 跳 target_url | 淘宝:返回 H5 落地页(图+复制淘口令按钮)
│ (淘宝)点复制 → POST /c/{code}/copy 记 copy
[美团] 运营在 admin 点「刷新对账」→ query_order 拉订单 → cps_order(按 sid 归群)
[admin 统计] 对账统计页 + 群详情(折线图 / 每日明细 / 群内微信用户)
```
---
## 四、数据表(6 张,均在 `app/models/cps_*`)
| 表 | 模型 | 职责 / 关键字段 |
|---|---|---|
| cps_group | `CpsGroup` | 群。`name` / `platforms`(多选 meituan/taobao/jd) / `sid`(美团渠道追踪位,仅美团群有;纯淘宝/京东为空) / `member_count` / `status`(active/archived) |
| cps_activity | `CpsActivity` | 可推广活动池。`platform` / `name` / `act_id`(美团活动物料ID) / `product_view_sign`(美团商品券) / `payload`(淘宝整段淘口令 / 京东链接) / `image_url`(淘宝落地页主视觉图,绝对URL) |
| cps_link | `CpsLink` | 群发短链。`code`(短码,`/c/{code}`) / `group_id` / `activity_id` / `sid` / `target_url`(美团短链/京东链接/淘宝淘口令) / `platform` |
| cps_click | `CpsClick` | 点击事件。`link_id` / `group_id` / `sid` / `event_type`(**visit**=进页/被跳转,**copy**=淘宝点复制口令) / `ip` / `ua` / **`openid`**(微信授权拿到,非微信/未授权为空) |
| cps_order | `CpsOrder` | 美团订单(query_order 拉回)。`order_id`(唯一,upsert) / `sid`(按它归群) / `pay_price_cents` / `commission_cents`(预估佣金) / `refund_profit_cents`(退款佣金) / `mt_status`(2付款3完成4取消5风控6结算) / `pay_time` |
| cps_wx_user | `CpsWxUser` | 微信用户。`openid`(唯一) / `unionid` / `nickname` / `headimgurl` / `first_group_id`(首次授权来源群) |
> 金额**全程存「分」**,前端 ÷100 显示「元」。时间统一 tz-aware UTC,展示按北京。
---
## 五、三平台差异(最关键的一张表)
| | 美团 | 淘宝 | 京东 |
|---|---|---|---|
| 链接生成 | 调 `meituan.get_referral_link` 转链(**需群 sid**) | 直接用活动 `payload`(淘口令) | 直接用活动 `payload`(链接) |
| 落地页行为 | 302 跳美团短链 | **H5 落地页**(主视觉图 + 复制淘口令按钮) | 302 跳京东链接 |
| 对账 | ✓ `query_order` 拉订单/佣金 | ✗ 无开放 API | ✗ 无开放 API |
| 能统计到 | 点击 + 下单 + 佣金 | 点击 + 复制次数 | 点击 |
> 淘宝/京东订单类指标(有效单/成交额/佣金)在统计页显示「-」(无法对账)。只有美团群(有 sid)有订单数据。
---
## 六、运营操作流程(带新人就照这个)
1. **建群**:CPS 分发 → 群管理 → 新建群。选这个群发哪些平台的券(可多选);含美团时可填 sid(留空自动 `g<id>`)。
2. **建活动**:活动管理 → 新建活动。按平台填:美团填 `actId`(联盟「我要推广-活动推广」第一列) 或 商品券 `productViewSign`;淘宝填整段淘口令 + **必须上传落地页图**;京东填推广链接。
3. **生成链接**:群管理 → 某群 → 「生成链接」→ 勾选要发的活动(只列该群平台范围内的)→ 每个活动出一条 `https://coupon.shaguabijia.com/c/{code}` → 复制发到群里。
4. **对账(仅美团,finance 角色)**:对账统计 → 「刷新对账(拉美团订单)」→ 拉近 N 天订单入库。**非实时,要手动点**。
5. **看统计**:对账统计页看各群点击/独立/复制/有效单/取消风控/成交额/预估佣金/结算佣金;**点群名进群详情** → 折线图(点击趋势,天/小时)+ 每日明细大表格(点击+订单按天,含退款/净佣金)+ 群内微信用户(头像/昵称/领券次数)。
---
## 七、微信授权(用户级统计)
### 目的
用户在微信内打开落地页时,拿到 `openid`(识别这个人) + 昵称头像,做「每个微信用户在每个群领没领券」的用户级统计。
### 链路
```
/c/{code} (微信内 + 开关开 + 无 wx_openid cookie)
→ 302 跳微信 base 授权(snsapi_base,静默不打断)
→ 微信回调 /wx/oauth/cb?code=..&state=base:{code}
→ wx_oauth.exchange_code 换 openid → cps_wx_user_repo.upsert 存用户 + 关联群 → 种 wx_openid cookie(30天)
→ 302 跳回 /c/{code}(这次有 cookie,正常展示,记 visit 带 openid)
[淘宝群] 用户点「复制口令」→ 若还没昵称头像 → 跳 snsapi_userinfo 授权(用户点击=交互触发,避免微信"快照页")
→ 回调拉 userinfo 补 nickname/headimgurl → 跳回落地页自动复制
```
代码:`cps_redirect.py:cps_landing` / `cps_redirect.py:wx_oauth_cb` / `cps_redirect.py:_taobao_landing_html`(注入 userinfo 授权链接);微信调用封装 `integrations/wx_oauth.py`
### 三个硬前提
1. **必须是已认证服务号**(订阅号、未认证都不支持网页授权 → 报 10005)。
2. **网页授权域名** = `coupon.shaguabijia.com`,在服务号后台「设置与开发→公众号设置→功能设置→网页授权域名」配置 + 放校验文件。校验文件由本服务端点 serve(见 `cps_redirect.py``MP_verify_*.txt` 路由,换号/换文件时改这里返回的串)。
3. **凭证 + 开关**:`.env``WX_MP_APPID` / `WX_MP_SECRET`(服务号的,**≠** 微信支付那个 `WECHAT_APP_ID`);总开关 `WX_MP_OAUTH_ENABLED=true`。开关默认关 → 落地页走原逻辑、不拿 openid(认证未就绪时用)。判定见 `config.py:Settings.wx_oauth_active`
### 群内微信用户怎么算(重要,踩过坑)
`admin/repositories/cps.py:group_wx_users` 按「**该群有带 openid 的点击**」(cps_click.group_id + openid)算群内用户,再关联 cps_wx_user 拿画像。**不是**按 `cps_wx_user.first_group_id`——因为一个用户(cookie 已存)会去多个群,first_group_id 只记首次授权群,会漏算。
- **美团/京东群只有 openid、没有昵称头像**:它们是 302 跳转、没有"点复制领券"那一步,触发不了 userinfo。只有**淘宝群**点复制口令时才补昵称头像。
---
## 八、端点清单
### admin(`/admin/api/cps`,operator/finance 鉴权)— `admin/routers/cps.py`
- 群:`GET /groups` `POST /groups` `PATCH /groups/{id}` `DELETE /groups/{id}`
- 活动:`GET /activities` `POST /activities` `PATCH /activities/{id}` `DELETE /activities/{id}` `POST /upload-image` `GET /activity-images`
- 生成链接:`POST /referral-links`(批量,按 activity_ids,美团转链)
- 对账:`POST /orders/reconcile`(拉美团订单) `GET /orders`(明细)
- 统计:`GET /stats`(按群对账统计) `GET /groups/{id}/timeseries`(折线) `GET /groups/{id}/daily`(每日明细) `GET /groups/{id}/wx-users`(群内微信用户)
### 落地页(coupon 域,公网无鉴权)— `api/v1/cps_redirect.py`
- `GET /c/{code}`(落地页:授权+记点击+跳转/H5)
- `POST /c/{code}/copy`(淘宝点复制,记 copy)
- `GET /wx/oauth/cb`(微信授权回调)
- `GET /MP_verify_*.txt`(微信域名归属校验文件)
> 删除/编辑都不级联删已生成的 cps_link——**已发出去的短链永远要能用**,落地页只查 link 本身,不依赖 group/activity 是否还在(删了淘宝活动,其落地页图回退默认图)。
---
## 九、配置项(`.env`,见 `app/core/config.py`)
| 配置 | 说明 |
|---|---|
| `CPS_REDIRECT_BASE` | 落地页域名,如 `https://coupon.shaguabijia.com`。生成 `/c/{code}` 拼前缀;也是图/回调的绝对 URL 基址 |
| `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` | 美团联盟凭证(转链 + 对账)。缺则美团相关优雅降级 |
| `MT_CPS_DEFAULT_SID` | 美团默认渠道位(群没 sid 时兜底) |
| `WX_MP_APPID` / `WX_MP_SECRET` | **微信服务号**网页授权凭证(≠ `WECHAT_APP_ID` 那个 App 支付用的) |
| `WX_MP_OAUTH_ENABLED` | 微信授权总开关。默认 `false`;认证就绪后置 `true` 重启启用 |
---
## 十、代码地图
| 关注点 | 位置 |
|---|---|
| admin 端点 | `admin/routers/cps.py` |
| admin 业务逻辑(群/活动/订单/统计/群内用户) | `admin/repositories/cps.py`(`group_stats` / `group_click_timeseries` / `group_order_daily` / `group_wx_users` / `reconcile_orders`) |
| admin 出入参 | `admin/schemas/cps.py` |
| 落地页 + 微信授权 | `api/v1/cps_redirect.py` |
| 短码生成 / 记点击 / 点击聚合 | `repositories/cps_link.py`(`create_link` / `record_click` / `click_stats_by_group`) |
| 微信用户 upsert | `repositories/cps_wx_user.py` |
| 美团集成(转链/拉单) | `integrations/meituan.py`(`get_referral_link` / `query_order`) |
| 微信网页授权集成 | `integrations/wx_oauth.py`(`build_authorize_url` / `exchange_code` / `get_userinfo`) |
| 数据模型 | `app/models/cps_*.py` |
| admin 前端 | shaguabijia-admin-web `src/app/(main)/cps/`(列表页 `page.tsx` + 群详情 `groups/[id]/page.tsx`) |
---
## 十一、部署 / 运维
- 部署:`ssh ecs1``deploy server`(app-server + admin 后端)/ `deploy web`(admin-web 前端)。机制 = git reset 到 origin/main + 装依赖 + alembic 迁移 + 重启 + health 自检。
- 改 `.env`(如开微信开关、换服务号凭证)后:`systemctl restart shaguabijia-app-server shaguabijia-admin` 或重跑 `deploy server`
- 数据库迁移:CPS 相关迁移在 `alembic/versions/cps_*`(建表 `cps_tables`/`cps_link_tables`、多平台 `cps_v2_platforms`、活动图 `cps_activity_image`、微信用户 `cps_wx_user`)。
---
## 十二、排障速查
| 现象 | 原因 / 处理 |
|---|---|
| 微信授权报 **10005**(没有这些 scope 权限) | 账号不是已认证服务号,或「网页授权获取用户基本信息」接口未获得。确认账号类型 + 接口权限 |
| 微信授权报 **10003**(redirect_uri 域名不一致) | 服务号后台「网页授权域名」没填 `coupon.shaguabijia.com` 或没保存成功 |
| 换了服务号后授权失败 | 换 `.env``WX_MP_APPID/SECRET` + 在**新服务号**后台重配网页授权域名(校验文件可能是新的,要换 `MP_verify_*` 端点返回值) |
| 落地页在微信里不跳授权 | 开关 `WX_MP_OAUTH_ENABLED` 没开,或不是微信 UA(外部浏览器不跳,正常) |
| 群详情「群内微信用户」看不到人 | 已按"本群点击"算(`group_wx_users`)。确认该群 cps_click 有带 openid 的记录;美团/京东群昵称头像本就为空 |
| 统计页订单/佣金是「-」 | 淘宝/京东群无对账(正常);美团群需先点「刷新对账」拉单 |
| 成交额/佣金不更新 | 对账非实时,要 finance 手动「刷新对账」 |
| 账号验证 appid/账号类型 | 可用基础 token 调 `cgi-bin/account/getaccountbasicinfo` 查(account_type / 认证 / 主体) |
---
## 十三、已知边界 / 技术债(接手前必读)
1. **下单归因只到「群」级,做不到「人」级**:美团 `query_order` 返回的订单只带 `sid`(群级渠道位),不带 openid。要统计"某个微信用户下没下单/下单额",需把 sid 体系改成**用户级**(每个用户进落地页时用其 openid 实时转链),是较大改动,**未做**。当前微信侧只能统计到"用户级**领券/点击**",下单仍是群级。
2. **美团/京东群拿不到昵称头像**:无 userinfo 触发点(直接 302 跳转)。只有淘宝群(H5 落地页点复制)能补昵称头像。
3. **淘宝/京东无对账 API**:只能统计点击/复制,拿不到下单/佣金。
4. **落地页公网无鉴权**:群发场景,链接谁拿到都能点(刻意如此)。点击 UV 按 (ip, ua) 近似去重,非精确。
5. **对账非实时**:`cps_order` 是上次「刷新对账」的快照,不点不更新。
6. **激励奖励金额未统计**:美团订单里 `incentiveOrder` 标记了是否参与激励活动,但 `query_order` 不给激励奖励金额,需另接「奖励活动报表」API(未做)。
---
*本系统由本轮从零搭建:CPS 分发(群/活动/链接/对账/统计)+ 群详情可视化 + 微信网页授权用户级统计。后端在 shaguabijia-app-server,前端在 shaguabijia-admin-web。*
+44
View File
@@ -52,6 +52,7 @@ try:
except Exception: # noqa: BLE001
pass
import httpx # noqa: E402
from sqlalchemy import delete, func, select # noqa: E402
from sqlalchemy.dialects.postgresql import insert as pg_insert # noqa: E402
@@ -85,6 +86,13 @@ DEFAULT_CONCURRENCY = 12 # 并发城市数(实测 15 并发 402 占 3% 可退
STARTUP_STAGGER = 0.3 # 首批城市启动错峰间隔秒(削平瞬时峰值,实测能压低 402)
PRUNE_FAIL_RATIO_MAX = 0.05 # 失败城占比超此值则本轮跳过 prune(避免大面积抓取失败误删库)
# 头图大小/类型富集:每城抓完后,按 head_url 去重并发 HEAD 取 Content-Length / Content-Type,
# 写进 image_size / image_type。图片走美团图片 CDN(p*.meituan.net / img.meituan.net),直连可达、
# 与 CPS 网关 402 限流无关;任何失败 → None,不阻断入库。城市级已 12 并发,这里每城再开 8,
# 叠加峰值 ~96 个 HEAD,CDN 扛得住(实测单进程 50 并发稳)。
IMAGE_META_WORKERS = 8 # 每城 HEAD 并发数
IMAGE_META_TIMEOUT = 8.0 # 单图 HEAD 超时秒
SOURCES = [
{"code": "search_waimai", "label": "外卖·搜外卖", "kind": "search", "platform": 1, "keyword": "外卖"},
{"code": "search_meishi", "label": "外卖·搜美食", "kind": "search", "platform": 1, "keyword": "美食"},
@@ -256,6 +264,9 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
"sell_price_cents": price_cents,
"original_price_cents": _to_cents(cpd.get("originalPrice")),
"head_url": ((cpd.get("headUrl") or "").split("@")[0][:512] or None),
# 占位;由 _fill_image_meta 抓完本城后并发 HEAD 回填(键必须在,upsert 才会带上这两列)
"image_size": None,
"image_type": None,
"sale_volume": cpd.get("saleVolume"),
"sale_volume_num": _sale_volume_num(cpd.get("saleVolume")),
"commission_percent": comm_pct,
@@ -268,6 +279,38 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
})
def _head_image_meta(client: httpx.Client, url: str) -> tuple[int | None, str | None]:
"""HEAD 单张头图,取 (字节大小, MIME 类型);任何失败返回 (None, None),不阻断入库。"""
try:
r = client.head(url, timeout=IMAGE_META_TIMEOUT, follow_redirects=True)
if r.status_code >= 400:
return None, None
cl = r.headers.get("content-length")
size = int(cl) if cl and cl.isdigit() else None
ctype = (r.headers.get("content-type") or "").split(";")[0].strip()[:32] or None
return size, ctype
except Exception: # noqa: BLE001
return None, None
def _fill_image_meta(rows: list[dict]) -> None:
"""就地为本城 rows 填 image_size / image_type:按 head_url 去重后并发 HEAD,再回填每行。
trust_env=False 直连图片 CDN(绕本机代理,CDN 无需走 CPS 代理)"""
urls = {r["head_url"] for r in rows if r.get("head_url")}
if not urls:
return
meta: dict[str, tuple[int | None, str | None]] = {}
with httpx.Client(trust_env=False, timeout=IMAGE_META_TIMEOUT) as client:
with ThreadPoolExecutor(max_workers=IMAGE_META_WORKERS) as pool:
futs = {pool.submit(_head_image_meta, client, u): u for u in urls}
for f in as_completed(futs):
meta[futs[f]] = f.result()
for r in rows:
m = meta.get(r.get("head_url"))
if m:
r["image_size"], r["image_type"] = m
def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) -> list[dict]:
"""抓单个城市的 3 路券并解析。worker 线程内执行(只抓取+解析,不碰 DB)。
@@ -286,6 +329,7 @@ def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) ->
p = _parse_item(it, src, cid)
if p:
parsed.append(p)
_fill_image_meta(parsed) # 抓完本城 → 并发 HEAD 回填头图 image_size/image_type
return parsed
+5 -3
View File
@@ -49,7 +49,7 @@ def _seed_user_with_data(phone: str) -> int:
db.add(WithdrawOrder(
user_id=uid, out_bill_no=f"test{uid}billno0001", amount_cents=100, status="success"
))
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="new"))
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="pending"))
db.commit()
return uid
finally:
@@ -181,9 +181,11 @@ def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -
def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
_seed_user_with_data("13800000005")
r = admin_client.get("/admin/api/feedbacks", params={"status": "new"}, headers=_auth(admin_token))
r = admin_client.get(
"/admin/api/feedbacks", params={"status": "pending"}, headers=_auth(admin_token)
)
assert r.status_code == 200
assert all(f["status"] == "new" for f in r.json()["items"])
assert all(f["status"] == "pending" for f in r.json()["items"])
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
+81 -6
View File
@@ -74,7 +74,7 @@ def _seed_feedback(phone: str) -> int:
uid = _seed_user(phone)
db = SessionLocal()
try:
fb = Feedback(user_id=uid, content="测试", contact="wx", status="new")
fb = Feedback(user_id=uid, content="测试", contact="wx", status="pending")
db.add(fb)
db.commit()
return fb.id
@@ -280,15 +280,90 @@ def test_super_admin_can_grant_coins(admin_client: TestClient, super_token: str)
assert r.status_code == 200
# ===== 反馈处理 =====
# ===== 反馈审核 =====
def test_handle_feedback(admin_client: TestClient, operator_token: str) -> None:
def test_approve_feedback_grants_coins_and_audit(
admin_client: TestClient, operator_token: str
) -> None:
fid = _seed_feedback("13900000006")
r = admin_client.post(f"/admin/api/feedbacks/{fid}/handle", headers=_auth(operator_token))
assert r.status_code == 200
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 800, "note": "真实详细,已采纳"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "adopted"
assert r.json()["reward_coins"] == 800
db = SessionLocal()
try:
assert db.get(Feedback, fid).status == "handled"
fb = db.get(Feedback, fid)
assert fb is not None
assert fb.status == "adopted"
assert fb.reward_coins == 800
assert fb.review_note == "真实详细,已采纳"
assert fb.reviewed_by_admin_id is not None
assert fb.reviewed_at is not None
assert db.get(CoinAccount, fb.user_id).coin_balance == 800
txns = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == fb.user_id,
CoinTransaction.biz_type == "feedback_reward",
CoinTransaction.ref_id == str(fid),
)
).scalars().all()
assert len(txns) == 1 and txns[0].amount == 800
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "feedback.approve",
AdminAuditLog.target_id == str(fid),
)
).scalars().all()
assert len(logs) == 1 and logs[0].detail["reward_coins"] == 800
finally:
db.close()
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 100},
headers=_auth(operator_token),
)
assert r.status_code == 400
def test_reject_feedback_records_reason_and_no_coin(
admin_client: TestClient, operator_token: str
) -> None:
fid = _seed_feedback("13900000013")
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/reject",
json={"reason": "暂未提供可复现信息", "note": "信息不足"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "rejected"
assert r.json()["reject_reason"] == "暂未提供可复现信息"
db = SessionLocal()
try:
fb = db.get(Feedback, fid)
assert fb is not None
assert fb.status == "rejected"
assert fb.reject_reason == "暂未提供可复现信息"
assert fb.review_note == "信息不足"
txns = db.execute(
select(CoinTransaction).where(
CoinTransaction.user_id == fb.user_id,
CoinTransaction.biz_type == "feedback_reward",
CoinTransaction.ref_id == str(fid),
)
).scalars().all()
assert txns == []
logs = db.execute(
select(AdminAuditLog).where(
AdminAuditLog.action == "feedback.reject",
AdminAuditLog.target_id == str(fid),
)
).scalars().all()
assert len(logs) == 1 and logs[0].detail["reason"] == "暂未提供可复现信息"
finally:
db.close()
+77
View File
@@ -0,0 +1,77 @@
"""用户反馈接口测试。"""
from __future__ import annotations
from app.db.session import SessionLocal
from app.models.feedback import Feedback
from app.repositories import user as user_repo
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def test_feedback_records_empty_returns_200(client) -> None:
token = _login(client, "13622000001")
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
assert r.json() == {
"records": [],
"counts": {"all": 0, "pending": 0, "adopted": 0, "rejected": 0},
}
def test_feedback_config_returns_default(client) -> None:
token = _login(client, "13622000002")
r = client.get("/api/v1/feedback/config", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
body = r.json()
assert body["enabled"] is True
assert body["group_name"] == "傻瓜比价官方群"
def test_feedback_records_maps_existing_statuses(client) -> None:
phone = "13622000003"
token = _login(client, phone)
db = SessionLocal()
try:
user = user_repo.get_user_by_phone(db, phone)
assert user is not None
db.add(Feedback(user_id=user.id, content="待处理反馈", contact="", status="pending"))
db.add(
Feedback(
user_id=user.id,
content="已采纳反馈",
contact="",
status="adopted",
reward_coins=800,
)
)
db.add(
Feedback(
user_id=user.id,
content="未采纳反馈",
contact="",
status="rejected",
reject_reason="暂未提供可复现信息",
)
)
db.commit()
finally:
db.close()
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
body = r.json()
assert body["counts"] == {"all": 3, "pending": 1, "adopted": 1, "rejected": 1}
by_status = {item["status"]: item for item in body["records"]}
assert by_status["adopted"]["reward_coins"] == 800
assert by_status["rejected"]["reject_reason"] == "暂未提供可复现信息"