Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acdb10b516 | |||
| b1849834f7 | |||
| 8008366abc | |||
| 43f28d358b | |||
| 128db7fb86 | |||
| c222137b2a | |||
| d8c7a6d1ef | |||
| 4a9d3f1d1a | |||
| b07ccb4bf5 | |||
| 2164155a23 | |||
| e81fd0a176 | |||
| a6f55a00b8 | |||
| a584e1f59f | |||
| e13f0f7658 | |||
| f868414966 | |||
| 900cc83d38 | |||
| 3cab75b6ac | |||
| 08bbb5775e | |||
| 8a2f72d366 | |||
| f7a3ef2e0b | |||
| d8358a6816 | |||
| 95a07f75b7 | |||
| 733a51260f | |||
| 7fd70a264d |
@@ -27,6 +27,11 @@ JG_PRIVATE_KEY_PATH=./secrets/jverify_rsa_private.pem
|
||||
JG_VERIFY_ENDPOINT=https://api.verification.jpush.cn/v1/web/loginTokenVerify
|
||||
JG_REQUEST_TIMEOUT_SEC=15
|
||||
|
||||
# ===== 无障碍保护存活监控(pull 后置检测;本期不接推送)=====
|
||||
HEARTBEAT_MONITOR_ENABLED=true
|
||||
HEARTBEAT_TIMEOUT_MINUTES=10
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC=60
|
||||
|
||||
# ===== 短信 (mock 模式) =====
|
||||
# mock = true 时,任意 6 位数字均通过,且 /sms/send 不真发短信(只 log)。
|
||||
# 后续接阿里云/腾讯云短信时,改成 false 并填供应商相关 key。
|
||||
@@ -34,6 +39,15 @@ SMS_MOCK=true
|
||||
SMS_CODE_TTL_SEC=300
|
||||
SMS_SEND_INTERVAL_SEC=60
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||
# (real 模式下也跳过校验)、每次登录【都重走新手引导】,并有【每日登录上限】防被人猜到号后脚本刷。
|
||||
# 逻辑见 app/core/test_account.py,与其他业务解耦。
|
||||
# ⚠️ 留空 = 关闭整功能(生产默认);要启用才填号(如 11111111111)。改完重启生效,随时可清空停用。
|
||||
TEST_ACCOUNT_PHONE=
|
||||
# 该测试号每日最多登录次数,当日超过即拒绝(429),次日归零。
|
||||
TEST_ACCOUNT_DAILY_LIMIT=500
|
||||
|
||||
# ===== 美团联盟 CPS =====
|
||||
# 美团联盟后台 → 媒体管理 拿到的 appkey 和密钥
|
||||
MT_CPS_APP_KEY=
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge device#65 and comparison_token_cols heads
|
||||
|
||||
Revision ID: 4dc2af7ebe74
|
||||
Revises: comparison_token_cols, ad_feed_reward_trace_id
|
||||
Create Date: 2026-06-23 17:57:54.083531
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4dc2af7ebe74'
|
||||
down_revision: Union[str, Sequence[str], None] = ('comparison_token_cols', 'ad_feed_reward_trace_id')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,42 @@
|
||||
"""ad_feed_reward_record.trace_id (比价看广告金币归属到比价记录)
|
||||
|
||||
Revision ID: ad_feed_reward_trace_id
|
||||
Revises: device_kill_alert_pending
|
||||
Create Date: 2026-06-21
|
||||
|
||||
比价等候期信息流广告(feed_ad_reward)结算时,客户端带上本场比价 trace_id 落此列;
|
||||
比价记录页按 trace_id 聚合本场实发金币,显示「比价赚 N 金币」。领券/福利/旧客户端 = NULL。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ad_feed_reward_trace_id'
|
||||
down_revision: Union[str, Sequence[str], None] = 'device_kill_alert_pending'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite 下 ADD COLUMN(可空) 与 CREATE INDEX 均原生支持,无需 batch_alter_table。
|
||||
op.add_column(
|
||||
'ad_feed_reward_record',
|
||||
sa.Column('trace_id', sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_ad_feed_reward_record_trace_id'),
|
||||
'ad_feed_reward_record',
|
||||
['trace_id'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f('ix_ad_feed_reward_record_trace_id'),
|
||||
table_name='ad_feed_reward_record',
|
||||
)
|
||||
op.drop_column('ad_feed_reward_record', 'trace_id')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""merge meituan_coupon_image_meta and comparison/ad heads
|
||||
|
||||
Revision ID: bcfcaf07152b
|
||||
Revises: 4dc2af7ebe74, meituan_coupon_image_meta
|
||||
Create Date: 2026-06-24 20:34:09.475641
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'bcfcaf07152b'
|
||||
down_revision: Union[str, Sequence[str], None] = ('4dc2af7ebe74', 'meituan_coupon_image_meta')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,26 @@
|
||||
"""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
|
||||
@@ -0,0 +1,37 @@
|
||||
"""add input_tokens / output_tokens to comparison_record
|
||||
|
||||
比价记录增加本次 LLM 累计 token(server 从 llm_calls[].usage 累加),
|
||||
供 admin 比价记录页展示 token 数与估算成本。
|
||||
|
||||
Revision ID: comparison_token_cols
|
||||
Revises: feedback_review_fields
|
||||
Create Date: 2026-06-23 00:40:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "comparison_token_cols"
|
||||
down_revision: Union[str, Sequence[str], None] = "feedback_review_fields"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"comparison_record",
|
||||
sa.Column("input_tokens", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"comparison_record",
|
||||
sa.Column("output_tokens", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("comparison_record", "output_tokens")
|
||||
op.drop_column("comparison_record", "input_tokens")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""cps 微信用户表 cps_wx_user + cps_click 加 openid
|
||||
|
||||
CPS 落地页微信网页授权拿 openid/昵称头像,做用户级群统计。
|
||||
|
||||
Revision ID: cps_wx_user
|
||||
Revises: comparison_debug_fields
|
||||
Create Date: 2026-06-19 12:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "cps_wx_user"
|
||||
down_revision: Union[str, Sequence[str], None] = "comparison_debug_fields"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"cps_wx_user",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("openid", sa.String(64), nullable=False),
|
||||
sa.Column("unionid", sa.String(64), nullable=True),
|
||||
sa.Column("nickname", sa.String(128), nullable=True),
|
||||
sa.Column("headimgurl", sa.String(512), nullable=True),
|
||||
sa.Column("first_code", sa.String(16), nullable=True),
|
||||
sa.Column("first_group_id", sa.Integer(), nullable=True),
|
||||
sa.Column("first_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_cps_wx_user_openid", "cps_wx_user", ["openid"], unique=True)
|
||||
op.create_index("ix_cps_wx_user_unionid", "cps_wx_user", ["unionid"])
|
||||
op.create_index("ix_cps_wx_user_first_group_id", "cps_wx_user", ["first_group_id"])
|
||||
|
||||
op.add_column("cps_click", sa.Column("openid", sa.String(64), nullable=True))
|
||||
op.create_index("ix_cps_click_openid", "cps_click", ["openid"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_cps_click_openid", table_name="cps_click")
|
||||
op.drop_column("cps_click", "openid")
|
||||
op.drop_index("ix_cps_wx_user_first_group_id", table_name="cps_wx_user")
|
||||
op.drop_index("ix_cps_wx_user_unionid", table_name="cps_wx_user")
|
||||
op.drop_index("ix_cps_wx_user_openid", table_name="cps_wx_user")
|
||||
op.drop_table("cps_wx_user")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""device.kill_alert_pending (无障碍掉线「后置检测」待提醒标记)
|
||||
|
||||
Revision ID: device_kill_alert_pending
|
||||
Revises: device_liveness_table
|
||||
Create Date: 2026-06-19
|
||||
|
||||
后置检测 pull 版(spec/accessibility-liveness-pull-prompt.md):worker 检出心跳掉线即置此标记,
|
||||
客户端下次进 App 拉取到 → 弹「开启自启动」引导,交互后 ack 清回。与 liveness_state 解耦,故单列。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'device_kill_alert_pending'
|
||||
down_revision: Union[str, Sequence[str], None] = 'device_liveness_table'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# server_default=sa.false() 跨方言: SQLite 渲染 0 / PG 渲染 false(别用 sa.text("0"),PG 布尔严格会报错),
|
||||
# 给存量行回填 False。
|
||||
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
'kill_alert_pending',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
|
||||
batch_op.drop_column('kill_alert_pending')
|
||||
@@ -0,0 +1,53 @@
|
||||
"""device table (无障碍保护存活检测 + 极光推送)
|
||||
|
||||
Revision ID: device_liveness_table
|
||||
Revises: ceb286289426
|
||||
Create Date: 2026-06-15 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'device_liveness_table'
|
||||
down_revision: Union[str, Sequence[str], None] = 'ceb286289426'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'device_liveness',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('device_id', sa.String(length=128), nullable=False),
|
||||
sa.Column('registration_id', sa.String(length=64), nullable=True),
|
||||
sa.Column('platform', sa.String(length=16), nullable=False),
|
||||
sa.Column('app_version', sa.String(length=32), nullable=True),
|
||||
sa.Column('ever_protected', sa.Boolean(), nullable=False),
|
||||
sa.Column('last_heartbeat_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('last_report_protection_on', sa.Boolean(), nullable=False),
|
||||
sa.Column('liveness_state', sa.String(length=16), nullable=False),
|
||||
sa.Column('notified_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'device_id', name='uq_device_liveness_user_device'),
|
||||
)
|
||||
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_device_liveness_user_id'), ['user_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_device_liveness_device_id'), ['device_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_device_liveness_last_heartbeat_at'), ['last_heartbeat_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_device_liveness_last_heartbeat_at'))
|
||||
batch_op.drop_index(batch_op.f('ix_device_liveness_device_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_device_liveness_user_id'))
|
||||
|
||||
op.drop_table('device_liveness')
|
||||
@@ -0,0 +1,78 @@
|
||||
"""feedback review fields
|
||||
|
||||
Revision ID: feedback_review_fields
|
||||
Revises: ceb286289426
|
||||
Create Date: 2026-06-22 20:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "feedback_review_fields"
|
||||
down_revision: str | Sequence[str] | None = "ceb286289426"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("feedback", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("reject_reason", sa.String(length=256), nullable=True))
|
||||
batch_op.add_column(sa.Column("reward_coins", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("review_note", sa.String(length=256), nullable=True))
|
||||
batch_op.add_column(sa.Column("reviewed_by_admin_id", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.create_index(batch_op.f("ix_feedback_status"), ["status"], unique=False)
|
||||
batch_op.create_foreign_key(
|
||||
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
|
||||
"admin_user",
|
||||
["reviewed_by_admin_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
feedback = sa.table(
|
||||
"feedback",
|
||||
sa.column("status", sa.String(length=16)),
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "new")
|
||||
.values(status="pending")
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "handled")
|
||||
.values(status="adopted")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
feedback = sa.table(
|
||||
"feedback",
|
||||
sa.column("status", sa.String(length=16)),
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "pending")
|
||||
.values(status="new")
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "adopted")
|
||||
.values(status="handled")
|
||||
)
|
||||
|
||||
with op.batch_alter_table("feedback", schema=None) as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
|
||||
type_="foreignkey",
|
||||
)
|
||||
batch_op.drop_index(batch_op.f("ix_feedback_status"))
|
||||
batch_op.drop_column("reviewed_at")
|
||||
batch_op.drop_column("reviewed_by_admin_id")
|
||||
batch_op.drop_column("review_note")
|
||||
batch_op.drop_column("reward_coins")
|
||||
batch_op.drop_column("reject_reason")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""launch_confirm_sample table (启动确认窗 agent 兜底样本: LLM 放行的弹窗样本沉淀)
|
||||
|
||||
Revision ID: launch_confirm_sample_table
|
||||
Revises: cps_wx_user
|
||||
Create Date: 2026-06-20 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "launch_confirm_sample_table"
|
||||
down_revision: Union[str, Sequence[str], None] = "cps_wx_user"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"launch_confirm_sample",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("device_id", sa.String(length=64), nullable=True),
|
||||
sa.Column("host_package", sa.String(length=128), nullable=True),
|
||||
sa.Column("target_app", sa.String(length=128), nullable=True),
|
||||
sa.Column("system_locale", sa.String(length=32), nullable=True),
|
||||
sa.Column("exec_success", sa.Boolean(), nullable=False),
|
||||
sa.Column("dialog_title", sa.String(length=256), nullable=True),
|
||||
# PG 上为 JSONB,其它(SQLite)为 JSON——与模型层 with_variant 对齐
|
||||
sa.Column("payload", sa.JSON().with_variant(sa.dialects.postgresql.JSONB(), "postgresql"), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_created_at"), ["created_at"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_trace_id"), ["trace_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_device_id"), ["device_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_host_package"), ["host_package"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_launch_confirm_sample_system_locale"), ["system_locale"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("launch_confirm_sample", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_system_locale"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_host_package"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_device_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_trace_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_launch_confirm_sample_created_at"))
|
||||
|
||||
op.drop_table("launch_confirm_sample")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""meituan_coupon image size & type
|
||||
|
||||
给 meituan_coupon 增加 image_size(字节)/ image_type(MIME)两列,采集时 HEAD 头图得到,
|
||||
供读取侧按图片大小决定是否拼缩放参数提速。两列均 nullable,不动存量行;下一轮全量 ETL
|
||||
upsert 自动回填,无需手动 backfill。
|
||||
|
||||
Revision ID: meituan_coupon_image_meta
|
||||
Revises: feedback_review_fields
|
||||
Create Date: 2026-06-23 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "meituan_coupon_image_meta"
|
||||
down_revision: str | Sequence[str] | None = "feedback_review_fields"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("image_size", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("image_type", sa.String(length=32), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
|
||||
batch_op.drop_column("image_type")
|
||||
batch_op.drop_column("image_size")
|
||||
@@ -14,6 +14,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.admin.routers.ad_audit import router as ad_audit_router
|
||||
from app.admin.routers.ad_config import router as ad_config_router
|
||||
from app.admin.routers.ad_revenue import router as ad_revenue_router
|
||||
from app.admin.routers.admins import router as admins_router
|
||||
from app.admin.routers.audit import router as audit_router
|
||||
@@ -24,6 +25,7 @@ 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
|
||||
@@ -89,10 +91,12 @@ 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)
|
||||
|
||||
@@ -67,6 +67,7 @@ def _reward_video_rows(
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -88,6 +89,7 @@ def _reward_video_rows(
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -154,6 +156,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
@@ -174,6 +177,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"ad_session_id": rec.ad_session_id,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。
|
||||
"""admin 广告收益报表:**逐条广告事件**列表(每行一次广告,含展示 + 发奖对账)。
|
||||
|
||||
只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出:
|
||||
- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。
|
||||
激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。
|
||||
- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,
|
||||
不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致
|
||||
(任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。
|
||||
只读。每行 = 一次广告事件(不再按用户聚合):
|
||||
- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行,
|
||||
直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。
|
||||
- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id)
|
||||
与逐条展示无法对应,单独成「纯发奖」行。
|
||||
- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。
|
||||
|
||||
展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示
|
||||
(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。
|
||||
展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算
|
||||
(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计,
|
||||
不受 limit(只截断 items)影响。
|
||||
|
||||
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw
|
||||
不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。
|
||||
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed。
|
||||
② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,6 +24,7 @@ from sqlalchemy.orm import Session
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.core import rewards
|
||||
from app.models.ad_ecpm import AdEcpmRecord
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _cn_hour(dt: datetime) -> int:
|
||||
@@ -32,17 +34,6 @@ def _cn_hour(dt: datetime) -> int:
|
||||
return dt.astimezone(rewards.CN_TZ).hour
|
||||
|
||||
|
||||
def _key(
|
||||
report_date: str,
|
||||
user_id: int,
|
||||
ad_type: str,
|
||||
app_env: str | None,
|
||||
our_code_id: str | None,
|
||||
hour: int | None,
|
||||
) -> tuple:
|
||||
return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour)
|
||||
|
||||
|
||||
def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
"""闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。"""
|
||||
d0 = _date.fromisoformat(date_from)
|
||||
@@ -58,6 +49,18 @@ def _date_range(date_from: str, date_to: str) -> list[str]:
|
||||
# 审计行的 scene 与报表 ad_type 一一对应
|
||||
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
|
||||
|
||||
# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。
|
||||
_REWARD_DETAIL_KEYS = (
|
||||
"record_id", "created_at", "status", "ecpm", "ecpm_factor", "units",
|
||||
"lt_index_start", "lt_index_end", "lt_factor_start", "lt_factor_end",
|
||||
"expected_coin", "actual_coin", "matched",
|
||||
)
|
||||
|
||||
|
||||
def _reward_detail(row: dict) -> dict:
|
||||
"""从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。"""
|
||||
return {k: row[k] for k in _REWARD_DETAIL_KEYS}
|
||||
|
||||
|
||||
def ad_revenue_report(
|
||||
db: Session,
|
||||
@@ -69,44 +72,42 @@ def ad_revenue_report(
|
||||
granularity: str = "day",
|
||||
limit: int = 500,
|
||||
) -> dict:
|
||||
"""日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。
|
||||
"""日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 date_from==date_to。
|
||||
|
||||
聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。
|
||||
ad_type: None=全部 / reward_video / feed / draw。
|
||||
granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。
|
||||
limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。
|
||||
|
||||
返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。
|
||||
|
||||
注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚
|
||||
一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。
|
||||
每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。
|
||||
ad_type: None=全部 / reward_video / feed / draw。granularity=hour 时每行带北京小时(由各自时间算)。
|
||||
limit 只截断 items(事件明细),total 与 total_* / daily 在全量上统计,数字始终可信。
|
||||
"""
|
||||
by_hour = granularity == "hour"
|
||||
groups: dict[tuple, dict] = {}
|
||||
|
||||
def _grp(key: tuple) -> dict:
|
||||
g = groups.get(key)
|
||||
if g is None:
|
||||
rdate, uid, atype, app_env, code_id, hour = key
|
||||
g = {
|
||||
"report_date": rdate,
|
||||
"user_id": uid,
|
||||
"ad_type": atype,
|
||||
"app_env": app_env,
|
||||
"our_code_id": code_id,
|
||||
"hour": hour,
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
"adns": set(),
|
||||
"impression_records": [], # 该组逐条展示明细(展开下钻用)
|
||||
"records": [], # 该组逐条发奖复算明细(展开下钻用)
|
||||
}
|
||||
groups[key] = g
|
||||
return g
|
||||
# 1) 发奖行(逐日 audit 复算):建 (user_id, ad_session_id) → [行] 映射用于和展示合并;
|
||||
# 同时保留全量列表,未被展示合并的成「纯发奖」事件。
|
||||
reward_by_session: dict[tuple[int, str], list[dict]] = {}
|
||||
all_reward_rows: list[dict] = []
|
||||
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
|
||||
if ad_type is None or audit_scene is not None:
|
||||
for d in _date_range(date_from, date_to):
|
||||
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
|
||||
row["_report_date"] = d
|
||||
all_reward_rows.append(row)
|
||||
sid = row.get("ad_session_id")
|
||||
if sid:
|
||||
reward_by_session.setdefault((row["user_id"], sid), []).append(row)
|
||||
|
||||
# 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序)
|
||||
used_reward_ids: set[int] = set()
|
||||
events: list[dict] = []
|
||||
|
||||
def _pop_reward(uid: int, sid: str | None) -> dict | None:
|
||||
"""取一条与 (uid, sid) 匹配且未被用过的发奖行(激励视频展示↔发奖按会话 1:1 合并)。"""
|
||||
if not sid:
|
||||
return None
|
||||
for r in reward_by_session.get((uid, sid), ()):
|
||||
if r["record_id"] not in used_reward_ids:
|
||||
used_reward_ids.add(r["record_id"])
|
||||
return r
|
||||
return None
|
||||
|
||||
# 2) 展示记录(ad_ecpm):每条一个事件;能匹配到发奖则合并成「展示 + 发奖」一行。
|
||||
stmt = select(AdEcpmRecord).where(
|
||||
AdEcpmRecord.report_date >= date_from,
|
||||
AdEcpmRecord.report_date <= date_to,
|
||||
@@ -116,124 +117,116 @@ def ad_revenue_report(
|
||||
if ad_type is not None:
|
||||
stmt = stmt.where(AdEcpmRecord.ad_type == ad_type)
|
||||
for rec in db.execute(stmt).scalars():
|
||||
hour = _cn_hour(rec.created_at) if by_hour else None
|
||||
g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour))
|
||||
g["impressions"] += 1
|
||||
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。
|
||||
rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0
|
||||
g["revenue_yuan"] += rev
|
||||
if rec.adn:
|
||||
g["adns"].add(rec.adn)
|
||||
g["impression_records"].append({
|
||||
"id": rec.id,
|
||||
rwd = _pop_reward(rec.user_id, rec.ad_session_id)
|
||||
ev = {
|
||||
"event_key": f"imp-{rec.id}",
|
||||
"report_date": rec.report_date,
|
||||
"user_id": rec.user_id,
|
||||
"ad_type": rec.ad_type,
|
||||
"app_env": rec.app_env,
|
||||
"our_code_id": rec.our_code_id,
|
||||
"created_at": rec.created_at,
|
||||
"hour": _cn_hour(rec.created_at) if by_hour else None,
|
||||
"has_impression": True,
|
||||
"impressions": 1,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"revenue_yuan": round(rev, 6),
|
||||
# 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。
|
||||
"revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6),
|
||||
"adn": rec.adn,
|
||||
"slot_id": rec.slot_id,
|
||||
}
|
||||
if rwd is not None:
|
||||
ev.update({
|
||||
"has_reward": True,
|
||||
"status": rwd["status"],
|
||||
"expected_coin": int(rwd["expected_coin"]),
|
||||
"actual_coin": int(rwd["actual_coin"]),
|
||||
"matched": bool(rwd["matched"]),
|
||||
"reward_detail": _reward_detail(rwd),
|
||||
})
|
||||
else:
|
||||
# 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。
|
||||
ev.update({
|
||||
"has_reward": False, "status": None,
|
||||
"expected_coin": 0, "actual_coin": 0, "matched": True,
|
||||
"reward_detail": None,
|
||||
})
|
||||
events.append(ev)
|
||||
|
||||
# 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。
|
||||
# 收益恒 0(收益只算展示侧,避免与展示行重复计)。
|
||||
for row in all_reward_rows:
|
||||
if row["record_id"] in used_reward_ids:
|
||||
continue
|
||||
events.append({
|
||||
"event_key": f"rwd-{row['record_id']}",
|
||||
"report_date": row["_report_date"],
|
||||
"user_id": row["user_id"],
|
||||
"ad_type": _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]),
|
||||
"app_env": row.get("app_env"),
|
||||
"our_code_id": row.get("our_code_id"),
|
||||
"created_at": row["created_at"],
|
||||
"hour": _cn_hour(row["created_at"]) if by_hour else None,
|
||||
"has_impression": False,
|
||||
"impressions": 0,
|
||||
"ecpm": row["ecpm"],
|
||||
"revenue_yuan": 0.0,
|
||||
"adn": None,
|
||||
"slot_id": None,
|
||||
"has_reward": True,
|
||||
"status": row["status"],
|
||||
"expected_coin": int(row["expected_coin"]),
|
||||
"actual_coin": int(row["actual_coin"]),
|
||||
"matched": bool(row["matched"]),
|
||||
"reward_detail": _reward_detail(row),
|
||||
})
|
||||
|
||||
# 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。
|
||||
# audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。
|
||||
# ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。
|
||||
audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None
|
||||
if ad_type is None or audit_scene is not None:
|
||||
for d in _date_range(date_from, date_to):
|
||||
for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene):
|
||||
atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"])
|
||||
hour = _cn_hour(row["created_at"]) if by_hour else None
|
||||
g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour))
|
||||
g["expected_coin"] += int(row["expected_coin"])
|
||||
g["actual_coin"] += int(row["actual_coin"])
|
||||
# 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。
|
||||
g["records"].append({
|
||||
"record_id": row["record_id"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"ecpm": row["ecpm"],
|
||||
"ecpm_factor": row["ecpm_factor"],
|
||||
"units": row["units"],
|
||||
"lt_index_start": row["lt_index_start"],
|
||||
"lt_index_end": row["lt_index_end"],
|
||||
"lt_factor_start": row["lt_factor_start"],
|
||||
"lt_factor_end": row["lt_factor_end"],
|
||||
"expected_coin": row["expected_coin"],
|
||||
"actual_coin": row["actual_coin"],
|
||||
"matched": row["matched"],
|
||||
})
|
||||
events.sort(key=lambda e: (e["report_date"], e["user_id"], e["created_at"]))
|
||||
|
||||
rows = list(groups.values())
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
r["report_date"],
|
||||
r["user_id"],
|
||||
r["hour"] if r["hour"] is not None else -1,
|
||||
r["ad_type"] or "",
|
||||
r["our_code_id"] or "",
|
||||
)
|
||||
)
|
||||
# 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。
|
||||
uids = {e["user_id"] for e in events}
|
||||
phone_map: dict[int, str] = {}
|
||||
if uids:
|
||||
phone_map = {
|
||||
uid: phone
|
||||
for uid, phone in db.execute(
|
||||
select(User.id, User.phone).where(User.id.in_(uids))
|
||||
).all()
|
||||
}
|
||||
for e in events:
|
||||
e["user_phone"] = phone_map.get(e["user_id"])
|
||||
|
||||
total_impressions = sum(r["impressions"] for r in rows)
|
||||
total_expected_coin = sum(r["expected_coin"] for r in rows)
|
||||
total_actual_coin = sum(r["actual_coin"] for r in rows)
|
||||
total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6)
|
||||
total_impressions = sum(e["impressions"] for e in events)
|
||||
total_revenue_yuan = round(sum(e["revenue_yuan"] for e in events), 6)
|
||||
total_expected_coin = sum(e["expected_coin"] for e in events)
|
||||
total_actual_coin = sum(e["actual_coin"] for e in events)
|
||||
mismatch_count = sum(1 for e in events if e["has_reward"] and not e["matched"])
|
||||
|
||||
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
|
||||
daily_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
d = daily_map.get(r["report_date"])
|
||||
for e in events:
|
||||
d = daily_map.get(e["report_date"])
|
||||
if d is None:
|
||||
d = {
|
||||
"date": r["report_date"],
|
||||
"impressions": 0,
|
||||
"revenue_yuan": 0.0,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": 0,
|
||||
}
|
||||
daily_map[r["report_date"]] = d
|
||||
d["impressions"] += r["impressions"]
|
||||
d["revenue_yuan"] += r["revenue_yuan"]
|
||||
d["expected_coin"] += r["expected_coin"]
|
||||
d["actual_coin"] += r["actual_coin"]
|
||||
d = {"date": e["report_date"], "impressions": 0, "revenue_yuan": 0.0,
|
||||
"expected_coin": 0, "actual_coin": 0}
|
||||
daily_map[e["report_date"]] = d
|
||||
d["impressions"] += e["impressions"]
|
||||
d["revenue_yuan"] += e["revenue_yuan"]
|
||||
d["expected_coin"] += e["expected_coin"]
|
||||
d["actual_coin"] += e["actual_coin"]
|
||||
daily = [
|
||||
{**d, "revenue_yuan": round(d["revenue_yuan"], 6)}
|
||||
for d in sorted(daily_map.values(), key=lambda x: x["date"])
|
||||
]
|
||||
|
||||
items = [
|
||||
{
|
||||
"report_date": r["report_date"],
|
||||
"user_id": r["user_id"],
|
||||
"ad_type": r["ad_type"],
|
||||
"app_env": r["app_env"],
|
||||
"our_code_id": r["our_code_id"],
|
||||
"hour": r["hour"],
|
||||
"impressions": r["impressions"],
|
||||
"revenue_yuan": round(r["revenue_yuan"], 6),
|
||||
"expected_coin": r["expected_coin"],
|
||||
"actual_coin": r["actual_coin"],
|
||||
# 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相
|
||||
# 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。
|
||||
"matched": all(rec["matched"] for rec in r["records"]),
|
||||
"adns": sorted(r["adns"]),
|
||||
"impression_records": sorted(
|
||||
r["impression_records"], key=lambda x: (x["created_at"], x["id"])
|
||||
),
|
||||
"records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])),
|
||||
}
|
||||
for r in rows[:limit]
|
||||
]
|
||||
|
||||
return {
|
||||
"total": len(rows),
|
||||
"truncated": len(rows) > limit,
|
||||
"total": len(events),
|
||||
"truncated": len(events) > limit,
|
||||
"total_impressions": total_impressions,
|
||||
"total_revenue_yuan": total_revenue_yuan,
|
||||
"total_expected_coin": total_expected_coin,
|
||||
"total_actual_coin": total_actual_coin,
|
||||
"mismatch_count": sum(
|
||||
1 for r in rows if not all(rec["matched"] for rec in r["records"])
|
||||
),
|
||||
"mismatch_count": mismatch_count,
|
||||
"daily": daily,
|
||||
"items": items,
|
||||
"items": events[:limit],
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin.repositories.queries import _as_utc, offset_paginate
|
||||
@@ -19,6 +19,7 @@ from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_group import CpsGroup
|
||||
from app.models.cps_link import CpsClick
|
||||
from app.models.cps_order import CpsOrder
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
|
||||
# 美团订单状态:取消(4)/风控(5)不计佣金;结算(6)为佣金真正到账
|
||||
_INVALID_STATUS = {"4", "5"}
|
||||
@@ -470,3 +471,56 @@ def group_order_daily(
|
||||
"settled_commission_cents": sum(o.commission_cents or 0 for o in settled),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def group_wx_users(db: Session, *, group_id: int, limit: int = 200) -> list[dict]:
|
||||
"""该群来过的微信用户:在本群有带 openid 点击的所有用户 + 本群 visit/copy 次数 + 画像。
|
||||
|
||||
用「本群点击」判定归属,而非 cps_wx_user.first_group_id —— 同一用户会在多个群活动,
|
||||
first_group_id 只记首次授权群,会漏掉"先在别处授权、cookie 已存、又来本群"的用户。
|
||||
copy=本群点「复制口令」次数(领券意向),visit=进落地页次数。按本群首次点击倒序。
|
||||
昵称/头像来自授权画像;美团/京东群只 302 跳转、无 userinfo 触发点,故多为空。
|
||||
"""
|
||||
rows = db.execute(
|
||||
select(CpsClick.openid, CpsClick.event_type, func.count())
|
||||
.where(CpsClick.group_id == group_id)
|
||||
.where(CpsClick.openid.is_not(None))
|
||||
.group_by(CpsClick.openid, CpsClick.event_type)
|
||||
).all()
|
||||
if not rows:
|
||||
return []
|
||||
stat: dict[str, dict] = {}
|
||||
for openid, event_type, cnt in rows:
|
||||
s = stat.setdefault(openid, {"visit": 0, "copy": 0})
|
||||
if event_type == "copy":
|
||||
s["copy"] = cnt
|
||||
else:
|
||||
s["visit"] = cnt
|
||||
openids = list(stat.keys())
|
||||
# 本群每个 openid 的首次点击时间(排序 + 展示"首次进入")
|
||||
first_seen = dict(
|
||||
db.execute(
|
||||
select(CpsClick.openid, func.min(CpsClick.clicked_at))
|
||||
.where(CpsClick.group_id == group_id)
|
||||
.where(CpsClick.openid.is_not(None))
|
||||
.group_by(CpsClick.openid)
|
||||
).all()
|
||||
)
|
||||
# 关联画像(未授权 userinfo 的昵称/头像为空)
|
||||
users = {
|
||||
u.openid: u
|
||||
for u in db.execute(select(CpsWxUser).where(CpsWxUser.openid.in_(openids))).scalars().all()
|
||||
}
|
||||
result = [
|
||||
{
|
||||
"openid": openid,
|
||||
"nickname": users[openid].nickname if openid in users else None,
|
||||
"headimgurl": users[openid].headimgurl if openid in users else None,
|
||||
"first_seen": first_seen.get(openid),
|
||||
"visit_count": stat[openid]["visit"],
|
||||
"copy_count": stat[openid]["copy"],
|
||||
}
|
||||
for openid in openids
|
||||
]
|
||||
result.sort(key=lambda x: x["first_seen"], reverse=True)
|
||||
return result[:limit]
|
||||
|
||||
@@ -56,6 +56,35 @@ def update_feedback_status(
|
||||
return feedback
|
||||
|
||||
|
||||
def review_feedback(
|
||||
db: Session,
|
||||
feedback: Feedback,
|
||||
*,
|
||||
status: str,
|
||||
reviewed_by_admin_id: int,
|
||||
reward_coins: int | None = None,
|
||||
reject_reason: str | None = None,
|
||||
review_note: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> Feedback:
|
||||
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
|
||||
|
||||
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
|
||||
"""
|
||||
feedback.status = status
|
||||
feedback.reward_coins = reward_coins
|
||||
feedback.reject_reason = reject_reason
|
||||
feedback.review_note = review_note
|
||||
feedback.reviewed_by_admin_id = reviewed_by_admin_id
|
||||
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(feedback)
|
||||
else:
|
||||
db.flush()
|
||||
return feedback
|
||||
|
||||
|
||||
def review_price_report(
|
||||
db: Session,
|
||||
report: PriceReport,
|
||||
|
||||
@@ -11,6 +11,9 @@ 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
|
||||
@@ -19,6 +22,16 @@ 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
|
||||
@@ -331,6 +344,38 @@ 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,
|
||||
*,
|
||||
@@ -448,15 +493,19 @@ 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("新注册用户")
|
||||
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}笔")
|
||||
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
|
||||
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}笔")
|
||||
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 + failed_or_rejected * 10)
|
||||
score = min(100, len(flags) * 20 + (rejected_n + failed_n) * 10)
|
||||
return flags, score
|
||||
|
||||
|
||||
@@ -546,6 +595,198 @@ 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,
|
||||
*,
|
||||
|
||||
@@ -119,7 +119,7 @@ def dashboard_overview(db: Session) -> dict:
|
||||
"success": comparison_success,
|
||||
"success_rate": success_rate,
|
||||
},
|
||||
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
|
||||
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
|
||||
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
||||
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""admin 广告配置:读/写穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
|
||||
|
||||
存在 app_config 表的 ad_config dict(见 repositories/app_config.get_ad_config/set_ad_config)。
|
||||
客户端经 /api/v1/platform/ad-config 拉取(不含 reward_mkey)。权限 operator/finance + 审计。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.schemas.ad_config import AdConfigOut, AdConfigUpdate
|
||||
from app.models.admin import AdminUser
|
||||
from app.repositories import app_config
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-config",
|
||||
tags=["admin-ad-config"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AdConfigOut, summary="广告配置(穿山甲ID/验签密钥/各场景开关)")
|
||||
def get_ad_config(db: AdminDb) -> AdConfigOut:
|
||||
return AdConfigOut(**app_config.get_ad_config(db))
|
||||
|
||||
|
||||
@router.patch("", response_model=AdConfigOut, summary="改广告配置(部分更新,带审计)")
|
||||
def update_ad_config(
|
||||
body: AdConfigUpdate,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator", "finance"))],
|
||||
db: AdminDb,
|
||||
) -> AdConfigOut:
|
||||
data = body.model_dump(exclude_none=True)
|
||||
app_config.set_ad_config(db, data, admin_id=admin.id, commit=False)
|
||||
# 审计只记改了哪些字段,不记 mkey 明文(机密)
|
||||
write_audit(
|
||||
db, admin, action="ad_config.set", target_type="ad_config", target_id="ad_config",
|
||||
detail={"changed": sorted(data.keys())}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return AdConfigOut(**app_config.get_ad_config(db))
|
||||
@@ -488,3 +488,11 @@ def group_daily(
|
||||
"days": days,
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/wx-users", summary="群内微信用户(领券画像:头像/昵称/领券次数)")
|
||||
def group_wx_users(group_id: int, db: AdminDb) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
return {"users": cps_repo.group_wx_users(db, group_id=group_id)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
|
||||
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
@@ -10,9 +10,10 @@ from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||
from app.admin.repositories import mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.feedback import FeedbackOut
|
||||
from app.admin.schemas.feedback import FeedbackApproveRequest, FeedbackOut, FeedbackRejectRequest
|
||||
from app.models.admin import AdminUser
|
||||
from app.models.feedback import Feedback
|
||||
from app.repositories import wallet as wallet_repo
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/feedbacks",
|
||||
@@ -21,6 +22,11 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _ensure_pending(fb: Feedback) -> None:
|
||||
if fb.status not in {"pending", "new"}:
|
||||
raise HTTPException(status_code=400, detail="反馈已审核")
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
|
||||
def list_feedbacks(
|
||||
db: AdminDb,
|
||||
@@ -56,18 +62,104 @@ def list_feedbacks(
|
||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||
def handle_feedback(
|
||||
feedback_id: int,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
_admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
raise HTTPException(status_code=400, detail="请使用采纳或拒绝接口审核反馈")
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/approve", response_model=FeedbackOut, summary="采纳反馈并发金币")
|
||||
def approve_feedback(
|
||||
feedback_id: int,
|
||||
payload: FeedbackApproveRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> FeedbackOut:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
_ensure_pending(fb)
|
||||
|
||||
before = fb.status
|
||||
mutations.update_feedback_status(db, fb, status="handled", commit=False)
|
||||
mutations.review_feedback(
|
||||
db,
|
||||
fb,
|
||||
status="adopted",
|
||||
reward_coins=payload.reward_coins,
|
||||
review_note=payload.note,
|
||||
reviewed_by_admin_id=admin.id,
|
||||
commit=False,
|
||||
)
|
||||
wallet_repo.grant_coins(
|
||||
db,
|
||||
fb.user_id,
|
||||
payload.reward_coins,
|
||||
biz_type="feedback_reward",
|
||||
ref_id=str(fb.id),
|
||||
remark="意见反馈被采纳",
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="feedback.handle", target_type="feedback", target_id=feedback_id,
|
||||
detail={"before": before, "after": "handled"}, ip=get_client_ip(request), commit=False,
|
||||
db,
|
||||
admin,
|
||||
action="feedback.approve",
|
||||
target_type="feedback",
|
||||
target_id=feedback_id,
|
||||
detail={
|
||||
"before": before,
|
||||
"after": "adopted",
|
||||
"reward_coins": payload.reward_coins,
|
||||
"note": payload.note,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return OkResponse()
|
||||
db.refresh(fb)
|
||||
return FeedbackOut.model_validate(fb)
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
|
||||
def reject_feedback(
|
||||
feedback_id: int,
|
||||
payload: FeedbackRejectRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> FeedbackOut:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
_ensure_pending(fb)
|
||||
|
||||
before = fb.status
|
||||
mutations.review_feedback(
|
||||
db,
|
||||
fb,
|
||||
status="rejected",
|
||||
reject_reason=payload.reason,
|
||||
review_note=payload.note,
|
||||
reviewed_by_admin_id=admin.id,
|
||||
commit=False,
|
||||
)
|
||||
write_audit(
|
||||
db,
|
||||
admin,
|
||||
action="feedback.reject",
|
||||
target_type="feedback",
|
||||
target_id=feedback_id,
|
||||
detail={
|
||||
"before": before,
|
||||
"after": "rejected",
|
||||
"reason": payload.reason,
|
||||
"note": payload.note,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(fb)
|
||||
return FeedbackOut.model_validate(fb)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""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)
|
||||
@@ -17,6 +17,8 @@ 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
|
||||
@@ -66,6 +68,46 @@ 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,
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.admin.schemas.wallet import (
|
||||
WithdrawBulkItemResult,
|
||||
WithdrawDetailOut,
|
||||
WithdrawLedgerCheckOut,
|
||||
WithdrawListItemOut,
|
||||
WithdrawOrderOut,
|
||||
WithdrawRejectRequest,
|
||||
WithdrawSummaryOut,
|
||||
@@ -44,7 +45,7 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[WithdrawOrderOut], summary="提现单列表")
|
||||
@router.get("", response_model=CursorPage[WithdrawListItemOut], summary="提现单列表")
|
||||
def list_withdraws(
|
||||
db: AdminDb,
|
||||
user_id: Annotated[int | None, Query()] = None,
|
||||
@@ -63,7 +64,7 @@ def list_withdraws(
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: Annotated[int | None, Query()] = None,
|
||||
) -> CursorPage[WithdrawOrderOut]:
|
||||
) -> CursorPage[WithdrawListItemOut]:
|
||||
items, next_cursor, total = queries.list_all_withdraw_orders(
|
||||
db,
|
||||
user_id=user_id,
|
||||
@@ -78,11 +79,16 @@ def list_withdraws(
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
return CursorPage(
|
||||
items=[WithdrawOrderOut.model_validate(o) for o in items],
|
||||
next_cursor=next_cursor,
|
||||
total=total,
|
||||
)
|
||||
# 联表带出手机号/昵称 + 累计成功提现(本页 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)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=WithdrawSummaryOut, summary="提现审核台统计")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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
|
||||
withdrawal_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
|
||||
withdrawal_ad_enabled: bool | None = None
|
||||
@@ -1,7 +1,7 @@
|
||||
"""广告收益报表 schemas。
|
||||
|
||||
按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数、收益(元)、金币、来源。
|
||||
字段 snake_case;收益按元(float),金币按整数。
|
||||
**逐条广告事件**只读报表:每行一次广告(激励视频展示+发奖按会话合并;信息流展示/发奖各自成行),
|
||||
含 展示条数、收益(元)、应发/实发金币、对账。字段 snake_case;收益按元(float),金币按整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -50,27 +50,33 @@ class AdRevenueDaily(BaseModel):
|
||||
|
||||
|
||||
class AdRevenueRow(BaseModel):
|
||||
"""一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。"""
|
||||
"""一次广告事件(逐条一行):激励视频展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行。"""
|
||||
|
||||
report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)")
|
||||
event_key: str = Field(..., description="该事件稳定唯一键(imp-{ecpm_id} / rwd-{reward_id});前端 rowKey 用")
|
||||
report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)")
|
||||
user_id: int
|
||||
user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)")
|
||||
ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)")
|
||||
app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空")
|
||||
our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空")
|
||||
hour: int | None = Field(None, description="北京时间小时 0–23(granularity=hour 时有值;按天为 null)")
|
||||
impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)")
|
||||
revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0")
|
||||
expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)")
|
||||
actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)")
|
||||
matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)")
|
||||
adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)")
|
||||
impression_records: list[AdRevenueImpression] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)",
|
||||
)
|
||||
records: list[AdRevenueRecord] = Field(
|
||||
default_factory=list,
|
||||
description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空",
|
||||
created_at: datetime = Field(..., description="事件时间(有展示=展示时间,纯发奖=发奖时间)")
|
||||
# ── 展示侧 ──
|
||||
has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)")
|
||||
impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)")
|
||||
ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值")
|
||||
revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0")
|
||||
adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空")
|
||||
slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空")
|
||||
# ── 发奖侧 ──
|
||||
has_reward: bool = Field(..., description="是否有发奖记录(激励视频合并行 / 信息流整场发奖行=True;纯展示=False)")
|
||||
status: str | None = Field(None, description="发奖状态 granted/closed_early/too_short/…;纯展示为空")
|
||||
expected_coin: int = Field(..., description="应发金币(公式复算,与金币审计同源);纯展示=0")
|
||||
actual_coin: int = Field(..., description="实发金币(实际入账);纯展示=0")
|
||||
matched: bool = Field(..., description="本条应发==实发;纯展示恒 True(不计对账)")
|
||||
reward_detail: AdRevenueRecord | None = Field(
|
||||
None,
|
||||
description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空",
|
||||
)
|
||||
|
||||
|
||||
@@ -80,11 +86,11 @@ class AdRevenueReportOut(BaseModel):
|
||||
date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)")
|
||||
date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)")
|
||||
daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)")
|
||||
total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)")
|
||||
total: int = Field(..., description="广告事件总数(全量,不受 limit 影响)")
|
||||
truncated: bool = Field(..., description="明细是否被 limit 截断")
|
||||
total_impressions: int = Field(..., description="全量展示条数合计")
|
||||
total_revenue_yuan: float = Field(..., description="全量收益合计(元)")
|
||||
total_expected_coin: int = Field(..., description="全量应发金币合计")
|
||||
total_actual_coin: int = Field(..., description="全量实发金币合计")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)")
|
||||
items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)")
|
||||
mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)")
|
||||
items: list[AdRevenueRow] = Field(..., description="逐条广告事件(按 日期→用户→时间 排序)")
|
||||
|
||||
@@ -33,6 +33,8 @@ class AdminComparisonListItem(BaseModel):
|
||||
step_count: int | None = None
|
||||
llm_call_count: int | None = None
|
||||
retry_count: int | None = None
|
||||
input_tokens: int | None = None # Σ usage.prompt_tokens(server 派生)
|
||||
output_tokens: int | None = None # Σ usage.completion_tokens(server 派生)
|
||||
device_model: str | None = None
|
||||
rom_vendor: str | None = None
|
||||
rom_name: str | None = None
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
|
||||
|
||||
|
||||
class FeedbackOut(BaseModel):
|
||||
@@ -15,4 +17,23 @@ class FeedbackOut(BaseModel):
|
||||
contact: str
|
||||
images: list[str] | None = None
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
review_note: str | None = None
|
||||
reviewed_by_admin_id: int | None = None
|
||||
reviewed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FeedbackApproveRequest(BaseModel):
|
||||
reward_coins: int = Field(
|
||||
ge=1,
|
||||
le=FEEDBACK_REWARD_MAX_COINS,
|
||||
description="采纳后发放金币数",
|
||||
)
|
||||
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
|
||||
|
||||
|
||||
class FeedbackRejectRequest(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=256, description="未采纳原因,用户端可见")
|
||||
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""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)
|
||||
@@ -37,6 +37,36 @@ 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():
|
||||
|
||||
@@ -50,6 +50,17 @@ 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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""启动确认窗兜底样本内部上报端点(pricebot → app-server)。
|
||||
|
||||
pricebot 的 launch_confirm_agent 每次靠 LLM 兜底放行一个"静态 PROFILES 没认出"的跨 App
|
||||
启动确认窗时,把完整样本 POST 到这里落库。**不是给客户端的接口**:不走用户 JWT,靠
|
||||
server 间共享密钥头 `X-Internal-Secret` 校验(== settings.INTERNAL_API_SECRET)。密钥未配置
|
||||
(默认空)时直接 503,避免裸奔写端点。
|
||||
|
||||
研发定期人工把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
|
||||
from app.api.deps import DbSession
|
||||
from app.core.config import settings
|
||||
from app.repositories import launch_confirm_sample as repo
|
||||
from app.schemas.launch_confirm_sample import (
|
||||
LaunchConfirmSampleIn,
|
||||
LaunchConfirmSampleOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.internal.launch_confirm")
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
def _check_secret(x_internal_secret: str | None) -> None:
|
||||
"""共享密钥校验。未配置 → 503(挡裸奔写端点);不匹配 → 401(常量时间比较)。"""
|
||||
configured = settings.INTERNAL_API_SECRET
|
||||
if not configured:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="internal api not configured",
|
||||
)
|
||||
if not x_internal_secret or not hmac.compare_digest(x_internal_secret, configured):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid internal secret",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/launch-confirm-sample",
|
||||
response_model=LaunchConfirmSampleOut,
|
||||
summary="启动确认窗兜底样本上报(pricebot→app-server,落 launch_confirm_sample)",
|
||||
)
|
||||
def report_launch_confirm_sample(
|
||||
payload: LaunchConfirmSampleIn,
|
||||
db: DbSession,
|
||||
x_internal_secret: Annotated[str | None, Header()] = None,
|
||||
) -> LaunchConfirmSampleOut:
|
||||
_check_secret(x_internal_secret)
|
||||
sid = repo.insert_sample(db, payload)
|
||||
logger.info(
|
||||
"launch_confirm_sample id=%d host=%s locale=%s success=%s trace=%s",
|
||||
sid, payload.host_package, payload.system_locale,
|
||||
payload.exec_success, payload.trace_id,
|
||||
)
|
||||
return LaunchConfirmSampleOut(id=sid)
|
||||
+45
-2
@@ -24,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories import ad_feed_reward as crud_feed
|
||||
from app.repositories import ad_reward as crud_ad
|
||||
from app.repositories import ad_watch as crud_watch
|
||||
from app.repositories import app_config
|
||||
from app.repositories import signin as crud_signin
|
||||
from app.schemas.ad import (
|
||||
AdRewardStatusOut,
|
||||
@@ -31,6 +32,7 @@ from app.schemas.ad import (
|
||||
EcpmReportOut,
|
||||
FeedRewardIn,
|
||||
FeedRewardOut,
|
||||
FeedRewardUnitsOut,
|
||||
PangleCallbackOut,
|
||||
RewardNoShowIn,
|
||||
RewardNoShowOut,
|
||||
@@ -51,6 +53,9 @@ REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
|
||||
|
||||
REWARD_SCENE_REWARD_VIDEO = "reward_video"
|
||||
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
|
||||
# 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径
|
||||
# ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。
|
||||
REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad"
|
||||
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST}
|
||||
|
||||
|
||||
@@ -80,14 +85,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
|
||||
granted / capped → is_verify=true + reason=0。
|
||||
"""
|
||||
if not settings.pangle_callback_configured:
|
||||
if not settings.PANGLE_CALLBACK_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
||||
)
|
||||
|
||||
params = dict(request.query_params)
|
||||
|
||||
if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets):
|
||||
# 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
|
||||
# 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
|
||||
ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
|
||||
secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
|
||||
if not secrets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
||||
)
|
||||
if not pangle.verify_callback_sign_any(params, secrets):
|
||||
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
|
||||
|
||||
@@ -116,6 +129,19 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
||||
|
||||
try:
|
||||
if reward_scene == REWARD_SCENE_WITHDRAWAL_AD:
|
||||
# 提现看视频:已知合法场景,只记一条幂等记录、**不动钱包**(发不发币由 reward_scene 决定)。
|
||||
# is_verify=true 让穿山甲不重试(区别于 unknown_scene 的 false)。
|
||||
crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=REWARD_SCENE_WITHDRAWAL_AD,
|
||||
ad_session_id=ad_session_id, ecpm=ecpm,
|
||||
reward_name=params.get("reward_name"), raw=raw[:1024],
|
||||
status="withdrawal_ad",
|
||||
)
|
||||
logger.info(
|
||||
"pangle callback withdrawal_ad user_id=%d trans_id=%s", user_id, trans_id,
|
||||
)
|
||||
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
||||
if reward_scene not in SUPPORTED_REWARD_SCENES:
|
||||
rec = crud_ad.record_external_reward(
|
||||
db, user_id, trans_id, coin=0, reward_scene=reward_scene[:32],
|
||||
@@ -363,6 +389,8 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
ad_session_id=payload.ad_session_id,
|
||||
adn=payload.adn,
|
||||
slot_id=payload.slot_id,
|
||||
feed_scene=payload.feed_scene,
|
||||
trace_id=payload.trace_id,
|
||||
app_env=payload.app_env,
|
||||
our_code_id=payload.our_code_id,
|
||||
aborted=payload.aborted,
|
||||
@@ -380,6 +408,21 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/feed-reward/units",
|
||||
response_model=FeedRewardUnitsOut,
|
||||
summary="查账号累计信息流发奖份数(前端算因子2 LT、做实时金币进度条用)",
|
||||
dependencies=[Depends(rate_limit(120, 60, "ad-feed-units"))],
|
||||
)
|
||||
def feed_reward_units(user: CurrentUser, db: DbSession) -> FeedRewardUnitsOut:
|
||||
"""返回账号累计已发奖份数(因子2 LT 基线)。
|
||||
|
||||
信息流金币进度条:前端 show 时拉一次,之后每满 10 秒一份、逐份用 (granted_units + offset)
|
||||
查 LT 因子,精确复刻发奖公式 → 进度条数值 ≈ 实际到账。
|
||||
"""
|
||||
return FeedRewardUnitsOut(granted_units=crud_feed.granted_unit_total(db, user.id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reward-noshow",
|
||||
response_model=RewardNoShowOut,
|
||||
|
||||
+26
-2
@@ -15,6 +15,7 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
from app.core.ratelimit import rate_limit
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
@@ -38,13 +39,17 @@ logger = logging.getLogger("shagua.auth")
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _login_response(user, *, onboarding_completed: bool) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入。"""
|
||||
def _login_response(
|
||||
user, *, onboarding_completed: bool, force_onboarding: bool = False
|
||||
) -> TokenWithUser:
|
||||
"""登录类接口共用的响应组装。onboarding_completed 据登录请求的 device_id 算好后传入;
|
||||
force_onboarding 仅测试号置 True(让应用内重新登录也重走引导)。"""
|
||||
tokens = issue_token_pair(user.id)
|
||||
return TokenWithUser(
|
||||
**tokens,
|
||||
user=UserOut.model_validate(user),
|
||||
onboarding_completed=onboarding_completed,
|
||||
force_onboarding=force_onboarding,
|
||||
)
|
||||
|
||||
|
||||
@@ -82,6 +87,12 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号)
|
||||
)
|
||||
def sms_send(req: SmsSendRequest) -> SmsSendResponse:
|
||||
# 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。
|
||||
# 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。
|
||||
if test_account.is_test_account(req.phone):
|
||||
logger.info("test_account sms_send short-circuit (不真发)")
|
||||
return SmsSendResponse(sent=True, mock=True, cooldown_sec=0)
|
||||
|
||||
try:
|
||||
cooldown = send_code(req.phone)
|
||||
except SmsError as e:
|
||||
@@ -99,6 +110,19 @@ def sms_send(req: SmsSendRequest) -> SmsSendResponse:
|
||||
dependencies=[Depends(rate_limit(20, 60, "sms-login"))], # 防撞库爆破(另有单码失败次数上限)
|
||||
)
|
||||
def sms_login(req: SmsLoginRequest, db: DbSession) -> TokenWithUser:
|
||||
# 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。
|
||||
# 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。
|
||||
if test_account.is_test_account(req.phone):
|
||||
if not test_account.try_consume_quota():
|
||||
raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试")
|
||||
user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms")
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone))
|
||||
# onboarding 恒 false + force_onboarding=true:每次登录都重走引导(含应用内退出后重登),
|
||||
# 不读/不写 onboarding_completion 表。
|
||||
return _login_response(user, onboarding_completed=False, force_onboarding=True)
|
||||
|
||||
if not verify_code(req.phone, req.code):
|
||||
raise HTTPException(status_code=400, detail="invalid sms code")
|
||||
|
||||
|
||||
@@ -77,8 +77,15 @@ def _backfill_llm_calls(record_id: int, trace_id: str) -> None:
|
||||
rec.llm_calls = calls
|
||||
rec.llm_call_count = len(calls)
|
||||
rec.retry_count = sum(1 for c in calls if c.get("error"))
|
||||
# token 累加(usage 已被 pricebot llm_client 归一为 prompt/completion_tokens;
|
||||
# error 的调用 usage 可能为 None,or {} 兜底)
|
||||
rec.input_tokens = sum((c.get("usage") or {}).get("prompt_tokens") or 0 for c in calls)
|
||||
rec.output_tokens = sum((c.get("usage") or {}).get("completion_tokens") or 0 for c in calls)
|
||||
db.commit()
|
||||
logger.info("backfill llm_calls trace=%s n=%d", trace_id, len(calls))
|
||||
logger.info(
|
||||
"backfill llm_calls trace=%s n=%d in_tok=%d out_tok=%d",
|
||||
trace_id, len(calls), rec.input_tokens, rec.output_tokens,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 best-effort
|
||||
logger.warning("backfill llm_calls failed trace=%s: %s", trace_id, e)
|
||||
finally:
|
||||
|
||||
+93
-11
@@ -8,16 +8,22 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import media
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_db
|
||||
from app.integrations import wx_oauth
|
||||
from app.models.cps_activity import CpsActivity
|
||||
from app.models.cps_link import CpsLink
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
from app.repositories import cps_wx_user as cps_wx_user_repo
|
||||
|
||||
logger = logging.getLogger("shagua.cps_redirect")
|
||||
|
||||
router = APIRouter(tags=["cps-redirect"])
|
||||
|
||||
@@ -27,6 +33,16 @@ _FALLBACK_URL = "https://www.meituan.com/"
|
||||
# 淘宝活动未设图时的兜底主视觉(存量已回填,基本只在老 link/异常时触发)
|
||||
_DEFAULT_TAOBAO_IMAGE = "/media/taobao_landing.jpg"
|
||||
|
||||
# 微信网页授权拿到的用户标识 cookie(种在 coupon 域,30 天免重复授权)
|
||||
_WX_OPENID_COOKIE = "wx_openid"
|
||||
_WX_UINFO_COOKIE = "wx_uinfo" # "1" = 已拿过昵称头像(userinfo),点领券不再跳授权
|
||||
_COOKIE_MAX_AGE = 30 * 86400
|
||||
|
||||
|
||||
def _is_wechat(request: Request) -> bool:
|
||||
"""是否微信内置浏览器(网页授权只在微信内有意义,外部浏览器不跳授权)。"""
|
||||
return "micromessenger" in (request.headers.get("user-agent", "").lower())
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
@@ -35,11 +51,13 @@ def _client_ip(request: Request) -> str | None:
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
def _record(db: Session, link: CpsLink, request: Request, event_type: str) -> None:
|
||||
def _record(
|
||||
db: Session, link: CpsLink, request: Request, event_type: str, openid: str | None = None
|
||||
) -> None:
|
||||
try:
|
||||
cps_link_repo.record_click(
|
||||
db, link=link, ip=_client_ip(request),
|
||||
ua=request.headers.get("user-agent"), event_type=event_type,
|
||||
ua=request.headers.get("user-agent"), event_type=event_type, openid=openid,
|
||||
)
|
||||
except Exception:
|
||||
pass # 记点击失败不阻断
|
||||
@@ -53,38 +71,92 @@ def wx_mp_domain_verify() -> PlainTextResponse:
|
||||
return PlainTextResponse("F7wnRQ7xPbhVOWC8")
|
||||
|
||||
|
||||
@router.get("/c/{code}", summary="群发短链落地(记点击 + 跳转/淘宝落地页)")
|
||||
@router.get("/c/{code}", summary="群发短链落地(微信授权拿 openid + 记点击 + 跳转/淘宝落地页)")
|
||||
def cps_landing(code: str, request: Request, db: Session = Depends(get_db)):
|
||||
link = cps_link_repo.get_by_code(db, code)
|
||||
if link is None:
|
||||
return RedirectResponse(_FALLBACK_URL, status_code=302)
|
||||
_record(db, link, request, "visit")
|
||||
openid = request.cookies.get(_WX_OPENID_COOKIE)
|
||||
# 微信内 + 还没拿到 openid + 配了服务号 → 先 base 静默授权拿 openid,再 302 回本页。
|
||||
# 非微信 / 已有 cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,绝不阻断领券)。
|
||||
if openid is None and settings.wx_oauth_active and _is_wechat(request):
|
||||
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
|
||||
auth_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_base", f"base:{code}")
|
||||
return RedirectResponse(auth_url, status_code=302)
|
||||
# ?authed=1 是 userinfo 授权后的自动回跳重载,不是用户主动打开:visit 已在授权前那次
|
||||
# 落地(base 静默授权回来那次)记过,这里再记会让每个走"领券授权"的用户 visit 虚增 1。
|
||||
if request.query_params.get("authed") != "1":
|
||||
_record(db, link, request, "visit", openid=openid)
|
||||
if link.platform == "taobao":
|
||||
activity = db.get(CpsActivity, link.activity_id)
|
||||
image_url = (activity.image_url if activity else None) or _DEFAULT_TAOBAO_IMAGE
|
||||
return HTMLResponse(_taobao_landing_html(link.target_url, image_url))
|
||||
has_uinfo = request.cookies.get(_WX_UINFO_COOKIE) == "1"
|
||||
return HTMLResponse(
|
||||
_taobao_landing_html(link.target_url, image_url, code, openid, has_uinfo)
|
||||
)
|
||||
# 美团短链 / 京东链接:直接 302 跳
|
||||
return RedirectResponse(link.target_url, status_code=302)
|
||||
|
||||
|
||||
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件)")
|
||||
@router.get("/wx/oauth/cb", include_in_schema=False)
|
||||
def wx_oauth_cb(code: str, state: str, db: Session = Depends(get_db)):
|
||||
"""微信网页授权回调。state='base:{原code}'(静默拿 openid) 或 'uinfo:{原code}'(补昵称头像)。
|
||||
换 openid(+userinfo)→ upsert 用户 → 种 cookie → 302 回落地页。任何失败兜底回落地页,
|
||||
绝不阻断用户领券。"""
|
||||
kind, _, orig_code = state.partition(":")
|
||||
try:
|
||||
token = wx_oauth.exchange_code(code) # {openid, access_token, scope, ...}
|
||||
openid = token["openid"]
|
||||
link = cps_link_repo.get_by_code(db, orig_code)
|
||||
group_id = link.group_id if link else None
|
||||
nickname = headimgurl = unionid = None
|
||||
if kind == "uinfo" and "userinfo" in (token.get("scope") or ""):
|
||||
info = wx_oauth.get_userinfo(token["access_token"], openid)
|
||||
nickname, headimgurl, unionid = (
|
||||
info.get("nickname"), info.get("headimgurl"), info.get("unionid"),
|
||||
)
|
||||
cps_wx_user_repo.upsert(
|
||||
db, openid=openid, code=orig_code, group_id=group_id,
|
||||
nickname=nickname, headimgurl=headimgurl, unionid=unionid,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[wx_oauth] callback failed state=%s", state)
|
||||
return RedirectResponse(f"/c/{orig_code}" if orig_code else _FALLBACK_URL, status_code=302)
|
||||
# userinfo 授权回来带 ?authed=1,落地页据此自动触发复制(把"点领券→授权→复制"衔接为一步)
|
||||
target = f"/c/{orig_code}" + ("?authed=1" if kind == "uinfo" else "")
|
||||
resp = RedirectResponse(target, status_code=302)
|
||||
resp.set_cookie(_WX_OPENID_COOKIE, openid, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax")
|
||||
if nickname:
|
||||
resp.set_cookie(_WX_UINFO_COOKIE, "1", max_age=_COOKIE_MAX_AGE, samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/c/{code}/copy", summary="淘宝落地页点了「复制口令」(记 copy 事件,带 openid)")
|
||||
def cps_copy(code: str, request: Request, db: Session = Depends(get_db)) -> dict:
|
||||
link = cps_link_repo.get_by_code(db, code)
|
||||
if link is not None:
|
||||
_record(db, link, request, "copy")
|
||||
_record(db, link, request, "copy", openid=request.cookies.get(_WX_OPENID_COOKIE))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _taobao_landing_html(token: str, image_url: str) -> str:
|
||||
def _taobao_landing_html(
|
||||
token: str, image_url: str, code: str, openid: str | None, has_uinfo: bool
|
||||
) -> str:
|
||||
"""淘宝落地页 H5(主视觉图按活动传入,复制淘口令按钮在 75% 屏高)。
|
||||
|
||||
image_url 转绝对(落地页 coupon 域,相对也可达,绝对更稳)后经 html.escape 嵌入
|
||||
<img src>(防属性注入);token 经 json.dumps 安全嵌入 JS。
|
||||
image_url 经 html.escape 嵌入 <img src>(防注入);token 经 json.dumps 安全嵌入 JS。
|
||||
uinfo_url:已有 openid 但还没拿过昵称头像时,生成 userinfo 授权链接 —— 用户点「领券」
|
||||
时先跳它(交互触发,避免微信快照页),授权回来自动复制。已拿过 / 无 openid 则为空,直接复制。
|
||||
"""
|
||||
safe_img = html.escape(media.to_abs_media_url(image_url) or image_url, quote=True)
|
||||
uinfo_url = ""
|
||||
if openid and not has_uinfo and settings.wx_oauth_active:
|
||||
redirect_uri = f"{settings.CPS_REDIRECT_BASE.rstrip('/')}/wx/oauth/cb"
|
||||
uinfo_url = wx_oauth.build_authorize_url(redirect_uri, "snsapi_userinfo", f"uinfo:{code}")
|
||||
return (
|
||||
_TAOBAO_HTML
|
||||
.replace("__IMAGE_URL__", safe_img)
|
||||
.replace("__UINFO_URL__", json.dumps(uinfo_url))
|
||||
.replace("__TOKEN_JS__", json.dumps(token))
|
||||
)
|
||||
|
||||
@@ -112,6 +184,7 @@ 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()}
|
||||
@@ -121,10 +194,19 @@ function fallback(){
|
||||
try{document.execCommand('copy');done()}catch(e){showToast('复制失败,请长按手动复制')}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
function copyToken(){
|
||||
function doCopy(){
|
||||
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):自动复制,把"点领券→授权→复制"衔接成一步无感;
|
||||
// 复制后用 replaceState 清掉 authed=1,避免刷新/微信返回(BFCache)重载时重复触发复制上报。
|
||||
if(location.search.indexOf('authed=1')>=0){doCopy();history.replaceState(null,'',location.pathname)}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。
|
||||
|
||||
路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。
|
||||
POST /register 注册设备 / 更新 registration_id(App 前台、拿到 push token 时调)
|
||||
POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活)
|
||||
|
||||
后端 heartbeat_monitor_worker 据此发现心跳超时的设备并极光推送告警。
|
||||
见 spec: spec/accessibility-liveness-push.md。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.repositories import device as device_repo
|
||||
from app.schemas.device import (
|
||||
DeviceOut,
|
||||
DeviceRegisterRequest,
|
||||
HeartbeatRequest,
|
||||
LivenessAckRequest,
|
||||
LivenessOut,
|
||||
OkResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.device")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/device", tags=["device"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token")
|
||||
def register_device(
|
||||
req: DeviceRegisterRequest,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> DeviceOut:
|
||||
device = device_repo.register_or_update(
|
||||
db,
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
registration_id=req.registration_id,
|
||||
platform=req.platform,
|
||||
app_version=req.app_version,
|
||||
)
|
||||
logger.info(
|
||||
"device register user_id=%d device_id=%s reg=%s",
|
||||
user.id,
|
||||
req.device_id,
|
||||
bool(req.registration_id),
|
||||
)
|
||||
return DeviceOut.model_validate(device)
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=OkResponse, summary="上报心跳")
|
||||
def report_heartbeat(
|
||||
req: HeartbeatRequest,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> OkResponse:
|
||||
device_repo.touch_heartbeat(
|
||||
db,
|
||||
user_id=user.id,
|
||||
device_id=req.device_id,
|
||||
accessibility_enabled=req.accessibility_enabled,
|
||||
registration_id=req.registration_id,
|
||||
)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)")
|
||||
def get_liveness(
|
||||
device_id: str,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> LivenessOut:
|
||||
"""客户端进 App 时拉取: 本机是否被判定掉线过(kill_alert_pending)。
|
||||
|
||||
设备从未注册过 → 返回默认(无告警)。命中 → 客户端弹「开启自启动」引导, 之后调 /liveness/ack 清。
|
||||
见 spec: spec/accessibility-liveness-pull-prompt.md。
|
||||
"""
|
||||
device = device_repo.get_device(db, user_id=user.id, device_id=device_id)
|
||||
if device is None:
|
||||
return LivenessOut()
|
||||
return LivenessOut.model_validate(device)
|
||||
|
||||
|
||||
@router.post("/liveness/ack", response_model=OkResponse, summary="确认已提醒(清掉线告警)")
|
||||
def ack_liveness(
|
||||
req: LivenessAckRequest,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
) -> OkResponse:
|
||||
"""客户端已弹过「开启自启动」引导 → 清掉「待提醒」标记(下次真掉线 worker 再置)。"""
|
||||
device_repo.ack_kill_alert(db, user_id=user.id, device_id=req.device_id)
|
||||
return OkResponse()
|
||||
+57
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
|
||||
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张)
|
||||
GET /records 我的反馈历史(pending/adopted/rejected)
|
||||
|
||||
截图复用 [app.core.media] 落盘到 /media/feedback/。
|
||||
"""
|
||||
@@ -9,12 +10,19 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.repositories import feedback as feedback_repo
|
||||
from app.schemas.feedback import FeedbackOut
|
||||
from app.repositories import feedback_qr as feedback_qr_repo
|
||||
from app.schemas.feedback import (
|
||||
FeedbackOut,
|
||||
FeedbackQrConfigOut,
|
||||
FeedbackRecordCountsOut,
|
||||
FeedbackRecordOut,
|
||||
FeedbackRecordsOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.feedback")
|
||||
|
||||
@@ -23,6 +31,27 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
||||
_MAX_IMAGES = 6
|
||||
_CONTENT_MAX = 200
|
||||
_CONTACT_MAX = 128
|
||||
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
|
||||
|
||||
|
||||
def _app_status(db_status: str) -> str:
|
||||
return {
|
||||
"new": "pending",
|
||||
"handled": "adopted",
|
||||
"approved": "adopted",
|
||||
}.get(db_status, db_status)
|
||||
|
||||
|
||||
def _record_out(fb) -> FeedbackRecordOut:
|
||||
return FeedbackRecordOut(
|
||||
id=fb.id,
|
||||
content=fb.content,
|
||||
images=fb.images or [],
|
||||
status=_app_status(fb.status),
|
||||
reject_reason=getattr(fb, "reject_reason", None),
|
||||
reward_coins=getattr(fb, "reward_coins", None),
|
||||
created_at=fb.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=FeedbackOut, summary="提交反馈")
|
||||
@@ -60,3 +89,29 @@ 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)
|
||||
|
||||
@@ -17,6 +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,
|
||||
@@ -54,6 +55,23 @@ def flags(db: DbSession) -> AppFlagsOut:
|
||||
)
|
||||
|
||||
|
||||
@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"],
|
||||
withdrawal_ad_enabled=c["withdrawal_ad_enabled"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/app-version", response_model=AppVersionOut, summary="最新 App 版本(OTA 检查更新,不鉴权)")
|
||||
def app_version(db: DbSession) -> AppVersionOut:
|
||||
"""客户端启动 / 手动检查更新时拉取。不鉴权:版本信息非敏感,且检查更新可能在登录前。
|
||||
|
||||
@@ -18,6 +18,7 @@ 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,
|
||||
@@ -87,6 +88,17 @@ 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)
|
||||
|
||||
+44
-3
@@ -66,6 +66,11 @@ class Settings(BaseSettings):
|
||||
JG_VERIFY_ENDPOINT: str = "https://api.verification.jpush.cn/v1/web/loginTokenVerify"
|
||||
JG_REQUEST_TIMEOUT_SEC: int = 15
|
||||
|
||||
# 无障碍保护存活监控后台任务(pull 后置检测;本期不接推送)
|
||||
HEARTBEAT_MONITOR_ENABLED: bool = True # 总开关
|
||||
HEARTBEAT_TIMEOUT_MINUTES: int = 10 # 多久没心跳算掉线(≈3 个客户端心跳周期)
|
||||
HEARTBEAT_SCAN_INTERVAL_SEC: int = 60 # 扫描周期
|
||||
|
||||
# ===== 短信 =====
|
||||
SMS_MOCK: bool = True
|
||||
SMS_CODE_TTL_SEC: int = 300
|
||||
@@ -79,6 +84,19 @@ class Settings(BaseSettings):
|
||||
SMS_DAILY_LIMIT_PER_PHONE: int = 10 # 单手机号每日发送上限(防刷 + 控费)
|
||||
SMS_MAX_VERIFY_ATTEMPTS: int = 5 # 单个验证码最多校验失败次数,超过即作废(防爆破)
|
||||
|
||||
# ===== 测试账号(release 包全流程联调用)=====
|
||||
# 配一个固定测试手机号,专供无 SIM 卡 / 不走一键登录时打通全流程:该号登录【免短信验证码】
|
||||
# (real 模式下也跳过校验)、每次登录【强制重走新手引导】,并设【每日使用次数上限】防被人
|
||||
# 猜到号后脚本滥用。两个值都能随时改 .env。逻辑全在 app/core/test_account.py,与其他业务解耦。
|
||||
# ⚠️ TEST_ACCOUNT_PHONE 留空 = 整个功能关闭(生产默认安全;要启用才显式填号)。
|
||||
TEST_ACCOUNT_PHONE: str = "" # 测试手机号(11 位,如 11111111111);空=关闭整功能
|
||||
TEST_ACCOUNT_DAILY_LIMIT: int = 500 # 该测试号每日最多登录次数,当日超过即拒绝登录
|
||||
|
||||
@property
|
||||
def test_account_phone(self) -> str:
|
||||
"""规整后的测试手机号(去空白);空串=功能关闭。"""
|
||||
return self.TEST_ACCOUNT_PHONE.strip()
|
||||
|
||||
# ===== 美团联盟 CPS =====
|
||||
# 未配置时所有 /api/v1/meituan/* 接口 200 返空(优雅降级),不影响登录/领券等其他业务。
|
||||
MT_CPS_APP_KEY: str = ""
|
||||
@@ -95,6 +113,25 @@ 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 一致。
|
||||
@@ -110,10 +147,14 @@ 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 点自动兑金币(由 deploy/daily-exchange.timer 触发 scripts.daily_auto_exchange)。
|
||||
# 客户端已删手动兑入口、改为「0 点自动兑现金」,故默认开;运营要临时停可在 .env 置 false,
|
||||
# 无需动 systemd timer——脚本读此开关,false 时直接 no-op 退出。
|
||||
# 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)。
|
||||
AUTO_EXCHANGE_ENABLED: bool = True
|
||||
# 进程内自动兑换 worker 的检查间隔(秒):每隔这么久醒一次,跨过北京 0 点就跑一轮。
|
||||
# 默认 600s=10min,即 0 点后最多 10 分钟内兑完(客户端文案已注明「可能存在延迟」)。
|
||||
AUTO_EXCHANGE_CHECK_INTERVAL_SEC: int = 600
|
||||
# 免确认收款授权(用户授权免确认模式)的授权结果回调地址,必须公网可访问 HTTPS、不带参数。
|
||||
# 发起授权 / 首单顺带授权时作为 authorization_notify_url 传给微信。一期不处理回调内容
|
||||
# (授权状态靠 query 查询兜底),但微信要求该字段非空,故启用免确认前必须配置;留空时免确认相关接口返回未配置。
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""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
|
||||
@@ -0,0 +1,148 @@
|
||||
"""无障碍保护存活监控后台任务。
|
||||
|
||||
周期扫描「曾经保护过、当前 alive、心跳超时」的设备 = App 被彻底杀掉/无障碍已停(心跳断了),
|
||||
**命中即在服务器终端打印告警**(本期先不接推送,工程量大,用终端打印代替真实通知);并把状态机
|
||||
推进到 notified 防每轮重复打印(心跳恢复时由 repositories.device.touch_heartbeat 重置回 alive)。
|
||||
结构仿 withdraw_reconcile_worker(单实例锁 + asyncio 轮询 + 优雅退出)。
|
||||
|
||||
见 spec: spec/accessibility-liveness-push.md。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.repositories import device as device_repo
|
||||
|
||||
logger = logging.getLogger("shagua.heartbeat_monitor")
|
||||
_LOCK_PATH = Path(__file__).resolve().parents[2] / "data" / "heartbeat_monitor.lock"
|
||||
|
||||
|
||||
def _touch_lock() -> None:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.utime(_LOCK_PATH, None)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _single_instance_lock(stale_after_sec: int) -> Iterator[bool]:
|
||||
"""同机多进程保护:同一时间只允许一个监控 worker 运行。"""
|
||||
_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd: int | None = None
|
||||
try:
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
try:
|
||||
age = time.time() - _LOCK_PATH.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
age = stale_after_sec + 1
|
||||
if age > stale_after_sec:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
try:
|
||||
fd = os.open(str(_LOCK_PATH), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
fd = None
|
||||
|
||||
if fd is None:
|
||||
yield False
|
||||
return
|
||||
|
||||
os.write(fd, f"pid={os.getpid()} started_at={int(time.time())}\n".encode("ascii"))
|
||||
yield True
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
_LOCK_PATH.unlink()
|
||||
|
||||
|
||||
def _silent_seconds(last: datetime | None) -> int | None:
|
||||
"""距上次心跳的秒数(兼容 sqlite 取回的 naive datetime)。"""
|
||||
if last is None:
|
||||
return None
|
||||
ref = datetime.now(timezone.utc) if last.tzinfo is not None else datetime.utcnow()
|
||||
return int((ref - last).total_seconds())
|
||||
|
||||
|
||||
def _scan_once(timeout_minutes: int) -> dict:
|
||||
"""扫描一轮:找出心跳超时(App 被彻底杀掉/无障碍已停)的设备,在**服务器终端打印**告警代替真实推送。
|
||||
|
||||
本期不接推送(极光/厂商通道工程量大),只做服务端掉线检测:命中即 logger.warning 打印到终端,
|
||||
并把状态机推进到 notified 防每轮重复打印(心跳恢复时 touch_heartbeat 会重置回 alive)。
|
||||
"""
|
||||
notified = 0
|
||||
with SessionLocal() as db:
|
||||
overdue = device_repo.list_overdue(db, timeout_minutes=timeout_minutes)
|
||||
for device in overdue:
|
||||
silent = _silent_seconds(device.last_heartbeat_at)
|
||||
logger.warning(
|
||||
"[掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)"
|
||||
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
silent if silent is not None else "?",
|
||||
timeout_minutes,
|
||||
)
|
||||
device_repo.mark_notified(db, device_id_pk=device.id)
|
||||
notified += 1
|
||||
return {"checked": len(overdue), "notified": notified}
|
||||
|
||||
|
||||
async def _run_loop() -> None:
|
||||
interval = max(10, int(settings.HEARTBEAT_SCAN_INTERVAL_SEC))
|
||||
timeout_minutes = max(1, int(settings.HEARTBEAT_TIMEOUT_MINUTES))
|
||||
lock_stale_after = max(interval * 3, 600)
|
||||
with _single_instance_lock(lock_stale_after) as lock_acquired:
|
||||
if not lock_acquired:
|
||||
logger.warning("heartbeat monitor skipped: another worker owns lock")
|
||||
return
|
||||
await _run_locked_loop(interval, timeout_minutes)
|
||||
|
||||
|
||||
async def _run_locked_loop(interval: int, timeout_minutes: int) -> None:
|
||||
logger.info(
|
||||
"heartbeat monitor started interval=%ss timeout=%sm",
|
||||
interval,
|
||||
timeout_minutes,
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
_touch_lock()
|
||||
result = await asyncio.to_thread(_scan_once, timeout_minutes)
|
||||
if result["notified"]:
|
||||
logger.info("heartbeat monitor result=%s", result)
|
||||
except SQLAlchemyError:
|
||||
logger.exception("heartbeat monitor db error")
|
||||
except Exception: # noqa: BLE001 - 后台任务不能因单次异常退出
|
||||
logger.exception("heartbeat monitor unexpected error")
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("heartbeat monitor stopped")
|
||||
raise
|
||||
|
||||
|
||||
def start_heartbeat_monitor() -> asyncio.Task | None:
|
||||
if not settings.HEARTBEAT_MONITOR_ENABLED:
|
||||
logger.info("heartbeat monitor disabled")
|
||||
return None
|
||||
return asyncio.create_task(_run_loop(), name="heartbeat-monitor")
|
||||
|
||||
|
||||
async def stop_heartbeat_monitor(task: asyncio.Task | None) -> None:
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
+26
-6
@@ -37,7 +37,7 @@ def _sniff_ext(data: bytes) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _save_image(subdir: str, user_id: int, data: bytes) -> str:
|
||||
def _save_named(subdir: str, name_prefix: str, data: bytes) -> str:
|
||||
"""通用图片落盘:校验非空 / ≤上限 / 魔数为图片,随机文件名,返回相对 URL。"""
|
||||
if not data:
|
||||
raise MediaError("空文件")
|
||||
@@ -47,11 +47,16 @@ def _save_image(subdir: str, user_id: int, data: bytes) -> str:
|
||||
if ext is None:
|
||||
raise MediaError("仅支持 JPEG / PNG / WebP 图片")
|
||||
|
||||
fname = f"u{user_id}_{secrets.token_hex(8)}{ext}"
|
||||
fname = f"{name_prefix}_{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)
|
||||
@@ -67,6 +72,11 @@ 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)
|
||||
@@ -84,15 +94,25 @@ def to_abs_media_url(rel_url: str | None) -> str | None:
|
||||
return f"{base}{rel_url}" if base else rel_url
|
||||
|
||||
|
||||
def delete_avatar(url: str | None) -> None:
|
||||
"""删除本服务托管的旧头像文件;外部 URL(如微信头像)或空值不处理。"""
|
||||
prefix = f"{settings.MEDIA_URL_PREFIX}/avatars/"
|
||||
def _delete_managed(subdir: str, url: str | None) -> None:
|
||||
"""删除本服务托管的某子目录下文件;外部 URL / 空值 / 非本子目录的不处理。"""
|
||||
prefix = f"{settings.MEDIA_URL_PREFIX}/{subdir}/"
|
||||
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("avatars") / fname).unlink(missing_ok=True)
|
||||
(_media_dir(subdir) / 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)
|
||||
|
||||
@@ -108,6 +108,10 @@ def record_milestone_reward(milestone: int) -> int:
|
||||
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
|
||||
PRICE_REPORT_REWARD_COINS: int = 1000
|
||||
|
||||
# ===== 意见反馈采纳奖励(人工审核按质量发放)=====
|
||||
# 后台审核反馈时允许发放的单条金币上限。只做后端硬保护,具体档位由 admin-web 呈现。
|
||||
FEEDBACK_REWARD_MAX_COINS: int = 10000
|
||||
|
||||
|
||||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""测试账号:免验证码登录 + 每次重走新手引导 + 每日使用上限。
|
||||
|
||||
为打通 **release 包全流程**(无 SIM 卡、不走极光一键登录)而设的一个固定测试手机号。
|
||||
对这个号(且仅这个号)放开三件事:
|
||||
1. **免短信验证码**:`/sms/send` 不真发、`/sms/login` 跳过 `verify_code`,real 模式下同样生效
|
||||
—— 测试时直接填任意验证码即可登入,不用等真短信。
|
||||
2. **每次都走新手引导**:登录响应 `onboarding_completed` 恒为 false,不读/不依赖
|
||||
onboarding_completion 表,所以反复登录都能体验引导。
|
||||
3. **每日使用次数上限**:防被人猜到这个号后写脚本一直刷。当天登录数超过上限即拒绝(429),
|
||||
次日自动归零。
|
||||
|
||||
手机号与上限都在 .env 配(`TEST_ACCOUNT_PHONE` / `TEST_ACCOUNT_DAILY_LIMIT`),随时可改。
|
||||
`TEST_ACCOUNT_PHONE` 留空 = 整个功能关闭(生产默认态),`is_test_account()` 对任何号都返回
|
||||
False,登录/短信回到原逻辑,零影响。
|
||||
|
||||
计数存**进程内存**(单 worker 够用,与 sms.py 同款约定):重启清零、多 worker 不共享。作为
|
||||
一个测试号的粗粒度防滥用闸够用;且因所有登录都落同一个 phone → 同一个 user,滥用面天然只
|
||||
限于这一个账号。要严格持久化/跨进程再迁 DB 或 Redis(同 sms.py 的技术债)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("shagua.test_account")
|
||||
|
||||
# 进程内每日计数:(date_str, 当日已登录次数)。单 worker 有效,重启清零(见模块 docstring)。
|
||||
_lock = Lock()
|
||||
_usage: tuple[str, int] = ("", 0)
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""功能总开关:配了 TEST_ACCOUNT_PHONE 才启用(空=关闭)。"""
|
||||
return bool(settings.test_account_phone)
|
||||
|
||||
|
||||
def is_test_account(phone: str) -> bool:
|
||||
"""该手机号是否为配置的测试账号。功能关闭时对任何号都返回 False。"""
|
||||
return is_enabled() and phone == settings.test_account_phone
|
||||
|
||||
|
||||
def try_consume_quota() -> bool:
|
||||
"""测试账号登录时调:当日计数 +1。
|
||||
|
||||
Returns:
|
||||
True —— 未超当日上限,本次放行(计数已 +1);
|
||||
False —— 已达上限,本次拒绝(计数不变)。
|
||||
|
||||
线程安全;跨天自动归零。上限取自 settings.TEST_ACCOUNT_DAILY_LIMIT(可在 .env 改)。
|
||||
"""
|
||||
global _usage
|
||||
limit = settings.TEST_ACCOUNT_DAILY_LIMIT
|
||||
with _lock:
|
||||
today = _today()
|
||||
day, cnt = _usage
|
||||
if day != today: # 跨天归零
|
||||
cnt = 0
|
||||
if cnt >= limit:
|
||||
_usage = (today, cnt) # 已满,保持不变
|
||||
logger.warning(
|
||||
"测试账号 %s 今日登录数已达上限 %d,拒绝", settings.test_account_phone, limit
|
||||
)
|
||||
return False
|
||||
_usage = (today, cnt + 1)
|
||||
logger.info("测试账号 %s 第 %d/%d 次登录", settings.test_account_phone, cnt + 1, limit)
|
||||
return True
|
||||
|
||||
|
||||
def _reset_for_test() -> None:
|
||||
"""仅供单测:清空进程内计数,隔离用例间状态。"""
|
||||
global _usage
|
||||
with _lock:
|
||||
_usage = ("", 0)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""微信服务号网页授权:构造授权链接 + 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
|
||||
+16
@@ -20,8 +20,10 @@ from app.api.v1.compare import router as compare_router
|
||||
from app.api.v1.compare_milestone import router as compare_milestone_router
|
||||
from app.api.v1.compare_record import router as compare_record_router
|
||||
from app.api.v1.coupon import router as coupon_router
|
||||
from app.api.v1.device import router as device_router
|
||||
from app.api.v1.cps_redirect import router as cps_redirect_router
|
||||
from app.api.internal.app_version import router as internal_app_version_router
|
||||
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
|
||||
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
|
||||
@@ -37,6 +39,14 @@ from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core.config import settings
|
||||
from app.core.heartbeat_monitor_worker import (
|
||||
start_heartbeat_monitor,
|
||||
stop_heartbeat_monitor,
|
||||
)
|
||||
from app.core.daily_exchange_worker import (
|
||||
start_daily_exchange_worker,
|
||||
stop_daily_exchange_worker,
|
||||
)
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.withdraw_reconcile_worker import (
|
||||
start_withdraw_reconcile_worker,
|
||||
@@ -58,10 +68,14 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
heartbeat_task = start_heartbeat_monitor()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_heartbeat_monitor(heartbeat_task)
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
await stop_daily_exchange_worker(daily_exchange_task)
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
@@ -93,6 +107,7 @@ app.include_router(user_router)
|
||||
app.include_router(feedback_router)
|
||||
app.include_router(invite_router)
|
||||
app.include_router(coupon_router)
|
||||
app.include_router(device_router)
|
||||
app.include_router(compare_router)
|
||||
app.include_router(compare_record_router)
|
||||
app.include_router(compare_milestone_router)
|
||||
@@ -109,6 +124,7 @@ app.include_router(report_router)
|
||||
app.include_router(internal_price_router)
|
||||
app.include_router(internal_store_router)
|
||||
app.include_router(internal_app_version_router)
|
||||
app.include_router(internal_launch_confirm_router)
|
||||
app.include_router(platform_router)
|
||||
# CPS 群发短链跳转 /c/{code}(公网无鉴权:记点击 → 302 跳美团)
|
||||
app.include_router(cps_redirect_router)
|
||||
|
||||
@@ -10,7 +10,9 @@ 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.device import DeviceLiveness # noqa: F401
|
||||
from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
@@ -19,6 +21,7 @@ from app.models.coupon_state import ( # noqa: F401
|
||||
from app.models.feedback import Feedback # noqa: F401
|
||||
from app.models.invite import InviteRelation # noqa: F401
|
||||
from app.models.invite_fingerprint import InviteFingerprint # noqa: F401
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401
|
||||
from app.models.meituan_coupon import MeituanCoupon # noqa: F401
|
||||
from app.models.onboarding import OnboardingCompletion # noqa: F401
|
||||
from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401
|
||||
|
||||
@@ -30,6 +30,12 @@ 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)
|
||||
# 本次比价 trace_id(仅 comparison 场景由客户端带上):把这场广告金币归属到对应比价记录,
|
||||
# 比价记录页按 trace_id 聚合本场实发金币显示「比价赚 N 金币」。领券/福利/旧客户端 = NULL。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
@@ -124,6 +124,9 @@ class ComparisonRecord(Base):
|
||||
step_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 客户端 agent loop 总步数
|
||||
llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # 本次 LLM 调用次数(server 从 llm_calls 算)
|
||||
retry_count: Mapped[int | None] = mapped_column(Integer, nullable=True) # LLM 失败重试次数(server 从 llm_calls error 算)
|
||||
# 本次 LLM 累计 token(server 从 llm_calls[].usage 累加;旧记录/未采集为 None)
|
||||
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) # Σ usage.prompt_tokens
|
||||
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) # Σ usage.completion_tokens
|
||||
# 每次 LLM 调用明细 [{scene,model,input_messages,output,usage,latency_ms,error}];
|
||||
# server 收上报后按 trace_id 同机拉 pricebot 落库(见 compare_record 端点)。旧记录/未采集为 None。
|
||||
llm_calls: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
@@ -49,6 +49,8 @@ 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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""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}>"
|
||||
@@ -0,0 +1,91 @@
|
||||
"""设备表(无障碍保护存活检测 + 极光推送)。
|
||||
|
||||
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。
|
||||
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
|
||||
registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
|
||||
现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。
|
||||
|
||||
liveness_state 状态机(防刷屏,一次掉线只推一条):
|
||||
unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送)
|
||||
心跳恢复时 handler 重置回 alive。
|
||||
见 spec: spec/accessibility-liveness-push.md。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class DeviceLiveness(Base):
|
||||
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
|
||||
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
|
||||
__tablename__ = "device_liveness"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
|
||||
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
|
||||
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
|
||||
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
|
||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
# 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)
|
||||
ever_protected: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
# 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
|
||||
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), index=True, nullable=True
|
||||
)
|
||||
# 最近一次上报的无障碍开关状态(观测用)
|
||||
last_report_protection_on: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
# unknown / alive / silent / notified
|
||||
liveness_state: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="unknown"
|
||||
)
|
||||
# 最近一次推送告警时间
|
||||
notified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# 掉线告警「待客户端提醒」标记(后置检测 pull 版, 见 spec accessibility-liveness-pull-prompt.md)。
|
||||
# 与 liveness_state 解耦: worker 检出掉线即置 True; touch_heartbeat(心跳恢复)不动它
|
||||
# → 规避「服务随 App 重启先发心跳、state 被重置回 alive → 客户端进 App 漏看」竞态; 只由客户端 ack 清。
|
||||
kill_alert_pending: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<DeviceLiveness id={self.id} user_id={self.user_id} "
|
||||
f"device_id={self.device_id} state={self.liveness_state}>"
|
||||
)
|
||||
+13
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
每条 = 用户一次提交。content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
|
||||
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
|
||||
(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。
|
||||
(/media/feedback/...,JSON 存)。status: pending(审核中)/adopted(已采纳)/rejected(未采纳)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,8 +25,18 @@ class Feedback(Base):
|
||||
contact: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
|
||||
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
# new(待处理) / handled(已处理)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
|
||||
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
reject_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
|
||||
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
reviewed_by_admin_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("admin_user.id"), nullable=True
|
||||
)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""启动确认窗 agent 兜底样本表(launch_confirm_sample)。
|
||||
|
||||
pricebot 的 launch_confirm_agent 每次"静态 PROFILES 没认出、靠 LLM 兜底放行"一个跨 App
|
||||
启动确认窗(繁体 / 英文 / 小语种 / ROM 改版)时,把完整样本 server→server 落这里。研发定期
|
||||
**人工**把 exec_success=true 的样本沉淀进 pricebot 的 launch_confirm.PROFILES(静态快路径),
|
||||
让以后同款窗不必再调 LLM。
|
||||
|
||||
设计:
|
||||
- 与登录无关、不鉴权(server→server 共享密钥头 X-Internal-Secret)。
|
||||
- **都上报、不去重**(by design):同一种窗多台机器上报多条,研发按 host_package / locale
|
||||
聚合后人工筛。
|
||||
- 大字段(弹窗树快照 + LLM plan + 设备信息)塞 payload(JSONB)兜底,免得加字段就迁移 schema。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
# PG 上用 JSONB(可建 GIN 索引),SQLite(本地/测试)退化为通用 JSON(同 price_observation)。
|
||||
_JSON = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
class LaunchConfirmSample(Base):
|
||||
__tablename__ = "launch_confirm_sample"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||
)
|
||||
|
||||
# pricebot 侧 trace_id:回指原始 trace,能去 price.shaguabijia.com 查完整上下文。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
device_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
|
||||
# 弹窗宿主包(= 触发判据, 也是研发沉淀进 PROFILES.host_packages 的键)。按它聚合人工筛。
|
||||
host_package: Mapped[str | None] = mapped_column(String(128), index=True, nullable=True)
|
||||
# 被打开的目标 App 包名(标题里"想要打开 X"的 X 是变量, 据此把它从 sign 里挖空)。
|
||||
target_app: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 系统语言地区(zh-TW / en-US…)—— 多语言问题的核心维度。
|
||||
# ⚠️ 前端 AgentStepRequest 暂未上报设备信息 → 现恒 None,待前端加 device_info 字段后回填。
|
||||
system_locale: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True)
|
||||
|
||||
# 这次兜底是否成功放行(弹窗消失=True;放弃 / LLM 没认出=False)。研发只沉淀 True 的。
|
||||
exec_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
# 弹窗标题全文(研发据此提 launch_sign),如"「傻瓜比价」想要開啟「京東」"。
|
||||
dialog_title: Mapped[str | None] = mapped_column(String(256), nullable=True)
|
||||
|
||||
# 大 JSON:完整样本。{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree,
|
||||
# device_info: {screen, locale, brand, model, android_version, rom_version}}
|
||||
payload: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
f"<LaunchConfirmSample id={self.id} host={self.host_package!r} "
|
||||
f"locale={self.system_locale!r} success={self.exec_success}>"
|
||||
)
|
||||
@@ -55,6 +55,10 @@ class MeituanCoupon(Base):
|
||||
sell_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
head_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# 头图字节大小 / MIME 类型:采集时 HEAD head_url 得到(Content-Length / Content-Type)。
|
||||
# 读取侧已统一压缩(不再按大小判定),这两列保留用于分析/监控(GIF 占比、体积分布等)。
|
||||
image_size: Mapped[int | None] = mapped_column(Integer, nullable=True) # 字节
|
||||
image_type: Mapped[str | None] = mapped_column(String(32), nullable=True) # 如 image/jpeg
|
||||
|
||||
# ===== 销量(美团只给粗档位:热销1w+;num=排序用的下界数值,如 1w+ → 10000) =====
|
||||
sale_volume: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
@@ -42,21 +42,20 @@ def _granted_today(db: Session, user_id: int, reward_date: str) -> int:
|
||||
).scalar_one()
|
||||
|
||||
|
||||
def _unit_reward_total(db: Session, user_id: int, ecpm: str, unit_count: int) -> int:
|
||||
"""按每个 10 秒单位逐份计算奖励,LT 使用**账号累计**奖励份序号(不按天重置)。"""
|
||||
if unit_count <= 0:
|
||||
return 0
|
||||
existing_units = db.execute(
|
||||
select(func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0))
|
||||
.where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
)
|
||||
).scalar_one()
|
||||
total = 0
|
||||
for offset in range(1, unit_count + 1):
|
||||
total += rewards.calculate_ad_reward_coin(ecpm, int(existing_units) + offset)
|
||||
return total
|
||||
def granted_unit_total(db: Session, user_id: int) -> int:
|
||||
"""账号累计已发奖**条**数(COUNT status=granted),不按天重置 = 因子2(LT)基线。
|
||||
|
||||
口径:**一条广告 = 一份额度(一个单次公式值)**,故"已发条数"即"已发份数"。供前端拉取后精确复刻
|
||||
金币公式做实时金币进度条(前端因子2 用 granted_unit_total + 本场已结算条数 + 1 取档,与本表发奖一致)。
|
||||
"""
|
||||
return int(
|
||||
db.execute(
|
||||
select(func.count()).select_from(AdFeedRewardRecord).where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def grant_feed_reward(
|
||||
@@ -69,19 +68,22 @@ def grant_feed_reward(
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
aborted: bool = False,
|
||||
) -> AdFeedRewardRecord:
|
||||
"""比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
"""**每条**信息流广告(客户端每条各上报一次)结算奖励。client_event_id 幂等,同号重试不重复发。
|
||||
|
||||
发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。
|
||||
- aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。
|
||||
- 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。
|
||||
发奖规则:**一条广告 = 一个单次公式值**(rewards.calculate_ad_reward_coin),因子2(LT)按账号累计
|
||||
**条**数递进;看满一份时长(unit_count>=1, 即 ≥10 秒)才发,**不逐份累加**。
|
||||
- aborted=True(用户中途 ✕ 关闭这条):本条不发,记 status='closed_early'。
|
||||
- 时长不足 10 秒(unit_count==0):记 status='too_short' 不发。
|
||||
- 命中当日条数上限:记 status='capped' 不发。
|
||||
duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到
|
||||
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到
|
||||
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
|
||||
duration_seconds 是**这一条**的观看秒数。服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS、
|
||||
eCPM 在 calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN;叠加每日 get_ad_daily_limit 条数上限。
|
||||
feed_scene:点位场景(comparison/coupon/welfare),仅归类落库,不参与计算。
|
||||
"""
|
||||
existing = _find_by_event(db, client_event_id)
|
||||
if existing is not None:
|
||||
@@ -104,6 +106,8 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
@@ -122,6 +126,8 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
@@ -141,6 +147,8 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
@@ -148,12 +156,14 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
coin = _unit_reward_total(db, user_id, ecpm, unit_count)
|
||||
# 一条广告 = 一个「单次公式值」(因子2 按账号累计**条**数, 即第 existing_ads+1 条);看满一份(unit_count>=1)即发,不逐份累加。
|
||||
existing_ads = granted_unit_total(db, user_id)
|
||||
coin = rewards.calculate_ad_reward_coin(ecpm, existing_ads + 1)
|
||||
if coin > 0:
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark=f"信息流广告奖励 {unit_count}份",
|
||||
remark="信息流广告奖励",
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
@@ -165,6 +175,8 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
trace_id=trace_id,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=coin,
|
||||
|
||||
@@ -91,3 +91,54 @@ def set_app_version(db: Session, data: dict, *, commit: bool = True) -> AppConfi
|
||||
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, # 领券广告开关
|
||||
"withdrawal_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
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.models.savings import SavingsRecord
|
||||
from app.schemas.compare_record import ComparisonRecordIn
|
||||
@@ -155,6 +156,29 @@ def _ordered_shop_names(db: Session, user_id: int) -> set[str]:
|
||||
return {s for s in rows if s}
|
||||
|
||||
|
||||
def _ad_coins_by_trace(db: Session, user_id: int, trace_ids: list[str]) -> dict[str, int]:
|
||||
"""各 trace_id「看广告赚的金币」:按 trace_id 聚合该用户已发放(granted)的信息流广告金币。
|
||||
|
||||
广告金币来自比价等候期信息流(biz_type=feed_ad_reward);客户端结算时把本场 trace_id 带上,
|
||||
落到 ad_feed_reward_record.trace_id。此处按 trace_id 求和 = 这次比价看广告实发的金币。
|
||||
trace_id 仅比价场景客户端带(领券/福利/旧客户端为 NULL),故按 trace_id 过滤天然只算比价广告。
|
||||
空集合直接返回(避免 IN () 非法)。
|
||||
"""
|
||||
if not trace_ids:
|
||||
return {}
|
||||
rows = db.execute(
|
||||
select(
|
||||
AdFeedRewardRecord.trace_id,
|
||||
func.coalesce(func.sum(AdFeedRewardRecord.coin), 0),
|
||||
).where(
|
||||
AdFeedRewardRecord.user_id == user_id,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.trace_id.in_(trace_ids),
|
||||
).group_by(AdFeedRewardRecord.trace_id)
|
||||
).all()
|
||||
return {tid: int(coin) for tid, coin in rows if tid}
|
||||
|
||||
|
||||
def list_records(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -162,7 +186,7 @@ def list_records(
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[ComparisonRecord], int | None]:
|
||||
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记(瞬态,不写库)。"""
|
||||
"""比价记录分页(按创建时间倒序、id 兜底,游标式)。附「已下单」店级标记 + 「看广告赚的金币」(瞬态,不写库)。"""
|
||||
stmt = select(ComparisonRecord).where(ComparisonRecord.user_id == user_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(ComparisonRecord.id < cursor)
|
||||
@@ -172,10 +196,13 @@ def list_records(
|
||||
next_cursor = items[-1].id if len(items) == limit else None
|
||||
|
||||
# 「已下单」标记:本页记录的 store_name 若落在该用户真实下单的店名集合里即 True。
|
||||
# ordered 非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
|
||||
# ordered / ad_coins_earned 均非 ORM 列,仅挂实例上供 ComparisonRecordOut(from_attributes) 读出,不持久化。
|
||||
ordered_shops = _ordered_shop_names(db, user_id)
|
||||
# 「本次比价看广告赚的金币」:按本页 trace_id 一次性聚合(同 ordered 范式)。
|
||||
ad_coins = _ad_coins_by_trace(db, user_id, [it.trace_id for it in items])
|
||||
for it in items:
|
||||
it.ordered = bool(it.store_name and it.store_name in ordered_shops)
|
||||
it.ad_coins_earned = ad_coins.get(it.trace_id, 0)
|
||||
|
||||
return items, next_cursor
|
||||
|
||||
@@ -210,10 +237,13 @@ def get_stats(db: Session, user_id: int) -> tuple[int, int]:
|
||||
|
||||
|
||||
def get_record(db: Session, user_id: int, record_id: int) -> ComparisonRecord | None:
|
||||
"""取单条(限本人,避免越权读他人记录)。"""
|
||||
return db.execute(
|
||||
"""取单条(限本人,避免越权读他人记录)。附「看广告赚的金币」瞬态属性(同 list_records)。"""
|
||||
rec = db.execute(
|
||||
select(ComparisonRecord).where(
|
||||
ComparisonRecord.id == record_id,
|
||||
ComparisonRecord.user_id == user_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if rec is not None:
|
||||
rec.ad_coins_earned = _ad_coins_by_trace(db, user_id, [rec.trace_id]).get(rec.trace_id, 0)
|
||||
return rec
|
||||
|
||||
@@ -52,12 +52,13 @@ def create_link(
|
||||
|
||||
def record_click(
|
||||
db: Session, *, link: CpsLink, ip: str | None = None, ua: str | None = None,
|
||||
event_type: str = "visit",
|
||||
event_type: str = "visit", openid: str | None = None,
|
||||
) -> None:
|
||||
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。"""
|
||||
"""记一条点击。event_type: visit(进落地页/被跳转) | copy(淘宝点了"复制口令")。
|
||||
openid: 微信网页授权拿到的用户标识(非微信/未授权为空),做用户级群统计。"""
|
||||
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),
|
||||
ip=ip, ua=(ua[:500] if ua else None), openid=openid,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""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
|
||||
@@ -0,0 +1,124 @@
|
||||
"""device 表读写(设备注册 / 心跳 / 超时扫描)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.device import DeviceLiveness
|
||||
|
||||
|
||||
def _get(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None:
|
||||
stmt = select(DeviceLiveness).where(
|
||||
DeviceLiveness.user_id == user_id, DeviceLiveness.device_id == device_id
|
||||
)
|
||||
return db.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
def register_or_update(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
registration_id: str | None,
|
||||
platform: str = "android",
|
||||
app_version: str | None = None,
|
||||
) -> DeviceLiveness:
|
||||
"""注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。"""
|
||||
device = _get(db, user_id=user_id, device_id=device_id)
|
||||
if device is None:
|
||||
device = DeviceLiveness(
|
||||
user_id=user_id,
|
||||
device_id=device_id,
|
||||
registration_id=registration_id,
|
||||
platform=platform or "android",
|
||||
app_version=app_version,
|
||||
)
|
||||
db.add(device)
|
||||
else:
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
if platform:
|
||||
device.platform = platform
|
||||
if app_version:
|
||||
device.app_version = app_version
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
def touch_heartbeat(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: int,
|
||||
device_id: str,
|
||||
accessibility_enabled: bool,
|
||||
registration_id: str | None,
|
||||
) -> DeviceLiveness:
|
||||
"""处理一次心跳(心跳也能自注册)。
|
||||
|
||||
service 心跳或 accessibility_enabled=true 时,刷新存活并把状态机重置回 alive、
|
||||
清掉 notified_at(掉线恢复 → 下次再断才会再推一条)。
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
device = _get(db, user_id=user_id, device_id=device_id)
|
||||
if device is None:
|
||||
device = DeviceLiveness(user_id=user_id, device_id=device_id)
|
||||
db.add(device)
|
||||
|
||||
if registration_id:
|
||||
device.registration_id = registration_id
|
||||
device.last_report_protection_on = accessibility_enabled
|
||||
|
||||
if accessibility_enabled:
|
||||
device.last_heartbeat_at = now
|
||||
device.ever_protected = True
|
||||
device.liveness_state = "alive"
|
||||
device.notified_at = None
|
||||
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]:
|
||||
"""掉线设备:曾经保护过、当前 alive、心跳超时。
|
||||
|
||||
本期只做终端打印检测、不推送 → 不再要求有 registration_id(没接极光 token 的设备也要检出)。
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||
stmt = select(DeviceLiveness).where(
|
||||
DeviceLiveness.ever_protected.is_(True),
|
||||
DeviceLiveness.liveness_state == "alive",
|
||||
DeviceLiveness.last_heartbeat_at.is_not(None),
|
||||
DeviceLiveness.last_heartbeat_at < cutoff,
|
||||
)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def mark_notified(db: Session, *, device_id_pk: int) -> None:
|
||||
"""标记掉线已检出(状态机进入 notified,避免每轮重复处理)+ 置「待客户端提醒」标记。
|
||||
|
||||
kill_alert_pending 与 state 解耦(见 spec accessibility-liveness-pull-prompt.md):
|
||||
心跳恢复(touch_heartbeat)只重置 state、不动此标记,故客户端下次进 App 必能拉到这次掉线。
|
||||
"""
|
||||
device = db.get(DeviceLiveness, device_id_pk)
|
||||
if device is not None:
|
||||
device.liveness_state = "notified"
|
||||
device.notified_at = datetime.now(timezone.utc)
|
||||
device.kill_alert_pending = True
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_device(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None:
|
||||
"""按 (user_id, device_id) 取设备(liveness 查询用)。"""
|
||||
return _get(db, user_id=user_id, device_id=device_id)
|
||||
|
||||
|
||||
def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None:
|
||||
"""客户端已弹过「开启自启动」引导 → 清「待提醒」标记(幂等; 下次真掉线 worker 再置)。"""
|
||||
device = _get(db, user_id=user_id, device_id=device_id)
|
||||
if device is not None and device.kill_alert_pending:
|
||||
device.kill_alert_pending = False
|
||||
db.commit()
|
||||
@@ -1,8 +1,12 @@
|
||||
"""feedback 表写入。"""
|
||||
"""feedback 表读写。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.rewards import CN_TZ
|
||||
from app.models.feedback import Feedback
|
||||
|
||||
|
||||
@@ -19,9 +23,19 @@ def create_feedback(
|
||||
content=content,
|
||||
contact=contact,
|
||||
images=images or None,
|
||||
status="new",
|
||||
status="pending",
|
||||
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||
)
|
||||
db.add(fb)
|
||||
db.commit()
|
||||
db.refresh(fb)
|
||||
return fb
|
||||
|
||||
|
||||
def list_feedback(db: Session, *, user_id: int) -> list[Feedback]:
|
||||
stmt = (
|
||||
select(Feedback)
|
||||
.where(Feedback.user_id == user_id)
|
||||
.order_by(Feedback.created_at.desc(), Feedback.id.desc())
|
||||
)
|
||||
return list(db.execute(stmt).scalars().all())
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""反馈页二维码卡配置读写(运营后台可配 → 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
|
||||
@@ -0,0 +1,35 @@
|
||||
"""启动确认窗兜底样本落库:单条 insert(都上报、不去重,by design)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.launch_confirm_sample import LaunchConfirmSample
|
||||
from app.schemas.launch_confirm_sample import LaunchConfirmSampleIn
|
||||
|
||||
logger = logging.getLogger("shagua.launch_confirm")
|
||||
|
||||
|
||||
def _trunc(s: Optional[str], n: int) -> Optional[str]:
|
||||
"""截断到列长上限(PG varchar 超长会报错;繁体长标题等需要)。空 → None。"""
|
||||
return s[:n] if s else None
|
||||
|
||||
|
||||
def insert_sample(db: Session, payload: LaunchConfirmSampleIn) -> int:
|
||||
"""写入一条样本,返回新行 id。"""
|
||||
row = LaunchConfirmSample(
|
||||
trace_id=_trunc(payload.trace_id, 64),
|
||||
device_id=_trunc(payload.device_id, 64),
|
||||
host_package=_trunc(payload.host_package, 128),
|
||||
target_app=_trunc(payload.target_app, 128),
|
||||
system_locale=_trunc(payload.system_locale, 32),
|
||||
exec_success=bool(payload.exec_success),
|
||||
dialog_title=_trunc(payload.dialog_title, 256),
|
||||
payload=payload.payload,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row.id
|
||||
@@ -6,13 +6,13 @@ feed 取最近的成功且省到钱的比价记录(脱敏用户名);不足 N 条
|
||||
种子是「生成规则」:用户名可空(空→随机合成脱敏名,避开同屏撞名)、金额是区间(每次随机取值)、
|
||||
feed 公平随机抽取(不看 sort_order),所有启用种子都有机会露出。
|
||||
|
||||
脱敏名规则(全 feed 统一,按字符算、中英文皆适用):
|
||||
- 有昵称 → 昵称脱敏:≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 / SaveKing→S******g),
|
||||
2 字「首+星」(阿强→阿*),1 字「用户+该字」(喵→用户喵);
|
||||
- 没昵称 → 合成一个假名(见下)再脱敏,少数露「用户+id 后 3 位」(用户********618)。
|
||||
真实条:设过昵称的按真实昵称脱敏;当前多数用户没设昵称,直接展示「用户****」会清一色,故给它们合成
|
||||
假名——后期真实昵称多了,真实条会直接用真实昵称,合成占比自然下降。种子 / 兜底同样合成、避开同屏撞名;
|
||||
种子留空 / 旧「用户****xxx」模板名一律重新合成(自愈历史种子,无需迁移)。
|
||||
脱敏名规则(对齐 PRD「用户标识打码规则」,按字符数算、中英文皆适用):
|
||||
- 有昵称 → 昵称脱敏:n≥5「首+***+末」(AAA省钱小能手→A***手 / Micky_ 毛→M***毛),
|
||||
n=4「首+**+末」(你好不好→你**好),n≤3「首+**」(小确呀→小** / 阿强→阿** / 喵→喵**);
|
||||
- 没昵称(真实用户)→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定。
|
||||
真实条按上述 PRD 规则脱敏。种子 / 兜底无真实用户(无昵称/无 id,套不上「用户*****+id后2位」),
|
||||
仍本地合成多样假名再走同一套昵称脱敏(避开同屏撞名),让横条不至清一色;种子留空 / 旧「用户****xxx」
|
||||
模板名一律重新合成(自愈历史种子,无需迁移)。
|
||||
假名合成:纯本地语料**组合生成**(中文姓池×名字池 拼真名 / 中文网络昵称 / 英文昵称 / 英文名带数字尾),
|
||||
组合空间上万、中文为主英文为辅,脱敏后像真实异质用户群。**真实条按 `user_id` 播种确定性合成**(同一用户
|
||||
每次展示恒定、不同用户各异),刷新/翻页不变脸;种子 / 兜底用运行时随机源出多样填充。不依赖外部库。
|
||||
@@ -44,12 +44,12 @@ _REAL_ROWS_TTL_SECONDS = 30
|
||||
_REAL_ROWS_FETCH_CAP = 600 # 一次多取些,够 limit≤30 去重后取数;命中缓存后复用
|
||||
_real_rows_cache: dict = {"at": None, "rows": None}
|
||||
|
||||
# ===== 用户标识脱敏 + 没真实昵称时的假名合成 =====
|
||||
# 脱敏规则(中英文皆适用,按字符算):有昵称→≥3 字「首+隐藏字数个星+末」(省钱小能手→省***手 /
|
||||
# SaveKing→S******g)、2 字「首+星」(阿强→阿*)、1 字「用户+该字」(喵→用户喵);没昵称→用户+8星+id 后 3 位。
|
||||
# 没真实昵称时(当前多数用户 / 种子 / 兜底)合成假名:本地姓/名字池组合出真名 + 网络昵称语料(中/英)
|
||||
# 混播再走同一套脱敏,像真实异质用户群。后期真实昵称多了,真实条直接用真实昵称,这里占比自然下降。
|
||||
_ANON_STARS = "*" * 8 # 无昵称匿名串固定 8 星(用户********xxx)
|
||||
# ===== 用户标识脱敏(对齐 PRD) + 种子无真实昵称时的假名合成 =====
|
||||
# 脱敏规则(按字符数,中英文皆适用):有昵称→n≥5「首+***+末」、n=4「首+**+末」、n≤3「首+**」;
|
||||
# 没昵称(真实用户)→「用户」+5星+id 后 2 位(用户*****08)。
|
||||
# 种子/兜底没有真实用户(无昵称/无 id),本地用姓/名字池组合真名 + 网络昵称语料(中/英)合成假名,
|
||||
# 再走同一套昵称脱敏,让横条像真实异质用户群、不清一色(PRD 只约束真实用户,种子是运营填充)。
|
||||
_ANON_STARS = "*" * 5 # 「用户」匿名串固定 5 星(用户*****xx,对齐 PRD)
|
||||
|
||||
# 中文真名组合池:姓(百家姓常见 ~100)× 名字单字(~120)拼「姓 + 1~2 字」,组合空间上万。
|
||||
# 脱敏后只露「姓*」或「姓*末」,故无需真实姓名库——组合够多即看着各异(替代原 Faker zh_CN)。
|
||||
@@ -86,19 +86,23 @@ _NICK_EN = [
|
||||
|
||||
|
||||
def _mask_nickname(nick: str) -> str:
|
||||
"""昵称脱敏(按字符,中英文皆可):≥3 字→首+(隐藏字数个)星+末(省钱小能手→省***手 / SaveKing→S******g);
|
||||
2 字→首+星(阿强→阿*);1 字→「用户」+该字(喵→用户喵)。调用方需保证 nick 已 strip 且非空。"""
|
||||
"""昵称脱敏(按字符数,中英文皆可,对齐 PRD「用户标识打码规则」):
|
||||
n≥5 → 首+***+末(AAA省钱小能手→A***手 / Micky_ 毛→M***毛);
|
||||
n=4 → 首+**+末(你好不好→你**好 / Lady→L**y);
|
||||
n≤3 → 首+**(小确呀→小** / 阿强→阿** / 喵→喵** / Lay→L**)。
|
||||
调用方需保证 nick 已 strip 且非空。"""
|
||||
n = len(nick)
|
||||
if n >= 3:
|
||||
return nick[0] + "*" * (n - 2) + nick[-1]
|
||||
if n == 2:
|
||||
return nick[0] + "*"
|
||||
return "用户" + nick
|
||||
if n >= 5:
|
||||
return nick[0] + "***" + nick[-1]
|
||||
if n == 4:
|
||||
return nick[0] + "**" + nick[-1]
|
||||
return nick[0] + "**"
|
||||
|
||||
|
||||
def _anon_by_id(user_id: int) -> str:
|
||||
"""没昵称匿名:固定「用户」+ 8 星 + id 后 3 位(不足 3 位前补 0):用户********618。"""
|
||||
return "用户" + _ANON_STARS + f"{user_id % 1000:03d}"
|
||||
def _mask_anon(user_id: int) -> str:
|
||||
"""没昵称(真实用户,对齐 PRD):「用户」+ 5 星 + id 后 2 位(不足 2 位前补 0):用户*****08。
|
||||
注:user_id 为整数,「id 后 2 位」取末两位数字(PRD 示例「用户*****x8」的 x 为占位符)。"""
|
||||
return "用户" + _ANON_STARS + f"{user_id % 100:02d}"
|
||||
|
||||
|
||||
def _synth_full_name(rng: random.Random) -> str:
|
||||
@@ -118,27 +122,24 @@ def _synth_full_name(rng: random.Random) -> str:
|
||||
|
||||
|
||||
def _synth_masked_name(rng: random.Random) -> str:
|
||||
"""合成一条「已脱敏」假名(供没真实昵称的真实用户 / 种子 / 兜底用)。用传入 rng 决定一切随机。"""
|
||||
"""合成一条「已脱敏」假名(供种子 / 兜底用,真实用户已改走 PRD 规则)。用传入 rng 决定一切随机。"""
|
||||
full = _synth_full_name(rng).strip()
|
||||
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
|
||||
return _mask_nickname(full) if full else "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
|
||||
|
||||
|
||||
def _mask_real(nickname: str | None, user_id: int) -> str:
|
||||
"""真实用户脱敏:设过昵称→昵称脱敏(中英文皆可);没昵称→**按 user_id 播种确定性合成假名**再脱敏
|
||||
(同一用户每次展示恒定、刷新不变脸,不同用户各异),避免无昵称用户清一色「用户****xxx」;
|
||||
少数(~15%)露「用户+真实 id 后 3 位」。当前多数用户没昵称,后期真实昵称多了占比下降。"""
|
||||
"""真实用户脱敏(对齐 PRD「用户标识打码规则」):设过昵称→昵称脱敏(中英文皆可);
|
||||
没昵称→「用户」+5星+id 后 2 位(用户*****08),按 user_id 稳定、刷新不变脸。"""
|
||||
nick = (nickname or "").strip()
|
||||
if nick:
|
||||
return _mask_nickname(nick)
|
||||
# 关键:按 user_id 播种本地 rng → 同一用户的合成名跨请求/刷新恒定(0.15 抽签也用它)。
|
||||
seeded = random.Random(user_id)
|
||||
return _anon_by_id(user_id) if seeded.random() < 0.15 else _synth_masked_name(seeded)
|
||||
return _mask_anon(user_id)
|
||||
|
||||
|
||||
def _synth_name(rng: random.Random) -> str:
|
||||
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 3 位(用户********618)。"""
|
||||
"""合成一条脱敏名(种子留空 / 旧模板名 / 兜底用):绝大多数=假名脱敏,少数=用户+随机 2 位(用户*****08)。"""
|
||||
if rng.random() < 0.15:
|
||||
return "用户" + _ANON_STARS + f"{rng.randint(0, 999):03d}"
|
||||
return "用户" + _ANON_STARS + f"{rng.randint(0, 99):02d}"
|
||||
return _synth_masked_name(rng)
|
||||
|
||||
|
||||
@@ -148,7 +149,7 @@ def _unique_name(used: set[str]) -> str:
|
||||
n = _synth_name(_rng)
|
||||
if n not in used:
|
||||
return n
|
||||
return "用户" + _ANON_STARS + f"{_rng.randint(0, 999):03d}" # 兜底:数字尾号天然好去重
|
||||
return "用户" + _ANON_STARS + f"{_rng.randint(0, 99):02d}" # 兜底:数字尾号天然好去重
|
||||
|
||||
|
||||
def _validate_amount(min_cents: int, max_cents: int) -> None:
|
||||
|
||||
@@ -22,7 +22,8 @@ _USERNAME_FIRST = "23456789"
|
||||
_USERNAME_DIGITS = "0123456789"
|
||||
_USERNAME_LEN = 11
|
||||
|
||||
# 默认昵称 = 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。
|
||||
# 默认昵称 = "用户" + 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。
|
||||
_NICKNAME_PREFIX = "用户"
|
||||
_NICKNAME_ALPHABET = string.ascii_letters + string.digits
|
||||
_NICKNAME_LEN = 9
|
||||
|
||||
@@ -35,8 +36,10 @@ def _gen_username() -> str:
|
||||
|
||||
|
||||
def _gen_nickname() -> str:
|
||||
"""随机一个 9 位字母+数字的默认昵称。"""
|
||||
return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN))
|
||||
"""默认昵称 = "用户" + 9 位字母+数字随机串。"""
|
||||
return _NICKNAME_PREFIX + "".join(
|
||||
secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN)
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||
@@ -111,14 +114,27 @@ 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()
|
||||
|
||||
@@ -340,6 +340,15 @@ 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)」的提现单。
|
||||
|
||||
|
||||
@@ -135,6 +135,18 @@ class FeedRewardIn(BaseModel):
|
||||
duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
|
||||
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(福利页);"
|
||||
"比价与领券共用同一信息流代码位,需客户端在各调用点显式标注,缺省=未分类",
|
||||
)
|
||||
trace_id: str | None = Field(
|
||||
None,
|
||||
max_length=64,
|
||||
description="本次比价 trace_id(比价场景必带):把这场广告金币归属到对应比价记录,"
|
||||
"比价记录页据此显示「比价赚 N 金币」。领券/福利场景为空",
|
||||
)
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
||||
)
|
||||
@@ -154,6 +166,14 @@ class FeedRewardOut(BaseModel):
|
||||
daily_limit: int = Field(..., description="每日信息流展示次数上限")
|
||||
|
||||
|
||||
class FeedRewardUnitsOut(BaseModel):
|
||||
granted_units: int = Field(
|
||||
...,
|
||||
description="账号累计已发奖的信息流**条**数(一条广告=一份额度),不按天重置;"
|
||||
"前端据此算因子2(LT)做实时金币进度条,与后端发奖公式对齐",
|
||||
)
|
||||
|
||||
|
||||
class RewardNoShowIn(BaseModel):
|
||||
"""激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ class TokenWithUser(TokenPair):
|
||||
description="该 设备+账号 是否已走完新手引导(据登录请求里的 device_id 计算)。"
|
||||
"true → 客户端登录后直接进首页,跳过引导。",
|
||||
)
|
||||
force_onboarding: bool = Field(
|
||||
False,
|
||||
description="是否强制本次登录走新手引导(仅测试号置 true)。客户端据此让【应用内重新登录】"
|
||||
"(退出后再登)也重走引导,普通号为 false、按原逻辑回原页/首页。与 onboarding_completed "
|
||||
"分工:后者管 App 启动路由,本字段管应用内登录后的去向。",
|
||||
)
|
||||
|
||||
|
||||
# ===== 极光一键登录 =====
|
||||
|
||||
@@ -51,6 +51,13 @@ class ComparisonResultIn(BaseModel):
|
||||
# 跟 coupon_saved 并存, 是更丰富的明细; 空=没抠到 → 前端回退单券路径。
|
||||
# 必须显式声明: 落库走 model_dump(), pydantic 默认丢未知字段, 不声明这行会被悄悄吞掉。
|
||||
applied_coupons: list[AppliedCouponIn] = Field(default_factory=list)
|
||||
# 逐平台状态(客户端上报前把 done.params 的 comparison_results + platform_results.status 合并写入):
|
||||
# success / below_minimum / store_closed / store_not_found / items_not_found / unsupported / failed。
|
||||
# 「比价记录」页据此对无价平台显示对应文案(未满起送价/门店打烊/店家未入驻/比价失败)。
|
||||
# 必须显式声明: 落库走 model_dump(), 不声明会被 pydantic 静默丢弃 → 记录页拿不到 status。
|
||||
status: str | None = None
|
||||
# 门店打烊原因(price 为 None 时带): 与 status="store_closed" 等价的更早信号, 一并落库供前端兜底判打烊。
|
||||
store_closed: str | None = None
|
||||
|
||||
|
||||
class ComparisonRecordIn(BaseModel):
|
||||
@@ -71,8 +78,11 @@ 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「卡在哪一步」从这里读。list[dict] 宽松存(结构由 pricebot 定,只作 debug 展示)。
|
||||
platform_results: list = Field(default_factory=list)
|
||||
# admin「卡在哪一步」从这里读。dict{platform_id: {...}} 宽松存(结构由 pricebot 定——是
|
||||
# 按平台 id 索引的 dict 而非 list,只作 debug 展示)。
|
||||
# ⚠️ 早先误声明成 list → 每条成功记录 422 被拒、记录页只剩失败/终止
|
||||
# (2026-06-17 引入 platform_results 上报后复现);改 dict 后修复。
|
||||
platform_results: dict = Field(default_factory=dict)
|
||||
skipped_dish_count: int | None = None
|
||||
skipped_dish_names: list[str] = Field(default_factory=list)
|
||||
total_dish_count: int | None = None
|
||||
@@ -137,6 +147,9 @@ class ComparisonRecordOut(BaseModel):
|
||||
# 「已下单」(店级):该店名在该用户真实下单(source='compare')里出现过即 True。
|
||||
# 由 list_records 动态算出挂在 ORM 实例上(非 DB 列),from_attributes 读出;缺省 False。
|
||||
ordered: bool = False
|
||||
# 「本次比价看广告赚的金币」(feed_ad_reward,按 trace_id 聚合 granted 金币;非 DB 列,
|
||||
# list_records/get_record 动态挂在 ORM 实例上,from_attributes 读出)。0=没赚到 → 记录页不显示金币标。
|
||||
ad_coins_earned: int = 0
|
||||
created_at: datetime
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""设备注册 / 心跳相关 schema(无障碍保护存活检测)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class DeviceRegisterRequest(BaseModel):
|
||||
device_id: str
|
||||
registration_id: str | None = None
|
||||
platform: str = "android"
|
||||
app_version: str | None = None
|
||||
|
||||
|
||||
class HeartbeatRequest(BaseModel):
|
||||
device_id: str
|
||||
source: str = "service" # service | app
|
||||
accessibility_enabled: bool = True
|
||||
registration_id: str | None = None
|
||||
|
||||
|
||||
class DeviceOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
device_id: str
|
||||
registration_id: str | None
|
||||
ever_protected: bool
|
||||
liveness_state: str
|
||||
last_heartbeat_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class LivenessOut(BaseModel):
|
||||
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
|
||||
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state 等)。从未注册过 → 默认 False(无告警)。"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
kill_alert_pending: bool = False
|
||||
|
||||
|
||||
class LivenessAckRequest(BaseModel):
|
||||
device_id: str
|
||||
+37
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class FeedbackOut(BaseModel):
|
||||
@@ -12,3 +12,39 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""启动确认窗兜底样本的内部上报模型(pricebot → app-server)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LaunchConfirmSampleIn(BaseModel):
|
||||
"""一次 agent 兜底的完整样本。
|
||||
|
||||
字段一律宽松可空:落盘是辅助数据,绝不能因个别字段缺失就拒收(否则连累 pricebot 兜底)。
|
||||
"""
|
||||
|
||||
trace_id: str | None = None
|
||||
device_id: str | None = None
|
||||
host_package: str | None = None
|
||||
target_app: str | None = None
|
||||
system_locale: str | None = None
|
||||
exec_success: bool = False
|
||||
dialog_title: str | None = None
|
||||
payload: dict | None = None
|
||||
|
||||
|
||||
class LaunchConfirmSampleOut(BaseModel):
|
||||
"""落库结果。"""
|
||||
|
||||
id: int
|
||||
+16
-1
@@ -13,6 +13,21 @@ from pydantic import BaseModel, Field
|
||||
FeedStatus = Literal["ok", "empty", "degraded"]
|
||||
|
||||
|
||||
# ───────────────── 头图传输优化(统一缩放 + 转 WebP) ─────────────────
|
||||
# 美团图片 CDN 支持在 URL 后拼 @<w>w_<h>h_1e_1c[.webp] 让服务端缩放/转码。feed 卡片只占
|
||||
# 124dp(≈372px@3x),而库里 head_url 是原图(实测中位 137KB、长尾到 17MB、GIF 均 4.8MB),
|
||||
# 是首页 feed 图片加载偏慢的来源。按产品要求,头图**统一**缩到 124dp + 转 WebP 再下发
|
||||
# (全部压缩、不设大小阈值),由美团 CDN 服务端缩放/转码,实测省 76–99% 体积。
|
||||
FEED_THUMB_PARAM = "@375w_375h_1e_1c.webp" # 124dp@3x 缩略 + 转 WebP
|
||||
|
||||
|
||||
def feed_image_url(head_url: str) -> str:
|
||||
"""统一给头图 URL 拼缩放参数(缩到 124dp + 转 WebP),让美团 CDN 出小图;空 URL 原样返回。"""
|
||||
if not head_url:
|
||||
return head_url
|
||||
return head_url.split("@")[0] + FEED_THUMB_PARAM
|
||||
|
||||
|
||||
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
|
||||
|
||||
class CouponCard(BaseModel):
|
||||
@@ -94,7 +109,7 @@ class CouponCard(BaseModel):
|
||||
platform=platform,
|
||||
biz_line=biz_line,
|
||||
name=cpd.get("name") or "",
|
||||
head_image_url=head_url,
|
||||
head_image_url=feed_image_url(head_url),
|
||||
brand_name=brand.get("brandName"),
|
||||
brand_logo_url=brand.get("brandLogoUrl"),
|
||||
sell_price=sell,
|
||||
|
||||
@@ -30,6 +30,22 @@ class AppFlagsOut(BaseModel):
|
||||
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 # 领券广告开关
|
||||
withdrawal_ad_enabled: bool # 提现激励视频开关(关=客户端直接放行提现)
|
||||
|
||||
|
||||
class AppVersionOut(BaseModel):
|
||||
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# 美团券头图大小/类型回填 —— 全量重跑一轮 ETL,把 meituan_coupon.image_size / image_type 灌满。
|
||||
# 上线本 PR 后在服务器手动跑一次,立即回填存量券(不必等定时器逐轮 upsert 自然补齐)。
|
||||
#
|
||||
# 用法(服务器, root):
|
||||
# cd /opt/shaguabijia-app-server
|
||||
# sudo bash deploy/backfill_image_meta.sh
|
||||
# # 全量一轮 ~70min,建议在 tmux/screen 里跑,防 SSH 断开中断
|
||||
#
|
||||
# 前提:已拉到含本 PR 的 main、已跑 `alembic upgrade head`(脚本会自检列是否存在)、
|
||||
# 已 restart shaguabijia-app-server.service。服务器 MT_CPS_PROXY 留空(直连 CDN)。
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/shaguabijia-app-server}"
|
||||
PY="$APP_DIR/.venv/bin/python"
|
||||
cd "$APP_DIR"
|
||||
mkdir -p data
|
||||
LOG="data/etl_backfill_$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
# 退出时务必恢复定时器(即使 ETL 中途失败,也不把 timer 留在停用态)
|
||||
restore_timer() { systemctl start meituan-etl.timer 2>/dev/null || true; }
|
||||
trap restore_timer EXIT
|
||||
|
||||
echo "[0/3] 自检 image_size/image_type 列(缺列说明没迁移,先跑 alembic upgrade head)..."
|
||||
"$PY" -c "from sqlalchemy import inspect; from app.db.session import engine; \
|
||||
cols=[c['name'] for c in inspect(engine).get_columns('meituan_coupon')]; \
|
||||
import sys; (('image_size' in cols) and ('image_type' in cols)) or sys.exit(' ❌ 缺列,请先: sudo .venv/bin/python -m alembic upgrade head'); \
|
||||
print(' ✓ 列已就绪')"
|
||||
|
||||
echo "[1/3] 停定时器,防与定时轮次并发占锁..."
|
||||
systemctl stop meituan-etl.timer 2>/dev/null || true
|
||||
|
||||
echo "[2/3] 全量重跑 ETL(~70min;日志 $LOG)..."
|
||||
# 不走 systemctl:meituan-etl.service 有 20min 硬超时,全量一轮跑不完会被掐断。
|
||||
"$PY" -m scripts.pull_meituan_coupons --once --prune-hours 24 2>&1 | tee "$LOG"
|
||||
|
||||
echo "[3/3] 验证填充率..."
|
||||
"$PY" - <<'PYEOF'
|
||||
from sqlalchemy import text
|
||||
from app.db.session import engine
|
||||
engine.echo = False
|
||||
with engine.connect() as c:
|
||||
tot = c.execute(text("SELECT count(*) FROM meituan_coupon")).scalar()
|
||||
enr = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_size IS NOT NULL")).scalar()
|
||||
gif = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_type='image/gif'")).scalar()
|
||||
print(f" 总 {tot} 行 | 有 image_size {enr} ({enr*100//max(tot,1)}%) | GIF {gif} 张")
|
||||
PYEOF
|
||||
echo "✅ 回填完成(定时器已恢复)。"
|
||||
@@ -3,6 +3,15 @@
|
||||
> 对象:维护「每天 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 分** 兑成现金。
|
||||
@@ -21,8 +30,9 @@
|
||||
| systemd 单元 | `deploy/daily-exchange.{service,timer}` |
|
||||
| 运行锁 | `/tmp/daily_auto_exchange.lock`(可用环境变量 `DAILY_EXCHANGE_LOCK` 覆盖) |
|
||||
|
||||
## 部署(Linux 服务器)
|
||||
## (遗留 / 可选)systemd timer 部署 — 现已被进程内 worker 取代,正常无需执行
|
||||
```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 # 确认下次触发时间
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
| [api/](./api/) | **API 接口文档**:"索引 + 一接口一文件"(组织方式见下)。想查"某个接口的协议"看这里。 |
|
||||
| [database/](./database/) | **数据库文档**:每张表一个文件(表结构/字段/索引/约定) + 两份迁移指南——[数据库迁移.md](./database/数据库迁移.md)(Alembic 操作:clone 后建表/日常升级/新增迁移/多 head 排查) + [postgres-migration.md](./database/postgres-migration.md)(SQLite → PostgreSQL 切换步骤,配套 `scripts/init_postgres.py`)。想"把库跑起来 / 改表结构 / 查某张表"看这里。 |
|
||||
| [integrations/](./integrations/) | **集成层实现文档**:穿山甲验签 / 微信支付 / 极光 / 短信 / 美团 CPS 等 SDK 集成的签名、加解密、协议细节与踩坑。想知道"接外部服务那块到底怎么实现"看这里。 |
|
||||
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试)。 |
|
||||
| [guides/](./guides/) | **开发 / 上线 / 功能 指南**:[待办与技术债.md](./guides/待办与技术债.md)(跨前后端 backlog,P1 鉴权/用户绑定、引擎移植待办等,"还欠什么、以后要补什么"看这份) + [看广告赚金币上线清单.md](./guides/看广告赚金币上线清单.md)(看广告发奖上线 checklist) + [邀请功能-实现原理与本地测试.md](./guides/邀请功能-实现原理与本地测试.md)(invite-mvp 实现原理 + 本地内网全链路测试) + [CPS发券分发与微信授权.md](./guides/CPS发券分发与微信授权.md)(**CPS 发券系统交接文档**:设群/建活动/生成落地页短链/美团对账/统计 + 微信网页授权拿 openid 做用户级统计;含数据流/表/平台差异/端点/配置/代码地图/排障/技术债)。 |
|
||||
|
||||
## api/ 目录是怎么组织的(传送门式)
|
||||
|
||||
|
||||
+46
-4
@@ -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-23(补全此前缺整族的端点:device 无障碍存活监控、internal 内部回写、CPS 短链落地、report/invite/wxpay,及 platform/coupon/feedback/user 的零散读端点;device/internal/cps-redirect 三族新建单文件文档。上一次 2026-06-18 补录比价透传端点与 `meituan/top-sales`)
|
||||
> 架构:`app/api/v1/` 只放很轻的接口层;穿山甲/微信支付/极光/短信/美团等 SDK 集成的重逻辑在 `app/integrations/`,实现细节见 [docs/integrations/](../integrations/README.md)。
|
||||
|
||||
---
|
||||
@@ -19,7 +19,14 @@
|
||||
| 5 | `POST /api/v1/auth/refresh` | 无 | [详情](./auth-refresh.md) |
|
||||
| 6 | `GET /api/v1/auth/me` | Bearer | [详情](./auth-me.md) |
|
||||
| 7 | `POST /api/v1/auth/logout` | Bearer | [详情](./auth-logout.md) |
|
||||
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md) |
|
||||
| 8 | `POST /api/v1/coupon/step` | 无 | [详情](./coupon-step.md)(透传 pricebot + best-effort 写 `coupon_*` 三表) |
|
||||
| 8a | `POST /api/v1/coupon/prompt/shown` | 无 | 引导窗弹出即上报(按 device+package+日记 `shown`,今天这个 App 不再自动弹)(无单独文档) |
|
||||
| 8b | `POST /api/v1/coupon/prompt/dismiss` | 无 | 用户拒绝/关闭引导窗(透传链路看不到拒绝,客户端通知)(无单独文档) |
|
||||
| 8c | `GET /api/v1/coupon/prompt/should-show` | 无 | 切到外卖 App 时是否还应弹引导窗(`device_id`+`package`)(无单独文档) |
|
||||
| 8d | `POST /api/v1/coupon/prompt/reset` | 无 | 重置今日引导窗 engagement(开发测频控用)(无单独文档) |
|
||||
| 8e | `GET /api/v1/coupon/completed-today` | 无 | 这台设备今天是否已跑完整轮领券(首页「去领取」卡置灰源)(无单独文档) |
|
||||
| 8f | `POST /api/v1/coupon/completed-today/reset` | 无 | 重置今日已完成(开发用)(无单独文档) |
|
||||
| 8g | `GET /api/v1/coupon/stats` | Bearer | 累计领券数(「我的」页战绩卡;按 user_id 聚合,**鉴权**)(无单独文档) |
|
||||
| 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) |
|
||||
@@ -38,6 +45,19 @@
|
||||
| **比价战绩里程碑**(前缀 `/api/v1/compare`;福利页「记录比价战绩」,按成功比价数解锁逐档发金币) |||
|
||||
| 12d | `GET /api/v1/compare/milestones` | Bearer | [详情](./compare-milestones.md) |
|
||||
| 12e | `POST /api/v1/compare/milestones/{milestone}/claim` | Bearer | [详情](./compare-milestone-claim.md) |
|
||||
| **设备 / 无障碍存活监控**(前缀 `/api/v1/device`;心跳超时检出 + 掉线召回,#65) |||
|
||||
| D1 | `POST /api/v1/device/register` | Bearer | [详情](./device-liveness.md)(注册设备/更新极光 push token) |
|
||||
| D2 | `POST /api/v1/device/heartbeat` | Bearer | [详情](./device-liveness.md)(无障碍服务存活心跳,心跳也能自注册) |
|
||||
| D3 | `GET /api/v1/device/liveness` | Bearer | [详情](./device-liveness.md)(进 App 查本机是否被判掉线过) |
|
||||
| D4 | `POST /api/v1/device/liveness/ack` | Bearer | [详情](./device-liveness.md)(确认已弹引导,清掉线告警) |
|
||||
| **上报更低价**(前缀 `/api/v1/report`;众包纠偏,人工审核发奖) |||
|
||||
| R1 | `POST /api/v1/report` | Bearer | 提交上报(multipart:`comparison_record_id`/`reported_platform_id`/`reported_price`(元) + 1~4 张截图;原最低价反查 `comparison_record.best_*` 校验须更低)(无单独文档) |
|
||||
| R2 | `GET /api/v1/report/records` | Bearer | 上报记录列表(`?status=` pending/approved/rejected 可选筛选)(无单独文档) |
|
||||
| **好友邀请**(前缀 `/api/v1/invite`;注册即生效,双方各发 1 万金币) |||
|
||||
| I1 | `GET /api/v1/invite/me` | Bearer | 我的邀请码 + 分享链接 + 已邀人数/已得金币(无单独文档) |
|
||||
| I2 | `GET /api/v1/invite/invitees` | Bearer | 我邀请的人列表(`limit`/`offset` 分页)(无单独文档) |
|
||||
| I3 | `POST /api/v1/invite/landing-track` | 无 | 落地页 `dl.html` 访问上报指纹(剪贴板归因兜底;浏览器无 token)(无单独文档) |
|
||||
| I4 | `POST /api/v1/invite/bind` | Bearer | 绑定邀请人;支持 clipboard/manual 邀请码 + fingerprint 指纹反查三种归因(无单独文档) |
|
||||
| **钱包 / 我的资产**(前缀 `/api/v1/wallet`) |||
|
||||
| 14 | `GET /api/v1/wallet/account` | Bearer | [详情](./wallet-account.md) |
|
||||
| 15 | `GET /api/v1/wallet/coin-transactions` | Bearer | [详情](./wallet-coin-transactions.md) |
|
||||
@@ -68,15 +88,37 @@
|
||||
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) |
|
||||
| 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) |
|
||||
| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) |
|
||||
| 35c | `GET /api/v1/ad/feed-reward/units` | Bearer | 信息流广告今日已发份数/上限(配合 `feed-reward` 看进度)(无单独文档) |
|
||||
| **用户资料**(前缀 `/api/v1/user`) |||
|
||||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
|
||||
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) |
|
||||
| 36a | `POST /api/v1/user/onboarding/complete` | Bearer | 标记新手引导完成(按 账号+device_id 幂等,跨卸载重装持久)(无单独文档) |
|
||||
| 36b | `GET /api/v1/user/onboarding/status` | Bearer | 查该 (账号,设备) 是否走过引导(运营在 admin 删记录即触发重走)(无单独文档) |
|
||||
| 37 | `DELETE /api/v1/user` | Bearer | [详情](./user-delete.md) |
|
||||
| **帮助与反馈**(前缀 `/api/v1/feedback`) |||
|
||||
| 38 | `POST /api/v1/feedback` | Bearer | [详情](./feedback.md) |
|
||||
| **首页门面数据**(前缀 `/api/v1/platform`;全平台展示数字,登录前可读) |||
|
||||
| 38a | `GET /api/v1/feedback/config` | Bearer | 反馈页「加群二维码」卡配置(开关 + 二维码图 + 三行文案)(无单独文档) |
|
||||
| 38b | `GET /api/v1/feedback/records` | Bearer | 我的反馈历史(pending/adopted/rejected)(无单独文档) |
|
||||
| **首页门面数据 / 客户端配置**(前缀 `/api/v1/platform`;全平台展示数字 + 运营开关,**全部不鉴权**,登录前可读) |||
|
||||
| 39 | `GET /api/v1/platform/stats` | 无 | [详情](./platform-stats.md) |
|
||||
| 40 | `GET /api/v1/platform/savings-feed` | 无 | [详情](./platform-savings-feed.md) |
|
||||
| 40a | `GET /api/v1/platform/flags` | 无 | 客户端运营 feature flag(比价/领券期广告开关等),拉取后缓存(无单独文档) |
|
||||
| 40b | `GET /api/v1/platform/ad-config` | 无 | 客户端拉广告配置(穿山甲 app_id + 各位 ID + 各场景开关;不含验签密钥)(无单独文档) |
|
||||
| 40c | `GET /api/v1/platform/app-version` | 无 | 最新 App 版本(OTA 检查更新;与本机 versionCode 比)(无单独文档) |
|
||||
| **微信支付回调**(前缀 `/api/v1/wxpay`) |||
|
||||
| W1 | `POST /api/v1/wxpay/transfer-auth-notify` | 无 | 免确认收款授权结果通知(一期 stub:仅应答 200 不验签不改账,授权状态靠主动查询兜底)(无单独文档) |
|
||||
| **CPS 群发短链落地**(**无前缀**,挂域名根;公网不鉴权) |||
|
||||
| C1 | `GET /c/{code}` | 无 | [详情](./cps-redirect.md)(短链落地:微信授权拿 openid + 记点击 + 302 跳/淘宝 H5 落地页) |
|
||||
| C2 | `POST /c/{code}/copy` | 无 | [详情](./cps-redirect.md)(淘宝落地页点「复制口令」记 `copy`) |
|
||||
| C3 | `GET /wx/oauth/cb` | 无 | [详情](./cps-redirect.md)(微信网页授权回调;upsert `cps_wx_user` + 种 cookie,`include_in_schema=False`) |
|
||||
| C4 | `GET /MP_verify_*.txt` | 无 | [详情](./cps-redirect.md)(微信「网页授权域名」归属校验文件,`include_in_schema=False`) |
|
||||
| **内部回写端点**(前缀 `/internal`;pricebot/发布流程→app-server,**`X-Internal-Secret` 头**,非客户端接口) |||
|
||||
| N1 | `POST /internal/price-observation` | 内部密钥 | [详情](./internal.md)(比价价格事实批量落 `price_observation`) |
|
||||
| N2 | `GET /internal/store-mapping/lookup` | 内部密钥 | [详情](./internal.md)(按源平台店名反查目标平台已沉淀店铺 id/deeplink) |
|
||||
| N3 | `POST /internal/store-mapping` | 内部密钥 | [详情](./internal.md)(跨平台店铺身份映射落 `store_mapping`) |
|
||||
| N4 | `POST /internal/store-mapping/invalidate` | 内部密钥 | [详情](./internal.md)(标记某平台 shopId 缓存 deeplink 失效) |
|
||||
| N5 | `POST /internal/launch-confirm-sample` | 内部密钥 | [详情](./internal.md)(启动确认窗兜底样本落 `launch_confirm_sample`) |
|
||||
| N6 | `POST /internal/app-version` | 内部密钥 | [详情](./internal.md)(发布流程写最新 App 版本,落 `app_config`) |
|
||||
| **静态资源**(StaticFiles 挂载,见下方 `/media` 静态服务) |||
|
||||
| - | `GET /media/avatars/<file>` | 无 | 用户头像;返回二进制图片 |
|
||||
| - | `GET /media/feedback/<file>` | 无 | 反馈截图;返回二进制图片 |
|
||||
@@ -173,7 +215,7 @@
|
||||
| `platform` | int | `1`=外卖/到家, `2`=到店 |
|
||||
| `biz_line` | int \| null | 到店子类:1到餐 2到综 3酒店 4门票 |
|
||||
| `name` | string | 商品名 |
|
||||
| `head_image_url` | string | 头图 |
|
||||
| `head_image_url` | string | 头图;后端已**统一**拼缩放参数 `@375w_375h_1e_1c.webp`(美团 CDN 服务端缩到 124dp + 转 WebP,实测省 76–99%),客户端直接用 |
|
||||
| `brand_name` | string \| null | 品牌名 |
|
||||
| `brand_logo_url` | string \| null | 品牌 logo |
|
||||
| `sell_price` | string | 现价 |
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# CPS 群发短链落地(cps-redirect 族)
|
||||
|
||||
> 所属:cps-redirect 组(**无前缀**,挂域名根;源 `app/api/v1/cps_redirect.py`) | 鉴权:**公网无鉴权**(群里任何人点都要能跳/能领) | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 落库:点击落 [`cps_click`](../database/cps_click.md)、微信落地页用户落 [`cps_wx_user`](../database/cps_wx_user.md);短链由 [`cps_link`](../database/cps_link.md) 解析。
|
||||
|
||||
运营把 `/c/{code}` 短链发到微信群,用户点击 → 记点击 → 按平台 302 跳转(美团/京东)或返回 H5 淘宝落地页。**记点击/写用户失败绝不阻断用户跳转/领券**(try/except 吞掉)。
|
||||
|
||||
## GET /c/{code} — 短链落地
|
||||
- 微信内 + 还没拿到 openid + 配了服务号 → 先 `snsapi_base` 静默授权拿 openid,再 302 回本页。
|
||||
- 非微信 / 已有 openid cookie / 未配服务号 → 直接走原逻辑(openid 可能为 None,**绝不阻断**)。
|
||||
- 记 `visit`(写 `cps_click`)后:**美团/京东** 302 跳 `target_url`;**淘宝** 返回 H5 落地页(主视觉图 + 「复制口令」按钮)。
|
||||
- `code` 不存在/失效 → 302 跳兜底(`https://www.meituan.com/`)。
|
||||
|
||||
**入参**:路径 `code`(短链码)。**出参**:`302 Redirect` 或 `200 HTML`(淘宝)。
|
||||
|
||||
## POST /c/{code}/copy — 淘宝落地页点了「复制口令」
|
||||
淘宝落地页是 H5,用户点「复制口令」才算转化 → 记一条 `copy` 事件(带 openid cookie)。
|
||||
|
||||
**入参**:路径 `code`;body 空。**出参**:`{ "ok": true }`。
|
||||
|
||||
## GET /wx/oauth/cb — 微信网页授权回调(`include_in_schema=False`)
|
||||
`state='base:{code}'`(静默拿 openid)或 `'uinfo:{code}'`(补昵称头像)。换 openid(+userinfo)→ upsert [`cps_wx_user`](../database/cps_wx_user.md) → 种 cookie(`wx_openid` / `wx_uinfo`,30 天)→ 302 回落地页。**任何失败兜底回落地页,绝不阻断领券**。`uinfo` 回流带 `?authed=1`,落地页据此自动触发复制(把「点领券→授权→复制」衔接成一步)。
|
||||
|
||||
## GET /MP_verify_*.txt — 微信「网页授权域名」归属校验(`include_in_schema=False`)
|
||||
微信请求 `coupon.shaguabijia.com/MP_verify_xxx.txt` 验证域名归属;端点放域名根、返回文件名核心串(纯文本)。为微信网页授权(拿 openid)接入做准备。
|
||||
|
||||
## 说明
|
||||
- **两阶授权**:`base` 静默拿 openid(统计主力、不打扰);`userinfo` 在用户点「领券」时触发(交互触发避免微信快照页),拿昵称/头像/unionid。
|
||||
- **`cps_click` 不阻断**:落地/跳转优先级高于统计——丢几条点击可接受,挡住用户领券不可接受。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 设备 / 无障碍存活监控(device 族)
|
||||
|
||||
> 所属:device 组(前缀 `/api/v1/device`,源 `app/api/v1/device.py`) | 鉴权:**全部 Bearer**(设备绑登录用户) | [← 返回 API 索引](./README.md)
|
||||
>
|
||||
> 落库:[`device_liveness`](../database/device_liveness.md) 表;后台 `heartbeat_monitor_worker` 据此检出心跳超时的设备并召回。设计见 spec `accessibility-liveness-push.md`(推送版)+ `accessibility-liveness-pull-prompt.md`(后置 pull 提醒版)。#65 新增。
|
||||
|
||||
无障碍服务存活监控链路:客户端无障碍服务存活时周期上报心跳,后端发现「曾保护过、现已超时」的设备 → 召回(推送 / 客户端进 App 弹「开启自启动」引导)。
|
||||
|
||||
## POST /register — 注册设备 / 更新推送 token
|
||||
App 前台、拿到极光 push token 时调。按 `(user_id, device_id)` upsert。
|
||||
|
||||
**入参(JSON)**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 客户端 `DeviceId.get()` 生成的 per-install id |
|
||||
| `registration_id` | string \| null | ❌ | 极光推送 registration id(`JCollectionAuth` 同意后才有) |
|
||||
| `platform` | string | ❌ | 默认 `android` |
|
||||
| `app_version` | string \| null | ❌ | App 版本 |
|
||||
|
||||
**出参**:`DeviceOut`(设备当前状态)。
|
||||
|
||||
## POST /heartbeat — 上报心跳
|
||||
无障碍服务存活时周期调(**心跳也能自注册**)。`accessibility_enabled=true` 时刷新 `last_heartbeat_at`、状态机置 `alive`、清掉线告警。
|
||||
|
||||
**入参(JSON)**
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `device_id` | string | ✅ | 同上 |
|
||||
| `accessibility_enabled` | bool | ✅ | 本机无障碍开关当前是否开 |
|
||||
| `registration_id` | string \| null | ❌ | 顺带更新 push token |
|
||||
|
||||
**出参**:`{ "ok": true }`。
|
||||
|
||||
## GET /liveness — 查本机掉线告警(后置检测)
|
||||
客户端进 App 时拉:本机是否被判定掉线过(`kill_alert_pending`)。命中 → 客户端弹「开启自启动」引导,之后调 `/liveness/ack` 清。设备从未注册过 → 返回默认(无告警)。
|
||||
|
||||
**入参(query)**:`device_id`(string,必填)。
|
||||
**出参**:`LivenessOut`(含 `kill_alert_pending` 等)。
|
||||
|
||||
## POST /liveness/ack — 确认已提醒(清掉线告警)
|
||||
客户端已弹过引导 → 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置)。
|
||||
|
||||
**入参(JSON)**:`{ device_id }`。
|
||||
**出参**:`{ "ok": true }`。
|
||||
|
||||
## 错误
|
||||
- `401` 未鉴权 / token 失效。
|
||||
- `422` 缺 `device_id` 或字段类型不符。
|
||||
|
||||
## 说明
|
||||
- **`kill_alert_pending` 与 `liveness_state` 解耦**:worker 检出掉线置 True,心跳恢复(`/heartbeat`)只重置 state、不动此标记,只由 `/liveness/ack` 清 → 规避「服务随 App 重启先发心跳把 state 重置回 alive、客户端进 App 漏看这次掉线」竞态。
|
||||
- 心跳超时阈值由 worker 配置决定,不在接口里。
|
||||
@@ -0,0 +1,31 @@
|
||||
# 内部回写端点(internal 族,pricebot / 发布流程 → app-server)
|
||||
|
||||
> 所属:internal 组(前缀 `/internal`,源 `app/api/internal/`) | 鉴权:**`X-Internal-Secret` 头**(== `settings.INTERNAL_API_SECRET`) | [← 返回 API 索引](./README.md)
|
||||
|
||||
**不是给客户端的接口**:不走用户 JWT。`§6` 是 app-server 透传给 pricebot,这里反过来——pricebot(及发布流程)把少量数据 server→server 回写 app-server 落库。
|
||||
|
||||
## 鉴权(所有 internal 端点共用)
|
||||
请求头带 `X-Internal-Secret: <密钥>`,服务端常量时间比对 `settings.INTERNAL_API_SECRET`:
|
||||
- 密钥**未配置**(默认空)→ `503`(挡裸奔写端点)。
|
||||
- 不匹配 / 缺失 → `401`。
|
||||
- 需 pricebot 与 app-server 两边 `.env` 配**同一** `INTERNAL_API_SECRET` 才生效(未配则 pricebot 侧跳过上报、不影响比价/领券)。
|
||||
|
||||
## 端点
|
||||
|
||||
| 方法 + 路径 | 落库 | 说明 |
|
||||
|---|---|---|
|
||||
| `POST /internal/price-observation` | [`price_observation`](../database/price_observation.md) | 比价 done 帧整批价格事实上报;`(trace_id, platform, scope)` 幂等,返回 `{inserted, skipped}` |
|
||||
| `GET /internal/store-mapping/lookup` | (只读 [`store_mapping`](../database/store_mapping.md)) | 比价前按 `source_platform`+`name`(+`lat`/`lng`)反查各目标平台已沉淀的店铺 id/deeplink;命中→pricebot 直接 deeplink 省现场搜店 |
|
||||
| `POST /internal/store-mapping` | `store_mapping` | 跨平台「同一家店」身份映射上报;`trace_id` 幂等、填空合并,返回 `{inserted, row_id}` |
|
||||
| `POST /internal/store-mapping/invalidate` | `store_mapping` | 标记某平台 `shop_id` 的缓存 deeplink 失效(pricebot 撞错误页回退时报);当前支持 `taobao`/`jd`,其它平台 no-op,返回 `{ok, affected}` |
|
||||
| `POST /internal/launch-confirm-sample` | [`launch_confirm_sample`](../database/launch_confirm_sample.md) | 启动确认窗 LLM 兜底放行后回写样本(host 包 + 弹窗树 + plan + locale);**都上报、不去重**,返回 `{id}` |
|
||||
| `POST /internal/app-version` | [`app_config`](../database/app_config.md)(key=`latest_app_version`) | **发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256;客户端再 `GET /api/v1/platform/app-version` 读做 OTA。也是应急改版本信息(紧急下线/改 `apk_url`)入口 |
|
||||
|
||||
## 错误
|
||||
- `401` 密钥不匹配 / 缺失。
|
||||
- `503` `INTERNAL_API_SECRET` 未配置。
|
||||
- `422` 请求体字段校验失败。
|
||||
|
||||
## 说明
|
||||
- 与不鉴权的比价透传(`compare.py` / `coupon.py`)无关:那两个是**客户端→server** 的透传壳,这里是 **server→server** 的内部写库。
|
||||
- `launch-confirm-sample` 的样本供研发**人工**定期沉淀进 pricebot 的 `launch_confirm.PROFILES` 静态规则(让以后同款启动窗回到快路径,不必再调 LLM)。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 跨表视角。单表字段级细节看同目录 `<表名>.md`(索引见 [README](./README.md))。
|
||||
> 本文专门回答三件「跨表」的事:**① 每块 App 功能用到哪些表 ② 什么操作往哪张表写 ③ 表和表怎么连(join key,含没有外键约束、靠业务字段对齐的语义关联)**。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **37 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 5 张)是两个独立子系统。
|
||||
> **范围**:业务表全部在 `shaguabijia-app-server`(SQLAlchemy 2.0 + SQLite 开发 / PostgreSQL 生产)。`pricebot-backend`(比价/领券 Agent)是纯内存态、**无任何表**;Android 客户端只有 EncryptedSharedPreferences / SharedPreferences、**无关系库**。共 **40 张业务表** + `alembic_version`(框架的迁移版本指针)。注意「比价/领券**过程**」始终在 pricebot 内存态跑、**不落库**——只有**结果**回 app-server 才落库:领券结果落 `coupon_*` 三张今日状态表;比价结果分两路——客户端带 JWT 上报「我的记录」落 `comparison_record`,pricebot 另经 `app/api/internal/` server→server 把客观价格/门店事实落 `price_observation`/`store_mapping`(不鉴权、匿名也记)+ 启动确认窗兜底样本落 `launch_confirm_sample`。此外好友邀请(`invite_*` 2 张)与美团 CPS 群发联盟(`cps_*` 6 张,含落地页微信身份 `cps_wx_user`)是两个独立子系统。无障碍存活监控 `device_liveness`(#65)按用户设备维度记心跳、检掉线召回。
|
||||
|
||||
---
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
| 「上报更低价」提交 / 列表 | [`price_report`](./price_report.md) | 众包纠偏:用户举证某平台更便宜,人工审核发奖 |
|
||||
| (无 App UI)比价 done 后客观价格沉淀 | [`price_observation`](./price_observation.md) | **pricebot server→server 内部上报**;平台/门店视角的到手价事实,匿名也记,与 `comparison_record`(用户视角)互补 |
|
||||
| (无 App UI)跨平台门店身份映射 | [`store_mapping`](./store_mapping.md) | **pricebot 内部上报**;同店在淘宝/美团/京东的 id/名/deeplink,供下次比价 `lookup` 反查省掉现场搜店 |
|
||||
| (无 App UI)启动确认窗兜底样本 | [`launch_confirm_sample`](./launch_confirm_sample.md) | **pricebot 内部上报**;LLM 兜底放行「想要打开 XX」跨 App 启动窗后回写样本(host 包 + 弹窗树 + plan + locale),供研发人工沉淀进 pricebot 规则 |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
@@ -47,6 +48,7 @@
|
||||
| 登录(极光/短信)/ 改资料 / 注销 | [`user`](./user.md) | 登录主体,注册即登录 |
|
||||
| 新手引导是否再展示 | [`onboarding_completion`](./onboarding_completion.md) | 按 设备+账号 去重;登录响应回 `onboarding_completed`,走完引导时标记,跨卸载重装 |
|
||||
| 帮助与反馈 | [`feedback`](./feedback.md) | 含截图,后台人工处理 |
|
||||
| 无障碍存活监控 / 掉线召回 | [`device_liveness`](./device_liveness.md) | 无障碍服务周期心跳;worker 检出心跳超时 → 推送/客户端进 App 弹「开启自启动」引导 |
|
||||
|
||||
### 好友邀请(注册增长)
|
||||
| App 位置 / 动作 | 表 | 说明 |
|
||||
@@ -61,6 +63,7 @@
|
||||
| 运营建微信推广群 | [`cps_group`](./cps_group.md) | 一群一行,`sid`=美团二级渠道追踪位 |
|
||||
| 后台批量生成群发短链 | [`cps_link`](./cps_link.md) | 群×活动 → `/c/{code}`,运营复制到微信群 |
|
||||
| 用户点短链(落地/复制口令) | [`cps_click`](./cps_click.md) | 记 visit/copy 事件,统计 PV/UV |
|
||||
| 微信内打开落地页授权 | [`cps_wx_user`](./cps_wx_user.md) | 服务号网页授权拿 openid(base 静默)/ 昵称头像 unionid(userinfo,点领券触发),记首次来源群 |
|
||||
| 定时拉美团联盟订单对账 | [`cps_order`](./cps_order.md) | `query_order` 按 sid 归群,串成点击→下单→佣金漏斗 |
|
||||
|
||||
### 运营后台 admin(独立子应用 `app/admin/`,端口 8771,独立鉴权)
|
||||
@@ -95,6 +98,9 @@
|
||||
| 看广告时长上报 `POST /ad/watch-report` | `ad_watch_log`(C) | |
|
||||
| 广告 eCPM 上报 `POST /ad/ecpm-report` | `ad_ecpm_record`(C) | |
|
||||
| 信息流广告结算 `POST /ad/feed-reward` | `ad_feed_reward_record`(C)+ granted→`coin_account`(U)+`coin_transaction`(C `feed_ad_reward`) | `client_event_id` 幂等 |
|
||||
| 注册设备 / 更新 push token `POST /device/register` | `device_liveness`(C/U upsert) | `(user_id, device_id)` 幂等;只在传入非空时更新 `registration_id`/版本 |
|
||||
| 无障碍服务心跳 `POST /device/heartbeat` | `device_liveness`(C/U) | 心跳也能自注册;`accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `alive`、清 `notified_at` |
|
||||
| 客户端 ack 掉线提醒 `POST /device/liveness/ack` | `device_liveness`(U) | 清 `kill_alert_pending`(幂等;下次真掉线 worker 再置) |
|
||||
| 比价 done 上报 `POST /compare/record` | `comparison_record`(C 或 U) | `(user_id, trace_id)` 幂等覆盖 |
|
||||
| 领里程碑 `POST /compare/milestone/claim` | `comparison_milestone_claim`(C) | **当前不发币**(coin_awarded=0) |
|
||||
| 支付归因上报 `POST /order/report` | `savings_record`(C `source=compare`) | `(user_id, client_event_id)` 幂等 |
|
||||
@@ -130,22 +136,25 @@
|
||||
| 运营后台建活动 / 建群 | `cps_activity`(C/U) / `cps_group`(C/U) | `app/admin/routers/cps.py`;群含美团平台才分配 `sid` |
|
||||
| 后台批量生成短链 `POST /admin/api/cps/referral-links` | `cps_link`(C) | 每 群×活动 一条;美团经 `sid` 转链拿 `target_url`(同群同活动重复生成产生多条) |
|
||||
| 用户点群发短链 `GET /c/{code}` / `POST /c/{code}/copy` | `cps_click`(C `visit`/`copy`) | 公开端点不鉴权;`group_id`/`sid` 从 link 冗余进来免 join |
|
||||
| 微信落地页授权回调 `GET /wx/oauth/cb` | `cps_wx_user`(C/U upsert) | 按 `openid` 幂等;base 拿 openid,userinfo 补昵称/unionid(非 None 才覆盖);任何失败兜底回落地页不阻断领券 |
|
||||
| 定时拉美团联盟订单对账 | `cps_order`(C/U upsert) | `query_order` 按 `sid` 归群;`order_id` 幂等(状态会变,重复拉则更新) |
|
||||
| pricebot 比价 done 内部上报 `POST /internal/price-observation` | `price_observation`(C 批量) | `(trace_id,platform,scope)` 幂等;**不走 JWT、靠 `X-Internal-Secret`**(未配→503) |
|
||||
| pricebot 比价 done 内部上报 `POST /internal/store-mapping` | `store_mapping`(C/U 填空合并) | `trace_id` 幂等;另有 `lookup` 反查 + `invalidate` deeplink 失效标记(淘宝/京东) |
|
||||
| pricebot LLM 兜底放行启动确认窗后上报 `POST /internal/launch-confirm-sample` | `launch_confirm_sample`(C) | **都上报、不去重**;靠 `X-Internal-Secret`(未配→503) |
|
||||
| 后台 worker 检出心跳超时 `heartbeat_monitor_worker` | `device_liveness`(U `mark_notified`) | 置 `notified` + `notified_at` + `kill_alert_pending=True`;非 C 端、非 admin,定时扫 `list_overdue` |
|
||||
|
||||
> CPS 5 表与本 App `user` **无关**(被推广群的用户在美团下单,不是本 App 注册用户);`price_observation`/`store_mapping` 的 `source_user_id` 是可空旁路(链路不鉴权,匿名也记)。
|
||||
> CPS 6 表与本 App `user` **无关**(被推广群的用户在美团下单 / 在微信落地页授权,不是本 App 注册用户;`cps_wx_user` 是微信 `openid` 维度的另一套用户身份);`price_observation`/`store_mapping`/`launch_confirm_sample` 的 `source_user_id`/`device_id` 是可空旁路(链路不鉴权,匿名也记)。
|
||||
|
||||
---
|
||||
|
||||
## 三、表间关系 & Join Key
|
||||
|
||||
### 硬外键(数据库 FK 约束)
|
||||
- **17 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`。
|
||||
- **18 张用户维度表 `.user_id` → `user.id`**:`coin_account`(同时是 PK)、`coin_transaction`、`cash_transaction`、`withdraw_order`、`wechat_transfer_authorization`(同时是 PK)、`signin_record`、`signin_boost_record`、`user_task`、`comparison_record`、`comparison_milestone_claim`、`savings_record`、`ad_reward_record`、`ad_watch_log`、`ad_ecpm_record`、`ad_feed_reward_record`、`price_report`、`feedback`、`device_liveness`。
|
||||
- `admin_audit_log.admin_id` → `admin_user.id`。
|
||||
- `price_report.comparison_record_id` → `comparison_record.id`(可空:关联记录被删后仍留上报历史)。
|
||||
- **邀请两表** → `user.id`:`invite_relation.inviter_user_id`、`invite_relation.invitee_user_id`(唯一)、`invite_fingerprint.inviter_user_id`——注意 FK 列名是 `inviter`/`invitee_user_id`,不是 `user_id`。
|
||||
- **CPS 5 表 + 比价沉淀 2 表均无硬 FK**(全靠 `sid`/`trace_id` 语义对齐,见下)。
|
||||
- **CPS 6 表 + 比价沉淀 2 表 + `launch_confirm_sample` 均无硬 FK**(全靠 `sid`/`trace_id`/`openid` 语义对齐,见下)。
|
||||
|
||||
### 语义 join key(无 FK 约束,靠业务字段对齐 —— 排障/对账必看)
|
||||
- **`coin_transaction.ref_id` 指向随 `biz_type` 变**:
|
||||
@@ -172,7 +181,7 @@
|
||||
- **里程碑解锁进度不存库**:`comparison_milestone_claim` 只记「哪几档已领」;进度 = `comparison_record` 里 `status='success'` 的 `count`。
|
||||
- **领券三表无硬 FK,全靠软关联**:`coupon_prompt_engagement` / `coupon_daily_completion` / `coupon_claim_record` 的 `user_id` **软指** `user.id`(可空、有登录态才记、不进唯一键、不阻塞判断);`trace_id` **软指** pricebot work_logs(排查回指);唯一键都以 `device_id` + 北京自然日为主(详见 [`coupon_state.md`](./coupon_state.md))。
|
||||
- **`onboarding_completion.(user_id, device_id)`**:`user_id` 语义关联 `user.id`(无硬 FK,同 `coupon_*` 设备表),`device_id` = 客户端硬件级 `ANDROID_ID`(≠ 领券 per-install `device_id`)。登录读、走完引导写,决定是否再展示新手引导。
|
||||
- **CPS 群发 5 表全靠 `sid` / id 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。
|
||||
- **CPS 群发 6 表全靠 `sid` / id / `openid` 语义串联(无硬 FK)**:`cps_link.group_id`→`cps_group.id`、`cps_link.activity_id`→`cps_activity.id`、`cps_click.link_id`→`cps_link.id`、`cps_wx_user.first_group_id`→`cps_group.id`;**点击与订单无法对到单笔**,只在群维度(`sid`)汇合——`cps_order.sid` ≈ `cps_group.sid` ≈ `cps_link.sid` ≈ `cps_click.sid`(仅美团有 sid,淘宝/京东无)。统计按群聚合,故 `group_id`/`sid` 冗余进 `cps_click` 免 join。`cps_wx_user` 按 `openid` 自成用户身份维度,与 `cps_click`/`cps_order` 无 id 级 join。
|
||||
- **比价沉淀两表(`price_observation` / `store_mapping`)**:`trace_id` 软指 pricebot work_logs(与 `comparison_record.trace_id` 同源但不互 join,各存各视角);`source_user_id` / `source_device_id` 软指用户/设备(可空,匿名也记)。`store_mapping.lookup` 靠**店名字符串精确相等** + geo 取最近,非 id 级 join。
|
||||
|
||||
### ER 关系(文字版)
|
||||
@@ -182,7 +191,8 @@ user ─1:1─ wechat_transfer_authorization
|
||||
user ─1:N─ { coin_transaction, cash_transaction, withdraw_order, signin_record,
|
||||
signin_boost_record, user_task, comparison_record, comparison_milestone_claim,
|
||||
savings_record, ad_reward_record, ad_watch_log, ad_ecpm_record, ad_feed_reward_record,
|
||||
price_report, feedback }
|
||||
price_report, feedback, device_liveness }
|
||||
(device_liveness 硬 FK; (user_id,device_id) 唯一)
|
||||
user ─1:N─ onboarding_completion (user_id, 无硬 FK; (user_id,device_id) 去重)
|
||||
comparison_record ─1:N─ price_report (comparison_record_id, 可空)
|
||||
admin_user ─1:N─ admin_audit_log
|
||||
@@ -192,8 +202,10 @@ coupon_prompt_engagement / coupon_daily_completion / coupon_claim_record
|
||||
user ─1:N─ invite_relation (inviter_user_id 硬 FK); invitee_user_id ─1:1─ user (唯一硬 FK)
|
||||
user ─1:N─ invite_fingerprint (inviter_user_id 硬 FK)
|
||||
cps_activity / cps_group ──语义(无FK)──▶ cps_link ─1:N─ cps_click
|
||||
(CPS 5 表自成子系统; cps_order 经 sid 归群对账, 与本 App user 无关)
|
||||
(CPS 6 表自成子系统; cps_order 经 sid 归群对账, 与本 App user 无关)
|
||||
cps_group ◀──语义(无FK)── cps_wx_user (first_group_id; openid 维度的微信落地页用户身份)
|
||||
price_observation / store_mapping (独立, 无硬 FK; 维度=trace_id, source_user/device 仅软关联; pricebot 内部上报)
|
||||
launch_confirm_sample (独立, 无硬 FK; 都上报不去重; pricebot 内部上报启动确认窗兜底样本)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
> 数据库:SQLite 起步(`data/app.db`),生产可切 PostgreSQL(改 `DATABASE_URL`)。
|
||||
> ORM:SQLAlchemy 2.0(`app/models/`),迁移:Alembic(`alembic/versions/`,`render_as_batch` 兼容 SQLite)。
|
||||
> 金额字段一律存**整数**:金币=个数,现金=**分**(`*_cents`)。时间列 `DateTime(timezone=True)`。
|
||||
> 最后更新:2026-06-17(补全 9 张此前缺文档的表:CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping`;并全表 review 对齐 model——`signin_record` 改 7 天循环、`coupon_prompt_engagement` 加 `package` 列按 App 频控、`user` 补 `username`/`invite_code`/`debug_trace_enabled` 等;含 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
> 最后更新:2026-06-23(补 3 张表文档:`device_liveness`(#65 无障碍存活监控)、`cps_wx_user`(CPS 落地页微信身份)、`launch_confirm_sample`(启动确认窗兜底样本);`comparison_record` 补 `input_tokens`/`output_tokens`。上一次 2026-06-17 补全 CPS 群发 5 张 `cps_*` + 好友邀请 2 张 `invite_*` + 比价沉淀 `price_observation`/`store_mapping` 并全表 review 对齐 model;含 [OVERVIEW 总览](./OVERVIEW.md))
|
||||
|
||||
> 🧭 **先看 [OVERVIEW.md — 表 × 功能 × 关系](./OVERVIEW.md)**:跨表的「每块功能用哪些表 / 什么操作写哪张表 / 表间 join key」都在那;本页只做**单表索引**,点进每张表的详情看字段级说明。
|
||||
|
||||
---
|
||||
|
||||
## 表总览(37 张业务表 + `alembic_version` 框架表)
|
||||
## 表总览(40 张业务表 + `alembic_version` 框架表)
|
||||
|
||||
### 账号 / 反馈
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -17,6 +17,7 @@
|
||||
| `user` | 用户(登录主体);几乎所有表的外键宿主 | `models/user.py` | [详情](./user.md) |
|
||||
| `onboarding_completion` | 新手引导完成标记(按 设备+账号 去重,登录时据此跳过引导) | `models/onboarding.py` | [详情](./onboarding_completion.md) |
|
||||
| `feedback` | 用户帮助与反馈(含截图) | `models/feedback.py` | [详情](./feedback.md) |
|
||||
| `device_liveness` | 无障碍存活监控(心跳超时检出 + 掉线召回;`(user_id, device_id)` 唯一) | `models/device.py` | [详情](./device_liveness.md) |
|
||||
|
||||
### 好友邀请(注册增长)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -53,6 +54,7 @@
|
||||
|---|---|---|---|
|
||||
| `price_observation` | 价格观测(平台/门店视角的到手价事实,匿名也记;`comparison_record` 的客观对照) | `models/price_observation.py` | [详情](./price_observation.md) |
|
||||
| `store_mapping` | 跨平台「同一家店」id/名/deeplink 身份映射(淘宝/美团/京东,供 lookup 反查省现场搜店) | `models/store_mapping.py` | [详情](./store_mapping.md) |
|
||||
| `launch_confirm_sample` | 启动确认窗 agent 兜底样本(pricebot LLM 放行跨 App 启动窗后回写,供研发人工沉淀规则) | `models/launch_confirm_sample.py` | [详情](./launch_confirm_sample.md) |
|
||||
|
||||
### 领券(每日领券联动 · 今日状态)
|
||||
| 表 | 用途 | 模型 | 文档 |
|
||||
@@ -73,6 +75,7 @@
|
||||
| `cps_group` | 推广微信群(`sid`=美团二级渠道追踪位,订单按 sid 归群对账) | `models/cps_group.py` | [详情](./cps_group.md) |
|
||||
| `cps_link` | 群发短链 `/c/{code}`(群×活动生成,302 跳/淘宝落地页) | `models/cps_link.py` | [详情](./cps_link.md) |
|
||||
| `cps_click` | 短链点击事件流(visit/copy,PV/UV 漏斗源) | `models/cps_link.py` | [详情](./cps_click.md) |
|
||||
| `cps_wx_user` | CPS 落地页微信用户身份(openid 维度;网页授权拿 openid/昵称/unionid,记首次来源群) | `models/cps_wx_user.py` | [详情](./cps_wx_user.md) |
|
||||
| `cps_order` | 美团联盟 query_order 拉回的对账订单(按 sid 归群算佣金) | `models/cps_order.py` | [详情](./cps_order.md) |
|
||||
|
||||
### 首页门面数据
|
||||
@@ -93,7 +96,7 @@
|
||||
## 通用约定
|
||||
|
||||
- **主键**:`id` Integer autoincrement;例外:`coin_account` / `wechat_transfer_authorization` 的 `user_id` 既是主键也是外键(一用户一行),`app_config` 用 `key` 作主键。
|
||||
- **外键**:所有用户维度表 `user_id` → `user.id` 且建 index;`admin_audit_log.admin_id` → `admin_user.id`;`price_report.comparison_record_id` → `comparison_record.id`。
|
||||
- **外键**:所有用户维度表 `user_id` → `user.id` 且建 index(含 `device_liveness`);`admin_audit_log.admin_id` → `admin_user.id`;`price_report.comparison_record_id` → `comparison_record.id`。
|
||||
- **金额**:整数;金币计数,现金/价格存「分」(`*_cents`)。汇率 `10000 金币 = 1 元 = 100 分`。
|
||||
- **时间**:`created_at` 等用 `DateTime(timezone=True)` + `server_default=func.now()`;业务"今天"按**北京时间**(`core/rewards.cn_today`),跨天计数用「日期串」列(`reward_date` / `watch_date` / `report_date`,`String(10)` 存 `YYYY-MM-DD`)等值查,**不在 SQL 里做跨时区 date 比较**(SQLite 不可靠)。
|
||||
- **JSON 列**:`JSON().with_variant(JSONB(), "postgresql")` —— PG 上 JSONB(可建 GIN 索引),SQLite 退化为通用 JSON(否则 `create_all` 编译报错);`feedback.images` 例外,用通用 `JSON`。
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
| `comparison_results` | JSON(PG: JSONB) | NOT NULL, default [] | 逐平台对比 `[{platform_id,platform_name,package,price(元),is_source,rank,coupon_saved(元),coupon_name,applied_coupons}]`;`coupon_saved`=该平台主优惠额(美团红包/淘宝平台红包/京东百亿补贴,只取一笔),`coupon_name`=优惠来源名(展示用),`applied_coupons`=`[{name,amount}]` 多券明细 |
|
||||
| `skipped_dish_names` | JSON(PG: JSONB) | NOT NULL, default [] | 被跳过的菜名 |
|
||||
| `raw_payload` | JSON(PG: JSONB) | nullable | 客户端原始上报全量(calibration + done.params),取数兜底 |
|
||||
| `input_tokens` | Integer | nullable | 本次 LLM 累计输入 token = Σ `llm_calls[].usage.prompt_tokens`(server 收上报后从 `llm_calls` 累加;旧记录/未采集为 null) |
|
||||
| `output_tokens` | Integer | nullable | 本次 LLM 累计输出 token = Σ `llm_calls[].usage.completion_tokens`(同上) |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 时间 |
|
||||
|
||||
> `ordered`(已下单)是**瞬态字段**,不在表里:`list_records` 读取时按 `store_name ∈ 该用户 source='compare' 的 savings_record.shop_name 集合` 现挂到实例上供出参用。
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# cps_wx_user — CPS 落地页微信用户身份(openid 维度)
|
||||
|
||||
> 模型 `app/models/cps_wx_user.py`(`CpsWxUser`) · 仓库 `app/repositories/cps_wx_user.py`(`get_by_openid` / `upsert`) · 接口 `GET /wx/oauth/cb`(微信网页授权回调,`app/api/v1/cps_redirect.py`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
用户在微信内打开群发短链落地页 [`/c/{code}`](./cps_link.md),经服务号网页授权拿到的微信身份,按 `openid` 唯一存一行。在 CPS 群发漏斗里,它给点击/领券补上**「是谁」**这一维度(此前 [`cps_click`](./cps_click.md) 只有 PV/UV 近似)。授权分两阶:`snsapi_base` 静默拿 `openid`(统计主力);用户点「领券」触发 `snsapi_userinfo` 才补 `nickname`/`headimgurl`/`unionid`。
|
||||
|
||||
> 注意:**下单归因到人**需 user-level sid(另一期),本表只承载身份 + 领券/点击侧,**不与 `cps_order` 关联**。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(upsert)**:`upsert`(`get_by_openid` 查 → 无则建、有则补),由 `wx_oauth_cb`(`GET /wx/oauth/cb`)在微信授权回调里调。首次建行记来源 `first_code`/`first_group_id`;已存在则**仅在传入非 None 时**更新 `nickname`/`headimgurl`/`unionid`(`base` 阶段为 None,不覆盖已有画像),并显式刷新 `last_seen`(`onupdate` 仅字段有变更才触发,故 base-only 回访也要手动 set)。任何授权失败兜底回落地页,**绝不阻断领券**。
|
||||
- **R**:`get_by_openid`(upsert 内部按 openid 查)。
|
||||
- **D**:无。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `openid` | String(64) | **UNIQUE**, index, NOT NULL | 服务号下用户唯一标识(网页授权拿到);upsert 主键 |
|
||||
| `unionid` | String(64) | index, nullable | 开放平台 unionid(服务号绑开放平台 + `scope=userinfo` 才有);跨 App/服务号统一用户 |
|
||||
| `nickname` | String(128) | nullable | 昵称;`userinfo` 授权后才有(base 阶段为空) |
|
||||
| `headimgurl` | String(512) | nullable | 头像 URL;同 `nickname`,userinfo 阶段才有 |
|
||||
| `first_code` | String(16) | nullable | 首次进入来源短链 code(从哪个群的链接授权进来) |
|
||||
| `first_group_id` | Integer | index, nullable | 首次来源群 = `cps_group.id`(语义关联,非 FK);群内用户级统计用 |
|
||||
| `first_seen` | DateTime(tz) | server_default now() | 首次授权时间 |
|
||||
| `last_seen` | DateTime(tz) | server_default now(), onupdate now() | 最近一次落地(upsert 时显式刷新) |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `first_group_id` → `cps_group.id`(**语义关联,无 ForeignKey**;记首次来源群)。
|
||||
- 与 [`cps_click`](./cps_click.md) **无 id 级 join**:点击事件流目前不回写 openid 维度的用户统计,两者各按各的维度看(cps_click 看 PV/UV,本表看「谁」)。
|
||||
- 与本 App `user` 表**无关**:微信落地页用户不是本 App 注册用户(同 CPS 其它表)。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;UNIQUE index `openid`;index `unionid`、`first_group_id`。
|
||||
|
||||
## 注意
|
||||
- **两阶授权**:`base`(静默)只能拿 `openid`,不弹窗、不打扰,用作统计主力;`userinfo`(弹授权页)拿昵称头像 + unionid,只在用户点「领券」这种有交互的时刻触发(避免微信快照页),授权回流带 `?authed=1` 自动衔接复制口令。详见 `cps_redirect.py` 落地页逻辑。
|
||||
- **`unionid` 不一定有**:需服务号已绑微信开放平台 + 走到 `userinfo` 授权;base 阶段、未绑开放平台时为 None。
|
||||
@@ -0,0 +1,43 @@
|
||||
# device_liveness — 无障碍存活监控(心跳 + 掉线召回)
|
||||
|
||||
> 模型 `app/models/device.py`(`DeviceLiveness`) · 仓库 `app/repositories/device.py`(`register_or_update` / `touch_heartbeat` / `list_overdue` / `mark_notified` / `get_device` / `ack_kill_alert`) · 接口 用户 `POST /api/v1/device/register`、`POST /api/v1/device/heartbeat`、`GET /api/v1/device/liveness`、`POST /api/v1/device/liveness/ack`(`app/api/v1/device.py`);后台 worker `heartbeat_monitor_worker` · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
每行 = 一个用户的一台设备(per-install,`(user_id, device_id)` 唯一)。客户端无障碍服务存活时周期上报心跳刷新 `last_heartbeat_at`;App 前台/登录拿到极光 push token 时上报 `registration_id`。后端 `heartbeat_monitor_worker` 扫「曾保护过、现已心跳超时」的设备,推送(或本期仅终端打印)提醒用户重开无障碍。**表名不叫 `device`**:它存的不是设备信息(品牌/型号),而是**无障碍存活状态**。#65 新增。
|
||||
|
||||
> 设计见 spec `spec/accessibility-liveness-push.md`(推送版)+ `spec/accessibility-liveness-pull-prompt.md`(后置 pull 提醒版)。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C / U(upsert)**:`register_or_update`(`POST /device/register`,App 拿到 push token 时调)按 `(user_id, device_id)` upsert,只在传入非空时更新 `registration_id`/`platform`/`app_version`;`touch_heartbeat`(`POST /device/heartbeat`,无障碍服务存活时周期调,**心跳也能自注册**)在 `accessibility_enabled=true` 时刷 `last_heartbeat_at`、置 `ever_protected=true`、状态机重置回 `alive`、清 `notified_at`(掉线恢复→下次再断才再推一条)。
|
||||
- **U(worker)**:`mark_notified`(`heartbeat_monitor_worker` 检出掉线后)置 `liveness_state='notified'` + `notified_at` + **`kill_alert_pending=True`**;`ack_kill_alert`(`POST /device/liveness/ack`,客户端弹过引导后)清 `kill_alert_pending`(幂等)。
|
||||
- **R**:`list_overdue`(worker 扫描:`ever_protected=true` + `liveness_state='alive'` + `last_heartbeat_at` 早于 `now - timeout`)→ 掉线设备列表;`get_device`(`GET /device/liveness`,客户端进 App 拉本机是否被判掉线过)。
|
||||
- **D**:无。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `user_id` | Integer | **FK→user.id**, index, NOT NULL | 归属用户 |
|
||||
| `device_id` | String(128) | index, NOT NULL | 客户端 `DeviceId.get()` 生成的 per-install id(如 `device_Pixel_ab12cd34`) |
|
||||
| `registration_id` | String(64) | nullable | 极光推送 registration id;`JCollectionAuth` 同意后才下发,拿到才填 |
|
||||
| `platform` | String(16) | NOT NULL, default `android` | |
|
||||
| `app_version` | String(32) | nullable | 上报时 App 版本 |
|
||||
| `ever_protected` | Boolean | NOT NULL, default false | 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)。`list_overdue` 的过滤前提 |
|
||||
| `last_heartbeat_at` | DateTime(tz) | index, nullable | 最近一次 service 心跳时间(存活证明);超时即视为保护掉线 |
|
||||
| `last_report_protection_on` | Boolean | NOT NULL, default false | 最近一次上报的无障碍开关状态(观测用) |
|
||||
| `liveness_state` | String(16) | NOT NULL, default `unknown` | 状态机:`unknown` → `alive`(收到 service 心跳)→ `silent`/`notified`(扫描发现超时并已推送);心跳恢复 handler 重置回 `alive` |
|
||||
| `notified_at` | DateTime(tz) | nullable | 最近一次推送告警时间 |
|
||||
| `kill_alert_pending` | Boolean | NOT NULL, default false | 掉线告警「待客户端提醒」标记(后置 pull 版)。**与 `liveness_state` 解耦**:worker 检出掉线即置 True;`touch_heartbeat`(心跳恢复)不动它;只由客户端 ack 清 |
|
||||
| `created_at` | DateTime(tz) | server_default now() | |
|
||||
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
|
||||
|
||||
## 关系 / Join Key
|
||||
- `user_id` → `user.id`(多对一,**硬外键**)。
|
||||
- 与 `comparison_record.device_id` 等表的 `device_id` **同源**(都来自客户端 per-install id),但本表无 id 级 join。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `user_id`、`device_id`、`last_heartbeat_at`;UNIQUE(`user_id`, `device_id`) = `uq_device_liveness_user_device`(一用户一设备一行,upsert 幂等键)。
|
||||
|
||||
## 注意
|
||||
- **`kill_alert_pending` 为什么和 `liveness_state` 解耦**:服务随 App 重启会先发心跳把 `state` 重置回 `alive`,若复用 `state` 判「待提醒」,客户端进 App 这一刻可能恰好已被重置 → 漏看这次掉线。故另设一个只由 worker 置、只由客户端 ack 清的标记,规避竞态。
|
||||
- **`list_overdue` 本期不要求有 `registration_id`**:本期只做终端打印检测、未真推送,没接极光 token 的设备也要检出。
|
||||
- 心跳超时阈值由 worker 的 `timeout_minutes` 决定(不在表里)。
|
||||
@@ -0,0 +1,36 @@
|
||||
# launch_confirm_sample — 启动确认窗 agent 兜底样本
|
||||
|
||||
> 模型 `app/models/launch_confirm_sample.py`(`LaunchConfirmSample`) · 仓库 `app/repositories/launch_confirm_sample.py`(`insert_sample`) · 接口 `POST /internal/launch-confirm-sample`(pricebot→app-server,`app/api/internal/launch_confirm.py`,`X-Internal-Secret` 校验) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
|
||||
|
||||
pricebot 的 `launch_confirm_agent` 每次「静态 PROFILES 没认出、靠 LLM 兜底放行」一个跨 App 启动确认窗(繁体 / 英文 / 小语种 / ROM 改版的「想要打开 XX」弹窗)时,把完整样本 server→server 落这里。研发定期**人工**把 `exec_success=true` 的样本沉淀进 pricebot 的 `launch_confirm.PROFILES`(静态快路径),让以后同款窗不必再调 LLM(回到快路径)。
|
||||
|
||||
> **不是给客户端的接口**:与登录无关、不鉴权,靠共享密钥头 `X-Internal-Secret`(== `settings.INTERNAL_API_SECRET`)校验,未配置则 503。
|
||||
|
||||
## 用在哪 / 增删改查
|
||||
- **C(插入)**:`insert_sample`(`POST /internal/launch-confirm-sample`)。**都上报、不去重**(by design):同一种窗多台机器上报多条,研发按 `host_package`/`system_locale` 聚合后人工筛。大字段截断到列长上限(繁体长标题等),空串入库为 None。
|
||||
- **R / U / D**:**无**(研发查库靠 SQL 直接看 + 人工沉淀,不经本服务接口)。
|
||||
|
||||
## 字段
|
||||
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|
||||
|---|---|---|---|
|
||||
| `id` | Integer | PK, autoincrement | |
|
||||
| `created_at` | DateTime(tz) | server_default now(), index | 上报时间 |
|
||||
| `trace_id` | String(64) | index, nullable | pricebot 侧 trace_id:回指原始 trace,可去 `price.shaguabijia.com` 查完整上下文 |
|
||||
| `device_id` | String(64) | index, nullable | 上报设备 |
|
||||
| `host_package` | String(128) | index, nullable | 弹窗宿主包(= 触发判据,也是研发沉淀进 `PROFILES.host_packages` 的键)。**按它聚合人工筛** |
|
||||
| `target_app` | String(128) | nullable | 被打开的目标 App 包名(标题里「想要打开 X」的 X 是变量,据此把它从 sign 里挖空) |
|
||||
| `system_locale` | String(32) | index, nullable | 系统语言地区(`zh-TW`/`en-US`…)—— 多语言问题的核心维度。⚠️ 前端 `AgentStepRequest` 暂未上报设备信息 → **现恒 None**,待前端加 `device_info` 字段后回填 |
|
||||
| `exec_success` | Boolean | NOT NULL, default false | 这次兜底是否成功放行(弹窗消失=true;放弃 / LLM 没认出=false)。**研发只沉淀 true 的** |
|
||||
| `dialog_title` | String(256) | nullable | 弹窗标题全文(研发据此提 `launch_sign`),如「『傻瓜比价』想要開啟『京東』」 |
|
||||
| `payload` | JSON(PG: JSONB) | nullable | 完整样本大 JSON:`{is_launch_confirm, llm_model, llm_reason, plan, dialog_tree, device_info:{screen,locale,brand,model,android_version,rom_version}}`。加字段不必迁移 schema |
|
||||
|
||||
## 关系 / Join Key
|
||||
- 无外键。`trace_id` 软指 pricebot trace(回指排查),`device_id` 软指设备;均不与本服务其它表 join。
|
||||
|
||||
## 索引与约束
|
||||
- PK `id`;index `created_at`、`trace_id`、`device_id`、`host_package`、`system_locale`。
|
||||
- **无唯一约束**(by design 不去重,见上)。
|
||||
|
||||
## 注意
|
||||
- **PG 上 `payload` 用 JSONB**(可建 GIN 索引),SQLite(本地/测试)退化为通用 `JSON`(同 `price_observation`、`comparison_record`)。
|
||||
- **为什么大字段塞 `payload` 而非拆列**:弹窗树快照 + LLM plan + 设备信息都是「研发偶尔人工看」的兜底数据,拆列会让每次扩样本字段都要迁移 schema;只把聚合/筛选要用的维度(`host_package`/`system_locale`/`exec_success`)提成结构化列建索引。
|
||||
+11
-17
@@ -92,23 +92,15 @@ alembic upgrade head
|
||||
- 生成后**手动把文件名前缀去掉**(内部 id 别动);或
|
||||
- 在 `alembic.ini` 配 `file_template = %%(slug)s`(注意 ini 里 `%` 要写成 `%%`)让以后直接生成无前缀文件名(缺点:同名 slug 会冲突,描述写具体点)。
|
||||
|
||||
## 6. 当前迁移链(12 条,含合并迁移)
|
||||
```
|
||||
init_user_table ← 起点(down_revision=None)
|
||||
→ welfare_tables_coin_account_coin_txn 金币账户/金币流水/签到/任务表
|
||||
→ cash_transaction_table 现金流水表
|
||||
→ savings_record_table 省钱明细表
|
||||
→ withdraw_order_and_user_openid 提现单表 + user.wechat_openid
|
||||
→ user_wechat_nickname_avatar user 微信昵称/头像字段
|
||||
→ unique_wechat_openid user.wechat_openid 唯一约束
|
||||
→ savings_shop_dishes 省钱明细 shop_name + dishes
|
||||
→ ad_reward_record 看广告发奖记录表
|
||||
→ (此处分叉,两条并行 head)
|
||||
├→ feedback_table 帮助与反馈表(PR #4)
|
||||
└→ ef96beb47b1e_convert_savings_record_dishes_from_json dishes 列改 JSONB(PR #6)
|
||||
→ f01db5d77dac_merge_pg_jsonb_and_feedback_heads (head,最新) 合并两条分叉
|
||||
```
|
||||
> 顺序以 `alembic history` 输出为准(按 `down_revision` 链,不是文件名字母序)。
|
||||
## 6. 当前迁移链(规模与当前 head)
|
||||
- `alembic/versions/` 当前约 **78 个迁移文件**(含 14 个合并迁移),从 `init_user_table`(`down_revision=None`)起一路接到当前单一 head。**别背具体链**——它随每次改表增长,看实时输出:
|
||||
```bash
|
||||
ls alembic/versions/*.py | wc -l # 迁移文件数
|
||||
alembic history # 完整链(按 down_revision,不是文件名字母序)
|
||||
alembic heads # 当前 head(正常只 1 个)
|
||||
```
|
||||
- **当前 head**(2026-06-23):`4dc2af7ebe74`(文件 `4dc2af7ebe74_merge_device_65_and_comparison_token_.py`)——它合并了今日两条并行链:`comparison_token_cols`(#a584e1f,`comparison_record` 加 `input_tokens`/`output_tokens`)与 #65 device 链末端(`device_liveness` 表 + `ad_feed_reward_trace_id`)。即 `down_revision = ('comparison_token_cols', 'ad_feed_reward_trace_id')`。
|
||||
- 频繁多 head 是常态(多人/多分支并行改表),靠合并迁移收敛回单 head,详见下面「踩坑 → 多 head 分叉」。
|
||||
|
||||
## 7. 踩坑
|
||||
|
||||
@@ -128,6 +120,8 @@ please specify a specific target revision, '<branchname>@head'...
|
||||
- PR #6 (PG/JSONB) 加了 `ef96beb47b1e`,`down_revision='c8d9e0f1a2b3'`(也是 ad_reward) ← 跟 #4 同源,撞车
|
||||
- 两个 PR 都合进 main 后,`alembic heads` 出现两个
|
||||
|
||||
> 此后这种合并是常态,每次都用 `alembic merge` 收敛。最近一次(2026-06-23):#65 device 链(`device_table` → `device_kill_alert_pending`,经 `ad_feed_reward_trace_id`)与 `comparison_token_cols`(`comparison_record` 加 token 列)并行,用 `4dc2af7ebe74_merge_device_65_and_comparison_token_.py` 合成单一 head。
|
||||
|
||||
**修复**:
|
||||
```bash
|
||||
alembic heads # 列出冲突的两个 head id
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# CPS 发券分发 + 微信授权 系统说明(交接文档)
|
||||
|
||||
> 给接手人。读完应能:看懂系统、带运营走完"设群→建活动→生成链接→看统计"、接微信授权、排障、知道边界。
|
||||
> 代码引用一律用「文件:符号」(不写行号,重构后符号比行号稳)。
|
||||
|
||||
---
|
||||
|
||||
## 一、这是什么 / 业务目标
|
||||
|
||||
傻瓜比价的 **CPS 群发分发**系统:运营在 admin 后台建「群」、建「活动」(美团/淘宝/京东的券)、生成带渠道追踪的**落地页短链 `/c/{code}`** 发到各微信群;用户点链接 → 落地页 → 跳转下单或复制淘口令领券 → 后端记点击、(美团)拉订单对账、(微信内)拿 openid 做用户级统计。
|
||||
|
||||
**目标**:把券发出去 + 把「点击 → 下单 → 佣金」漏斗、「哪个群/哪个微信用户领了券」统计出来,指导投放。
|
||||
|
||||
---
|
||||
|
||||
## 二、两套入口 / 两个域名(先建立全局)
|
||||
|
||||
| | 域名 | 反代 | 代码 | 鉴权 |
|
||||
|---|---|---|---|---|
|
||||
| 运营后台 | admin-web.shaguabijia.com | admin 子应用 :8771 | `admin/routers/cps.py`,前缀 `/admin/api/cps` | admin JWT(operator/finance) |
|
||||
| 用户落地页 | **coupon.shaguabijia.com** | app-server :8770 | `api/v1/cps_redirect.py`,**无前缀** | **公网无鉴权**(群里谁都能点) |
|
||||
|
||||
> 两个域名都全反代到 app-server/admin。落地页域名 `coupon.shaguabijia.com` 由 `settings.CPS_REDIRECT_BASE` 配置,生成短链时拼前缀。
|
||||
|
||||
---
|
||||
|
||||
## 三、数据流
|
||||
|
||||
```
|
||||
[admin] 建群(cps_group) → 建活动(cps_activity) → 生成链接(cps_link, 得 /c/{code})
|
||||
│ 复制发到微信群
|
||||
▼
|
||||
[用户点 /c/{code}] cps_landing:
|
||||
├─ (微信内+开关开+无cookie) 302 跳微信授权 → 回调换 openid → 存 cps_wx_user + 种 cookie → 跳回
|
||||
├─ 记一条点击 cps_click(visit, 带 openid)
|
||||
└─ 美团/京东:302 跳 target_url | 淘宝:返回 H5 落地页(图+复制淘口令按钮)
|
||||
│ (淘宝)点复制 → POST /c/{code}/copy 记 copy
|
||||
▼
|
||||
[美团] 运营在 admin 点「刷新对账」→ query_order 拉订单 → cps_order(按 sid 归群)
|
||||
▼
|
||||
[admin 统计] 对账统计页 + 群详情(折线图 / 每日明细 / 群内微信用户)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、数据表(6 张,均在 `app/models/cps_*`)
|
||||
|
||||
| 表 | 模型 | 职责 / 关键字段 |
|
||||
|---|---|---|
|
||||
| cps_group | `CpsGroup` | 群。`name` / `platforms`(多选 meituan/taobao/jd) / `sid`(美团渠道追踪位,仅美团群有;纯淘宝/京东为空) / `member_count` / `status`(active/archived) |
|
||||
| cps_activity | `CpsActivity` | 可推广活动池。`platform` / `name` / `act_id`(美团活动物料ID) / `product_view_sign`(美团商品券) / `payload`(淘宝整段淘口令 / 京东链接) / `image_url`(淘宝落地页主视觉图,绝对URL) |
|
||||
| cps_link | `CpsLink` | 群发短链。`code`(短码,`/c/{code}`) / `group_id` / `activity_id` / `sid` / `target_url`(美团短链/京东链接/淘宝淘口令) / `platform` |
|
||||
| cps_click | `CpsClick` | 点击事件。`link_id` / `group_id` / `sid` / `event_type`(**visit**=进页/被跳转,**copy**=淘宝点复制口令) / `ip` / `ua` / **`openid`**(微信授权拿到,非微信/未授权为空) |
|
||||
| cps_order | `CpsOrder` | 美团订单(query_order 拉回)。`order_id`(唯一,upsert) / `sid`(按它归群) / `pay_price_cents` / `commission_cents`(预估佣金) / `refund_profit_cents`(退款佣金) / `mt_status`(2付款3完成4取消5风控6结算) / `pay_time` |
|
||||
| cps_wx_user | `CpsWxUser` | 微信用户。`openid`(唯一) / `unionid` / `nickname` / `headimgurl` / `first_group_id`(首次授权来源群) |
|
||||
|
||||
> 金额**全程存「分」**,前端 ÷100 显示「元」。时间统一 tz-aware UTC,展示按北京。
|
||||
|
||||
---
|
||||
|
||||
## 五、三平台差异(最关键的一张表)
|
||||
|
||||
| | 美团 | 淘宝 | 京东 |
|
||||
|---|---|---|---|
|
||||
| 链接生成 | 调 `meituan.get_referral_link` 转链(**需群 sid**) | 直接用活动 `payload`(淘口令) | 直接用活动 `payload`(链接) |
|
||||
| 落地页行为 | 302 跳美团短链 | **H5 落地页**(主视觉图 + 复制淘口令按钮) | 302 跳京东链接 |
|
||||
| 对账 | ✓ `query_order` 拉订单/佣金 | ✗ 无开放 API | ✗ 无开放 API |
|
||||
| 能统计到 | 点击 + 下单 + 佣金 | 点击 + 复制次数 | 点击 |
|
||||
|
||||
> 淘宝/京东订单类指标(有效单/成交额/佣金)在统计页显示「-」(无法对账)。只有美团群(有 sid)有订单数据。
|
||||
|
||||
---
|
||||
|
||||
## 六、运营操作流程(带新人就照这个)
|
||||
|
||||
1. **建群**:CPS 分发 → 群管理 → 新建群。选这个群发哪些平台的券(可多选);含美团时可填 sid(留空自动 `g<id>`)。
|
||||
2. **建活动**:活动管理 → 新建活动。按平台填:美团填 `actId`(联盟「我要推广-活动推广」第一列) 或 商品券 `productViewSign`;淘宝填整段淘口令 + **必须上传落地页图**;京东填推广链接。
|
||||
3. **生成链接**:群管理 → 某群 → 「生成链接」→ 勾选要发的活动(只列该群平台范围内的)→ 每个活动出一条 `https://coupon.shaguabijia.com/c/{code}` → 复制发到群里。
|
||||
4. **对账(仅美团,finance 角色)**:对账统计 → 「刷新对账(拉美团订单)」→ 拉近 N 天订单入库。**非实时,要手动点**。
|
||||
5. **看统计**:对账统计页看各群点击/独立/复制/有效单/取消风控/成交额/预估佣金/结算佣金;**点群名进群详情** → 折线图(点击趋势,天/小时)+ 每日明细大表格(点击+订单按天,含退款/净佣金)+ 群内微信用户(头像/昵称/领券次数)。
|
||||
|
||||
---
|
||||
|
||||
## 七、微信授权(用户级统计)
|
||||
|
||||
### 目的
|
||||
用户在微信内打开落地页时,拿到 `openid`(识别这个人) + 昵称头像,做「每个微信用户在每个群领没领券」的用户级统计。
|
||||
|
||||
### 链路
|
||||
```
|
||||
/c/{code} (微信内 + 开关开 + 无 wx_openid cookie)
|
||||
→ 302 跳微信 base 授权(snsapi_base,静默不打断)
|
||||
→ 微信回调 /wx/oauth/cb?code=..&state=base:{code}
|
||||
→ wx_oauth.exchange_code 换 openid → cps_wx_user_repo.upsert 存用户 + 关联群 → 种 wx_openid cookie(30天)
|
||||
→ 302 跳回 /c/{code}(这次有 cookie,正常展示,记 visit 带 openid)
|
||||
[淘宝群] 用户点「复制口令」→ 若还没昵称头像 → 跳 snsapi_userinfo 授权(用户点击=交互触发,避免微信"快照页")
|
||||
→ 回调拉 userinfo 补 nickname/headimgurl → 跳回落地页自动复制
|
||||
```
|
||||
代码:`cps_redirect.py:cps_landing` / `cps_redirect.py:wx_oauth_cb` / `cps_redirect.py:_taobao_landing_html`(注入 userinfo 授权链接);微信调用封装 `integrations/wx_oauth.py`。
|
||||
|
||||
### 三个硬前提
|
||||
1. **必须是已认证服务号**(订阅号、未认证都不支持网页授权 → 报 10005)。
|
||||
2. **网页授权域名** = `coupon.shaguabijia.com`,在服务号后台「设置与开发→公众号设置→功能设置→网页授权域名」配置 + 放校验文件。校验文件由本服务端点 serve(见 `cps_redirect.py` 里 `MP_verify_*.txt` 路由,换号/换文件时改这里返回的串)。
|
||||
3. **凭证 + 开关**:`.env` 配 `WX_MP_APPID` / `WX_MP_SECRET`(服务号的,**≠** 微信支付那个 `WECHAT_APP_ID`);总开关 `WX_MP_OAUTH_ENABLED=true`。开关默认关 → 落地页走原逻辑、不拿 openid(认证未就绪时用)。判定见 `config.py:Settings.wx_oauth_active`。
|
||||
|
||||
### 群内微信用户怎么算(重要,踩过坑)
|
||||
`admin/repositories/cps.py:group_wx_users` 按「**该群有带 openid 的点击**」(cps_click.group_id + openid)算群内用户,再关联 cps_wx_user 拿画像。**不是**按 `cps_wx_user.first_group_id`——因为一个用户(cookie 已存)会去多个群,first_group_id 只记首次授权群,会漏算。
|
||||
- **美团/京东群只有 openid、没有昵称头像**:它们是 302 跳转、没有"点复制领券"那一步,触发不了 userinfo。只有**淘宝群**点复制口令时才补昵称头像。
|
||||
|
||||
---
|
||||
|
||||
## 八、端点清单
|
||||
|
||||
### admin(`/admin/api/cps`,operator/finance 鉴权)— `admin/routers/cps.py`
|
||||
- 群:`GET /groups` `POST /groups` `PATCH /groups/{id}` `DELETE /groups/{id}`
|
||||
- 活动:`GET /activities` `POST /activities` `PATCH /activities/{id}` `DELETE /activities/{id}` `POST /upload-image` `GET /activity-images`
|
||||
- 生成链接:`POST /referral-links`(批量,按 activity_ids,美团转链)
|
||||
- 对账:`POST /orders/reconcile`(拉美团订单) `GET /orders`(明细)
|
||||
- 统计:`GET /stats`(按群对账统计) `GET /groups/{id}/timeseries`(折线) `GET /groups/{id}/daily`(每日明细) `GET /groups/{id}/wx-users`(群内微信用户)
|
||||
|
||||
### 落地页(coupon 域,公网无鉴权)— `api/v1/cps_redirect.py`
|
||||
- `GET /c/{code}`(落地页:授权+记点击+跳转/H5)
|
||||
- `POST /c/{code}/copy`(淘宝点复制,记 copy)
|
||||
- `GET /wx/oauth/cb`(微信授权回调)
|
||||
- `GET /MP_verify_*.txt`(微信域名归属校验文件)
|
||||
|
||||
> 删除/编辑都不级联删已生成的 cps_link——**已发出去的短链永远要能用**,落地页只查 link 本身,不依赖 group/activity 是否还在(删了淘宝活动,其落地页图回退默认图)。
|
||||
|
||||
---
|
||||
|
||||
## 九、配置项(`.env`,见 `app/core/config.py`)
|
||||
|
||||
| 配置 | 说明 |
|
||||
|---|---|
|
||||
| `CPS_REDIRECT_BASE` | 落地页域名,如 `https://coupon.shaguabijia.com`。生成 `/c/{code}` 拼前缀;也是图/回调的绝对 URL 基址 |
|
||||
| `MT_CPS_APP_KEY` / `MT_CPS_APP_SECRET` | 美团联盟凭证(转链 + 对账)。缺则美团相关优雅降级 |
|
||||
| `MT_CPS_DEFAULT_SID` | 美团默认渠道位(群没 sid 时兜底) |
|
||||
| `WX_MP_APPID` / `WX_MP_SECRET` | **微信服务号**网页授权凭证(≠ `WECHAT_APP_ID` 那个 App 支付用的) |
|
||||
| `WX_MP_OAUTH_ENABLED` | 微信授权总开关。默认 `false`;认证就绪后置 `true` 重启启用 |
|
||||
|
||||
---
|
||||
|
||||
## 十、代码地图
|
||||
|
||||
| 关注点 | 位置 |
|
||||
|---|---|
|
||||
| admin 端点 | `admin/routers/cps.py` |
|
||||
| admin 业务逻辑(群/活动/订单/统计/群内用户) | `admin/repositories/cps.py`(`group_stats` / `group_click_timeseries` / `group_order_daily` / `group_wx_users` / `reconcile_orders`) |
|
||||
| admin 出入参 | `admin/schemas/cps.py` |
|
||||
| 落地页 + 微信授权 | `api/v1/cps_redirect.py` |
|
||||
| 短码生成 / 记点击 / 点击聚合 | `repositories/cps_link.py`(`create_link` / `record_click` / `click_stats_by_group`) |
|
||||
| 微信用户 upsert | `repositories/cps_wx_user.py` |
|
||||
| 美团集成(转链/拉单) | `integrations/meituan.py`(`get_referral_link` / `query_order`) |
|
||||
| 微信网页授权集成 | `integrations/wx_oauth.py`(`build_authorize_url` / `exchange_code` / `get_userinfo`) |
|
||||
| 数据模型 | `app/models/cps_*.py` |
|
||||
| admin 前端 | shaguabijia-admin-web `src/app/(main)/cps/`(列表页 `page.tsx` + 群详情 `groups/[id]/page.tsx`) |
|
||||
|
||||
---
|
||||
|
||||
## 十一、部署 / 运维
|
||||
|
||||
- 部署:`ssh ecs1` → `deploy server`(app-server + admin 后端)/ `deploy web`(admin-web 前端)。机制 = git reset 到 origin/main + 装依赖 + alembic 迁移 + 重启 + health 自检。
|
||||
- 改 `.env`(如开微信开关、换服务号凭证)后:`systemctl restart shaguabijia-app-server shaguabijia-admin` 或重跑 `deploy server`。
|
||||
- 数据库迁移:CPS 相关迁移在 `alembic/versions/cps_*`(建表 `cps_tables`/`cps_link_tables`、多平台 `cps_v2_platforms`、活动图 `cps_activity_image`、微信用户 `cps_wx_user`)。
|
||||
|
||||
---
|
||||
|
||||
## 十二、排障速查
|
||||
|
||||
| 现象 | 原因 / 处理 |
|
||||
|---|---|
|
||||
| 微信授权报 **10005**(没有这些 scope 权限) | 账号不是已认证服务号,或「网页授权获取用户基本信息」接口未获得。确认账号类型 + 接口权限 |
|
||||
| 微信授权报 **10003**(redirect_uri 域名不一致) | 服务号后台「网页授权域名」没填 `coupon.shaguabijia.com` 或没保存成功 |
|
||||
| 换了服务号后授权失败 | 换 `.env` 的 `WX_MP_APPID/SECRET` + 在**新服务号**后台重配网页授权域名(校验文件可能是新的,要换 `MP_verify_*` 端点返回值) |
|
||||
| 落地页在微信里不跳授权 | 开关 `WX_MP_OAUTH_ENABLED` 没开,或不是微信 UA(外部浏览器不跳,正常) |
|
||||
| 群详情「群内微信用户」看不到人 | 已按"本群点击"算(`group_wx_users`)。确认该群 cps_click 有带 openid 的记录;美团/京东群昵称头像本就为空 |
|
||||
| 统计页订单/佣金是「-」 | 淘宝/京东群无对账(正常);美团群需先点「刷新对账」拉单 |
|
||||
| 成交额/佣金不更新 | 对账非实时,要 finance 手动「刷新对账」 |
|
||||
| 账号验证 appid/账号类型 | 可用基础 token 调 `cgi-bin/account/getaccountbasicinfo` 查(account_type / 认证 / 主体) |
|
||||
|
||||
---
|
||||
|
||||
## 十三、已知边界 / 技术债(接手前必读)
|
||||
|
||||
1. **下单归因只到「群」级,做不到「人」级**:美团 `query_order` 返回的订单只带 `sid`(群级渠道位),不带 openid。要统计"某个微信用户下没下单/下单额",需把 sid 体系改成**用户级**(每个用户进落地页时用其 openid 实时转链),是较大改动,**未做**。当前微信侧只能统计到"用户级**领券/点击**",下单仍是群级。
|
||||
2. **美团/京东群拿不到昵称头像**:无 userinfo 触发点(直接 302 跳转)。只有淘宝群(H5 落地页点复制)能补昵称头像。
|
||||
3. **淘宝/京东无对账 API**:只能统计点击/复制,拿不到下单/佣金。
|
||||
4. **落地页公网无鉴权**:群发场景,链接谁拿到都能点(刻意如此)。点击 UV 按 (ip, ua) 近似去重,非精确。
|
||||
5. **对账非实时**:`cps_order` 是上次「刷新对账」的快照,不点不更新。
|
||||
6. **激励奖励金额未统计**:美团订单里 `incentiveOrder` 标记了是否参与激励活动,但 `query_order` 不给激励奖励金额,需另接「奖励活动报表」API(未做)。
|
||||
|
||||
---
|
||||
|
||||
*本系统由本轮从零搭建:CPS 分发(群/活动/链接/对账/统计)+ 群详情可视化 + 微信网页授权用户级统计。后端在 shaguabijia-app-server,前端在 shaguabijia-admin-web。*
|
||||
+36
-26
@@ -3,7 +3,7 @@
|
||||
> 域名:`app-api.shaguabijia.com`(HTTPS,nginx 反代)
|
||||
> 仓库:`shaguabijia-app-server`
|
||||
> 接口协议详见 [`docs/api/`](./api/)(索引 + 一接口一文件)
|
||||
> 最后更新:2026-05-31
|
||||
> 最后更新:2026-06-23(§7 数据模型改为指向 OVERVIEW、§5 美团 CPS 对齐多 tab feed/4 接口、§6 厘清 coupon/step 有写库副作用 ≠ 纯透传壳、§6.5 补 `/internal/app-version`、Alembic 迁移数更新)
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
| **看广告发奖** | 穿山甲 GroMore 激励视频 S2S 回调(SHA256 验签)+ 4 态 CTA 冷却 |
|
||||
| **帮助与反馈** | 用户提交反馈(含截图)+ 静态 `/media` 服务 |
|
||||
|
||||
**本服务自身没有"比价"实现**——比价/领券业务的核心都在 pricebot-backend(另一个 repo),本服务对 step 类接口做"透传壳"。无爬虫、无 LLM,但**有钱包/福利等业务模型**(数据模型从早期 1 张 `user` 表已扩展到 10 张,见 §7)。
|
||||
**本服务自身没有"比价"实现**——比价/领券业务的核心都在 pricebot-backend(另一个 repo),本服务对 step 类接口做"透传壳"。无爬虫、无 LLM,但**有钱包/福利等业务模型**(数据模型从早期 1 张 `user` 表已扩展到 **40 张业务表**,见 §7 / [database/OVERVIEW.md](./database/OVERVIEW.md))。
|
||||
|
||||
---
|
||||
|
||||
@@ -188,32 +188,44 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
> 美团请求字段 `listTopiId`(少个 c)是官方拼写,非笔误。经纬度 `*1_000_000` 取整。
|
||||
|
||||
### 5.2 三个接口
|
||||
### 5.2 对外四个接口
|
||||
|
||||
- `query_coupon`:底层取数,被 `coupons` 路由和 `feed` 路由共用。
|
||||
- `coupons` 路由:对外的搜索/榜单接口,**客户端暂未接入**。
|
||||
- `feed` 路由:首页推荐流,每页并发拉外卖(platform=1)+ 到店(platform=2,到餐)各一次,按 **2 外卖 + 1 到店** 交叉去重(`_interleave`);榜单组合写死 3 页(`_TOPIC_ROUNDS`:爆款 / 今日必推 / 外卖精选+到店限时),第 3 页 `has_next=false`、第 4 页返空。
|
||||
> 接口级入参/出参/各 tab 行为详见 [api/meituan-feed.md](./api/meituan-feed.md) 等,本节只讲后端形态。
|
||||
|
||||
- `coupons`:对外的搜索/榜单接口(底层 `query_coupon`),**客户端暂未接入**。
|
||||
- `feed`:首页推荐流,**已是多 tab**(入参 `tab`):
|
||||
- `rec` 智能推荐:走**离线库 `meituan_coupon`**(筛佣金率≥3%、`DISTINCT ON` 去重、按销量降序分页),**纯库查询、不打美团、不依赖 MT 凭证**(实测同城热销中位佣金 ~0.8%,实时筛≥3% 每页剩 0–1 条又撞 402,故从库出)。不显示距离。
|
||||
- `distance` 距离最近:实时拉外卖+到店两路,按用户坐标由近及远。
|
||||
- 默认(空 tab):旧的逐轮分页混合 feed(2 外卖 + 1 到店交叉,写死 3 页爆款/今日必推/精选+限时,第 4 页返空),**仅老客户端兼容**。
|
||||
- 任何失败场景返 `200` + 空 `items` + `status=degraded`(不抛 5xx)。
|
||||
- `top-sales`:独立的销量榜接口,离线库 `meituan_coupon` 按销量降序 + 跨源去重,**不实时打美团**。
|
||||
- `referral-link`:换推广链接,客户端取 `link_map["3"]`(deeplink)优先跳美团 App。
|
||||
- `query_coupon`:底层取数(被 `coupons`/`distance` feed 共用),不直接对外。
|
||||
|
||||
### 5.3 风险点
|
||||
|
||||
- **三接口无鉴权**:任何人可调,消耗你的美团 appkey 能力/配额。
|
||||
- **对外接口无鉴权**:任何人可调,消耗你的美团 appkey 能力/配额。
|
||||
- **`sid` 可被客户端覆盖**:他人可借接口刷自己渠道分佣,建议服务端锁定。
|
||||
- **`feed` 静默吞异常**:`_fetch_topic` 对美团错误 `except 返回 []` 且不打日志,导致美团失败时 `feed` 返回"200 + 空 items"无声失败。排查时改用 `coupons`(它以 502 暴露错误)。
|
||||
- **`feed` 失败返 `status=degraded`**:任何失败场景返 `200` + 空 `items` + `status=degraded`(不抛 5xx,避免前端弹报错 toast)。排查时看 `status` 字段区分 `empty`(真没数据)与 `degraded`(上游/库查询失败);或改用 `coupons`(它以 502 暴露错误)。`rec`/`top-sales` 走离线库,prod 刚部署 ETL 未灌则返 `empty`——开内测前先手动跑一次 ETL。
|
||||
|
||||
---
|
||||
|
||||
## 6. 领券透传(coupon/step)
|
||||
|
||||
产品"一键领券"的核心接入点,但**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。本服务只做"透传壳"。
|
||||
产品"一键领券"的核心接入点,**真正的领券逻辑不在本服务**——在另一个 repo `pricebot-backend`(GoalEngine + 事件驱动)。但 `coupon/step` **不是纯透传壳**:它在转发 pricebot 之余,还**best-effort 写库**(纯透传壳是 `compare.py` 的 `intent/recognize`、`price/step`,它们只转发不写库)。
|
||||
|
||||
```
|
||||
客户端 → POST /api/v1/coupon/step (任意 JSON body,含 device_id/trace_id/step)
|
||||
→ coupon.py:不做 schema 校验,仅读 device_id/trace_id/step 打日志
|
||||
→ async httpx 把 body 原样 POST 到 PRICEBOT_BASE_URL/api/coupon/step
|
||||
→ coupon.py:不做 schema 校验,仅读 device_id/trace_id/step
|
||||
→ [副作用1] step==0 → 写 coupon_prompt_engagement(claim_started)
|
||||
→ async httpx 把 body 原样 POST 到 PRICEBOT_BASE_URL/api/coupon/step(按 trace_id 一致性 hash 选实例)
|
||||
→ [副作用2] 从响应抽券结果(去重)→ 写 coupon_claim_record
|
||||
→ [副作用3] action.command==done → 写 coupon_daily_completion(首页「去领取」卡置灰源)
|
||||
→ 把 pricebot 的响应原样返回
|
||||
```
|
||||
|
||||
> 三处写库(`_mark_engagement_blocking` / `_record_claims_blocking` / `_mark_completed_blocking`)全 **best-effort**:各自独立 session、经 `run_in_threadpool` 不阻塞事件循环,写失败只 `logger.warning`、绝不连累领券返回。判断维度是 `device_id`(`user_id` 可空旁路,仅资产留痕)。落库细节见 [database/coupon_state.md](./database/coupon_state.md)。
|
||||
|
||||
- **MVP 不鉴权**:已去掉 `CurrentUser` 依赖。`device_id` 透传给 pricebot 区分设备,但后端拿不到 `user_id` → 领券行为暂时绑不到登录用户(采集不到用户级画像)。待补 JWT,见 `待办与技术债.md` P1。
|
||||
- **不做 schema 校验**:body 原样透传,pricebot 自己校验。
|
||||
- **错误**:body 非合法 JSON → 400;pricebot 不可达或返回 5xx → 502。
|
||||
@@ -222,23 +234,21 @@ 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 侧跳过上报、不影响比价/领券)。
|
||||
|
||||
> 同类内部回写端点(同走 `X-Internal-Secret`)还有:`/internal/price-observation`(价格观测)、`/internal/store-mapping` + `/store-mapping/lookup` + `/store-mapping/invalidate`(跨平台店铺映射沉淀/反查/失效)等 pricebot 比价资产沉淀;以及 **`/internal/app-version`(OTA)**——**发布流程**(非 pricebot)出 APK 后写最新版本号/下载链接/sha256 落 `app_config`,客户端再 `GET /api/v1/platform/app-version` 读做检查更新。完整 6 个内部端点见 [api/internal.md](./api/internal.md)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
|
||||
**完整表清单不在本文维护**(避免双份漂移)——共 **40 张业务表** + `alembic_version` 框架表,逐表字段级说明 + 跨表关系/写入路径见 **[database/OVERVIEW.md](./database/OVERVIEW.md)**(总览)与 [database/README.md](./database/README.md)(一表一文件索引)。生产 PG / 开发可回退 SQLite。
|
||||
|
||||
| 表 | models 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| `user` | `models/user.py` | 用户(phone 唯一索引;注销时改成 `deleted_<id>` 释放约束) |
|
||||
| `feedback` | `models/feedback.py` | 用户反馈(content/contact/images JSON 列/status) |
|
||||
| `coin_account` | `models/wallet.py` | 用户的金币余额(`balance_cents` 单位:分) |
|
||||
| `coin_transaction` | `models/wallet.py` | 金币流水(签到/任务/兑换/广告;`type` + `amount_cents`) |
|
||||
| `cash_transaction` | `models/wallet.py` | 现金流水(兑换/提现) |
|
||||
| `withdraw_order` | `models/wallet.py` | 提现单(`status` + `wxpay_batch_id`,微信支付商家转账) |
|
||||
| `signin_record` | `models/signin.py` | 签到记录(每日一行,索引 `(user_id, signin_date)`) |
|
||||
| `user_task` | `models/task.py` | 一次性任务领取记录 |
|
||||
| `savings_record` | `models/savings.py` | 省钱明细(`dishes` 在 PG 上为 JSONB,SQLite 上是 TEXT) |
|
||||
| `comparison_record` | `models/comparison.py` | 比价记录(每次比价完整明细;`(user_id, trace_id)` 唯一幂等;4 个 JSON(B) 列含 `raw_payload`;独立于 savings) |
|
||||
| `ad_reward_record` | `models/ad_reward.py` | 看广告发奖(`trans_id` 唯一,幂等) |
|
||||
本文只保留分层与高频维度的速查:数据访问统一在 `repositories/`,ORM 表结构在 `models/`(钱包/福利在 `wallet.py`/`signin.py`/`task.py`、比价在 `comparison.py`、领券今日状态在 `coupon_state.py`、CPS 群发在 `cps_*.py`、pricebot 内部沉淀在 `price_observation.py`/`store_mapping.py`/`launch_confirm_sample.py`、无障碍存活在 `device.py`)。下面单列最常用的 `user` 表字段。
|
||||
|
||||
**user 表字段速查**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
@@ -255,7 +265,7 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
`upsert_user_for_login`:phone 存在则更新 `last_login_at`,不存在则注册(注册即登录)。
|
||||
|
||||
**Alembic 迁移**:`alembic/versions/` 当前 11 个迁移 + 1 个合并迁移(`f01db5d77dac_merge_pg_jsonb_and_feedback_heads.py`,2026-05-29 创建——实习生 PG 分支和 user-profile-feedback 分支并行开发时 down_revision 都挂在 `c8d9e0f1a2b3` 上,导致多 head,合并迁移把两条链合并)。详见 [数据库迁移.md](./database/数据库迁移.md)。
|
||||
**Alembic 迁移**:`alembic/versions/` 当前约 **78 个迁移文件**(含 14 个合并迁移),当前单一 head `4dc2af7ebe74`(2026-06-23 合并 #65 device 链 + comparison token 列两条并行链)。多人/多分支并行改表频繁产生多 head,靠 `alembic merge` 收敛回单 head。规模随改表增长,**别背具体链**,以 `alembic history`/`alembic heads` 实时输出为准。详见 [数据库迁移.md](./database/数据库迁移.md)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ try:
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
import httpx # noqa: E402
|
||||
from sqlalchemy import delete, func, select # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert # noqa: E402
|
||||
|
||||
@@ -85,6 +86,13 @@ DEFAULT_CONCURRENCY = 12 # 并发城市数(实测 15 并发 402 占 3% 可退
|
||||
STARTUP_STAGGER = 0.3 # 首批城市启动错峰间隔秒(削平瞬时峰值,实测能压低 402)
|
||||
PRUNE_FAIL_RATIO_MAX = 0.05 # 失败城占比超此值则本轮跳过 prune(避免大面积抓取失败误删库)
|
||||
|
||||
# 头图大小/类型富集:每城抓完后,按 head_url 去重并发 HEAD 取 Content-Length / Content-Type,
|
||||
# 写进 image_size / image_type。图片走美团图片 CDN(p*.meituan.net / img.meituan.net),直连可达、
|
||||
# 与 CPS 网关 402 限流无关;任何失败 → None,不阻断入库。城市级已 12 并发,这里每城再开 8,
|
||||
# 叠加峰值 ~96 个 HEAD,CDN 扛得住(实测单进程 50 并发稳)。
|
||||
IMAGE_META_WORKERS = 8 # 每城 HEAD 并发数
|
||||
IMAGE_META_TIMEOUT = 8.0 # 单图 HEAD 超时秒
|
||||
|
||||
SOURCES = [
|
||||
{"code": "search_waimai", "label": "外卖·搜外卖", "kind": "search", "platform": 1, "keyword": "外卖"},
|
||||
{"code": "search_meishi", "label": "外卖·搜美食", "kind": "search", "platform": 1, "keyword": "美食"},
|
||||
@@ -256,6 +264,9 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
|
||||
"sell_price_cents": price_cents,
|
||||
"original_price_cents": _to_cents(cpd.get("originalPrice")),
|
||||
"head_url": ((cpd.get("headUrl") or "").split("@")[0][:512] or None),
|
||||
# 占位;由 _fill_image_meta 抓完本城后并发 HEAD 回填(键必须在,upsert 才会带上这两列)
|
||||
"image_size": None,
|
||||
"image_type": None,
|
||||
"sale_volume": cpd.get("saleVolume"),
|
||||
"sale_volume_num": _sale_volume_num(cpd.get("saleVolume")),
|
||||
"commission_percent": comm_pct,
|
||||
@@ -268,6 +279,38 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
|
||||
})
|
||||
|
||||
|
||||
def _head_image_meta(client: httpx.Client, url: str) -> tuple[int | None, str | None]:
|
||||
"""HEAD 单张头图,取 (字节大小, MIME 类型);任何失败返回 (None, None),不阻断入库。"""
|
||||
try:
|
||||
r = client.head(url, timeout=IMAGE_META_TIMEOUT, follow_redirects=True)
|
||||
if r.status_code >= 400:
|
||||
return None, None
|
||||
cl = r.headers.get("content-length")
|
||||
size = int(cl) if cl and cl.isdigit() else None
|
||||
ctype = (r.headers.get("content-type") or "").split(";")[0].strip()[:32] or None
|
||||
return size, ctype
|
||||
except Exception: # noqa: BLE001
|
||||
return None, None
|
||||
|
||||
|
||||
def _fill_image_meta(rows: list[dict]) -> None:
|
||||
"""就地为本城 rows 填 image_size / image_type:按 head_url 去重后并发 HEAD,再回填每行。
|
||||
trust_env=False 直连图片 CDN(绕本机代理,CDN 无需走 CPS 代理)。"""
|
||||
urls = {r["head_url"] for r in rows if r.get("head_url")}
|
||||
if not urls:
|
||||
return
|
||||
meta: dict[str, tuple[int | None, str | None]] = {}
|
||||
with httpx.Client(trust_env=False, timeout=IMAGE_META_TIMEOUT) as client:
|
||||
with ThreadPoolExecutor(max_workers=IMAGE_META_WORKERS) as pool:
|
||||
futs = {pool.submit(_head_image_meta, client, u): u for u in urls}
|
||||
for f in as_completed(futs):
|
||||
meta[futs[f]] = f.result()
|
||||
for r in rows:
|
||||
m = meta.get(r.get("head_url"))
|
||||
if m:
|
||||
r["image_size"], r["image_type"] = m
|
||||
|
||||
|
||||
def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) -> list[dict]:
|
||||
"""抓单个城市的 3 路券并解析。worker 线程内执行(只抓取+解析,不碰 DB)。
|
||||
|
||||
@@ -286,6 +329,7 @@ def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) ->
|
||||
p = _parse_item(it, src, cid)
|
||||
if p:
|
||||
parsed.append(p)
|
||||
_fill_image_meta(parsed) # 抓完本城 → 并发 HEAD 回填头图 image_size/image_type
|
||||
return parsed
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""一次性 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()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""一次性 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()
|
||||
@@ -49,7 +49,7 @@ def _seed_user_with_data(phone: str) -> int:
|
||||
db.add(WithdrawOrder(
|
||||
user_id=uid, out_bill_no=f"test{uid}billno0001", amount_cents=100, status="success"
|
||||
))
|
||||
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="new"))
|
||||
db.add(Feedback(user_id=uid, content="测试反馈内容", contact="wx_test", status="pending"))
|
||||
db.commit()
|
||||
return uid
|
||||
finally:
|
||||
@@ -181,9 +181,11 @@ def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -
|
||||
|
||||
def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
|
||||
_seed_user_with_data("13800000005")
|
||||
r = admin_client.get("/admin/api/feedbacks", params={"status": "new"}, headers=_auth(admin_token))
|
||||
r = admin_client.get(
|
||||
"/admin/api/feedbacks", params={"status": "pending"}, headers=_auth(admin_token)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert all(f["status"] == "new" for f in r.json()["items"])
|
||||
assert all(f["status"] == "pending" for f in r.json()["items"])
|
||||
|
||||
|
||||
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
|
||||
|
||||
@@ -74,7 +74,7 @@ def _seed_feedback(phone: str) -> int:
|
||||
uid = _seed_user(phone)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
fb = Feedback(user_id=uid, content="测试", contact="wx", status="new")
|
||||
fb = Feedback(user_id=uid, content="测试", contact="wx", status="pending")
|
||||
db.add(fb)
|
||||
db.commit()
|
||||
return fb.id
|
||||
@@ -280,15 +280,90 @@ def test_super_admin_can_grant_coins(admin_client: TestClient, super_token: str)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ===== 反馈处理 =====
|
||||
# ===== 反馈审核 =====
|
||||
|
||||
def test_handle_feedback(admin_client: TestClient, operator_token: str) -> None:
|
||||
def test_approve_feedback_grants_coins_and_audit(
|
||||
admin_client: TestClient, operator_token: str
|
||||
) -> None:
|
||||
fid = _seed_feedback("13900000006")
|
||||
r = admin_client.post(f"/admin/api/feedbacks/{fid}/handle", headers=_auth(operator_token))
|
||||
assert r.status_code == 200
|
||||
r = admin_client.post(
|
||||
f"/admin/api/feedbacks/{fid}/approve",
|
||||
json={"reward_coins": 800, "note": "真实详细,已采纳"},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "adopted"
|
||||
assert r.json()["reward_coins"] == 800
|
||||
db = SessionLocal()
|
||||
try:
|
||||
assert db.get(Feedback, fid).status == "handled"
|
||||
fb = db.get(Feedback, fid)
|
||||
assert fb is not None
|
||||
assert fb.status == "adopted"
|
||||
assert fb.reward_coins == 800
|
||||
assert fb.review_note == "真实详细,已采纳"
|
||||
assert fb.reviewed_by_admin_id is not None
|
||||
assert fb.reviewed_at is not None
|
||||
assert db.get(CoinAccount, fb.user_id).coin_balance == 800
|
||||
txns = db.execute(
|
||||
select(CoinTransaction).where(
|
||||
CoinTransaction.user_id == fb.user_id,
|
||||
CoinTransaction.biz_type == "feedback_reward",
|
||||
CoinTransaction.ref_id == str(fid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(txns) == 1 and txns[0].amount == 800
|
||||
logs = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "feedback.approve",
|
||||
AdminAuditLog.target_id == str(fid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(logs) == 1 and logs[0].detail["reward_coins"] == 800
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.post(
|
||||
f"/admin/api/feedbacks/{fid}/approve",
|
||||
json={"reward_coins": 100},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_reject_feedback_records_reason_and_no_coin(
|
||||
admin_client: TestClient, operator_token: str
|
||||
) -> None:
|
||||
fid = _seed_feedback("13900000013")
|
||||
r = admin_client.post(
|
||||
f"/admin/api/feedbacks/{fid}/reject",
|
||||
json={"reason": "暂未提供可复现信息", "note": "信息不足"},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "rejected"
|
||||
assert r.json()["reject_reason"] == "暂未提供可复现信息"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
fb = db.get(Feedback, fid)
|
||||
assert fb is not None
|
||||
assert fb.status == "rejected"
|
||||
assert fb.reject_reason == "暂未提供可复现信息"
|
||||
assert fb.review_note == "信息不足"
|
||||
txns = db.execute(
|
||||
select(CoinTransaction).where(
|
||||
CoinTransaction.user_id == fb.user_id,
|
||||
CoinTransaction.biz_type == "feedback_reward",
|
||||
CoinTransaction.ref_id == str(fid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert txns == []
|
||||
logs = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "feedback.reject",
|
||||
AdminAuditLog.target_id == str(fid),
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(logs) == 1 and logs[0].detail["reason"] == "暂未提供可复现信息"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user