Compare commits

..

7 Commits

Author SHA1 Message Date
OuYingJun1024 b01b251f83 Merge remote-tracking branch 'origin/main' into feat/feed-ad-realcoin-orb 2026-06-17 22:39:58 +08:00
OuYingJun1024 2472cfc44a chore(alembic): cps_v2_platforms 跨方言(SQLite batch)兼容修复
随本分支一并提交的工作区既有改动(非本次信息流发奖功能):
batch_alter_table + 按方言区分 server_default,让迁移在 SQLite 本地也能跑。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:28:05 +08:00
OuYingJun1024 c958546e07 feat(ad): 信息流广告按条 segments 精确发奖 + 只读 preview 接口
- compute_feed_reward/preview_feed_reward: 逐条 (eCPM,秒) 跨条进位、每满 10s 一份、
  每份用所在段 eCPM × LT,封顶 12 份/场;发奖与预览同一口径
- grant_feed_reward 改收 segments(旧 ecpm/duration 单段自动回退,兼容旧客户端)
- 新增 POST /api/v1/ad/feed-reward/preview(只读不入账,给比价金币小球实时显示真实即将到账额)
- schemas 加 FeedSegmentIn/FeedPreviewIn/FeedPreviewOut;docs/api 补 preview 文档 + 更新 feed-reward
- tests: 按条结算/12份封顶/aborted/preview==grant 等 6 个用例

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 22:27:22 +08:00
OuYingJun1024 a4c0fa0de6 Merge branch 'feat/ops-config-ad-flags-reconcile' 2026-06-17 21:34:32 +08:00
OuYingJun1024 a173e07c5f Merge remote-tracking branch 'origin/main' into feat/ops-config-ad-flags-reconcile 2026-06-17 19:44:24 +08:00
OuYingJun1024 d3e3516ce9 docs(ad): 对齐 S2S 多 m-key API + 记录穿山甲打线上超时风险
- ad-pangle-callback / integrations/pangle: 文档改为 verify_callback_sign_any
  + 命名变量 PANGLE_REWARD_SECRET_TEST/_TEST_DEDICATED/_PROD(多激励位共用同一回调 URL)
- 看广告赚金币上线清单: 记录 2026-06-16 实测穿山甲打线上 errorCode=50002 超时
  (头号上线阻塞,m-key 配对也救不了)+ 生产 .env 命名变量配置 + AD_REWARD_TEST_GRANT 红线

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:59:55 +08:00
OuYingJun1024 599432cd05 feat(admin/ad): 提现自动对账后台开关 + 客户端广告 flags 端点 + 穿山甲多 m-key 验签
- 自动对账接入 app_config 运行时配置(新增 bool 配置类型):env 部署总闸 + DB 运营日常开关
  双层;worker 每轮读 DB 即时生效,健康检查改报「env AND DB」实际生效态
- 新增 GET /api/v1/platform/flags(不鉴权)下发 comparing_ad_enabled 远程 kill-switch,
  客户端拉取后缓存;空库回退默认 True
- 穿山甲发奖回调支持多激励位 m-key(三命名项 PANGLE_REWARD_SECRET_TEST/_DEDICATED/_PROD
  + 旧逗号分隔合并去重),verify_callback_sign_any 逐个验签任一通过即受理;向后兼容旧单 key
