Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d1eaf5829 | |||
| 32266742b5 | |||
| f868414966 | |||
| 900cc83d38 |
@@ -0,0 +1,78 @@
|
||||
"""feedback review fields
|
||||
|
||||
Revision ID: feedback_review_fields
|
||||
Revises: ceb286289426
|
||||
Create Date: 2026-06-22 20:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "feedback_review_fields"
|
||||
down_revision: str | Sequence[str] | None = "ceb286289426"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("feedback", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("reject_reason", sa.String(length=256), nullable=True))
|
||||
batch_op.add_column(sa.Column("reward_coins", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("review_note", sa.String(length=256), nullable=True))
|
||||
batch_op.add_column(sa.Column("reviewed_by_admin_id", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.create_index(batch_op.f("ix_feedback_status"), ["status"], unique=False)
|
||||
batch_op.create_foreign_key(
|
||||
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
|
||||
"admin_user",
|
||||
["reviewed_by_admin_id"],
|
||||
["id"],
|
||||
)
|
||||
|
||||
feedback = sa.table(
|
||||
"feedback",
|
||||
sa.column("status", sa.String(length=16)),
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "new")
|
||||
.values(status="pending")
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "handled")
|
||||
.values(status="adopted")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
feedback = sa.table(
|
||||
"feedback",
|
||||
sa.column("status", sa.String(length=16)),
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "pending")
|
||||
.values(status="new")
|
||||
)
|
||||
op.execute(
|
||||
feedback.update()
|
||||
.where(feedback.c.status == "adopted")
|
||||
.values(status="handled")
|
||||
)
|
||||
|
||||
with op.batch_alter_table("feedback", schema=None) as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
batch_op.f("fk_feedback_reviewed_by_admin_id_admin_user"),
|
||||
type_="foreignkey",
|
||||
)
|
||||
batch_op.drop_index(batch_op.f("ix_feedback_status"))
|
||||
batch_op.drop_column("reviewed_at")
|
||||
batch_op.drop_column("reviewed_by_admin_id")
|
||||
batch_op.drop_column("review_note")
|
||||
batch_op.drop_column("reward_coins")
|
||||
batch_op.drop_column("reject_reason")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""meituan_coupon image size & type
|
||||
|
||||
给 meituan_coupon 增加 image_size(字节)/ image_type(MIME)两列,采集时 HEAD 头图得到,
|
||||
供读取侧按图片大小决定是否拼缩放参数提速。两列均 nullable,不动存量行;下一轮全量 ETL
|
||||
upsert 自动回填,无需手动 backfill。
|
||||
|
||||
Revision ID: meituan_coupon_image_meta
|
||||
Revises: feedback_review_fields
|
||||
Create Date: 2026-06-23 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "meituan_coupon_image_meta"
|
||||
down_revision: str | Sequence[str] | None = "feedback_review_fields"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("image_size", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("image_type", sa.String(length=32), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("meituan_coupon", schema=None) as batch_op:
|
||||
batch_op.drop_column("image_type")
|
||||
batch_op.drop_column("image_size")
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="运营内部审核备注")
|
||||
|
||||
+50
-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,13 +10,19 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import media
|
||||
from app.repositories import feedback as feedback_repo
|
||||
from app.repositories import feedback_qr as feedback_qr_repo
|
||||
from app.schemas.feedback import FeedbackOut, FeedbackQrConfigOut
|
||||
from app.schemas.feedback import (
|
||||
FeedbackOut,
|
||||
FeedbackQrConfigOut,
|
||||
FeedbackRecordCountsOut,
|
||||
FeedbackRecordOut,
|
||||
FeedbackRecordsOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.feedback")
|
||||
|
||||
@@ -24,6 +31,27 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
||||
_MAX_IMAGES = 6
|
||||
_CONTENT_MAX = 200
|
||||
_CONTACT_MAX = 128
|
||||
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
|
||||
|
||||
|
||||
def _app_status(db_status: str) -> str:
|
||||
return {
|
||||
"new": "pending",
|
||||
"handled": "adopted",
|
||||
"approved": "adopted",
|
||||
}.get(db_status, db_status)
|
||||
|
||||
|
||||
def _record_out(fb) -> FeedbackRecordOut:
|
||||
return FeedbackRecordOut(
|
||||
id=fb.id,
|
||||
content=fb.content,
|
||||
images=fb.images or [],
|
||||
status=_app_status(fb.status),
|
||||
reject_reason=getattr(fb, "reject_reason", None),
|
||||
reward_coins=getattr(fb, "reward_coins", None),
|
||||
created_at=fb.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=FeedbackOut, summary="提交反馈")
|
||||
@@ -67,3 +95,23 @@ async def submit_feedback(
|
||||
def feedback_config(user: CurrentUser, db: DbSession) -> FeedbackQrConfigOut:
|
||||
"""运营后台配的反馈页「加群二维码」卡(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取。"""
|
||||
return FeedbackQrConfigOut(**feedback_qr_repo.get_config(db))
|
||||
|
||||
|
||||
@router.get("/records", response_model=FeedbackRecordsOut, summary="我的反馈历史")
|
||||
def feedback_records(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
status: str | None = Query(default=None),
|
||||
) -> FeedbackRecordsOut:
|
||||
if status is not None and status not in _VALID_RECORD_STATUS:
|
||||
raise HTTPException(status_code=400, detail="invalid status")
|
||||
|
||||
all_records = [_record_out(fb) for fb in feedback_repo.list_feedback(db, user_id=user.id)]
|
||||
counts = FeedbackRecordCountsOut(
|
||||
all=len(all_records),
|
||||
pending=sum(1 for r in all_records if r.status == "pending"),
|
||||
adopted=sum(1 for r in all_records if r.status == "adopted"),
|
||||
rejected=sum(1 for r in all_records if r.status == "rejected"),
|
||||
)
|
||||
records = [r for r in all_records if r.status == status] if status else all_records
|
||||
return FeedbackRecordsOut(records=records, counts=counts)
|
||||
|
||||
@@ -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)。
|
||||
|
||||
+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
|
||||
|
||||
@@ -55,6 +55,10 @@ class MeituanCoupon(Base):
|
||||
sell_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
original_price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
head_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# 头图字节大小 / MIME 类型:采集时 HEAD head_url 得到(Content-Length / Content-Type)。
|
||||
# 读取侧已统一压缩(不再按大小判定),这两列保留用于分析/监控(GIF 占比、体积分布等)。
|
||||
image_size: Mapped[int | None] = mapped_column(Integer, nullable=True) # 字节
|
||||
image_type: Mapped[str | None] = mapped_column(String(32), nullable=True) # 如 image/jpeg
|
||||
|
||||
# ===== 销量(美团只给粗档位:热销1w+;num=排序用的下界数值,如 1w+ → 10000) =====
|
||||
sale_volume: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
@@ -101,8 +101,10 @@ AD_CONFIG_KEY = "ad_config"
|
||||
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
|
||||
"app_id": "5830519", # 穿山甲应用ID(正式)
|
||||
"reward_code_id": "104099389", # 福利页激励视频位
|
||||
"compare_feed_code_id": "104090333", # 比价信息流位
|
||||
"coupon_feed_code_id": "104090333", # 领券信息流位(初始同比价,运营可拆)
|
||||
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」;
|
||||
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准。
|
||||
"compare_feed_code_id": "104142227", # 比价信息流位
|
||||
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆)
|
||||
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
|
||||
"reward_enabled": True, # 福利激励视频开关
|
||||
"compare_ad_enabled": True, # 比价广告开关
|
||||
|
||||
@@ -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())
|
||||
|
||||
+23
-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):
|
||||
@@ -26,3 +26,25 @@ class FeedbackQrConfigOut(BaseModel):
|
||||
group_name: str
|
||||
subtitle: str
|
||||
|
||||
|
||||
class FeedbackRecordOut(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
images: list[str] = Field(default_factory=list)
|
||||
status: str
|
||||
reject_reason: str | None = None
|
||||
reward_coins: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FeedbackRecordCountsOut(BaseModel):
|
||||
all: int = 0
|
||||
pending: int = 0
|
||||
adopted: int = 0
|
||||
rejected: int = 0
|
||||
|
||||
|
||||
class FeedbackRecordsOut(BaseModel):
|
||||
records: list[FeedbackRecordOut]
|
||||
counts: FeedbackRecordCountsOut
|
||||
|
||||
|
||||
+16
-1
@@ -13,6 +13,21 @@ from pydantic import BaseModel, Field
|
||||
FeedStatus = Literal["ok", "empty", "degraded"]
|
||||
|
||||
|
||||
# ───────────────── 头图传输优化(统一缩放 + 转 WebP) ─────────────────
|
||||
# 美团图片 CDN 支持在 URL 后拼 @<w>w_<h>h_1e_1c[.webp] 让服务端缩放/转码。feed 卡片只占
|
||||
# 124dp(≈372px@3x),而库里 head_url 是原图(实测中位 137KB、长尾到 17MB、GIF 均 4.8MB),
|
||||
# 是首页 feed 图片加载偏慢的来源。按产品要求,头图**统一**缩到 124dp + 转 WebP 再下发
|
||||
# (全部压缩、不设大小阈值),由美团 CDN 服务端缩放/转码,实测省 76–99% 体积。
|
||||
FEED_THUMB_PARAM = "@375w_375h_1e_1c.webp" # 124dp@3x 缩略 + 转 WebP
|
||||
|
||||
|
||||
def feed_image_url(head_url: str) -> str:
|
||||
"""统一给头图 URL 拼缩放参数(缩到 124dp + 转 WebP),让美团 CDN 出小图;空 URL 原样返回。"""
|
||||
if not head_url:
|
||||
return head_url
|
||||
return head_url.split("@")[0] + FEED_THUMB_PARAM
|
||||
|
||||
|
||||
# ───────────────── 券卡片(归一化后给客户端) ─────────────────
|
||||
|
||||
class CouponCard(BaseModel):
|
||||
@@ -94,7 +109,7 @@ class CouponCard(BaseModel):
|
||||
platform=platform,
|
||||
biz_line=biz_line,
|
||||
name=cpd.get("name") or "",
|
||||
head_image_url=head_url,
|
||||
head_image_url=feed_image_url(head_url),
|
||||
brand_name=brand.get("brandName"),
|
||||
brand_logo_url=brand.get("brandLogoUrl"),
|
||||
sell_price=sell,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# 美团券头图大小/类型回填 —— 全量重跑一轮 ETL,把 meituan_coupon.image_size / image_type 灌满。
|
||||
# 上线本 PR 后在服务器手动跑一次,立即回填存量券(不必等定时器逐轮 upsert 自然补齐)。
|
||||
#
|
||||
# 用法(服务器, root):
|
||||
# cd /opt/shaguabijia-app-server
|
||||
# sudo bash deploy/backfill_image_meta.sh
|
||||
# # 全量一轮 ~70min,建议在 tmux/screen 里跑,防 SSH 断开中断
|
||||
#
|
||||
# 前提:已拉到含本 PR 的 main、已跑 `alembic upgrade head`(脚本会自检列是否存在)、
|
||||
# 已 restart shaguabijia-app-server.service。服务器 MT_CPS_PROXY 留空(直连 CDN)。
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/shaguabijia-app-server}"
|
||||
PY="$APP_DIR/.venv/bin/python"
|
||||
cd "$APP_DIR"
|
||||
mkdir -p data
|
||||
LOG="data/etl_backfill_$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
# 退出时务必恢复定时器(即使 ETL 中途失败,也不把 timer 留在停用态)
|
||||
restore_timer() { systemctl start meituan-etl.timer 2>/dev/null || true; }
|
||||
trap restore_timer EXIT
|
||||
|
||||
echo "[0/3] 自检 image_size/image_type 列(缺列说明没迁移,先跑 alembic upgrade head)..."
|
||||
"$PY" -c "from sqlalchemy import inspect; from app.db.session import engine; \
|
||||
cols=[c['name'] for c in inspect(engine).get_columns('meituan_coupon')]; \
|
||||
import sys; (('image_size' in cols) and ('image_type' in cols)) or sys.exit(' ❌ 缺列,请先: sudo .venv/bin/python -m alembic upgrade head'); \
|
||||
print(' ✓ 列已就绪')"
|
||||
|
||||
echo "[1/3] 停定时器,防与定时轮次并发占锁..."
|
||||
systemctl stop meituan-etl.timer 2>/dev/null || true
|
||||
|
||||
echo "[2/3] 全量重跑 ETL(~70min;日志 $LOG)..."
|
||||
# 不走 systemctl:meituan-etl.service 有 20min 硬超时,全量一轮跑不完会被掐断。
|
||||
"$PY" -m scripts.pull_meituan_coupons --once --prune-hours 24 2>&1 | tee "$LOG"
|
||||
|
||||
echo "[3/3] 验证填充率..."
|
||||
"$PY" - <<'PYEOF'
|
||||
from sqlalchemy import text
|
||||
from app.db.session import engine
|
||||
engine.echo = False
|
||||
with engine.connect() as c:
|
||||
tot = c.execute(text("SELECT count(*) FROM meituan_coupon")).scalar()
|
||||
enr = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_size IS NOT NULL")).scalar()
|
||||
gif = c.execute(text("SELECT count(*) FROM meituan_coupon WHERE image_type='image/gif'")).scalar()
|
||||
print(f" 总 {tot} 行 | 有 image_size {enr} ({enr*100//max(tot,1)}%) | GIF {gif} 张")
|
||||
PYEOF
|
||||
echo "✅ 回填完成(定时器已恢复)。"
|
||||
+1
-1
@@ -173,7 +173,7 @@
|
||||
| `platform` | int | `1`=外卖/到家, `2`=到店 |
|
||||
| `biz_line` | int \| null | 到店子类:1到餐 2到综 3酒店 4门票 |
|
||||
| `name` | string | 商品名 |
|
||||
| `head_image_url` | string | 头图 |
|
||||
| `head_image_url` | string | 头图;后端已**统一**拼缩放参数 `@375w_375h_1e_1c.webp`(美团 CDN 服务端缩到 124dp + 转 WebP,实测省 76–99%),客户端直接用 |
|
||||
| `brand_name` | string \| null | 品牌名 |
|
||||
| `brand_logo_url` | string \| null | 品牌 logo |
|
||||
| `sell_price` | string | 现价 |
|
||||
|
||||
@@ -52,6 +52,7 @@ try:
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
import httpx # noqa: E402
|
||||
from sqlalchemy import delete, func, select # noqa: E402
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert # noqa: E402
|
||||
|
||||
@@ -85,6 +86,13 @@ DEFAULT_CONCURRENCY = 12 # 并发城市数(实测 15 并发 402 占 3% 可退
|
||||
STARTUP_STAGGER = 0.3 # 首批城市启动错峰间隔秒(削平瞬时峰值,实测能压低 402)
|
||||
PRUNE_FAIL_RATIO_MAX = 0.05 # 失败城占比超此值则本轮跳过 prune(避免大面积抓取失败误删库)
|
||||
|
||||
# 头图大小/类型富集:每城抓完后,按 head_url 去重并发 HEAD 取 Content-Length / Content-Type,
|
||||
# 写进 image_size / image_type。图片走美团图片 CDN(p*.meituan.net / img.meituan.net),直连可达、
|
||||
# 与 CPS 网关 402 限流无关;任何失败 → None,不阻断入库。城市级已 12 并发,这里每城再开 8,
|
||||
# 叠加峰值 ~96 个 HEAD,CDN 扛得住(实测单进程 50 并发稳)。
|
||||
IMAGE_META_WORKERS = 8 # 每城 HEAD 并发数
|
||||
IMAGE_META_TIMEOUT = 8.0 # 单图 HEAD 超时秒
|
||||
|
||||
SOURCES = [
|
||||
{"code": "search_waimai", "label": "外卖·搜外卖", "kind": "search", "platform": 1, "keyword": "外卖"},
|
||||
{"code": "search_meishi", "label": "外卖·搜美食", "kind": "search", "platform": 1, "keyword": "美食"},
|
||||
@@ -256,6 +264,9 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
|
||||
"sell_price_cents": price_cents,
|
||||
"original_price_cents": _to_cents(cpd.get("originalPrice")),
|
||||
"head_url": ((cpd.get("headUrl") or "").split("@")[0][:512] or None),
|
||||
# 占位;由 _fill_image_meta 抓完本城后并发 HEAD 回填(键必须在,upsert 才会带上这两列)
|
||||
"image_size": None,
|
||||
"image_type": None,
|
||||
"sale_volume": cpd.get("saleVolume"),
|
||||
"sale_volume_num": _sale_volume_num(cpd.get("saleVolume")),
|
||||
"commission_percent": comm_pct,
|
||||
@@ -268,6 +279,38 @@ def _parse_item(item: dict, source: dict, city_id: str) -> dict | None:
|
||||
})
|
||||
|
||||
|
||||
def _head_image_meta(client: httpx.Client, url: str) -> tuple[int | None, str | None]:
|
||||
"""HEAD 单张头图,取 (字节大小, MIME 类型);任何失败返回 (None, None),不阻断入库。"""
|
||||
try:
|
||||
r = client.head(url, timeout=IMAGE_META_TIMEOUT, follow_redirects=True)
|
||||
if r.status_code >= 400:
|
||||
return None, None
|
||||
cl = r.headers.get("content-length")
|
||||
size = int(cl) if cl and cl.isdigit() else None
|
||||
ctype = (r.headers.get("content-type") or "").split(";")[0].strip()[:32] or None
|
||||
return size, ctype
|
||||
except Exception: # noqa: BLE001
|
||||
return None, None
|
||||
|
||||
|
||||
def _fill_image_meta(rows: list[dict]) -> None:
|
||||
"""就地为本城 rows 填 image_size / image_type:按 head_url 去重后并发 HEAD,再回填每行。
|
||||
trust_env=False 直连图片 CDN(绕本机代理,CDN 无需走 CPS 代理)。"""
|
||||
urls = {r["head_url"] for r in rows if r.get("head_url")}
|
||||
if not urls:
|
||||
return
|
||||
meta: dict[str, tuple[int | None, str | None]] = {}
|
||||
with httpx.Client(trust_env=False, timeout=IMAGE_META_TIMEOUT) as client:
|
||||
with ThreadPoolExecutor(max_workers=IMAGE_META_WORKERS) as pool:
|
||||
futs = {pool.submit(_head_image_meta, client, u): u for u in urls}
|
||||
for f in as_completed(futs):
|
||||
meta[futs[f]] = f.result()
|
||||
for r in rows:
|
||||
m = meta.get(r.get("head_url"))
|
||||
if m:
|
||||
r["image_size"], r["image_type"] = m
|
||||
|
||||
|
||||
def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) -> list[dict]:
|
||||
"""抓单个城市的 3 路券并解析。worker 线程内执行(只抓取+解析,不碰 DB)。
|
||||
|
||||
@@ -286,6 +329,7 @@ def _pull_one_city(city: dict, index: int, concurrency: int, stagger: float) ->
|
||||
p = _parse_item(it, src, cid)
|
||||
if p:
|
||||
parsed.append(p)
|
||||
_fill_image_meta(parsed) # 抓完本城 → 并发 HEAD 回填头图 image_size/image_type
|
||||
return parsed
|
||||
|
||||
|
||||
|
||||
@@ -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,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