Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f51d51658 |
@@ -1,93 +0,0 @@
|
||||
"""add per-session coupon claim event table
|
||||
|
||||
Revision ID: coupon_claim_event
|
||||
Revises: 8e04cc13a211
|
||||
Create Date: 2026-07-23
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "coupon_claim_event"
|
||||
down_revision: str | Sequence[str] | None = "8e04cc13a211"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"coupon_claim_event",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("trace_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("device_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("coupon_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("claim_date", sa.Date(), nullable=False),
|
||||
sa.Column("status", sa.String(length=24), nullable=False),
|
||||
sa.Column("app_env", sa.String(length=16), nullable=True),
|
||||
sa.Column("vendor", sa.String(length=48), nullable=True),
|
||||
sa.Column("coupon_name", sa.String(length=128), nullable=True),
|
||||
sa.Column("claimed_count", sa.Integer(), nullable=True),
|
||||
sa.Column("reason", sa.String(length=255), nullable=True),
|
||||
sa.Column("extra", _JSON, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"trace_id", "coupon_id",
|
||||
name="uq_coupon_claim_event_trace_coupon",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_coupon_claim_event_date_env",
|
||||
"coupon_claim_event",
|
||||
["claim_date", "app_env"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_coupon_claim_event_app_env"),
|
||||
"coupon_claim_event",
|
||||
["app_env"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_coupon_claim_event_trace_id"),
|
||||
"coupon_claim_event",
|
||||
["trace_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_coupon_claim_event_user_id"),
|
||||
"coupon_claim_event",
|
||||
["user_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# 旧表只能回填当前仍保留的 trace;历史上已被每日去重覆盖的关联无法恢复。
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO coupon_claim_event (
|
||||
trace_id, device_id, user_id, coupon_id, claim_date, status, app_env,
|
||||
vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
trace_id, device_id, user_id, coupon_id, claim_date, status, app_env,
|
||||
vendor, coupon_name, claimed_count, reason, extra, created_at, updated_at
|
||||
FROM coupon_claim_record
|
||||
WHERE trace_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_coupon_claim_event_user_id"), table_name="coupon_claim_event")
|
||||
op.drop_index(op.f("ix_coupon_claim_event_trace_id"), table_name="coupon_claim_event")
|
||||
op.drop_index(op.f("ix_coupon_claim_event_app_env"), table_name="coupon_claim_event")
|
||||
op.drop_index("ix_coupon_claim_event_date_env", table_name="coupon_claim_event")
|
||||
op.drop_table("coupon_claim_event")
|
||||
@@ -16,7 +16,7 @@ from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord, CouponSession
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
from app.models.user import User
|
||||
from app.repositories import ad_ecpm as crud_ecpm
|
||||
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
|
||||
@@ -193,18 +193,18 @@ def _point_scores_by_trace(db: Session, trace_ids: list[str]) -> dict[str, dict[
|
||||
"""聚合查询批量返回逐场点位分数,不加载逐券明细。"""
|
||||
if not trace_ids:
|
||||
return {}
|
||||
succeeded = func.sum(case((CouponClaimEvent.status.in_(_SLOT_OK), 1), else_=0))
|
||||
succeeded = func.sum(case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0))
|
||||
rows = db.execute(
|
||||
select(
|
||||
CouponClaimEvent.trace_id,
|
||||
CouponClaimRecord.trace_id,
|
||||
succeeded.label("succeeded"),
|
||||
func.count().label("tried"),
|
||||
)
|
||||
.where(
|
||||
CouponClaimEvent.trace_id.in_(trace_ids),
|
||||
CouponClaimEvent.status.in_(_SLOT_TRIED),
|
||||
CouponClaimRecord.trace_id.in_(trace_ids),
|
||||
CouponClaimRecord.status.in_(_SLOT_TRIED),
|
||||
)
|
||||
.group_by(CouponClaimEvent.trace_id)
|
||||
.group_by(CouponClaimRecord.trace_id)
|
||||
).all()
|
||||
return {
|
||||
trace_id: {"succeeded": int(success_count or 0), "tried": int(tried or 0)}
|
||||
@@ -217,13 +217,13 @@ def coupon_point_details(db: Session, *, trace_id: str) -> list[dict]:
|
||||
"""按单个 trace 查询逐券结果;仅在后台用户点击分数时调用。"""
|
||||
rows = db.execute(
|
||||
select(
|
||||
CouponClaimEvent.coupon_id,
|
||||
CouponClaimEvent.coupon_name,
|
||||
CouponClaimEvent.status,
|
||||
CouponClaimEvent.reason,
|
||||
CouponClaimRecord.coupon_id,
|
||||
CouponClaimRecord.coupon_name,
|
||||
CouponClaimRecord.status,
|
||||
CouponClaimRecord.reason,
|
||||
)
|
||||
.where(CouponClaimEvent.trace_id == trace_id)
|
||||
.order_by(CouponClaimEvent.id)
|
||||
.where(CouponClaimRecord.trace_id == trace_id)
|
||||
.order_by(CouponClaimRecord.id)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
|
||||
+167
-85
@@ -12,6 +12,10 @@ from app.admin.repositories import mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.feedback import (
|
||||
FeedbackApproveRequest,
|
||||
FeedbackBulkApproveRequest,
|
||||
FeedbackBulkItemResult,
|
||||
FeedbackBulkRejectRequest,
|
||||
FeedbackBulkResult,
|
||||
FeedbackOut,
|
||||
FeedbackRejectRequest,
|
||||
FeedbackSummary,
|
||||
@@ -33,6 +37,123 @@ def _ensure_pending(fb: Feedback) -> None:
|
||||
raise HTTPException(status_code=400, detail="反馈已审核")
|
||||
|
||||
|
||||
def _approve_feedback(
|
||||
db: AdminDb,
|
||||
admin: AdminUser,
|
||||
feedback_id: int,
|
||||
payload: FeedbackApproveRequest | FeedbackBulkApproveRequest,
|
||||
ip: str,
|
||||
*,
|
||||
bulk: bool = False,
|
||||
) -> FeedbackOut:
|
||||
fb = db.get(Feedback, feedback_id, with_for_update=True)
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
_ensure_pending(fb)
|
||||
|
||||
before = fb.status
|
||||
mutations.review_feedback(
|
||||
db,
|
||||
fb,
|
||||
status="adopted",
|
||||
reward_coins=payload.reward_coins,
|
||||
review_note=payload.note,
|
||||
admin_reply=payload.reply,
|
||||
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="意见反馈被采纳",
|
||||
)
|
||||
detail = {
|
||||
"before": before,
|
||||
"after": "adopted",
|
||||
"reward_coins": payload.reward_coins,
|
||||
"note": payload.note,
|
||||
"reply": payload.reply,
|
||||
}
|
||||
if bulk:
|
||||
detail["bulk"] = True
|
||||
write_audit(
|
||||
db,
|
||||
admin,
|
||||
action="feedback.approve",
|
||||
target_type="feedback",
|
||||
target_id=feedback_id,
|
||||
detail=detail,
|
||||
ip=ip,
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(fb)
|
||||
out = FeedbackOut.model_validate(fb)
|
||||
notification_events.notify_feedback_reward(db, fb)
|
||||
return out
|
||||
|
||||
|
||||
def _reject_feedback(
|
||||
db: AdminDb,
|
||||
admin: AdminUser,
|
||||
feedback_id: int,
|
||||
payload: FeedbackRejectRequest | FeedbackBulkRejectRequest,
|
||||
ip: str,
|
||||
*,
|
||||
bulk: bool = False,
|
||||
) -> FeedbackOut:
|
||||
fb = db.get(Feedback, feedback_id, with_for_update=True)
|
||||
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,
|
||||
admin_reply=payload.reply,
|
||||
reviewed_by_admin_id=admin.id,
|
||||
commit=False,
|
||||
)
|
||||
detail = {
|
||||
"before": before,
|
||||
"after": "rejected",
|
||||
"reason": payload.reason,
|
||||
"note": payload.note,
|
||||
"reply": payload.reply,
|
||||
}
|
||||
if bulk:
|
||||
detail["bulk"] = True
|
||||
write_audit(
|
||||
db,
|
||||
admin,
|
||||
action="feedback.reject",
|
||||
target_type="feedback",
|
||||
target_id=feedback_id,
|
||||
detail=detail,
|
||||
ip=ip,
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(fb)
|
||||
out = FeedbackOut.model_validate(fb)
|
||||
notification_events.notify_feedback_reply(db, fb)
|
||||
return out
|
||||
|
||||
|
||||
def _bulk_result(items: list[FeedbackBulkItemResult]) -> FeedbackBulkResult:
|
||||
success = sum(1 for item in items if item.ok)
|
||||
return FeedbackBulkResult(
|
||||
total=len(items), success=success, failed=len(items) - success, items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
|
||||
def list_feedbacks(
|
||||
db: AdminDb,
|
||||
@@ -73,6 +194,50 @@ def feedback_summary(db: AdminDb) -> FeedbackSummary:
|
||||
return FeedbackSummary.model_validate(queries.feedback_summary(db))
|
||||
|
||||
|
||||
@router.post("/bulk/approve", response_model=FeedbackBulkResult, summary="批量采纳反馈并发金币")
|
||||
def bulk_approve_feedbacks(
|
||||
body: FeedbackBulkApproveRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> FeedbackBulkResult:
|
||||
results: list[FeedbackBulkItemResult] = []
|
||||
ip = get_client_ip(request)
|
||||
for feedback_id in body.ids:
|
||||
try:
|
||||
out = _approve_feedback(db, admin, feedback_id, body, ip, bulk=True)
|
||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
|
||||
except HTTPException as exc:
|
||||
db.rollback()
|
||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
|
||||
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||
db.rollback()
|
||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
|
||||
return _bulk_result(results)
|
||||
|
||||
|
||||
@router.post("/bulk/reject", response_model=FeedbackBulkResult, summary="批量拒绝采纳反馈")
|
||||
def bulk_reject_feedbacks(
|
||||
body: FeedbackBulkRejectRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> FeedbackBulkResult:
|
||||
results: list[FeedbackBulkItemResult] = []
|
||||
ip = get_client_ip(request)
|
||||
for feedback_id in body.ids:
|
||||
try:
|
||||
out = _reject_feedback(db, admin, feedback_id, body, ip, bulk=True)
|
||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=True, status=out.status))
|
||||
except HTTPException as exc:
|
||||
db.rollback()
|
||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error=str(exc.detail)))
|
||||
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||
db.rollback()
|
||||
results.append(FeedbackBulkItemResult(id=feedback_id, ok=False, error="系统异常"))
|
||||
return _bulk_result(results)
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||
def handle_feedback(
|
||||
feedback_id: int,
|
||||
@@ -93,53 +258,7 @@ def approve_feedback(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> FeedbackOut:
|
||||
fb = db.get(Feedback, feedback_id)
|
||||
if fb is None:
|
||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
||||
_ensure_pending(fb)
|
||||
|
||||
before = fb.status
|
||||
mutations.review_feedback(
|
||||
db,
|
||||
fb,
|
||||
status="adopted",
|
||||
reward_coins=payload.reward_coins,
|
||||
review_note=payload.note,
|
||||
admin_reply=payload.reply,
|
||||
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.approve",
|
||||
target_type="feedback",
|
||||
target_id=feedback_id,
|
||||
detail={
|
||||
"before": before,
|
||||
"after": "adopted",
|
||||
"reward_coins": payload.reward_coins,
|
||||
"note": payload.note,
|
||||
"reply": payload.reply,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(fb)
|
||||
out = FeedbackOut.model_validate(fb)
|
||||
# PRD #10 反馈奖励:采纳发金币后通知用户(站内 + push,必带官方留言)。
|
||||
# 业务已 commit,通知失败只 log 不影响审核结果。
|
||||
notification_events.notify_feedback_reward(db, fb)
|
||||
return out
|
||||
return _approve_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
||||
|
||||
|
||||
@router.post("/{feedback_id}/reject", response_model=FeedbackOut, summary="拒绝采纳反馈")
|
||||
@@ -150,41 +269,4 @@ def reject_feedback(
|
||||
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,
|
||||
admin_reply=payload.reply,
|
||||
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,
|
||||
"reply": payload.reply,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
commit=False,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(fb)
|
||||
out = FeedbackOut.model_validate(fb)
|
||||
# PRD #9 官方回复:未采纳也回复了用户(原因/留言用户端可见),通知去反馈历史页查看。
|
||||
notification_events.notify_feedback_reply(db, fb)
|
||||
return out
|
||||
return _reject_feedback(db, admin, feedback_id, payload, get_client_ip(request))
|
||||
|
||||
@@ -17,6 +17,10 @@ from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_ro
|
||||
from app.admin.repositories import mutations, queries
|
||||
from app.admin.schemas.common import CursorPage, OkResponse
|
||||
from app.admin.schemas.price_report import (
|
||||
PriceReportBulkItemResult,
|
||||
PriceReportBulkRejectRequest,
|
||||
PriceReportBulkRequest,
|
||||
PriceReportBulkResult,
|
||||
PriceReportOut,
|
||||
PriceReportRejectRequest,
|
||||
PriceReportSummary,
|
||||
@@ -34,6 +38,59 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
def _approve_price_report(
|
||||
db: AdminDb, admin: AdminUser, report_id: int, ip: str, *, bulk: bool = False
|
||||
) -> PriceReport:
|
||||
rep = db.get(PriceReport, report_id, with_for_update=True)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||
coins = PRICE_REPORT_REWARD_COINS
|
||||
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
|
||||
wallet_repo.grant_coins(
|
||||
db, rep.user_id, coins,
|
||||
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
|
||||
)
|
||||
detail = {"reward_coins": coins, "user_id": rep.user_id}
|
||||
if bulk:
|
||||
detail["bulk"] = True
|
||||
write_audit(
|
||||
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
|
||||
detail=detail, ip=ip, commit=False,
|
||||
)
|
||||
db.commit()
|
||||
notification_events.notify_report_approved(db, rep)
|
||||
return rep
|
||||
|
||||
|
||||
def _reject_price_report(
|
||||
db: AdminDb, admin: AdminUser, report_id: int, reason: str, ip: str, *, bulk: bool = False
|
||||
) -> PriceReport:
|
||||
rep = db.get(PriceReport, report_id, with_for_update=True)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
|
||||
detail = {"reason": reason, "user_id": rep.user_id}
|
||||
if bulk:
|
||||
detail["bulk"] = True
|
||||
write_audit(
|
||||
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
|
||||
detail=detail, ip=ip, commit=False,
|
||||
)
|
||||
db.commit()
|
||||
return rep
|
||||
|
||||
|
||||
def _bulk_result(items: list[PriceReportBulkItemResult]) -> PriceReportBulkResult:
|
||||
success = sum(1 for item in items if item.ok)
|
||||
return PriceReportBulkResult(
|
||||
total=len(items), success=success, failed=len(items) - success, items=items,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=CursorPage[PriceReportOut], summary="上报更低价列表(筛选+分页)")
|
||||
def list_price_reports(
|
||||
db: AdminDb,
|
||||
@@ -60,6 +117,50 @@ def price_report_summary(db: AdminDb) -> PriceReportSummary:
|
||||
return PriceReportSummary.model_validate(queries.price_report_summary(db))
|
||||
|
||||
|
||||
@router.post("/bulk/approve", response_model=PriceReportBulkResult, summary="批量通过上报(发固定金币)")
|
||||
def bulk_approve_price_reports(
|
||||
body: PriceReportBulkRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> PriceReportBulkResult:
|
||||
results: list[PriceReportBulkItemResult] = []
|
||||
ip = get_client_ip(request)
|
||||
for report_id in body.ids:
|
||||
try:
|
||||
rep = _approve_price_report(db, admin, report_id, ip, bulk=True)
|
||||
results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status))
|
||||
except HTTPException as exc:
|
||||
db.rollback()
|
||||
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail)))
|
||||
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||
db.rollback()
|
||||
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常"))
|
||||
return _bulk_result(results)
|
||||
|
||||
|
||||
@router.post("/bulk/reject", response_model=PriceReportBulkResult, summary="批量拒绝上报")
|
||||
def bulk_reject_price_reports(
|
||||
body: PriceReportBulkRejectRequest,
|
||||
request: Request,
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> PriceReportBulkResult:
|
||||
results: list[PriceReportBulkItemResult] = []
|
||||
ip = get_client_ip(request)
|
||||
for report_id in body.ids:
|
||||
try:
|
||||
rep = _reject_price_report(db, admin, report_id, body.reason, ip, bulk=True)
|
||||
results.append(PriceReportBulkItemResult(id=report_id, ok=True, status=rep.status))
|
||||
except HTTPException as exc:
|
||||
db.rollback()
|
||||
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error=str(exc.detail)))
|
||||
except Exception: # noqa: BLE001 - 单笔失败不打断整批
|
||||
db.rollback()
|
||||
results.append(PriceReportBulkItemResult(id=report_id, ok=False, error="系统异常"))
|
||||
return _bulk_result(results)
|
||||
|
||||
|
||||
@router.post("/{report_id}/approve", response_model=OkResponse, summary="通过上报(发固定金币)")
|
||||
def approve_price_report(
|
||||
report_id: int,
|
||||
@@ -67,27 +168,7 @@ def approve_price_report(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
# 行锁(SELECT FOR UPDATE):并发/连点双请求会都读到 pending → 各发一次金币双倍发奖,
|
||||
# 锁住该行串行化,第二个请求拿锁后看到 approved → 走 400。SQLite 下 FOR UPDATE 为 no-op。
|
||||
rep = db.get(PriceReport, report_id, with_for_update=True)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||
coins = PRICE_REPORT_REWARD_COINS
|
||||
# 改状态 + 发金币 + 审计同一事务(commit=False),最后一起 commit:改了就有痕、发了就留账
|
||||
mutations.review_price_report(db, rep, status="approved", reward_coins=coins, commit=False)
|
||||
wallet_repo.grant_coins(
|
||||
db, rep.user_id, coins,
|
||||
biz_type="price_report_reward", ref_id=str(rep.id), remark="上报更低价审核通过",
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="price_report.approve", target_type="price_report", target_id=report_id,
|
||||
detail={"reward_coins": coins, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
# PRD #11 爆料审核通过:发金币后通知用户(站内 + push)。业务已 commit,通知失败只 log。
|
||||
notification_events.notify_report_approved(db, rep)
|
||||
_approve_price_report(db, admin, report_id, get_client_ip(request))
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@@ -99,16 +180,5 @@ def reject_price_report(
|
||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||
db: AdminDb,
|
||||
) -> OkResponse:
|
||||
rep = db.get(PriceReport, report_id, with_for_update=True) # 行锁,同 approve(防并发重复审核)
|
||||
if rep is None:
|
||||
raise HTTPException(status_code=404, detail="上报记录不存在")
|
||||
if rep.status != "pending":
|
||||
raise HTTPException(status_code=400, detail=f"该上报已审核过(当前 {rep.status}),不可重复操作")
|
||||
reason = body.reason.strip()
|
||||
mutations.review_price_report(db, rep, status="rejected", reject_reason=reason, commit=False)
|
||||
write_audit(
|
||||
db, admin, action="price_report.reject", target_type="price_report", target_id=report_id,
|
||||
detail={"reason": reason, "user_id": rep.user_id}, ip=get_client_ip(request), commit=False,
|
||||
)
|
||||
db.commit()
|
||||
_reject_price_report(db, admin, report_id, body.reason, get_client_ip(request))
|
||||
return OkResponse()
|
||||
|
||||
@@ -79,10 +79,10 @@ class CouponDataRow(BaseModel):
|
||||
started_at: datetime = Field(..., description="发起时刻(明细「时间」列)")
|
||||
claimed_count: int | None = None
|
||||
point_success_count: int | None = Field(
|
||||
None, description="本次成功单券数(success+already_claimed);无逐券事件为空"
|
||||
None, description="本次成功券点位数(success+already_claimed);无逐券埋点为空"
|
||||
)
|
||||
point_total_count: int | None = Field(
|
||||
None, description="本次尝试单券数(success+already_claimed+failed,不含 skipped);无逐券事件为空"
|
||||
None, description="本次尝试券点位数(success+already_claimed+failed,不含 skipped);无逐券埋点为空"
|
||||
)
|
||||
trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id")
|
||||
ad_revenue_yuan: float = Field(
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.rewards import FEEDBACK_REWARD_MAX_COINS
|
||||
|
||||
@@ -55,6 +55,54 @@ class FeedbackRejectRequest(BaseModel):
|
||||
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
|
||||
|
||||
|
||||
class FeedbackBulkRequest(BaseModel):
|
||||
ids: list[int] = Field(min_length=1, max_length=50, description="待审核反馈 ID 列表")
|
||||
|
||||
@field_validator("ids")
|
||||
@classmethod
|
||||
def _ids_must_be_unique(cls, ids: list[int]) -> list[int]:
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("反馈 ID 不能重复")
|
||||
return ids
|
||||
|
||||
|
||||
class FeedbackBulkApproveRequest(FeedbackBulkRequest):
|
||||
reward_coins: int = Field(
|
||||
ge=1,
|
||||
le=FEEDBACK_REWARD_MAX_COINS,
|
||||
description="每条采纳反馈发放的金币数",
|
||||
)
|
||||
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
|
||||
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
|
||||
|
||||
|
||||
class FeedbackBulkRejectRequest(FeedbackBulkRequest):
|
||||
reason: str = Field(min_length=1, max_length=256, description="批量未采纳原因,用户端可见")
|
||||
note: str | None = Field(default=None, max_length=256, description="运营内部审核备注")
|
||||
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def _reason_not_blank(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("未采纳原因不能为空")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class FeedbackBulkItemResult(BaseModel):
|
||||
id: int
|
||||
ok: bool
|
||||
status: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class FeedbackBulkResult(BaseModel):
|
||||
total: int
|
||||
success: int
|
||||
failed: int
|
||||
items: list[FeedbackBulkItemResult]
|
||||
|
||||
|
||||
class FeedbackSummary(BaseModel):
|
||||
"""审核台顶部各状态计数(pending 含历史 new 态)。"""
|
||||
|
||||
|
||||
@@ -56,6 +56,42 @@ class PriceReportRejectRequest(BaseModel):
|
||||
return v.strip()
|
||||
|
||||
|
||||
class PriceReportBulkRequest(BaseModel):
|
||||
ids: list[int] = Field(min_length=1, max_length=50, description="待审核上报 ID 列表")
|
||||
|
||||
@field_validator("ids")
|
||||
@classmethod
|
||||
def _ids_must_be_unique(cls, ids: list[int]) -> list[int]:
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("上报 ID 不能重复")
|
||||
return ids
|
||||
|
||||
|
||||
class PriceReportBulkRejectRequest(PriceReportBulkRequest):
|
||||
reason: str = Field(min_length=1, max_length=256, description="批量拒绝理由,用户端记录页会看到")
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def _reason_not_blank(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("拒绝理由不能为空")
|
||||
return value.strip()
|
||||
|
||||
|
||||
class PriceReportBulkItemResult(BaseModel):
|
||||
id: int
|
||||
ok: bool
|
||||
status: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class PriceReportBulkResult(BaseModel):
|
||||
total: int
|
||||
success: int
|
||||
failed: int
|
||||
items: list[PriceReportBulkItemResult]
|
||||
|
||||
|
||||
class PriceReportSummary(BaseModel):
|
||||
"""审核台顶部各状态计数。"""
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ def _record_claims_blocking(
|
||||
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
|
||||
) -> None:
|
||||
with SessionLocal() as db:
|
||||
# 取本次 session 环境,给每日资产和逐次事件同时打环境标。
|
||||
# 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。
|
||||
app_env = coupon_repo.session_app_env(db, trace_id)
|
||||
coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
|
||||
# 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
|
||||
@@ -176,7 +176,7 @@ async def coupon_step(
|
||||
|
||||
resp_json = resp.json()
|
||||
|
||||
# 领券结果沉淀:每日资产 + 逐次事件;中间帧和 done 全量帧均幂等写库。
|
||||
# 领券结果沉淀(资产):中间帧 last_coupon_result + done 帧 coupon_results 幂等写库。
|
||||
# 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
|
||||
if device_id:
|
||||
results = _extract_coupon_results(resp_json)
|
||||
|
||||
@@ -21,7 +21,6 @@ from app.models.cps_wx_user import CpsWxUser # noqa: F401
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
|
||||
from app.models.device import DeviceLiveness # noqa: F401
|
||||
from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimEvent,
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
|
||||
@@ -96,40 +96,6 @@ class CouponClaimRecord(Base):
|
||||
)
|
||||
|
||||
|
||||
class CouponClaimEvent(Base):
|
||||
"""一次领券任务中的单券结果,按 ``(trace_id, coupon_id)`` 幂等。"""
|
||||
|
||||
__tablename__ = "coupon_claim_event"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"trace_id", "coupon_id",
|
||||
name="uq_coupon_claim_event_trace_coupon",
|
||||
),
|
||||
Index("ix_coupon_claim_event_date_env", "claim_date", "app_env"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
trace_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
|
||||
coupon_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
claim_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(24), nullable=False)
|
||||
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
|
||||
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
extra: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class CouponDailyCompletion(Base):
|
||||
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.coupon_state import (
|
||||
CouponClaimEvent,
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
CouponPromptEngagement,
|
||||
@@ -165,12 +164,11 @@ def record_claims(
|
||||
results: list[dict],
|
||||
app_env: str | None = None,
|
||||
) -> int:
|
||||
"""一批券领取结果同时写入每日资产表和逐次事件表。
|
||||
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
|
||||
|
||||
results 单项取自 pricebot 的 last_coupon_result / done.coupon_results,识别字段:
|
||||
coupon_id(必需)/ status(必需)/ name / vendor / reason /(display_count)。
|
||||
- CouponClaimRecord 按 (device, coupon_id, 今天) 幂等,供每日资产口径使用。
|
||||
- CouponClaimEvent 按 (trace_id, coupon_id) 幂等,供 admin 逐场统计使用。
|
||||
(device, coupon_id, 今天) 唯一:重复上报同张券走更新(status 以最后一次为准)。
|
||||
"""
|
||||
today = today_cn()
|
||||
written = 0
|
||||
@@ -210,41 +208,6 @@ def record_claims(
|
||||
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
|
||||
extra=r,
|
||||
))
|
||||
if trace_id:
|
||||
event = db.execute(
|
||||
select(CouponClaimEvent).where(
|
||||
CouponClaimEvent.trace_id == trace_id,
|
||||
CouponClaimEvent.coupon_id == coupon_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if event is not None:
|
||||
event.device_id = device_id
|
||||
event.status = status
|
||||
event.reason = r.get("reason")
|
||||
event.vendor = r.get("vendor")
|
||||
event.coupon_name = r.get("name")
|
||||
event.extra = r
|
||||
if user_id is not None:
|
||||
event.user_id = user_id
|
||||
if count is not None:
|
||||
event.claimed_count = count
|
||||
if app_env is not None:
|
||||
event.app_env = app_env
|
||||
else:
|
||||
db.add(CouponClaimEvent(
|
||||
trace_id=trace_id,
|
||||
device_id=device_id,
|
||||
user_id=user_id,
|
||||
coupon_id=coupon_id,
|
||||
claim_date=today,
|
||||
status=status,
|
||||
app_env=app_env,
|
||||
vendor=r.get("vendor"),
|
||||
coupon_name=r.get("name"),
|
||||
claimed_count=count,
|
||||
reason=r.get("reason"),
|
||||
extra=r,
|
||||
))
|
||||
written += 1
|
||||
if written == 0:
|
||||
return 0
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
"""生成 admin「领券记录」本地联调数据。
|
||||
|
||||
用法:
|
||||
python scripts/seed_coupon_session_mock.py
|
||||
|
||||
脚本只清理 ``mock-coupon-repeat-*`` 前缀的数据并重新生成。打开后台「领券记录」,
|
||||
日期选今天;分别切换 prod/dev,可验证同一设备同一天多次领券仍各自显示正确分数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.db.session import SessionLocal, engine # noqa: E402
|
||||
from app.models.coupon_state import ( # noqa: E402
|
||||
CouponClaimEvent,
|
||||
CouponClaimRecord,
|
||||
CouponSession,
|
||||
)
|
||||
from app.models.user import User # noqa: E402
|
||||
from app.repositories.coupon_state import record_claims, today_cn # noqa: E402
|
||||
|
||||
PREFIX = "mock-coupon-repeat-"
|
||||
CN_TZ = ZoneInfo("Asia/Shanghai")
|
||||
DEVICE_REPEAT = f"{PREFIX}device"
|
||||
DEVICE_CONTROL = f"{PREFIX}control-device"
|
||||
PHONE = "19900009001"
|
||||
USERNAME = "80000009001"
|
||||
PLATFORM_ELAPSED = {
|
||||
"meituan-waimai": 46_800,
|
||||
"taobao-shanguang": 19_500,
|
||||
"jd-waimai": 24_000,
|
||||
}
|
||||
|
||||
FIRST_RESULTS = [
|
||||
{"coupon_id": "mt_mock_1", "name": "美团模拟券1", "vendor": "meituan", "status": "success"},
|
||||
{"coupon_id": "mt_mock_2", "name": "美团模拟券2", "vendor": "meituan", "status": "success"},
|
||||
{"coupon_id": "mt_mock_3", "name": "美团模拟券3", "vendor": "meituan", "status": "success"},
|
||||
{"coupon_id": "mt_mock_4", "name": "美团模拟券4", "vendor": "meituan", "status": "failed", "reason": "模拟失败"},
|
||||
{"coupon_id": "tb_mock_1", "name": "淘宝模拟券1", "vendor": "taobao", "status": "success"},
|
||||
{"coupon_id": "tb_mock_2", "name": "淘宝模拟券2", "vendor": "taobao", "status": "success"},
|
||||
{"coupon_id": "tb_mock_3", "name": "淘宝模拟券3", "vendor": "taobao", "status": "success"},
|
||||
{"coupon_id": "jd_mock_1", "name": "京东模拟券", "vendor": "jingdong", "status": "success"},
|
||||
]
|
||||
|
||||
SECOND_RESULTS = [
|
||||
{"coupon_id": "mt_mock_1", "name": "美团模拟券1", "vendor": "meituan", "status": "skipped", "reason": "模拟跳过,不计分母"},
|
||||
{"coupon_id": "mt_mock_2", "name": "美团模拟券2", "vendor": "meituan", "status": "already_claimed"},
|
||||
{"coupon_id": "mt_mock_3", "name": "美团模拟券3", "vendor": "meituan", "status": "success"},
|
||||
{"coupon_id": "mt_mock_4", "name": "美团模拟券4", "vendor": "meituan", "status": "success"},
|
||||
{"coupon_id": "tb_mock_1", "name": "淘宝模拟券1", "vendor": "taobao", "status": "success"},
|
||||
{"coupon_id": "tb_mock_2", "name": "淘宝模拟券2", "vendor": "taobao", "status": "success"},
|
||||
{"coupon_id": "tb_mock_3", "name": "淘宝模拟券3", "vendor": "taobao", "status": "already_claimed"},
|
||||
{"coupon_id": "jd_mock_1", "name": "京东模拟券", "vendor": "jingdong", "status": "success"},
|
||||
]
|
||||
|
||||
|
||||
def _started_at(hour: int, minute: int) -> datetime:
|
||||
local = datetime.combine(today_cn(), datetime.min.time()).replace(
|
||||
hour=hour, minute=minute, tzinfo=CN_TZ
|
||||
)
|
||||
return local.astimezone(UTC)
|
||||
|
||||
|
||||
def _session(
|
||||
*,
|
||||
trace_id: str,
|
||||
device_id: str,
|
||||
user_id: int,
|
||||
app_env: str,
|
||||
hour: int,
|
||||
minute: int,
|
||||
status: str = "completed",
|
||||
elapsed_ms: int | None = 91_900,
|
||||
platform_elapsed: dict[str, int] | None = None,
|
||||
) -> CouponSession:
|
||||
started_at = _started_at(hour, minute)
|
||||
return CouponSession(
|
||||
trace_id=trace_id,
|
||||
device_id=device_id,
|
||||
user_id=user_id,
|
||||
status=status,
|
||||
app_env=app_env,
|
||||
platforms=[],
|
||||
origin_package=None,
|
||||
device_model="Mock Phone",
|
||||
rom="MockOS 1",
|
||||
started_at=started_at,
|
||||
started_date=today_cn(),
|
||||
finished_at=started_at if status != "started" else None,
|
||||
elapsed_ms=elapsed_ms,
|
||||
platform_elapsed=platform_elapsed,
|
||||
platform_success=(
|
||||
["meituan-waimai", "taobao-shanguang", "jd-waimai"]
|
||||
if status == "completed" else None
|
||||
),
|
||||
claimed_count=7 if status == "completed" else 0,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# 本地旧库 Alembic 版本链可能未同步;仅为联调补建新事件表,正式环境仍走 migration。
|
||||
CouponClaimEvent.__table__.create(bind=engine, checkfirst=True)
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(CouponClaimEvent).where(
|
||||
CouponClaimEvent.trace_id.startswith(PREFIX)
|
||||
))
|
||||
db.execute(delete(CouponClaimRecord).where(
|
||||
CouponClaimRecord.device_id.startswith(PREFIX)
|
||||
))
|
||||
db.execute(delete(CouponSession).where(
|
||||
CouponSession.trace_id.startswith(PREFIX)
|
||||
))
|
||||
user = db.execute(select(User).where(User.phone == PHONE)).scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
phone=PHONE,
|
||||
username=USERNAME,
|
||||
register_channel="sms",
|
||||
nickname="领券重复测试",
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
first_trace = f"{PREFIX}dev-first"
|
||||
second_trace = f"{PREFIX}prod-second"
|
||||
abandoned_trace = f"{PREFIX}prod-abandoned"
|
||||
control_trace = f"{PREFIX}prod-control"
|
||||
db.add_all([
|
||||
_session(
|
||||
trace_id=first_trace,
|
||||
device_id=DEVICE_REPEAT,
|
||||
user_id=user.id,
|
||||
app_env="dev",
|
||||
hour=10,
|
||||
minute=0,
|
||||
platform_elapsed=PLATFORM_ELAPSED,
|
||||
),
|
||||
_session(
|
||||
trace_id=second_trace,
|
||||
device_id=DEVICE_REPEAT,
|
||||
user_id=user.id,
|
||||
app_env="prod",
|
||||
hour=15,
|
||||
minute=0,
|
||||
platform_elapsed=PLATFORM_ELAPSED,
|
||||
),
|
||||
_session(
|
||||
trace_id=abandoned_trace,
|
||||
device_id=DEVICE_REPEAT,
|
||||
user_id=user.id,
|
||||
app_env="prod",
|
||||
hour=16,
|
||||
minute=0,
|
||||
status="abandoned",
|
||||
elapsed_ms=21_500,
|
||||
platform_elapsed={"meituan-waimai": 20_500},
|
||||
),
|
||||
_session(
|
||||
trace_id=control_trace,
|
||||
device_id=DEVICE_CONTROL,
|
||||
user_id=user.id,
|
||||
app_env="prod",
|
||||
hour=17,
|
||||
minute=0,
|
||||
platform_elapsed=PLATFORM_ELAPSED,
|
||||
),
|
||||
])
|
||||
db.commit()
|
||||
|
||||
record_claims(
|
||||
db, DEVICE_REPEAT, user.id, first_trace, FIRST_RESULTS, app_env="dev"
|
||||
)
|
||||
record_claims(
|
||||
db, DEVICE_REPEAT, user.id, second_trace, SECOND_RESULTS, app_env="prod"
|
||||
)
|
||||
record_claims(
|
||||
db, DEVICE_CONTROL, user.id, control_trace, FIRST_RESULTS, app_env="prod"
|
||||
)
|
||||
|
||||
print(f"已生成 {today_cn()} 的领券 mock 数据。")
|
||||
print("筛选用户 19900009001。")
|
||||
print("prod 应有:7/7(100.0%)、-、7/8(87.5%)三条。")
|
||||
print("dev 应有:7/8(87.5%)一条。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,6 +14,7 @@ from app.core.security import hash_password
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.admin import AdminAuditLog
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.price_report import PriceReport
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount, CoinTransaction, WithdrawOrder
|
||||
from app.repositories import user as user_repo
|
||||
@@ -82,6 +83,26 @@ def _seed_feedback(phone: str) -> int:
|
||||
db.close()
|
||||
|
||||
|
||||
def _seed_price_report(phone: str) -> int:
|
||||
uid = _seed_user(phone)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
report = PriceReport(
|
||||
user_id=uid,
|
||||
store_name="测试门店",
|
||||
reported_platform_id="eleme",
|
||||
reported_platform_name="饿了么",
|
||||
reported_price_cents=2990,
|
||||
images=[],
|
||||
status="pending",
|
||||
)
|
||||
db.add(report)
|
||||
db.commit()
|
||||
return report.id
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ===== 调金币 =====
|
||||
|
||||
def test_grant_coins_writes_txn_and_audit(admin_client: TestClient, finance_token: str) -> None:
|
||||
@@ -397,6 +418,100 @@ def test_feedback_review_stores_admin_reply(
|
||||
assert r.json()["admin_reply"] == "已收到,后续跟进"
|
||||
|
||||
|
||||
def test_bulk_approve_feedbacks_returns_per_item_results(
|
||||
admin_client: TestClient, operator_token: str
|
||||
) -> None:
|
||||
first_id = _seed_feedback("13900000031")
|
||||
second_id = _seed_feedback("13900000032")
|
||||
r = admin_client.post(
|
||||
"/admin/api/feedbacks/bulk/approve",
|
||||
json={"ids": [first_id, second_id, 999999], "reward_coins": 600, "note": "批量采纳"},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1
|
||||
assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "反馈不存在"}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for feedback_id in (first_id, second_id):
|
||||
feedback = db.get(Feedback, feedback_id)
|
||||
assert feedback is not None and feedback.status == "adopted"
|
||||
assert db.get(CoinAccount, feedback.user_id).coin_balance == 600
|
||||
log = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "feedback.approve",
|
||||
AdminAuditLog.target_id == str(feedback_id),
|
||||
)
|
||||
).scalar_one()
|
||||
assert log.detail["bulk"] is True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_bulk_approve_price_reports_returns_per_item_results(
|
||||
admin_client: TestClient, operator_token: str
|
||||
) -> None:
|
||||
first_id = _seed_price_report("13900000041")
|
||||
second_id = _seed_price_report("13900000042")
|
||||
r = admin_client.post(
|
||||
"/admin/api/price-reports/bulk/approve",
|
||||
json={"ids": [first_id, second_id, 999999]},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
assert payload["total"] == 3 and payload["success"] == 2 and payload["failed"] == 1
|
||||
assert payload["items"][-1] == {"id": 999999, "ok": False, "status": None, "error": "上报记录不存在"}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
for report_id in (first_id, second_id):
|
||||
report = db.get(PriceReport, report_id)
|
||||
assert report is not None and report.status == "approved"
|
||||
assert report.reward_coins == 1000
|
||||
assert db.get(CoinAccount, report.user_id).coin_balance == 1000
|
||||
log = db.execute(
|
||||
select(AdminAuditLog).where(
|
||||
AdminAuditLog.action == "price_report.approve",
|
||||
AdminAuditLog.target_id == str(report_id),
|
||||
)
|
||||
).scalar_one()
|
||||
assert log.detail["bulk"] is True
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_bulk_reject_review_requests_apply_shared_reason(
|
||||
admin_client: TestClient, operator_token: str
|
||||
) -> None:
|
||||
feedback_id = _seed_feedback("13900000051")
|
||||
report_id = _seed_price_report("13900000052")
|
||||
feedback_response = admin_client.post(
|
||||
"/admin/api/feedbacks/bulk/reject",
|
||||
json={"ids": [feedback_id], "reason": "信息不足", "reply": "请补充完整截图"},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
report_response = admin_client.post(
|
||||
"/admin/api/price-reports/bulk/reject",
|
||||
json={"ids": [report_id], "reason": "截图无法核实"},
|
||||
headers=_auth(operator_token),
|
||||
)
|
||||
assert feedback_response.status_code == 200, feedback_response.text
|
||||
assert report_response.status_code == 200, report_response.text
|
||||
assert feedback_response.json()["success"] == 1
|
||||
assert report_response.json()["success"] == 1
|
||||
db = SessionLocal()
|
||||
try:
|
||||
feedback = db.get(Feedback, feedback_id)
|
||||
report = db.get(PriceReport, report_id)
|
||||
assert feedback is not None and feedback.status == "rejected"
|
||||
assert feedback.reject_reason == "信息不足" and feedback.admin_reply == "请补充完整截图"
|
||||
assert report is not None and report.status == "rejected"
|
||||
assert report.reject_reason == "截图无法核实"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ===== admin 账号管理(super_admin) =====
|
||||
|
||||
def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None:
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""逐次单券事件不能被同设备同日的每日去重记录串场。"""
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.admin.repositories.coupon_data import _point_scores_by_trace, coupon_point_details
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord
|
||||
from app.repositories.coupon_state import record_claims
|
||||
|
||||
|
||||
def test_same_device_same_day_keeps_scores_for_each_trace() -> None:
|
||||
db = SessionLocal()
|
||||
device = "event-repeat-device"
|
||||
first_trace = "event-repeat-first"
|
||||
second_trace = "event-repeat-second"
|
||||
first_results = [
|
||||
{"coupon_id": "mt-repeat", "name": "美团测试券", "status": "success"},
|
||||
{"coupon_id": "tb-repeat", "name": "淘宝测试券", "status": "failed"},
|
||||
]
|
||||
second_results = [
|
||||
{"coupon_id": "mt-repeat", "name": "美团测试券", "status": "already_claimed"},
|
||||
{"coupon_id": "tb-repeat", "name": "淘宝测试券", "status": "success"},
|
||||
]
|
||||
try:
|
||||
record_claims(
|
||||
db, device, None, first_trace, first_results, app_env="dev"
|
||||
)
|
||||
record_claims(
|
||||
db, device, None, second_trace, second_results, app_env="prod"
|
||||
)
|
||||
|
||||
assets = db.execute(
|
||||
select(CouponClaimRecord).where(CouponClaimRecord.device_id == device)
|
||||
).scalars().all()
|
||||
assert len(assets) == 2
|
||||
assert {row.trace_id for row in assets} == {first_trace}
|
||||
|
||||
events = db.execute(
|
||||
select(CouponClaimEvent).where(CouponClaimEvent.device_id == device)
|
||||
).scalars().all()
|
||||
assert len(events) == 4
|
||||
assert {row.trace_id for row in events} == {first_trace, second_trace}
|
||||
|
||||
scores = _point_scores_by_trace(db, [first_trace, second_trace])
|
||||
assert scores[first_trace] == {"succeeded": 1, "tried": 2}
|
||||
assert scores[second_trace] == {"succeeded": 2, "tried": 2}
|
||||
assert [row["status"] for row in coupon_point_details(
|
||||
db, trace_id=second_trace
|
||||
)] == ["already_claimed", "success"]
|
||||
finally:
|
||||
db.execute(delete(CouponClaimEvent).where(CouponClaimEvent.device_id == device))
|
||||
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == device))
|
||||
db.commit()
|
||||
db.close()
|
||||
@@ -13,7 +13,7 @@ from app.admin.repositories.coupon_data import (
|
||||
)
|
||||
from app.admin.security import create_admin_token
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.coupon_state import CouponClaimEvent, CouponSession
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
|
||||
|
||||
def test_point_scores_by_trace() -> None:
|
||||
@@ -22,14 +22,14 @@ def test_point_scores_by_trace() -> None:
|
||||
trace = "point-score-trace"
|
||||
try:
|
||||
db.add_all([
|
||||
CouponClaimEvent(
|
||||
trace_id=trace,
|
||||
CouponClaimRecord(
|
||||
device_id="score-device",
|
||||
coupon_id=f"mt-score-{status}",
|
||||
claim_date=date(2020, 1, 2),
|
||||
status=status,
|
||||
coupon_name=f"测试点位-{status}",
|
||||
reason="测试失败" if status == "failed" else None,
|
||||
trace_id=trace,
|
||||
)
|
||||
for status in ("success", "already_claimed", "failed", "skipped")
|
||||
])
|
||||
@@ -53,12 +53,12 @@ def test_skipped_detail_does_not_create_a_score() -> None:
|
||||
db = SessionLocal()
|
||||
trace = "point-score-skipped"
|
||||
try:
|
||||
db.add(CouponClaimEvent(
|
||||
trace_id=trace,
|
||||
db.add(CouponClaimRecord(
|
||||
device_id="score-device-skipped",
|
||||
coupon_id="mt-score-skipped-only",
|
||||
claim_date=date(2020, 1, 2),
|
||||
status="skipped",
|
||||
trace_id=trace,
|
||||
))
|
||||
db.flush()
|
||||
|
||||
@@ -87,12 +87,12 @@ def test_coupon_data_report_returns_scores_without_embedding_details() -> None:
|
||||
started_date=report_date,
|
||||
))
|
||||
db.add_all([
|
||||
CouponClaimEvent(
|
||||
trace_id=trace,
|
||||
CouponClaimRecord(
|
||||
device_id="score-report-device",
|
||||
coupon_id=f"mt-report-{status}",
|
||||
claim_date=report_date,
|
||||
status=status,
|
||||
trace_id=trace,
|
||||
)
|
||||
for status in ("success", "failed")
|
||||
])
|
||||
@@ -128,14 +128,14 @@ def test_coupon_point_details_endpoint() -> None:
|
||||
role="super_admin",
|
||||
)
|
||||
token, _expires_at = create_admin_token(admin_id=admin.id, role=admin.role)
|
||||
db.add(CouponClaimEvent(
|
||||
trace_id=trace,
|
||||
db.add(CouponClaimRecord(
|
||||
device_id="point-details-endpoint-device",
|
||||
coupon_id="mt-point-details-endpoint",
|
||||
coupon_name="接口测试券",
|
||||
claim_date=date(2020, 1, 5),
|
||||
status="failed",
|
||||
reason="接口测试失败",
|
||||
trace_id=trace,
|
||||
))
|
||||
db.commit()
|
||||
|
||||
@@ -156,6 +156,6 @@ def test_coupon_point_details_endpoint() -> None:
|
||||
}
|
||||
finally:
|
||||
db.rollback()
|
||||
db.execute(delete(CouponClaimEvent).where(CouponClaimEvent.trace_id == trace))
|
||||
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.trace_id == trace))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy import delete, select
|
||||
|
||||
from app.admin.repositories.coupon_data import coupon_slot_report
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.coupon_state import CouponClaimEvent, CouponClaimRecord, CouponSession
|
||||
from app.models.coupon_state import CouponClaimRecord, CouponSession
|
||||
from app.repositories.coupon_state import record_claims, session_app_env
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ def test_record_claims_stamps_app_env() -> None:
|
||||
assert row.app_env == "prod"
|
||||
assert row.status == "already_claimed"
|
||||
finally:
|
||||
db.execute(delete(CouponClaimEvent).where(CouponClaimEvent.trace_id == "t-stamp"))
|
||||
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == dev))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user