From 07cf80680506ba9e12d2ae1472cc3d13f8c9b312 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 23 Jul 2026 16:48:55 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(withdraw):=20=E5=85=8D=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=8E=88=E6=9D=83=E5=B7=B2=E5=BC=80=E5=90=AF=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E5=8A=A0=20authorization=5Fid=20=E9=9D=9E=E7=A9=BA,?= =?UTF-8?q?=E4=B8=8E=E6=89=93=E6=AC=BE=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/api/v1/wallet.py | 5 ++-- tests/test_withdraw.py | 59 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 454ff7c..7b0dac0 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -192,7 +192,7 @@ def withdraw_info( wechat_bound=bool(u and u.wechat_openid), wechat_nickname=u.wechat_nickname if u else None, wechat_avatar_url=u.wechat_avatar_url if u else None, - transfer_auth_enabled=bool(auth and auth.state == "active"), + transfer_auth_enabled=bool(auth and auth.state == "active" and auth.authorization_id), tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)], ) @@ -335,7 +335,8 @@ def open_transfer_auth(user: CurrentUser, db: DbSession) -> TransferAuthResultOu def transfer_auth_status(user: CurrentUser, db: DbSession) -> TransferAuthStatusOut: auth = crud_wallet.sync_transfer_auth(db, user.id) state = auth.state if auth else "none" - return TransferAuthStatusOut(state=state, enabled=(state == "active")) + enabled = bool(auth and auth.state == "active" and auth.authorization_id) + return TransferAuthStatusOut(state=state, enabled=enabled) @router.post( diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index 55a978a..509fb0a 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -9,7 +9,7 @@ from sqlalchemy import select from app.db.session import SessionLocal from app.models.user import User -from app.models.wallet import CoinAccount, WithdrawOrder +from app.models.wallet import CoinAccount, WithdrawOrder, WechatTransferAuthorization from app.repositories import wallet as crud_wallet @@ -426,3 +426,60 @@ def test_withdraw_reject_refunds(client, monkeypatch) -> None: r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token)) assert r.json()["status"] == "rejected" assert r.json()["fail_reason"] == "测试拒绝" + + +# ===== §fix 免确认授权 enabled 判定收严:active+authorization_id 非空才算已授权 ===== + +def _seed_transfer_auth(phone: str, state: str, authorization_id: str | None) -> None: + """直接在库里写/改该用户的免确认授权记录(绕过微信,用于判定测试)。""" + db = SessionLocal() + try: + user = db.execute(select(User).where(User.phone == phone)).scalar_one() + auth = db.get(WechatTransferAuthorization, user.id) + if auth is None: + auth = WechatTransferAuthorization( + user_id=user.id, openid="openid_test_abc", + out_authorization_no=f"oan_{user.id}", + ) + db.add(auth) + auth.state = state + auth.authorization_id = authorization_id + db.commit() + finally: + db.close() + + +def test_withdraw_info_auth_enabled_requires_authorization_id(client, monkeypatch) -> None: + _patch_userinfo(monkeypatch) + token = _login(client, "13800002051") + # 触发建号 + 绑定微信(withdraw-info 读 openid) + client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c1"}, headers=_auth(token)) + + # active 但无 authorization_id → 视为未授权 + _seed_transfer_auth("13800002051", "active", None) + r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["transfer_auth_enabled"] is False + + # active 且有 authorization_id → 已授权 + _seed_transfer_auth("13800002051", "active", "wx_auth_123") + r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + assert r.json()["transfer_auth_enabled"] is True + + +def test_transfer_auth_status_requires_authorization_id(client, monkeypatch) -> None: + _patch_userinfo(monkeypatch) + token = _login(client, "13800002052") + client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + client.post("/api/v1/wallet/bind-wechat", json={"code": "c2"}, headers=_auth(token)) + + _seed_transfer_auth("13800002052", "active", None) + r = client.get("/api/v1/wallet/transfer-auth/status", headers=_auth(token)) + assert r.status_code == 200, r.text + assert r.json()["state"] == "active" + assert r.json()["enabled"] is False + + _seed_transfer_auth("13800002052", "active", "wx_auth_456") + r = client.get("/api/v1/wallet/transfer-auth/status", headers=_auth(token)) + assert r.json()["enabled"] is True -- 2.52.0 From d872755bb87565d85309bbe836f23275e25a6d94 Mon Sep 17 00:00:00 2001 From: guke Date: Thu, 23 Jul 2026 18:28:51 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(withdraw):=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=81=A5=E5=A3=AE=E6=80=A7=20polish(openid=20=E5=8F=96?= =?UTF-8?q?=E8=87=AA=20User=20+=20=E8=A1=A5=20status=5Fcode=20=E6=96=AD?= =?UTF-8?q?=E8=A8=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_withdraw.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index 509fb0a..ac395a4 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -438,7 +438,7 @@ def _seed_transfer_auth(phone: str, state: str, authorization_id: str | None) -> auth = db.get(WechatTransferAuthorization, user.id) if auth is None: auth = WechatTransferAuthorization( - user_id=user.id, openid="openid_test_abc", + user_id=user.id, openid=user.wechat_openid or "openid_test_abc", out_authorization_no=f"oan_{user.id}", ) db.add(auth) @@ -465,6 +465,7 @@ def test_withdraw_info_auth_enabled_requires_authorization_id(client, monkeypatc # active 且有 authorization_id → 已授权 _seed_transfer_auth("13800002051", "active", "wx_auth_123") r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token)) + assert r.status_code == 200, r.text assert r.json()["transfer_auth_enabled"] is True @@ -482,4 +483,5 @@ def test_transfer_auth_status_requires_authorization_id(client, monkeypatch) -> _seed_transfer_auth("13800002052", "active", "wx_auth_456") r = client.get("/api/v1/wallet/transfer-auth/status", headers=_auth(token)) + assert r.status_code == 200, r.text assert r.json()["enabled"] is True -- 2.52.0 From 1ec376880a61fedf953e23fe723f700368247c13 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 24 Jul 2026 09:55:39 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(logging):=20Windows=20=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=BD=AE=E8=BD=AC=E6=94=B9=E7=94=A8=20copytruncate=20?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20WinError=2032=20=E5=8D=A1=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stdlib RotatingFileHandler 靠 rename 活动文件轮转;Windows 上只要有第二个句柄(admin 第二进程、残留 --reload worker、IDE 索引、杀软)开着它,rename 就 WinError 32、轮转永久卡死。SafeRotatingFileHandler 在 Windows 改走 copytruncate(拷备份→自有句柄就地清空,不 rename 活动文件),POSIX 保持 stdlib 原子轮转不变。含 test_logging_rotation.py:monkeypatch os.name=nt,任意 CI 覆盖该分支。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/core/logging.py | 53 +++++++++++++++++++++++- tests/test_logging_rotation.py | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 tests/test_logging_rotation.py diff --git a/app/core/logging.py b/app/core/logging.py index cb91b5c..c3833bd 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -22,13 +22,13 @@ from __future__ import annotations import json import logging import os +import shutil import sys from contextvars import ContextVar from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path - # 请求级 trace_id:入口(如 compare.py 透传壳)set 之后, 本请求上下文(含 run_in_threadpool # 拷贝出去的线程)内所有日志自动带上。默认空串 = 非请求上下文(启动/后台 worker)。 trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="") @@ -91,6 +91,55 @@ class TextFormatter(logging.Formatter): return f"{base} trace={tid}" if tid else base +class SafeRotatingFileHandler(RotatingFileHandler): + """Windows 下不会被外部句柄卡死的 RotatingFileHandler。 + + stdlib 轮转靠 rename 活动文件(app-server.log → .1);Windows 只要有别的句柄(IDE 索引、 + app.admin.main 第二进程、残留 --reload worker、杀软扫描)开着它, rename 就 WinError 32, + 轮转永久卡死——文件停在 maxBytes、之后每条日志被丢。这里 Windows 改用 copytruncate:把活动 + 文件拷进备份、再通过自己的句柄原地清空, 从不 rename 活动文件, 故外部句柄开着也能转。 + POSIX(生产 Linux)rename 打开中的文件本就合法, 保留 stdlib 的原子轮转不变。 + + 代价:copytruncate 在“拷贝→清空”极窄窗口内并发写可能丢几行(仅跨进程;同进程 emit 有 + handler 锁串行, 无此问题)。对本地开发日志可接受。 + """ + + def doRollover(self) -> None: + if os.name != "nt": + super().doRollover() + return + if self.stream is None: + self.stream = self._open() + else: + self.stream.flush() + try: + self._copytruncate_backups() + except OSError: + # 备份腾挪是尽力而为:任一备份被占用也绝不能挡住下面的清空, 否则活动文件继续涨、 + # 轮转又卡死——那就白改了。 + pass + # 通过自己独占的句柄原地清空:不涉及 rename, 外部只读句柄不受影响。 + self.stream.seek(0) + self.stream.truncate() + self.stream.flush() + + def _copytruncate_backups(self) -> None: + """把 .N-1→.N 逐级腾挪, 再把活动文件拷到 .1(不动活动文件本身)。""" + if self.backupCount <= 0: + return + for i in range(self.backupCount - 1, 0, -1): + sfn = self.rotation_filename(f"{self.baseFilename}.{i}") + dfn = self.rotation_filename(f"{self.baseFilename}.{i + 1}") + if os.path.exists(sfn): + if os.path.exists(dfn): + os.remove(dfn) + os.replace(sfn, dfn) + dfn = self.rotation_filename(f"{self.baseFilename}.1") + if os.path.exists(dfn): + os.remove(dfn) + shutil.copyfile(self.baseFilename, dfn) + + _CONFIGURED = False @@ -126,7 +175,7 @@ def setup_logging(debug: bool = False) -> None: Path(os.getenv("LOG_DIR", "logs")) / "app-server.log" ) Path(log_file).parent.mkdir(parents=True, exist_ok=True) - file_handler = RotatingFileHandler( + file_handler = SafeRotatingFileHandler( log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8", ) file_handler.setFormatter(JsonFormatter(service)) diff --git a/tests/test_logging_rotation.py b/tests/test_logging_rotation.py new file mode 100644 index 0000000..0d53a45 --- /dev/null +++ b/tests/test_logging_rotation.py @@ -0,0 +1,73 @@ +"""SafeRotatingFileHandler:Windows 轮转不被外部句柄卡死(WinError 32)。 + +stdlib RotatingFileHandler 靠 rename 活动文件轮转;Windows 上只要有第二个句柄(admin +第二进程、残留 --reload worker、IDE 索引、杀软)开着它, rename 就 WinError 32、轮转永久 +卡死。Safe 版在 Windows 改走 copytruncate(拷贝→就地清空, 从不 rename 活动文件)。 + +这些用例用 monkeypatch 把 os.name 强制成 "nt", 使 copytruncate 分支在任何 OS 的 CI 上都被 +覆盖;在真实 Windows 上则天然命中。 +""" +from __future__ import annotations + +import logging +from pathlib import Path + +from app.core.logging import SafeRotatingFileHandler + + +def _emit(handler: logging.Handler, msg: str) -> None: + handler.emit(logging.LogRecord("t", logging.INFO, __file__, 0, msg, (), None)) + + +def test_rollover_survives_second_open_handle(tmp_path: Path, monkeypatch) -> None: + """第二个句柄开着活动文件时轮转:不抛异常, 且确实转了(生成 .1、活动文件就地清空)。""" + monkeypatch.setattr("app.core.logging.os.name", "nt") + + log_file = tmp_path / "app-server.log" + # maxBytes 放大, 避免 emit 期间自动轮转干扰;本用例手动触发 doRollover。 + handler = SafeRotatingFileHandler( + str(log_file), maxBytes=10**9, backupCount=3, encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + try: + for i in range(20): + _emit(handler, f"line-{i:03d}") + handler.flush() + before = log_file.stat().st_size + assert before > 0 + + # 正是 Windows 上 rename 失败的条件:另一个句柄开着活动文件。 + with open(log_file, "a", encoding="utf-8"): + handler.doRollover() # 不应抛 PermissionError / WinError 32 + + backup = tmp_path / "app-server.log.1" + assert backup.exists() + assert backup.stat().st_size == before # 轮转前内容完整进了备份 + assert log_file.stat().st_size == 0 # 活动文件就地清空(不是 rename) + + # 句柄没被 rename 破坏, 仍能继续写。 + _emit(handler, "after-rollover") + handler.flush() + assert log_file.stat().st_size > 0 + finally: + handler.close() + + +def test_backups_shift_and_capped(tmp_path: Path, monkeypatch) -> None: + """多次轮转:.1/.2 逐级腾挪, 超过 backupCount 的丢弃(不出现 .3)。""" + monkeypatch.setattr("app.core.logging.os.name", "nt") + + log_file = tmp_path / "app-server.log" + handler = SafeRotatingFileHandler( + str(log_file), maxBytes=10**9, backupCount=2, encoding="utf-8", + ) + handler.setFormatter(logging.Formatter("%(message)s")) + try: + for _ in range(4): + _emit(handler, "x" * 50) + handler.doRollover() + assert (tmp_path / "app-server.log.1").exists() + assert (tmp_path / "app-server.log.2").exists() + assert not (tmp_path / "app-server.log.3").exists() + finally: + handler.close() -- 2.52.0 From ceef6428547ab7386f7e164158efc1b5883237ee Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 24 Jul 2026 09:55:47 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(scripts):=20seed=20=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E8=A1=A5=20username=20=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=20mock=20=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User.username 现为 NOT NULL+unique,原 seed 脚本未设 username 会 IntegrityError,补 _gen_unique_username(仿真实注册生成)。顺带 zip(strict=True)、timezone.utc→datetime.UTC(与代码库统一)。新增 seed_mock_feedback.py:造待审核/已采纳/未采纳三态用户反馈 + 可加载纯色 PNG 截图,供后台反馈工单页联调。 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/seed_mock_feedback.py | 277 +++++++++++++++++++++++++++++ scripts/seed_mock_price_reports.py | 4 +- scripts/seed_mock_withdraws.py | 6 +- 3 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 scripts/seed_mock_feedback.py 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", -- 2.52.0