diff --git a/scripts/seed_mock_feedback.py b/scripts/seed_mock_feedback.py new file mode 100644 index 0000000..2754054 --- /dev/null +++ b/scripts/seed_mock_feedback.py @@ -0,0 +1,277 @@ +"""一次性 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/)。""" + _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() diff --git a/scripts/seed_mock_price_reports.py b/scripts/seed_mock_price_reports.py index 6ca04fe..5c70e4e 100644 --- a/scripts/seed_mock_price_reports.py +++ b/scripts/seed_mock_price_reports.py @@ -30,6 +30,7 @@ from app.core.config import settings from app.db.session import SessionLocal from app.models.price_report import PriceReport from app.models.user import User +from app.repositories.user import _gen_unique_username if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") # Windows 控制台输出中文/¥ @@ -84,10 +85,11 @@ def seed(db) -> list[PriceReport]: # 1) 建 3 个 mock 用户 users: dict[str, User] = {} for i, (phone, nickname) in enumerate( - zip(MOCK_PHONES, ["省钱小王", "比价老李", "薅羊毛阿珍"]) + zip(MOCK_PHONES, ["省钱小王", "比价老李", "薅羊毛阿珍"], strict=True) ): u = User( phone=phone, + username=_gen_unique_username(db), # 列 NOT NULL + unique,仿真实注册生成 nickname=nickname, register_channel="sms", status="active", diff --git a/scripts/seed_mock_withdraws.py b/scripts/seed_mock_withdraws.py index ad45f29..4902bc8 100644 --- a/scripts/seed_mock_withdraws.py +++ b/scripts/seed_mock_withdraws.py @@ -21,7 +21,7 @@ import argparse import sys import uuid from collections import defaultdict -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from sqlalchemy import delete, select @@ -31,6 +31,7 @@ from app.models.ad_feed_reward import AdFeedRewardRecord from app.models.ad_reward import AdRewardRecord from app.models.user import User from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder +from app.repositories.user import _gen_unique_username # Windows GBK 控制台下也能正常打印中文(避免 UnicodeEncodeError) if hasattr(sys.stdout, "reconfigure"): @@ -51,7 +52,7 @@ REMARK = {"exchange_in": "金币兑入", "withdraw": "提现扣款", "withdraw_r def _naive_utc_now() -> datetime: """与 func.now() 在 SQLite 的口径一致:naive UTC。前端按 UTC 解析再转北京时间。""" - return datetime.now(timezone.utc).replace(tzinfo=None) + return datetime.now(UTC).replace(tzinfo=None) def _mock_transfer_no() -> str: @@ -96,6 +97,7 @@ def seed(db) -> list[WithdrawOrder]: 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",