feat: 反馈加来源/场景/运营回复 + 提现类型筛选 (#105)

用户反馈后台:区分反馈类型(比价反馈/普通反馈)+ 审核可给用户留言;提现后台:按提现类型筛选。

- feedback 表加 source(profile/comparison)/scene/admin_reply + 迁移;提交接口 /api/v1/feedback
  接收 source/scene,来源判定显式 source 优先、否则据 scene 有无派生(有=comparison);
  /records 带回 scene + admin_reply。
- admin 反馈:列表加「反馈类型」筛选;采纳/拒绝支持存 admin_reply(给用户的回复,用户端可见);
  FeedbackOut 带出 source/scene/admin_reply。
- admin 提现:WithdrawOut 暴露 source,列表加「提现类型」筛选(coin_cash=福利页提现 / invite_cash=邀请提现)。
- 补 4 项测试:来源派生、回复存取、反馈按类型筛选、提现按类型筛选。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #105
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
This commit was merged in pull request #105.
This commit is contained in:
2026-07-02 19:50:14 +08:00
committed by marco
parent 4630fd017b
commit 4512b6ecac
14 changed files with 254 additions and 7 deletions
+48
View File
@@ -0,0 +1,48 @@
"""feedback 加反馈来源/场景/运营回复(source / scene / admin_reply)
admin「用户反馈」页要展示并筛选「反馈类型」(比价反馈 / 普通反馈),并支持审核时给用户留言:
- source: 反馈来源入口(profile=「我的」页 / comparison=比价结果页)。NOT NULL,
旧数据 + 普通反馈默认 profile(server_default)。加索引供 admin 按类型筛选。
- scene: 比价反馈的问题场景(找错商品/优惠不对…),普通反馈为 NULL。
- admin_reply: 运营给用户的回复留言(用户端可见),随「我的反馈」历史下发。
均为新增列(SQLite 原生支持 add_column);downgrade 的 drop_column 在 SQLite 走 batch 兜底。
Revision ID: feedback_type_reply
Revises: 761ef181ce7c
Create Date: 2026-07-02 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "feedback_type_reply"
down_revision: str | Sequence[str] | None = "761ef181ce7c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"feedback",
sa.Column(
"source",
sa.String(length=16),
nullable=False,
server_default="profile",
),
)
op.add_column("feedback", sa.Column("scene", sa.String(length=32), nullable=True))
op.add_column("feedback", sa.Column("admin_reply", sa.String(length=256), nullable=True))
op.create_index("ix_feedback_source", "feedback", ["source"])
def downgrade() -> None:
with op.batch_alter_table("feedback") as batch_op:
batch_op.drop_index("ix_feedback_source")
batch_op.drop_column("admin_reply")
batch_op.drop_column("scene")
batch_op.drop_column("source")
+4 -1
View File
@@ -65,16 +65,19 @@ def review_feedback(
reward_coins: int | None = None,
reject_reason: str | None = None,
review_note: str | None = None,
admin_reply: str | None = None,
commit: bool = True,
) -> Feedback:
"""审核反馈:置 adopted/rejected + 记录奖励/原因/审核人/审核时间。
"""审核反馈:置 adopted/rejected + 记录奖励/原因/回复留言/审核人/审核时间。
发金币(wallet.grant_coins)由 router 在同一事务里调,确保状态、金币流水、审计一起提交。
admin_reply=给用户的回复留言(用户端可见),与 review_note(内部备注)区分。
"""
feedback.status = status
feedback.reward_coins = reward_coins
feedback.reject_reason = reject_reason
feedback.review_note = review_note
feedback.admin_reply = admin_reply
feedback.reviewed_by_admin_id = reviewed_by_admin_id
feedback.reviewed_at = datetime.now(CN_TZ).replace(tzinfo=None)
if commit:
+11 -4
View File
@@ -390,6 +390,7 @@ def list_all_withdraw_orders(
*,
user_id: int | None = None,
status: str | None = None,
source: str | None = None,
keyword: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
@@ -409,6 +410,9 @@ def list_all_withdraw_orders(
stmt = stmt.where(WithdrawOrder.user_id == user_id)
if status:
stmt = stmt.where(WithdrawOrder.status == status)
# 提现类型:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
if source:
stmt = stmt.where(WithdrawOrder.source == source)
kw = (keyword or "").strip()
if kw:
@@ -534,6 +538,7 @@ def list_feedbacks(
db: Session,
*,
status: str | None = None,
source: str | None = None,
user_id: int | None = None,
content: str | None = None,
created_from: datetime | None = None,
@@ -543,13 +548,15 @@ def list_feedbacks(
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[Feedback], int | None, int]:
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间
数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。
created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
"""反馈工单列表。支持 状态 / 反馈类型(source) / 用户ID / 内容模糊 / 提交时间范围 筛选,
按 id·提交时间排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),
total 供页码分页。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。"""
stmt = select(Feedback)
if status:
stmt = stmt.where(Feedback.status == status)
if source:
stmt = stmt.where(Feedback.source == source)
if user_id is not None:
stmt = stmt.where(Feedback.user_id == user_id)
if content and content.strip():
+7
View File
@@ -36,6 +36,8 @@ def _ensure_pending(fb: Feedback) -> None:
def list_feedbacks(
db: AdminDb,
status: Annotated[str | None, Query()] = None,
# 反馈类型筛选:profile(普通反馈)/ comparison(比价反馈);None=全部
source: Annotated[str | None, Query(pattern="^(profile|comparison)$")] = None,
user_id: Annotated[int | None, Query()] = None,
content: Annotated[str | None, Query(max_length=100)] = None,
created_from: Annotated[datetime | None, Query()] = None,
@@ -48,6 +50,7 @@ def list_feedbacks(
items, next_cursor, total = queries.list_feedbacks(
db,
status=status,
source=source,
user_id=user_id,
content=content,
created_from=created_from,
@@ -101,6 +104,7 @@ def approve_feedback(
status="adopted",
reward_coins=payload.reward_coins,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
@@ -123,6 +127,7 @@ def approve_feedback(
"after": "adopted",
"reward_coins": payload.reward_coins,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
@@ -152,6 +157,7 @@ def reject_feedback(
status="rejected",
reject_reason=payload.reason,
review_note=payload.note,
admin_reply=payload.reply,
reviewed_by_admin_id=admin.id,
commit=False,
)
@@ -166,6 +172,7 @@ def reject_feedback(
"after": "rejected",
"reason": payload.reason,
"note": payload.note,
"reply": payload.reply,
},
ip=get_client_ip(request),
commit=False,
+3
View File
@@ -50,6 +50,8 @@ def list_withdraws(
db: AdminDb,
user_id: Annotated[int | None, Query()] = None,
status: Annotated[str | None, Query()] = None,
# 提现类型筛选:coin_cash(福利页提现)/ invite_cash(邀请提现);None=全部
source: Annotated[str | None, Query(pattern="^(coin_cash|invite_cash)$")] = None,
keyword: Annotated[str | None, Query(max_length=100)] = None,
date_from: Annotated[datetime | None, Query()] = None,
date_to: Annotated[datetime | None, Query()] = None,
@@ -69,6 +71,7 @@ def list_withdraws(
db,
user_id=user_id,
status=status,
source=source,
keyword=keyword,
date_from=date_from,
date_to=date_to,
+9 -1
View File
@@ -15,11 +15,17 @@ class FeedbackOut(BaseModel):
user_id: int
content: str
contact: str
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)——admin 展示/筛选「反馈类型」
source: str = "profile"
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] | None = None
status: str
reject_reason: str | None = None
reward_coins: int | None = None
review_note: str | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可填)
admin_reply: str | None = None
reviewed_by_admin_id: int | None = None
reviewed_at: datetime | None = None
created_at: datetime
@@ -39,12 +45,14 @@ class FeedbackApproveRequest(BaseModel):
le=FEEDBACK_REWARD_MAX_COINS,
description="采纳后发放金币数",
)
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注")
note: str | None = Field(default=None, max_length=256, description="采纳要点/审核备注(内部)")
reply: 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="运营内部审核备注")
reply: str | None = Field(default=None, max_length=256, description="给用户的回复留言,用户端可见")
class FeedbackSummary(BaseModel):
+2
View File
@@ -41,6 +41,8 @@ class WithdrawOrderOut(BaseModel):
user_id: int
out_bill_no: str
amount_cents: int
# 提现类型:coin_cash(福利页提现,金币兑换的现金)/ invite_cash(邀请提现,邀请奖励金)
source: str = "coin_cash"
user_name: str | None = None # 提现实名(审核核对 + 打款用)
status: str
wechat_state: str | None = None
+24 -1
View File
@@ -31,7 +31,18 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 6
_CONTENT_MAX = 200
_CONTACT_MAX = 128
_SCENE_MAX = 32
_VALID_RECORD_STATUS = {"pending", "adopted", "rejected"}
_VALID_SOURCES = {"profile", "comparison"}
def _resolve_source(source: str, scene: str) -> str:
"""反馈来源:客户端显式传的 source 优先(profile / comparison);未传或非法时,
scene 有无派生比价结果页反馈才带 scene, scene 非空即视作 comparison"""
src = source.strip().lower()
if src in _VALID_SOURCES:
return src
return "comparison" if scene.strip() else "profile"
def _app_status(db_status: str) -> str:
@@ -46,10 +57,12 @@ def _record_out(fb) -> FeedbackRecordOut:
return FeedbackRecordOut(
id=fb.id,
content=fb.content,
scene=getattr(fb, "scene", None),
images=fb.images or [],
status=_app_status(fb.status),
reject_reason=getattr(fb, "reject_reason", None),
reward_coins=getattr(fb, "reward_coins", None),
admin_reply=getattr(fb, "admin_reply", None),
created_at=fb.created_at,
)
@@ -61,6 +74,11 @@ async def submit_feedback(
content: str = Form(...),
# 原型改版后客户端不再采集联系方式;保留字段以兼容旧端 + 后续可能复用,默认空串。
contact: str = Form(default=""),
# 反馈来源入口:profile(「我的」页)/ comparison(比价结果页)。新端显式传;旧端不带 →
# 空串 → 按 scene 有无派生。admin 据此展示/筛选「反馈类型」。
source: str = Form(default=""),
# 比价结果页反馈的「问题场景」(找错商品/优惠不对…);普通反馈不带 → 空串 → 存 NULL。
scene: str = Form(default=""),
# 提交端环境快照(admin 反馈页展示「提交版本号」「机型OS版本」);旧端不带 → 空串 → 存 NULL。
app_version: str = Form(default=""),
device_model: str = Form(default=""),
@@ -76,6 +94,8 @@ async def submit_feedback(
raise HTTPException(status_code=400, detail="反馈内容过长")
if len(contact) > _CONTACT_MAX:
raise HTTPException(status_code=400, detail="联系方式过长")
scene = scene.strip()[:_SCENE_MAX]
resolved_source = _resolve_source(source, scene)
files = [f for f in (images or []) if f is not None and f.filename]
if len(files) > _MAX_IMAGES:
@@ -91,10 +111,13 @@ async def submit_feedback(
fb = feedback_repo.create_feedback(
db, user_id=user.id, content=content, contact=contact, images=urls,
source=resolved_source, scene=scene or None,
app_version=app_version.strip(), device_model=device_model.strip(),
rom_name=rom_name.strip(), android_version=android_version.strip(),
)
logger.info("feedback id=%d user_id=%d images=%d", fb.id, user.id, len(urls))
logger.info(
"feedback id=%d user_id=%d source=%s images=%d", fb.id, user.id, resolved_source, len(urls)
)
return FeedbackOut.model_validate(fb)
+11
View File
@@ -23,6 +23,14 @@ class Feedback(Base):
)
content: Mapped[str] = mapped_column(Text, nullable=False)
contact: Mapped[str] = mapped_column(String(128), nullable=False)
# 反馈来源入口:profile(「我的」页反馈入口)/ comparison(比价结果页反馈入口)。
# admin 据此展示/筛选「反馈类型」。旧数据与「我的」页反馈均为 profile(server_default)。
# 客户端显式传 source 优先;未传时由 scene 有无派生(见 api/v1/feedback.submit_feedback)。
source: Mapped[str] = mapped_column(
String(16), nullable=False, default="profile", server_default="profile", index=True
)
# 比价反馈的「问题场景」(找错商品/优惠不对/比价太慢…),比价结果页反馈才有;普通反馈为 NULL。
scene: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
# 提交时的端环境快照(admin 排查用;客户端改版带上后的新反馈才有,历史数据为 NULL)
@@ -36,6 +44,9 @@ class Feedback(Base):
reward_coins: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 审核批注:采纳时可写采纳要点,未采纳时也可保留运营侧备注
review_note: Mapped[str | None] = mapped_column(String(256), nullable=True)
# 运营给用户的回复留言(**用户端可见**,采纳/未采纳都可填,随「我的反馈」历史下发)。
# 与 review_note(内部备注,用户不可见)、reject_reason(未采纳原因)区分开。
admin_reply: 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
)
+4
View File
@@ -17,6 +17,8 @@ def create_feedback(
content: str,
contact: str,
images: list[str] | None,
source: str = "profile",
scene: str | None = None,
app_version: str | None = None,
device_model: str | None = None,
rom_name: str | None = None,
@@ -26,6 +28,8 @@ def create_feedback(
user_id=user_id,
content=content,
contact=contact,
source=source,
scene=scene or None,
images=images or None,
app_version=app_version or None,
device_model=device_model or None,
+4
View File
@@ -30,10 +30,14 @@ class FeedbackQrConfigOut(BaseModel):
class FeedbackRecordOut(BaseModel):
id: int
content: str
# 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 None
scene: str | None = None
images: list[str] = Field(default_factory=list)
status: str
reject_reason: str | None = None
reward_coins: int | None = None
# 运营给用户的回复留言(用户端可见,采纳/未采纳都可能有)
admin_reply: str | None = None
created_at: datetime
+61
View File
@@ -190,6 +190,67 @@ def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
assert all(f["status"] == "pending" for f in r.json()["items"])
def test_feedback_list_filter_by_source(admin_client: TestClient, admin_token: str) -> None:
"""反馈列表按反馈类型(source)筛选;返回项带 source/scene 字段。"""
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800009001", register_channel="sms").id
db.add(Feedback(user_id=uid, content="比价反馈", contact="", status="pending",
source="comparison", scene="找错商品"))
db.add(Feedback(user_id=uid, content="普通反馈", contact="", status="pending",
source="profile"))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/feedbacks", params={"user_id": uid, "source": "comparison"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
items = r.json()["items"]
assert items and all(it["source"] == "comparison" for it in items)
assert any(it["scene"] == "找错商品" for it in items)
r = admin_client.get(
"/admin/api/feedbacks", params={"user_id": uid, "source": "profile"},
headers=_auth(admin_token),
)
assert all(it["source"] == "profile" for it in r.json()["items"])
# 非法 source 值 → 422
assert admin_client.get(
"/admin/api/feedbacks", params={"source": "bogus"}, headers=_auth(admin_token)
).status_code == 422
def test_withdraw_list_exposes_source_and_filters(
admin_client: TestClient, admin_token: str
) -> None:
"""提现列表带 source 字段(coin_cash/invite_cash),并可按提现类型筛选。"""
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800009002", register_channel="sms").id
db.add(WithdrawOrder(user_id=uid, out_bill_no=f"src{uid}coin0001",
amount_cents=100, status="success", source="coin_cash"))
db.add(WithdrawOrder(user_id=uid, out_bill_no=f"src{uid}invite001",
amount_cents=200, status="success", source="invite_cash"))
db.commit()
finally:
db.close()
r = admin_client.get("/admin/api/withdraws", params={"user_id": uid}, headers=_auth(admin_token))
assert r.status_code == 200, r.text
assert {it["source"] for it in r.json()["items"]} == {"coin_cash", "invite_cash"}
r = admin_client.get(
"/admin/api/withdraws", params={"user_id": uid, "source": "invite_cash"},
headers=_auth(admin_token),
)
items = r.json()["items"]
assert items and all(it["source"] == "invite_cash" for it in items)
def test_ad_coin_audit_full_count_truncate_and_only_mismatch(
admin_client: TestClient, admin_token: str
) -> None:
+29
View File
@@ -368,6 +368,35 @@ def test_reject_feedback_records_reason_and_no_coin(
db.close()
def test_feedback_review_stores_admin_reply(
admin_client: TestClient, operator_token: str
) -> None:
"""审核可给用户留言(admin_reply,用户端可见),采纳/拒绝都能带,并回显在响应里。"""
fid = _seed_feedback("13900000021")
r = admin_client.post(
f"/admin/api/feedbacks/{fid}/approve",
json={"reward_coins": 100, "note": "内部备注", "reply": "感谢反馈,已采纳~"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["admin_reply"] == "感谢反馈,已采纳~"
db = SessionLocal()
try:
fb = db.get(Feedback, fid)
assert fb.admin_reply == "感谢反馈,已采纳~" and fb.review_note == "内部备注"
finally:
db.close()
fid2 = _seed_feedback("13900000022")
r = admin_client.post(
f"/admin/api/feedbacks/{fid2}/reject",
json={"reason": "无法复现", "reply": "已收到,后续跟进"},
headers=_auth(operator_token),
)
assert r.status_code == 200, r.text
assert r.json()["admin_reply"] == "已收到,后续跟进"
# ===== admin 账号管理(super_admin) =====
def test_create_and_update_admin(admin_client: TestClient, super_token: str) -> None:
+37
View File
@@ -36,6 +36,43 @@ def test_feedback_config_returns_default(client) -> None:
assert body["group_name"] == "傻瓜比价官方群"
def test_submit_feedback_derives_source_from_scene(client) -> None:
"""比价结果页反馈带 scene → source=comparison 并存 scene;「我的」页不带 → profile;
客户端显式传 source 优先(即便没 scene 也判 comparison)历史接口带回 scene"""
token = _login(client, "13622000010")
h = {"Authorization": f"Bearer {token}"}
r = client.post("/api/v1/feedback", data={"content": "比价太慢了", "scene": "比价太慢"}, headers=h)
assert r.status_code == 200, r.text
fid_cmp = r.json()["id"]
r = client.post("/api/v1/feedback", data={"content": "普通建议"}, headers=h)
assert r.status_code == 200, r.text
fid_prof = r.json()["id"]
r = client.post(
"/api/v1/feedback", data={"content": "只填了描述", "source": "comparison"}, headers=h
)
assert r.status_code == 200, r.text
fid_explicit = r.json()["id"]
db = SessionLocal()
try:
cmp_fb = db.get(Feedback, fid_cmp)
assert cmp_fb.source == "comparison" and cmp_fb.scene == "比价太慢"
prof_fb = db.get(Feedback, fid_prof)
assert prof_fb.source == "profile" and prof_fb.scene is None
assert db.get(Feedback, fid_explicit).source == "comparison"
finally:
db.close()
r = client.get("/api/v1/feedback/records", headers=h)
assert r.status_code == 200, r.text
scenes = {item["id"]: item["scene"] for item in r.json()["records"]}
assert scenes[fid_cmp] == "比价太慢"
assert scenes[fid_prof] is None
def test_feedback_records_maps_existing_statuses(client) -> None:
phone = "13622000003"
token = _login(client, phone)