Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65ce011db7 | |||
| f868414966 | |||
| 900cc83d38 | |||
| 3cab75b6ac |
@@ -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")
|
||||||
@@ -56,6 +56,35 @@ def update_feedback_status(
|
|||||||
return feedback
|
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(
|
def review_price_report(
|
||||||
db: Session,
|
db: Session,
|
||||||
report: PriceReport,
|
report: PriceReport,
|
||||||
|
|||||||
@@ -493,15 +493,19 @@ def withdraw_risk_flags(
|
|||||||
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
|
created_at = user.created_at.replace(tzinfo=timezone.utc) if user.created_at.tzinfo is None else user.created_at
|
||||||
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
|
if datetime.now(timezone.utc) - created_at < timedelta(hours=24):
|
||||||
flags.append("新注册用户")
|
flags.append("新注册用户")
|
||||||
failed_or_rejected = sum(1 for item in recent_withdraws if item.status in {"failed", "rejected"})
|
# 历史异常提现拆「拒绝」「失败」两类(口径不同:拒绝=人工驳回退款,失败=打款失败退款)
|
||||||
if failed_or_rejected:
|
rejected_n = sum(1 for item in recent_withdraws if item.status == "rejected")
|
||||||
flags.append(f"历史异常提现{failed_or_rejected}笔")
|
failed_n = sum(1 for item in recent_withdraws if item.status == "failed")
|
||||||
|
if rejected_n:
|
||||||
|
flags.append(f"提现拒绝{rejected_n}笔")
|
||||||
|
if failed_n:
|
||||||
|
flags.append(f"提现失败{failed_n}笔")
|
||||||
recent_reviewing = sum(1 for item in recent_withdraws if item.status == "reviewing")
|
recent_reviewing = sum(1 for item in recent_withdraws if item.status == "reviewing")
|
||||||
if recent_reviewing >= 3:
|
if recent_reviewing >= 3:
|
||||||
flags.append(f"待审核提现偏多:{recent_reviewing}笔")
|
flags.append(f"待审核提现偏多:{recent_reviewing}笔")
|
||||||
if cash_balance_cents < 0:
|
if cash_balance_cents < 0:
|
||||||
flags.append("现金余额为负")
|
flags.append("现金余额为负")
|
||||||
score = min(100, len(flags) * 20 + failed_or_rejected * 10)
|
score = min(100, len(flags) * 20 + (rejected_n + failed_n) * 10)
|
||||||
return flags, score
|
return flags, score
|
||||||
|
|
||||||
|
|
||||||
@@ -688,8 +692,8 @@ def user_coin_records(
|
|||||||
date_to: datetime | None = None,
|
date_to: datetime | None = None,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
cursor: int | None = None,
|
cursor: int | None = None,
|
||||||
) -> tuple[list[dict], int | None]:
|
) -> tuple[list[dict], int | None, int]:
|
||||||
"""金币发放记录(提现详情底部表),按时间倒序、offset 游标分页。
|
"""金币发放记录(提现详情底部表),按时间倒序、offset 游标分页 + 总数。
|
||||||
|
|
||||||
合并三类来源(Phase1 信息流不分比价/领券):
|
合并三类来源(Phase1 信息流不分比价/领券):
|
||||||
- 激励视频 / 签到膨胀 = ad_reward_record(granted)
|
- 激励视频 / 签到膨胀 = ad_reward_record(granted)
|
||||||
@@ -758,7 +762,29 @@ def user_coin_records(
|
|||||||
|
|
||||||
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
rows.sort(key=lambda r: r["created_at"], reverse=True)
|
||||||
has_more = len(rows) > offset + limit
|
has_more = len(rows) > offset + limit
|
||||||
return rows[offset:offset + limit], (offset + limit if has_more else None)
|
|
||||||
|
# 总数 = 三源在窗口内 granted 计数之和(供前端页码分页渲染页码/共 N 条)
|
||||||
|
def _count(model, *conds) -> int:
|
||||||
|
return int(db.execute(select(func.count()).select_from(model).where(*conds)).scalar_one())
|
||||||
|
|
||||||
|
total = (
|
||||||
|
_count(
|
||||||
|
AdRewardRecord, AdRewardRecord.user_id == user_id,
|
||||||
|
AdRewardRecord.status == "granted",
|
||||||
|
*_window_conds(AdRewardRecord.created_at, date_from, date_to),
|
||||||
|
)
|
||||||
|
+ _count(
|
||||||
|
AdFeedRewardRecord, AdFeedRewardRecord.user_id == user_id,
|
||||||
|
AdFeedRewardRecord.status == "granted",
|
||||||
|
*_window_conds(AdFeedRewardRecord.created_at, date_from, date_to),
|
||||||
|
)
|
||||||
|
+ _count(
|
||||||
|
CoinTransaction, CoinTransaction.user_id == user_id,
|
||||||
|
CoinTransaction.biz_type == "signin",
|
||||||
|
*_window_conds(CoinTransaction.created_at, date_from, date_to),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows[offset:offset + limit], (offset + limit if has_more else None), total
|
||||||
|
|
||||||
|
|
||||||
def list_price_reports(
|
def list_price_reports(
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ def dashboard_overview(db: Session) -> dict:
|
|||||||
"success": comparison_success,
|
"success": comparison_success,
|
||||||
"success_rate": success_rate,
|
"success_rate": success_rate,
|
||||||
},
|
},
|
||||||
"feedback": {"new": _count(Feedback, Feedback.status == "new")},
|
"feedback": {"new": _count(Feedback, Feedback.status.in_(("pending", "new")))},
|
||||||
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
# CPS 收入数据源未接(referral-link 只换链接,转化/佣金未回收)→ 前端显示"待接入"。
|
||||||
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
"cps": {"available": False, "note": "CPS 转化数据未接入(P2)"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""admin 反馈工单:列表(读,支持 状态/用户ID/内容/时间 筛选 + 排序)+ 标记已处理(写,带审计)。"""
|
"""admin 反馈工单:列表筛选 + 审核采纳/拒绝(带金币发放与审计)。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
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.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
||||||
from app.admin.repositories import mutations, queries
|
from app.admin.repositories import mutations, queries
|
||||||
from app.admin.schemas.common import CursorPage, OkResponse
|
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.admin import AdminUser
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
|
from app.repositories import wallet as wallet_repo
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/admin/api/feedbacks",
|
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="反馈工单列表")
|
@router.get("", response_model=CursorPage[FeedbackOut], summary="反馈工单列表")
|
||||||
def list_feedbacks(
|
def list_feedbacks(
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
@@ -56,18 +62,104 @@ def list_feedbacks(
|
|||||||
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
@router.post("/{feedback_id}/handle", response_model=OkResponse, summary="标记反馈已处理")
|
||||||
def handle_feedback(
|
def handle_feedback(
|
||||||
feedback_id: int,
|
feedback_id: int,
|
||||||
request: Request,
|
_admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
||||||
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
||||||
db: AdminDb,
|
db: AdminDb,
|
||||||
) -> OkResponse:
|
) -> OkResponse:
|
||||||
fb = db.get(Feedback, feedback_id)
|
fb = db.get(Feedback, feedback_id)
|
||||||
if fb is None:
|
if fb is None:
|
||||||
raise HTTPException(status_code=404, detail="反馈不存在")
|
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
|
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(
|
write_audit(
|
||||||
db, admin, action="feedback.handle", target_type="feedback", target_id=feedback_id,
|
db,
|
||||||
detail={"before": before, "after": "handled"}, ip=get_client_ip(request), commit=False,
|
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()
|
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)
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ def get_user_coin_records(
|
|||||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
cursor: Annotated[int | None, Query()] = None,
|
cursor: Annotated[int | None, Query()] = None,
|
||||||
) -> CursorPage[UserCoinRecord]:
|
) -> CursorPage[UserCoinRecord]:
|
||||||
items, next_cursor = queries.user_coin_records(
|
items, next_cursor, total = queries.user_coin_records(
|
||||||
db, user_id, date_from=date_from, date_to=date_to, limit=limit, cursor=cursor,
|
db, user_id, date_from=date_from, date_to=date_to, limit=limit, cursor=cursor,
|
||||||
)
|
)
|
||||||
return CursorPage(
|
return CursorPage(
|
||||||
items=[UserCoinRecord(**r) for r in items], next_cursor=next_cursor,
|
items=[UserCoinRecord(**r) for r in items], next_cursor=next_cursor, total=total,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
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):
|
class FeedbackOut(BaseModel):
|
||||||
@@ -15,4 +17,23 @@ class FeedbackOut(BaseModel):
|
|||||||
contact: str
|
contact: str
|
||||||
images: list[str] | None = None
|
images: list[str] | None = None
|
||||||
status: str
|
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
|
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 鉴权(反馈绑到登录用户,便于回访)。
|
路由前缀 `/api/v1/feedback`,需 Bearer 鉴权(反馈绑到登录用户,便于回访)。
|
||||||
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张)
|
POST / 提交反馈(multipart:content 必填;contact 可选(原型改版后客户端已不再采集);images 可选 ≤6 张)
|
||||||
|
GET /records 我的反馈历史(pending/adopted/rejected)
|
||||||
|
|
||||||
截图复用 [app.core.media] 落盘到 /media/feedback/。
|
截图复用 [app.core.media] 落盘到 /media/feedback/。
|
||||||
"""
|
"""
|
||||||
@@ -9,13 +10,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
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.api.deps import CurrentUser, DbSession
|
||||||
from app.core import media
|
from app.core import media
|
||||||
from app.repositories import feedback as feedback_repo
|
from app.repositories import feedback as feedback_repo
|
||||||
from app.repositories import feedback_qr as feedback_qr_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")
|
logger = logging.getLogger("shagua.feedback")
|
||||||
|
|
||||||
@@ -24,6 +31,27 @@ router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
|
|||||||
_MAX_IMAGES = 6
|
_MAX_IMAGES = 6
|
||||||
_CONTENT_MAX = 200
|
_CONTENT_MAX = 200
|
||||||
_CONTACT_MAX = 128
|
_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="提交反馈")
|
@router.post("", response_model=FeedbackOut, summary="提交反馈")
|
||||||
@@ -67,3 +95,23 @@ async def submit_feedback(
|
|||||||
def feedback_config(user: CurrentUser, db: DbSession) -> FeedbackQrConfigOut:
|
def feedback_config(user: CurrentUser, db: DbSession) -> FeedbackQrConfigOut:
|
||||||
"""运营后台配的反馈页「加群二维码」卡(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取。"""
|
"""运营后台配的反馈页「加群二维码」卡(开关 + 二维码图 + 三行文案)。客户端进反馈页时拉取。"""
|
||||||
return FeedbackQrConfigOut(**feedback_qr_repo.get_config(db))
|
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 显示。
|
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
|
||||||
PRICE_REPORT_REWARD_COINS: int = 1000
|
PRICE_REPORT_REWARD_COINS: int = 1000
|
||||||
|
|
||||||
|
# ===== 意见反馈采纳奖励(人工审核按质量发放)=====
|
||||||
|
# 后台审核反馈时允许发放的单条金币上限。只做后端硬保护,具体档位由 admin-web 呈现。
|
||||||
|
FEEDBACK_REWARD_MAX_COINS: int = 10000
|
||||||
|
|
||||||
|
|
||||||
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
# ===== 邀请好友(注册即生效,邀请人 + 被邀请人各发金币)=====
|
||||||
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
# 10000 金币 = 1 元,双方各得 1 元。MVP 先用固定常量(不走 app_config)。
|
||||||
|
|||||||
+13
-3
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
每条 = 用户一次提交。content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
|
每条 = 用户一次提交。content 必填;contact 原为必填(微信/QQ/手机),原型改版后客户端不再采集,
|
||||||
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
|
新数据存空串(列保持 NOT NULL,免迁移;历史数据仍有值);images 为可选的截图 URL 列表
|
||||||
(/media/feedback/...,JSON 存)。status: new(待处理)/ handled(已处理)。
|
(/media/feedback/...,JSON 存)。status: pending(审核中)/adopted(已采纳)/rejected(未采纳)。
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -25,8 +25,18 @@ class Feedback(Base):
|
|||||||
contact: Mapped[str] = mapped_column(String(128), nullable=False)
|
contact: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
|
# 截图 URL 列表(相对路径,如 ["/media/feedback/u1_ab12.jpg"]);无图为 None
|
||||||
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
images: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||||
# new(待处理) / handled(已处理)
|
# pending(审核中) / adopted(已采纳) / rejected(未采纳)
|
||||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="new")
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
|
||||||
|
|||||||
@@ -101,8 +101,10 @@ AD_CONFIG_KEY = "ad_config"
|
|||||||
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
|
_AD_CONFIG_DEFAULTS: dict[str, Any] = {
|
||||||
"app_id": "5830519", # 穿山甲应用ID(正式)
|
"app_id": "5830519", # 穿山甲应用ID(正式)
|
||||||
"reward_code_id": "104099389", # 福利页激励视频位
|
"reward_code_id": "104099389", # 福利页激励视频位
|
||||||
"compare_feed_code_id": "104090333", # 比价信息流位
|
# ⚠️ 2026-06-21 真机核对穿山甲后台:5830519 名下信息流真实位是 104142227「信息流 1」;
|
||||||
"coupon_feed_code_id": "104090333", # 领券信息流位(初始同比价,运营可拆)
|
# 旧值 104090333 不在该应用名下(请求会报 44406/配置 null、出不了广告)。客户端接入下发后以本值为准。
|
||||||
|
"compare_feed_code_id": "104142227", # 比价信息流位
|
||||||
|
"coupon_feed_code_id": "104142227", # 领券信息流位(初始同比价,运营可拆)
|
||||||
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
|
"reward_mkey": "", # 激励位 GroMore 验签密钥(空则回退 .env PANGLE_REWARD_SECRET*)
|
||||||
"reward_enabled": True, # 福利激励视频开关
|
"reward_enabled": True, # 福利激励视频开关
|
||||||
"compare_ad_enabled": True, # 比价广告开关
|
"compare_ad_enabled": True, # 比价广告开关
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""feedback 表写入。"""
|
"""feedback 表读写。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.rewards import CN_TZ
|
||||||
from app.models.feedback import Feedback
|
from app.models.feedback import Feedback
|
||||||
|
|
||||||
|
|
||||||
@@ -19,9 +23,19 @@ def create_feedback(
|
|||||||
content=content,
|
content=content,
|
||||||
contact=contact,
|
contact=contact,
|
||||||
images=images or None,
|
images=images or None,
|
||||||
status="new",
|
status="pending",
|
||||||
|
created_at=datetime.now(CN_TZ).replace(tzinfo=None),
|
||||||
)
|
)
|
||||||
db.add(fb)
|
db.add(fb)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(fb)
|
db.refresh(fb)
|
||||||
return 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())
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ _USERNAME_FIRST = "23456789"
|
|||||||
_USERNAME_DIGITS = "0123456789"
|
_USERNAME_DIGITS = "0123456789"
|
||||||
_USERNAME_LEN = 11
|
_USERNAME_LEN = 11
|
||||||
|
|
||||||
# 默认昵称 = 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。
|
# 默认昵称 = "用户" + 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。
|
||||||
|
_NICKNAME_PREFIX = "用户"
|
||||||
_NICKNAME_ALPHABET = string.ascii_letters + string.digits
|
_NICKNAME_ALPHABET = string.ascii_letters + string.digits
|
||||||
_NICKNAME_LEN = 9
|
_NICKNAME_LEN = 9
|
||||||
|
|
||||||
@@ -35,8 +36,10 @@ def _gen_username() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _gen_nickname() -> str:
|
def _gen_nickname() -> str:
|
||||||
"""随机一个 9 位字母+数字的默认昵称。"""
|
"""默认昵称 = "用户" + 9 位字母+数字随机串。"""
|
||||||
return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN))
|
return _NICKNAME_PREFIX + "".join(
|
||||||
|
secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_user_by_username(db: Session, username: str) -> User | None:
|
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||||
|
|||||||
+23
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class FeedbackOut(BaseModel):
|
class FeedbackOut(BaseModel):
|
||||||
@@ -26,3 +26,25 @@ class FeedbackQrConfigOut(BaseModel):
|
|||||||
group_name: str
|
group_name: str
|
||||||
subtitle: 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
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def _seed_user_with_data(phone: str) -> int:
|
|||||||
db.add(WithdrawOrder(
|
db.add(WithdrawOrder(
|
||||||
user_id=uid, out_bill_no=f"test{uid}billno0001", amount_cents=100, status="success"
|
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()
|
db.commit()
|
||||||
return uid
|
return uid
|
||||||
finally:
|
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:
|
def test_feedback_list(admin_client: TestClient, admin_token: str) -> None:
|
||||||
_seed_user_with_data("13800000005")
|
_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 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(
|
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)
|
uid = _seed_user(phone)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
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.add(fb)
|
||||||
db.commit()
|
db.commit()
|
||||||
return fb.id
|
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
|
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")
|
fid = _seed_feedback("13900000006")
|
||||||
r = admin_client.post(f"/admin/api/feedbacks/{fid}/handle", headers=_auth(operator_token))
|
r = admin_client.post(
|
||||||
assert r.status_code == 200
|
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()
|
db = SessionLocal()
|
||||||
try:
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -211,7 +211,7 @@ def test_sms_gc_purges_stale_only(monkeypatch) -> None:
|
|||||||
# ============================ 用户名 / 默认昵称 ============================
|
# ============================ 用户名 / 默认昵称 ============================
|
||||||
|
|
||||||
def test_login_assigns_username_and_nickname(client) -> None:
|
def test_login_assigns_username_and_nickname(client) -> None:
|
||||||
"""新用户创建即分配:11 位纯数字 username(首位非 0/1,与手机号天然区分)+ 9 位字母数字默认昵称。"""
|
"""新用户创建即分配:11 位纯数字 username(首位非 0/1,与手机号天然区分)+ "用户"+9 位字母数字默认昵称。"""
|
||||||
phone = "13600136000"
|
phone = "13600136000"
|
||||||
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
||||||
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||||
@@ -223,7 +223,9 @@ def test_login_assigns_username_and_nickname(client) -> None:
|
|||||||
assert uname[0] not in ("0", "1") # 无前导 0、与手机号(均以 1 开头)区分
|
assert uname[0] not in ("0", "1") # 无前导 0、与手机号(均以 1 开头)区分
|
||||||
|
|
||||||
nick = user["nickname"]
|
nick = user["nickname"]
|
||||||
assert nick and len(nick) == 9 and nick.isalnum() # 9 位字母+数字
|
assert nick.startswith("用户") # "用户" 前缀
|
||||||
|
suffix = nick.removeprefix("用户")
|
||||||
|
assert len(suffix) == 9 and suffix.isalnum() # 后接 9 位字母+数字
|
||||||
|
|
||||||
|
|
||||||
def test_username_stable_and_unique_on_relogin() -> None:
|
def test_username_stable_and_unique_on_relogin() -> None:
|
||||||
|
|||||||
@@ -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