- 补 test_platform / bool config 用例;app_config 文档同步

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:28:10 +08:00
79 changed files with 512 additions and 3767 deletions
-34
View File
@@ -1,34 +0,0 @@
"""add feed_scene to ad_feed_reward_record
信息流广告点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。
比价与领券共用同一个信息流代码位(AdConfig.feedCodeId),slot_id / our_code_id 都分不出来,
只能由客户端在各调用点(PriceBotService / CouponForegroundService / WelfareScreen)显式打标。
可空:历史记录与未升级客户端为 NULL = 未分类。
Revision ID: add_feed_scene
Revises: drop_force_onboarding
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 = "add_feed_scene"
down_revision: Union[str, Sequence[str], None] = "drop_force_onboarding"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"ad_feed_reward_record",
sa.Column("feed_scene", sa.String(length=16), nullable=True),
)
def downgrade() -> None:
op.drop_column("ad_feed_reward_record", "feed_scene")
@@ -1,26 +0,0 @@
"""merge add_feed_scene and launch_confirm_sample heads
Revision ID: ceb286289426
Revises: add_feed_scene, launch_confirm_sample_table
Create Date: 2026-06-20 23:52:57.853400
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ceb286289426'
down_revision: Union[str, Sequence[str], None] = ('add_feed_scene', 'launch_confirm_sample_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
+36 -22
View File
@@ -28,34 +28,48 @@ def upgrade() -> None:
op.add_column("cps_activity", sa.Column("payload", sa.Text(), nullable=True))
# 群:多平台(现有群都是美团,server_default 回填)+ sid 可空
op.add_column(
"cps_group",
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=sa.text("'[\"meituan\"]'::jsonb"),
),
# server_default 按方言区分:Postgres 用 ::jsonb 转换;SQLite(本地)用纯 JSON 文本字面量
#(SQLite 不认 ::jsonb,会报 unrecognized token: ":")。
bind = op.get_bind()
platforms_default = (
sa.text("'[\"meituan\"]'::jsonb")
if bind.dialect.name == "postgresql"
else sa.text("'[\"meituan\"]'")
)
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=True)
# alter_column 在 SQLite 原生不支持 ALTER COLUMN,必须走 batch(重建表);Postgres 下 batch 直接 ALTER。
# 用 batch_alter_table 跨方言通吃(render_as_batch 只管 autogenerate 渲染,不会自动包裹手写的 alter_column)。
with op.batch_alter_table("cps_group") as batch_op:
batch_op.add_column(
sa.Column(
"platforms",
sa.JSON().with_variant(postgresql.JSONB(), "postgresql"),
nullable=False,
server_default=platforms_default,
)
)
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
# link:sid 可空 + target 加长
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=True)
op.alter_column("cps_link", "target_url", existing_type=sa.String(1024), type_=sa.String(2048))
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
batch_op.alter_column("target_url", existing_type=sa.String(1024), type_=sa.String(2048))
# click:sid 可空 + 事件类型
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=True)
op.add_column(
"cps_click",
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit"),
)
with op.batch_alter_table("cps_click") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=True)
batch_op.add_column(
sa.Column("event_type", sa.String(16), nullable=False, server_default="visit")
)
def downgrade() -> None:
op.drop_column("cps_click", "event_type")
op.alter_column("cps_click", "sid", existing_type=sa.String(64), nullable=False)
op.alter_column("cps_link", "target_url", existing_type=sa.String(2048), type_=sa.String(1024))
op.alter_column("cps_link", "sid", existing_type=sa.String(64), nullable=False)
op.drop_column("cps_group", "platforms")
op.alter_column("cps_group", "sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_click") as batch_op:
batch_op.drop_column("event_type")
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_link") as batch_op:
batch_op.alter_column("target_url", existing_type=sa.String(2048), type_=sa.String(1024))
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
with op.batch_alter_table("cps_group") as batch_op:
batch_op.alter_column("sid", existing_type=sa.String(64), nullable=False)
batch_op.drop_column("platforms")
op.drop_column("cps_activity", "payload")
-48
View File
@@ -1,48 +0,0 @@
"""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")
@@ -1,78 +0,0 @@
"""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")
@@ -1,53 +0,0 @@
"""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")
-4
View File
@@ -14,7 +14,6 @@ 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
@@ -25,7 +24,6 @@ from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router
from app.admin.routers.onboarding import router as onboarding_router
from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router
from app.admin.routers.price_report import router as price_report_router
@@ -91,12 +89,10 @@ admin_app.include_router(wallet_router)
admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(feedback_qr_router)
admin_app.include_router(admins_router)
admin_app.include_router(audit_router)
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)
+2 -92
View File
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation
from uuid import uuid4
from sqlalchemy import desc, func, select
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.admin.repositories.queries import _as_utc, offset_paginate
@@ -19,7 +19,6 @@ 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"}
@@ -395,10 +394,7 @@ def group_click_timeseries(
agg: dict = {} # bucket_key -> {vp, vu(set), cp, cu(set)}
for clicked_at, event_type, ip, ua in rows:
# naive(本地 SQLite 读回无 tz)按 UTC 兜底,再转北京——与 queries._as_utc 约定一致,
# 否则 astimezone 会把 UTC 瞬刻当系统本地时区解释,分桶整体偏移。
ca = clicked_at if clicked_at.tzinfo else clicked_at.replace(tzinfo=timezone.utc)
bj = ca.astimezone(_BJ_TZ)
bj = clicked_at.astimezone(_BJ_TZ)
key = bj.strftime("%Y-%m-%d") if granularity == "day" else bj.hour
b = agg.setdefault(key, {"vp": 0, "vu": set(), "cp": 0, "cu": set()})
uid = f"{ip or ''}|{ua or ''}"
@@ -429,89 +425,3 @@ def group_click_timeseries(
for h in range(24):
points.append(_point(f"{h:02d}:00", agg.get(h)))
return points
def group_order_daily(
db: Session, *, sid: str, start: datetime, end: datetime,
) -> dict[str, dict]:
"""美团群订单按天(北京时区,key=YYYY-MM-DD)聚合。口径与 group_stats._order_agg 一致:
取消(4)/风控(5)不计有效;结算只数 status=6。退款佣金取当天全部单的 refund_profit_cents 之和。
返回 {date: {order_count, canceled_count, gmv_cents, est_commission_cents,
refund_profit_cents, settled_commission_cents}};无单的天不在 dict 里(端点侧补零)。
"""
rows = (
db.execute(
select(CpsOrder)
.where(CpsOrder.sid == sid)
.where(CpsOrder.pay_time >= _as_utc(start))
.where(CpsOrder.pay_time <= _as_utc(end))
)
.scalars()
.all()
)
by_day: dict[str, list] = {}
for o in rows:
if not o.pay_time:
continue
pt = o.pay_time if o.pay_time.tzinfo else o.pay_time.replace(tzinfo=timezone.utc)
d = pt.astimezone(_BJ_TZ).strftime("%Y-%m-%d")
by_day.setdefault(d, []).append(o)
out: dict[str, dict] = {}
for d, items in by_day.items():
valid = [o for o in items if o.mt_status not in _INVALID_STATUS]
settled = [o for o in items if o.mt_status == _SETTLED_STATUS]
canceled = [o for o in items if o.mt_status in _INVALID_STATUS]
out[d] = {
"order_count": len(valid),
"canceled_count": len(canceled),
"gmv_cents": sum(o.pay_price_cents or 0 for o in valid),
"est_commission_cents": sum(o.commission_cents or 0 for o in valid),
"refund_profit_cents": sum(o.refund_profit_cents or 0 for o in items),
"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
]
-29
View File
@@ -56,35 +56,6 @@ 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,
+4 -245
View File
@@ -11,9 +11,6 @@ from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, desc, func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog
from app.models.comparison import ComparisonRecord
from app.models.feedback import Feedback
@@ -22,16 +19,6 @@ from app.models.price_report import PriceReport
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
# 信息流点位场景 → 金币记录「赚取途径」展示名;NULL/未知 = 历史未分类。
_FEED_SCENE_LABEL = {
"comparison": "比价信息流",
"coupon": "领券信息流",
"welfare": "福利信息流",
}
def cursor_paginate(
db: Session, stmt: Select, id_col, *, limit: int, cursor: int | None
@@ -344,38 +331,6 @@ def _as_utc(value: datetime) -> datetime:
return value.astimezone(timezone.utc)
def withdraw_list_enrichment(db: Session, user_ids: list[int]) -> dict[int, dict]:
"""批量富化提现单列表:按本页 user_id 取 手机号/昵称 + 各自累计成功提现金额(分)。
两条聚合查询搞定(避免逐行 N+1)累计口径与 get_user_overview withdraw_success_cents
完全一致:SUM(amount_cents) WHERE status='success'返回 {user_id: {phone, nickname,
cumulative_success_cents}}; user_id 查不到用户则不在 dict (路由侧回退到默认值)
"""
uniq = list(set(user_ids))
if not uniq:
return {}
success_rows = db.execute(
select(
WithdrawOrder.user_id,
func.coalesce(func.sum(WithdrawOrder.amount_cents), 0),
)
.where(WithdrawOrder.user_id.in_(uniq), WithdrawOrder.status == "success")
.group_by(WithdrawOrder.user_id)
).all()
success_map = {uid: int(total) for uid, total in success_rows}
users = db.execute(
select(User.id, User.phone, User.nickname).where(User.id.in_(uniq))
).all()
return {
uid: {
"phone": phone,
"nickname": nickname,
"cumulative_success_cents": success_map.get(uid, 0),
}
for uid, phone, nickname in users
}
def list_feedbacks(
db: Session,
*,
@@ -493,19 +448,15 @@ def withdraw_risk_flags(
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
flags.append("新注册用户")
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected")
failed_n = sum(1 for item in recent_withdraws if item.status == "failed")
if rejected_n:
flags.append(f"提现拒绝{rejected_n}")
if failed_n:
flags.append(f"提现失败{failed_n}")
failed_or_rejected = sum(1 for item in recent_withdraws if item.status in {"failed", "rejected"})
if failed_or_rejected:
flags.append(f"历史异常提现{failed_or_rejected}")
recent_reviewing = sum(1 for item in recent_withdraws if item.status == "reviewing")
if recent_reviewing >= 3:
flags.append(f"待审核提现偏多:{recent_reviewing}")
if cash_balance_cents < 0:
flags.append("现金余额为负")
score = min(100, len(flags) * 20 + (rejected_n + failed_n) * 10)
score = min(100, len(flags) * 20 + failed_or_rejected * 10)
return flags, score
@@ -595,198 +546,6 @@ def get_user_overview(db: Session, user_id: int) -> dict | None:
}
def _window_conds(col, date_from: datetime | None, date_to: datetime | None) -> list:
"""把 [date_from, date_to] 转成对 col(created_at)的过滤条件;都为 None = 全量(注册至今)。"""
conds = []
if date_from is not None:
conds.append(col >= _as_utc_naive(date_from))
if date_to is not None:
conds.append(col <= _as_utc_naive(date_to))
return conds
def _coins_to_cents(coins: int) -> int:
"""累计金币折算成可提现现金(分)。COIN_PER_YUAN=10000 → 100 金币 = 1 分。"""
return round(coins / (rewards.COIN_PER_YUAN / 100))
def user_reward_stats(
db: Session,
user_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
) -> dict:
"""提现详情「用户统计区」10 项。窗口作用于除「现金余额」外的所有项(余额是当前快照)。
口径:激励视频/信息流只统计 granted;数量视频按条数信息流按份数(unit_count 累加);
平均 eCPM 用原始分值(/千次)按记录取算术平均;提现= 该来源累计金币折现
传统任务 = 窗口内正向金币中,排除广告(reward_video/feed_ad_reward)与人工调整后的折现
"""
wd_success = db.execute(
select(func.coalesce(func.sum(WithdrawOrder.amount_cents), 0)).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status == "success",
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
)
).scalar_one()
wd_total = db.execute(
select(func.count(WithdrawOrder.id)).where(
WithdrawOrder.user_id == user_id,
*_window_conds(WithdrawOrder.created_at, date_from, date_to),
)
).scalar_one()
acc = db.get(CoinAccount, user_id) # 现金余额:当前快照,不随窗口
cash_balance = acc.cash_balance_cents if acc else 0
rv = list(db.execute(
select(AdRewardRecord).where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.reward_scene == "reward_video",
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
)
).scalars())
rv_ecpms = [rewards.parse_ecpm_fen(r.ecpm_raw) for r in rv if r.ecpm_raw]
rv_coins = sum(r.coin for r in rv)
feed = list(db.execute(
select(AdFeedRewardRecord).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
)
).scalars())
feed_ecpms = [rewards.parse_ecpm_fen(f.ecpm_raw) for f in feed if f.ecpm_raw]
feed_coins = sum(f.coin for f in feed)
trad_coins = db.execute(
select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
CoinTransaction.user_id == user_id,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.not_in(_NON_TASK_BIZ_TYPES),
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
).scalar_one()
return {
"withdraw_success_cents": int(wd_success),
"cash_balance_cents": int(cash_balance),
"withdraw_total": int(wd_total),
"traditional_task_cash_cents": _coins_to_cents(int(trad_coins)),
"reward_video_count": len(rv),
"reward_video_avg_ecpm": round(sum(rv_ecpms) / len(rv_ecpms), 2) if rv_ecpms else 0.0,
"reward_video_cash_cents": _coins_to_cents(rv_coins),
"feed_count": int(sum(f.unit_count for f in feed)),
"feed_avg_ecpm": round(sum(feed_ecpms) / len(feed_ecpms), 2) if feed_ecpms else 0.0,
"feed_cash_cents": _coins_to_cents(feed_coins),
}
def user_coin_records(
db: Session,
user_id: int,
*,
date_from: datetime | None = None,
date_to: datetime | None = None,
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[dict], int | None, int]:
"""金币发放记录(提现详情底部表),按时间倒序、offset 游标分页 + 总数。
合并三类来源(Phase1 信息流不分比价/领券):
- 激励视频 / 签到膨胀 = ad_reward_record(granted)
- 信息流广告 = ad_feed_reward_record(granted)
- 签到 = coin_transaction(biz_type='signin')
每来源各取 offset+limit+1 (倒序)再归并切片,避免 union 全量拉取
"""
offset = max(cursor or 0, 0)
fetch = offset + limit + 1
rows: list[dict] = []
for rec in db.execute(
select(AdRewardRecord)
.where(
AdRewardRecord.user_id == user_id,
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
)
.order_by(AdRewardRecord.created_at.desc())
.limit(fetch)
).scalars():
is_video = rec.reward_scene == "reward_video"
rows.append({
"source": rec.reward_scene,
"source_label": "激励视频" if is_video else "签到膨胀",
"created_at": rec.created_at,
"ecpm": rec.ecpm_raw,
"coin": rec.coin,
})
for rec in db.execute(
select(AdFeedRewardRecord)
.where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
)
.order_by(AdFeedRewardRecord.created_at.desc())
.limit(fetch)
).scalars():
rows.append({
"source": "feed",
"source_label": _FEED_SCENE_LABEL.get(rec.feed_scene, "信息流广告"),
"created_at": rec.created_at,
"ecpm": rec.ecpm_raw,
"coin": rec.coin,
})
for rec in db.execute(
select(CoinTransaction)
.where(
CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
.order_by(CoinTransaction.created_at.desc())
.limit(fetch)
).scalars():
rows.append({
"source": "signin",
"source_label": "签到",
"created_at": rec.created_at,
"ecpm": None,
"coin": rec.amount,
})
rows.sort(key=lambda r: r["created_at"], reverse=True)
has_more = len(rows) > offset + limit
# 总数 = 三源在窗口内 granted 计数之和(供前端页码分页渲染页码/共 N 条)
def _count(model, *conds) -> int:
return int(db.execute(select(func.count()).select_from(model).where(*conds)).scalar_one())
total = (
_count(
AdRewardRecord, AdRewardRecord.user_id == user_id,
AdRewardRecord.status == "granted",
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
)
+ _count(
AdFeedRewardRecord, AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
)
+ _count(
CoinTransaction, CoinTransaction.user_id == user_id,
CoinTransaction.biz_type == "signin",
*_window_conds(CoinTransaction.created_at, date_from, date_to),
)
)
return rows[offset:offset + limit], (offset + limit if has_more else None), total
def list_price_reports(
db: Session,
*,
+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.in_(("pending", "new")))},
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
}
-45
View File
@@ -1,45 +0,0 @@
"""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))
+1 -1
View File
@@ -30,7 +30,7 @@ def list_comparison_records(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
phone: Annotated[str | None, Query(description="手机号前缀")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed|cancelled)$")] = None,
status: Annotated[str | None, Query(pattern="^(success|failed)$")] = None,
business_type: Annotated[str | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
-63
View File
@@ -433,66 +433,3 @@ def group_timeseries(
"granularity": granularity,
"points": points,
}
@router.get("/groups/{group_id}/daily", summary="群每天明细大表格(点击+订单按天合并)")
def group_daily(
group_id: int,
db: AdminDb,
days: Annotated[int, Query(ge=1, le=90)] = 7,
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
now_bj = datetime.now(bj)
start = (now_bj - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
end = now_bj
# 点击侧:连续 N 天补零,顺序 = start..end(与下面 while 同序,按 index 对齐避免跨年 MM-DD 碰撞)
click_points = cps_repo.group_click_timeseries(
db, group_id=group_id, granularity="day", start=start, end=end
)
# 订单侧:仅美团群(有 sid)有对账;淘宝/京东订单列返回 None(前端显示 "-")
is_meituan = "meituan" in (group.platforms or []) and bool(group.sid)
order_daily = (
cps_repo.group_order_daily(db, sid=group.sid, start=start, end=end) if is_meituan else {}
)
_ORDER_KEYS = (
"order_count", "canceled_count", "gmv_cents",
"est_commission_cents", "refund_profit_cents", "settled_commission_cents",
)
rows = []
cur = start.astimezone(bj).date()
last = end.astimezone(bj).date()
idx = 0
while cur <= last:
cp = click_points[idx] if idx < len(click_points) else None
row = {
"date": cur.strftime("%m-%d"),
"click_pv": cp["click_pv"] if cp else 0,
"click_uv": cp["click_uv"] if cp else 0,
"copy_pv": cp["copy_pv"] if cp else 0,
}
if is_meituan:
od = order_daily.get(cur.strftime("%Y-%m-%d"))
row.update({k: (od[k] if od else 0) for k in _ORDER_KEYS})
else:
row.update({k: None for k in _ORDER_KEYS})
rows.append(row)
idx += 1
cur += timedelta(days=1)
return {
"group_id": group.id,
"group_name": group.name,
"is_meituan": is_meituan,
"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)}
+8 -100
View File
@@ -1,4 +1,4 @@
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
from __future__ import annotations
from datetime import datetime
@@ -10,10 +10,9 @@ 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 FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
from app.admin.schemas.feedback import FeedbackOut
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",
@@ -22,11 +21,6 @@ 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,
@@ -62,104 +56,18 @@ def list_feedbacks(
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
def handle_feedback(
feedback_id: int,
_admin: Annotated[AdminUser, Depends(require_role("operator"))],
request: Request,
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.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="意见反馈被采纳",
)
mutations.update_feedback_status(db, fb, status="handled", commit=False)
write_audit(
db,
admin,
action="feedback.approve",
target_type="feedback",
target_id=feedback_id,
detail={
"before": before,
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
},
ip=get_client_ip(request),
commit=False,
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.commit()
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)
return OkResponse()
-92
View File
@@ -1,92 +0,0 @@
"""admin 反馈页二维码卡配置:读 / 改文案开关 / 上传二维码 / 删二维码(带审计)。
整张卡存通用 app_config ( app/repositories/feedback_qr.py),App 反馈页拉
GET /api/v1/feedback/config 同步权限:operator 可改(运营维护),super 恒可;
读为只读(任意已登录 admin)
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
from app.admin.schemas.feedback_qr import FeedbackQrOut, FeedbackQrUpdate
from app.core import media
from app.models.admin import AdminUser
from app.repositories import feedback_qr
router = APIRouter(
prefix="/admin/api/feedback-config",
tags=["admin-feedback-config"],
dependencies=[Depends(get_current_admin)],
)
@router.get("", response_model=FeedbackQrOut, summary="反馈页二维码卡配置")
def get_config(db: AdminDb) -> FeedbackQrOut:
return FeedbackQrOut(**feedback_qr.get_config(db))
@router.patch("", response_model=FeedbackQrOut, summary="改反馈页文案/开关(带审计)")
def update_config(
body: FeedbackQrUpdate,
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackQrOut:
before, after = feedback_qr.update_config(
db,
enabled=body.enabled,
title=body.title,
group_name=body.group_name,
subtitle=body.subtitle,
admin_id=admin.id,
commit=False,
)
write_audit(
db, admin, action="feedback_qr.update", target_type="feedback_qr", target_id=None,
detail={"before": before, "after": after}, ip=get_client_ip(request), commit=False,
)
db.commit()
return FeedbackQrOut(**after)
@router.post("/image", response_model=FeedbackQrOut, summary="上传反馈页二维码(带审计)")
async def upload_image(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
file: UploadFile = File(...),
) -> FeedbackQrOut:
data = await file.read()
try:
url = media.save_feedback_qr(data)
except media.MediaError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
before, after = feedback_qr.set_image(db, url, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="feedback_qr.set_image", target_type="feedback_qr", target_id=None,
detail={"before": before.get("image_url"), "after": url},
ip=get_client_ip(request), commit=False,
)
db.commit()
media.delete_feedback_qr(before.get("image_url")) # 提交成功后再删旧图,避免新图未落库就丢旧图
return FeedbackQrOut(**after)
@router.delete("/image", response_model=FeedbackQrOut, summary="移除反馈页二维码(带审计)")
def delete_image(
request: Request,
admin: Annotated[AdminUser, Depends(require_role("operator"))],
db: AdminDb,
) -> FeedbackQrOut:
before, after = feedback_qr.set_image(db, None, admin_id=admin.id, commit=False)
write_audit(
db, admin, action="feedback_qr.delete_image", target_type="feedback_qr", target_id=None,
detail={"before": before.get("image_url")}, ip=get_client_ip(request), commit=False,
)
db.commit()
media.delete_feedback_qr(before.get("image_url"))
return FeedbackQrOut(**after)
-42
View File
@@ -17,8 +17,6 @@ from app.admin.schemas.user import (
GrantCoinsRequest,
SetDebugTraceRequest,
SetUserStatusRequest,
UserCoinRecord,
UserRewardStats,
)
from app.models.admin import AdminUser
from app.repositories import user as user_repo
@@ -68,46 +66,6 @@ def get_user(user_id: int, db: AdminDb) -> AdminUserOverview:
return AdminUserOverview.model_validate(overview)
@router.get(
"/{user_id}/reward-stats",
response_model=UserRewardStats,
summary="用户提现/看广告统计(提现详情用,按时间窗口)",
)
def get_user_reward_stats(
user_id: int,
db: AdminDb,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
) -> UserRewardStats:
"""提现详情抽屉「用户统计区」。date_from/date_to 都不传 = 注册至今(全量)。"""
if user_repo.get_user_by_id(db, user_id) is None:
raise HTTPException(status_code=404, detail="用户不存在")
return UserRewardStats(
**queries.user_reward_stats(db, user_id, date_from=date_from, date_to=date_to)
)
@router.get(
"/{user_id}/coin-records",
response_model=CursorPage[UserCoinRecord],
summary="用户金币发放记录(提现详情底部表,按时间窗口分页)",
)
def get_user_coin_records(
user_id: int,
db: AdminDb,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[UserCoinRecord]:
items, next_cursor, total = queries.user_coin_records(
db, user_id, date_from=date_from, date_to=date_to, limit=limit, cursor=cursor,
)
return CursorPage(
items=[UserCoinRecord(**r) for r in items], next_cursor=next_cursor, total=total,
)
@router.post("/{user_id}/status", response_model=OkResponse, summary="封禁/解封用户")
def set_user_status(
user_id: int,
+7 -13
View File
@@ -25,7 +25,6 @@ from app.admin.schemas.wallet import (
WithdrawBulkItemResult,
WithdrawDetailOut,
WithdrawLedgerCheckOut,
WithdrawListItemOut,
WithdrawOrderOut,
WithdrawRejectRequest,
WithdrawSummaryOut,
@@ -45,7 +44,7 @@ router = APIRouter(
)
@router.get("", response_model=CursorPage[WithdrawListItemOut], summary="提现单列表")
@router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表")
def list_withdraws(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
@@ -64,7 +63,7 @@ def list_withdraws(
] = None,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[WithdrawListItemOut]:
) -> CursorPage[WithdrawOrderOut]:
items, next_cursor, total = queries.list_all_withdraw_orders(
db,
user_id=user_id,
@@ -79,16 +78,11 @@ def list_withdraws(
limit=limit,
cursor=cursor,
)
# 联表带出手机号/昵称 + 累计成功提现(本页 user_id 批量富化,2 条聚合查询,无 N+1)。
enrichment = queries.withdraw_list_enrichment(db, [o.user_id for o in items])
_empty = {"phone": None, "nickname": None, "cumulative_success_cents": 0}
out_items = [
WithdrawListItemOut.model_validate(o).model_copy(
update=enrichment.get(o.user_id, _empty)
)
for o in items
]
return CursorPage(items=out_items, next_cursor=next_cursor, total=total)
return CursorPage(
items=[WithdrawOrderOut.model_validate(o) for o in items],
next_cursor=next_cursor,
total=total,
)
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
-30
View File
@@ -1,30 +0,0 @@
"""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
+1 -22
View File
@@ -3,9 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
from pydantic import BaseModel, ConfigDict
class FeedbackOut(BaseModel):
@@ -17,23 +15,4 @@ 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="运营内部审核备注")
-22
View File
@@ -1,22 +0,0 @@
"""admin 反馈页二维码卡配置 schemas。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class FeedbackQrOut(BaseModel):
enabled: bool
image_url: str | None = None
title: str
group_name: str
subtitle: str
updated_at: str | None = None
class FeedbackQrUpdate(BaseModel):
"""改文案/开关(均可选,只改传了的字段;图片走单独的上传/删除接口)。"""
enabled: bool | None = None
title: str | None = Field(default=None, max_length=40)
group_name: str | None = Field(default=None, max_length=40)
subtitle: str | None = Field(default=None, max_length=60)
-30
View File
@@ -37,36 +37,6 @@ class AdminUserOverview(BaseModel):
feedback_total: int
class UserRewardStats(BaseModel):
"""提现详情抽屉「用户统计区」:按时间窗口算的提现 + 看广告行为统计。
窗口由 date_from/date_to 决定(都不传 = 注册至今 = 全量);现金余额除外它是当前快照
*_cash_cents是把该来源累计发放金币折算成可提现现金():100 金币 = 1 (COIN_PER_YUAN=10000)
eCPM 单位沿用穿山甲原值/千次
"""
withdraw_success_cents: int # 累计提现(窗口内 success 金额)
cash_balance_cents: int # 现金余额(当前快照,不随窗口)
withdraw_total: int # 提现总次数(窗口内全部状态)
traditional_task_cash_cents: int # 传统任务提现(非广告非人工的金币折现)
reward_video_count: int # 累计激励视频数(granted 条数)
reward_video_avg_ecpm: float # 平均激励视频 eCPM(分/千次)
reward_video_cash_cents: int # 激励视频提现(金币折现)
feed_count: int # 累计信息流广告数(granted 份数,unit_count 累加)
feed_avg_ecpm: float # 平均信息流广告 eCPM(分/千次)
feed_cash_cents: int # 信息流广告提现(金币折现)
class UserCoinRecord(BaseModel):
"""金币发放记录(提现详情底部表)。source 见 source_label;非广告来源 ecpm 为 None。"""
source: str # reward_video / signin_boost / feed / signin
source_label: str # 激励视频 / 签到膨胀 / 信息流广告 / 签到
created_at: datetime
ecpm: str | None = None # 原始 eCPM(分/千次),非广告为 None
coin: int # 发放金币数
def _strip_reason(v: str) -> str:
# min_length=1 放过纯空白(" "),trim 后再校验非空,避免审计记到空原因
if not v.strip():
-11
View File
@@ -50,17 +50,6 @@ class WithdrawOrderOut(BaseModel):
updated_at: datetime
class WithdrawListItemOut(WithdrawOrderOut):
"""提现单列表项:在提现单字段基础上,补本页用户的手机号/昵称 + 累计成功提现金额(分)。
仅列表接口用(需联表 User + 聚合);详情/批量仍用 WithdrawOrderOut(不带这些字段)
"""
phone: str | None = None
nickname: str | None = None
cumulative_success_cents: int = 0 # 累计成功提现 = SUM(amount_cents) WHERE status='success'
class WithdrawSummaryOut(BaseModel):
reviewing_count: int
reviewing_amount_cents: int
-58
View File
@@ -1,58 +0,0 @@
"""最新 App 版本写入端点(发布流程 → app-server)。
发车流程出 APK ,把版本号 / 下载链接 / sha256 POST 到这里, app_config(key=latest_app_version)
客户端再 GET /api/v1/platform/app-version 读取做 OTA 检查更新**不是给客户端的接口**:
不走用户 JWT, server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)
密钥未配置(默认空)时直接 503,避免裸奔的写端点也是应急改版本信息(紧急下线/ apk_url)的入口
"""
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 app_config
from app.schemas.platform import AppVersionOut, AppVersionWriteIn
logger = logging.getLogger("shagua.internal.app_version")
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(
"/app-version",
response_model=AppVersionOut,
summary="写最新 App 版本(发布流程→app-server,落 app_config)",
)
def write_app_version(
payload: AppVersionWriteIn,
db: DbSession,
x_internal_secret: Annotated[str | None, Header()] = None,
) -> AppVersionOut:
_check_secret(x_internal_secret)
data = payload.model_dump()
app_config.set_app_version(db, data)
logger.info(
"app_version set code=%d name=%s url=%s",
payload.latest_version_code, payload.latest_version_name, payload.apk_url,
)
return AppVersionOut(**data)
-63
View File
@@ -1,63 +0,0 @@
"""启动确认窗兜底样本内部上报端点(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)
+44 -16
View File
@@ -24,12 +24,13 @@ 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,
EcpmReportIn,
EcpmReportOut,
FeedPreviewIn,
FeedPreviewOut,
FeedRewardIn,
FeedRewardOut,
PangleCallbackOut,
@@ -81,22 +82,14 @@ 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_ENABLED:
if not settings.pangle_callback_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
)
params = dict(request.query_params)
# 验签密钥: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):
if not pangle.verify_callback_sign_any(params, settings.pangle_reward_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")
@@ -352,6 +345,23 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
)
def _feed_segments(payload: FeedRewardIn) -> tuple[list[tuple[str, int]], str]:
"""把 feed-reward 入参归一成 `(segments, ecpm_repr)`。
优先用逐条 segments(每条自带 eCPM);未传则回退把 ecpm + duration_seconds 当单段(旧客户端)
ecpm_repr 取末段 eCPM(报表/审计落库代表值)两者都缺 422
"""
if payload.segments:
segs = [(s.ecpm, max(0, s.seconds)) for s in payload.segments]
return segs, segs[-1][0]
if payload.ecpm is not None:
return [(payload.ecpm, max(0, payload.duration_seconds))], payload.ecpm
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="feed-reward 需提供 segments 或 ecpm",
)
@router.post(
"/feed-reward",
response_model=FeedRewardOut,
@@ -359,20 +369,21 @@ def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = N
dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
)
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,整场结束(全程不关)一次性入账。
当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发
新客户端按条上报 [segments](每条 eCPM + 秒数),服务端按时序跨条进位精确结算;旧客户端只传
ecpm + duration_seconds 时回退当单段client_event_id 做幂等键,避免重试重复发
"""
segments, ecpm_repr = _feed_segments(payload)
rec = crud_feed.grant_feed_reward(
db,
user.id,
client_event_id=payload.client_event_id,
ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds,
segments=segments,
ecpm_repr=ecpm_repr,
ad_session_id=payload.ad_session_id,
adn=payload.adn,
slot_id=payload.slot_id,
feed_scene=payload.feed_scene,
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
@@ -390,6 +401,23 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
)
@router.post(
"/feed-reward/preview",
response_model=FeedPreviewOut,
summary="信息流播放中预演此刻将发放的金币(只读,不入账)",
dependencies=[Depends(rate_limit(600, 60, "ad-feed-preview"))],
)
def feed_reward_preview(payload: FeedPreviewIn, user: CurrentUser, db: DbSession) -> FeedPreviewOut:
"""比价/领券金币小球实时显示**真实即将到账金额**用:按截至此刻的逐条 segments 算,与
/feed-reward 同一口径(同份数封顶 / LT / eCPM ),**不入账不写库**
限流放宽到 600/:小球每满 10 (或换条)拉一次,一整场最多十几次,留足并发余量
"""
segments = [(s.ecpm, max(0, s.seconds)) for s in payload.segments]
result = crud_feed.preview_feed_reward(db, user.id, segments)
return FeedPreviewOut(**result)
@router.post(
"/reward-noshow",
response_model=RewardNoShowOut,
+12 -34
View File
@@ -15,11 +15,9 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, status
from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import CurrentUser, DbSession
from app.db.session import SessionLocal
from app.models.comparison import ComparisonRecord
from app.repositories import comparison as crud_compare
from app.services.pricebot_llm_calls import fetch_llm_calls
from app.schemas.compare_record import (
@@ -42,49 +40,29 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
summary="上报一次比价结果(幂等)",
)
def report_record(
payload: ComparisonRecordIn,
user: CurrentUser,
db: DbSession,
background_tasks: BackgroundTasks,
payload: ComparisonRecordIn, user: CurrentUser, db: DbSession
) -> ComparisonRecordCreatedOut:
rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload)
# LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台
# 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用
# 独立 session 按 record id 回填 llm_calls + 派生 llm_call_count/retry_count。
background_tasks.add_task(_backfill_llm_calls, rec.id, rec.trace_id)
# 同机拉 pricebot 本次比价的 LLM 调用明细落库(best-effort,失败不阻断上报)。
# llm_call_count/retry_count 直接由明细派生:次数=条数,重试=带 error 的条数。
calls = fetch_llm_calls(rec.trace_id)
if calls:
rec.llm_calls = calls
rec.llm_call_count = len(calls)
rec.retry_count = sum(1 for c in calls if c.get("error"))
db.commit()
logger.info(
"compare record user=%s trace=%s biz=%s status=%s saved=%s (llm_calls backfill queued)",
"compare record user=%s trace=%s biz=%s status=%s saved=%s llm_calls=%d",
user.id,
rec.trace_id,
rec.business_type,
rec.status,
rec.saved_amount_cents,
len(calls),
)
return ComparisonRecordCreatedOut(id=rec.id)
def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
"""后台回填本次比价的 LLM 调用明细 + 派生 llm_call_count/retry_count。
独立 DB session(请求 session 此时已关);拉取/写库失败只 log,绝不影响已落库的上报"""
calls = fetch_llm_calls(trace_id)
if not calls:
return
db = SessionLocal()
try:
rec = db.get(ComparisonRecord, record_id)
if rec is None:
return
rec.llm_calls = calls
rec.llm_call_count = len(calls)
rec.retry_count = sum(1 for c in calls if c.get("error"))
db.commit()
logger.info("backfill llm_calls trace=%s n=%d", trace_id, len(calls))
except Exception as e: # noqa: BLE001 best-effort
logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e)
finally:
db.close()
@router.get(
"/stats",
response_model=CompareStatsOut,
+12 -98
View File
@@ -8,22 +8,16 @@ from __future__ import annotations
import html
import json
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.responses import HTMLResponse, 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"])
@@ -33,16 +27,6 @@ _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")
@@ -51,109 +35,48 @@ 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, openid: str | None = None
) -> None:
def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> None:
try:
cps_link_repo.record_click(
db, link=link, ip=_client_ip(request),
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
ua=request.headers.get("user-agent"), event_type=event_type,
)
except Exception:
pass # 记点击失败不阻断
@router.get("/MP_verify_F7wnRQ7xPbhVOWC8.txt", include_in_schema=False)
def wx_mp_domain_verify() -> PlainTextResponse:
"""微信「网页授权域名」归属校验文件。coupon.shaguabijia.com 全反代 app-server,
微信请求 coupon.shaguabijia.com/MP_verify_xxx.txt 验证域名归属;内容=文件名核心串
放域名根目录(本端点即根路径),为微信网页授权( openid)接入做准备"""
return PlainTextResponse("F7wnRQ7xPbhVOWC8")
@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)")
@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)")
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)
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)
_record(db, link, request, "visit")
if link.platform == "taobao":
activity = db.get(CpsActivity, link.activity_id)
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1"
return HTMLResponse(
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
)
return HTMLResponse(_taobao_landing_html(link.target_url, image_url))
# 美团短链 / 京东链接:直接 302 跳
return RedirectResponse(link.target_url, status_code=302)
@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)")
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)")
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", openid=request.cookies.get(_WX_OPENID_COOKIE))
_record(db, link, request, "copy")
return {"ok": True}
def _taobao_landing_html(
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
) -> str:
def _taobao_landing_html(token: str, image_url: str) -> str:
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
image_url html.escape 嵌入 <img src>(防注入);token json.dumps 安全嵌入 JS
uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 用户点领券
时先跳它(交互触发,避免微信快照页),授权回来自动复制已拿过 / openid 则为空,直接复制
image_url 转绝对(落地页 coupon ,相对也可达,绝对更稳) html.escape 嵌入
<img src>(防属性注入);token json.dumps 安全嵌入 JS
"""
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))
)
@@ -181,7 +104,6 @@ body{font-family:-apple-system,"PingFang SC",sans-serif;background:#fff0ef;color
<div class="toast" id="toast"></div>
<script>
var TOKEN = __TOKEN_JS__;
var UINFO_URL = __UINFO_URL__; // 非空=还没拿昵称头像,点领券先跳它补(授权回来自动复制)
function showToast(m){var t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(function(){t.classList.remove('show')},2200)}
function reportCopy(){try{fetch(location.pathname+'/copy',{method:'POST',keepalive:true})}catch(e){}}
function done(){showToast('复制成功!打开淘宝即可领取');reportCopy()}
@@ -191,18 +113,10 @@ function fallback(){
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
document.body.removeChild(ta);
}
function doCopy(){
function copyToken(){
if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(TOKEN).then(done).catch(fallback)}
else{fallback()}
}
function copyToken(){
// 第一次领券且还没授权过昵称头像:先跳 userinfo 授权(用户点击=交互触发,避免快照页),
// 授权回来 ?authed=1 自动复制;已授权过/ openid 则直接复制
if(UINFO_URL){location.href=UINFO_URL;return}
doCopy();
}
// userinfo 授权回流(?authed=1):自动复制,"点领券→授权→复制"衔接成一步无感
if(location.search.indexOf('authed=1')>=0){doCopy()}
</script>
</body>
</html>"""
+2 -57
View File
@@ -2,7 +2,6 @@
路由前缀 `/api/v1/feedback`, Bearer 鉴权(反馈绑到登录用户,便于回访)
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 6 )
GET /records 我的反馈历史(pending/adopted/rejected)
截图复用 [app.core.media] 落盘到 /media/feedback/
"""
@@ -10,19 +9,12 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
from fastapi import APIRouter, File, Form, HTTPException, 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,
FeedbackRecordCountsOut,
FeedbackRecordOut,
FeedbackRecordsOut,
)
from app.schemas.feedback import FeedbackOut
logger = logging.getLogger("shagua.feedback")
@@ -31,27 +23,6 @@ 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="提交反馈")
@@ -89,29 +60,3 @@ async def submit_feedback(
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
return FeedbackOut.model_validate(fb)
@router.get("/config", response_model=FeedbackQrConfigOut, summary="反馈页二维码卡配置")
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)
-28
View File
@@ -17,9 +17,7 @@ from app.repositories import app_config
from app.repositories import ops_marquee as marquee_crud
from app.repositories import ops_stat as crud
from app.schemas.platform import (
AdConfigPublicOut,
AppFlagsOut,
AppVersionOut,
PlatformStatsOut,
SavingsFeedItem,
SavingsFeedOut,
@@ -53,29 +51,3 @@ def flags(db: DbSession) -> AppFlagsOut:
return AppFlagsOut(
comparing_ad_enabled=bool(app_config.get_value(db, "comparing_ad_enabled")),
)
@router.get("/ad-config", response_model=AdConfigPublicOut, summary="客户端拉广告配置(穿山甲ID+场景开关,不鉴权)")
def ad_config(db: DbSession) -> AdConfigPublicOut:
"""客户端启动/每场广告前拉,缓存后用:app_id + 各位ID + 各场景开关。
不含验签密钥;空库回退默认(=客户端内置值,维持现状)"""
c = app_config.get_ad_config(db)
return AdConfigPublicOut(
app_id=c["app_id"],
reward_code_id=c["reward_code_id"],
compare_feed_code_id=c["compare_feed_code_id"],
coupon_feed_code_id=c["coupon_feed_code_id"],
reward_enabled=c["reward_enabled"],
compare_ad_enabled=c["compare_ad_enabled"],
coupon_ad_enabled=c["coupon_ad_enabled"],
)
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
def app_version(db: DbSession) -> AppVersionOut:
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
latest_version_code 与本机 versionCode ;未配置(返回默认 0)时客户端视为已是最新"""
data = app_config.get_app_version(db)
if not data:
return AppVersionOut()
return AppVersionOut(**data)
-12
View File
@@ -18,7 +18,6 @@ from app.api.deps import CurrentUser, DbSession
from app.core import media
from app.repositories import onboarding as onboarding_repo
from app.repositories import user as user_repo
from app.repositories import wallet as wallet_repo
from app.schemas.auth import UserOut
from app.schemas.user import (
OkResponse,
@@ -88,17 +87,6 @@ def onboarding_status(
@router.delete("", response_model=OkResponse, summary="注销账号(软删除)")
def delete_account(user: CurrentUser, db: DbSession) -> OkResponse:
# 资金前置校验:注销是软删 + 匿名化,余额一旦留在 deleted 行就锁死(既退不出、新号也
# 拿不回)。有可提现现金 / 在审提现单 → 拒绝注销,引导用户先把钱处理掉。
# (pending 提现转账已在途、对账 worker 不依赖 user.status 照常到账,故不拦 pending。)
if wallet_repo.get_cash_balance_cents(db, user.id) > 0:
raise HTTPException(
status_code=409, detail="账户还有未提现的现金余额,请先提现后再注销"
)
if wallet_repo.has_reviewing_withdraw(db, user.id):
raise HTTPException(
status_code=409, detail="有提现正在审核中,请等审核完成后再注销"
)
media.delete_avatar(user.avatar_url)
user_repo.soft_delete_account(db, user)
logger.info("delete account user_id=%d", user.id)
+3 -26
View File
@@ -95,25 +95,6 @@ class Settings(BaseSettings):
"""美团 CPS 凭证齐全(缺则接口返空,而非 502)。"""
return bool(self.MT_CPS_APP_KEY and self.MT_CPS_APP_SECRET)
# ===== 微信服务号(网页授权) =====
# CPS 落地页在微信内拿用户 openid(base 静默)/昵称头像(userinfo),做用户级群统计。
# ⚠️ 区别于 WECHAT_APP_ID(那是 App 移动应用,用于微信支付);这是【已认证服务号】。
WX_MP_APPID: str = ""
WX_MP_SECRET: str = ""
# 落地页微信网页授权【总开关】。默认关:服务号认证审核中/未就绪时,落地页走原逻辑
# (不跳授权、不拿 openid),整套微信代码保留。审核通过后 .env 置 true + 重启即启用,无需改代码。
WX_MP_OAUTH_ENABLED: bool = False
@property
def wx_mp_configured(self) -> bool:
"""服务号网页授权凭证齐全(缺则落地页不发起授权,降级为无 openid)。"""
return bool(self.WX_MP_APPID and self.WX_MP_SECRET)
@property
def wx_oauth_active(self) -> bool:
"""落地页是否真正发起微信授权 = 凭证齐全 且 总开关开。"""
return self.wx_mp_configured and self.WX_MP_OAUTH_ENABLED
# ===== 微信支付(商家转账到零钱 / 提现)=====
# 真实凭证放 .env(已 gitignore),证书 .pem 放 secrets/。WECHAT_APP_ID 同时用于
# 微信登录(code 换 openid)与转账,必须与 App 端开放平台 appid 一致。
@@ -129,14 +110,10 @@ class Settings(BaseSettings):
WITHDRAW_AUTO_RECONCILE_ENABLED: bool = False
WITHDRAW_AUTO_RECONCILE_INTERVAL_SEC: int = 300
WITHDRAW_AUTO_RECONCILE_OLDER_THAN_MINUTES: int = 15
# 0 点自动兑金币:App 进程内任务 app.core.daily_exchange_worker 跨 0 点跑一轮
# wallet.daily_auto_exchange(原 deploy/daily-exchange.timer 已不再需要,见 deploy/daily-exchange.md)。
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停在 .env 置 false
# (worker 不启动;脚本也 no-op)。
# 0 点自动兑金币(deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false,
# 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。
AUTO_EXCHANGE_ENABLED: bool = True
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
-125
View File
@@ -1,125 +0,0 @@
"""0 点金币→现金自动兑换的进程内定时任务。
替代原 systemd timer(deploy/daily-exchange.*):App 启动即自带,
`AUTO_EXCHANGE_CHECK_INTERVAL_SEC` 醒一次,跨进北京新的一天(0 点后)就跑一轮
`wallet.daily_auto_exchange`
健壮性:
- **逐用户幂等**:当天已有 exchange_in 流水的用户跳过( wallet._has_exchange_in_on),
故启动补跑 / 多次唤醒 / 进程重启都安全,不会重复兑
- **当天首跑即补**:进程起来时若当天还没兑过,立即兑一轮(等价原 timer Persistent 补跑)
- **同机多进程互斥**:文件锁保证多 worker 只有一个实际跑(防跨进程并发导致 TOCTOU 双兑)
- **开关**:settings.AUTO_EXCHANGE_ENABLED=false 时不启动(与脚本/ timer 同一开关)
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
import time
from collections.abc import Iterator
from datetime import date
from pathlib import Path
from sqlalchemy.exc import SQLAlchemyError
from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.repositories import wallet as wallet_repo
logger = logging.getLogger("shagua.daily_exchange")
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "daily_exchange.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 _exchange_once() -> dict:
with SessionLocal() as db:
return wallet_repo.daily_auto_exchange(db)
async def _run_loop() -> None:
interval = max(60, int(settings.AUTO_EXCHANGE_CHECK_INTERVAL_SEC))
lock_stale_after = max(interval * 3, 1800)
with _single_instance_lock(lock_stale_after) as lock_acquired:
if not lock_acquired:
logger.warning("daily auto-exchange skipped: another worker owns lock")
return
await _run_locked_loop(interval)
async def _run_locked_loop(interval: int) -> None:
logger.info("daily auto-exchange worker started interval=%ss", interval)
# 本进程上次跑过的北京日;None=尚未跑过本进程(启动即补当天)。
last_run: date | None = None
try:
while True:
try:
_touch_lock()
today = rewards.cn_today()
if last_run != today:
result = await asyncio.to_thread(_exchange_once)
last_run = today
logger.info("daily auto-exchange done date=%s result=%s", today, result)
except SQLAlchemyError:
logger.exception("daily auto-exchange db error")
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
logger.exception("daily auto-exchange unexpected error")
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.info("daily auto-exchange worker stopped")
raise
def start_daily_exchange_worker() -> asyncio.Task | None:
if not settings.AUTO_EXCHANGE_ENABLED:
logger.info("daily auto-exchange disabled (AUTO_EXCHANGE_ENABLED=false)")
return None
return asyncio.create_task(_run_loop(), name="daily-auto-exchange")
async def stop_daily_exchange_worker(task: asyncio.Task | None) -> None:
if task is None:
return
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
+6 -26
View File
@@ -37,7 +37,7 @@ def _sniff_ext(data: bytes) -> str | None:
return None
def _save_named(subdir: str, name_prefix: str, data: bytes) -> str:
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
"""通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。"""
if not data:
raise MediaError("空文件")
@@ -47,16 +47,11 @@ def _save_named(subdir: str, name_prefix: str, data: bytes) -> str:
if ext is None:
raise MediaError("仅支持 JPEG / PNG / WebP 图片")
fname = f"{name_prefix}_{secrets.token_hex(8)}{ext}"
fname = f"u{user_id}_{secrets.token_hex(8)}{ext}"
(_media_dir(subdir) / fname).write_bytes(data)
return f"{settings.MEDIA_URL_PREFIX}/{subdir}/{fname}"
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
"""用户上传图片落盘(文件名带 user_id 前缀)。"""
return _save_named(subdir, f"u{user_id}", data)
def save_avatar(user_id: int, data: bytes) -> str:
"""保存头像,返回相对 URL(`/media/avatars/<file>`)。"""
return _save_image("avatars", user_id, data)
@@ -72,11 +67,6 @@ def save_report_image(user_id: int, data: bytes) -> str:
return _save_image("price_report", user_id, data)
def save_feedback_qr(data: bytes) -> str:
"""保存反馈页二维码(运营后台上传的运营素材,非用户文件),返回相对 URL(`/media/feedback_qr/<file>`)。"""
return _save_named("feedback_qr", "qr", data)
def save_cps_image(admin_id: int, data: bytes) -> str:
"""保存 CPS 活动落地页图,返回相对 URL(`/media/cps/<file>`)。admin_id 入文件名便于追溯。"""
return _save_image("cps", admin_id, data)
@@ -94,25 +84,15 @@ def to_abs_media_url(rel_url: str | None) -> str | None:
return f"{base}{rel_url}" if base else rel_url
def _delete_managed(subdir: str, url: str | None) -> None:
"""删除本服务托管的某子目录下文件;外部 URL / 空值 / 非本子目录的不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/{subdir}/"
def delete_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
if not url or not url.startswith(prefix):
return
fname = url[len(prefix):]
if not fname or "/" in fname or "\\" in fname or ".." in fname:
return # 防路径穿越
try:
(_media_dir(subdir) / fname).unlink(missing_ok=True)
(_media_dir("avatars") / fname).unlink(missing_ok=True)
except OSError:
pass
def delete_avatar(url: str | None) -> None:
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
_delete_managed("avatars", url)
def delete_feedback_qr(url: str | None) -> None:
"""删除本服务托管的旧反馈页二维码文件;外部 URL 或空值不处理。"""
_delete_managed("feedback_qr", url)
-4
View File
@@ -108,10 +108,6 @@ 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)。
-75
View File
@@ -1,75 +0,0 @@
"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。
用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份流程(见微信文档):
授权链接(snsapi_base|snsapi_userinfo) 回调带 code sns/oauth2/access_token openid
(userinfo )sns/userinfo 拿昵称头像
注意:
- 这里的 access_token 网页授权 token,与基础 access_token(cgi-bin/token)不同,
sns/* 接口,不需要 IP 白名单
- secret 只在服务器,不下发客户端
- WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)
"""
from __future__ import annotations
from urllib.parse import quote
import httpx
from app.core.config import settings
_API = "https://api.weixin.qq.com"
_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize"
_TIMEOUT = 10
class WxOauthError(Exception):
"""网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。"""
def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str:
"""构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。
微信要求 redirect_uri urlEncode参数顺序固定结尾 #wechat_redirect。
"""
return (
f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}"
f"&redirect_uri={quote(redirect_uri, safe='')}"
f"&response_type=code&scope={scope}&state={quote(state, safe='')}"
f"#wechat_redirect"
)
def exchange_code(code: str) -> dict:
"""code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。
code 5 分钟内一次性有效失败(errcode) WxOauthError
"""
resp = httpx.get(
f"{_API}/sns/oauth2/access_token",
params={
"appid": settings.WX_MP_APPID,
"secret": settings.WX_MP_SECRET,
"code": code,
"grant_type": "authorization_code",
},
timeout=_TIMEOUT,
)
data = resp.json()
if "openid" not in data:
raise WxOauthError(f"exchange_code failed: {data}")
return data
def get_userinfo(access_token: str, openid: str) -> dict:
"""拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。"""
resp = httpx.get(
f"{_API}/sns/userinfo",
params={"access_token": access_token, "openid": openid, "lang": "zh_CN"},
timeout=_TIMEOUT,
)
resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码
data = resp.json()
if "openid" not in data:
raise WxOauthError(f"get_userinfo failed: {data}")
return data
-10
View File
@@ -21,8 +21,6 @@ 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.cps_redirect import router as cps_redirect_router
from app.api.internal.app_version import router as internal_app_version_router
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
from app.api.internal.price import router as internal_price_router
from app.api.internal.store import router as internal_store_router
from app.api.v1.feedback import router as feedback_router
@@ -38,10 +36,6 @@ 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.daily_exchange_worker import (
start_daily_exchange_worker,
stop_daily_exchange_worker,
)
from app.core.logging import setup_logging
from app.core.withdraw_reconcile_worker import (
start_withdraw_reconcile_worker,
@@ -63,12 +57,10 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings.DATABASE_URL.split("://", 1)[0],
)
reconcile_task = start_withdraw_reconcile_worker()
daily_exchange_task = start_daily_exchange_worker()
try:
yield
finally:
await stop_withdraw_reconcile_worker(reconcile_task)
await stop_daily_exchange_worker(daily_exchange_task)
logger.info("shutting down")
@@ -115,8 +107,6 @@ app.include_router(report_router)
# 内部(server→server)端点:pricebot 上报价格观测 / 店铺映射,靠共享密钥头校验,不对客户端开放。
app.include_router(internal_price_router)
app.include_router(internal_store_router)
app.include_router(internal_app_version_router)
app.include_router(internal_launch_confirm_router)
app.include_router(platform_router)
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
app.include_router(cps_redirect_router)
-2
View File
@@ -10,7 +10,6 @@ from app.models.cps_activity import CpsActivity # noqa: F401
from app.models.cps_group import CpsGroup # noqa: F401
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.coupon_state import ( # noqa: F401
CouponClaimRecord,
@@ -20,7 +19,6 @@ from app.models.coupon_state import ( # noqa: F401
from app.models.feedback import Feedback # noqa: F401
from app.models.invite import InviteRelation # noqa: F401
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
from app.models.onboarding import OnboardingCompletion # noqa: F401
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
-3
View File
@@ -30,9 +30,6 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
feed_scene: Mapped[str | None] = mapped_column(String(16), 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)
-2
View File
@@ -49,8 +49,6 @@ class CpsClick(Base):
)
ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
ua: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计
openid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
clicked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
-42
View File
@@ -1,42 +0,0 @@
"""CPS 落地页微信用户(cps_wx_user)。
用户在微信内打开群发短链落地页 /c/{code},经服务号网页授权:
- base 静默: openid(唯一标识,统计主力)
- userinfo(点领券触发): nickname/headimgurl/unionid
openid 唯一,记录首次来源群(first_group_id),用于群内用户级统计(谁领了券)
下单归因到人需 user-level sid(另一期),本表只承载身份 + 领券/点击侧
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class CpsWxUser(Base):
__tablename__ = "cps_wx_user"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 服务号下用户唯一标识(网页授权拿到)
openid: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
# 开放平台 unionid(服务号绑开放平台 + scope=userinfo 才有);跨 App/服务号统一用户
unionid: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 昵称/头像:userinfo 授权后才有(base 阶段为空)
nickname: Mapped[str | None] = mapped_column(String(128), nullable=True)
headimgurl: Mapped[str | None] = mapped_column(String(512), nullable=True)
# 首次进入来源(从哪个群的链接授权进来)
first_code: Mapped[str | None] = mapped_column(String(16), nullable=True)
first_group_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
first_seen: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
last_seen: 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"<CpsWxUser id={self.id} openid={self.openid!r} nickname={self.nickname!r}>"
+3 -13
View File
@@ -2,7 +2,7 @@
每条 = 用户一次提交content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
(/media/feedback/...,JSON )status: pending(审核中)/adopted(采纳)/rejected(未采纳)
(/media/feedback/...,JSON )status: new(待处理)/ handled(处理)
"""
from __future__ import annotations
@@ -25,18 +25,8 @@ 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)
# 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
)
# new(待处理) / handled(已处理)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
-61
View File
@@ -1,61 +0,0 @@
"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。
pricebot launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App
启动确认窗(繁体 / 英文 / 小语种 / ROM 改版),把完整样本 serverserver 落这里研发定期
**人工** exec_success=true 的样本沉淀进 pricebot launch_confirm.PROFILES(静态快路径),
让以后同款窗不必再调 LLM
设计:
- 与登录无关不鉴权(serverserver 共享密钥头 X-Internal-Secret)
- **都上报不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale
聚合后人工筛
- 大字段(弹窗树快照 + LLM plan + 设备信息) payload(JSONB)兜底,免得加字段就迁移 schema
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 price_observation)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class LaunchConfirmSample(Base):
__tablename__ = "launch_confirm_sample"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
# pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。
host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
# 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。
target_app: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。
# ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。
system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
# 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。
exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。
dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree,
# device_info: {screen, locale, brand, model, android_version, rom_version}}
payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
def __repr__(self) -> str: # pragma: no cover
return (
f"<LaunchConfirmSample id={self.id} host={self.host_package!r} "
f"locale={self.system_locale!r} success={self.exec_success}>"
)
+119 -95
View File
@@ -16,10 +16,13 @@ from app.repositories import wallet as crud_wallet
FEED_REWARD_UNIT_SECONDS = 10
# 单个 feed 事件的时长上限(秒):一期 duration_seconds 由客户端上报,伪造超长时长会刷份数
# 单个 feed 事件的时长上限(秒):一期时长由客户端上报,伪造超长时长会刷份数
# (每 10 秒 1 份)。真实单条信息流视频远小于此;取 120s=12 份封顶,挡刷量、不影响正规单。
# 与 rewards.AD_ECPM_MAX_FEN(eCPM 钳顶)合起来,把单事件可铸金币锁进有限区间。
FEED_MAX_DURATION_SECONDS = 120
# 单场份数上限(=120s//10):多条广告按时序「跨条进位」累计满 10 秒一份,封顶 12 份/场。
# 时长改由逐条 segments 上报后,防刷的硬闸从「单事件时长钳」上移到这个份数上限(配合 eCPM 钳顶)。
FEED_MAX_UNITS = FEED_MAX_DURATION_SECONDS // FEED_REWARD_UNIT_SECONDS
def _find_by_event(db: Session, client_event_id: str) -> AdFeedRewardRecord | None:
@@ -42,21 +45,89 @@ 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",
def _existing_granted_units(db: Session, user_id: int) -> int:
"""该账号历史已 granted 的累计份数(LT 因子起点,不按天重置)。"""
return int(
db.execute(
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0)).where(
AdFeedRewardRecord.user_id == user_id,
AdFeedRewardRecord.status == "granted",
)
).scalar_one()
)
def _walk_unit_ecpms(segments: list[tuple[str, int]]) -> list[str]:
"""按时序遍历各广告段,**跨条进位**累计满 10 秒结算一份,返回每份对应的 eCPM。
每份归属它满 10 秒那一刻正在播的那条广告的 eCPM:换条时不足一份的零头进位到下一条
(与旧版 `总时长//10` 份数口径一致不浪费零头,但每份 eCPM 归属精确)封顶 [FEED_MAX_UNITS]
;尾部不足一份的秒数丢弃segments `[(ecpm, seconds), ...]`,seconds0 的段跳过
"""
unit_ecpms: list[str] = []
carry = 0
for ecpm, seconds in segments:
if seconds <= 0:
continue
carry += seconds
while carry >= FEED_REWARD_UNIT_SECONDS and len(unit_ecpms) < FEED_MAX_UNITS:
carry -= FEED_REWARD_UNIT_SECONDS
unit_ecpms.append(ecpm)
if len(unit_ecpms) >= FEED_MAX_UNITS:
break
return unit_ecpms
def compute_feed_reward(
db: Session, user_id: int, segments: list[tuple[str, int]]
) -> tuple[int, int, int]:
"""按逐条 segments 计算「此刻若结束将发放的金币」。**只读、不入账**,发奖与预览共用此口径。
返回 `(coin, unit_count, next_unit_coin)`:
- coin:已累计满的各份金币之和(每份 = 所在段 eCPM × LT(账号累计份序号) × 汇率)
- unit_count:已满份数(封顶 [FEED_MAX_UNITS])
- next_unit_coin:下一份( unit_count+1 )的边际金币,按当前在播段(segments 末段)eCPM ;
已封顶或无段时为 0供客户端小球在本份未满区间内做平滑插值显示
"""
unit_ecpms = _walk_unit_ecpms(segments)
unit_count = len(unit_ecpms)
existing_units = _existing_granted_units(db, user_id)
coin = sum(
rewards.calculate_ad_reward_coin(ecpm, existing_units + i)
for i, ecpm in enumerate(unit_ecpms, start=1)
)
if unit_count < FEED_MAX_UNITS and segments:
next_ecpm = segments[-1][0]
next_unit_coin = rewards.calculate_ad_reward_coin(
next_ecpm, existing_units + unit_count + 1
)
).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
else:
next_unit_coin = 0
return coin, unit_count, next_unit_coin
def preview_feed_reward(
db: Session, user_id: int, segments: list[tuple[str, int]]
) -> dict:
"""信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账、不写库)。
给比价/领券金币小球实时显示**真实即将到账金额**would_status 预判若此刻结束的结算状态:
daily_capped(已达每日条数上限,整场将发 0)/ too_short(凑不满一份)/ granted
"""
coin, unit_count, next_unit_coin = compute_feed_reward(db, user_id, segments)
if _granted_today(db, user_id, cn_today().isoformat()) >= rewards.get_ad_daily_limit(db):
would_status = "daily_capped"
elif unit_count == 0:
would_status = "too_short"
else:
would_status = "granted"
return {
"coin": coin,
"unit_count": unit_count,
"next_unit_coin": next_unit_coin,
"session_full": unit_count >= FEED_MAX_UNITS,
"would_status": would_status,
}
def grant_feed_reward(
@@ -64,119 +135,72 @@ def grant_feed_reward(
user_id: int,
*,
client_event_id: str,
ecpm: str,
duration_seconds: int,
segments: list[tuple[str, int]],
ecpm_repr: str,
ad_session_id: str | None = None,
adn: str | None = None,
slot_id: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
aborted: bool = False,
) -> AdFeedRewardRecord:
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份( 10 1 )
发奖规则:**比价全程不关广告才发**,金额按整场逐条 [segments]跨条进位折份
( 10 1 ,每份用所在段 eCPM, [_walk_unit_ecpms])
- 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),仅做归类落库,不参与发奖计算
segments 逐条广告 `(ecpm, seconds)` 时序列表(旧客户端只传总时长时,API 层包成单段)
ecpm_repr 是落库代表 eCPM(报表/审计用,通常取末段)三道硬闸防刷:份数封顶 [FEED_MAX_UNITS]
限单场份数,eCPM rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额,
叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间
"""
existing = _find_by_event(db, client_event_id)
if existing is not None:
return existing
today = cn_today().isoformat()
# 客户端上报时长先钳到 FEED_MAX_DURATION_SECONDS,防伪造超长时长刷份数(见常量注释)。
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS
total_seconds = sum(max(0, s) for _, s in segments)
# 份数封顶在 compute 内做(FEED_MAX_UNITS);记录的 duration 仍钳一道,纯做报表展示防异常大值。
safe_duration = min(total_seconds, FEED_MAX_DURATION_SECONDS)
coin, unit_count, _next = compute_feed_reward(db, user_id, segments)
def _record(coin_val: int, status: str) -> AdFeedRewardRecord:
return AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm_repr,
adn=adn,
slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
coin=coin_val,
status=status,
)
# 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。
if aborted:
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
status="closed_early",
)
return _commit_record(db, rec, client_event_id)
return _commit_record(db, _record(0, "closed_early"), client_event_id)
if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
status="capped",
)
return _commit_record(db, rec, client_event_id)
return _commit_record(db, _record(0, "capped"), client_event_id)
# 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。
if unit_count == 0:
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=0,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
coin=0,
status="too_short",
)
return _commit_record(db, rec, client_event_id)
return _commit_record(db, _record(0, "too_short"), client_event_id)
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
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}",
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
user_id=user_id,
reward_date=today,
duration_seconds=safe_duration,
unit_count=unit_count,
ad_session_id=ad_session_id,
ecpm_raw=ecpm,
adn=adn,
slot_id=slot_id,
feed_scene=feed_scene,
app_env=app_env,
our_code_id=our_code_id,
coin=coin,
status="granted",
)
return _commit_record(db, rec, client_event_id)
return _commit_record(db, _record(coin, "granted"), client_event_id)
def _commit_record(db: Session, rec: AdFeedRewardRecord, client_event_id: str) -> AdFeedRewardRecord:
-78
View File
@@ -63,81 +63,3 @@ def list_all(db: Session) -> list[dict]:
}
)
return out
# ── 最新 App 版本(OTA 检查更新)──────────────────────────────────────────────
# 版本信息不是"运营可配置项",不进 CONFIG_DEFS:它由发布流程(发车)/应急整组写入、客户端读取做
# OTA,语义是"发布系统状态"而非"运营调参"。故走下面专用读写,直接操作 AppConfig 表(复用表不复用 repo)。
APP_VERSION_KEY = "latest_app_version"
def get_app_version(db: Session) -> dict | None:
"""读最新 App 版本信息(OTA)。无记录返回 None(客户端视为无更新)。"""
row = db.get(AppConfig, APP_VERSION_KEY)
return row.value if row is not None else None
def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfig:
"""整组原子覆盖最新版本信息(发布流程/应急写,非 admin,故 updated_by_admin_id 留空)。"""
row = db.get(AppConfig, APP_VERSION_KEY)
if row is None:
row = AppConfig(key=APP_VERSION_KEY, value=data, updated_by_admin_id=None)
db.add(row)
else:
row.value = data
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
# ── 穿山甲广告配置(admin 可配,客户端动态拉)──────────────────────────────────
# 同 app_version:结构化 dict,复用 AppConfig 表不进 CONFIG_DEFS(它是一组关联配置,不是散项)。
# 默认值 = 客户端当前内置的硬编码 ID(AdConfig.kt),空库时下发默认 = 维持现状。
# reward_mkey 是 GroMore S2S 验签密钥(机密),只后端验签用、绝不下发客户端(见 platform/ad-config)。
AD_CONFIG_KEY = "ad_config"
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
"app_id": "5830519", # 穿山甲应用ID(正式)
"reward_code_id": "104099389", # 福利页激励视频位
# ⚠️ 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, # 比价广告开关
"coupon_ad_enabled": True, # 领券广告开关
}
def get_ad_config(db: Session) -> dict:
"""读广告配置。DB 无则返回默认(=客户端内置值,维持现状);DB 有则与默认 merge
(新增字段向后兼容,老记录缺的字段用默认补)"""
row = db.get(AppConfig, AD_CONFIG_KEY)
merged = dict(_AD_CONFIG_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
return merged
def set_ad_config(db: Session, data: dict, *, admin_id: int, commit: bool = True) -> AppConfig:
"""admin 写广告配置。与现有值 merge(部分更新),只接受已知字段(防脏键)。"""
row = db.get(AppConfig, AD_CONFIG_KEY)
merged = dict(_AD_CONFIG_DEFAULTS)
if row is not None and isinstance(row.value, dict):
merged.update(row.value)
merged.update({k: v for k, v in data.items() if k in _AD_CONFIG_DEFAULTS})
if row is None:
row = AppConfig(key=AD_CONFIG_KEY, value=merged, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = merged
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
return row
+3 -4
View File
@@ -52,13 +52,12 @@ def create_link(
def record_click(
db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None,
event_type: str = "visit", openid: str | None = None,
event_type: str = "visit",
) -> None:
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。
openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计"""
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。"""
db.add(CpsClick(
link_id=link.id, group_id=link.group_id, sid=link.sid, event_type=event_type,
ip=ip, ua=(ua[:500] if ua else None), openid=openid,
ip=ip, ua=(ua[:500] if ua else None),
))
db.commit()
-43
View File
@@ -1,43 +0,0 @@
"""cps_wx_user 数据访问:按 openid upsert。"""
from __future__ import annotations
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.cps_wx_user import CpsWxUser
def get_by_openid(db: Session, openid: str) -> CpsWxUser | None:
return db.execute(
select(CpsWxUser).where(CpsWxUser.openid == openid)
).scalar_one_or_none()
def upsert(
db: Session, *, openid: str, code: str | None = None, group_id: int | None = None,
nickname: str | None = None, headimgurl: str | None = None, unionid: str | None = None,
) -> CpsWxUser:
"""按 openid upsert。
- 首次:建行,记来源 code/group
- 已存在:仅在传入非 None 时更新昵称/头像/unionid(base 阶段为 None,不覆盖已有画像);
并刷新 last_seen(显式 set, onupdate 仅在字段有变更时触发)
"""
u = get_by_openid(db, openid)
if u is None:
u = CpsWxUser(
openid=openid, first_code=code, first_group_id=group_id,
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
)
db.add(u)
else:
if nickname is not None:
u.nickname = nickname
if headimgurl is not None:
u.headimgurl = headimgurl
if unionid is not None:
u.unionid = unionid
u.last_seen = func.now()
db.commit()
db.refresh(u)
return u
+2 -16
View File
@@ -1,12 +1,8 @@
"""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
@@ -23,19 +19,9 @@ def create_feedback(
content=content,
contact=contact,
images=images or None,
status="pending",
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
status="new",
)
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())
-106
View File
@@ -1,106 +0,0 @@
"""反馈页二维码卡配置读写(运营后台可配 → App 反馈页同步)。
整张卡(开关 + 二维码图 + 三行文案)作为一个 JSON 对象,复用通用 app_config
(key=feedback_qr_card,value 存这个 dict)表里没这行 返回内置默认(= App 反馈页原
硬编码文案,空配置 = 改造前现状,图片走 App 本地兜底)admin 写一行 App 下次拉
GET /api/v1/feedback/config 即生效(跨进程一致)
不经 app.repositories.app_config(那层按 CONFIG_DEFS 白名单限定 key,且会出现在系统配置页);
key 由本模块独占维护,不进 CONFIG_DEFS,所以不会污染系统配置页
"""
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from app.models.app_config import AppConfig
_KEY = "feedback_qr_card"
# 默认值镜像 App 反馈页原硬编码:空配置时整体行为与改造前一致。
_DEFAULTS: dict[str, Any] = {
"enabled": True,
"image_url": None, # None = App 用本地 asset 兜底(group_qr.png),再不行画占位伪码
"title": "长按图片保存二维码",
"group_name": "傻瓜比价官方群",
"subtitle": "一起唠嗑共创、解锁新玩法",
}
_FIELDS = tuple(_DEFAULTS.keys())
def _merge(raw: Any) -> dict[str, Any]:
"""把 DB 存的(可能不全的)dict 叠加到默认上,得到完整配置(5 个字段,无 updated_at)。"""
out = dict(_DEFAULTS)
if isinstance(raw, dict):
for k in _FIELDS:
v = raw.get(k)
if v is not None: # image_url 的 None 与默认 None 等价,无需特判
out[k] = v
return out
def get_config(db: Session) -> dict[str, Any]:
"""完整配置 + updated_at(admin 读 / App 读共用;App schema 会忽略多余的 updated_at)。"""
row = db.get(AppConfig, _KEY)
cfg = _merge(row.value if row is not None else None)
cfg["updated_at"] = row.updated_at.isoformat() if row is not None and row.updated_at else None
return cfg
def _write(db: Session, value: dict[str, Any], *, admin_id: int, commit: bool) -> dict[str, Any]:
"""整体覆写该行(value 须为完整 5 字段 dict),返回合并后的完整配置(含 updated_at)。"""
row = db.get(AppConfig, _KEY)
if row is None:
row = AppConfig(key=_KEY, value=value, updated_by_admin_id=admin_id)
db.add(row)
else:
row.value = value # 重新赋整个 dict,SQLAlchemy 能侦测到变更(in-place 改才侦测不到)
row.updated_by_admin_id = admin_id
if commit:
db.commit()
db.refresh(row)
else:
db.flush()
out = _merge(row.value)
out["updated_at"] = row.updated_at.isoformat() if row.updated_at else None
return out
def update_config(
db: Session,
*,
enabled: bool | None = None,
title: str | None = None,
group_name: str | None = None,
subtitle: str | None = None,
admin_id: int,
commit: bool = True,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""改文案/开关(只改传了的字段;图片走 set_image)。返回 (before, after) 供审计。"""
row = db.get(AppConfig, _KEY)
before = _merge(row.value if row is not None else None)
new_value = {k: before[k] for k in _FIELDS}
if enabled is not None:
new_value["enabled"] = enabled
if title is not None:
new_value["title"] = title.strip()
if group_name is not None:
new_value["group_name"] = group_name.strip()
if subtitle is not None:
new_value["subtitle"] = subtitle.strip()
after = _write(db, new_value, admin_id=admin_id, commit=commit)
return before, after
def set_image(
db: Session, image_url: str | None, *, admin_id: int, commit: bool = True
) -> tuple[dict[str, Any], dict[str, Any]]:
"""设置/清空二维码图 URL。返回 (before, after);before['image_url'] 供调用方删旧文件。"""
row = db.get(AppConfig, _KEY)
before = _merge(row.value if row is not None else None)
new_value = {k: before[k] for k in _FIELDS}
new_value["image_url"] = image_url
after = _write(db, new_value, admin_id=admin_id, commit=commit)
return before, after
-35
View File
@@ -1,35 +0,0 @@
"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。"""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy.orm import Session
from app.models.launch_confirm_sample import LaunchConfirmSample
from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn
logger = logging.getLogger("shagua.launch_confirm")
def _trunc(s: Optional[str], n: int) -> Optional[str]:
"""截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。"""
return s[:n] if s else None
def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
"""写入一条样本,返回新行 id。"""
row = LaunchConfirmSample(
trace_id=_trunc(payload.trace_id, 64),
device_id=_trunc(payload.device_id, 64),
host_package=_trunc(payload.host_package, 128),
target_app=_trunc(payload.target_app, 128),
system_locale=_trunc(payload.system_locale, 32),
exec_success=bool(payload.exec_success),
dialog_title=_trunc(payload.dialog_title, 256),
payload=payload.payload,
)
db.add(row)
db.commit()
db.refresh(row)
return row.id
+2 -15
View File
@@ -111,27 +111,14 @@ def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User:
def soft_delete_account(db: Session, user: User) -> None:
"""注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定
"""注销账号:软删除 + 匿名化。
phone 改成 `deleted_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号;
phone 改成 `deleted_<id>` 释放唯一约束,允许同号码重新注册成全新账号;
清空 PII(昵称/头像),保留行用于审计status=deleted 后将无法再登录该行
(登录接口校验 status==active)
注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位,
对应资源再也无法被复用:
- wechat_openid: 不清 这个微信再也无法被任何账号绑定(提现通道彻底锁死);
- invite_code: 不清 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind
status==active 校验兜住,清掉更干净)
资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化
"""
user.status = "deleted"
user.phone = f"deleted_{user.id}"
user.nickname = None
user.avatar_url = None
# 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像
user.wechat_openid = None
user.wechat_nickname = None
user.wechat_avatar_url = None
# 释放邀请码唯一槽
user.invite_code = None
db.commit()
-9
View File
@@ -340,15 +340,6 @@ def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
return info
def get_cash_balance_cents(db: Session, user_id: int) -> int:
"""只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用,
不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)"""
val = db.execute(
select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
).scalar_one_or_none()
return val or 0
def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
"""用户是否有「待审核(reviewing)」的提现单。
+49 -10
View File
@@ -120,27 +120,42 @@ class TestGrantOut(BaseModel):
)
class FeedSegmentIn(BaseModel):
"""信息流轮播中的**一条广告段**:自带 eCPM + 观看秒数。
新客户端按条上报(每条 eCPM 不同),服务端按时序跨条进位每满 10 秒结算一份每份用所在段
eCPM 计价( ad_feed_reward._walk_unit_ecpms)
"""
ecpm: str = Field(..., description="该条广告 eCPM(分/千次展示,SDK getEcpm 原值,非元)")
seconds: int = Field(..., ge=0, description="该条广告的观看秒数")
class FeedRewardIn(BaseModel):
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。
规则:全程不关广告才发,金额按整场**总观看时长**折份( 10 1 )client_event_id 用于
客户端超时重试幂等中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)
规则:全程不关广告才发,金额按整场折份( 10 1 )client_event_id 用于客户端超时重试
幂等中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)
计价口径:优先用 [segments] 逐条精确结算(每条自带 eCPM);未传 segments 时回退用
ecpm + duration_seconds **单段**(旧客户端兼容)两者都没有则 422
"""
client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
)
ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)")
duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
segments: list[FeedSegmentIn] | None = Field(
None, description="逐条广告段(每条自带 eCPM + 观看秒数,时序)。新客户端传它做按条精确结算"
)
ecpm: str | None = Field(
None, description="[旧客户端/回退] 本场信息流代表 eCPM(分/千次);传了 segments 时仅作落库代表值"
)
duration_seconds: int = Field(
0, ge=0, description="[旧客户端/回退] 整场累计观看秒数;传了 segments 时忽略"
)
adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位")
feed_scene: str | None = Field(
None,
max_length=16,
description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);"
"比价与领券共用同一信息流代码位,需客户端在各调用点显式标注,缺省=未分类",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
@@ -160,6 +175,30 @@ class FeedRewardOut(BaseModel):
daily_limit: int = Field(..., description="每日信息流展示次数上限")
class FeedPreviewIn(BaseModel):
"""信息流播放中查询「此刻若结束将发放多少金币」(只读预演,不入账)。
给比价/领券金币小球实时显示**真实即将到账金额**segments 含在播那条的部分秒数
"""
segments: list[FeedSegmentIn] = Field(
..., min_length=1, description="截至此刻的逐条广告段(时序;含在播那条的部分秒数)"
)
class FeedPreviewOut(BaseModel):
coin: int = Field(..., description="此刻结束将发放的累计金币(已满 10 秒的各份之和)")
unit_count: int = Field(..., description="已累计满的份数(封顶 12)")
next_unit_coin: int = Field(
..., description="下一份(第 unit_count+1 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。"
"供小球在本份未满区间内做平滑插值显示"
)
session_full: bool = Field(..., description="是否已达单场份数上限(12),小球应停涨")
would_status: str = Field(
..., description="若此刻结束的结算预判:granted / too_short / daily_capped"
)
class RewardNoShowIn(BaseModel):
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
+2 -3
View File
@@ -71,9 +71,8 @@ class ComparisonRecordIn(BaseModel):
comparison_results: list[ComparisonResultIn] = Field(default_factory=list)
# 逐平台结局摘要(含失败平台的细分原因 status: store_not_found/items_not_found/below_minimum/
# unsupported/...)。来自 done.params.platform_results,客户端透传;落 raw_payload(不单列),
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
# 按平台 id 索引的 dict 而非 list,只作 debug 展示)。
platform_results: dict = Field(default_factory=dict)
# admin「卡在哪一步」从这里读。list[dict] 宽松存(结构由 pricebot 定,只作 debug 展示)。
platform_results: list = Field(default_factory=list)
skipped_dish_count: int | None = None
skipped_dish_names: list[str] = Field(default_factory=list)
total_dish_count: int | None = None
+1 -37
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict
class FeedbackOut(BaseModel):
@@ -12,39 +12,3 @@ class FeedbackOut(BaseModel):
id: int
status: str
created_at: datetime
class FeedbackQrConfigOut(BaseModel):
"""反馈页二维码卡配置(运营后台可配)。App 反馈页据此渲染整张「加群二维码」卡。
image_url 是相对 /media 路径(客户端按自己的 BASE_URL 拼绝对地址),为空 客户端走本地兜底
"""
enabled: bool
image_url: str | None = None
title: str
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
-26
View File
@@ -1,26 +0,0 @@
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
from __future__ import annotations
from pydantic import BaseModel
class LaunchConfirmSampleIn(BaseModel):
"""一次 agent 兜底的完整样本。
字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)
"""
trace_id: str | None = None
device_id: str | None = None
host_package: str | None = None
target_app: str | None = None
system_locale: str | None = None
exec_success: bool = False
dialog_title: str | None = None
payload: dict | None = None
class LaunchConfirmSampleOut(BaseModel):
"""落库结果。"""
id: int
+1 -44
View File
@@ -1,7 +1,7 @@
"""首页平台级展示数据 schemas(客户端首页门面数字)。"""
from __future__ import annotations
from pydantic import BaseModel, Field
from pydantic import BaseModel
class PlatformStatsOut(BaseModel):
@@ -28,46 +28,3 @@ class AppFlagsOut(BaseModel):
"""客户端拉取的运营 feature flag(不鉴权,登录前也能拉)。客户端缓存后按需读。"""
comparing_ad_enabled: bool # 比价/领券期是否展示信息流广告(远程 kill-switch)
class AdConfigPublicOut(BaseModel):
"""下发给客户端的穿山甲广告配置(不鉴权,客户端缓存后用)。
不含 reward_mkey(GroMore 验签密钥,机密,只后端验签用,绝不下发)
"""
app_id: str # 穿山甲应用ID(改了客户端需冷启才生效,SDK init 一次性读)
reward_code_id: str # 福利页激励视频位
compare_feed_code_id: str # 比价信息流位
coupon_feed_code_id: str # 领券信息流位
reward_enabled: bool # 福利激励视频开关
compare_ad_enabled: bool # 比价广告开关
coupon_ad_enabled: bool # 领券广告开关
class AppVersionOut(BaseModel):
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
客户端用 latest_version_code(整数,单调递增)与本机 versionCode 比较,**不要** version_name 字符串比
latest_version_code=0 表示后台尚未配置任何版本 客户端一律视为"已是最新"
"""
latest_version_code: int = 0 # 最新版 versionCode;0=未配置(无更新)
latest_version_name: str = "" # 展示用,如 "0.1.4"
apk_url: str = "" # 下载链接(发车产出的永久版本化链接)
update_note: str = "" # 更新说明,弹窗展示
min_supported_version_code: int = 0 # 本机低于此版本=强制更新;0=不强更(全可选)
apk_size_bytes: int = 0 # 包大小(字节),展示"约 xMB"
apk_sha256: str = "" # APK 完整性校验(客户端下载后比对,防损坏/劫持)
class AppVersionWriteIn(BaseModel):
"""内部写入最新版本(发布流程/应急,X-Internal-Secret 校验)。整组原子覆盖。"""
latest_version_code: int = Field(gt=0)
latest_version_name: str = Field(min_length=1)
apk_url: str = Field(min_length=1)
update_note: str = ""
min_supported_version_code: int = 0
apk_size_bytes: int = 0
apk_sha256: str = ""
+1 -11
View File
@@ -3,15 +3,6 @@
> 对象:维护「每天 0 点把用户金币自动兑成现金」这套定时任务的同事。
> 🔒 服务器登录信息见**私密交接清单**,不入库。
## 现状(2026-06 起):已改为 App 进程内任务,不再需要 systemd timer
「0 点自动兑换」现由 **App 进程内后台任务** `app.core.daily_exchange_worker` 负责:App 一起来就
每 10 分钟检查、跨过北京 0 点自动跑一轮(逐用户幂等、文件锁互斥、重启补跑当天遗漏)。**无需再装 / 启用
`daily-exchange.timer`**。
- 开关仍是 `.env``AUTO_EXCHANGE_ENABLED`(false → worker 不启动);间隔由 `AUTO_EXCHANGE_CHECK_INTERVAL_SEC`(默认 600s=10min)控。
- 下方 systemd timer / 脚本属**遗留 + 手动应急**:逻辑同一套且幂等,可手动 `--once` 补跑;
但**不要再 `enable` timer 与进程内 worker 并存**(虽幂等不会双兑,纯属多余)。
## 它是什么
客户端已删手动「兑金币」入口、改为「0 点自动兑现金(可能存在延迟)」,故服务端每天 0 点跑一轮:
把每个用户的金币按汇率 **100 金币 = 1 分** 兑成现金。
@@ -30,9 +21,8 @@
| systemd 单元 | `deploy/daily-exchange.{service,timer}` |
| 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) |
## (遗留 / 可选)systemd timer 部署 — 现已被进程内 worker 取代,正常无需执行
## 部署(Linux 服务器)
```bash
# ⚠️ 仅在你明确想用外部 timer 而非进程内 worker 时才做(并把 worker 关掉),否则跳过。
sudo cp deploy/daily-exchange.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now daily-exchange.timer
systemctl list-timers daily-exchange.timer # 确认下次触发时间
+6 -9
View File
@@ -3,7 +3,7 @@
> Base URL:生产 `https://app-api.shaguabijia.com`;本地联调 `http://<开发机>:8770`
> 协议:HTTP / JSON,请求与响应体均 `application/json`,字段统一 **snake_case**
> 鉴权:需鉴权的接口在请求头带 `Authorization: Bearer <access_token>`
> 最后更新:2026-06-18(补录比价透传端点 `intent/precoupon/step``intent/step``trace/finalize``meituan/top-sales` 到总览;上一次功能更新 2026-06-07 金币数值体系一期)
> 最后更新:2026-06-07金币数值体系一期:签到膨胀 + 信息流广告结算
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
---
@@ -23,13 +23,9 @@
| 9 | `POST /api/v1/meituan/coupons` | 无 | [详情](./meituan-coupons.md) |
| 10 | `POST /api/v1/meituan/feed` | 无 | [详情](./meituan-feed.md) |
| 11 | `POST /api/v1/meituan/referral-link` | 无 | [详情](./meituan-referral-link.md) |
| 11a | `POST /api/v1/meituan/top-sales` | 无 | [详情](./meituan-top-sales.md)(销量榜:离线库 `meituan_coupon` 按销量降序 + 跨源去重,不实时打美团 |
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend;下按 Phase 流程列,均不鉴权) |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md)(Phase 1 意图识别,单次,多数源) |
| 12a | `POST /api/v1/intent/precoupon/step` | 无 | Phase 0 意图识别前先用券,仅美团源(透传,无单独文档) |
| 12b | `POST /api/v1/intent/step` | 无 | Phase 1 多帧意图识别,仅淘宝源,循环到 done(透传,无单独文档) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md)Phase 2 步进) |
| 13a | `POST /api/v1/trace/finalize` | 无 | 比价 trace 收尾上云,终止/未识别拿 trace_url(透传,无单独文档) |
| **比价透传**(前缀 `/api/v1`,外卖 MVP;与 `coupon/step` 同为透传 pricebot-backend |||
| 12 | `POST /api/v1/intent/recognize` | 无 | [详情](./compare-intent-recognize.md) |
| 13 | `POST /api/v1/price/step` | 无 | [详情](./compare-price-step.md) |
| **比价记录**(前缀 `/api/v1/compare`;按用户落库,**鉴权**,区别于上面不鉴权的透传) |||
| 12a | `POST /api/v1/compare/record` | Bearer | [详情](./compare-record-report.md) |
| 12b | `GET /api/v1/compare/records` | Bearer | [详情](./compare-records.md) |
@@ -67,6 +63,7 @@
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
| 35a' | `POST /api/v1/ad/feed-reward/preview` | Bearer | [详情](./ad-feed-reward-preview.md)(播放中预演将发放金币,只读不入账,金币小球用) |
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
| **用户资料**(前缀 `/api/v1/user` |||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
@@ -113,7 +110,7 @@
| - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
> `coupon/step` 及外卖比价的 `intent/recognize``intent/precoupon/step``intent/step``price/step``trace/finalize` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见 `app/api/v1/compare.py`)。
> `coupon/step` 及外卖比价的 `intent/recognize` / `price/step` 都透传到 pricebot-backend,**MVP 阶段均不鉴权**(device_id 透传,待补 JWT——见各接口详情)。
> 福利相关业务接口(wallet/signin/tasks/savings、`ad/reward-status``ad/feed-reward`)均需 **Bearer**;`wallet/exchange-info` 是静态规则无鉴权;`ad/pangle-callback` 不走 JWT、靠穿山甲**验签**;`ad/test-grant` **仅本地联调**(开关控制,生产 404)。
> 金额字段一律以**分**为单位(`*_cents`)。
+35
View File
@@ -0,0 +1,35 @@
# POST /api/v1/ad/feed-reward/preview — 信息流播放中预演将发放的金币(只读)
比价 / 领券信息流广告**播放过程中**,查询「若此刻结束、整场将发放多少金币」。给金币小球实时显示**真实即将到账金额**用:与 [feed-reward](./ad-feed-reward.md) **同一计价口径**(同份数封顶 / 同 LT / 同 eCPM 钳),但**只读、不入账、不写库**。
客户端每满 10 秒观看(或换条 eCPM 变化)拉一次;两次之间用 `next_unit_coin × 本份进度` 在本地做平滑插值显示。
## 鉴权
需要 Bearer token。
## 请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `segments` | array | 是 | 截至此刻的**逐条广告段**(时序,至少 1 项;含在播那条的部分秒数),每项 `{ecpm: string, seconds: int}` |
## 响应
| 字段 | 类型 | 说明 |
|---|---|---|
| `coin` | int | 此刻结束将发放的累计金币(已满 10 秒的各份之和) |
| `unit_count` | int | 已累计满的份数(封顶 12) |
| `next_unit_coin` | int | 下一份(第 `unit_count+1` 份)的金币值,按当前在播段 eCPM 算;封顶后为 0。供小球在本份未满区间内平滑插值 |
| `session_full` | bool | 是否已达单场份数上限(12)→ 小球应停涨 |
| `would_status` | string | 若此刻结束的结算预判:`granted` / `too_short`(凑不满一份) / `daily_capped`(已达每日条数上限,整场将发 0) |
## 与 feed-reward 的关系
- 同样按 `segments` 跨条进位每满 10 秒一份、每份用所在段 eCPM 计价(详见 [feed-reward 计算口径](./ad-feed-reward.md#计算口径))。
- preview 的 `coin` 等于「用相同 segments 调 feed-reward 实发的 `coin`」——小球显示值即整场结束实发额(除非中途 `would_status` 提示 `daily_capped`,那种情况整场实发 0)。
- preview **不消耗每日上限、不写任何表**,可高频调用(限流 600/分)。
## 限流
600 次 / 分钟(`ad-feed-preview`)。
+19 -10
View File
@@ -1,6 +1,8 @@
# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币
点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。
点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。
播放过程中查询「此刻将发放多少金币」(金币小球实时显示用)见 [feed-reward/preview](./ad-feed-reward-preview.md),与本接口同一计价口径但只读不入账。
## 鉴权
@@ -8,37 +10,44 @@
## 请求体
计价优先用 `segments` 逐条精确结算;未传 `segments` 时回退用 `ecpm` + `duration_seconds` 当**单段**(旧客户端兼容)。两者都不传 → 422。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---:|---|
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
| `ecpm` | string | | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
| `duration_seconds` | int | | **整场累计观看秒数**(轮播各条相加) |
| `segments` | array\|null | 否* | **逐条广告段**(时序),每项 `{ecpm: string, seconds: int}`。新客户端传它做按条精确结算 |
| `ecpm` | string\|null | 否* | **[回退]** 本场代表 eCPM(穿山甲 getEcpm 原值,分/千次展示,非元);传了 `segments` 时仅作落库代表值 |
| `duration_seconds` | int | 否 | **[回退]** 整场累计观看秒数;传了 `segments` 时忽略。默认 0 |
| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) |
| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) |
| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) |
| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit);供广告收益报表按代码位聚合金币 |
| `aborted` | bool | 否 | 用户中途 ✕ 关闭广告(未走完比价):整场不发,仅记 `closed_early`。默认 `false` |
\* `segments``ecpm` 至少传其一。
## 响应
| 字段 | 类型 | 说明 |
|---|---|---|
| `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` |
| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) |
| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场<10s 凑不满一份) / `closed_early`(用户中途关闭) |
| `coin` | int | 本次发放金币;非 granted 为 0 |
| `unit_count` | int | 按 10 秒折算出的奖励份数 |
| `unit_count` | int | 折算出的奖励份数(封顶 12 |
| `daily_limit` | int | 每日信息流展示次数上限,默认 500 |
## 计算口径
- 奖励份数`整场总时长 // 10`
- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币
- eCPM 档:`0-100=0.1``101-200=0.3``201-400=0.4``>400=0.6`
- LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`
- **奖励份数(跨条进位)**:按 `segments` 时序遍历,维护一个连续秒数计数器,每满 10 秒结算一份;换条时不足一份的零头进位到下一条。份数封顶 **12 份/场**(防刷硬闸,替代旧的「单事件时长钳 120s」)
- **每份归属的 eCPM**:该份「满 10 秒那一刻正在播的那条广告」的 eCPM
- **单份奖励**`eCPM / 1000 × 因子1(eCPM 档) × 因子2(账号累计份序号) × 10000`,四舍五入为整数金币
- eCPM 档:`0-100=0.1``101-200=0.3``201-400=0.4``>400=0.6`eCPM 钳顶 ¥500 CPM
- LT 档(按**账号累计已发份数**,不按天重置):第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`
- 旧客户端单段口径 `[(ecpm, duration_seconds)]` 是上述的特例,份数 = `min(duration//10, 12)`,结果与改造前一致。
## 数据写入
- `ad_feed_reward_record` 新增一行。
- `ad_feed_reward_record` 新增一行`ecpm_raw` 存代表 eCPM,通常为末段)
- 入账时 `coin_account` 增加余额。
- 入账时 `coin_transaction` 写入 `biz_type=feed_ad_reward`
-40
View File
@@ -1,40 +0,0 @@
# cps_activity — CPS 可推广活动池(运营预存的券/物料)
> 模型 `app/models/cps_activity.py` · 仓库 `app/admin/repositories/cps.py`(`create_activity` / `list_activities` / `get_activity` / `delete_activity` / `list_activity_images`) · 接口 admin `POST /admin/api/cps/activities``GET /admin/api/cps/activities``DELETE /admin/api/cps/activities/{id}``POST /admin/api/cps/upload-image``GET /admin/api/cps/activity-images` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
运营把常推的活动(券)预存成池,生成群发链接时从池里挑,省得每次记物料 ID。数据**全由运营在后台手填/上传**,无外部同步。在 CPS 群发漏斗里它是**最上游的"卖什么"**:活动(本表)× 群([`cps_group`](./cps_group.md))经"批量生成短链"组合出一条条 [`cps_link`](./cps_link.md)。一条活动可被多个群、多次复用生成 link(一对多,无外键)。
## 用在哪 / 增删改查
- **C(插入)**:`POST /admin/api/cps/activities`(`create_activity`,需 `operator` 角色)。按 `platform` 分支校验:`meituan` 必须填 `act_id``product_view_sign` 之一;`taobao`/`jd` 必须填 `payload`,且 `taobao` 还必须有 `image_url`(落地页图)。淘宝图经 `POST /admin/api/cps/upload-image`(`media.save_cps_image`)上传得绝对 URL,或经 `GET /admin/api/cps/activity-images` 选已有。无幂等键,重复提交建多条。
- **U(更新)**:**无更新端点**。改活动 = 删了重建。`status`(active/archived)列存在但目前没有切换它的路由(待确认是否前端规划)。
- **R(读)**:`GET /admin/api/cps/activities`(`list_activities`,游标分页,可按 `platform`/`status` 过滤,登录即可);生成 link 时 `get_activity` 按 id 取;淘宝落地页渲染时 `cps_redirect``db.get(CpsActivity, link.activity_id)``image_url`;`list_activity_images` 取所有 distinct 非空 `image_url` 供新建复用。
- **D(删除)**:`DELETE /admin/api/cps/activities/{id}`(`delete_activity`,`operator`)。**硬删,不级联** `cps_link`——已发出去的短链继续可用;淘宝落地页若取不到活动(被删),`cps_redirect` 用兜底默认图 `/media/taobao_landing.jpg`
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `cps_link.activity_id` 语义引用(非 FK) |
| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定转链/落地方式,也决定下面哪些字段必填 |
| `name` | String(128) | NOT NULL | 活动名,运营自定,后台列表/审计展示 |
| `act_id` | String(64) | nullable | **美团**联盟「我要推广-活动推广」第一列的物料 ID(actId)。转链 `get_referral_link` 入参 |
| `product_view_sign` | String(128) | nullable | **美团**商品券 productViewSign,与 `act_id` 二选一转链(多数活动用 `act_id`) |
| `payload` | Text | nullable | **淘宝**=整段淘口令文本(落地页原样供复制);**京东**=推广链接(落地页 302 跳)。美团不用此列(用 `act_id`)。生成 link 时直接作 `target_url` |
| `image_url` | String(512) | nullable | **淘宝**落地页主视觉图,存绝对 URL(基于 `CPS_REDIRECT_BASE`,跨域 admin-web 可显示)。落地页按活动展示对应图 |
| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`(目前无切换入口,主要靠删) |
| `remark` | String(256) | nullable | 运营备注 |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间。**无 `updated_at`**(本表无更新路径) |
## 关系 / Join Key
- `id``cps_link.activity_id`(一活动多 link,**语义关联,无 ForeignKey**;删活动不级联删 link)。
- `id``cps_redirect` 渲染淘宝落地页时按 `link.activity_id` 反查本表取 `image_url`
- 与 [`cps_group`](./cps_group.md) 无直接列关系:两者在"批量生成短链"(`generate_referral_links`)时按 **platform 是否在群范围内**组合,产物是 [`cps_link`](./cps_link.md)。
## 索引与约束
- PK `id`
- **无唯一约束、无额外索引、无外键**(运营低频、量级小,按 id 主键访问即可)。
## 注意
- **`platform` 是多平台演进的核心**:初版(commit `277f9b1`)只有美团,字段就是 `act_id` + `product_view_sign`;`3a40f61` 接淘宝/京东时**复用本表**,只加了 `payload`(异构标识塞一列);`d55f47f` 再加 `image_url` 把淘宝落地页图"做活"(此前是写死一张图)。所以三平台的"卖点"挤在同一张表,靠 `platform` 分流——这是省表的刻意取舍。
- **美团二选一**:`act_id``product_view_sign` 对应 `get_referral_link` 的两种转链入参,router 层强制至少填一个。
- **淘宝必须有图**:`taobao` 落地页是我们自渲染的 H5(`cps_redirect._taobao_landing_html`),没图页面就空,故建活动时强制。`d55f47f` 的迁移把存量淘宝活动的 `image_url` 回填成此前写死的 `/media/taobao_landing.jpg`,无缝衔接。
- **`image_url` 存绝对 URL 的原因**:落地页域(`CPS_REDIRECT_BASE`)与 admin-web 跨域,存绝对值两边都能取(`media.to_abs_media_url`)。dev 未配 `CPS_REDIRECT_BASE` 时退化为相对路径。
-37
View File
@@ -1,37 +0,0 @@
# cps_click — CPS 短链点击事件(/c/{code} 每次访问/复制记一条)
> 模型 `app/models/cps_link.py`(`CpsClick`,与 `CpsLink` 同文件) · 仓库 `app/repositories/cps_link.py`(`record_click` / `click_stats_by_group`) · 接口 用户 `GET /c/{code}`(记 `visit`)、`POST /c/{code}/copy`(记 `copy`,`app/api/v1/cps_redirect.py`);统计 admin `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
群发短链 [`cps_link`](./cps_link.md) 每被点一次就在此记一条。在 CPS 群发漏斗里它是**"用户点了"这一步**:用户点 `/c/{code}` 进落地/被跳转记 `visit`,淘宝落地页点"复制口令"记 `copy`。据此统计点击 PV/UV(按群/活动/时段),配合 [`cps_order`](./cps_order.md) 的下单/佣金构成"点击→下单→佣金"漏斗。`group_id`/`sid` 在本表**冗余一份**(来自 link),统计直接按群聚合、不必 join `cps_link`
## 用在哪 / 增删改查
- **C(插入)**:`record_click`(`app/repositories/cps_link.py`),由 `cps_redirect` 两处触发——`GET /c/{code}`(`cps_landing`)记 `event_type=visit`;`POST /c/{code}/copy`(`cps_copy`,淘宝落地页点复制按钮 fetch 回调)记 `event_type=copy`。写入时把 link 的 `group_id`/`sid` 冗余进来,`ua` 截断到 500。**记点击失败被 `_record` 吞掉绝不阻断用户跳转/落地**(公网无鉴权,任何人点都要能跳)。无幂等——每次点都写新行(PV 即行数)。
- **U / D**:**无**。点击是只追加的事件流。
- **R(读)**:`click_stats_by_group`(`GET /admin/api/cps/stats``group_stats` 调用)按 `group_id` 聚合 → `{pv, uv, copy_pv, copy_uv}`;UV 按 `(ip, ua)` 近似去重,`visit` 计 pv/uv、`copy` 计 copy_pv/copy_uv。聚合在 Python 侧做(量级小、跨 PG/SQLite 无方言坑)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `link_id` | Integer | index, NOT NULL | 来源短链 = `cps_link.id`(语义关联,非 FK) |
| `group_id` | Integer | index, NOT NULL | **冗余**自 link.group_id;按群聚合点击快,免 join cps_link |
| `sid` | String(64) | index, **nullable** | **冗余**自 link.sid;美团有、淘宝/京东无 |
| `event_type` | String(16) | NOT NULL, default/server_default `visit` | `visit`(进落地页/被 302 跳) / `copy`(淘宝落地页点了"复制口令") |
| `ip` | String(64) | nullable | 客户端 IP(取 `X-Forwarded-For` 首段,否则 `request.client.host`),UV 去重键之一 |
| `ua` | String(512) | nullable | User-Agent,入库截断到 500;UV 去重键之一 |
| `clicked_at` | DateTime(tz) | server_default now(), index, NOT NULL | 点击时间,统计按时间窗过滤 |
## 关系 / Join Key
- `link_id``cps_link.id`(多对一,**语义关联,无 ForeignKey**)。
- `group_id``cps_group.id``sid``cps_group.sid`——均**冗余字段**,直接按群聚合用,非 FK。
- 与 [`cps_order`](./cps_order.md) 无直接关联:点击与订单在 `group_stats` 里**各按 `group_id`/`sid` 聚合后并到同一群行**,共同组成漏斗,但两表之间无 join key(无法把某次点击对到某笔订单——CPS 联盟不回传点击 id)。
## 索引与约束
- PK `id`;index `link_id``group_id``sid``clicked_at`
- **无唯一约束、无外键**(纯事件流,PV 即行数,允许重复)。
## 注意
- **`event_type` 是接淘宝时新增的**:初版(`277f9b1`)只有 visit(点了就 302 跳美团,记一条即可);`3a40f61` 接淘宝后,淘宝落地页是 H5、用户要点按钮"复制口令"才算转化,故迁移 `cps_v2_platforms``event_type`(server_default `visit` 回填存量),`copy` 单独统计成 `copy_pv/copy_uv`。同迁移把 `sid` 改 nullable(淘宝/京东点击无 sid)。
- **`group_id`/`sid` 冗余的理由**:统计是高频读路径,直接 `WHERE group_id=?` 聚合,省掉 `cps_click → cps_link` 的 join;代价是写入时多带两列(`record_click` 从 link 拷)。这与 model docstring 明说的"统计直接按群聚合、不必 join cps_link"一致。
- **UV 是近似值**:按 `(ip, ua)` 去重,无 Cookie/设备指纹;同一人换网络/浏览器会重复计 UV。够运营看趋势,不作精确口径。
- **记点击绝不阻断**:`_record``try/except: pass` 包住,落地/跳转优先级高于统计——丢几条点击可接受,挡住用户领券不可接受。
-38
View File
@@ -1,38 +0,0 @@
# cps_group — CPS 推广群(一个微信群一条,sid 是美团渠道追踪位)
> 模型 `app/models/cps_group.py` · 仓库 `app/admin/repositories/cps.py`(`create_group` / `update_group` / `delete_group` / `get_group` / `get_group_by_sid` / `list_groups`、统计 `group_stats`) · 接口 admin `POST/GET/PATCH/DELETE /admin/api/cps/groups`、统计 `GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每个发券的微信群一条记录。核心字段是 `sid`——**美团联盟二级渠道追踪位**:发链接时塞进推广链接(`get_referral_link`),订单回来按 `sid` 归到这个群对账(`query_order` → [`cps_order`](./cps_order.md))。在 CPS 群发漏斗里它是**"发给谁/在哪个渠道"**:群(本表)× 活动([`cps_activity`](./cps_activity.md))组合生成 [`cps_link`](./cps_link.md);点击([`cps_click`](./cps_click.md))与订单都靠 `sid`/`group_id` 归到本群,统计接口 `group_stats` 即以群为行做"点击→下单→佣金"漏斗。
## 用在哪 / 增删改查
- **C(插入)**:`POST /admin/api/cps/groups`(`create_group`,`operator`)。`platforms` 至少选一个,非法平台 400。**`sid` 仅当 `platforms``meituan` 时才有**:运营填了就用(校验全局唯一,重复 409),没填则先占位 `tmp<uuid>`、flush 拿到 id 后回填成 `g<id>`;纯淘宝/京东群 `sid``None`(那俩平台不支持 sid)。无业务幂等键(同名可重复建),唯一性只在 `sid` 上。
- **U(更新)**:`PATCH /admin/api/cps/groups/{id}`(`update_group`,`operator`)。可改 `name`/`platforms`/`member_count`/`status`/`remark`。**`sid` 不可改**(改了会断开历史订单的归群)。
- **R(读)**:`GET /admin/api/cps/groups`(`list_groups`,游标分页,`keyword` 模糊匹配 name 或 sid、`status` 过滤);生成 link 时 `get_group` 取群拿 `sid`;统计 `group_stats` 遍历所有群、按 `sid` 关联订单、按 `group_id` 关联点击。
- **D(删除)**:`DELETE /admin/api/cps/groups/{id}`(`delete_group`,`operator`)。**硬删,不级联**已生成的 [`cps_link`](./cps_link.md)(已发链接继续可用);该群历史 [`cps_click`](./cps_click.md)/[`cps_order`](./cps_order.md) 按 `sid`/`group_id` 仍在库,删群后这些 sid 在统计里降级为"未归群"行(`group_id=None`)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `cps_link.group_id``cps_click.group_id` 语义引用(非 FK) |
| `sid` | String(64) | **UNIQUE, index, nullable** | 美团渠道追踪位,仅字母+数字 ≤64(美团限制,schema 正则 `^[A-Za-z0-9]+$`)。**仅含 meituan 的群有**;留空自动 `g<id>`;纯淘宝/京东群为 `None`。订单/点击按它归群 |
| `name` | String(128) | NOT NULL | 群名,运营自定 |
| `platforms` | JSON / JSONB | NOT NULL, default `[]` | 该群发哪些平台(多选):`["meituan","taobao","jd"]`。含 `meituan` 才需要 `sid`。生成 link 时校验"活动平台必须在群范围内" |
| `member_count` | Integer | nullable | 群人数,运营填,作转化率分母(可空) |
| `status` | String(20) | NOT NULL, default `active` | `active` / `archived`。统计 `group_stats` 只列 `active` 群 |
| `remark` | String(256) | nullable | 运营备注 |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 创建时间(无 `updated_at`) |
## 关系 / Join Key
- `id``cps_link.group_id``cps_click.group_id`(一群多 link/多点击,**语义关联,无 ForeignKey**)。
- `sid``cps_order.sid`(订单按 sid 归群对账,**语义关联,非 FK**);`sid``cps_link.sid` / `cps_click.sid`(生成 link 时把群 sid 冗余进 link/click)。
- 与 [`cps_activity`](./cps_activity.md) 无直接列关系,经"批量生成短链"按平台组合。
## 索引与约束
- PK `id`;**UNIQUE+index `sid`**(`uq_cps_group_sid` + `ix_cps_group_sid`)。
- **无外键**
## 注意
- **`sid` 从"必填"放宽到"可空"是多平台演进的关键改动**:初版(`277f9b1`)`sid` NOT NULL + UNIQUE(每个美团群必有渠道位);`3a40f61` 接淘宝/京东后,这俩平台**没有 sid 概念**,迁移 `cps_v2_platforms``sid` 改 nullable,并加 `platforms` 列(存量群 server_default 回填 `["meituan"]`)。理解本表必须记住:**sid 只对美团有意义**,对账/归群全链路只走美团群。
- **`g<id>` 自动 sid**:运营不填 sid 时服务端生成 `g<群id>`,既保证唯一又人类可读。先写 `tmp<uuid>` 占位是因为建群事务里 id 还没生成(flush 前拿不到),拿到 id 后二次 flush 回填。
- **删群不级联**是刻意的:已发到微信群里的短链不能因为后台删群就失效;历史订单/点击也要保留用于对账。代价是删群后其 `sid` 的订单在 `group_stats` 里变成"未归群"独立行(如历史的 `wonderableai`,见 [`cps_order`](./cps_order.md) 注意)。
- **`platforms` 用 JSON 列**:遵循全库约定 `JSON().with_variant(JSONB(), "postgresql")`——PG 上 JSONB,SQLite 退化通用 JSON。
-38
View File
@@ -1,38 +0,0 @@
# cps_link — CPS 群发短链(发群用的 /c/{code},外套一层我们的短链)
> 模型 `app/models/cps_link.py`(`CpsLink`) · 仓库 `app/repositories/cps_link.py`(`create_link` / `get_by_code`) · 接口 admin 生成 `POST /admin/api/cps/referral-links`、用户落地 `GET /c/{code}`(`app/api/v1/cps_redirect.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
群发券链接外套一层我们自己的短链 `/c/{code}`,目的是**统计点击 + 控制落地**。在 CPS 群发漏斗里它是**"发出去的那条链接"**:由 群([`cps_group`](./cps_group.md))× 活动([`cps_activity`](./cps_activity.md))在后台"批量生成短链"产出,运营把 `/c/{code}` 复制到微信群。用户一点 → 记一条 [`cps_click`](./cps_click.md) → 按平台 302 跳 `target_url`(美团短链/京东链接)或返回淘宝落地页。`cps_click``cps_link` 同文件(`models/cps_link.py`),详见 [`cps_click.md`](./cps_click.md)。
## 用在哪 / 增删改查
- **C(插入)**:`POST /admin/api/cps/referral-links`(`generate_referral_links`,`operator`)批量生成——按 `group_id` + 一组 `activity_ids`,对每个活动调 `_gen_one_link`:美团走 `meituan.get_referral_link(act_id/product_view_sign, sid=群.sid)` 拿短链作 `target_url`(无群 sid 则 400);淘宝/京东直接用活动 `payload``target_url`。落库走 `create_link`,短码 `code``_gen_code` 生成(去易混字符,冲突重试 5 次仍冲突用 12 位兜底)。`code` 全局唯一即天然幂等键,但**每次调用都新建 link**(同群同活动重复生成会产生多条不同 code)。
- **U(更新)**:**无**。link 一旦生成不改(改了等于换链接)。
- **R(读)**:`GET /c/{code}`(`cps_landing`)落地时 `get_by_code` 取 link → 决定跳转/落地;`POST /c/{code}/copy` 同样按 code 取。后台无 link 列表端点(运营只看生成结果与按群统计)。
- **D(删除)**:**无删除路径**。删群/删活动都不级联删 link(已发链接保持可用)。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | 被 `cps_click.link_id` 语义引用(非 FK) |
| `code` | String(16) | **UNIQUE, index, NOT NULL** | 短码,发群链接 `/c/{code}` 的 code。字母表去掉 `0/O/1/l/I`(肉眼复制不易错),默认 8 位 |
| `group_id` | Integer | index, NOT NULL | 归属群 = `cps_group.id`(语义关联,非 FK) |
| `activity_id` | Integer | index, NOT NULL | 对应活动 = `cps_activity.id`(语义关联,非 FK);淘宝落地页反查活动取图 |
| `sid` | String(64) | index, **nullable** | 渠道追踪位,**仅美团 link 有**(= 群 sid);淘宝/京东无 sid。生成时从群冗余进来 |
| `platform` | String(20) | NOT NULL, default `meituan` | `meituan` / `taobao` / `jd`。决定 `cps_landing` 是 302 跳还是渲染淘宝落地页 |
| `target_url` | String(2048) | NOT NULL | 跳转/落地目标:美团短链 / 京东链接(302 跳) / 淘宝整段淘口令文本(H5 落地页复制) |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 生成时间(无 `updated_at`) |
## 关系 / Join Key
- `group_id``cps_group.id`;`activity_id``cps_activity.id`;`sid``cps_group.sid`——**全是语义关联,无 ForeignKey**(删群/删活动都不报错、不级联)。
- `id``cps_click.link_id`(一 link 多点击);记点击时 `record_click` 把 link 的 `group_id`/`sid` 冗余进 [`cps_click`](./cps_click.md)。
- `target_url` 间接关联 [`cps_order`](./cps_order.md):美团 `target_url` 携带群 sid,用户经它下单后订单回来的 `sid` 即此群 sid——但 link 与 order **没有直接列关联**,只经 `sid` 在群维度汇合。
## 索引与约束
- PK `id`;**UNIQUE+index `code`**(`uq_cps_link_code` + `ix_cps_link_code`);index `group_id``activity_id``sid`
- **无外键**
## 注意
- **`target_url` 从 1024 加长到 2048**:初版(`277f9b1`)只装美团短链(够短),`3a40f61` 接淘宝后要装**整段淘口令文本**(较长),迁移 `cps_v2_platforms``target_url` 放宽到 2048,同时 `sid` 改 nullable(淘宝/京东 link 无 sid)。
- **`sid` 冗余进 link 又冗余进 click**:统计时按群聚合点击不必 `join cps_link`(见 `click_stats_by_group` 直接读 `cps_click.group_id`);`sid` 留在 link 上则是为了生成时一次性带齐,跳转无需再查群。
- **短码去易混字符**:`_CODE_ALPHABET` 排除 `0/O/1/l/I`,因为短链会被运营/用户肉眼复制粘贴,降低抄错率;碰撞重试 5 次后降级 12 位,概率近 0。
- **三平台落地分叉**(`cps_redirect.cps_landing`):美团/京东 → 302 跳 `target_url`;淘宝 → 不能 302(淘口令不是 URL),改返回自渲染 H5 落地页(图来自活动 `image_url`,按钮复制 `target_url` 淘口令)。code 不存在时兜底跳 `https://www.meituan.com/`
-51
View File
@@ -1,51 +0,0 @@
# cps_order — CPS 对账订单(美团联盟 query_order 拉回的订单明细)
> 模型 `app/models/cps_order.py` · 仓库 `app/admin/repositories/cps.py`(`reconcile_orders` / `_map_order_fields` / `list_orders`、统计 `group_stats`) · 接口 admin `POST /admin/api/cps/orders/reconcile``GET /admin/api/cps/orders``GET /admin/api/cps/stats` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
从美团联盟 `query_order` 按时间窗拉回、按 `sid` 归群的订单明细,是 CPS 群发漏斗的**最下游"赚了多少佣金"**:用户点 [`cps_link`](./cps_link.md)(携群 [`cps_group`](./cps_group.md) 的 `sid`)下单后,美团把订单连同 `sid` 回传,这里按 `sid` 归群做对账,与 [`cps_click`](./cps_click.md) 的点击量在 `group_stats` 汇成"点击→下单→佣金"漏斗。**仅美团有此表**——淘宝/京东无对账 API,统计里对账字段显示 `-`
## 用在哪 / 增删改查
- **C/U(upsert)**:`POST /admin/api/cps/orders/reconcile`(`reconcile_orders`,需 `finance` 角色)。调 `meituan.query_order(sid?, start_time, end_time, page, limit=100)` 分页拉单(`max_pages=200` 防死循环),每条经 `_map_order_fields` 转字段,按 `order_id` **upsert**:不存在则 insert,存在则逐字段覆盖(订单状态会随时间变 付款→完成→结算/退款,重复拉则更新)。返回 `{fetched, inserted, updated, pages}``order_id` 全局唯一即幂等键。
- **R(读)**:`GET /admin/api/cps/orders`(`list_orders`,游标分页,可按 `sid`/`mt_status` 过滤);统计 `group_stats` 拉全部订单按 `sid` 分桶,算 `order_count`/`gmv`/`est_commission`/`settled_commission` 等。
- **D**:**无**。
## 字段
> 字段对齐 `query_order` 实测返回:金额(payPrice/profit)是「元」字符串 → 入库 `_yuan_to_cents` 转「分」(全站口径);时间(payTime/updateTime)是秒级时间戳 → `_ts_to_dt` 转 tz-aware UTC。
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join / 源字段) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `order_id` | String(64) | **UNIQUE, index, NOT NULL** | 美团订单号(加密串),`orderId`。upsert 幂等键 |
| `sid` | String(64) | index, **nullable** | 渠道追踪位 = 群 `sid`,源 `sid`。**按它归群聚合**;历史无 sid 订单为空 |
| `act_id` | String(64) | index, nullable | 活动物料 ID,源 `actId`(转字符串) |
| `biz_line` | Integer | nullable | 业务线,源 `businessLine``1=外卖` |
| `trade_type` | Integer | nullable | 源 `tradeType``1=cps 2=cpa` |
| `pay_price_cents` | Integer | nullable | 付款金额(分),源 `payPrice`(元)。统计算 GMV |
| `commission_cents` | Integer | nullable | **预估佣金**(分),源 `profit`(元)。统计算预估/已结算佣金 |
| `commission_rate` | String(16) | nullable | 佣金率,源 `commissionRate``"300"=3%``"10"=0.1%`(原样字符串,前端解释) |
| `refund_price_cents` | Integer | nullable | 退款金额(分),源 `refundPrice`(元) |
| `refund_profit_cents` | Integer | nullable | 退款佣金(分),源 `refundProfit`(元) |
| `mt_status` | String(8) | index, nullable | 美团订单状态,源 `status`:`2`付款 `3`完成 `4`取消 `5`风控 `6`结算 |
| `invalid_reason` | String(128) | nullable | 失效原因,源 `invalidReason` |
| `product_name` | String(512) | nullable | 商品名,源 `productName`(超 500 截断) |
| `pay_time` | DateTime(tz) | index, nullable | 付款时间,源 `payTime`(秒级 ts)。统计按时间窗过滤的就是它 |
| `mt_update_time` | DateTime(tz) | nullable | 美团侧更新时间,源 `updateTime`(秒级 ts) |
| `raw` | JSON / JSONB | NOT NULL, default `{}` | `query_order` 单条原始 dataList,留底排查/补字段 |
| `first_seen` | DateTime(tz) | server_default now(), NOT NULL | 首次拉到入库时间(本地) |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | 最近 upsert 时间(本地) |
## 关系 / Join Key
- `sid``cps_group.sid`(订单按 sid 归群,**语义关联,无 ForeignKey**)。`group_stats` 即据此把订单挂到群行。
- 与 [`cps_link`](./cps_link.md)/[`cps_click`](./cps_click.md) **无直接列关联**:下单链路是"美团短链(带 sid)→ 美团 App 下单",订单只回传 `sid`、不回传我们的 `link.code` 或某次点击 id。所以点击与订单只能在**群(sid)维度**汇合,**无法把单笔订单对到单次点击**。
- **本表无 `user_id`**:CPS 联盟订单是别人(被推广的微信群用户)在美团下的单,与本 App 的 `user` 表无关,纯渠道佣金对账,不归属本 App 用户。
## 索引与约束
- PK `id`;**UNIQUE+index `order_id`**(`uq_cps_order_order_id` + `ix_cps_order_order_id`);index `sid``act_id``mt_status``pay_time`
- **无外键**
## 注意
- **状态决定佣金口径**(`group_stats._order_agg`):`4`取消/`5`风控**不计佣金**(`_INVALID_STATUS`);`6`结算才是佣金**真正到账**(`_SETTLED_STATUS`)。统计区分 `order_count`(有效=非取消非风控)、`settled_count`(已结算)、`canceled_count`,佣金分 `est_commission`(有效单预估)与 `settled_commission`(结算单)。
- **"未归群"独立行**:`group_stats` 遍历完所有群后,对剩下的、有订单但 `sid` 不属任何现存群的 sid(历史遗留如 `wonderableai`、或别处来源、或群被删),单列一行 `group_id=None`、有对账无点击。所以本表 `sid` 可空/可孤立是设计内的,不是脏数据。
- **金额/时间统一转换的原因**:`query_order` 返回金额是「元」字符串、时间是秒级 ts;`_yuan_to_cents`(Decimal 防浮点,`"null"`/空 → None)与 `_ts_to_dt`(转 tz-aware UTC,前端按北京展示)在入库时归一,与全站"金额存分、时间存 tz-aware"口径对齐。`raw` 整条留底,字段不够时不用重拉。
- **upsert 而非 append**:同一订单会被多次拉到(状态变化),按 `order_id` 覆盖即可拿到最新状态;`reconcile` 可重复跑(幂等)。
- **本表自始至终只服务美团**:初版(`277f9b1`)就定型,`3a40f61` 接淘宝/京东时**没动本表**——那俩平台无对账 API,统计里对账列直接给 `None`(前端显示 `-`)。这是"对账 = 美团专属"的边界。
-39
View File
@@ -1,39 +0,0 @@
# invite_fingerprint — 邀请指纹归因(剪贴板兜底)
> 模型 `app/models/invite_fingerprint.py`(`InviteFingerprint`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/landing-track`(写,无鉴权)、`POST /api/v1/invite/bind`(读,指纹反查分支) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一行 = 一次落地页访问的设备指纹快照。**剪贴板归因的兜底**:剪贴板 deferred-deeplink 是邀请归因主路径,但从"点下载"到"App 首启"之间剪贴板可能被覆盖、归因丢失。此表在落地页访问时先把 `(inviter, ip, screen, device_model, ua)` 记下来,被邀请人登录后剪贴板没拿到邀请码时,用 `(ip, device_model)` 反查 7 天内最近一条匹配 → 拿出邀请人 → 走 `bind(channel='fingerprint')`。任务来源见 #31 好友邀请任务 3。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/invite/landing-track`(`landing_track`,**无需鉴权**,浏览器没 token)→ `invite_repo.record_fingerprint()`。B 浏览器打开 `dl.html?ref=<码>` 时 JS POST,服务端从 HTTP 头拿 `ip`(`_client_ip`,优先 `X-Forwarded-For` 第一段)、解析 UA 拿 `device_model`(`_parse_device_model`Android 正则抓 `;...Build/` 前 token = `Build.MODEL`),连同 JS 上报的 `screen` 一起入库。`record_fingerprint` 自行 commit(调用方此前无其它写)。无效码 / 拿不到 IP 走 silent 返回(`invalid_code`/`no_ip`,不抛 5xx 影响下载流程)。
- **R(反查)**:`POST /api/v1/invite/bind`(`bind_invite`)的指纹分支 → `resolve_inviter_by_fingerprint()`。仅在请求**无邀请码、有 `fingerprint`** 时触发:`WHERE ip=? AND device_model=? AND created_at > now - INVITE_FP_WINDOW_DAYS(7d)` `ORDER BY created_at DESC LIMIT 1` → 命中则取 `inviter_user_id` 对应用户 → 用其 `invite_code` 走原 `bind()`(同样过幂等/自邀/新人闸/发币逻辑);未命中返回 `fp_not_found`
- **U / D**:无。只增不改不删。
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 落地页对应的邀请人(由 `?ref=<码>` 解析得到) |
| `ip` | String(64) | NOT NULL | 访问者 IP(服务端从 HTTP 头拿;nginx 反代时走 `X-Forwarded-For`)。入库 `[:64]`。**反查参与匹配** |
| `device_model` | String(64) | NOT NULL, default `""` | 设备型号(如 `PJF110``23046PNC9C`)。浏览器侧由 UA 正则解析,客户端侧 = `Build.MODEL`,跨端对齐。入库 `[:64]`。**反查参与匹配** |
| `screen` | String(32) | NOT NULL, default `""` | 屏幕分辨率 `"1080x2400"`(浏览器 `screen.w×h`,客户端 `DisplayMetrics`)。入库 `[:32]`。**只入库不反查**(见注意) |
| `user_agent` | Text | NOT NULL, default `""` | 完整 UA 串(debug / 排查"匹配不上"用,不参与匹配)。入库 `[:2000]` 防异常长 UA |
| `created_at` | DateTime(tz) | server_default now(), index | 落地页访问时间(= 7 天窗口判据 + 反查倒序键) |
## 关系 / Join Key
- `inviter_user_id``user.id`(硬 FK,多对一):一个邀请人的落地页可被多次访问。
- **反查组合键 = `(ip, device_model)` + `created_at > cutoff`**:等值匹配 IP 与型号、再取窗口内最近一条。注意 `screen` 虽进了组合索引最左前缀,但 SQL 不拿它做 WHERE 条件。
- 反查结果不直接 join 本表→`user`,而是 `db.get(User, fp.inviter_user_id)` 拿邀请人,再回 `invite_relation``bind()`
## 索引与约束
- PK `id`
- `ix_invite_fp_match``ip, screen, device_model, created_at`):反查主索引。`/bind` 兜底分支按 `ip + device_model + created_at` 范围扫 + 倒序 LIMIT 1SQLite/PG 优化器都能用上该复合索引。
- `ix_invite_fp_inviter``inviter_user_id`):统计/排查用("这个邀请人的落地页被谁访问过")。
## 注意
- **`screen` 只入库不反查(关键设计,`resolve_inviter_by_fingerprint` docstring)**:浏览器算物理像素走 `CSS × devicePixelRatio`、Android 走 `DisplayMetrics.widthPixels` 真实硬件值,两端浮点 round 必然 ±1 像素漂移(DPR 不严格是整数)→ 严格匹配注定撞不上。而**同 `device_model` 必同 `screen`**(同型号同硬件),故 `screen` 是冗余字段,删它不损精度。所以匹配只用 `(ip, device_model)`
- **组合索引为何含 `screen`**:索引列序是 `(ip, screen, device_model, created_at)`,但 WHERE 只用 `ip`/`device_model`/`created_at``screen` 夹在中间会打断"`ip` 之后直接接 `device_model`"的最左连续前缀,理论上略损此查询的索引效率(待确认:实测扫描量是否受影响;当前数据量下无感)。若后续优化,建议把索引调成 `(ip, device_model, created_at)`
- **7 天窗口(`rewards.INVITE_FP_WINDOW_DAYS`)的取舍**:太短(24h)覆盖不到"晚上看链接、第二天装";太宽(30d)IP/设备会漂、匹配错率上升;7 天兼顾"看广告→使用"周期与匹配精度。
- **撞错概率**:只有"同一 IP 下、同型号手机、7 天内同时被不同人邀请"才会归因到错的邀请人,中国家庭/公司 WiFi 场景概率极低;属可接受的工程取舍而非 bug。
- **与 invite_relation 的关系**:本表只是**线索库**,不发奖、不是结果。真正落地是反查出邀请人后回到 [invite_relation](./invite_relation.md) 的 `bind()`,写一行 `channel='fingerprint'` 并发币;本表本身不参与发奖与幂等。
- **迁移**`invite_fingerprint_table``down_revision=0cf18d590b1d`),晚于 `invite_code_and_relation`#24 的表先建)。
-46
View File
@@ -1,46 +0,0 @@
# invite_relation — 好友邀请绑定关系(注册即生效)
> 模型 `app/models/invite.py`(`InviteRelation`) · 仓库 `app/repositories/invite.py` · 接口 `app/api/v1/invite.py`:`POST /api/v1/invite/bind`(写)、`GET /api/v1/invite/me`(战绩)、`GET /api/v1/invite/invitees`(列表) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
一行 = 一次**成功的**邀请绑定。被邀请人(B)用邀请人(A)的邀请码完成绑定后写入,**注册即生效**:同一事务里给 A、B 各发 1 万金币(= 1 元,可提现)。数据来自 `bind()`,触发它的归因来源有三种(`channel`)。这是邀请功能的**结果表 / 账本**;邀请码本身存在 `user.invite_code`,不在这张表。
## 用在哪 / 增删改查
- **C(插入)**:`POST /api/v1/invite/bind`(`bind_invite`)→ `invite_repo.bind()`。过四道防线后建一行 `status='effective'`,**同事务**复用 `wallet.grant_coins` 给双方发金币(`grant_coins` 只 flush,由 `bind()` 统一 commit)→ "建关系 + 双方加金币"原子。
- **幂等键 = `invitee_user_id` 唯一**:一个 B 只能被绑一次,重复请求返回 `already_bound`,不重复发奖(仿 `ad_reward_record.trans_id` 思路)。并发下两请求同时插同一 invitee → 后者撞唯一约束 `IntegrityError`,`bind()` rollback 后改判 `already_bound` 兜底。
- 四道防线(`bind()` 内):① invitee 已绑 → `already_bound`;② 无效码 / 邀请人非 `active``invalid_code`;③ 自邀(inviter==invitee)→ `self_invite`;④ 新人闸 `_is_new_user`(B 的 `created_at``rewards.INVITE_NEW_USER_WINDOW_HOURS`=72h 内)→ 否则 `not_eligible`。只有全过才插行 + 发奖。
- **U / D**:无。这张表只增不改不删(纯账本)。
- **R**:
- `GET /api/v1/invite/me`(`my_invite`)→ `get_stats(inviter_id)`:`count(*)` 得已邀人数、`sum(inviter_coin)` 得累计金币。
- `GET /api/v1/invite/invitees`(`my_invitees`)→ `get_invitees(inviter_id, limit, offset)`:join `user` 出被邀请人列表(倒序分页),名字降级兜底 `nickname → wechat_nickname → 脱敏手机号`,`coins``inviter_coin`
## 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `inviter_user_id` | Integer | FK→user.id, index, NOT NULL | 邀请人(A) |
| `invitee_user_id` | Integer | FK→user.id, **UNIQUE**, index, NOT NULL | 被邀请人(B)。**唯一 = 幂等键**,一个 B 只能被归因一次 |
| `channel` | String(16) | NOT NULL, default `clipboard` | 归因来源:`clipboard`(剪贴板自动)/ `manual`(手动填码)/ `fingerprint`(指纹反查兜底)。入库前 `[:16]` 截断 |
| `status` | String(16) | NOT NULL, default `effective` | 当前只有 `effective`(注册即生效)。**预留** `pending`/`effective`:将来若改"完成首单才生效"时启用 |
| `inviter_coin` | Integer | NOT NULL, default 0 | 本次给 A 发的金币(记账留痕,=`rewards.INVITE_INVITER_COINS`=10000) |
| `invitee_coin` | Integer | NOT NULL, default 0 | 本次给 B 发的金币(=`rewards.INVITE_INVITEE_COINS`=10000) |
| `created_at` | DateTime(tz) | server_default now(), index | 绑定时间(= 列表倒序键) |
## 关系 / Join Key
- `inviter_user_id``user.id`(硬 FK,多对一):一个 A 可邀多个 B。
- `invitee_user_id``user.id`(硬 FK,**一对一**,唯一约束):一个 B 至多一行。
- 发金币时落 `coin_transaction`:`biz_type='invite_inviter'`(给 A,`ref_id=invitee.id`)/ `biz_type='invite_invitee'`(给 B,`ref_id=inviter.id`),双方 `ref_id` 互指对方便于对账。
- `get_invitees``InviteRelation JOIN user ON user.id = invitee_user_id` 取被邀请人资料;`total` 单独 `count``has_more`
## 索引与约束
- PK `id`
- `ix_invite_relation_inviter_user_id`(`inviter_user_id`):`get_stats` / `get_invitees` 按邀请人查。
- `ix_invite_relation_invitee_user_id`(`invitee_user_id`**UNIQUE**):幂等键 + 加速 `_relation_of_invitee` 反查。
- `ix_invite_relation_created_at`(`created_at`):列表倒序。
## 注意
- **防重复发奖三道**(`repositories/invite.py` docstring):① `invitee_user_id` 唯一(应用层 `_relation_of_invitee` 先查 + DB 唯一约束并发兜底);② 自邀屏蔽;③ 手机号天然唯一(每个 B = 一个真实手机号账号)= 限制刷量规模。
- **`status` 当前恒为 `effective`**:产品取"注册即生效"而非"完成首单才生效",`pending` 取值是为后者预留、目前不写入。
- **`inviter_coin`/`invitee_coin` 是留痕字段**:写死当时发的金币值,即便日后改奖励常量,历史行仍保留发奖时的额度,便于对账。
- **`channel` 三种取值**:`clipboard`(deferred-deeplink 主路径,落地页写剪贴板、首启读回)/ `manual`(用户在邀请页手输)/ `fingerprint`(剪贴板被覆盖时走指纹兜底,见 [invite_fingerprint](./invite_fingerprint.md));三者都汇入同一个 `bind()`,只 `channel` 不同。
- **风控缺口(#24 设计文档「上线前还差什么」标注)**:1 万金币可提现且**邀请人无总数上限**,接码平台批量注册新号绑同码即可刷;上线前需加 inviter 上限 + 基础风控。当前 MVP 仅靠"手机号唯一 + 72h 新人闸"挡。
- **alembic 多 head**:本表迁移 `invite_code_and_relation`(`down_revision=11a1d08c6f55`)与 `coupon_state_tables` 是同一父的兄弟迁移,合 main 前需建 merge 迁移,否则 prod `alembic upgrade head``Multiple head revisions`(#24 设计文档已警示)。
-71
View File
@@ -1,71 +0,0 @@
# price_observation — 比价沉淀的价格事实(平台/门店视角的价格资产层)
> 模型 `app/models/price_observation.py` · 仓库 `app/repositories/price_observation.py` · 上报端点 `app/api/internal/price.py`(`POST /internal/price-observation`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每完成一次比价,pricebot 在 done 帧把「在某平台某店、这一篮菜/这单到手价多少钱」按平台逐条算出来,**server→server 内部上报**落这里(一次外卖比价 = 源平台 1 条 + N 个目标平台各 1 条,`scope='order'`)。**与登录无关、不依赖客户端鉴权**——比价透传链路(`compare.py`)当前不鉴权,只要比价跑到 done 就记,**匿名用户也记**(来源记 `source_device_id``source_user_id` 客户端带上时才有)。
与 [`comparison_record`](./comparison_record.md) 的区别(model docstring 明示,两表独立、互不影响):
- `comparison_record` = **用户视角**的「我的比价记录」,客户端登录后用带 JWT 的通道(`POST /compare/record`)主动上报,按 `(user_id, trace_id)` 存。
- `price_observation` = **平台/门店视角的客观价格事实**server 侧**无条件沉淀**,按 (平台, 门店, 菜, 时间) 组织。是未来「别人查过同店 → 直接秒回价格」价格大数据的源头(#21,「先存数据、用法后续」)。
> raw trace(逐 step 全过程)另存 pricebot 本地 `jsonl.gz`,本表只存**提炼后的价格事实**(本表可从 raw 重算);`trace_id` 软指 pricebot work_logs 供溯源/重算。
## 用在哪 / 增删改查
- **C(批量插入,幂等)**`POST /internal/price-observation``report_price_observation``repo.insert_batch`)。pricebot 在 done 帧把整批观测 POST 过来落库。幂等键 = `(trace_id, platform, scope)`:仓库**先查该 trace 已有的 `(platform, scope)`,只插新的**(不依赖 `ON CONFLICT`,跨方言 PG/SQLite 都安全),并发撞唯一约束则 `db.rollback()` 后整批跳过(本批本就幂等,**不重试**,见 P0 约定)。同批内还会去重一次(防 pricebot 同帧给重复平台)。返回 `{inserted, skipped}`
- **U / D**:无。append-only,无更新无删除。
- **R**:**当前无读取端点**(纯写入沉淀层,未来做「秒回价格」/聚合时再用)。
> 上报端点是 **server→server 专用**:不走用户 JWT,靠共享密钥头 `X-Internal-Secret`== `settings.INTERNAL_API_SECRET`,常量时间比较 `hmac.compare_digest`)。**密钥未配置(默认空)→ 直接 503**(挡住裸奔的写端点);不匹配 → 401。该密钥须与 pricebot 侧同值。
## 字段
批级字段(门店/菜篮/地理/来源,一次比价共享,由 `insert_batch` 灌进每条)vs 逐平台字段(`observations[]` 各自带)见下表「来源」列。
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `observed_at` | DateTime(tz) | server_default now(), index, NOT NULL | 观测时刻(≈比价 done 时;服务端落库时取 now)。按时间查「最近有效价」用 |
| `trace_id` | String(64) | index, NOT NULL | pricebot 侧 trace_id**幂等去重键**(含于唯一约束)+ 回指 A 层原始 trace(溯源/重算)。软指 pricebot work_logs,无硬 FK |
| `business_type` | String(16) | NOT NULL, default `food`, index | `food`(外卖,当前唯一接通)/ `ecom`(电商,二期)。**批级共享** |
| **门店维度** | | | denormalize 成文本,不建维度表;实体归一二期再做 |
| `platform` | String(32) | index, NOT NULL | 平台标识。**逐平台**(`observations[].platform`),含于唯一约束 |
| `platform_store_id` | String(64) | 可空 | 平台内门店 ID(抓得到才存,将来归一最稳的 key;**现在多为空**)。**逐平台** |
| `store_name` | String(128) | index, 可空 | 门店名。**批级共享**(源/目标同名场景下整批一份) |
| `city` | String(64) | 可空 | **批级共享** |
| `geohash` | String(16) | index, 可空 | 地理(**现在拿不到→留空**;二期把 device 经纬度透传进比价请求后回填)。**批级共享** |
| `lng` / `lat` | Float | 可空 | 同上,**批级共享** |
| **价格事实** | | | |
| `is_source` | Boolean | NOT NULL, default false | 是否源平台(发起比价那家)。源平台价也是真实观测,照记。**逐平台** |
| `scope` | String(16) | NOT NULL, default `order` | 口径:`order`(整单到手价,外卖当前唯一)/ `dish`(单菜单价,引擎暂不逐菜读,二期)。含于唯一约束。**逐平台** |
| `price_cents` | Integer | 可空 | 该口径到手价(分);**None = 该平台采集失败 / 门店打烊,无有效价**。**逐平台** |
| `coupon_saved_cents` | Integer | 可空 | 红包/券省了多少(分)。**逐平台** |
| `coupon_name` | String(64) | 可空 | 优惠来源名。**逐平台** |
| `store_closed` | String(32) | 可空 | 目标店打烊原因(**非空时 `price_cents` 为 None**)。**逐平台** |
| `rank` | Integer | 可空 | 本次比价全局名次(1=最便宜;按到手价升序,源/目标统一排)。**逐平台** |
| **明细(JSON** | | | |
| `dishes` | JSONPG JSONB | 可空 | 菜篮 `[{name, qty, subtotal?}]`(外卖)。dish 级单价二期从这里 + 引擎增强推。**批级共享** |
| `attrs` | JSONPG JSONB | 可空 | 灵活字段兜底(配送费/起送/活动名/跳过菜数 等),免得加字段就迁移。**逐平台**(`observations[].attrs` |
| **来源(溯源/去重/置信)** | | | |
| `source_device_id` | String(64) | index, 可空 | 来源设备;匿名比价也有。**批级共享** |
| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,先可空、不进唯一键。软指 `user.id`。**批级共享** |
| `confidence` | Float | NOT NULL, default 1.0 | 置信度,预留 |
| `created_at` | DateTime(tz) | server_default now(), NOT NULL | 落库时间 |
## 关系 / Join Key
- **无硬外键**
- `trace_id` **软指** pricebot work_logs(溯源/重算/排查),并作幂等去重键;与 `comparison_record.trace_id` 同源(同一次比价的 pricebot trace),但两表不互相 join、各存各的视角。
- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。
- `source_device_id` 与不鉴权期 `comparison_record.device_id` / 领券三表 `device_id` 同源,可用于对账(无 SQL 级约束)。
## 索引与约束
- PK `id`
- **UNIQUE(`trace_id`, `platform`, `scope`) = `uq_price_obs_trace_platform_scope`**:一次比价(trace)里同一平台、同一口径只一条 → pricebot 重试 / 客户端 replay 重复上报幂等去重。
- index`observed_at``trace_id``business_type``platform``store_name``geohash``source_device_id``source_user_id`(为将来按 平台/门店/菜/时间/地理 查询聚合预埋)。
## 注意
- **资产层定位「先存后用」**:当前**只写不读**(无 R 端点、不参与任何业务判断),是为「别人查过同店→秒回价格」大数据预先沉淀的原始事实。结构化列预埋查询/聚合维度,用法二期再定。
- **`attrs` / `dishes` 是 JSONB 兜底,别塞原始无障碍树**(几十 KB → 行膨胀);原始大树/逐 step 看 `trace_id` 指过去的 pricebot 落盘。`_JSON = JSON().with_variant(JSONB(), "postgresql")`PG 用 JSONB(可建 GIN),SQLite dev 退化通用 JSON(同 `comparison_record` / `coupon_claim_record`)。
- **`price_cents=None` 是有意义的观测**:表示该平台采集失败 / 门店打烊(看 `store_closed`),不是「没记」。统计「最低价」时要先过滤 None。
- **当前数据多为半空**`platform_store_id` 多为空(门店 ID 抓不全)、`geohash/lng/lat` 留空(device 经纬度二期才透传进比价请求)、`source_user_id` 多为空(链路不鉴权)。这些是**二期回填**计划,不是 bug。
- **幂等/并发**:不重试、失败就失败(P0 约定);同批去重 + 唯一键 + `IntegrityError` 回滚三重防御,但回滚是**整批**回滚(撞约束则本批全跳过)。
- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(不是 401),这是「内部端点默认关闭」的安全默认态——本地 dev / 未启用时该表不会有新数据。
-85
View File
@@ -1,85 +0,0 @@
# store_mapping — 跨平台「同一家店」的 id/名/deeplink 身份映射(门店资产层)
> 模型 `app/models/store_mapping.py` · 仓库 `app/repositories/store_mapping.py` · 上报/反查端点 `app/api/internal/store.py``POST /internal/store-mapping``GET /internal/store-mapping/lookup``POST /internal/store-mapping/invalidate` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
每完成一次比价,pricebot 把同一家物理店在 **淘宝/美团/京东** 各自的店铺 id、店名、可复跳 deeplink **server→server 内部上报**落这里一行(一次比价 = 一个 trace = 一行,各平台「腿」解析出 id 后填空合并进同一行)。**与登录无关、不依赖客户端鉴权**(同 [`price_observation`](./price_observation.md)`source_user_id` 客户端带上时才有,匿名也记)。
与 [`price_observation`](./price_observation.md) 的区别(两表平行、独立):
- `price_observation` = 平台/门店视角的**价格事实**(某店这单多少钱)。
- `store_mapping` = 平台/门店视角的**身份映射**(同一家店在各平台的店铺 id/名/deeplink)。是未来「我见过这家店 → 跳过重新搜索/匹配,直接 deeplink 进店内搜索」的源头(#48)。
与 [`comparison_record`](./comparison_record.md)(用户视角、带 JWT、按 `user_id` 存)也无关:本表是 server 无条件沉淀的客观映射、匿名也记。
> **⚠️ 数据质量**:跨平台「同一家店」的连接来自 agent 的 **LLM 店铺匹配**,匹配错则一行里 `id_taobao``id_jd`/`name_meituan` **连到不同店**。本表是 **append-only 原始记录**(每比价一行、`trace_id` 幂等防重试重复),「一行里多平台 id 共存」只表示「两条腿搜同一个源店名各自匹配到了某家店」= name-match 置信度、**非已核实同一实体**;清洗/归一/精确匹配二期由下游做。
## 用在哪 / 增删改查
- **C / Uupserttrace_id 维度幂等 + 填空合并)**`POST /internal/store-mapping``report_store_mapping``repo.upsert`)。合并键 = `trace_id`(唯一约束)。一次跨平台比价 = 一个 trace = 一个真实店铺:某条腿解析出 id 先建行(填自己那几列),后到的腿(将来)再 upsert 进**同一行**。**合并策略 = 填空(fill-the-blanks**`_merge_fill_blanks` 只写该行当前为 NULL 的列、**绝不覆盖已有非空值**;共享列(geo/source/溯源)先到先得。首写用逐列 `setattr`(而非硬编码构造器,否则首写是美团/京东腿时其列会漏)。不依赖 `ON CONFLICT`,跨方言安全;并发撞唯一约束则 `rollback()` 后转填空合并路径。返回 `{inserted, row_id}`inserted=1 新建 / 0 合并)。
- **R(lookup 反查,命中即省一整段现场反查)**`GET /internal/store-mapping/lookup?source_platform=&name=&lat=&lng=``lookup_nearest`)。**比价前**按「源平台店名」反查各目标平台已沉淀的店铺 id,命中就让 pricebot **直接 deeplink 跳店内搜索**,省掉「开平台→进店→分享→反查」整段。机制:匹配键 = 源平台对应的 name 列精确等于 `name`;每个目标**分别**取「含该目标 id 的同名候选里最近一条」(有入参 geo 且候选带 geo → haversine 取最近,否则取 `created_at` 最新);**排除源平台自己**(不复用源平台 id);**过滤掉 deeplink 已失效的候选**(见下)。返回 `{target_key: {id..., deeplink, row_id, dist_km?}}`,空 = 没缓存、pricebot 走现场反查老路。
- **Udeeplink 失效标记)**`POST /internal/store-mapping/invalidate``{platform, shop_id}`)。pricebot 撞错误页回退时上报,按 `shop_id` 把**所有**匹配行的对应 `*_deeplink_invalid_at``now()`(幂等:已标记的跳过)。`taobao``mark_taobao_deeplink_invalid``jd``mark_jd_deeplink_invalid`;**其它平台(含 meituan)当前 no-op 返回 `affected=0`、不报错**(向后兼容 pricebot 将来扩展)。返回 `{ok, affected}`
- **D**:无业务删除。
> 上报/反查/失效端点全是 **server→server 专用**,复用 `price.py``_check_secret`(同一 `X-Internal-Secret` 密钥,未配置→503,不匹配→401)。
## 字段
各平台列**按比价角色稀疏填充**(淘宝当目标才有 `*_taobao`,依此类推)。
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| **跨平台身份** | | | 同一物理店在各平台的 id/名 |
| `id_taobao` | String(64) | index, 可空 | 淘宝 = 分享短链解析出的 **shopId**(淘宝当目标、走完取 id 流程才有)。门店稳定数字主键 |
| `name_taobao` | String(128) | 可空 | 店铺页 a11y `content_desc`「店铺标题:xxx」剥前缀 |
| `id_meituan` | String(64) | index, 可空 | **留给将来 CPS API 的稳定数字 poi_id**;当前无 share→稳定 id 机制 → 多为空(可复跳票据见 `meituan_poi_id_str` |
| `name_meituan` | String(128) | 可空 | 源平台店名(来自 intent/agent 匹配名) |
| `id_jd` | String(64) | index, 可空 | 京东 = **storeId**(门店稳定数字主键,同 taobao shopId3.cn 短链反查)。`venderId` 另存 `jd_vender_id` |
| `name_jd` | String(128) | 可空 | 京东店铺页店名 |
| **地理** | | | 同名店异地区分 / 地理分桶匹配主要燃料 |
| `city` | String(64) | 可空 | |
| `geohash` | String(16) | index, 可空 | |
| `lng` / `lat` | Float | 可空 | lookup 时按入参 geo 取最近候选用 |
| `taobao_address` | String(256) | 可空 | 淘宝门店地址(店铺页 a11y 抓到才有;比经纬度更利于人工/LLM 匹配) |
| **溯源 / 画像** | | | |
| `source_platform` | String(32) | index, 可空 | 发起比价那家:`meituan` / `taobao_flash` / `jd_waimai` … lookup 据此选 name 列、排除源平台自己 |
| `business_type` | String(16) | NOT NULL, default `food` | |
| `trace_id` | String(64) | NOT NULL | **幂等合并键**(唯一约束)+ 回指原始 trace(溯源)。软指 pricebot work_logs,无硬 FK |
| `source_device_id` | String(64) | index, 可空 | |
| `source_user_id` | Integer | index, 可空 | **比价链路不鉴权拿不到,客户端带上时才有**,软指 `user.id` |
| **淘宝原料**(可复跳/重解析/调试) | | | |
| `taobao_share_url` | String(256) | 可空 | `m.tb.cn` 短链 |
| `taobao_resolved_url` | Text | 可空 | 解析出的目标 URL(含 shopId)。Text 因 URL 可能很长 |
| `taobao_deeplink` | Text | 可空 | 拼好的 et-store/search deeplink |
| `taobao_deeplink_invalid_at` | DateTime(tz) | 可空 | **淘宝 deeplink 失效标记**#53)。撞「页面出错了」降级页时被置;**NULL=有效**。lookup 反查过滤掉非 NULL 的淘宝候选 |
| **美团原料**dpurl.cn 短链 → 302 反查 → imeituan:// | | | |
| `meituan_poi_id_str` | String(64) | index, 可空 | **⚠️ 每次分享重新加密、非稳定主键**(调研 §八)→ 单列存「可复跳的一次性票据」,**不进 `id_meituan`** |
| `meituan_share_url` | String(256) | 可空 | `dpurl.cn` 短链 |
| `meituan_resolved_url` | Text | 可空 | 302 落地 menu URL(含 poi_id_str |
| `meituan_deeplink` | Text | 可空 | 拼好的 `imeituan://` 店内搜索 deeplink |
| **京东原料**(3.cn 短链 → 重定向反查 → openapp.jdmobile:// | | | |
| `jd_vender_id` | String(64) | index, 可空 | venderId 单列存(deeplink 模板 venderId+storeId 都要;venderId 是商家维度、可跨门店,与门店 storeId 分开记) |
| `jd_share_url` | String(256) | 可空 | `3.cn` 短链 |
| `jd_resolved_url` | Text | 可空 | 反查出的目标 `openapp.jdmobile://` URL |
| `jd_deeplink` | Text | 可空 | 拼好的 pages/search 店内搜索 deeplink |
| `jd_deeplink_invalid_at` | DateTime(tz) | 可空 | **京东 deeplink 失效标记**#56,对标淘宝)。撞「当前门店超出配送范围」页时被置;**NULL=有效**。lookup 过滤非 NULL 的京东候选 |
| `attrs` | JSONPG JSONB) | 可空 | 灵活字段兜底,免得加字段就迁移 |
| `created_at` | DateTime(tz) | server_default now(), index, NOT NULL | lookup 同名候选无 geo 时按它取最新 |
## 关系 / Join Key
- **无硬外键**
- `trace_id` **软指** pricebot work_logs(溯源/排查),并作 upsert 幂等合并键。
- `source_user_id` **软指** `user.id`(可空,不进唯一键、不阻塞落库)。
- lookup 的匹配靠**店名字符串精确相等**(`_SOURCE_NAME_COLUMN` 把源平台映到对应 name 列)+ 可选 geo 取最近,**非 id 级 join**。
## 索引与约束
- PK `id`
- **UNIQUE(`trace_id`) = `uq_store_mapping_trace`**:一次比价(trace)只一行 → pricebot 重试 / 客户端 replay 重复上报幂等去重;后到的平台腿填空合并进同一行。
- index`id_taobao``id_meituan``id_jd``meituan_poi_id_str``jd_vender_id``geohash``source_platform``source_device_id``source_user_id``created_at`(lookup 反查 / 失效标记按 id 列查需要)。
## 注意
- **deeplink 失效→标记→回退闭环**#53 淘宝 / #56 京东,本表最关键机制):缓存的 deeplink 会过期(淘宝撞「页面出错了」、京东撞「当前门店超出配送范围」)。pricebot 撞到错误降级页 → 调 `/internal/store-mapping/invalidate` 按 shopId/storeId 把**所有**相关行打上 `*_deeplink_invalid_at` → 后续 `lookup_nearest` **整条排除**失效候选(当没缓存,pricebot 走正常搜店)→ 重搜成功会写**新行**自然覆盖。按 shopId 标记所有行(不是单行)是因为同一坏 id 可能散在多次比价的多行里,全标掉才能让 lookup 不再返回它。
- **美团没有失效标记**`meituan_poi_id_str` 是「每次分享重新加密」的一次性票据、本就不稳定不复用为稳定 id,故无 invalidate 路径(`invalidate` 端点对 meituan no-op)。三平台 share→id 反查机制不对称:淘宝 `m.tb.cn`→shopId(稳定)、京东 `3.cn`→venderId+storeId(稳定)、美团 `dpurl.cn`→poi_id_str**非稳定**)。
- **资产层「先存后用」+ append-only**:lookup 是已接通的读用途(省现场反查),但表本身是原始记录、不做清洗归一。「一行多平台 id 共存」**不代表已核实同一实体**——下游做精确匹配前别当真值用。
- **数据质量依赖 LLM 匹配**:跨平台连接来自 agent 店铺匹配,**可能连错店**;排查某行可疑映射时回 `trace_id` 指的 pricebot trace 看当时的匹配上下文。
- **`attrs` 是 JSONB 兜底,别塞大树**(同 `price_observation`);`_JSON = JSON().with_variant(JSONB(), "postgresql")`PG JSONB / SQLite dev 通用 JSON。
- **填空合并不覆盖**:共享列(geo/source/device)先到先得,后到的腿只填自己那几列的空位;想「纠正」已写错的列**当前没有路径**(append-only,靠新 trace 写新行)。
- 上报端点未配 `INTERNAL_API_SECRET` 时返 **503**(默认关闭态),本地 dev / 未启用时该表不会有新数据。
-10
View File
@@ -222,16 +222,6 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
---
## 6.5 pricebot 回写:内部端点(server→server)
§6 是 app-server 透传给 pricebot;反过来 pricebot 也会把少量数据回写 app-server 落库,走 `app/api/internal/*` —— **不走用户 JWT**,靠共享密钥头 `X-Internal-Secret`(== `INTERNAL_API_SECRET`,未配则 503,防裸奔写端点)校验。
**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。
> 同类内部回写端点还有 `/internal/price-observation`(价格观测)、`/internal/store-mapping`(跨平台店铺映射)等 pricebot 比价资产沉淀。
---
## 7. 数据模型
业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
-208
View File
@@ -1,208 +0,0 @@
"""一次性 mock:造几条带「真实可加载截图」的上报更低价记录,供后台「上报审核」页验收图片加载。
为什么要真实图片:验证截图加载bug 是否修好,必须让 images 指向**真实存在于 /media 的文件**,
否则 404 文件不存在而非前端取图地址不对,无法区分本脚本:
1. data/media/price_report/ 生成几张纯色 PNG(手写 PNG 字节,无需 Pillow)作为截图;
2. 3 mock 用户 + 覆盖 待审核 / 已通过 / 已拒绝 的上报记录,引用这些图
幂等:重跑先按固定 mock 手机号清掉旧用户/上报 + mock 图再重建;--clean-only 只清
python -m scripts.seed_mock_price_reports
python -m scripts.seed_mock_price_reports --clean-only
看图:前端上报审核(http://localhost:3001 上报审核)图经 NEXT_PUBLIC_MEDIA_BASE
(本地 = http://localhost:8770) App 后端 /media 加载改过 .env.local 后需重启 next dev,
App 后端(:8770)要在跑
"""
from __future__ import annotations
import argparse
import struct
import sys
import zlib
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import delete, select
from app.core import rewards
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.price_report import PriceReport
from app.models.user import User
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥
# 固定 mock 手机号:脚本只动这些号,便于幂等重建/清理。
MOCK_PHONES = ["13266660001", "13266660002", "13266660003"]
_REPORT_DIR = Path(settings.MEDIA_ROOT) / "price_report"
_MOCK_IMG_GLOB = "mock_pr_*.png" # 本脚本生成的图前缀,清理时按此删
def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes:
"""生成一张纯色 PNG(truecolor RGB)的字节,无需 Pillow。颜色块即可肉眼判断「图加载出来了」。"""
def _chunk(typ: bytes, data: bytes) -> bytes:
body = typ + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB
row = b"\x00" + bytes(rgb) * width # 每行前缀 filter byte 0
idat = zlib.compress(row * height, 9)
return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
def _write_mock_image(name: str, rgb: tuple[int, int, int]) -> str:
"""写一张 mock 截图到 media/price_report/,返回其相对 URL(/media/price_report/<name>)。"""
_REPORT_DIR.mkdir(parents=True, exist_ok=True)
(_REPORT_DIR / name).write_bytes(_solid_png(320, 320, rgb))
return f"{settings.MEDIA_URL_PREFIX}/price_report/{name}"
def _bj_now() -> datetime:
"""北京 wall-clock(naive),与 report_repo.create_report 一致(前端按北京时间解析,不加 Z)。"""
return datetime.now(rewards.CN_TZ).replace(tzinfo=None)
def clean(db) -> int:
uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars())
if uids:
db.execute(delete(PriceReport).where(PriceReport.user_id.in_(uids)))
db.execute(delete(User).where(User.id.in_(uids)))
db.commit()
# 删 mock 截图文件
if _REPORT_DIR.exists():
for f in _REPORT_DIR.glob(_MOCK_IMG_GLOB):
f.unlink(missing_ok=True)
return len(uids)
def seed(db) -> list[PriceReport]:
now = _bj_now()
# 1) 建 3 个 mock 用户
users: dict[str, User] = {}
for i, (phone, nickname) in enumerate(
zip(MOCK_PHONES, ["省钱小王", "比价老李", "薅羊毛阿珍"])
):
u = User(
phone=phone,
nickname=nickname,
register_channel="sms",
status="active",
created_at=now - timedelta(days=8 + i),
last_login_at=now - timedelta(hours=3),
)
db.add(u)
users[phone] = u
db.flush() # 拿 user.id
# 2) 生成 mock 截图(不同颜色块,便于肉眼区分「都加载出来了」)
palette = [
(250, 173, 20), # 橙
(82, 196, 26), # 绿
(24, 144, 255), # 蓝
(114, 46, 209), # 紫
(245, 34, 45), # 红
]
imgs = [_write_mock_image(f"mock_pr_{i}.png", palette[i]) for i in range(len(palette))]
# 3) 造上报记录(覆盖 pending / approved / rejected 三个 tab,每条都带可加载截图)
def report(
phone: str,
store: str,
dish: str,
orig_name: str,
orig_cents: int,
rep_id: str,
rep_name: str,
rep_cents: int,
images: list[str],
*,
status: str = "pending",
reward_coins: int | None = None,
reject_reason: str | None = None,
created: datetime,
) -> PriceReport:
return PriceReport(
user_id=users[phone].id,
comparison_record_id=None, # 可空:不依赖真实比价记录
store_name=store,
dish_summary=dish,
original_platform_id="meituan-waimai",
original_platform_name=orig_name,
original_price_cents=orig_cents,
reported_platform_id=rep_id,
reported_platform_name=rep_name,
reported_price_cents=rep_cents,
images=images,
status=status,
reward_coins=reward_coins,
reject_reason=reject_reason,
reviewed_at=(created + timedelta(hours=1)) if status != "pending" else None,
created_at=created,
)
reports = [
# 待审核(默认 tab,重点验图):2 图 + 1 图各一条
report("13266660001", "瑞幸咖啡(国贸店)", "生椰拿铁 × 2", "美团外卖", 3980,
"jd-waimai", "京东外卖", 3200, [imgs[0], imgs[1]],
created=now - timedelta(minutes=8)),
report("13266660002", "麦当劳(三里屯店)", "巨无霸套餐", "美团外卖", 4200,
"taobao-shanguang", "淘宝闪购", 3550, [imgs[2]],
created=now - timedelta(minutes=35)),
# 已通过(已发金币)
report("13266660003", "蜜雪冰城(西单店)", "柠檬水 × 3", "美团外卖", 1800,
"jd-waimai", "京东外卖", 1500, [imgs[3]],
status="approved", reward_coins=1000, created=now - timedelta(days=1)),
# 已拒绝(带理由)
report("13266660001", "星巴克(望京店)", "美式 × 1", "美团外卖", 3000,
"jd-waimai", "京东外卖", 2800, [imgs[4]],
status="rejected", reject_reason="截图不清晰", created=now - timedelta(days=2)),
]
db.add_all(reports)
db.commit()
for r in reports:
db.refresh(r)
return reports
def _yuan(cents: int | None) -> str:
return "-" if cents is None else f"¥{cents / 100:.2f}"
def main() -> None:
parser = argparse.ArgumentParser(description="造上报更低价 mock 数据(后台上报审核页验图用)")
parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建")
args = parser.parse_args()
db = SessionLocal()
try:
removed = clean(db)
if removed:
print(f"🧹 已清理旧 mock:{removed} 个用户及其上报记录 + mock 截图")
if args.clean_only:
print("✅ 仅清理,已完成。")
return
reports = seed(db)
label = {"pending": "待审核", "approved": "已通过", "rejected": "已拒绝"}
print(f"\n✅ 已生成 {len(reports)} 条上报记录(截图落 {_REPORT_DIR}):")
for r in reports:
print(
f" #{r.id} [{label.get(r.status, r.status)}] {r.store_name} | "
f"{r.original_platform_name}{_yuan(r.original_price_cents)}"
f"{r.reported_platform_name}{_yuan(r.reported_price_cents)} | {len(r.images)}"
)
print(
"\n👉 打开 http://localhost:3001 → 上报审核 查看(默认「待审核」tab)。"
"\n 图加载不出来时排查:① 是否重启过 next dev(读 .env.local 的 NEXT_PUBLIC_MEDIA_BASE)"
" ② App 后端(:8770)是否在跑(它托管 /media)。"
)
finally:
db.close()
if __name__ == "__main__":
main()
-296
View File
@@ -1,296 +0,0 @@
"""一次性 mock:造几条不同状态的提现单,供运营后台「提现管理」页联调验收。
覆盖:
- 全部 5 个状态 tab(待审核 / 打款中 / 已到账 / 已拒绝 / 打款失败)
- 累计提现(按该用户 success 单金额求和;有非零也有 0)
- 空昵称(展示 "-")提现实名失败/拒绝原因
- 配套双分录现金流水(withdraw / withdraw_refund / exchange_in)+ 账户余额,
让顶部账本校验保持绿色详情抽屉的现金余额/流水也真实
约束:withdraw_order 有部分唯一索引(同一 user reviewing/pending 最多 1 ),
本脚本每个 mock 用户至多 1 个活动单,满足约束
幂等:每次运行先按固定 mock 手机号清掉旧 mock 再重建仅清理用 --clean-only
.venv\\Scripts\\python.exe scripts\\seed_mock_withdraws.py
.venv\\Scripts\\python.exe scripts\\seed_mock_withdraws.py --clean-only
"""
from __future__ import annotations
import argparse
import sys
import uuid
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, select
from app.core import rewards
from app.db.session import SessionLocal
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.user import User
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
# Windows GBK 控制台下也能正常打印中文(避免 UnicodeEncodeError)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
# 固定 mock 手机号:脚本只动这些号,便于幂等重建 / 清理。
MOCK_PHONES = [
"13800000001",
"13900000002",
"13700000003",
"13600000004",
"13500000005",
"13312345678",
]
REMARK = {"exchange_in": "金币兑入", "withdraw": "提现扣款", "withdraw_refund": "提现退款"}
def _naive_utc_now() -> datetime:
"""与 func.now() 在 SQLite 的口径一致:naive UTC。前端按 UTC 解析再转北京时间。"""
return datetime.now(timezone.utc).replace(tzinfo=None)
def _mock_transfer_no() -> str:
"""模拟微信转账单号(长串数字)。"""
return "1330" + str(uuid.uuid4().int)[:26]
def clean(db) -> int:
uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars())
if not uids:
return 0
for model in (
WithdrawOrder,
CashTransaction,
CoinTransaction,
CoinAccount,
AdRewardRecord,
AdFeedRewardRecord,
):
db.execute(delete(model).where(model.user_id.in_(uids)))
db.execute(delete(User).where(User.id.in_(uids)))
db.commit()
return len(uids)
def seed(db) -> list[WithdrawOrder]:
now = _naive_utc_now()
def ago(**kw) -> datetime:
return now - timedelta(**kw)
# 1) 建用户(手机号 / 昵称;U4 昵称留空测 "-" 展示)
users_spec = [
("13800000001", "省钱小能手"),
("13900000002", "薅羊毛达人"),
("13700000003", "比价狂魔"),
("13600000004", None),
("13500000005", "退款体验官"),
("13312345678", "已到账老用户"),
]
users: dict[str, User] = {}
for phone, nickname in users_spec:
u = User(
phone=phone,
nickname=nickname,
register_channel="sms",
status="active",
created_at=ago(days=12),
last_login_at=ago(hours=2),
)
db.add(u)
users[phone] = u
db.flush() # 拿 user.id
# 2) 建提现单(覆盖各状态)
def order(
phone: str,
cents: int,
status: str,
*,
name: str | None = None,
wechat: str | None = None,
transfer: str | None = None,
fail: str | None = None,
created: datetime,
updated: datetime | None = None,
) -> WithdrawOrder:
return WithdrawOrder(
user_id=users[phone].id,
out_bill_no=uuid.uuid4().hex, # 与线上同格式:uuid4 的 32 位 hex
amount_cents=cents,
user_name=name,
status=status,
wechat_state=wechat,
transfer_bill_no=transfer,
fail_reason=fail,
created_at=created,
updated_at=updated or created,
)
orders = [
# U1 省钱小能手:2 已到账 + 1 待审核 → 累计提现 ¥18.00
order("13800000001", 1000, "success", name="张伟", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=3)),
order("13800000001", 800, "success", name="张伟", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=5)),
order("13800000001", 500, "reviewing", name="张伟", created=ago(minutes=3)),
# U2 薅羊毛达人:1 已到账 + 1 待审核 → 累计提现 ¥20.00
order("13900000002", 2000, "success", name="李娜", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=2)),
order("13900000002", 1250, "reviewing", name="李娜", created=ago(minutes=25)),
# U3 比价狂魔:1 待审核 → 累计提现 ¥0
order("13700000003", 100, "reviewing", name="王芳", created=ago(hours=1)),
# U4(空昵称):1 打款中 → 累计提现 ¥0
order("13600000004", 300, "pending", name="刘洋", wechat="ACCEPTED",
transfer=_mock_transfer_no(), created=ago(minutes=40), updated=ago(minutes=20)),
# U5 退款体验官:1 已拒绝 + 1 打款失败 → 累计提现 ¥0
order("13500000005", 5000, "rejected", name="陈静", fail="微信实名不匹配",
created=ago(days=1)),
order("13500000005", 3000, "failed", name="陈静", wechat="FAIL",
transfer=_mock_transfer_no(), fail="收款账户异常,已退回余额",
created=ago(hours=6), updated=ago(hours=5)),
# U6 已到账老用户:2 已到账 → 累计提现 ¥40.00
order("13312345678", 1500, "success", name="赵强", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=10)),
order("13312345678", 2500, "success", name="赵强", wechat="SUCCESS",
transfer=_mock_transfer_no(), created=ago(days=8)),
]
db.add_all(orders)
db.flush()
# 3) 配套双分录现金流水 + 账户余额(让账本校验绿、详情抽屉真实):
# 每单创建时扣款(withdraw, -amount, ref=out_bill_no);失败/拒绝再退款(withdraw_refund, +amount);
# 每用户起始补一笔 exchange_in 把余额垫正;CoinAccount.cash 余额 = 该用户流水净额。
by_user: dict[int, list[WithdrawOrder]] = defaultdict(list)
for o in orders:
by_user[o.user_id].append(o)
for uid, uorders in by_user.items():
initial = sum(o.amount_cents for o in uorders) + 2000 # 垫 ¥20 余额,确保全程非负
earliest = min(o.created_at for o in uorders)
events: list[tuple[datetime, str, int, str | None]] = [
(earliest - timedelta(days=1), "exchange_in", initial, None)
]
for o in uorders:
events.append((o.created_at, "withdraw", -o.amount_cents, o.out_bill_no))
if o.status in ("failed", "rejected"):
events.append((o.updated_at, "withdraw_refund", o.amount_cents, o.out_bill_no))
events.sort(key=lambda e: e[0])
bal = 0
for t, biz, amt, ref in events:
bal += amt
db.add(CashTransaction(
user_id=uid, amount_cents=amt, balance_after_cents=bal,
biz_type=biz, ref_id=ref, remark=REMARK[biz], created_at=t,
))
db.add(CoinAccount(user_id=uid, coin_balance=0, cash_balance_cents=bal, total_coin_earned=0))
# 4) 给部分用户造看广告/签到记录(喂提现详情抽屉的「统计区」+「金币记录」表)。
# eCPM 原值单位=分/千次;金币用与线上同源的 rewards.calculate_ad_reward_coin 复算,真实偏小。
def _bj_date(t: datetime) -> str:
return (t + timedelta(hours=8)).date().isoformat()
coin_bal: dict[int, int] = defaultdict(int)
def add_video(uid: int, ecpm_fen: int, created: datetime, nth: int, scene: str = "reward_video") -> None:
db.add(AdRewardRecord(
trans_id=uuid.uuid4().hex, user_id=uid,
coin=rewards.calculate_ad_reward_coin(str(ecpm_fen), nth),
status="granted", reward_scene=scene, ecpm_raw=str(ecpm_fen),
reward_date=_bj_date(created), created_at=created,
))
def add_feed(uid: int, ecpm_fen: int, units: int, created: datetime, scene: str | None = None) -> None:
coin = sum(rewards.calculate_ad_reward_coin(str(ecpm_fen), i) for i in range(1, units + 1))
db.add(AdFeedRewardRecord(
client_event_id=uuid.uuid4().hex, user_id=uid, reward_date=_bj_date(created),
duration_seconds=units * 10, unit_count=units, ecpm_raw=str(ecpm_fen),
feed_scene=scene, coin=coin, status="granted", created_at=created,
))
def add_signin(uid: int, coin: int, created: datetime) -> None:
coin_bal[uid] += coin
db.add(CoinTransaction(
user_id=uid, amount=coin, balance_after=coin_bal[uid],
biz_type="signin", ref_id=_bj_date(created), remark="每日签到", created_at=created,
))
u1 = users["13800000001"].id # 丰富:激励视频 + 信息流 + 签到 + 1 条签到膨胀
for i, e in enumerate([3000, 5000, 8000, 4000, 6000, 3500, 7000, 4500, 5500, 6500]):
add_video(u1, e, ago(days=1, hours=i), nth=(i % 5) + 1)
add_video(u1, 9000, ago(hours=2), nth=1, scene="signin_boost")
# 信息流打 scene:比价×2 / 领券×1 / 未分类×1(演示金币记录「途径」分类)
feed_scenes = ["comparison", "coupon", "comparison", None]
for i, (e, units) in enumerate([(2000, 6), (3000, 4), (5000, 3), (2500, 5)]):
add_feed(u1, e, units, ago(days=2, hours=i), scene=feed_scenes[i])
for i in range(5):
add_signin(u1, 2000, ago(days=i + 1))
u2 = users["13900000002"].id # 较轻:几条激励视频 + 签到
for i, e in enumerate([3000, 4000, 5000]):
add_video(u2, e, ago(days=1, hours=i), nth=i + 1)
for i in range(3):
add_signin(u2, 2000, ago(days=i + 1))
db.commit()
return orders
def _yuan(cents: int) -> str:
return f"¥{cents / 100:.2f}"
def main() -> None:
parser = argparse.ArgumentParser(description="造提现 mock 数据(运营后台提现管理页联调用)")
parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建")
args = parser.parse_args()
db = SessionLocal()
try:
removed = clean(db)
if removed:
print(f"🧹 已清理旧 mock:{removed} 个用户及其提现单/流水")
if args.clean_only:
print("✅ 仅清理,已完成。")
return
orders = seed(db)
# 汇总打印(对照页面应显示的内容)
cum: dict[int, int] = defaultdict(int)
for o in orders:
if o.status == "success":
cum[o.user_id] += o.amount_cents
uid2phone = dict(db.execute(select(User.id, User.phone).where(User.phone.in_(MOCK_PHONES))).all())
print(f"\n✅ 已生成 {len(orders)} 条提现单,分布:")
by_status: dict[str, list[WithdrawOrder]] = defaultdict(list)
for o in orders:
by_status[o.status].append(o)
label = {"reviewing": "待审核", "pending": "打款中", "success": "已到账",
"rejected": "已拒绝", "failed": "打款失败"}
for st in ("reviewing", "pending", "success", "rejected", "failed"):
lst = by_status.get(st, [])
total = sum(o.amount_cents for o in lst)
print(f" {label[st]:<5} {len(lst)} 单 合计 {_yuan(total)}")
print("\n 明细(手机号 | 提现金额 | 累计提现 | 状态 | 失败原因):")
for o in sorted(orders, key=lambda x: (x.user_id, x.created_at)):
phone = uid2phone.get(o.user_id, "?")
print(
f" {phone} {_yuan(o.amount_cents):>8} 累计 {_yuan(cum.get(o.user_id, 0)):>8} "
f"{label[o.status]:<5} {o.fail_reason or ''}"
)
print("\n👉 打开 http://localhost:3001 → 提现管理 查看。后端若没带 --reload 需重启才有新列。")
finally:
db.close()
if __name__ == "__main__":
main()
+121
View File
@@ -315,6 +315,127 @@ def test_feed_reward_daily_display_cap(client, monkeypatch) -> None:
assert _coin_balance(client, token) == before
def test_feed_reward_segments_per_ad_ecpm(client) -> None:
"""按条 segments 结算:每份用「满 10 秒那刻在播段」的 eCPM,跨条进位不浪费零头。"""
phone = "13800003303"
token = _login(client, phone)
# ad1: eCPM 200 看 25s;ad2: eCPM 400 看 7s。
# 跨条进位:25s→2 份(@200,进位 5s);5+7=12s→第 3 份(@400,进位 2s 丢弃)。
payload = {
"client_event_id": "feed_seg_0001",
"ad_session_id": "feedsess0001",
"segments": [
{"ecpm": "200", "seconds": 25},
{"ecpm": "400", "seconds": 7},
],
}
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
expected = (
calculate_ad_reward_coin("200", 1)
+ calculate_ad_reward_coin("200", 2)
+ calculate_ad_reward_coin("400", 3)
)
assert body["status"] == "granted"
assert body["unit_count"] == 3
assert body["coin"] == expected
assert _coin_balance(client, token) == expected
def test_feed_reward_session_unit_cap(client) -> None:
"""单场份数封顶 12:超长 segments 也只发 12 份。"""
phone = "13800003304"
token = _login(client, phone)
payload = {
"client_event_id": "feed_cap_unit_1",
"segments": [{"ecpm": "200", "seconds": 1000}], # 100 份 → 钳到 12
}
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["unit_count"] == 12
expected = sum(calculate_ad_reward_coin("200", i) for i in range(1, 13))
assert r.json()["coin"] == expected
def test_feed_reward_aborted_no_grant(client) -> None:
"""中途关闭(aborted)即使看够时长也整场不发,记 closed_early。"""
phone = "13800003305"
token = _login(client, phone)
payload = {
"client_event_id": "feed_abort_1",
"segments": [{"ecpm": "200", "seconds": 30}],
"aborted": True,
}
r = client.post("/api/v1/ad/feed-reward", json=payload, headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["granted"] is False
assert r.json()["status"] == "closed_early"
assert r.json()["coin"] == 0
assert _coin_balance(client, token) == 0
def test_feed_reward_requires_segments_or_ecpm(client) -> None:
"""既不传 segments 也不传 ecpm → 422。"""
phone = "13800003306"
token = _login(client, phone)
r = client.post(
"/api/v1/ad/feed-reward",
json={"client_event_id": "feed_bad_1", "duration_seconds": 30},
headers=_auth(token),
)
assert r.status_code == 422, r.text
def test_feed_preview_matches_grant(client) -> None:
"""preview 与最终 feed-reward 同口径:preview 的 coin == 同 segments 实发 coin。"""
phone = "13800003307"
token = _login(client, phone)
segments = [{"ecpm": "200", "seconds": 25}, {"ecpm": "400", "seconds": 7}]
pv = client.post(
"/api/v1/ad/feed-reward/preview", json={"segments": segments}, headers=_auth(token)
)
assert pv.status_code == 200, pv.text
pvb = pv.json()
assert pvb["unit_count"] == 3
assert pvb["would_status"] == "granted"
assert pvb["session_full"] is False
# 下一份(第 4 份)边际金币,按末段 eCPM=400 算
assert pvb["next_unit_coin"] == calculate_ad_reward_coin("400", 4)
# preview 不入账:此时余额仍为 0
assert _coin_balance(client, token) == 0
# 真正结算,coin 应与 preview 一致
gr = client.post(
"/api/v1/ad/feed-reward",
json={"client_event_id": "feed_pv_1", "segments": segments},
headers=_auth(token),
)
assert gr.status_code == 200, gr.text
assert gr.json()["coin"] == pvb["coin"]
assert _coin_balance(client, token) == pvb["coin"]
def test_feed_preview_too_short(client) -> None:
"""不足 10 秒:preview would_status=too_short、coin=0、next_unit_coin>0(本份在攒)。"""
phone = "13800003308"
token = _login(client, phone)
pv = client.post(
"/api/v1/ad/feed-reward/preview",
json={"segments": [{"ecpm": "200", "seconds": 6}]},
headers=_auth(token),
)
assert pv.status_code == 200, pv.text
body = pv.json()
assert body["unit_count"] == 0
assert body["coin"] == 0
assert body["would_status"] == "too_short"
assert body["next_unit_coin"] == calculate_ad_reward_coin("200", 1)
def test_callback_disabled_returns_503(client, monkeypatch) -> None:
"""未配置回调(开关关)时 → 503。"""
monkeypatch.setattr(settings, "PANGLE_CALLBACK_ENABLED", False)
+3 -5
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="pending"))
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="new"))
db.commit()
return uid
finally:
@@ -181,11 +181,9 @@ 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": "pending"}, headers=_auth(admin_token)
)
r = admin_client.get("/admin/api/feedbacks", params={"status": "new"}, headers=_auth(admin_token))
assert r.status_code == 200
assert all(f["status"] == "pending" for f in r.json()["items"])
assert all(f["status"] == "new" for f in r.json()["items"])
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
+6 -81
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="pending")
fb = Feedback(user_id=uid, content="测试", contact="wx", status="new")
db.add(fb)
db.commit()
return fb.id
@@ -280,90 +280,15 @@ def test_super_admin_can_grant_coins(admin_client: TestClient, super_token: str)
assert r.status_code == 200
# ===== 反馈审核 =====
# ===== 反馈处理 =====
def test_approve_feedback_grants_coins_and_audit(
admin_client: TestClient, operator_token: str
) -> None:
def test_handle_feedback(admin_client: TestClient, operator_token: str) -> None:
fid = _seed_feedback("13900000006")
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
r = admin_client.post(f"/admin/api/feedbacks/{fid}/handle", headers=_auth(operator_token))
assert r.status_code == 200
db = SessionLocal()
try:
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"] == "暂未提供可复现信息"
assert db.get(Feedback, fid).status == "handled"
finally:
db.close()
-95
View File
@@ -1,95 +0,0 @@
"""OTA 检查更新端点测试。
覆盖:公开读端点未配置时返回默认(latest_version_code=0=无更新)内部写端点鉴权
(503 未配 / 401 头错 / 200)写后能读到同一组值写入参数校验(422)
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.repositories.app_config import APP_VERSION_KEY
_SECRET = "test-internal-secret-only-for-pytest"
# 合法 body(够过 pydantic 校验,用于鉴权用例)
_VALID = {
"latest_version_code": 31,
"latest_version_name": "0.1.6",
"apk_url": "https://app-api.shaguabijia.com/media/releases/shaguabijia-v0.1.6-build31.apk",
}
@pytest.fixture()
def clean_version():
"""每个用例前后清掉 latest_app_version 行(表由 conftest 建好,session 级共享)。"""
def _clear():
s = SessionLocal()
s.query(AppConfig).filter(AppConfig.key == APP_VERSION_KEY).delete()
s.commit()
s.close()
_clear()
yield
_clear()
def test_read_default_when_unset(client: TestClient, clean_version) -> None:
"""未配置任何版本时,公开读端点返回 latest_version_code=0(客户端视为已是最新);不鉴权。"""
r = client.get("/api/v1/platform/app-version")
assert r.status_code == 200, r.text
assert r.json()["latest_version_code"] == 0
def test_write_secret_unset_503(client: TestClient, clean_version, monkeypatch) -> None:
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", "") # 未配 = 写端点关闭
r = client.post("/internal/app-version", json=_VALID)
assert r.status_code == 503
def test_write_auth(client: TestClient, clean_version, monkeypatch) -> None:
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
assert client.post("/internal/app-version", json=_VALID).status_code == 401
assert client.post(
"/internal/app-version", json=_VALID, headers={"X-Internal-Secret": "wrong"}
).status_code == 401
r = client.post("/internal/app-version", json=_VALID, headers={"X-Internal-Secret": _SECRET})
assert r.status_code == 200, r.text
def test_write_then_read(client: TestClient, clean_version, monkeypatch) -> None:
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
payload = {
"latest_version_code": 99,
"latest_version_name": "9.9.9",
"apk_url": "https://app-api.shaguabijia.com/media/releases/shaguabijia-v9.9.9-build99.apk",
"update_note": "测试更新说明",
"min_supported_version_code": 50,
"apk_size_bytes": 12345678,
"apk_sha256": "abc123",
}
w = client.post("/internal/app-version", json=payload, headers={"X-Internal-Secret": _SECRET})
assert w.status_code == 200, w.text
assert w.json()["latest_version_code"] == 99
body = client.get("/api/v1/platform/app-version").json()
assert body["latest_version_code"] == 99
assert body["latest_version_name"] == "9.9.9"
assert body["apk_url"] == payload["apk_url"]
assert body["update_note"] == "测试更新说明"
assert body["min_supported_version_code"] == 50
assert body["apk_size_bytes"] == 12345678
assert body["apk_sha256"] == "abc123"
def test_write_rejects_invalid(client: TestClient, clean_version, monkeypatch) -> None:
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
h = {"X-Internal-Secret": _SECRET}
# latest_version_code 必须 > 0
bad_code = {"latest_version_code": 0, "latest_version_name": "x", "apk_url": "u"}
assert client.post("/internal/app-version", json=bad_code, headers=h).status_code == 422
# apk_url 不能空
bad_url = {"latest_version_code": 5, "latest_version_name": "x", "apk_url": ""}
assert client.post("/internal/app-version", json=bad_url, headers=h).status_code == 422
-141
View File
@@ -1,141 +0,0 @@
"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。
覆盖:
- 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放
- 回归原始 bug:注销释放 openid ,别的账号能绑同一个微信
- 资金前置闸:有可提现现金 / 在审提现单 409 拒绝注销,账号不被删
- 注销后旧 token 即时失效(status==active )
"""
from __future__ import annotations
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount
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 _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _patch_userinfo(monkeypatch, openid: str) -> None:
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": openid, "nickname": "微信昵称", "avatar_url": "https://x/a.png", "raw": {}},
)
def _seed_cash(client, token: str, phone: str, cents: int) -> None:
"""先访问 /account 触发建账户,再用 DB 直接灌现金余额。"""
client.get("/api/v1/wallet/account", headers=_auth(token))
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
acc = db.get(CoinAccount, user.id)
acc.cash_balance_cents = cents
db.commit()
finally:
db.close()
def _get_user(phone: str) -> User | None:
db = SessionLocal()
try:
return db.execute(select(User).where(User.phone == phone)).scalar_one_or_none()
finally:
db.close()
def test_delete_clean_account_releases_all_unique_slots(client) -> None:
phone = "13900000001"
token = _login(client, phone)
# 预置:给该用户造昵称 + 邀请码(占 invite_code 唯一槽),验证注销时一并释放
db = SessionLocal()
try:
u = db.execute(select(User).where(User.phone == phone)).scalar_one()
u.nickname = "张三"
u.invite_code = "INVCODE0001"
db.commit()
uid = u.id
finally:
db.close()
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 200, r.text
u = _get_user(f"deleted_{uid}")
assert u is not None
assert u.status == "deleted"
assert u.phone == f"deleted_{uid}"
assert u.nickname is None
assert u.avatar_url is None
assert u.wechat_openid is None
assert u.wechat_nickname is None
assert u.wechat_avatar_url is None
assert u.invite_code is None
def test_delete_releases_wechat_so_another_account_can_bind(client, monkeypatch) -> None:
"""原始 bug 回归:A 绑微信 → 注销,B 必须能绑同一个 openid(修复前会 409)。"""
openid = "openid_delreuse_a1" # 独特值,避免跨测试共享库污染(如 test_withdraw 也用 openid_shared_xyz)
# A 绑微信
_patch_userinfo(monkeypatch, openid)
token_a = _login(client, "13900000002")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a))
assert r.status_code == 200, r.text
# A 注销(无现金、无提现单 → 允许)
r = client.delete("/api/v1/user", headers=_auth(token_a))
assert r.status_code == 200, r.text
# B 绑同一个 openid → 必须成功(修复前会 409 "该微信已绑定其他账号")
token_b = _login(client, "13900000003")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b))
assert r.status_code == 200, r.text
assert r.json()["bound"] is True
def test_delete_blocked_when_cash_balance_positive(client) -> None:
phone = "13900000004"
token = _login(client, phone)
_seed_cash(client, token, phone, 100) # 1 元未提现
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 409, r.text
assert "现金" in r.json()["detail"]
u = _get_user(phone) # 账号未被删
assert u is not None and u.status == "active"
def test_delete_blocked_when_reviewing_withdraw_exists(client, monkeypatch) -> None:
"""cash 提光(=0)但有在审提现单 → 仍拒绝(命中 reviewing 闸,非 cash 闸)。"""
phone = "13900000005"
_patch_userinfo(monkeypatch, "openid_delrev_b2")
token = _login(client, phone)
_seed_cash(client, token, phone, 50)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
# 提光 50 → cash 归 0,单进 reviewing
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.status_code == 200 and r.json()["status"] == "reviewing", r.text
assert r.json()["cash_balance_cents"] == 0
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 409, r.text
assert "审核" in r.json()["detail"]
u = _get_user(phone)
assert u is not None and u.status == "active"
def test_deleted_user_old_token_is_rejected(client) -> None:
phone = "13900000006"
token = _login(client, phone)
r = client.delete("/api/v1/user", headers=_auth(token))
assert r.status_code == 200, r.text
# 注销后旧 token 立即失效(get_current_user 校验 status==active)
r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token))
assert r.status_code == 401, r.text
-77
View File
@@ -1,77 +0,0 @@
"""用户反馈接口测试。"""
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"] == "暂未提供可复现信息"