f7a7a49281
免确认授权已开启判定加 authorization_id 非空,与打款一致 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #168
278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""一次性 mock:造几条不同状态的用户反馈,供运营后台「反馈工单」页联调验收。
|
|
|
|
覆盖:
|
|
- 三个状态 tab(待审核 pending / 已采纳 adopted / 未采纳 rejected),重点铺「待审核」;
|
|
- 两种反馈类型 source(普通反馈 profile /「我的」页入口、比价反馈 comparison / 比价结果页入口),
|
|
比价反馈带「问题场景」scene(找错商品/优惠不对/比价太慢…);
|
|
- 提交端环境快照(app_version / device_model / rom_name / android_version)——新端反馈才有,
|
|
另留 1~2 条历史反馈(env 全 NULL、contact 有值)测「旧数据」展示;
|
|
- 截图:在 data/media/feedback/ 生成真实可加载的纯色 PNG(手写字节,无需 Pillow),
|
|
让审核抽屉的图能真加载出来(与 seed_mock_price_reports 同法)。
|
|
- 已采纳条带 reward_coins + admin_reply + review_note;未采纳条带 reject_reason + admin_reply。
|
|
|
|
幂等:每次运行先按固定 mock 手机号清掉旧 mock 用户/反馈 + 删 mock 截图再重建。仅清理用 --clean-only。
|
|
|
|
.venv\\Scripts\\python.exe scripts\\seed_mock_feedback.py
|
|
.venv\\Scripts\\python.exe scripts\\seed_mock_feedback.py --clean-only
|
|
|
|
看图:前端「反馈工单」页(http://localhost:3001 → 反馈)。图经 NEXT_PUBLIC_MEDIA_BASE
|
|
(本地 = http://localhost:8770)由 App 后端 /media 加载——改过 .env.local 后需重启 next dev,
|
|
且 App 后端(:8770)要在跑。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import struct
|
|
import sys
|
|
import zlib
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import delete, select
|
|
|
|
from app.core.config import settings
|
|
from app.db.session import SessionLocal
|
|
from app.models.feedback import Feedback
|
|
from app.models.user import User
|
|
from app.repositories.user import _gen_unique_username
|
|
|
|
# Windows GBK 控制台下也能正常打印中文/¥(避免 UnicodeEncodeError)
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
# 固定 mock 手机号:脚本只动这些号,便于幂等重建 / 清理。
|
|
# 刻意与 seed_mock_withdraws / seed_mock_price_reports 的号段错开,互不干扰。
|
|
MOCK_PHONES = [
|
|
"13255550001",
|
|
"13255550002",
|
|
"13255550003",
|
|
"13255550004",
|
|
"13255550005",
|
|
]
|
|
|
|
_FEEDBACK_DIR = Path(settings.MEDIA_ROOT) / "feedback"
|
|
_MOCK_IMG_GLOB = "mock_fb_*.png" # 本脚本生成的图前缀,清理时按此删
|
|
|
|
|
|
def _naive_utc_now() -> datetime:
|
|
"""与 func.now() 在 SQLite 的口径一致:naive UTC。反馈 created_at 走 server_default=func.now(),
|
|
这里显式造数据也用 naive UTC,和真实反馈行同源,前端展示口径一致。"""
|
|
return datetime.now(UTC).replace(tzinfo=None)
|
|
|
|
|
|
def _solid_png(width: int, height: int, rgb: tuple[int, int, int]) -> bytes:
|
|
"""生成一张纯色 PNG(truecolor RGB)的字节,无需 Pillow。颜色块即可肉眼判断「图加载出来了」。"""
|
|
def _chunk(typ: bytes, data: bytes) -> bytes:
|
|
body = typ + data
|
|
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
|
|
|
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8bit/通道, color type 2 = RGB
|
|
row = b"\x00" + bytes(rgb) * width # 每行前缀 filter byte 0
|
|
idat = zlib.compress(row * height, 9)
|
|
return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"")
|
|
|
|
|
|
def _write_mock_image(name: str, rgb: tuple[int, int, int]) -> str:
|
|
"""写一张 mock 截图到 media/feedback/,返回其相对 URL(/media/feedback/<name>)。"""
|
|
_FEEDBACK_DIR.mkdir(parents=True, exist_ok=True)
|
|
(_FEEDBACK_DIR / name).write_bytes(_solid_png(320, 320, rgb))
|
|
return f"{settings.MEDIA_URL_PREFIX}/feedback/{name}"
|
|
|
|
|
|
def clean(db) -> int:
|
|
uids = list(db.execute(select(User.id).where(User.phone.in_(MOCK_PHONES))).scalars())
|
|
if uids:
|
|
db.execute(delete(Feedback).where(Feedback.user_id.in_(uids)))
|
|
db.execute(delete(User).where(User.id.in_(uids)))
|
|
db.commit()
|
|
# 删 mock 截图文件
|
|
if _FEEDBACK_DIR.exists():
|
|
for f in _FEEDBACK_DIR.glob(_MOCK_IMG_GLOB):
|
|
f.unlink(missing_ok=True)
|
|
return len(uids)
|
|
|
|
|
|
def seed(db) -> list[Feedback]:
|
|
now = _naive_utc_now()
|
|
|
|
def ago(**kw) -> datetime:
|
|
return now - timedelta(**kw)
|
|
|
|
# 1) 建 5 个 mock 用户(U5 昵称留空测 "-" 展示)
|
|
users_spec = [
|
|
("13255550001", "反馈小达人"),
|
|
("13255550002", "比价挑刺王"),
|
|
("13255550003", "热心用户阿明"),
|
|
("13255550004", "老用户张姐"),
|
|
("13255550005", None),
|
|
]
|
|
users: dict[str, User] = {}
|
|
for phone, nickname in users_spec:
|
|
u = User(
|
|
phone=phone,
|
|
username=_gen_unique_username(db), # 列 NOT NULL + unique,仿真实注册生成
|
|
nickname=nickname,
|
|
register_channel="sms",
|
|
status="active",
|
|
created_at=ago(days=15),
|
|
last_login_at=ago(hours=1),
|
|
)
|
|
db.add(u)
|
|
users[phone] = u
|
|
db.flush() # 拿 user.id
|
|
|
|
# 2) 生成 mock 截图(不同颜色块,便于肉眼区分「都加载出来了」)
|
|
palette = [
|
|
(24, 144, 255), # 蓝
|
|
(82, 196, 26), # 绿
|
|
(250, 173, 20), # 橙
|
|
(245, 34, 45), # 红
|
|
]
|
|
imgs = [_write_mock_image(f"mock_fb_{i}.png", palette[i]) for i in range(len(palette))]
|
|
|
|
# 3) 造反馈记录
|
|
def fb(
|
|
phone: str,
|
|
content: str,
|
|
*,
|
|
source: str = "profile",
|
|
scene: str | None = None,
|
|
images: list[str] | None = None,
|
|
contact: str = "",
|
|
status: str = "pending",
|
|
reject_reason: str | None = None,
|
|
reward_coins: int | None = None,
|
|
review_note: str | None = None,
|
|
admin_reply: str | None = None,
|
|
app_version: str | None = None,
|
|
device_model: str | None = None,
|
|
rom_name: str | None = None,
|
|
android_version: str | None = None,
|
|
created: datetime,
|
|
) -> Feedback:
|
|
return Feedback(
|
|
user_id=users[phone].id,
|
|
content=content,
|
|
contact=contact, # 列 NOT NULL:新端存空串,历史数据有值
|
|
source=source,
|
|
scene=scene,
|
|
images=images,
|
|
status=status,
|
|
reject_reason=reject_reason,
|
|
reward_coins=reward_coins,
|
|
review_note=review_note,
|
|
admin_reply=admin_reply,
|
|
app_version=app_version,
|
|
device_model=device_model,
|
|
rom_name=rom_name,
|
|
android_version=android_version,
|
|
reviewed_at=(created + timedelta(hours=2)) if status != "pending" else None,
|
|
created_at=created,
|
|
)
|
|
|
|
feedbacks = [
|
|
# ===== 待审核 pending(默认 tab,重点铺量)=====
|
|
# 普通反馈 · 新端(带环境快照)· 无图
|
|
fb("13255550001", "签到金币到账有时候会延迟一两分钟,能不能做成实时到账?",
|
|
source="profile",
|
|
app_version="2.3.1", device_model="PJA110", rom_name="ColorOS", android_version="14",
|
|
created=ago(minutes=6)),
|
|
# 比价反馈 · scene=优惠不对 · 新端 · 2 图
|
|
fb("13255550002", "这家店京东外卖的到手价比你们算出来的最低价还低,截图为证,麻烦核实。",
|
|
source="comparison", scene="优惠不对", images=[imgs[0], imgs[1]],
|
|
app_version="2.3.1", device_model="M2012K11AC", rom_name="MIUI", android_version="13",
|
|
created=ago(minutes=22)),
|
|
# 比价反馈 · scene=找错商品 · 新端 · 无图
|
|
fb("13255550002", "比价结果里的商品跟我搜的不是同一个规格,数量对不上。",
|
|
source="comparison", scene="找错商品",
|
|
app_version="2.3.0", device_model="V2309A", rom_name="OriginOS", android_version="14",
|
|
created=ago(hours=1)),
|
|
# 普通反馈 · 新端 · 1 图(表扬 + 小问题)
|
|
fb("13255550003", "提现秒到账,好评!顺手反馈个小 bug:金币记录页偶尔白屏,要退出去重进。",
|
|
source="profile", images=[imgs[2]],
|
|
app_version="2.3.1", device_model="23078RKD5C", rom_name="MIUI", android_version="14",
|
|
created=ago(hours=3)),
|
|
# 比价反馈 · scene=比价太慢 · 历史数据(env 全 NULL、contact 有值)
|
|
fb("13255550004", "比价转圈太久了,经常要等十几秒才出结果,体验不太好。",
|
|
source="comparison", scene="比价太慢", contact="微信 zhangjie_66",
|
|
created=ago(days=1, hours=2)),
|
|
# 普通反馈 · 历史数据(env 全 NULL、contact 有值)· 无昵称用户
|
|
fb("13255550005", "希望能增加支付宝提现,微信零钱用不太习惯。",
|
|
source="profile", contact="QQ 100200300",
|
|
created=ago(days=1, hours=8)),
|
|
|
|
# ===== 已采纳 adopted(发金币 + 回复)=====
|
|
fb("13255550003", "建议在比价结果页加个「一键复制口令」,分享给家人更方便。",
|
|
source="profile", images=[imgs[3]],
|
|
status="adopted", reward_coins=2000,
|
|
review_note="有效产品建议,已排期到 2.4.0", admin_reply="感谢反馈!该功能已在规划中,金币奖励已发放~",
|
|
app_version="2.2.8", device_model="PJA110", rom_name="ColorOS", android_version="13",
|
|
created=ago(days=2)),
|
|
|
|
# ===== 未采纳 rejected(带原因 + 回复)=====
|
|
fb("13255550002", "你们算的价格不准,我看到的更便宜。",
|
|
source="comparison", scene="价格不准",
|
|
status="rejected", reject_reason="截图价格为限时活动价且已过期,不满足「长期可复现更低价」条件,暂不采纳。",
|
|
admin_reply="感谢参与,本次未通过,欢迎继续上报有效更低价~",
|
|
app_version="2.3.0", device_model="M2012K11AC", rom_name="MIUI", android_version="13",
|
|
created=ago(days=3)),
|
|
]
|
|
db.add_all(feedbacks)
|
|
db.commit()
|
|
for f in feedbacks:
|
|
db.refresh(f)
|
|
return feedbacks
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="造用户反馈 mock 数据(运营后台反馈工单页联调用)")
|
|
parser.add_argument("--clean-only", action="store_true", help="只清理 mock 数据,不重建")
|
|
args = parser.parse_args()
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
removed = clean(db)
|
|
if removed:
|
|
print(f"🧹 已清理旧 mock:{removed} 个用户及其反馈 + mock 截图")
|
|
if args.clean_only:
|
|
print("✅ 仅清理,已完成。")
|
|
return
|
|
|
|
feedbacks = seed(db)
|
|
|
|
status_label = {"pending": "待审核", "adopted": "已采纳", "rejected": "未采纳"}
|
|
source_label = {"profile": "普通反馈", "comparison": "比价反馈"}
|
|
by_status: dict[str, list[Feedback]] = {}
|
|
for f in feedbacks:
|
|
by_status.setdefault(f.status, []).append(f)
|
|
|
|
print(f"\n✅ 已生成 {len(feedbacks)} 条反馈(截图落 {_FEEDBACK_DIR}),分布:")
|
|
for st in ("pending", "adopted", "rejected"):
|
|
lst = by_status.get(st, [])
|
|
print(f" {status_label[st]:<4} {len(lst)} 条")
|
|
|
|
print("\n 明细(#id | 状态 | 类型/场景 | 图 | 内容):")
|
|
uid2phone = dict(
|
|
db.execute(select(User.id, User.phone).where(User.phone.in_(MOCK_PHONES))).all()
|
|
)
|
|
for f in feedbacks:
|
|
src = source_label.get(f.source, f.source)
|
|
scene = f"·{f.scene}" if f.scene else ""
|
|
nimg = len(f.images or [])
|
|
snippet = f.content[:20] + ("…" if len(f.content) > 20 else "")
|
|
print(
|
|
f" #{f.id} [{status_label.get(f.status, f.status)}] "
|
|
f"{src}{scene} {nimg}图 {uid2phone.get(f.user_id, '?')} {snippet}"
|
|
)
|
|
print(
|
|
"\n👉 打开 http://localhost:3001 → 反馈 查看(默认「待审核」tab)。"
|
|
"\n 图加载不出来时排查:① 是否重启过 next dev(读 .env.local 的 NEXT_PUBLIC_MEDIA_BASE)"
|
|
" ② App 后端(:8770)是否在跑(它托管 /media)。"
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|