Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f868414966 | |||
| 900cc83d38 | |||
| 3cab75b6ac | |||
| 08bbb5775e | |||
| 8a2f72d366 | |||
| f7a3ef2e0b | |||
| d8358a6816 | |||
| 95a07f75b7 |
@@ -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 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,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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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,30 @@
|
||||
"""admin 广告配置 schemas(穿山甲 app_id/各位ID/验签密钥/各场景开关)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AdConfigOut(BaseModel):
|
||||
"""admin 读到的完整广告配置(含 reward_mkey;admin 有权见,但绝不经 /platform 下发客户端)。"""
|
||||
|
||||
app_id: str
|
||||
reward_code_id: str
|
||||
compare_feed_code_id: str
|
||||
coupon_feed_code_id: str
|
||||
reward_mkey: str
|
||||
reward_enabled: bool
|
||||
compare_ad_enabled: bool
|
||||
coupon_ad_enabled: bool
|
||||
|
||||
|
||||
class AdConfigUpdate(BaseModel):
|
||||
"""部分更新:只改传入(非 None)字段。空串是合法值(如清空 mkey 回退 .env)。"""
|
||||
|
||||
app_id: str | None = None
|
||||
reward_code_id: str | None = None
|
||||
compare_feed_code_id: str | None = None
|
||||
coupon_feed_code_id: str | None = None
|
||||
reward_mkey: str | None = None
|
||||
reward_enabled: bool | None = None
|
||||
compare_ad_enabled: bool | None = None
|
||||
coupon_ad_enabled: bool | None = None
|
||||
@@ -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)
|
||||
+12
-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,
|
||||
@@ -80,14 +81,22 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
||||
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
|
||||
granted / capped → is_verify=true + reason=0。
|
||||
"""
|
||||
if not settings.pangle_callback_configured:
|
||||
if not settings.PANGLE_CALLBACK_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
||||
)
|
||||
|
||||
params = dict(request.query_params)
|
||||
|
||||
if not pangle.verify_callback_sign_any(params, settings.pangle_reward_secrets):
|
||||
# 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
|
||||
# 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
|
||||
ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
|
||||
secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
|
||||
if not secrets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
||||
)
|
||||
if not pangle.verify_callback_sign_any(params, secrets):
|
||||
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
|
||||
|
||||
@@ -363,6 +372,7 @@ 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,
|
||||
app_env=payload.app_env,
|
||||
our_code_id=payload.our_code_id,
|
||||
aborted=payload.aborted,
|
||||
|
||||
+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,22 @@ 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"],
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
+7
-3
@@ -129,10 +129,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
|
||||
+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)。
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.api.v1.compare_record import router as compare_record_router
|
||||
from app.api.v1.coupon import router as coupon_router
|
||||
from app.api.v1.cps_redirect import router as cps_redirect_router
|
||||
from app.api.internal.app_version import router as internal_app_version_router
|
||||
from app.api.internal.launch_confirm import router as internal_launch_confirm_router
|
||||
from app.api.internal.price import router as internal_price_router
|
||||
from app.api.internal.store import router as internal_store_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
@@ -37,6 +38,10 @@ from app.api.v1.user import router as user_router
|
||||
from app.api.v1.wallet import router as wallet_router
|
||||
from app.api.v1.wxpay import router as wxpay_router
|
||||
from app.core.config import settings
|
||||
from app.core.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 +63,12 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
settings.DATABASE_URL.split("://", 1)[0],
|
||||
)
|
||||
reconcile_task = start_withdraw_reconcile_worker()
|
||||
daily_exchange_task = start_daily_exchange_worker()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_withdraw_reconcile_worker(reconcile_task)
|
||||
await stop_daily_exchange_worker(daily_exchange_task)
|
||||
logger.info("shutting down")
|
||||
|
||||
|
||||
@@ -109,6 +116,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)
|
||||
|
||||
@@ -20,6 +20,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,9 @@ class AdFeedRewardRecord(Base):
|
||||
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页)。比价与领券共用同一信息流
|
||||
# 代码位,slot_id/our_code_id 分不出,只能客户端各调用点显式打标;NULL=历史/未升级客户端=未分类。
|
||||
feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
+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}>"
|
||||
)
|
||||
@@ -69,6 +69,7 @@ def grant_feed_reward(
|
||||
ad_session_id: str | None = None,
|
||||
adn: str | None = None,
|
||||
slot_id: str | None = None,
|
||||
feed_scene: str | None = None,
|
||||
app_env: str | None = None,
|
||||
our_code_id: str | None = None,
|
||||
aborted: bool = False,
|
||||
@@ -82,6 +83,7 @@ def grant_feed_reward(
|
||||
duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到
|
||||
FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到
|
||||
AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。
|
||||
feed_scene:点位场景(comparison/coupon/welfare),仅做归类落库,不参与发奖计算。
|
||||
"""
|
||||
existing = _find_by_event(db, client_event_id)
|
||||
if existing is not None:
|
||||
@@ -104,6 +106,7 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
@@ -122,6 +125,7 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
@@ -141,6 +145,7 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=0,
|
||||
@@ -165,6 +170,7 @@ def grant_feed_reward(
|
||||
ecpm_raw=ecpm,
|
||||
adn=adn,
|
||||
slot_id=slot_id,
|
||||
feed_scene=feed_scene,
|
||||
app_env=app_env,
|
||||
our_code_id=our_code_id,
|
||||
coin=coin,
|
||||
|
||||
@@ -91,3 +91,53 @@ 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, # 领券广告开关
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -111,14 +111,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,12 @@ 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(福利页);"
|
||||
"比价与领券共用同一信息流代码位,需客户端在各调用点显式标注,缺省=未分类",
|
||||
)
|
||||
app_env: str | None = Field(
|
||||
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
|
||||
)
|
||||
|
||||
@@ -71,8 +71,9 @@ 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 展示)。
|
||||
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
|
||||
|
||||
+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
|
||||
@@ -30,6 +30,21 @@ 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 # 领券广告开关
|
||||
|
||||
|
||||
class AppVersionOut(BaseModel):
|
||||
"""最新 App 版本信息(OTA 检查更新,不鉴权)。
|
||||
|
||||
|
||||
@@ -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 # 确认下次触发时间
|
||||
|
||||
@@ -222,6 +222,16 @@ POST /api/v1/auth/sms/login { phone, code } → 任意 6 位通过 → upsert
|
||||
|
||||
---
|
||||
|
||||
## 6.5 pricebot 回写:内部端点(server→server)
|
||||
|
||||
§6 是 app-server 透传给 pricebot;反过来 pricebot 也会把少量数据回写 app-server 落库,走 `app/api/internal/*` —— **不走用户 JWT**,靠共享密钥头 `X-Internal-Secret`(== `INTERNAL_API_SECRET`,未配则 503,防裸奔写端点)校验。
|
||||
|
||||
**启动确认窗兜底样本**(2026-06):国产 ROM 打开别的 App 时弹"想要打开 XX"确认窗,pricebot 对没见过文案(繁体/英文/ROM 改版)的窗用 LLM 兜底放行后,把样本 POST 到 [`/internal/launch-confirm-sample`](../app/api/internal/launch_confirm.py) 落 `launch_confirm_sample` 表(host 包 + 弹窗树 + LLM plan + 设备 locale/机型),供研发定期人工沉淀回 pricebot 的规则 yaml(回到快路径)。**需 pricebot 与 app-server 两边 `.env` 配同一 `INTERNAL_API_SECRET` 才生效**(未配则 pricebot 侧跳过上报、不影响比价/领券)。
|
||||
|
||||
> 同类内部回写端点还有 `/internal/price-observation`(价格观测)、`/internal/store-mapping`(跨平台店铺映射)等 pricebot 比价资产沉淀。
|
||||
|
||||
---
|
||||
|
||||
## 7. 数据模型
|
||||
|
||||
业务表(下表)+ `alembic_version` 框架表。生产 PG / 开发可回退 SQLite。
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""注销账号(DELETE /api/v1/user):软删 + 释放唯一约束 + 资金前置校验。
|
||||
|
||||
覆盖:
|
||||
- 干净注销:status=deleted + phone 匿名化 + wechat_openid/invite_code 等唯一槽全部释放
|
||||
- 回归原始 bug:注销释放 openid 后,别的账号能绑同一个微信
|
||||
- 资金前置闸:有可提现现金 / 在审提现单 → 409 拒绝注销,账号不被删
|
||||
- 注销后旧 token 即时失效(status==active 闸)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _patch_userinfo(monkeypatch, openid: str) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": openid, "nickname": "微信昵称", "avatar_url": "https://x/a.png", "raw": {}},
|
||||
)
|
||||
|
||||
|
||||
def _seed_cash(client, token: str, phone: str, cents: int) -> None:
|
||||
"""先访问 /account 触发建账户,再用 DB 直接灌现金余额。"""
|
||||
client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
|
||||
acc = db.get(CoinAccount, user.id)
|
||||
acc.cash_balance_cents = cents
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _get_user(phone: str) -> User | None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db.execute(select(User).where(User.phone == phone)).scalar_one_or_none()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_delete_clean_account_releases_all_unique_slots(client) -> None:
|
||||
phone = "13900000001"
|
||||
token = _login(client, phone)
|
||||
# 预置:给该用户造昵称 + 邀请码(占 invite_code 唯一槽),验证注销时一并释放
|
||||
db = SessionLocal()
|
||||
try:
|
||||
u = db.execute(select(User).where(User.phone == phone)).scalar_one()
|
||||
u.nickname = "张三"
|
||||
u.invite_code = "INVCODE0001"
|
||||
db.commit()
|
||||
uid = u.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = client.delete("/api/v1/user", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
u = _get_user(f"deleted_{uid}")
|
||||
assert u is not None
|
||||
assert u.status == "deleted"
|
||||
assert u.phone == f"deleted_{uid}"
|
||||
assert u.nickname is None
|
||||
assert u.avatar_url is None
|
||||
assert u.wechat_openid is None
|
||||
assert u.wechat_nickname is None
|
||||
assert u.wechat_avatar_url is None
|
||||
assert u.invite_code is None
|
||||
|
||||
|
||||
def test_delete_releases_wechat_so_another_account_can_bind(client, monkeypatch) -> None:
|
||||
"""原始 bug 回归:A 绑微信 → 注销,B 必须能绑同一个 openid(修复前会 409)。"""
|
||||
openid = "openid_delreuse_a1" # 独特值,避免跨测试共享库污染(如 test_withdraw 也用 openid_shared_xyz)
|
||||
# A 绑微信
|
||||
_patch_userinfo(monkeypatch, openid)
|
||||
token_a = _login(client, "13900000002")
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a))
|
||||
assert r.status_code == 200, r.text
|
||||
# A 注销(无现金、无提现单 → 允许)
|
||||
r = client.delete("/api/v1/user", headers=_auth(token_a))
|
||||
assert r.status_code == 200, r.text
|
||||
# B 绑同一个 openid → 必须成功(修复前会 409 "该微信已绑定其他账号")
|
||||
token_b = _login(client, "13900000003")
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["bound"] is True
|
||||
|
||||
|
||||
def test_delete_blocked_when_cash_balance_positive(client) -> None:
|
||||
phone = "13900000004"
|
||||
token = _login(client, phone)
|
||||
_seed_cash(client, token, phone, 100) # 1 元未提现
|
||||
r = client.delete("/api/v1/user", headers=_auth(token))
|
||||
assert r.status_code == 409, r.text
|
||||
assert "现金" in r.json()["detail"]
|
||||
u = _get_user(phone) # 账号未被删
|
||||
assert u is not None and u.status == "active"
|
||||
|
||||
|
||||
def test_delete_blocked_when_reviewing_withdraw_exists(client, monkeypatch) -> None:
|
||||
"""cash 提光(=0)但有在审提现单 → 仍拒绝(命中 reviewing 闸,非 cash 闸)。"""
|
||||
phone = "13900000005"
|
||||
_patch_userinfo(monkeypatch, "openid_delrev_b2")
|
||||
token = _login(client, phone)
|
||||
_seed_cash(client, token, phone, 50)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
# 提光 50 → cash 归 0,单进 reviewing
|
||||
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
||||
assert r.status_code == 200 and r.json()["status"] == "reviewing", r.text
|
||||
assert r.json()["cash_balance_cents"] == 0
|
||||
|
||||
r = client.delete("/api/v1/user", headers=_auth(token))
|
||||
assert r.status_code == 409, r.text
|
||||
assert "审核" in r.json()["detail"]
|
||||
u = _get_user(phone)
|
||||
assert u is not None and u.status == "active"
|
||||
|
||||
|
||||
def test_deleted_user_old_token_is_rejected(client) -> None:
|
||||
phone = "13900000006"
|
||||
token = _login(client, phone)
|
||||
r = client.delete("/api/v1/user", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
# 注销后旧 token 立即失效(get_current_user 校验 status==active)
|
||||
r = client.get("/api/v1/user/onboarding/status", params={"device_id": "d"}, headers=_auth(token))
|
||||
assert r.status_code == 401, r.text
|
||||
@@ -0,0 +1,77 @@
|
||||
"""用户反馈接口测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.feedback import Feedback
|
||||
from app.repositories import user as user_repo
|
||||
|
||||
|
||||
def _login(client, phone: str) -> str:
|
||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def test_feedback_records_empty_returns_200(client) -> None:
|
||||
token = _login(client, "13622000001")
|
||||
|
||||
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {
|
||||
"records": [],
|
||||
"counts": {"all": 0, "pending": 0, "adopted": 0, "rejected": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_feedback_config_returns_default(client) -> None:
|
||||
token = _login(client, "13622000002")
|
||||
|
||||
r = client.get("/api/v1/feedback/config", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["enabled"] is True
|
||||
assert body["group_name"] == "傻瓜比价官方群"
|
||||
|
||||
|
||||
def test_feedback_records_maps_existing_statuses(client) -> None:
|
||||
phone = "13622000003"
|
||||
token = _login(client, phone)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = user_repo.get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
db.add(Feedback(user_id=user.id, content="待处理反馈", contact="", status="pending"))
|
||||
db.add(
|
||||
Feedback(
|
||||
user_id=user.id,
|
||||
content="已采纳反馈",
|
||||
contact="",
|
||||
status="adopted",
|
||||
reward_coins=800,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Feedback(
|
||||
user_id=user.id,
|
||||
content="未采纳反馈",
|
||||
contact="",
|
||||
status="rejected",
|
||||
reject_reason="暂未提供可复现信息",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = client.get("/api/v1/feedback/records", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["counts"] == {"all": 3, "pending": 1, "adopted": 1, "rejected": 1}
|
||||
by_status = {item["status"]: item for item in body["records"]}
|
||||
assert by_status["adopted"]["reward_coins"] == 800
|
||||
assert by_status["rejected"]["reject_reason"] == "暂未提供可复现信息"
|
||||
Reference in New Issue
Block a user