Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8da8c3cfae | |||
| 0cf5b3816f | |||
| 9e082e6376 | |||
| 93cb283c1b | |||
| a43e082391 | |||
| e11ad9449c | |||
| 6ec4cfb4be |
+2
-5
@@ -6,11 +6,8 @@ APP_NAME=shaguabijia-app-server
|
||||
APP_DEBUG=true
|
||||
|
||||
# ===== 数据库 =====
|
||||
# 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
|
||||
# (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
|
||||
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
|
||||
# ===== JWT =====
|
||||
# 生产部署务必改成随机长字符串,可用:python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
|
||||
@@ -57,6 +57,3 @@ tests/meituan_coupon_bj.tsv
|
||||
tests/meituan_coupon_data.tsv
|
||||
tests/meituan_coupon_fz.tsv
|
||||
tests/meituan_coupon_xm.tsv
|
||||
|
||||
# git worktrees (superpowers 隔离工作区)
|
||||
.worktrees/
|
||||
|
||||
@@ -76,8 +76,8 @@ Endpoints under `app/api/internal/` are for server-to-server communication (pric
|
||||
|
||||
## Database
|
||||
|
||||
- **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat` auto-start it via `scripts/ensure_pg.py` + `docker-compose.yml`; `.env.example` ships the PG URL by default; pytest uses the same container's `shaguabijia_test` DB. **Local no longer uses SQLite** (the SQLite branch in `db/session.py` is retained as a fallback only).
|
||||
- **Prod**: native PostgreSQL — bootstrap with `scripts/init_postgres.py` (no Docker). Pool size 10 + max overflow 20, pool_recycle 3600.
|
||||
- **Dev**: SQLite (`sqlite:///./data/app.db`), `check_same_thread=False`, no connection pool.
|
||||
- **Prod**: PostgreSQL — just change `DATABASE_URL` in `.env`. Pool size 10 + max overflow 20, pool_recycle 3600.
|
||||
- **Migrations**: Alembic with `render_as_batch` for SQLite compatibility. ~60+ migration files in `alembic/versions/` (filenames are descriptive, not hex prefixes). Migration chain uses `down_revision` within each file.
|
||||
- **New models**: Define in `app/models/`, import in `app/models/__init__.py`, then run `alembic revision --autogenerate`.
|
||||
|
||||
@@ -89,7 +89,7 @@ All config via `pydantic-settings` in `app/core/config.py`. Single `Settings` cl
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/conftest.py`: Sets env vars BEFORE imports, ensures the Docker PG `shaguabijia_test` DB via `scripts/ensure_pg.py`, builds all tables with `Base.metadata.create_all()` (drop+create for a clean start), tears down with `drop_all()`.
|
||||
- `tests/conftest.py`: Sets env vars BEFORE imports, creates temp SQLite file, builds all tables with `Base.metadata.create_all()`, tears down with `drop_all()` + unlink.
|
||||
- External integrations are monkeypatched in tests (e.g., WeChat Pay, Jiguang, Pangle callbacks) — tests never make real HTTP calls.
|
||||
- `TestClient` from FastAPI is used for all tests. Rate limiting is disabled globally in tests.
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""admin_user 加 pages_override 列(「自定义」权限:按人存专属可见页 key 列表)
|
||||
|
||||
权限管理页新增「自定义」角色:选它时该成员的可见页不跟随任何共享角色,而由逐页勾选决定,
|
||||
存这个人专属的一份页 key 列表。仅 role == "custom" 时有效;普通角色为 None(可见页跟随角色)。
|
||||
PG 用 JSONB,SQLite 退化为通用 JSON(同 admin_audit_log.detail / comparison_record.raw_payload)。
|
||||
|
||||
Revision ID: admin_user_pages_override
|
||||
Revises: admin_user_plain_password
|
||||
Create Date: 2026-07-08 00:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "admin_user_pages_override"
|
||||
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# 与 app/models/admin.py 的 _JSON 一致:PG JSONB / 其它 JSON
|
||||
_JSON = sa.JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("pages_override", _JSON, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("admin_user", schema=None) as batch_op:
|
||||
batch_op.drop_column("pages_override")
|
||||
@@ -9,6 +9,9 @@ super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。
|
||||
from __future__ import annotations
|
||||
|
||||
SUPER_ADMIN_ROLE = "super_admin"
|
||||
# 「自定义」哨兵角色:不是 admin_role 表里的行,而是标记「这个人的可见页由 pages_override 决定」。
|
||||
# admin_user.role == CUSTOM_ROLE 时,有效可见页取 admin_user.pages_override(见 auth._admin_out_with_pages)。
|
||||
CUSTOM_ROLE = "custom"
|
||||
|
||||
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
|
||||
PERMISSION_CATALOG: list[dict] = [
|
||||
|
||||
@@ -26,14 +26,17 @@ def create_admin(
|
||||
password: str,
|
||||
role: str = "operator",
|
||||
plain_password: str | None = None,
|
||||
pages_override: list[str] | None = None,
|
||||
) -> AdminUser:
|
||||
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
|
||||
脚本/起后台建账号不传(留 None → 前端不显示密码)。"""
|
||||
脚本/起后台建账号不传(留 None → 前端不显示密码)。
|
||||
pages_override:role=="custom" 时传专属可见页 key 列表;普通角色为 None。"""
|
||||
admin = AdminUser(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
plain_password=plain_password,
|
||||
pages_override=pages_override,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
|
||||
@@ -30,11 +30,17 @@ from app.models.user import User
|
||||
from app.models.wallet import CoinTransaction, WithdrawOrder
|
||||
|
||||
_BEIJING = timezone(timedelta(hours=8))
|
||||
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
|
||||
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
|
||||
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
|
||||
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
|
||||
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
|
||||
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
|
||||
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
|
||||
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
|
||||
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
|
||||
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
|
||||
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
|
||||
*REWARD_VIDEO_BIZ_TYPES,
|
||||
*COUPON_REWARD_BIZ_TYPES,
|
||||
*COMPARISON_REWARD_BIZ_TYPES,
|
||||
*EXCLUDED_REWARD_BIZ_TYPES,
|
||||
@@ -362,7 +368,7 @@ def dashboard_overview(
|
||||
period_reward_video_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
)
|
||||
period_feed_ad_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
@@ -384,15 +390,29 @@ def dashboard_overview(
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.like("task_%"),
|
||||
)
|
||||
# 领券/比价奖励金币 = biz_type 桶(历史空,兜底)+ 该场景信息流广告实发金币
|
||||
# (ad_feed_reward_record.feed_scene,granted;reward_date 是北京日期串,与 period 同自然日窗口)。
|
||||
period_coupon_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "coupon",
|
||||
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
|
||||
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
|
||||
)
|
||||
period_comparison_reward_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
*period_coin_conds,
|
||||
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
|
||||
) + _sum(
|
||||
AdFeedRewardRecord.coin,
|
||||
AdFeedRewardRecord.status == "granted",
|
||||
AdFeedRewardRecord.feed_scene == "comparison",
|
||||
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
|
||||
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
|
||||
)
|
||||
period_regular_task_coin_total = _sum(
|
||||
CoinTransaction.amount,
|
||||
@@ -543,7 +563,7 @@ def dashboard_overview(
|
||||
"reward_video_coin_total": _sum(
|
||||
CoinTransaction.amount,
|
||||
CoinTransaction.amount > 0,
|
||||
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
|
||||
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
|
||||
),
|
||||
"reward_video_watch_count": _count(
|
||||
AdRewardRecord,
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from app.admin.audit import write_audit
|
||||
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
|
||||
from app.admin.permissions import SUPER_ADMIN_ROLE
|
||||
from app.admin.permissions import CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
|
||||
@@ -26,14 +26,22 @@ def _active_super_count(db: AdminDb) -> int:
|
||||
|
||||
|
||||
def _validate_role(db: AdminDb, role: str) -> None:
|
||||
"""角色必须是 super_admin 或 admin_role 表里已存在的角色,否则 400。"""
|
||||
if role == SUPER_ADMIN_ROLE:
|
||||
"""角色必须是 super_admin / custom(自定义) / admin_role 表里已存在的角色,否则 400。"""
|
||||
if role in (SUPER_ADMIN_ROLE, CUSTOM_ROLE):
|
||||
return
|
||||
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
|
||||
if role_repo.get_role(db, role) is None:
|
||||
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
|
||||
|
||||
|
||||
def _clean_override(role: str, pages_override: list[str] | None) -> list[str] | None:
|
||||
"""按最终角色算出该存的 pages_override:custom → 勾选集(过滤非法 key,可空列表);
|
||||
非 custom → None(切回普通角色即清空自定义页)。"""
|
||||
if role == CUSTOM_ROLE:
|
||||
return sanitize_pages(pages_override)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
|
||||
def list_admins(db: AdminDb) -> list[AdminOut]:
|
||||
out: list[AdminOut] = []
|
||||
@@ -51,13 +59,16 @@ def create_admin(
|
||||
if admin_repo.get_by_username(db, body.username) is not None:
|
||||
raise HTTPException(status_code=409, detail="用户名已存在")
|
||||
_validate_role(db, body.role)
|
||||
override = _clean_override(body.role, body.pages_override)
|
||||
new = admin_repo.create_admin(
|
||||
db, username=body.username, password=body.password, role=body.role,
|
||||
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
|
||||
pages_override=override,
|
||||
)
|
||||
write_audit(
|
||||
db, admin, action="admin.create", target_type="admin", target_id=new.id,
|
||||
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
|
||||
detail={"username": new.username, "role": new.role, "pages_override": override},
|
||||
ip=get_client_ip(request), commit=True,
|
||||
)
|
||||
return AdminOut.model_validate(new)
|
||||
|
||||
@@ -89,7 +100,8 @@ def update_admin(
|
||||
_validate_role(db, body.role)
|
||||
|
||||
changes: dict = {}
|
||||
if body.role is not None and body.role != target.role:
|
||||
role_changed = body.role is not None and body.role != target.role
|
||||
if role_changed:
|
||||
changes["role"] = {"before": target.role, "after": body.role}
|
||||
target.role = body.role
|
||||
if body.status is not None and body.status != target.status:
|
||||
@@ -99,6 +111,17 @@ def update_admin(
|
||||
changes["password"] = "reset"
|
||||
target.password_hash = hash_password(body.password)
|
||||
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
|
||||
|
||||
# 自定义可见页:按「最终角色」(target.role,已应用完角色变更)决定该存什么。
|
||||
# - 切到/维持 custom 且传了 pages_override → 用勾选集;切到 custom 没传 → 清成空列表。
|
||||
# - 切回普通角色 → 清空 override(_clean_override 返回 None)。
|
||||
# - 角色没变、只传 pages_override(编辑现有 custom 用户的勾选)→ 也更新。
|
||||
if role_changed or body.pages_override is not None:
|
||||
new_override = _clean_override(target.role, body.pages_override)
|
||||
if new_override != target.pages_override:
|
||||
changes["pages_override"] = {"before": target.pages_override, "after": new_override}
|
||||
target.pages_override = new_override
|
||||
|
||||
if not changes:
|
||||
raise HTTPException(status_code=400, detail="无任何变更字段")
|
||||
db.commit()
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.admin.deps import AdminDb, CurrentAdmin
|
||||
from app.admin.permissions import CUSTOM_ROLE, sanitize_pages
|
||||
from app.admin.repositories import admin_role as role_repo
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
|
||||
@@ -19,9 +20,13 @@ router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
|
||||
|
||||
|
||||
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
|
||||
"""AdminOut + 当前角色有效可见页(前端左侧导航按此过滤)。"""
|
||||
"""AdminOut + 有效可见页(前端左侧导航按此过滤)。
|
||||
role=="custom" → 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析。"""
|
||||
out = AdminOut.model_validate(admin)
|
||||
out.pages = role_repo.effective_pages_of(db, admin.role)
|
||||
if admin.role == CUSTOM_ROLE:
|
||||
out.pages = sanitize_pages(admin.pages_override)
|
||||
else:
|
||||
out.pages = role_repo.effective_pages_of(db, admin.role)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ from app.admin.schemas.ops_marquee_seed import (
|
||||
OpsMarqueeSeedCreate,
|
||||
OpsMarqueeSeedOut,
|
||||
OpsMarqueeSeedUpdate,
|
||||
OpsRealRecordItem,
|
||||
OpsRealRecordsOut,
|
||||
OpsSavingsFeedPreviewOut,
|
||||
)
|
||||
from app.models.admin import AdminUser
|
||||
@@ -68,6 +70,22 @@ def preview_feed(
|
||||
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
|
||||
|
||||
|
||||
@router.get("/real-records", response_model=OpsRealRecordsOut, summary="分页浏览当前模式下可展示的记录(审核用)")
|
||||
def list_real_records(
|
||||
db: AdminDb,
|
||||
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=50)] = 8,
|
||||
) -> OpsRealRecordsOut:
|
||||
"""分页列出**当前模式**下可在 app 轮播展示的全部记录(**不去重**):只真实=真实记录;只种子=各启用
|
||||
种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,固定种子→翻页稳定、能翻遍全部。
|
||||
item.user_id=0 表示种子行。"""
|
||||
if mode is not None and mode not in ops_marquee.FEED_MODES:
|
||||
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
||||
items, total = ops_marquee.list_real_records(db, mode=mode, offset=offset, limit=limit)
|
||||
return OpsRealRecordsOut(items=[OpsRealRecordItem(**it) for it in items], total=total)
|
||||
|
||||
|
||||
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
|
||||
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
|
||||
def get_feed_mode(db: AdminDb) -> dict:
|
||||
|
||||
@@ -13,14 +13,18 @@ class AdminCreateRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=64)
|
||||
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
|
||||
role: str = Field("operator", min_length=1, max_length=32)
|
||||
# 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。
|
||||
pages_override: list[str] | None = None
|
||||
|
||||
|
||||
class AdminUpdateRequest(BaseModel):
|
||||
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
|
||||
"""改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。"""
|
||||
|
||||
role: str | None = Field(None, min_length=1, max_length=32)
|
||||
status: Literal["active", "disabled"] | None = None
|
||||
password: str | None = Field(None, min_length=8, max_length=72)
|
||||
# 改成/更新「自定义」可见页;role 切回普通角色时后端会清空 override(见路由)。
|
||||
pages_override: list[str] | None = None
|
||||
|
||||
|
||||
class AdminAuditLogOut(BaseModel):
|
||||
|
||||
@@ -21,8 +21,11 @@ class AdminOut(BaseModel):
|
||||
created_at: datetime
|
||||
last_login_at: datetime | None = None
|
||||
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
|
||||
# 前端据此过滤左侧导航(super_admin = 全部页)。见 app/admin/permissions.py。
|
||||
# 前端据此过滤左侧导航(super_admin = 全部页;role=="custom" = pages_override)。见 permissions.py。
|
||||
pages: list[str] = []
|
||||
# 「自定义」可见页原始勾选集(role=="custom" 时非空);列表接口下发,供权限管理页编辑回填勾选。
|
||||
# 普通角色为 None。from_attributes 自动从 ORM 列取。
|
||||
pages_override: list[str] | None = None
|
||||
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
|
||||
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
|
||||
password: str | None = None
|
||||
|
||||
@@ -62,3 +62,16 @@ class OpsSavingsFeedPreviewItem(BaseModel):
|
||||
class OpsSavingsFeedPreviewOut(BaseModel):
|
||||
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
|
||||
items: list[OpsSavingsFeedPreviewItem]
|
||||
|
||||
|
||||
class OpsRealRecordItem(BaseModel):
|
||||
masked_user: str # 脱敏后展示名(与 app 一致)
|
||||
saved_amount_cents: int # 节省金额(分)
|
||||
created_at: str # 比价记录时间(YYYY-MM-DD HH:MM)
|
||||
user_id: int # 真实 user_id(供运营核对,不下发客户端)
|
||||
|
||||
|
||||
class OpsRealRecordsOut(BaseModel):
|
||||
"""分页浏览「全部可展示的真实记录」(success+省>0,不去重、稳定顺序)。"""
|
||||
items: list[OpsRealRecordItem]
|
||||
total: int # 满足条件的真实记录总数(算页数用)
|
||||
|
||||
+14
-2
@@ -43,6 +43,7 @@ from app.schemas.welfare import (
|
||||
WithdrawRequest,
|
||||
WithdrawResultOut,
|
||||
WithdrawStatusOut,
|
||||
WithdrawTierOut,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.wallet")
|
||||
@@ -173,8 +174,15 @@ def unbind_wechat(
|
||||
return UnbindWechatResultOut(bound=False)
|
||||
|
||||
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关")
|
||||
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位")
|
||||
def withdraw_info(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
source: str = Query(
|
||||
"coin_cash",
|
||||
description="提现账户:coin_cash(福利页,下发 tiers 档位) / invite_cash(邀请页,tiers 为空走旧逻辑)",
|
||||
),
|
||||
) -> WithdrawInfoOut:
|
||||
u = db.get(User, user.id)
|
||||
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
|
||||
auth = crud_wallet.sync_transfer_auth(db, user.id)
|
||||
@@ -185,6 +193,7 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
|
||||
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"),
|
||||
tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)],
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +227,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
|
||||
) from e
|
||||
except crud_wallet.WithdrawTierUnavailableError as e:
|
||||
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
|
||||
except crud_wallet.InsufficientCashError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import NamedTuple
|
||||
|
||||
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
|
||||
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
|
||||
@@ -52,6 +53,30 @@ WITHDRAW_MIN_CENTS: int = 10
|
||||
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
|
||||
|
||||
|
||||
# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)=====
|
||||
# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。
|
||||
# 规则(2026-07-09 拍板):
|
||||
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
|
||||
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
|
||||
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
|
||||
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
|
||||
class WithdrawTier(NamedTuple):
|
||||
amount_cents: int
|
||||
label: str # 客户端档位方块展示文案
|
||||
badge: str | None # 角标文案;None=无角标
|
||||
daily_limit: int # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定)
|
||||
is_newbie: bool
|
||||
|
||||
|
||||
WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
|
||||
WithdrawTier(10, "0.1", "新人福利", 1, True),
|
||||
WithdrawTier(30, "0.3", "新人福利", 1, True),
|
||||
WithdrawTier(50, "0.5", None, 3, False),
|
||||
WithdrawTier(1000, "10", None, 1, False),
|
||||
WithdrawTier(2000, "20", None, 1, False),
|
||||
)
|
||||
|
||||
|
||||
# ===== 一次性任务(领一次,user_task 去重)=====
|
||||
TASK_ENABLE_NOTIFICATION = "enable_notification"
|
||||
|
||||
|
||||
+4
-1
@@ -28,8 +28,11 @@ class AdminUser(Base):
|
||||
# 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。
|
||||
# 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。
|
||||
plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)
|
||||
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)/ custom(按人自定义)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator")
|
||||
# 「自定义」权限:仅当 role == "custom" 时有效,存这个人专属的可见页 key 列表(不共享给他人)。
|
||||
# 非 custom 用户为 None → 可见页跟随角色。登录/`/me` 下发有效页时,非空即优先用它(见 auth._admin_out_with_pages)。
|
||||
pages_override: Mapped[list | None] = mapped_column(_JSON, nullable=True)
|
||||
# active / disabled
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
|
||||
|
||||
@@ -176,10 +176,19 @@ def grant_feed_reward(
|
||||
)
|
||||
return _commit_record(db, rec, client_event_id)
|
||||
|
||||
# 按点位场景拆流水文案(2026-07):比价等候期看的广告 vs 领券时看的广告,在收益明细里分开显示。
|
||||
# feed_scene=comparison→比价奖励 / coupon→领券奖励;其它(welfare/空/旧端不带)维持通用「信息流广告奖励」。
|
||||
# 客户端按此 biz_type 直显固定文案(见 CoinHistoryViewModel.coinTitle),故 remark 只作后台留痕/兜底。
|
||||
if feed_scene == "comparison":
|
||||
reward_biz, reward_remark = "feed_ad_reward_comparison", "比价奖励"
|
||||
elif feed_scene == "coupon":
|
||||
reward_biz, reward_remark = "feed_ad_reward_coupon", "领券奖励"
|
||||
else:
|
||||
reward_biz, reward_remark = "feed_ad_reward", "信息流广告奖励"
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type="feed_ad_reward", ref_id=client_event_id,
|
||||
remark="信息流广告奖励",
|
||||
biz_type=reward_biz, ref_id=client_event_id,
|
||||
remark=reward_remark,
|
||||
)
|
||||
rec = AdFeedRewardRecord(
|
||||
client_event_id=client_event_id,
|
||||
|
||||
@@ -217,12 +217,28 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
|
||||
return out
|
||||
|
||||
|
||||
def _shuffle_declustered(rows: list, rng: random.Random | None = None) -> list:
|
||||
"""洗牌 + 「去连簇」:先洗牌,再贪心重排让相邻两条尽量不是同一 user_id(元素 [0] 即 user_id)。
|
||||
rng=None → 用全局 _rng(feed 每次新随机);传入 rng(如固定种子 Random)→ 排列确定(admin 稳定分页)。
|
||||
减少同一用户连续出现;只有少数几个用户时 best-effort。"""
|
||||
rng = rng or _rng
|
||||
pool = list(rows)
|
||||
rng.shuffle(pool)
|
||||
result: list = []
|
||||
while pool:
|
||||
prev_uid = result[-1][0] if result else None
|
||||
# 优先挑与上一条不同 user 的;挑不到(只剩同 user)才取第一个
|
||||
idx = next((i for i, r in enumerate(pool) if r[0] != prev_uid), 0)
|
||||
result.append(pool.pop(idx))
|
||||
return result
|
||||
|
||||
|
||||
def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
|
||||
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
|
||||
|
||||
mode:显式传入(admin 预览指定模式)则用它、**不改持久化配置**;不传(客户端 /savings-feed)读
|
||||
持久化的 marquee_feed_mode;非法值一律回退到持久化模式。
|
||||
真实条:success 且 0 < saved ≤ 上限,按 user 去重(同一用户只取最新一条,避免单人刷屏)。
|
||||
真实条:success 且 0 < saved ≤ 上限,**不按 user 去重**(打乱 + 去连簇:相邻尽量不同用户、减少单人连刷)后取前 limit。
|
||||
不足用启用的种子补齐——**公平随机抽取** need 个(而非固定取前 N),让所有种子都有机会露出;
|
||||
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多、偶尔大额,更像真实分布)。
|
||||
真实 + 种子仍不满 limit → 内置合成条**补满**,保证轮播既不空也不稀疏。
|
||||
@@ -235,19 +251,13 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
|
||||
items: list[dict] = []
|
||||
used_names: set[str] = set()
|
||||
|
||||
# 真实条(mixed / real):取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
|
||||
# 真实条(mixed / real):**不按 user 去重**——打乱 + 去连簇(相邻尽量不同用户、减少单人连刷)后取前
|
||||
# limit;金额超上限的异常值已在查询剔除。同一用户可多条露出,但被打散、尽量不连续。
|
||||
if mode != "seed":
|
||||
rows = _recent_real_rows(db)
|
||||
seen_users: set[int] = set()
|
||||
for uid, sc, nick in rows:
|
||||
if uid in seen_users:
|
||||
continue
|
||||
seen_users.add(uid)
|
||||
for uid, sc, nick in _shuffle_declustered(_recent_real_rows(db))[:limit]:
|
||||
name = _mask_real(nick, uid)
|
||||
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
|
||||
used_names.add(name) # 同一用户可多条,名字重复无害(used_names 仅供种子避重)
|
||||
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
|
||||
if len(items) >= limit:
|
||||
break
|
||||
|
||||
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
|
||||
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
|
||||
@@ -300,6 +310,78 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
|
||||
return items
|
||||
|
||||
|
||||
# ===== admin 侧:分页浏览「当前模式下可展示的记录」(审核用,不去重) =====
|
||||
_REAL_BROWSE_CAP = 1000 # 真实记录一次最多纳入这么多去洗牌+分页(足够审核;防超大库全量洗牌)
|
||||
_BROWSE_SEED = 20260707 # 固定洗牌种子:同一批数据下排列恒定 → 翻页稳定、能翻遍全部
|
||||
|
||||
|
||||
def _seed_browse_row(seed: OpsMarqueeSeed) -> dict:
|
||||
"""把一条种子按其「生成逻辑」**确定性**生成一行浏览项(名字/金额按 seed.id 派生固定种子 → 翻页稳定)。
|
||||
名字:运营手填的非模板名原样用,否则本地合成;金额:区间内确定性长尾取值。user_id=0(种子无真实用户)。"""
|
||||
r = random.Random(_BROWSE_SEED * 1_000_003 + int(seed.id))
|
||||
fixed = (seed.masked_user or "").strip()
|
||||
name = fixed if (fixed and not fixed.startswith("用户****")) else _synth_name(r)
|
||||
lo = max(0, int(seed.min_cents))
|
||||
hi = max(lo, int(seed.max_cents))
|
||||
amt = lo if hi <= lo else lo + int(round((hi - lo) * (r.random() ** 2.2)))
|
||||
return {"masked_user": name, "saved_amount_cents": amt, "created_at": "", "user_id": 0}
|
||||
|
||||
|
||||
def list_real_records(
|
||||
db: Session, mode: str | None = None, offset: int = 0, limit: int = 8
|
||||
) -> tuple[list[dict], int]:
|
||||
"""分页浏览「**当前模式**下可在 app 轮播展示的全部记录」,供 admin 逐页审核(**不去重**):
|
||||
- 只真实:全部 success+省>0 的真实记录;
|
||||
- 只种子:每条启用种子按生成逻辑各出一行;
|
||||
- 混播:真实 + 种子 全部合在一起。
|
||||
与 app 轮播同口径:先洗牌 + 去连簇(相邻尽量不同 user;种子各自独立、不算同 user);**固定种子** →
|
||||
同批数据下排列恒定,翻页不跳、能翻遍全部。返回 (items, total);item.user_id=0 表示种子。"""
|
||||
mode = mode if mode in FEED_MODES else get_feed_mode(db)
|
||||
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
|
||||
pool: list[tuple[int, dict]] = []
|
||||
if mode != "seed":
|
||||
rows = db.execute(
|
||||
select(
|
||||
ComparisonRecord.user_id,
|
||||
ComparisonRecord.saved_amount_cents,
|
||||
User.nickname,
|
||||
ComparisonRecord.created_at,
|
||||
)
|
||||
.join(User, User.id == ComparisonRecord.user_id)
|
||||
.where(
|
||||
ComparisonRecord.status == "success",
|
||||
ComparisonRecord.saved_amount_cents > 0,
|
||||
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
|
||||
)
|
||||
.order_by(ComparisonRecord.created_at.desc())
|
||||
.limit(_REAL_BROWSE_CAP)
|
||||
).all()
|
||||
for uid, sc, nick, ca in rows:
|
||||
pool.append((
|
||||
int(uid),
|
||||
{
|
||||
"masked_user": _mask_real(nick, int(uid)),
|
||||
"saved_amount_cents": int(sc),
|
||||
"created_at": str(ca)[:16] if ca is not None else "",
|
||||
"user_id": int(uid),
|
||||
},
|
||||
))
|
||||
if mode != "real":
|
||||
seeds = (
|
||||
db.execute(select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for i, s in enumerate(seeds):
|
||||
pool.append((-(i + 1), _seed_browse_row(s))) # 每个种子唯一 key → 互不聚簇
|
||||
total = len(pool)
|
||||
# 每次用同一固定种子新建 Random → 同批数据排列恒定(翻页稳定);同时相邻尽量不同 user。
|
||||
ordered = _shuffle_declustered(pool, random.Random(_BROWSE_SEED))
|
||||
off = max(0, offset)
|
||||
items = [item for _key, item in ordered[off : off + limit]]
|
||||
return items, total
|
||||
|
||||
|
||||
# ===== 运营侧:种子 CRUD =====
|
||||
def list_seeds(db: Session) -> list[OpsMarqueeSeed]:
|
||||
return list(
|
||||
|
||||
+101
-1
@@ -11,7 +11,7 @@ import unicodedata
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -67,6 +67,10 @@ class WithdrawTooFrequentError(Exception):
|
||||
"""提现申请过于频繁,或已有未完成提现单。"""
|
||||
|
||||
|
||||
class WithdrawTierUnavailableError(Exception):
|
||||
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
|
||||
|
||||
|
||||
class WithdrawTransferError(Exception):
|
||||
"""调用微信转账失败(已退回余额)。"""
|
||||
|
||||
@@ -605,6 +609,89 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
|
||||
db.commit()
|
||||
|
||||
|
||||
def _beijing_today_start_utc() -> datetime:
|
||||
"""北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储,
|
||||
比较时统一转 UTC,与 admin 看板 today_start 同口径(admin/repositories/queries.py)。"""
|
||||
return (
|
||||
datetime.now(rewards.CN_TZ)
|
||||
.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
.astimezone(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]:
|
||||
"""福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。
|
||||
|
||||
规则(2026-07-09 拍板,7-9提现ui对齐):
|
||||
- 新人档(0.1/0.3):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算")
|
||||
即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。
|
||||
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
|
||||
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
|
||||
- invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
|
||||
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。
|
||||
"""
|
||||
if source != "coin_cash":
|
||||
return []
|
||||
tiers = rewards.WITHDRAW_TIERS_COIN_CASH
|
||||
amounts = [t.amount_cents for t in tiers]
|
||||
newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie]
|
||||
# 新人档历史是否用过:任意时间、任意状态("发起就算")
|
||||
used_newbie: set[int] = set(
|
||||
db.execute(
|
||||
select(WithdrawOrder.amount_cents)
|
||||
.distinct()
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.source == "coin_cash",
|
||||
WithdrawOrder.amount_cents.in_(newbie_amounts),
|
||||
)
|
||||
).scalars()
|
||||
) if newbie_amounts else set()
|
||||
# 今日(北京日)每档已发起次数(任意状态)
|
||||
today_counts: dict[int, int] = {
|
||||
int(amount): int(cnt)
|
||||
for amount, cnt in db.execute(
|
||||
select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id))
|
||||
.where(
|
||||
WithdrawOrder.user_id == user_id,
|
||||
WithdrawOrder.source == "coin_cash",
|
||||
WithdrawOrder.amount_cents.in_(amounts),
|
||||
WithdrawOrder.created_at >= _beijing_today_start_utc(),
|
||||
)
|
||||
.group_by(WithdrawOrder.amount_cents)
|
||||
)
|
||||
}
|
||||
# "每日选一个额度":今天发起过的常规档(新人档不算)
|
||||
selected_regular = next(
|
||||
(t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0),
|
||||
None,
|
||||
)
|
||||
out: list[dict] = []
|
||||
for t in tiers:
|
||||
if t.is_newbie:
|
||||
if t.amount_cents in used_newbie:
|
||||
continue # 用过即消失,不再下发
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": True, "available": True, "disabled_reason": None,
|
||||
"remaining_today": 1,
|
||||
})
|
||||
continue
|
||||
used = today_counts.get(t.amount_cents, 0)
|
||||
if selected_regular is not None and selected_regular != t.amount_cents:
|
||||
available, reason, remaining = False, "other_tier_selected", 0
|
||||
elif used >= t.daily_limit:
|
||||
available, reason, remaining = False, "quota_exhausted", 0
|
||||
else:
|
||||
available, reason, remaining = True, None, t.daily_limit - used
|
||||
out.append({
|
||||
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
|
||||
"is_newbie": False, "available": available, "disabled_reason": reason,
|
||||
"remaining_today": remaining,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def create_withdraw(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
@@ -660,6 +747,19 @@ def create_withdraw(
|
||||
if active_order_id is not None:
|
||||
raise WithdrawTooFrequentError
|
||||
|
||||
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
|
||||
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
|
||||
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
|
||||
if source == "coin_cash" and not allow_sub_min:
|
||||
tier_state = next(
|
||||
(t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents),
|
||||
None,
|
||||
)
|
||||
if tier_state is None: # 非预设档位金额,或新人档已用过(已从列表消失)
|
||||
raise InvalidWithdrawAmountError
|
||||
if not tier_state["available"]:
|
||||
raise WithdrawTierUnavailableError
|
||||
|
||||
# 账户须存在(原子扣款的 UPDATE 不会建账户)
|
||||
get_or_create_account(db, user_id, commit=True)
|
||||
|
||||
|
||||
@@ -75,6 +75,21 @@ class ExchangeResultOut(BaseModel):
|
||||
|
||||
# ===== 提现(现金 → 微信零钱) =====
|
||||
|
||||
class WithdrawTierOut(BaseModel):
|
||||
"""提现档位(福利页 coin_cash;7-9 对齐原型)。served by rewards.WITHDRAW_TIERS_COIN_CASH。"""
|
||||
|
||||
amount_cents: int = Field(..., description="档位金额(分)")
|
||||
label: str = Field(..., description="档位方块展示文案,如 0.1 / 10")
|
||||
badge: str | None = Field(None, description="角标文案(如 新人福利);无则空")
|
||||
is_newbie: bool = Field(False, description="新人档:历史一次性,用过后不再下发;免广告直提")
|
||||
available: bool = Field(True, description="当前是否可提(次数/选一额度口径;余额由客户端自判)")
|
||||
disabled_reason: str | None = Field(
|
||||
None,
|
||||
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
|
||||
)
|
||||
remaining_today: int = Field(0, description="今日剩余可提次数")
|
||||
|
||||
|
||||
class WithdrawInfoOut(BaseModel):
|
||||
min_cents: int = Field(..., description="单次最低提现(分)")
|
||||
max_cents: int = Field(..., description="单次最高提现(分)")
|
||||
@@ -84,6 +99,10 @@ class WithdrawInfoOut(BaseModel):
|
||||
transfer_auth_enabled: bool = Field(
|
||||
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
|
||||
)
|
||||
tiers: list[WithdrawTierOut] = Field(
|
||||
default_factory=list,
|
||||
description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)",
|
||||
)
|
||||
|
||||
|
||||
# ===== 免确认收款授权(用户授权免确认模式)=====
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# 本地开发/测试用 PostgreSQL。生产用原生 PG(scripts/init_postgres.py),不使用本文件。
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: shaguabijia-pg
|
||||
environment:
|
||||
POSTGRES_USER: shaguabijia_app
|
||||
POSTGRES_PASSWORD: shaguabijia_dev_pw
|
||||
POSTGRES_DB: shaguabijia
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 仅在 pgdata 卷首次初始化时执行一次(以 shaguabijia_app 连 shaguabijia 库运行)。
|
||||
-- 幂等兜底见 scripts/ensure_pg.py 的 _ensure_test_db()。
|
||||
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
|
||||
@@ -27,21 +27,7 @@ PG 默认上 16 版(工具链最齐),驱动用 **psycopg3**(SQLAlchemy 2.0 时
|
||||
|
||||
## 1. 本地起 PG + 跑通空库(半天)
|
||||
|
||||
### 1.0 推荐:Docker 一键起(本地开发/测试)
|
||||
|
||||
本地开发不必手动装 PG。已提供 `docker-compose.yml` + `scripts/ensure_pg.py`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # DATABASE_URL 默认已是 Docker PG 连接串
|
||||
./run.sh # 或 run.bat;会自动:探测 PG → 没起则启 Docker → 起 PG 容器 → 建库 → alembic → uvicorn
|
||||
pytest # conftest 自动引导同一容器的 shaguabijia_test 库
|
||||
```
|
||||
|
||||
容器:`postgres:16-alpine`(名 `shaguabijia-pg`,端口 5432,命名卷 `pgdata` 持久化),
|
||||
首启即建业务库 `shaguabijia` 与测试库 `shaguabijia_test`。下面 1.1-1.5 的手动装 PG 步骤仅在
|
||||
不用 Docker 时才需要;生产仍走 §4 的原生 PG。
|
||||
|
||||
### 1.1 装 PG(不用 Docker 时的手动方式)
|
||||
### 1.1 装 PG
|
||||
|
||||
macOS:
|
||||
```bash
|
||||
|
||||
@@ -1,740 +0,0 @@
|
||||
# 本地开发切 Docker PostgreSQL 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 app-server 本地开发运行与 pytest 都跑在 Docker 化的 PostgreSQL 16 上,退掉 SQLite,`run.bat`/`run.sh` 启动时自动检测并拉起 PG(必要时先启 Docker Desktop、缺镜像先拉)。
|
||||
|
||||
**Architecture:** 新增 `docker-compose.yml`(声明 PG 服务/卷/健康检查)+ `scripts/ensure_pg.py`(跨平台引导:探测→启 Docker→compose up→等就绪→幂等建测试库),由 `run.sh`/`run.bat`/`tests/conftest.py` 三处共用。`.env.example` 默认切 PG。`app/db/session.py` 与 `alembic/env.py` 已天然支持 PG,无需改。
|
||||
|
||||
**Tech Stack:** Docker Compose、`postgres:16-alpine`、Python 3.10+ 标准库(`socket`/`subprocess`/`urllib.parse`)、psycopg3(已装)、SQLAlchemy 2.0 + Alembic、pytest。
|
||||
|
||||
**工作目录:** 本计划在 worktree `.worktrees/local-dev-postgres-docker`(分支 `chore/local-dev-postgres-docker`,基于 `main`)内执行。下面所有路径相对该 worktree 根(= 仓库根)。
|
||||
|
||||
**设计依据:** [docs/superpowers/specs/2026-07-08-local-dev-postgres-docker-design.md](../specs/2026-07-08-local-dev-postgres-docker-design.md)
|
||||
|
||||
**连接参数(全程固定值):**
|
||||
- 镜像 `postgres:16-alpine`,容器名 `shaguabijia-pg`,宿主端口 `5432`
|
||||
- 用户 `shaguabijia_app`,dev 密码 `shaguabijia_dev_pw`(本地非机密)
|
||||
- 业务库 `shaguabijia`,测试库 `shaguabijia_test`,命名卷 `pgdata`
|
||||
- dev URL:`postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`
|
||||
- test URL:`...@localhost:5432/shaguabijia_test`
|
||||
|
||||
**前置:** 执行机已安装 Docker Desktop。
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Docker Compose + 测试库 initdb 脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `docker-compose.yml`
|
||||
- Create: `docker/initdb/01-create-test-db.sql`
|
||||
|
||||
- [ ] **Step 1: 写 `docker-compose.yml`**
|
||||
|
||||
```yaml
|
||||
# 本地开发/测试用 PostgreSQL。生产用原生 PG(scripts/init_postgres.py),不使用本文件。
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: shaguabijia-pg
|
||||
environment:
|
||||
POSTGRES_USER: shaguabijia_app
|
||||
POSTGRES_PASSWORD: shaguabijia_dev_pw
|
||||
POSTGRES_DB: shaguabijia
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 写 `docker/initdb/01-create-test-db.sql`**
|
||||
|
||||
```sql
|
||||
-- 仅在 pgdata 卷首次初始化时执行一次(以 shaguabijia_app 连 shaguabijia 库运行)。
|
||||
-- 幂等兜底见 scripts/ensure_pg.py 的 _ensure_test_db()。
|
||||
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 起容器验证**
|
||||
|
||||
Run: `docker compose up -d`
|
||||
Expected: 拉取 `postgres:16-alpine`(首次)后 `Container shaguabijia-pg Started`。
|
||||
|
||||
- [ ] **Step 4: 验证两个库都在 + 健康**
|
||||
|
||||
Run: `docker compose exec -T postgres psql -U shaguabijia_app -d shaguabijia -tAc "SELECT datname FROM pg_database WHERE datname IN ('shaguabijia','shaguabijia_test') ORDER BY 1"`
|
||||
Expected 输出:
|
||||
```
|
||||
shaguabijia
|
||||
shaguabijia_test
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证 `.worktrees/` 与 `data/` 忽略不受影响、compose 无落盘到项目目录**
|
||||
|
||||
Run: `git status --short`
|
||||
Expected: 只列出本任务新增的 `docker-compose.yml`、`docker/initdb/01-create-test-db.sql`(数据在命名卷 `pgdata`,不在项目目录;`.worktrees/` 已忽略)。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml docker/initdb/01-create-test-db.sql
|
||||
git commit -m "feat(dev): docker-compose 起本地 PostgreSQL(含测试库 initdb)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `scripts/ensure_pg.py` 引导脚本(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/ensure_pg.py`
|
||||
- Test: `tests/test_ensure_pg.py`
|
||||
|
||||
> 说明:纯函数(URL 解析 / sqlite 判定 / 端口探测 / 平台命令映射 / sqlite 守卫 / 端口通时短路)走 TDD 单测;真正拉 Docker 的编排 `ensure()` 全链路靠 Task 3/5 的运行来验证(需真 Docker,不做单测)。此时 `conftest.py` 仍是 SQLite,不依赖 PG,单测可独立跑。
|
||||
|
||||
- [ ] **Step 1: 写失败测试 `tests/test_ensure_pg.py`**
|
||||
|
||||
```python
|
||||
"""scripts/ensure_pg.py 纯函数单测(不需要 Docker/PG)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from scripts.ensure_pg import (
|
||||
_docker_desktop_cmd,
|
||||
_is_sqlite,
|
||||
_parse_host_port,
|
||||
_port_open,
|
||||
ensure,
|
||||
)
|
||||
|
||||
|
||||
def test_is_sqlite():
|
||||
assert _is_sqlite("sqlite:///./data/app.db")
|
||||
assert _is_sqlite(" SQLite:///x ")
|
||||
assert not _is_sqlite("postgresql+psycopg://u:p@localhost:5432/db")
|
||||
|
||||
|
||||
def test_parse_host_port_full():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_defaults():
|
||||
# 缺端口 → 5432
|
||||
assert _parse_host_port("postgresql+psycopg://u:p@db.example/x")[1] == 5432
|
||||
# 缺 host → localhost
|
||||
assert _parse_host_port("postgresql+psycopg:///x") == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_testdb():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia_test"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_port_open_true():
|
||||
srv = socket.socket()
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(1)
|
||||
port = srv.getsockname()[1]
|
||||
try:
|
||||
assert _port_open("127.0.0.1", port, timeout=1.0)
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
def test_port_open_false():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close() # 释放端口,无人监听 → 连接应失败
|
||||
assert not _port_open("127.0.0.1", port, timeout=0.3)
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_windows():
|
||||
cmd = _docker_desktop_cmd("win32", r"C:\Program Files")
|
||||
assert cmd is not None
|
||||
assert cmd[0].endswith("Docker Desktop.exe")
|
||||
assert "Docker" in cmd[0]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_darwin():
|
||||
assert _docker_desktop_cmd("darwin", "") == ["open", "-a", "Docker"]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_linux():
|
||||
assert _docker_desktop_cmd("linux", "") is None
|
||||
|
||||
|
||||
def test_ensure_rejects_sqlite():
|
||||
# dev 守卫:sqlite 直接 False(不碰 Docker)
|
||||
assert ensure("sqlite:///./data/app.db") is False
|
||||
|
||||
|
||||
def test_ensure_shortcircuits_when_pg_up(monkeypatch):
|
||||
# 端口通 → 直接 True,绝不触碰 docker
|
||||
monkeypatch.setattr("scripts.ensure_pg._port_open", lambda *a, **k: True)
|
||||
|
||||
def _boom():
|
||||
raise AssertionError("端口通时不应调用 docker")
|
||||
|
||||
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
|
||||
assert ensure("postgresql+psycopg://u:p@localhost:5432/shaguabijia") is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_ensure_pg.py -v`
|
||||
Expected: FAIL —— `ModuleNotFoundError: No module named 'scripts.ensure_pg'`(还没建)。
|
||||
|
||||
- [ ] **Step 3: 写实现 `scripts/ensure_pg.py`**
|
||||
|
||||
```python
|
||||
"""确保本地 PostgreSQL 就绪(开发/测试统一用 Docker PG)。
|
||||
|
||||
被三处复用:
|
||||
- run.sh / run.bat:`python -m scripts.ensure_pg`(CLI,失败退非 0)
|
||||
- tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)`
|
||||
|
||||
流程:读 DATABASE_URL → TCP 探测 → 没起就(必要时启 Docker Desktop)→
|
||||
`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。全程无 SQLite 兜底。
|
||||
|
||||
生产用原生 PG(scripts/init_postgres.py),不走本模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 日志里可能含 emoji(如 ✅);Windows GBK 控制台(cmd.exe)无法编码会抛 UnicodeEncodeError → 脚本崩、
|
||||
# run.bat 误判 ensure_pg 失败。用 backslashreplace 保底:中文仍正常,仅不可编码字符被转义,不崩。
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(errors="backslashreplace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
APP_DB = "shaguabijia"
|
||||
TEST_DB = "shaguabijia_test"
|
||||
DB_USER = "shaguabijia_app"
|
||||
COMPOSE_SERVICE = "postgres"
|
||||
|
||||
DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120"))
|
||||
PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60"))
|
||||
POLL_INTERVAL = 3.0
|
||||
|
||||
SQLITE_FIX_HINT = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"
|
||||
)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[ensure_pg] {msg}", flush=True)
|
||||
|
||||
|
||||
def _is_sqlite(url: str) -> bool:
|
||||
return url.strip().lower().startswith("sqlite")
|
||||
|
||||
|
||||
def _parse_host_port(url: str) -> tuple[str, int]:
|
||||
"""从 SQLAlchemy URL 取 host/port,缺省 localhost:5432。"""
|
||||
parts = urlsplit(url)
|
||||
return (parts.hostname or "localhost"), (parts.port or 5432)
|
||||
|
||||
|
||||
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _docker_desktop_cmd(platform: str, program_files: str) -> list[str] | None:
|
||||
"""按平台给出启动 Docker Desktop 的命令;Linux 返回 None(daemon 需 sudo,让用户手动)。"""
|
||||
if platform.startswith("win"):
|
||||
return [str(Path(program_files) / "Docker" / "Docker" / "Docker Desktop.exe")]
|
||||
if platform == "darwin":
|
||||
return ["open", "-a", "Docker"]
|
||||
return None
|
||||
|
||||
|
||||
def _docker_ok(subcmd: str) -> bool:
|
||||
"""`docker version`(CLI 在不在)/`docker info`(daemon 起没起)成功与否。"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", subcmd],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return False
|
||||
|
||||
|
||||
def _docker_cli_ok() -> bool:
|
||||
return _docker_ok("version")
|
||||
|
||||
|
||||
def _docker_daemon_ok() -> bool:
|
||||
return _docker_ok("info")
|
||||
|
||||
|
||||
def _start_docker_daemon() -> bool:
|
||||
"""守护进程没起时按平台拉起,轮询到就绪。返回是否成功。"""
|
||||
if _docker_daemon_ok():
|
||||
return True
|
||||
cmd = _docker_desktop_cmd(
|
||||
sys.platform, os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
)
|
||||
if cmd is None:
|
||||
_log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。")
|
||||
return False
|
||||
if sys.platform.startswith("win") and not Path(cmd[0]).exists():
|
||||
_log(f"找不到 Docker Desktop:{cmd[0]}。请手动启动 Docker Desktop 后重试。")
|
||||
return False
|
||||
_log(f"启动 Docker Desktop(首次冷启可能 30-60s)…")
|
||||
try:
|
||||
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError as e:
|
||||
_log(f"启动 Docker Desktop 失败:{e}")
|
||||
return False
|
||||
deadline = time.monotonic() + DOCKER_START_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _docker_daemon_ok():
|
||||
_log("Docker 守护进程已就绪。")
|
||||
return True
|
||||
_log("等待 Docker 守护进程…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 Docker 守护进程超时({DOCKER_START_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _compose_up() -> bool:
|
||||
_log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…")
|
||||
try:
|
||||
subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
_log(f"docker compose up 失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def _pg_isready() -> bool:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"pg_isready", "-U", DB_USER, "-d", APP_DB],
|
||||
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def _wait_pg_ready(host: str, port: int) -> bool:
|
||||
deadline = time.monotonic() + PG_READY_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _port_open(host, port) and _pg_isready():
|
||||
_log("PostgreSQL 已就绪。")
|
||||
return True
|
||||
_log("等待 PostgreSQL 就绪…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 PostgreSQL 就绪超时({PG_READY_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_test_db() -> None:
|
||||
"""幂等建测试库(兼容老 pgdata 卷首启没跑 initdb 的情况)。"""
|
||||
check = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-tAc",
|
||||
f"SELECT 1 FROM pg_database WHERE datname='{TEST_DB}'"],
|
||||
cwd=ROOT, capture_output=True, text=True,
|
||||
)
|
||||
if check.returncode == 0 and check.stdout.strip() == "1":
|
||||
return
|
||||
_log(f"建测试库 {TEST_DB}…")
|
||||
subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-c",
|
||||
f"CREATE DATABASE {TEST_DB} OWNER {DB_USER}"],
|
||||
cwd=ROOT, check=False,
|
||||
)
|
||||
|
||||
|
||||
def ensure(database_url: str | None = None) -> bool:
|
||||
"""确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。"""
|
||||
if database_url is None:
|
||||
from app.core.config import settings # 延迟导入,避免过早固化 settings
|
||||
|
||||
database_url = settings.DATABASE_URL
|
||||
|
||||
if _is_sqlite(database_url):
|
||||
_log("检测到 DATABASE_URL 仍是 SQLite。本地开发/测试已切 PostgreSQL,请改成:")
|
||||
_log(f" DATABASE_URL={SQLITE_FIX_HINT}")
|
||||
return False
|
||||
|
||||
host, port = _parse_host_port(database_url)
|
||||
|
||||
if _port_open(host, port):
|
||||
_log(f"✅ PostgreSQL 已在 {host}:{port} 运行,跳过 Docker。")
|
||||
return True
|
||||
|
||||
_log(f"{host}:{port} 无 PostgreSQL,准备用 Docker 拉起…")
|
||||
|
||||
if not _docker_cli_ok():
|
||||
_log("未检测到 docker 命令。请先安装 Docker Desktop:")
|
||||
_log(" https://www.docker.com/products/docker-desktop/")
|
||||
return False
|
||||
if not _start_docker_daemon():
|
||||
return False
|
||||
if not _compose_up():
|
||||
return False
|
||||
if not _wait_pg_ready(host, port):
|
||||
return False
|
||||
|
||||
_ensure_test_db()
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if ensure() else 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_ensure_pg.py -v`
|
||||
Expected: 11 passed。
|
||||
|
||||
- [ ] **Step 5: 手动冒烟(PG 已在跑时应秒过短路)**
|
||||
|
||||
Run: `python -m scripts.ensure_pg`
|
||||
Expected: 打印 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行,跳过 Docker。`,退出码 0。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/ensure_pg.py tests/test_ensure_pg.py
|
||||
git commit -m "feat(dev): scripts/ensure_pg.py 探测/拉起本地 Docker PostgreSQL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `run.sh` / `run.bat` 接入 ensure_pg
|
||||
|
||||
**Files:**
|
||||
- Modify: `run.sh`(在 `alembic upgrade head` 前插一步)
|
||||
- Modify: `run.bat`(同上)
|
||||
|
||||
- [ ] **Step 1: 改 `run.sh`**
|
||||
|
||||
在 `mkdir -p data` 之后、`"$PY" -m alembic upgrade head` 之前插入:
|
||||
|
||||
```bash
|
||||
"$PY" -m scripts.ensure_pg # 确保本地 Docker PostgreSQL 就绪(没起会自动拉起;失败即退出)
|
||||
```
|
||||
|
||||
(`set -e` 已在文件顶部,ensure_pg 失败会自动终止脚本。)
|
||||
|
||||
- [ ] **Step 2: 改 `run.bat`**
|
||||
|
||||
在 `if not exist data mkdir data` 之后、`call "%PY%" -m alembic upgrade head` 之前插入:
|
||||
|
||||
```bat
|
||||
REM 确保本地 Docker PostgreSQL 就绪(没起会自动拉起 Docker + PG 容器)
|
||||
call "%PY%" -m scripts.ensure_pg
|
||||
if errorlevel 1 (
|
||||
echo [X] ensure_pg failed ^(PostgreSQL 未就绪^)
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 `run.sh`(PG 已在跑,应短路后继续 alembic + uvicorn)**
|
||||
|
||||
Run(Git Bash):`bash run.sh 8770`
|
||||
Expected: 依次出现 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行` → alembic 无报错 → uvicorn `Application startup complete`。`Ctrl-C` 停。
|
||||
|
||||
- [ ] **Step 4: 验证 `run.bat`(同上,Windows 原生)**
|
||||
|
||||
Run(cmd/PowerShell):`.\run.bat 8770`
|
||||
Expected: 同 Step 3。`Ctrl-C` 停。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add run.sh run.bat
|
||||
git commit -m "feat(dev): run.sh/run.bat 启动前确保 Docker PostgreSQL 就绪"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `.env.example` 默认切 PostgreSQL
|
||||
|
||||
**Files:**
|
||||
- Modify: `.env.example`(第 8-10 行「数据库」段)
|
||||
|
||||
- [ ] **Step 1: 改 `.env.example` 的 DATABASE_URL**
|
||||
|
||||
把:
|
||||
```ini
|
||||
# ===== 数据库 =====
|
||||
# SQLite 本地文件路径。生产环境用 /opt/shaguabijia-app-server/data.db
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
```
|
||||
改成:
|
||||
```ini
|
||||
# ===== 数据库 =====
|
||||
# 本地开发/测试统一用 Docker PostgreSQL:run.bat/run.sh 会自动拉起容器
|
||||
# (docker-compose.yml + scripts/ensure_pg.py)。详见 docs/database/postgres-migration.md。
|
||||
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||
# ⚠️ scheme 必须是 postgresql+psycopg://(psycopg3);不要写成 postgresql://(会去找未装的 psycopg2)。
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证(新 .env 从模板复制后能起服务)**
|
||||
|
||||
Run: `cp .env.example /tmp/env.check && grep '^DATABASE_URL=' /tmp/env.check`
|
||||
Expected: `DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .env.example
|
||||
git commit -m "feat(dev): .env.example 默认 DATABASE_URL 切 Docker PostgreSQL"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `tests/conftest.py` 切 PostgreSQL 测试库
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/conftest.py`(整体替换:去掉临时 SQLite,改指 `shaguabijia_test` + 调 ensure_pg + fixture 改 drop/create)
|
||||
|
||||
- [ ] **Step 1: 整体替换 `tests/conftest.py`**
|
||||
|
||||
```python
|
||||
"""测试用 fixtures。
|
||||
|
||||
测试库用 Docker PG 的 shaguabijia_test(与 dev 业务库 shaguabijia 隔离)。
|
||||
顺序(必须):设 test DATABASE_URL(在 import app.* 之前)→ ensure PG 就绪 →
|
||||
import app → 建表。持久卷可能残留上次的表 → session 开头先 drop 再 create。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
# 1) 测试库连接串——必须在 import app.* 之前设好(app.db.session 在 import 期建 engine)
|
||||
_TEST_DB_URL = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"
|
||||
)
|
||||
os.environ["DATABASE_URL"] = _TEST_DB_URL
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
|
||||
os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real")
|
||||
os.environ.setdefault("JG_APP_KEY", "test-key")
|
||||
os.environ.setdefault("JG_MASTER_SECRET", "test-secret")
|
||||
os.environ.setdefault("SMS_MOCK", "true")
|
||||
os.environ.setdefault("APP_ENV", "dev")
|
||||
os.environ.setdefault("APP_DEBUG", "false") # 测试不打 SQL 日志
|
||||
os.environ.setdefault("WECHAT_APP_ID", "wxtest0000000000")
|
||||
os.environ.setdefault("WECHAT_APP_SECRET", "test-secret")
|
||||
os.environ.setdefault("WXPAY_MCH_ID", "test-mch")
|
||||
os.environ.setdefault("WXPAY_MCH_SERIAL_NO", "test-serial")
|
||||
os.environ.setdefault("WXPAY_PUBLIC_KEY_ID", "test-pubkey-id")
|
||||
os.environ.setdefault("RATE_LIMIT_ENABLED", "false")
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
|
||||
|
||||
# 2) 保证 Docker PG 就绪 + 测试库存在(必须在 import app.db.session 建 engine 之前)
|
||||
from scripts.ensure_pg import ensure
|
||||
|
||||
if not ensure(_TEST_DB_URL):
|
||||
raise RuntimeError(
|
||||
"测试需要 Docker PostgreSQL 就绪。请确认已装 Docker Desktop;"
|
||||
"或先跑一次 run.bat/run.sh 把 PG 拉起,再重试 pytest。"
|
||||
)
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _setup_db() -> Iterator[None]:
|
||||
# 持久卷可能残留上次跑崩后的表/数据 → 先 drop 再 create,保证干净起点
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 conftest 能引导 PG 并收集用例(选一个不涉 DB 的测试文件)**
|
||||
|
||||
Run: `pytest tests/test_ensure_pg.py -v`
|
||||
Expected: conftest 先打印 `[ensure_pg] ✅ PostgreSQL 已在 localhost:5432 运行`(或拉起过程),随后 12 passed。说明「测试走 PG 引导」链路通、且纯函数测试不受影响。
|
||||
|
||||
- [ ] **Step 3: 验证建表落到 PG 测试库(跑一个 DB 相关用例)**
|
||||
|
||||
Run: `pytest tests/test_invite.py -v`
|
||||
Expected: 用例在 `shaguabijia_test` 上建表并执行(可能有个别红,留待 Task 6);关键是不再出现 SQLite 临时文件、engine 连的是 PG。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/conftest.py
|
||||
git commit -m "test(dev): conftest 切 shaguabijia_test(Docker PG),引导+drop/create"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 全量跑 pytest on PG,逐个修红用例
|
||||
|
||||
> SQLite 宽松、PG 严格,切库会暴露一批真 bug(迁移指南 §2.2 已列)。本任务是**发现驱动**:先跑全量、按类别归因、按下述配方修,直到全绿。修改范围限被测业务/模型代码,不改测试来掩盖真 bug(除非测试本身依赖 SQLite 特性,如秒级时间精度)。
|
||||
|
||||
**Files:**
|
||||
- Modify: 视失败而定(常见:`app/models/*.py`、`app/**/repositories/*.py`、少量 `tests/*.py`)
|
||||
|
||||
- [ ] **Step 1: 全量跑,拿到失败清单**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: 大部分通过;记录所有 FAIL 的用例名与报错文本,按下面类别归因。
|
||||
|
||||
- [ ] **Step 2: 修「naive datetime / 时区」类**
|
||||
|
||||
定位:`git grep -n "utcnow()" app/`。把 `datetime.utcnow()` 改成 `datetime.now(timezone.utc)`(并 `from datetime import timezone`)。
|
||||
症状:PG `TIMESTAMPTZ` 与 naive datetime 比较/写入报错或结果错位;`tests/test_cps_admin.py` 已注释过 SQLite 忽略 tzinfo 的行为。
|
||||
|
||||
例:
|
||||
```python
|
||||
# 改前
|
||||
from datetime import datetime
|
||||
ts = datetime.utcnow()
|
||||
# 改后
|
||||
from datetime import datetime, timezone
|
||||
ts = datetime.now(timezone.utc)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 修「字符串/整数隐式比较」类**
|
||||
|
||||
症状:SQLite 允许 `WHERE phone = 13800138000`(自动转型),PG 直接报类型错。定位报错用例引用的查询,确保比较两侧类型一致(手机号等一律按字符串传参 `:phone`,不要传裸 int)。
|
||||
|
||||
- [ ] **Step 3.5: 修「事务已中止」类**
|
||||
|
||||
症状:某用例后续报 `current transaction is aborted, commands ignored until end of transaction block`,根因是前一句 SQL 出错后业务代码缺 `db.rollback()`/`db.commit()` 边界。补上正确的 commit/rollback。
|
||||
|
||||
- [ ] **Step 4: 修「测试依赖 SQLite 特性」类(仅此类可改测试)**
|
||||
|
||||
症状:测试断言依赖 SQLite 秒级时间精度或 FK 不强制(见 `test_invite.py:328`、`test_compare_harvest.py:153` 的注释)。PG 下时间精度更高/FK 更严——调整测试数据(如手动拉开时间间隔、用合法 FK)使断言在 PG 下成立,不改业务逻辑。
|
||||
|
||||
- [ ] **Step 5: 反复跑到全绿**
|
||||
|
||||
Run: `pytest -q`
|
||||
Expected: `N passed`(0 failed)。若仍有红,回到 Step 2-4 继续归因。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix(db): 测试套件切 PostgreSQL 后修复严格性暴露的用例"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 文档更新
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/database/postgres-migration.md`(§1 增「本地 Docker 一键起」小节)
|
||||
- Modify: `CLAUDE.md`(DB 段注明 dev/test = Docker PG)
|
||||
- Modify: `scripts/init_postgres.py`(顶部注释区分生产/本地)
|
||||
|
||||
- [ ] **Step 1: `postgres-migration.md` 在「## 1. 本地起 PG」开头插入推荐做法**
|
||||
|
||||
在 `## 1. 本地起 PG + 跑通空库(半天)` 标题下、`### 1.1 装 PG` 之前插入:
|
||||
|
||||
```markdown
|
||||
### 1.0 推荐:Docker 一键起(本地开发/测试)
|
||||
|
||||
本地开发不必手动装 PG。已提供 `docker-compose.yml` + `scripts/ensure_pg.py`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env # DATABASE_URL 默认已是 Docker PG 连接串
|
||||
./run.sh # 或 run.bat;会自动:探测 PG → 没起则启 Docker → 起 PG 容器 → 建库 → alembic → uvicorn
|
||||
pytest # conftest 自动引导同一容器的 shaguabijia_test 库
|
||||
```
|
||||
|
||||
容器:`postgres:16-alpine`(名 `shaguabijia-pg`,端口 5432,命名卷 `pgdata` 持久化),
|
||||
首启即建业务库 `shaguabijia` 与测试库 `shaguabijia_test`。下面 1.1-1.5 的手动装 PG 步骤仅在
|
||||
不用 Docker 时才需要;生产仍走 §4 的原生 PG。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `CLAUDE.md` DB 段补充**
|
||||
|
||||
找到 DB 相关行(`**Prod**: PostgreSQL — just change DATABASE_URL...`)所在段,在其上方加一行:
|
||||
|
||||
```markdown
|
||||
- **Dev/Test**: Docker PostgreSQL 16 — `run.sh`/`run.bat` 经 `scripts/ensure_pg.py` + `docker-compose.yml` 自动拉起;`.env.example` 默认即 PG 连接串;pytest 用同容器的 `shaguabijia_test` 库。**本地不再用 SQLite**。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `scripts/init_postgres.py` 顶部注释区分场景**
|
||||
|
||||
把模块 docstring 第一行下方(`新机器初始化用。前置:...` 那行)改为:
|
||||
|
||||
```python
|
||||
新机器初始化用(面向【生产原生 PG】:apt/systemd 装好的 PostgreSQL)。
|
||||
本地开发/测试请改用 docker-compose.yml + scripts/ensure_pg.py(run.sh/run.bat 自动拉起),不必跑本脚本。
|
||||
前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证无坏链接/格式**
|
||||
|
||||
Run: `git diff --stat`
|
||||
Expected: 三个文档文件有改动,无其他文件被误改。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/database/postgres-migration.md CLAUDE.md scripts/init_postgres.py
|
||||
git commit -m "docs(dev): 记录本地 Docker PostgreSQL 用法,区分生产原生 PG 路径"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完成标准(对齐 spec §8 验收)
|
||||
|
||||
- [ ] 全新机器(装了 Docker Desktop、`.env` 从 `.env.example` 复制)跑 `run.bat`/`run.sh` 全自动拉起 PG 并起服务,无手动装 PG。
|
||||
- [ ] `docker ps` 见 `shaguabijia-pg` healthy;`shaguabijia` 与 `shaguabijia_test` 两库都在。
|
||||
- [ ] PG 已在跑时再跑 `run`,ensure_pg 秒过短路。
|
||||
- [ ] `pytest -q` 全绿(连 `shaguabijia_test`)。
|
||||
- [ ] `.env` 改回 sqlite 时,`python -m scripts.ensure_pg` 硬失败并打印正确 PG 串。
|
||||
- [ ] 能在 `admin/repositories` 写一段 PG 专有聚合(如 `count(*) FILTER (WHERE ...)`),`run` 手动跑通且相应 pytest 通过。
|
||||
|
||||
## Self-Review 记录(计划作者已核)
|
||||
|
||||
- **Spec 覆盖:** spec §9 待实现清单 8 项 → Task 1(compose+initdb)、Task 2(ensure_pg)、Task 3(run 接线)、Task 4(.env.example)、Task 5(conftest)、Task 6(修红用例)、Task 7(文档);「session.py 无需改」在 Header 与 spec §4.7 说明;「data/ 忽略」在 Task 1 Step 5 验证。无遗漏。
|
||||
- **占位符:** 全部步骤含真实代码/命令/期望输出。Task 6 是发现驱动,已用「类别+具体转换配方+定位命令」代替不可预知的逐条 diff——非占位。
|
||||
- **类型/命名一致:** `ensure(database_url=None)` 签名在 Task 2 定义,Task 5 以 `ensure(_TEST_DB_URL)` 调用一致;库名/用户/密码/端口全程为 Header 固定值;compose 服务名 `postgres` 与 ensure_pg `COMPOSE_SERVICE` 一致;`shaguabijia_test` 在 initdb SQL、`_ensure_test_db()`、conftest 三处一致。
|
||||
@@ -1,281 +0,0 @@
|
||||
# 本地开发切 Docker PostgreSQL —— 设计文档
|
||||
|
||||
> 让本地开发与测试统一跑在 Docker 化的 PostgreSQL 上,彻底退掉 SQLite。
|
||||
> `run.bat` / `run.sh` 启动时自动检测本机 PG,没起就拉起 Docker → 起 PG 容器(镜像缺失先拉),
|
||||
> 目的是让开发/大模型能放心用 PG 专有的高效聚合函数,不再为兼容 SQLite 而退化成"取基础数据后内存聚合"。
|
||||
>
|
||||
> 状态:已定稿(待用户复核)。作者对话日期:2026-07-08。
|
||||
> 关联:[postgres-migration.md](../../database/postgres-migration.md)(切引擎完整步骤)、`scripts/init_postgres.py`(生产原生 PG 初始化)。
|
||||
> ⚠️ **2026-07-27 增补(见 §10)**:D4 已从「sqlite 硬失败」松为「显式 SQLite 逃生舱」。§1-9 描述的是初版「彻底退掉 SQLite」设计;凡涉及「无 Docker / DATABASE_URL 是 sqlite 时如何处理」,**以 §10 为准**(测试仍只跑 PG 不变)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 问题
|
||||
当前开发环境默认用 SQLite(`DATABASE_URL=sqlite:///./data/app.db`),生产用 PostgreSQL 16。两套引擎并存,导致写数据访问代码时(尤其 `app/admin/repositories/` 的报表聚合)为了"两边都能跑",放弃 PG 专有能力(窗口函数、`FILTER`、`JSONB` 操作符、`GROUPING SETS` 等),改成"先查基础数据、再在 Python 内存里聚合"——既慢又啰嗦。
|
||||
|
||||
### 目标
|
||||
本地开发与测试都跑在 PG 上,SQLite 退出本地开发闭环。之后写 PG 专有 SQL 时:
|
||||
- 开发运行时(`run.bat`/`run.sh`)直接连 PG,手动验证可行;
|
||||
- `pytest` 也连 PG,PG 专有 SQL 在被测代码路径里也安全,不会因 SQLite 而挂——**这是"双库兼容代码彻底消失"的必要条件**。
|
||||
|
||||
### 非目标(本期不做)
|
||||
- 不动**生产**部署(生产仍是原生 PG16 + systemd,无 Docker;`init_postgres.py` 保持不变)。
|
||||
- 不做数据搬迁(MVP 阶段无真实用户数据,详见迁移指南背景假设)。
|
||||
- 不接 CI(仓库当前无 `.github/workflows`;若将来加 CI,再单独让 CI 起 PG service)。
|
||||
- 不引入 Redis / testcontainers / 连接池中间件。
|
||||
|
||||
---
|
||||
|
||||
## 2. 决策记录(本次对话已拍板)
|
||||
|
||||
| # | 决策点 | 结论 | 理由 |
|
||||
|---|---|---|---|
|
||||
| D1 | PG 覆盖范围 | **dev 运行 + 测试都切 PG** | 只切运行时的话,被 SQLite 测试覆盖的代码路径(如 `test_cps_admin.py` 覆盖的 `admin/repositories/cps.py`)仍不能用 PG 专有 SQL,双库代码不会真正消失 |
|
||||
| D2 | 打包方式 | **方案 A:Compose + `scripts/ensure_pg.py`** | 唯一真正需要定制的部分(启动 Docker 守护进程、等 PG 就绪)集中到一个跨平台模块,`run.bat`/`run.sh`/`conftest.py` 共用;声明式的容器/卷/健康检查交给 Compose |
|
||||
| D3 | 宿主端口 | **5432**(与生产/文档一致) | 边界:若本机已有原生 PG 占 5432,`ensure_pg` 会探测到"PG 已在"直接复用它(可能连到不带业务库的实例)——见 §7 风险,文档提示 |
|
||||
| D4 | dev 下 `DATABASE_URL` 仍是 sqlite | **硬失败**(打印一行 fix 后非 0 退出) | 彻底断掉 SQLite 退路,符合"让大家都用 PG"的目标 |
|
||||
| D5 | dev 数据库密码 | 固定 `shaguabijia_dev_pw`,写进 compose + `.env.example` | 本地容器仅绑 `localhost`,非机密;保证 `.env.example` 复制即可用。生产密码另由 `init_postgres.py` 强随机生成,不复用 |
|
||||
| D6 | 改哪些启动脚本 | `run.bat` 与 `run.sh` **都改** | 仓库一贯保持两者同步 |
|
||||
| D7 | 镜像 | `postgres:16-alpine` | 对齐生产 PG16;alpine 体积小 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 现状(改动前)
|
||||
|
||||
- **配置**:`app/core/config.py` `DATABASE_URL` 默认 `sqlite:///./data/app.db`,pydantic-settings 从 `.env` 读(环境变量优先级高于 `.env` 文件)。
|
||||
- **引擎**:`app/db/session.py` 用 `_is_sqlite = DATABASE_URL.startswith("sqlite")` 分流——SQLite 加 `check_same_thread=False`、不建池;非 SQLite 加 `pool_size=10/max_overflow=20/pool_recycle=3600`。**已天然支持 PG,无需改。**
|
||||
- **迁移**:`alembic/env.py` 从 `settings.DATABASE_URL` 读连接串,`render_as_batch` 仅对 sqlite 开;PG 下自动关。**无 psycopg2 硬编码,切 PG 无需改。**
|
||||
- **驱动**:`pyproject.toml` 已装 `psycopg[binary]>=3.1`(psycopg3)。URL scheme 必须 `postgresql+psycopg://`(裸 `postgresql://` 会被 SQLAlchemy 路由到未安装的 psycopg2 → ModuleNotFoundError)。
|
||||
- **测试**:`tests/conftest.py` 在 import app 前把 `DATABASE_URL` 设成临时文件 SQLite;session 级 autouse fixture 做 `Base.metadata.create_all(engine)` / 结束 `drop_all`(schema 来自 model 而非 alembic,无逐用例 rollback,全会话共享一个库)。
|
||||
- **启动脚本**:`run.bat` / `run.sh` 均为:校验 `.env` 存在 → `mkdir data` → `alembic upgrade head` → uvicorn 监听 `0.0.0.0:8770`(`.sh` 带 `--reload --reload-dir app`)。
|
||||
- **现有 PG 资产**:`scripts/init_postgres.py`(交互式:建用户/建库/授权/写 .env/跑迁移,面向**已装好的原生 PG**)、`docs/database/postgres-migration.md`(切引擎完整步骤,含 §2 测试切 PG、§2.2 会暴露的真 bug 清单)。
|
||||
- **CI**:无(`.github/workflows` 不存在),故测试切 PG 无 CI 联动负担。
|
||||
|
||||
---
|
||||
|
||||
## 4. 方案详解
|
||||
|
||||
### 4.1 新增 `docker-compose.yml`(app-server 根目录)
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: shaguabijia-pg
|
||||
environment:
|
||||
POSTGRES_USER: shaguabijia_app
|
||||
POSTGRES_PASSWORD: shaguabijia_dev_pw
|
||||
POSTGRES_DB: shaguabijia
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shaguabijia_app -d shaguabijia"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
- `POSTGRES_USER` 设定后,该用户以超级用户身份创建并拥有 `POSTGRES_DB`,故能再建测试库。
|
||||
- 首启 initdb 脚本建测试库(见 4.2)。命名卷 `pgdata` 让数据跨重启留存。
|
||||
|
||||
### 4.2 新增 `docker/initdb/01-create-test-db.sql`
|
||||
```sql
|
||||
-- 仅在 pgdata 卷首次初始化时执行一次。以 shaguabijia_app(超级用户)连 shaguabijia 库运行。
|
||||
CREATE DATABASE shaguabijia_test OWNER shaguabijia_app;
|
||||
```
|
||||
|
||||
### 4.3 新增 `scripts/ensure_pg.py`(纯标准库 + docker CLI,跨平台)
|
||||
对外同时暴露**可导入函数** `ensure()`(供 `conftest.py` 直接调)和 **CLI 入口** `if __name__ == "__main__": sys.exit(0 if ensure() else 1)`(供 `run` 以 `python -m scripts.ensure_pg` 跑)。`ensure()` 从 `app.core.config.settings` 读 `DATABASE_URL`,解析 host/port,主流程:
|
||||
1. **sqlite 守卫**:若 `DATABASE_URL` 以 `sqlite` 开头 → 打印"dev 已切 PG,请把 .env 的 DATABASE_URL 改成 `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia`"→ 非 0 退出(D4)。
|
||||
2. **TCP 探测** `host:port`(stdlib `socket`,超时 1s)。通 → 打印"✅ PG 已就绪"直接返回(幂等:PG 已在跑时开销≈一次握手)。
|
||||
3. 不通 → `docker version` 探 CLI;缺失 → 中文报错"请先安装 Docker Desktop:https://www.docker.com/products/docker-desktop/" → 非 0 退出。
|
||||
4. `docker info` 探守护进程;不通 → 按平台启动:
|
||||
- Windows:`start "" "%ProgramFiles%\Docker\Docker\Docker Desktop.exe"`(找不到则报错让用户手动开)
|
||||
- macOS:`open -a Docker`
|
||||
- Linux:不自动 sudo,打印 `sudo systemctl start docker` 让用户执行后重试
|
||||
然后轮询 `docker info` 直到就绪或超时(默认 120s,每 3s 一次,打印进度)。
|
||||
5. `docker compose up -d`(Compose 在镜像缺失时**自动拉取**,首用拉 alpine ~90MB;有进度输出)。
|
||||
6. 轮询 healthcheck(`docker inspect` 的 health 状态)/ TCP 直到 PG 接受连接(默认 60s 超时)。
|
||||
7. **幂等确保测试库存在**(兼容"老 pgdata 卷没跑过 initdb"的情况):
|
||||
`docker compose exec -T postgres psql -U shaguabijia_app -tc "SELECT 1 FROM pg_database WHERE datname='shaguabijia_test'"`,不存在则 `CREATE DATABASE shaguabijia_test OWNER shaguabijia_app`。
|
||||
|
||||
失败即清晰中文报错 + 非 0 退出,**全程不回退 SQLite**。所有超时可用环境变量覆盖(如 `ENSURE_PG_DOCKER_TIMEOUT`)。
|
||||
|
||||
### 4.4 `run.bat` / `run.sh` 接线
|
||||
在 `alembic upgrade head` **之前**插一行调用,失败即退出:
|
||||
- `run.sh`:`"$PY" -m scripts.ensure_pg`(`set -e` 已在,失败自动退出)
|
||||
- `run.bat`:`call "%PY%" -m scripts.ensure_pg` + `if errorlevel 1 exit /b 1`
|
||||
|
||||
其余逻辑不动(`mkdir data` 保留给 media 等落盘目录)。
|
||||
|
||||
### 4.5 `.env.example` 默认切 PG
|
||||
把
|
||||
```ini
|
||||
DATABASE_URL=sqlite:///./data/app.db
|
||||
```
|
||||
改为
|
||||
```ini
|
||||
# 本地开发/测试统一用 Docker PG(run.bat/run.sh 会自动拉起容器;详见 docs/database/postgres-migration.md §本地 Docker 一键起)。
|
||||
# 生产用原生 PG,由 scripts/init_postgres.py 写入强随机密码的连接串。
|
||||
DATABASE_URL=postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia
|
||||
```
|
||||
|
||||
### 4.6 `tests/conftest.py` 切 PG
|
||||
调整顶部顺序(仍必须在 `import app.*` 之前完成 env 设定):
|
||||
1. 设 `os.environ["DATABASE_URL"] = "postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"`(测试库,永不碰 dev 业务库)。
|
||||
2. 调 `scripts.ensure_pg.ensure()`(保证容器在 + 测试库在;PG 已在时几乎零开销)。
|
||||
3. `import app...`。
|
||||
session 级 autouse fixture:改为 **`Base.metadata.drop_all(engine)` → `create_all(engine)`(开头先清干净,防持久卷里上一次跑残留的表/数据)→ yield → 结束 `drop_all`**;删掉临时 SQLite 文件相关代码。
|
||||
|
||||
> 预期:部分用例会因 PG 的严格性变红(SQLite 宽松、PG 严格),按迁移指南 §2.2 逐个修——常见为:字符串/整数隐式比较、`datetime.utcnow()` naive vs `TIMESTAMPTZ`、事务边界(`current transaction is aborted`)。这既是工作量也是本次改造的**直接收益**(暴露真 bug)。实现阶段需为"跑 pytest 并修红用例"单列步骤。
|
||||
|
||||
### 4.7 `app/db/session.py`
|
||||
**无需改动**——`_is_sqlite` 为假时自动走 PG 池化分支。
|
||||
|
||||
### 4.8 文档
|
||||
- `docs/database/postgres-migration.md` 增一节「本地 Docker 一键起 PG(推荐)」,指向 compose + `ensure_pg`,并说明它替代了 §1.1 的手动 brew/apt 装 PG。
|
||||
- `CLAUDE.md` 的 DB 段注明:dev/test = Docker PG(`run` 自动拉起);prod = 原生 PG(`init_postgres.py`)。
|
||||
- `scripts/init_postgres.py` 顶部注释补一句"本脚本面向生产原生 PG;本地开发用 docker-compose + scripts/ensure_pg"。
|
||||
|
||||
---
|
||||
|
||||
## 5. 连接参数汇总
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 镜像 | `postgres:16-alpine` |
|
||||
| 容器名 | `shaguabijia-pg` |
|
||||
| 宿主端口 | `5432` |
|
||||
| 超级/业务用户 | `shaguabijia_app` |
|
||||
| dev 密码 | `shaguabijia_dev_pw`(本地非机密) |
|
||||
| 业务库(dev 运行) | `shaguabijia` |
|
||||
| 测试库(pytest) | `shaguabijia_test` |
|
||||
| dev `DATABASE_URL` | `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia` |
|
||||
| test `DATABASE_URL` | `postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test` |
|
||||
| 数据持久化 | 命名卷 `pgdata` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 失败处理矩阵(无 SQLite 兜底)
|
||||
|
||||
| 情形 | ensure_pg 行为 |
|
||||
|---|---|
|
||||
| `DATABASE_URL` 是 sqlite | 打印应改成的 PG 串 → 非 0 退出 |
|
||||
| PG 已在跑(TCP 通) | 打印"已就绪" → 返回 0(跳过 docker) |
|
||||
| 无 docker CLI | 提示装 Docker Desktop + 官网链接 → 非 0 退出 |
|
||||
| docker 守护进程未起 | 尝试按平台启动 Docker Desktop,轮询到就绪;超时则报错 → 非 0 退出 |
|
||||
| 镜像缺失 | `docker compose up -d` 自动拉取(不额外处理) |
|
||||
| 容器起了但 PG 未 ready | 轮询 healthcheck 到超时;超时报错 → 非 0 退出 |
|
||||
| 老 pgdata 卷缺测试库 | 幂等 `CREATE DATABASE shaguabijia_test` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与边界
|
||||
|
||||
- **端口占用(原生 PG 撞 5432)**:D3 选了 5432。若开发机已有原生 PG 监听 5432,step 2 的 TCP 探测会判"PG 已在"并复用它——但那个实例可能没有 `shaguabijia`/`shaguabijia_test` 库或用户,后续 `alembic upgrade head` / 测试会报连不上库或认证失败。**缓解**:文档提示"本机别再单独跑原生 PG";报错信息里提示检查是不是撞了原生 PG。
|
||||
- **首次启动慢**:首用需 Docker Desktop 冷启(~30–60s)+ 拉镜像(~数十秒~数分钟,视网络)。`ensure_pg` 全程打印进度,超时可配。
|
||||
- **`DATABASE_URL` 环境变量优先级**:pydantic-settings 里 shell 环境变量优先于 `.env`。若开发者 shell 残留旧的 `DATABASE_URL`(如指向 sqlite),会盖过 `.env`。sqlite 守卫(D4)能挡住 sqlite 残留;但若残留的是另一个 PG 串,则以它为准——文档提示。
|
||||
- **持久卷脏状态**:测试用 drop_all→create_all 开头清库,避免上次崩溃残留污染;dev 业务库随卷留存(符合预期)。
|
||||
- **Docker 未安装/公司网络拉镜像受限**:硬失败并给出明确指引;不提供 SQLite 退路是刻意选择(D4/目标)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
1. 全新机器(装了 Docker Desktop、`.env` 从 `.env.example` 复制)执行 `run.bat`(或 `run.sh`):自动拉起 Docker→起 PG 容器→建库→`alembic upgrade head`→uvicorn 起在 8770,无手动装 PG 步骤。
|
||||
2. `docker ps` 见 `shaguabijia-pg` 健康;`psql`/客户端能连 `shaguabijia` 与 `shaguabijia_test` 两个库。
|
||||
3. PG 已在跑时再次 `run`,`ensure_pg` 秒过(不重复拉容器)。
|
||||
4. `pytest` 连 `shaguabijia_test` 跑;红用例全部修绿(PG 严格性暴露的问题)。
|
||||
5. `.env` 的 `DATABASE_URL` 改回 sqlite 时,`run`/`pytest` 硬失败并打印正确的 PG 串。
|
||||
6. 能在 `admin/repositories/` 里写一段 PG 专有聚合 SQL(如带 `FILTER (WHERE ...)` 的聚合),`run` 下手动跑通、相应 pytest 也通过——即"双库兼容负担消失"的实证。
|
||||
|
||||
---
|
||||
|
||||
## 9. 待实现清单(供 writing-plans 拆解)
|
||||
|
||||
- [ ] 新增 `docker-compose.yml`
|
||||
- [ ] 新增 `docker/initdb/01-create-test-db.sql`
|
||||
- [ ] 新增 `scripts/ensure_pg.py`(TCP 探测 / 启 Docker Desktop 轮询 / compose up / 等 healthy / 幂等建测试库 / sqlite 守卫)
|
||||
- [ ] `run.sh`、`run.bat` 接入 `ensure_pg`
|
||||
- [ ] `.env.example` 的 `DATABASE_URL` 切 PG
|
||||
- [ ] `tests/conftest.py` 切 `shaguabijia_test` + 调 `ensure_pg` + fixture 改 drop/create
|
||||
- [ ] 跑 `pytest`,按迁移指南 §2.2 修红用例
|
||||
- [ ] 文档:`postgres-migration.md` 增「本地 Docker 一键起」节;`CLAUDE.md` DB 段;`init_postgres.py` 注释
|
||||
- [ ] `.gitignore` 确认 `data/` 已忽略(compose 用命名卷,不落项目目录,无需额外忽略)
|
||||
|
||||
---
|
||||
|
||||
## 10. 增补(2026-07-27):D4 反转 —— 显式 SQLite 逃生舱
|
||||
|
||||
> 背景:§2 的 D4 定为「dev 下 `DATABASE_URL` 仍是 sqlite → 硬失败」,目的是彻底断掉 SQLite 退路。实践中这对「本机装不了 Docker」的开发者过于刚性——直接被卡死、连跑都跑不起来。本次(2026-07-27 对话)把 D4 从「硬失败」松成「**显式逃生舱**」:工具**从不替你静默切库**,但会在没 Docker 时告诉你怎么手动降级,且降级时每次启动都醒目告警。
|
||||
|
||||
### 10.1 决策更新
|
||||
|
||||
| # | 原决策 | 新决策 | 理由 |
|
||||
|---|---|---|---|
|
||||
| D4′ | dev sqlite URL → 硬失败退出 | **放行 + 每次打印醒目降级横幅**(仍非静默) | 已手动改 `.env`=sqlite = 开发者的显式选择,尊重它;但吼一嗓子防止忘了自己在降级、把 PG 专有 SQL 提交上去 |
|
||||
| D8(新) | (无) | 无 docker CLI 时,报错里**追加逃生舱指路**(改 `.env`=sqlite),但仍非 0 退出 | 「显式」的关键:工具不替你切库,只指路;开发者改完 `.env` 再跑一次才真正降级 |
|
||||
|
||||
**未变**:D1(测试仍只跑 PG)、D2-D3、D5-D7 全部保留。逃生舱**只作用于 `run.sh`/`run.bat` 运行时**;`pytest` 仍写死连 PG 测试库(`conftest.py` 传 PG URL,sqlite 分支根本不触发),没 Docker 就 `raise`、跑不了完整套件——这正是 D1「测试上 PG 才能暴露真 bug」的初衷,刻意不给逃生舱。
|
||||
|
||||
### 10.2 代码改动(仅 `scripts/ensure_pg.py` 的 `ensure()`)
|
||||
|
||||
1. **sqlite 分支**(原 `return False`)→ 打印多行降级横幅后 `return True`。横幅点明:PG 专有 SQL/严格类型在此模式**不被验证**、提交前须在有 Docker 的机器上用 PG 复跑、装好 Docker 后把 `DATABASE_URL` 改回 PG 串。
|
||||
2. **无 docker CLI 分支**(原仅提示装 Docker + `return False`)→ 追加一句「装不了 Docker?把 `.env` 的 `DATABASE_URL` 改成 `sqlite:///./data/app.db` 可降级运行」;**仍 `return False`**(run 脚本照常退出,开发者需显式改 .env 再跑)。
|
||||
3. **常量**:新增 `SQLITE_URL = "sqlite:///./data/app.db"`(逃生舱指路用);`SQLITE_FIX_HINT` 重命名 `PG_URL`(降级横幅"改回 PG"引用)。
|
||||
4. 更新模块 docstring 中「全程无 SQLite 兜底」一句,改述为「无 Docker/sqlite URL 时【显式】降级 SQLite(带醒目告警),测试侧不降级」。
|
||||
|
||||
**其余全不动**:`run.sh`/`run.bat`(sqlite 下 `ensure` 返 True → 照常 `alembic upgrade head` + uvicorn)、`docker-compose.yml`、`app/db/session.py`(SQLite 引擎分支本就保留为 fallback)、`tests/conftest.py`、`.env.example`(默认仍 PG)。
|
||||
|
||||
### 10.3 改完后行为矩阵(覆盖用户列的 5 场景)
|
||||
|
||||
| 场景 | `DATABASE_URL` | ensure_pg 行为 |
|
||||
|---|---|---|
|
||||
| ① 无 Docker | PG(默认) | 报错 + 指逃生舱 → 退出;开发者改 `.env`=sqlite → 再跑 → **放行 + 降级横幅**,alembic/uvicorn 跑 SQLite |
|
||||
| ② 有 Docker 未启动 | PG | 启 Docker Desktop → `compose up` → 等 ready → 建测试库(**不变**) |
|
||||
| ③ 有 Docker 已启动 | PG | `compose up` → 等 ready(**不变**) |
|
||||
| ④ PG 已在跑 | PG | TCP 通 → 秒过跳过 Docker(**不变**) |
|
||||
| ⑤ PG 起来后 | 任意 | run 脚本 `alembic upgrade head`(**不变**;SQLite 走 `render_as_batch`) |
|
||||
|
||||
### 10.4 风险
|
||||
|
||||
- **降级被忽视**:横幅仅在 `run` 启动时打印一次;若开发者用 IDE 直接起 uvicorn(绕过 run 脚本)则看不到。缓解:横幅足够醒目 + 文档强调;**不**引入 app 启动期重复告警(YAGNI)。
|
||||
- **测试无 Docker 跑不了**:刻意保留(D1)。文档提示无 Docker 者:要么装 Docker 跑全量测试,要么只在 CI/有 Docker 的机器上验证 PG 相关改动。
|
||||
|
||||
### 10.5 Redis 前瞻(不在本次)
|
||||
|
||||
§2 未涉及 Redis。②③ 场景未来若加 Redis 实例:在 `docker-compose.yml` 增 `redis` 服务即可,`docker compose up -d` 天然带起;仅当启动期有组件依赖 Redis 才需给 `ensure_pg` 加 redis readiness 探测。本次不做,方案对它友好。
|
||||
|
||||
### 10.6 附带修复:`_docker_cli_ok` 守护进程误判(2026-07-27)
|
||||
|
||||
诊断「装了 Docker Desktop 却报未检测到 docker」时发现的真 bug:`_docker_cli_ok()` 原用 `docker version`
|
||||
判断 CLI 是否存在,但该命令**要连 daemon**,守护进程没起时退非零 → 把「Docker 装了但没启动」
|
||||
误判成「没装 CLI」,`ensure()` 直接打印"请安装 Docker Desktop"并 `return False`,**绕过了专为需求②
|
||||
写的 `_start_docker_daemon()` 自动拉起逻辑**——需求②(有 Docker 未启动 → 自动启动)因此从未真正生效。
|
||||
修复:改用 `docker --version`(纯客户端、不连 daemon、退 0)。`_docker_daemon_ok()` 仍用 `docker info`
|
||||
(正确,该检查本就依赖 daemon)。实测机器:Docker Desktop 20.10.12 已装但引擎未起,修复前 `_docker_cli_ok()`
|
||||
误报 False,修复后 True。
|
||||
|
||||
### 10.7 附带修复:固定 compose 项目名 + 清理残留同名容器(2026-07-27)
|
||||
|
||||
诊断「`docker compose up` 报 `container name "/shaguabijia-pg" already in use`」时发现的又一 bug:compose
|
||||
项目名默认取运行目录 basename,在不同目录/worktree(如 `local-dev-postgres-docker` vs `shaguabijia-app-server`)
|
||||
之间切换会各自成一个项目;而 `docker-compose.yml` 写死了 `container_name: shaguabijia-pg`(全局唯一名),
|
||||
于是新项目 `up` 时要创建同名容器 → 撞上旧项目留下的那个 → 冲突。副作用:`pgdata` 卷也按项目名分裂
|
||||
成 `local-dev-postgres-docker_pgdata` / `shaguabijia-app-server_pgdata`,数据被切成两半。
|
||||
|
||||
修复(均在 `scripts/ensure_pg.py`,`docker-compose.yml` 不动、容器名仍是 `shaguabijia-pg`):
|
||||
1. 模块级 `os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia")` —— 钉死项目名,无论从哪个
|
||||
目录/worktree 跑都是同一个项目、同一个卷 `shaguabijia_pgdata`,所有 `docker compose up/exec` 一致。
|
||||
2. `_compose_up()` 前置 `_remove_stale_container()`:若存在「同名但不属于本项目」的残留容器,先 `docker rm -f`
|
||||
再 up(靠 `docker ps --filter name/label` 判归属;数据在命名卷里,删容器不丢)。旧目录/worktree 留下的
|
||||
残留容器就此自动清掉,不需手动干预。
|
||||
|
||||
影响:本次修复后首跑,旧的 `shaguabijia-pg`(属项目 `local-dev-postgres-docker`)会被自动删除、在项目
|
||||
`shaguabijia` 下重建,挂载全新的 `shaguabijia_pgdata`(空库,`alembic upgrade head` 重建表)。旧数据仍留在
|
||||
`local-dev-postgres-docker_pgdata` 卷里(未删,可恢复);确认不需要后可 `docker volume rm` 清理两个旧卷。
|
||||
@@ -30,14 +30,7 @@ if not exist .env (
|
||||
|
||||
if not exist data mkdir data
|
||||
|
||||
REM Ensure local Docker PostgreSQL is up (auto-starts Docker + PG container if needed)
|
||||
call "%PY%" -m scripts.ensure_pg
|
||||
if errorlevel 1 (
|
||||
echo [X] ensure_pg failed ^(PostgreSQL not ready^)
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Build/upgrade schema (idempotent; no-op if already at head)
|
||||
REM Build/upgrade SQLite schema (idempotent; no-op if already at head)
|
||||
call "%PY%" -m alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo [X] alembic upgrade head failed
|
||||
|
||||
@@ -18,8 +18,7 @@ if [ ! -f .env ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p data # 运行期落盘目录(媒体上传等)
|
||||
"$PY" -m scripts.ensure_pg # 确保本地 Docker PostgreSQL 就绪(没起会自动拉起;失败即退出)
|
||||
mkdir -p data # sqlite 文件所在目录
|
||||
"$PY" -m alembic upgrade head # 确保表已建(幂等,已是最新则 no-op)
|
||||
|
||||
# --reload 只盯源码目录 app/:别去监视 logs/(日志写入触发"检测→再写日志"回环)和
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
@echo off
|
||||
REM Admin backend startup (Windows) - the :8771 peer of run.bat.
|
||||
REM
|
||||
REM Usage:
|
||||
REM cd shaguabijia-app-server
|
||||
REM run8771.bat
|
||||
REM
|
||||
REM Runs the ADMIN FastAPI app (app.admin.main:admin_app) on 127.0.0.1:8771 —
|
||||
REM a SEPARATE process from run.bat (which runs app.main:app on 8770). The admin
|
||||
REM web frontend (Next.js :3001) points at http://localhost:8771. Auto-reload on
|
||||
REM code change.
|
||||
REM
|
||||
REM Prerequisite (first time):
|
||||
REM conda activate pricebot ^&^& pip install -e .
|
||||
REM copy .env.example .env ^&^& fill JWT_SECRET_KEY
|
||||
REM
|
||||
REM Tip: shaguabijia-admin-web\start.bat starts user-api(8770) + admin-api(8771)
|
||||
REM + frontend(3001) in one go, if you prefer a single command.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Prefer the project virtualenv (.venv) so we never inherit a wrong
|
||||
REM global/conda interpreter. FastAPI<0.115 on Pydantic 2.12 crashes at import
|
||||
REM with "'FieldInfo' object has no attribute 'in_'". Falls back to PATH python.
|
||||
set "PY=python"
|
||||
if exist "%~dp0.venv\Scripts\python.exe" set "PY=%~dp0.venv\Scripts\python.exe"
|
||||
|
||||
if not exist .env (
|
||||
echo [X] Missing .env. Run: copy .env.example .env and fill JWT_SECRET_KEY ^(plus MT_CPS_* if you test Meituan^)
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not exist data mkdir data
|
||||
|
||||
REM Ensure local Docker PostgreSQL is up (auto-starts Docker + PG container if needed)
|
||||
call "%PY%" -m scripts.ensure_pg
|
||||
if errorlevel 1 (
|
||||
echo [X] ensure_pg failed ^(PostgreSQL not ready^)
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Build/upgrade schema (idempotent; no-op if already at head)
|
||||
call "%PY%" -m alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo [X] alembic upgrade head failed
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
REM Long-running foreground process. Ctrl+C to stop.
|
||||
"%PY%" -m uvicorn app.admin.main:admin_app --host 127.0.0.1 --port 8771 --reload
|
||||
@@ -1,317 +0,0 @@
|
||||
"""确保本地 PostgreSQL 就绪(开发/测试统一用 Docker PG)。
|
||||
|
||||
被三处复用:
|
||||
- run.sh / run.bat:`python -m scripts.ensure_pg`(CLI,失败退非 0)
|
||||
- tests/conftest.py:`from scripts.ensure_pg import ensure; ensure(test_url)`
|
||||
|
||||
流程:读 DATABASE_URL → TCP 探测 → 没起就(必要时启 Docker Desktop)→
|
||||
`docker compose up -d` → 等 PG ready → 幂等确保测试库存在。
|
||||
|
||||
运行时(run.sh/run.bat)支持【显式】SQLite 逃生舱:DATABASE_URL 设为 sqlite → 放行并打印
|
||||
醒目降级横幅(绝不静默替你切库);无 docker CLI 时报错里也指路该逃生舱。测试侧
|
||||
(conftest 传 PG URL)不降级——sqlite 分支不触发,没 PG 直接 raise。详见设计文档 §10。
|
||||
|
||||
生产用原生 PG(scripts/init_postgres.py),不走本模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# 日志里可能含 emoji(如 ✅);Windows GBK 控制台(cmd.exe)无法编码会抛 UnicodeEncodeError → 脚本崩、
|
||||
# run.bat 误判 ensure_pg 失败。用 backslashreplace 保底:中文仍正常,仅不可编码字符被转义,不崩。
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(errors="backslashreplace")
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
|
||||
APP_DB = "shaguabijia"
|
||||
TEST_DB = "shaguabijia_test"
|
||||
DB_USER = "shaguabijia_app"
|
||||
COMPOSE_SERVICE = "postgres"
|
||||
CONTAINER_NAME = "shaguabijia-pg" # 必须与 docker-compose.yml 的 container_name 一致
|
||||
|
||||
# 钉死 compose 项目名:否则它默认取运行目录 basename,在不同目录/worktree 之间切会各自
|
||||
# 成一个项目 → 同一个固定 container_name 撞名报错、pgdata 卷还会按项目名分裂成多份。
|
||||
# 钉成 app 名后,无论从哪个目录/worktree 跑都是同一个项目、同一个卷。setdefault:尊重外部覆盖。
|
||||
os.environ.setdefault("COMPOSE_PROJECT_NAME", "shaguabijia")
|
||||
|
||||
DOCKER_START_TIMEOUT = int(os.environ.get("ENSURE_PG_DOCKER_TIMEOUT", "120"))
|
||||
PG_READY_TIMEOUT = int(os.environ.get("ENSURE_PG_READY_TIMEOUT", "60"))
|
||||
# 单条 docker 探测/exec 命令的超时:防 Docker 守护进程半死(尤其 Windows 冷启)时
|
||||
# docker info / exec 无限挂起、绕过上面的总超时。
|
||||
DOCKER_CMD_TIMEOUT = int(os.environ.get("ENSURE_PG_CMD_TIMEOUT", "15"))
|
||||
POLL_INTERVAL = 3.0
|
||||
|
||||
PG_URL = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia"
|
||||
)
|
||||
SQLITE_URL = "sqlite:///./data/app.db" # 无 Docker 时的显式降级逃生舱(仅 run 运行时)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[ensure_pg] {msg}", flush=True)
|
||||
|
||||
|
||||
def _is_sqlite(url: str) -> bool:
|
||||
return url.strip().lower().startswith("sqlite")
|
||||
|
||||
|
||||
def _parse_host_port(url: str) -> tuple[str, int]:
|
||||
"""从 SQLAlchemy URL 取 host/port,缺省 localhost:5432。"""
|
||||
parts = urlsplit(url)
|
||||
return (parts.hostname or "localhost"), (parts.port or 5432)
|
||||
|
||||
|
||||
def _port_open(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _docker_desktop_cmd(platform: str, program_files: str) -> list[str] | None:
|
||||
"""按平台给出启动 Docker Desktop 的命令;Linux 返回 None(daemon 需 sudo,让用户手动)。"""
|
||||
if platform.startswith("win"):
|
||||
return [str(Path(program_files) / "Docker" / "Docker" / "Docker Desktop.exe")]
|
||||
if platform == "darwin":
|
||||
return ["open", "-a", "Docker"]
|
||||
return None
|
||||
|
||||
|
||||
def _docker_ok(subcmd: str) -> bool:
|
||||
"""`docker --version`(CLI 在不在,纯客户端)/`docker info`(daemon 起没起)成功与否。"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", subcmd],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def _docker_cli_ok() -> bool:
|
||||
# 必须用 `docker --version`(纯客户端,不连 daemon)而非 `docker version`
|
||||
# (后者要连 daemon,守护进程没起时退非零)——否则「装了 Docker 但没启动」
|
||||
# 会被误判成「没装 CLI」,直接绕过下面 _start_docker_daemon() 的自动拉起(需求②)。
|
||||
return _docker_ok("--version")
|
||||
|
||||
|
||||
def _docker_daemon_ok() -> bool:
|
||||
return _docker_ok("info")
|
||||
|
||||
|
||||
def _start_docker_daemon() -> bool:
|
||||
"""守护进程没起时按平台拉起,轮询到就绪。返回是否成功。"""
|
||||
if _docker_daemon_ok():
|
||||
return True
|
||||
cmd = _docker_desktop_cmd(
|
||||
sys.platform, os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
)
|
||||
if cmd is None:
|
||||
_log("Docker 守护进程未运行。Linux 请手动:sudo systemctl start docker,然后重试。")
|
||||
return False
|
||||
if sys.platform.startswith("win") and not Path(cmd[0]).exists():
|
||||
_log(f"找不到 Docker Desktop:{cmd[0]}。请手动启动 Docker Desktop 后重试。")
|
||||
return False
|
||||
_log("启动 Docker Desktop(首次冷启可能 30-60s)…")
|
||||
try:
|
||||
subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError as e:
|
||||
_log(f"启动 Docker Desktop 失败:{e}")
|
||||
return False
|
||||
deadline = time.monotonic() + DOCKER_START_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _docker_daemon_ok():
|
||||
_log("Docker 守护进程已就绪。")
|
||||
return True
|
||||
_log("等待 Docker 守护进程…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 Docker 守护进程超时({DOCKER_START_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _ps_names(*filters: str) -> str:
|
||||
"""docker ps -a 按 filter 查容器名(每行一个);失败返回空串。"""
|
||||
args = ["docker", "ps", "-a", "--format", "{{.Names}}"]
|
||||
for f in filters:
|
||||
args += ["--filter", f]
|
||||
try:
|
||||
r = subprocess.run(
|
||||
args, cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def _remove_stale_container() -> None:
|
||||
"""删掉「同名但不属于本 compose 项目」的残留容器(旧目录/worktree 建的)。
|
||||
|
||||
固定的 container_name 是全局唯一名:若旧项目留下一个同名容器,`docker compose up`
|
||||
会因撞名报 "container name already in use" 而失败。这里在 up 之前主动清掉它。
|
||||
数据在命名卷(<project>_pgdata)里,删容器不删卷、不丢数据。
|
||||
"""
|
||||
project = os.environ.get("COMPOSE_PROJECT_NAME", "")
|
||||
name_filter = f"name=^{CONTAINER_NAME}$"
|
||||
if CONTAINER_NAME not in _ps_names(name_filter).split():
|
||||
return # 没有同名容器
|
||||
ours = _ps_names(name_filter, f"label=com.docker.compose.project={project}")
|
||||
if CONTAINER_NAME in ours.split():
|
||||
return # 就是本项目的容器,compose 会自己 start/复用,别删
|
||||
_log(f"发现残留同名容器 {CONTAINER_NAME}(非本项目 '{project}'),删除以避免撞名"
|
||||
f"(数据在卷里,不丢)…")
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", CONTAINER_NAME], cwd=ROOT,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
_log(f"⚠️ 删除残留容器失败,可手动: docker rm -f {CONTAINER_NAME}")
|
||||
|
||||
|
||||
def _compose_up() -> bool:
|
||||
_remove_stale_container()
|
||||
_log("docker compose up -d(镜像缺失会自动拉取,首用约几十秒)…")
|
||||
try:
|
||||
subprocess.run(["docker", "compose", "up", "-d"], cwd=ROOT, check=True)
|
||||
return True
|
||||
except (OSError, subprocess.CalledProcessError) as e:
|
||||
_log(f"docker compose up 失败:{e}")
|
||||
return False
|
||||
|
||||
|
||||
def _pg_isready() -> bool:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"pg_isready", "-U", DB_USER, "-d", APP_DB],
|
||||
cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return r.returncode == 0
|
||||
|
||||
|
||||
def _wait_pg_ready(host: str, port: int) -> bool:
|
||||
deadline = time.monotonic() + PG_READY_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if _port_open(host, port) and _pg_isready():
|
||||
_log("PostgreSQL 已就绪。")
|
||||
return True
|
||||
_log("等待 PostgreSQL 就绪…")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
_log(f"等待 PostgreSQL 就绪超时({PG_READY_TIMEOUT}s)。")
|
||||
return False
|
||||
|
||||
|
||||
def _test_db_exists() -> bool:
|
||||
"""测试库是否已存在(连业务库 shaguabijia 查 pg_database)。"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-tAc",
|
||||
f"SELECT 1 FROM pg_database WHERE datname='{TEST_DB}'"],
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return r.returncode == 0 and r.stdout.strip() == "1"
|
||||
|
||||
|
||||
def ensure_test_db() -> bool:
|
||||
"""幂等建测试库(兼容老 pgdata 卷首启没跑 initdb 的情况)。返回测试库是否就绪。
|
||||
|
||||
公开给 conftest 单独调用:ensure() 在「端口已通」时会短路返回、不建测试库,
|
||||
所以测试侧需在 ensure() 之后再显式补一刀(best-effort)。
|
||||
"""
|
||||
if _test_db_exists():
|
||||
return True
|
||||
_log(f"建测试库 {TEST_DB}…")
|
||||
try:
|
||||
create = subprocess.run(
|
||||
["docker", "compose", "exec", "-T", COMPOSE_SERVICE,
|
||||
"psql", "-U", DB_USER, "-d", APP_DB, "-c",
|
||||
f"CREATE DATABASE {TEST_DB} OWNER {DB_USER}"],
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=DOCKER_CMD_TIMEOUT,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
_log(f"⚠️ 建测试库 {TEST_DB} 失败:{e}")
|
||||
return False
|
||||
# returncode==0=建成功;非 0 但库已存在=与并发创建者竞争失败(42P04),仍算就绪
|
||||
if create.returncode == 0 or _test_db_exists():
|
||||
return True
|
||||
_log(f"⚠️ 建测试库 {TEST_DB} 失败:{(create.stderr or '').strip()}")
|
||||
return False
|
||||
|
||||
|
||||
def _warn_sqlite_degraded() -> None:
|
||||
"""DATABASE_URL 是 SQLite 时打印醒目降级横幅(显式逃生舱,非静默切库)。"""
|
||||
for line in (
|
||||
"⚠️ ================= 降级模式(SQLite) =================",
|
||||
"⚠️ DATABASE_URL 是 SQLite,不是 PostgreSQL。",
|
||||
"⚠️ PG 专有 SQL(窗口函数/FILTER/JSONB)与严格类型在此模式【不被验证】。",
|
||||
"⚠️ 提交前请在装了 Docker 的机器上用 PG 复跑;装好后把 DATABASE_URL 改回:",
|
||||
f"⚠️ {PG_URL}",
|
||||
"⚠️ ===================================================",
|
||||
):
|
||||
_log(line)
|
||||
|
||||
|
||||
def ensure(database_url: str | None = None) -> bool:
|
||||
"""确保 PG 就绪,返回 True/False。database_url 缺省从 settings 读(尊重 .env)。
|
||||
|
||||
运行时若 DATABASE_URL 是 SQLite → 打印降级横幅并返回 True(显式逃生舱);
|
||||
conftest 传的是 PG URL,故测试侧永不走此分支。
|
||||
"""
|
||||
if database_url is None:
|
||||
from app.core.config import settings # 延迟导入,避免过早固化 settings
|
||||
|
||||
database_url = settings.DATABASE_URL
|
||||
|
||||
if _is_sqlite(database_url):
|
||||
_warn_sqlite_degraded()
|
||||
return True
|
||||
|
||||
host, port = _parse_host_port(database_url)
|
||||
|
||||
if _port_open(host, port):
|
||||
_log(f"✅ PostgreSQL 已在 {host}:{port} 运行,跳过 Docker。")
|
||||
return True
|
||||
|
||||
_log(f"{host}:{port} 无 PostgreSQL,准备用 Docker 拉起…")
|
||||
|
||||
if not _docker_cli_ok():
|
||||
_log("未检测到 docker 命令。请先安装 Docker Desktop:")
|
||||
_log(" https://www.docker.com/products/docker-desktop/")
|
||||
_log(f"装不了 Docker?把 .env 的 DATABASE_URL 改成 {SQLITE_URL} 可降级用 SQLite 跑")
|
||||
_log(" (PG 专有 SQL/严格性不被验证,仅救急);改完重跑 run.sh/run.bat。")
|
||||
return False
|
||||
if not _start_docker_daemon():
|
||||
return False
|
||||
if not _compose_up():
|
||||
return False
|
||||
if not _wait_pg_ready(host, port):
|
||||
return False
|
||||
if not ensure_test_db():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if ensure() else 1)
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Bootstrap PostgreSQL: 建用户 + 建库 + 授权 + 写 .env + 跑迁移。
|
||||
|
||||
新机器初始化用(面向【生产原生 PG】:apt/systemd 装好的 PostgreSQL)。
|
||||
本地开发/测试请改用 docker-compose.yml + scripts/ensure_pg.py(run.sh/run.bat 自动拉起),不必跑本脚本。
|
||||
前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
新机器初始化用。前置:已装 PostgreSQL 16 + 知道 postgres 超级用户密码。
|
||||
|
||||
用法:
|
||||
python scripts/init_postgres.py
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""收益明细「金币记录」文案验证脚手架(2026-07 文案改版验收用)。
|
||||
|
||||
问题:客户端按 bizType 显示固定文案(路线B,强制覆盖后端 remark),但账号若没有对应
|
||||
bizType 的流水,收益明细页就空着,无从验证。本脚本往指定测试用户塞每种 bizType 各一条
|
||||
流水,让你在手机收益明细页一屏核对全部新文案;验收完 --clean 一键删除,不污染数据。
|
||||
|
||||
⚠️ 仅 dev 库用(APP_ENV=dev 时才允许 --seed/--clean)。所有测试流水 ref_id 前缀 TESTDOC,
|
||||
按前缀精确清理,不会误删真实流水。
|
||||
|
||||
用法(pricebot env 直调,见项目 CLAUDE.md):
|
||||
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --show
|
||||
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --seed
|
||||
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --clean
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.rewards import CN_TZ
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount, CoinTransaction
|
||||
|
||||
TEST_PHONE = "11111111111"
|
||||
REF_PREFIX = "TESTDOC" # 所有本脚本造的流水都带这个 ref_id 前缀,便于精确清理
|
||||
|
||||
# 每条 = (bizType, 故意写错/留空的 remark, 金币数)。
|
||||
# remark 故意填「错的」→ 若客户端仍显示新文案 = 证明路线B强制覆盖生效(无视后端 remark)。
|
||||
# task_ 一条留空 remark → 走客户端兜底映射。顺序即手机上从新到旧的展示顺序(后塞的在最上)。
|
||||
CASES: list[tuple[str, str, int]] = [
|
||||
("signin", "每日签到 第99天(旧文案,应被覆盖)", 220),
|
||||
("signin_boost", "签到膨胀 第99天", 3000),
|
||||
("reward_video", "看视频奖励金币(旧文案,应被覆盖)", 200),
|
||||
("feed_ad_reward_comparison", "", 50), # 后端新拆:比价场景 → 比价奖励
|
||||
("feed_ad_reward_coupon", "", 50), # 后端新拆:领券场景 → 领券奖励
|
||||
("feed_ad_reward", "信息流广告奖励(welfare/旧数据兜底)", 50),
|
||||
("price_report_reward", "上报更低价审核通过(旧文案,应被覆盖)", 1000),
|
||||
("feedback_reward", "意见反馈被采纳(旧文案,应被覆盖)", 10000),
|
||||
("task_enable_notification", "", 750), # remark 留空 → 客户端「打开消息提醒奖励」
|
||||
# 下两条现实中不会进金币记录(invite 发现金进邀请钱包 / compare_milestone 后端死代码不发钱),
|
||||
# 仅用于验证「杀掉好友比价奖励 + 兜底改任务奖励」:两条都应显示「任务奖励」(remark 被强制无视)。
|
||||
("invite", "好友比价奖励(旧文案,应被杀→任务奖励)", 200),
|
||||
("compare_milestone", "", 120),
|
||||
]
|
||||
|
||||
|
||||
def _client_coin_title(biz_type: str, remark: str | None) -> str:
|
||||
"""复刻 CoinHistoryViewModel.coinTitle 的最新逻辑(路线B),用于 --show 预览。
|
||||
|
||||
⚠️ 必须与客户端保持一致;客户端改了这里也要同步,否则预览会骗人。
|
||||
"""
|
||||
fixed = {
|
||||
"exchange_out": "金币兑换现金",
|
||||
"signin": "每日签到奖励",
|
||||
"signin_boost": "签到膨胀奖励",
|
||||
"reward_video": "看视频赚金币",
|
||||
"ad_reward": "看视频赚金币",
|
||||
"feed_ad_reward_comparison": "比价奖励",
|
||||
"feed_ad_reward_coupon": "领券奖励",
|
||||
"feed_ad_reward": "信息流广告奖励",
|
||||
"price_report_reward": "爆料奖励",
|
||||
"feedback_reward": "反馈奖励",
|
||||
"invite": "任务奖励", # 杀掉"好友比价奖励",归兜底
|
||||
}
|
||||
if biz_type in fixed:
|
||||
return fixed[biz_type]
|
||||
if remark:
|
||||
return remark
|
||||
if biz_type == "task_enable_notification":
|
||||
return "打开消息提醒奖励"
|
||||
return "任务奖励"
|
||||
|
||||
|
||||
def _get_user(db) -> User:
|
||||
u = db.query(User).filter(User.phone == TEST_PHONE).first()
|
||||
if not u:
|
||||
print(f"✗ 库里没有测试号 {TEST_PHONE} —— 先在手机上用这个号登录一次再跑本脚本。")
|
||||
sys.exit(1)
|
||||
return u
|
||||
|
||||
|
||||
def cmd_show() -> None:
|
||||
"""只打印:每种 bizType 经客户端映射后会显示成什么(不写库)。"""
|
||||
print("bizType 造流水后,收益明细页预期显示的文案:\n")
|
||||
print(f" {'bizType':32} {'后端remark(故意填的)':32} → 手机显示")
|
||||
print(" " + "-" * 90)
|
||||
for biz, remark, _coin in CASES:
|
||||
shown = _client_coin_title(biz, remark or None)
|
||||
rk = (remark or "(空)")
|
||||
print(f" {biz:32} {rk:32} → {shown}")
|
||||
print("\n注:remark 列是故意填的『旧/错』文案;'手机显示'若为新文案 = 强制覆盖生效。")
|
||||
print("invite / compare_milestone 已从客户端映射删除:invite 有 remark 故显示原样,")
|
||||
print("compare_milestone remark 空故落兜底『奖励』—— 两者都不再有专属新文案(符合『去掉』)。")
|
||||
|
||||
|
||||
def cmd_seed() -> None:
|
||||
if settings.APP_ENV != "dev":
|
||||
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV},本脚本只在 dev 库造测试数据。")
|
||||
sys.exit(1)
|
||||
db = SessionLocal()
|
||||
u = _get_user(db)
|
||||
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
|
||||
if acc is None:
|
||||
acc = CoinAccount(user_id=u.id, coin_balance=0, cash_balance_cents=0)
|
||||
db.add(acc)
|
||||
db.flush()
|
||||
now = datetime.now(CN_TZ).replace(tzinfo=None)
|
||||
made = 0
|
||||
for i, (biz, remark, coin) in enumerate(CASES):
|
||||
ref = f"{REF_PREFIX}:{biz}:{i}"
|
||||
exists = db.query(CoinTransaction).filter(CoinTransaction.ref_id == ref).first()
|
||||
if exists:
|
||||
continue
|
||||
acc.coin_balance += coin
|
||||
db.add(CoinTransaction(
|
||||
user_id=u.id, amount=coin, balance_after=acc.coin_balance,
|
||||
biz_type=biz, ref_id=ref, remark=remark or None, created_at=now,
|
||||
))
|
||||
made += 1
|
||||
db.commit()
|
||||
print(f"✓ 已给 user_id={u.id}({TEST_PHONE})造 {made} 条测试流水,当前金币余额 {acc.coin_balance}。")
|
||||
print(" → 打开手机 App「收益明细 / 金币记录」下拉刷新,逐条核对文案。")
|
||||
print(" → 验收完跑 --clean 删除这些测试流水。")
|
||||
|
||||
|
||||
def cmd_clean() -> None:
|
||||
if settings.APP_ENV != "dev":
|
||||
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV}。")
|
||||
sys.exit(1)
|
||||
db = SessionLocal()
|
||||
u = _get_user(db)
|
||||
rows = db.query(CoinTransaction).filter(
|
||||
CoinTransaction.user_id == u.id,
|
||||
CoinTransaction.ref_id.like(f"{REF_PREFIX}:%"),
|
||||
).all()
|
||||
total = sum(r.amount for r in rows)
|
||||
for r in rows:
|
||||
db.delete(r)
|
||||
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
|
||||
if acc is not None:
|
||||
acc.coin_balance -= total # 把造流水时加的余额扣回,还原
|
||||
db.commit()
|
||||
print(f"✓ 已删除 {len(rows)} 条 TESTDOC 测试流水,余额回扣 {total},当前 {acc.coin_balance if acc else 0}。")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="收益明细金币文案验证脚手架")
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--show", action="store_true", help="只打印每种 bizType 的预期显示文案,不写库")
|
||||
g.add_argument("--seed", action="store_true", help="往测试号造每种 bizType 各一条流水")
|
||||
g.add_argument("--clean", action="store_true", help="删除本脚本造的所有测试流水")
|
||||
args = ap.parse_args()
|
||||
if args.show:
|
||||
cmd_show()
|
||||
elif args.seed:
|
||||
cmd_seed()
|
||||
elif args.clean:
|
||||
cmd_clean()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+15
-22
@@ -1,19 +1,22 @@
|
||||
"""测试用 fixtures。
|
||||
|
||||
测试库用 Docker PostgreSQL 的 shaguabijia_test(与 dev 业务库 shaguabijia 隔离)。
|
||||
顺序(必须):设 test DATABASE_URL(在 import app.* 之前)→ ensure PG 就绪 + 测试库存在 →
|
||||
import app → 建表。持久卷可能残留上次的表 → session 开头先 drop 再 create。
|
||||
测试 DB 用临时文件 SQLite。
|
||||
- 不用 in-memory:in-memory 默认 per-connection,跨连接看不到表。
|
||||
- 用临时文件保证 SessionLocal 每次新连都看到同一份 schema。
|
||||
|
||||
顺序:set env(必须在 import app.* 之前) → import app → 建表 → TestClient
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
|
||||
# 1) 测试库连接串——必须在 import app.* 之前设好(app.db.session 在 import 期就建 engine)
|
||||
_TEST_DB_URL = (
|
||||
"postgresql+psycopg://shaguabijia_app:shaguabijia_dev_pw@localhost:5432/shaguabijia_test"
|
||||
)
|
||||
os.environ["DATABASE_URL"] = _TEST_DB_URL
|
||||
# 临时 db 文件路径,进程退出后清理
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{_tmp_db.name}"
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "test-secret-please-ignore-this-is-only-for-pytest-not-real")
|
||||
os.environ.setdefault("ADMIN_JWT_SECRET", "test-admin-secret-please-ignore-only-for-pytest-not-real")
|
||||
os.environ.setdefault("JG_APP_KEY", "test-key")
|
||||
@@ -32,18 +35,6 @@ os.environ.setdefault("RATE_LIMIT_ENABLED", "false") # 限流内存计数会跨
|
||||
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
||||
os.environ.setdefault("PANGLE_REWARD_SECRET", "test-pangle-secret-only-for-pytest")
|
||||
|
||||
# 2) 保证 Docker PG 就绪(必须在 import app.db.session 建 engine 之前)。
|
||||
# ensure() 在「端口已通」时会短路、不建测试库,故随后再显式补一刀 ensure_test_db()。
|
||||
from scripts.ensure_pg import ensure, ensure_test_db
|
||||
|
||||
if not ensure(_TEST_DB_URL):
|
||||
raise RuntimeError(
|
||||
"测试需要 Docker PostgreSQL 就绪。请确认已装 Docker Desktop;"
|
||||
"或先跑一次 run.bat/run.sh 把 PG 拉起,再重试 pytest。"
|
||||
)
|
||||
# best-effort 兜底建测试库(PG 已在跑但测试库缺失=老卷)。真缺库时下面 create_all 会明确报错。
|
||||
ensure_test_db()
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -54,11 +45,13 @@ from app.main import app
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _setup_db() -> Iterator[None]:
|
||||
# 持久卷可能残留上次跑崩后的表/数据 → 先 drop 再 create,保证干净起点
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
Base.metadata.drop_all(engine)
|
||||
try:
|
||||
os.unlink(_tmp_db.name)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -320,3 +320,92 @@ def test_read_apis_require_auth(admin_client: TestClient) -> None:
|
||||
"/admin/api/feedbacks",
|
||||
]:
|
||||
assert admin_client.get(path).status_code == 401, path
|
||||
|
||||
|
||||
def test_period_comparison_reward_coin_from_feed_scene(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""比价奖励金币口径:按 ad_feed_reward_record.feed_scene='comparison' 的实发金币
|
||||
(status=granted)汇总,而非查从不写入的 biz_type 桶(修复大盘该卡恒 0)。
|
||||
too_short(未发奖)不计。"""
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
|
||||
d = "2021-06-15" # 独立历史日,隔离其它用例数据
|
||||
db = SessionLocal()
|
||||
try:
|
||||
uid = user_repo.upsert_user_for_login(db, phone="13800008801", register_channel="sms").id
|
||||
db.add(AdFeedRewardRecord(
|
||||
client_event_id="cmp-fs-granted", user_id=uid, reward_date=d,
|
||||
ecpm_raw="0", coin=123, feed_scene="comparison", status="granted",
|
||||
))
|
||||
db.add(AdFeedRewardRecord(
|
||||
client_event_id="cmp-fs-tooshort", user_id=uid, reward_date=d,
|
||||
ecpm_raw="0", coin=99, feed_scene="comparison", status="too_short",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.get(
|
||||
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["period"]["coins"]["comparison_reward_coin_total"] == 123
|
||||
|
||||
|
||||
def test_period_coupon_reward_coin_from_feed_scene(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""领券奖励金币口径:按 ad_feed_reward_record.feed_scene='coupon' 的实发金币汇总。"""
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
|
||||
d = "2021-06-16"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
uid = user_repo.upsert_user_for_login(db, phone="13800008802", register_channel="sms").id
|
||||
db.add(AdFeedRewardRecord(
|
||||
client_event_id="cpn-fs-granted", user_id=uid, reward_date=d,
|
||||
ecpm_raw="0", coin=456, feed_scene="coupon", status="granted",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.get(
|
||||
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["period"]["coins"]["coupon_reward_coin_total"] == 456
|
||||
|
||||
|
||||
def test_period_coupon_reward_excludes_reward_video(
|
||||
admin_client: TestClient, admin_token: str
|
||||
) -> None:
|
||||
"""领券奖励金币不再把激励视频金币算进来(reward_video/ad_reward 从领券桶拆出);
|
||||
激励视频仍单独计入 reward_video_coin_total。"""
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.wallet import CoinTransaction
|
||||
|
||||
d = "2021-06-17"
|
||||
db = SessionLocal()
|
||||
try:
|
||||
uid = user_repo.upsert_user_for_login(db, phone="13800008803", register_channel="sms").id
|
||||
db.add(CoinTransaction(
|
||||
user_id=uid, amount=50, balance_after=50, biz_type="reward_video",
|
||||
ref_id="rv-split-1", created_at=datetime(2021, 6, 17, 12, 0, 0),
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
r = admin_client.get(
|
||||
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
coins = r.json()["period"]["coins"]
|
||||
assert coins["coupon_reward_coin_total"] == 0 # 激励视频不计入领券奖励
|
||||
assert coins["reward_video_coin_total"] == 50 # 仍计入激励视频卡
|
||||
|
||||
@@ -150,3 +150,80 @@ def test_create_admin_rejects_unknown_role(admin_client, super_token) -> None:
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def _login_pages(username: str, password: str = "pass1234") -> list[str]:
|
||||
"""以某账号登录,取下发的有效可见页(左侧导航过滤依据)。"""
|
||||
c = TestClient(admin_app)
|
||||
return c.post(
|
||||
"/admin/api/auth/login", json={"username": username, "password": password}
|
||||
).json()["admin"]["pages"]
|
||||
|
||||
|
||||
def test_custom_role_create_pages_take_effect(admin_client, super_token) -> None:
|
||||
# 建「自定义」账号:role=custom + 勾选页(含一个非法 key,应被过滤)
|
||||
r = admin_client.post(
|
||||
"/admin/api/admins",
|
||||
json={
|
||||
"username": "cust_user", "password": "pass1234", "role": "custom",
|
||||
"pages_override": ["dashboard", "withdraws", "xx-bad"],
|
||||
},
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# 登录下发的 pages == 勾选集(非法 key 过滤),真正驱动左侧导航
|
||||
assert set(_login_pages("cust_user")) == {"dashboard", "withdraws"}
|
||||
# 账号列表回显原始勾选集(供编辑回填),同样已过滤
|
||||
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
|
||||
assert set(admins["cust_user"]["pages_override"]) == {"dashboard", "withdraws"}
|
||||
assert admins["cust_user"]["role"] == "custom"
|
||||
|
||||
|
||||
def test_custom_role_update_and_switch_back_clears_override(admin_client, super_token) -> None:
|
||||
admin_client.post(
|
||||
"/admin/api/admins",
|
||||
json={
|
||||
"username": "cust_sw", "password": "pass1234", "role": "custom",
|
||||
"pages_override": ["dashboard"],
|
||||
},
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
aid = next(
|
||||
a["id"] for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()
|
||||
if a["username"] == "cust_sw"
|
||||
)
|
||||
# 只改勾选集(角色不变)→ 生效
|
||||
u = admin_client.patch(
|
||||
f"/admin/api/admins/{aid}", json={"pages_override": ["dashboard", "feedbacks"]},
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
assert u.status_code == 200, u.text
|
||||
assert set(_login_pages("cust_sw")) == {"dashboard", "feedbacks"}
|
||||
# 切回普通角色 operator → override 清空,pages 跟随角色(运营页集,且不含 admins)
|
||||
u2 = admin_client.patch(
|
||||
f"/admin/api/admins/{aid}", json={"role": "operator"}, headers=_auth(super_token)
|
||||
)
|
||||
assert u2.status_code == 200, u2.text
|
||||
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
|
||||
assert admins["cust_sw"]["pages_override"] is None
|
||||
pages = set(_login_pages("cust_sw"))
|
||||
assert "feedbacks" in pages and "admins" not in pages # 运营口径,自定义页已不生效
|
||||
|
||||
|
||||
def test_switch_operator_to_custom_sets_override(admin_client, super_token) -> None:
|
||||
admin_client.post(
|
||||
"/admin/api/admins",
|
||||
json={"username": "op2cust", "password": "pass1234", "role": "operator"},
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
aid = next(
|
||||
a["id"] for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()
|
||||
if a["username"] == "op2cust"
|
||||
)
|
||||
u = admin_client.patch(
|
||||
f"/admin/api/admins/{aid}",
|
||||
json={"role": "custom", "pages_override": ["config"]},
|
||||
headers=_auth(super_token),
|
||||
)
|
||||
assert u.status_code == 200, u.text
|
||||
assert set(_login_pages("op2cust")) == {"config"}
|
||||
|
||||
@@ -18,7 +18,6 @@ import httpx
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.comparison import ComparisonRecord
|
||||
from app.repositories import comparison as crud
|
||||
from app.repositories.user import get_user_by_phone
|
||||
from app.schemas.compare_record import ComparisonRecordIn
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -48,17 +47,6 @@ def _get(db, trace_id: str) -> ComparisonRecord | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _make_user(client, phone: str) -> int:
|
||||
"""登录建号并返回其真实 user_id。
|
||||
|
||||
PG 强制 comparison_record.user_id → user.id 外键,须引用真实存在的用户;
|
||||
用本用例自己登录出的用户,不会与别的用例撞。
|
||||
"""
|
||||
client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
||||
with SessionLocal() as db:
|
||||
return get_user_by_phone(db, phone).id
|
||||
|
||||
|
||||
# ============================================================
|
||||
# repo 层
|
||||
# ============================================================
|
||||
@@ -156,18 +144,17 @@ def test_harvest_abort_missing_row_returns_none(client) -> None:
|
||||
def test_upsert_record_no_downgrade_after_harvest_success(client) -> None:
|
||||
"""harvest 落 success 后,老客户端 fromFailure 的 cancelled 上报不许把它盖回去。"""
|
||||
tid = _tid()
|
||||
# PG 强制 comparison_record.user_id → user.id 外键(SQLite 不强制,老写法用合成 id
|
||||
# 987654)。用本用例自己登录出的真实用户,既满足外键、又不与别的用例撞。
|
||||
uid = _make_user(client, "13800007701")
|
||||
with SessionLocal() as db:
|
||||
crud.harvest_done(db, trace_id=tid, user_id=None, done_params=_done_params())
|
||||
payload = ComparisonRecordIn(
|
||||
trace_id=tid, business_type="food", status="cancelled",
|
||||
information="用户终止", comparison_results=[],
|
||||
)
|
||||
rec = crud.upsert_record(db, user_id=uid, payload=payload)
|
||||
# 用一个不会与顺序自增用户撞的合成 id(SQLite 测试库 FK 不强制;别用小整数,
|
||||
# 否则会撞上别的测试 login 出来的真实 user_id → 记录混进那个用户的列表)。
|
||||
rec = crud.upsert_record(db, user_id=987654, payload=payload)
|
||||
assert rec.status == "success" # 不降级
|
||||
assert rec.user_id == uid # 但补上了 user_id(原为 None)
|
||||
assert rec.user_id == 987654 # 但补上了 user_id(原为 None)
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""scripts/ensure_pg.py 纯函数单测(不需要 Docker/PG)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from scripts.ensure_pg import (
|
||||
_docker_desktop_cmd,
|
||||
_is_sqlite,
|
||||
_parse_host_port,
|
||||
_port_open,
|
||||
ensure,
|
||||
)
|
||||
|
||||
|
||||
def test_is_sqlite():
|
||||
assert _is_sqlite("sqlite:///./data/app.db")
|
||||
assert _is_sqlite(" SQLite:///x ")
|
||||
assert not _is_sqlite("postgresql+psycopg://u:p@localhost:5432/db")
|
||||
|
||||
|
||||
def test_parse_host_port_full():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_defaults():
|
||||
# 缺端口 → 5432
|
||||
assert _parse_host_port("postgresql+psycopg://u:p@db.example/x")[1] == 5432
|
||||
# 缺 host → localhost
|
||||
assert _parse_host_port("postgresql+psycopg:///x") == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_parse_host_port_testdb():
|
||||
assert _parse_host_port(
|
||||
"postgresql+psycopg://u:p@localhost:5432/shaguabijia_test"
|
||||
) == ("localhost", 5432)
|
||||
|
||||
|
||||
def test_port_open_true():
|
||||
srv = socket.socket()
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(1)
|
||||
port = srv.getsockname()[1]
|
||||
try:
|
||||
assert _port_open("127.0.0.1", port, timeout=1.0)
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
def test_port_open_false():
|
||||
s = socket.socket()
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
s.close() # 释放端口,无人监听 → 连接应失败
|
||||
assert not _port_open("127.0.0.1", port, timeout=0.3)
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_windows():
|
||||
cmd = _docker_desktop_cmd("win32", r"C:\Program Files")
|
||||
assert cmd is not None
|
||||
assert cmd[0].endswith("Docker Desktop.exe")
|
||||
assert "Docker" in cmd[0]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_darwin():
|
||||
assert _docker_desktop_cmd("darwin", "") == ["open", "-a", "Docker"]
|
||||
|
||||
|
||||
def test_docker_desktop_cmd_linux():
|
||||
assert _docker_desktop_cmd("linux", "") is None
|
||||
|
||||
|
||||
def test_ensure_rejects_sqlite():
|
||||
# dev 守卫:sqlite 直接 False(不碰 Docker)
|
||||
assert ensure("sqlite:///./data/app.db") is False
|
||||
|
||||
|
||||
def test_ensure_shortcircuits_when_pg_up(monkeypatch):
|
||||
# 端口通 → 直接 True,绝不触碰 docker
|
||||
monkeypatch.setattr("scripts.ensure_pg._port_open", lambda *a, **k: True)
|
||||
|
||||
def _boom():
|
||||
raise AssertionError("端口通时不应调用 docker")
|
||||
|
||||
monkeypatch.setattr("scripts.ensure_pg._docker_cli_ok", _boom)
|
||||
assert ensure("postgresql+psycopg://u:p@localhost:5432/shaguabijia") is True
|
||||
@@ -143,14 +143,15 @@ def test_two_accounts_withdraw_independent(client, monkeypatch) -> None:
|
||||
_reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单
|
||||
r2 = client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 100, "source": "coin_cash"},
|
||||
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
|
||||
json={"amount_cents": 50, "source": "coin_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r2.json()["status"] == "reviewing"
|
||||
|
||||
cash, invite_cash = _balances(client, token)
|
||||
assert invite_cash == 500 # 已退回
|
||||
assert cash == 300 # 扣了 cash 100
|
||||
assert cash == 350 # 扣了 cash 50
|
||||
|
||||
|
||||
def test_invite_me_returns_reward_stats(client) -> None:
|
||||
@@ -177,7 +178,8 @@ def test_withdraw_orders_source_filter(client, monkeypatch) -> None:
|
||||
_reject(r1.json()["out_bill_no"]) # 结清,才能提第二笔
|
||||
client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 100, "source": "coin_cash"},
|
||||
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
|
||||
json={"amount_cents": 50, "source": "coin_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
|
||||
@@ -139,7 +139,8 @@ def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None:
|
||||
before = _ledger()
|
||||
r = client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 200, "source": "coin_cash"},
|
||||
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
|
||||
json={"amount_cents": 50, "source": "coin_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。
|
||||
|
||||
规则(2026-07-09 拍板):
|
||||
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)。
|
||||
- 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过。
|
||||
- 常规三档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。
|
||||
- invite_cash 无档位概念:tiers 为空、下单不走档位闸(邀请页行为不变)。
|
||||
wxpay 调用全部 monkeypatch;现金余额 DB 直灌(同 test_withdraw.py 套路)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CoinAccount
|
||||
from app.repositories import wallet as crud_wallet
|
||||
|
||||
|
||||
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 _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _seed_balances(client, token: str, phone: str, cash: int = 0, invite_cash: int = 0) -> None:
|
||||
client.get("/api/v1/wallet/account", headers=_auth(token))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
|
||||
acc = db.get(CoinAccount, user.id)
|
||||
acc.cash_balance_cents = cash
|
||||
acc.invite_cash_balance_cents = invite_cash
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _patch_userinfo(monkeypatch, openid: str) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": openid, "nickname": "昵称", "avatar_url": None, "raw": {}},
|
||||
)
|
||||
|
||||
|
||||
def _reject(bill: str) -> None:
|
||||
"""管理员拒绝:退款结清活跃单(便于同用户继续发起下一笔;名额按'发起就算'仍占)。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
crud_wallet.reject_withdraw(db, bill, "test")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _withdraw(client, token: str, cents: int):
|
||||
return client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": cents, "source": "coin_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
|
||||
def _tiers(client, token: str, source: str = "coin_cash") -> list[dict]:
|
||||
r = client.get(
|
||||
"/api/v1/wallet/withdraw-info", params={"source": source}, headers=_auth(token)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["tiers"]
|
||||
|
||||
|
||||
def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None:
|
||||
"""新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。"""
|
||||
token = _login(client, "13800006001")
|
||||
tiers = _tiers(client, token)
|
||||
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000]
|
||||
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20"]
|
||||
assert tiers[0]["badge"] == "新人福利" and tiers[0]["is_newbie"] is True
|
||||
assert tiers[1]["badge"] == "新人福利" and tiers[1]["is_newbie"] is True
|
||||
assert all(t["available"] for t in tiers)
|
||||
assert tiers[2]["remaining_today"] == 3 # 0.5 日 3 次
|
||||
assert _tiers(client, token, source="invite_cash") == []
|
||||
|
||||
|
||||
def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None:
|
||||
"""0.1 提过(即使被拒)→ 永久消失;同天 0.3 仍可提;新人档不锁常规档。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_2")
|
||||
token = _login(client, "13800006002")
|
||||
_seed_balances(client, token, "13800006002", cash=5000)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = _withdraw(client, token, 10) # 0.1 新人档
|
||||
assert r.status_code == 200, r.text
|
||||
_reject(r.json()["out_bill_no"]) # 被拒也算用过("发起就算")
|
||||
|
||||
tiers = _tiers(client, token)
|
||||
amounts = [t["amount_cents"] for t in tiers]
|
||||
assert 10 not in amounts # 0.1 消失
|
||||
assert 30 in amounts # 0.3 还在,同天仍可提
|
||||
# 新人档不参与"选一个额度":常规三档全部仍可提
|
||||
regular = {t["amount_cents"]: t for t in tiers if not t["is_newbie"]}
|
||||
assert all(regular[a]["available"] for a in (50, 1000, 2000))
|
||||
|
||||
r = _withdraw(client, token, 30) # 同天 0.3 照提
|
||||
assert r.status_code == 200, r.text
|
||||
_reject(r.json()["out_bill_no"])
|
||||
|
||||
# 0.1 已用过,重提 → 非法金额(档位已消失)
|
||||
r = _withdraw(client, token, 10)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
|
||||
"""当天提过 0.5 → 10/20 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_3")
|
||||
token = _login(client, "13800006003")
|
||||
_seed_balances(client, token, "13800006003", cash=10_000)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = _withdraw(client, token, 50)
|
||||
assert r.status_code == 200, r.text
|
||||
_reject(r.json()["out_bill_no"]) # 结清活跃单;当天名额仍占("发起就算")
|
||||
|
||||
tiers = {t["amount_cents"]: t for t in _tiers(client, token)}
|
||||
assert tiers[50]["available"] and tiers[50]["remaining_today"] == 2
|
||||
assert not tiers[1000]["available"] and tiers[1000]["disabled_reason"] == "other_tier_selected"
|
||||
assert not tiers[2000]["available"] and tiers[2000]["disabled_reason"] == "other_tier_selected"
|
||||
|
||||
r = _withdraw(client, token, 1000) # 选一额度互斥 → 409
|
||||
assert r.status_code == 409, r.text
|
||||
assert "今日额度已达上限" in r.json()["detail"]
|
||||
|
||||
r = _withdraw(client, token, 50) # 0.5 第 2 次照常
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_regular_daily_quota_exhausted(client, monkeypatch) -> None:
|
||||
"""0.5 日 3 次:第 4 次 409;tiers 显示 quota_exhausted。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_4")
|
||||
token = _login(client, "13800006004")
|
||||
_seed_balances(client, token, "13800006004", cash=10_000)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
for _ in range(3):
|
||||
r = _withdraw(client, token, 50)
|
||||
assert r.status_code == 200, r.text
|
||||
_reject(r.json()["out_bill_no"])
|
||||
|
||||
tiers = {t["amount_cents"]: t for t in _tiers(client, token)}
|
||||
assert not tiers[50]["available"]
|
||||
assert tiers[50]["disabled_reason"] == "quota_exhausted"
|
||||
assert tiers[50]["remaining_today"] == 0
|
||||
|
||||
r = _withdraw(client, token, 50)
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_non_tier_amount_rejected_for_coin_cash(client, monkeypatch) -> None:
|
||||
"""coin_cash 只能提预设档位:任意其他金额 400(防绕过客户端刷)。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_5")
|
||||
token = _login(client, "13800006005")
|
||||
_seed_balances(client, token, "13800006005", cash=10_000)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = _withdraw(client, token, 123)
|
||||
assert r.status_code == 400, r.text
|
||||
|
||||
|
||||
def test_invite_cash_not_gated_by_tiers(client, monkeypatch) -> None:
|
||||
"""invite_cash 不走档位闸:非档位金额(200=2元)照常下单——邀请页行为不变。"""
|
||||
_patch_userinfo(monkeypatch, "openid_tier_6")
|
||||
token = _login(client, "13800006006")
|
||||
_seed_balances(client, token, "13800006006", invite_cash=500)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/wallet/withdraw",
|
||||
json={"amount_cents": 200, "source": "invite_cash"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "reviewing"
|
||||
Reference in New Issue
Block a user