bce8cd9923
一、RBAC 权限管理(角色 → 可见页面) - 新增 admin_role 表 + 权限目录 permissions.py:角色持有一组页面 key,登录后左侧只展示这些页 - 内建角色 管理员(super_admin 全权锁定)/运营/财务/技术,页集对齐原型;key 承重(require_role 用),另加中文 label 展示 - 角色 CRUD 端点(仅 super_admin)+ 目录端点;登录/me 下发当前角色有效可见页 - admins 加删除、角色存在性校验;可复看已确定登录密码:UI 建的账号留存明文 plain_password,脚本建的超管不留存 二、系统配置下发修复 - 首页数据「保存即生效」:显式保存(apply_now)对 real/manual 直接落配置目标值、绕过只增不减护栏(修「改了 app 端不变」),护栏仍管自动 tick - 首页轮播新增数据源三选一:mixed(真实优先+种子)/real(只真实)/seed(只种子),get_feed 分支 + /marquee-seeds/mode 端点 - 福利页 Tab 隐藏 任务/里程碑,及看广告的单次金币/每轮次数/信息流开关:CONFIG_DEFS 加 hidden + list_config 过滤,业务读取默认值不受影响 三、比价记录店/商品搜索 - comparison_record 加 product_names 派生列(从下单 items 拼商品名),迁移建列并回填历史行 - admin 比价记录列表店/商品分列可搜:走 product_names 普通列 LIKE,规避 SQLite JSON 中文 ensure_ascii 转义搜不到的坑 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
229 lines
7.9 KiB
Python
229 lines
7.9 KiB
Python
"""admin 首页轮播种子管理:列出 / 新增 / 改 / 删 / 批量生成 / 预览(带审计)。
|
|
|
|
种子用于首页轮播「真实+种子混播」的兜底(见 app/repositories/ops_marquee.py)。
|
|
权限:operator 可改,super 恒可;预览为只读(任意已登录 admin)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel
|
|
|
|
from app.admin.audit import write_audit
|
|
from app.admin.deps import AdminDb, get_client_ip, get_current_admin, require_role
|
|
from app.admin.schemas.ops_marquee_seed import (
|
|
OpsMarqueeSeedBatchDelete,
|
|
OpsMarqueeSeedBatchEnable,
|
|
OpsMarqueeSeedBulkCreate,
|
|
OpsMarqueeSeedCreate,
|
|
OpsMarqueeSeedOut,
|
|
OpsMarqueeSeedUpdate,
|
|
OpsSavingsFeedPreviewOut,
|
|
)
|
|
from app.models.admin import AdminUser
|
|
from app.models.ops_marquee_seed import OpsMarqueeSeed
|
|
from app.repositories import app_config, ops_marquee
|
|
|
|
|
|
class MarqueeModeUpdate(BaseModel):
|
|
mode: str # mixed / real / seed
|
|
|
|
router = APIRouter(
|
|
prefix="/admin/api/marquee-seeds",
|
|
tags=["admin-marquee-seeds"],
|
|
dependencies=[Depends(get_current_admin)],
|
|
)
|
|
|
|
|
|
def _out(seed: OpsMarqueeSeed) -> OpsMarqueeSeedOut:
|
|
return OpsMarqueeSeedOut(
|
|
id=seed.id,
|
|
masked_user=seed.masked_user,
|
|
min_cents=seed.min_cents,
|
|
max_cents=seed.max_cents,
|
|
enabled=seed.enabled,
|
|
sort_order=seed.sort_order,
|
|
created_at=seed.created_at.isoformat() if seed.created_at else None,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=list[OpsMarqueeSeedOut], summary="轮播种子列表")
|
|
def list_seeds(db: AdminDb) -> list[OpsMarqueeSeedOut]:
|
|
return [_out(s) for s in ops_marquee.list_seeds(db)]
|
|
|
|
|
|
@router.get("/preview", response_model=OpsSavingsFeedPreviewOut, summary="预览实际混播 feed")
|
|
def preview_feed(
|
|
db: AdminDb,
|
|
limit: Annotated[int, Query(ge=1, le=30)] = 8,
|
|
) -> OpsSavingsFeedPreviewOut:
|
|
"""返回客户端实际会看到的 feed(真实记录会插队、种子随机抽取/金额随机/名字合成),供运营对效果。"""
|
|
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit))
|
|
|
|
|
|
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
|
|
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
|
|
def get_feed_mode(db: AdminDb) -> dict:
|
|
"""当前轮播取数模式:mixed=真实优先+种子补位(默认)/ real=只真实 / seed=只种子·合成。"""
|
|
return {"mode": ops_marquee.get_feed_mode(db)}
|
|
|
|
|
|
@router.patch("/mode", summary="改首页轮播数据源模式(带审计)")
|
|
def set_feed_mode(
|
|
body: MarqueeModeUpdate,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> dict:
|
|
if body.mode not in ops_marquee.FEED_MODES:
|
|
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
|
|
before = ops_marquee.get_feed_mode(db)
|
|
app_config.set_value(db, "marquee_feed_mode", body.mode, admin_id=admin.id, commit=False)
|
|
write_audit(
|
|
db, admin, action="ops_marquee_seed.set_mode", target_type="config",
|
|
target_id="marquee_feed_mode", detail={"before": before, "after": body.mode},
|
|
ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
return {"mode": body.mode}
|
|
|
|
|
|
@router.post("", response_model=OpsMarqueeSeedOut, summary="新增轮播种子(带审计)")
|
|
def create_seed(
|
|
body: OpsMarqueeSeedCreate,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> OpsMarqueeSeedOut:
|
|
try:
|
|
seed = ops_marquee.create_seed(
|
|
db,
|
|
masked_user=body.masked_user,
|
|
min_cents=body.min_cents,
|
|
max_cents=body.max_cents,
|
|
enabled=body.enabled,
|
|
sort_order=body.sort_order,
|
|
commit=False,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
write_audit(
|
|
db, admin, action="ops_marquee_seed.create", target_type="ops_marquee_seed",
|
|
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
db.refresh(seed)
|
|
return _out(seed)
|
|
|
|
|
|
@router.post("/bulk", response_model=list[OpsMarqueeSeedOut], summary="批量生成种子(带审计)")
|
|
def bulk_generate(
|
|
body: OpsMarqueeSeedBulkCreate,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> list[OpsMarqueeSeedOut]:
|
|
try:
|
|
seeds = ops_marquee.bulk_generate(
|
|
db,
|
|
count=body.count,
|
|
min_cents=body.min_cents,
|
|
max_cents=body.max_cents,
|
|
enabled=body.enabled,
|
|
commit=False,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
write_audit(
|
|
db, admin, action="ops_marquee_seed.bulk_generate", target_type="ops_marquee_seed",
|
|
target_id=None, detail=body.model_dump(), ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
for s in seeds:
|
|
db.refresh(s)
|
|
return [_out(s) for s in seeds]
|
|
|
|
|
|
@router.post("/batch-delete", summary="批量删除种子(带审计)")
|
|
def batch_delete_seeds(
|
|
body: OpsMarqueeSeedBatchDelete,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> dict:
|
|
n = ops_marquee.batch_delete(db, body.ids, commit=False)
|
|
write_audit(
|
|
db, admin, action="ops_marquee_seed.batch_delete", target_type="ops_marquee_seed",
|
|
target_id=None, detail={"ids": body.ids, "deleted": n},
|
|
ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
return {"deleted": n}
|
|
|
|
|
|
@router.post("/batch-enable", summary="批量启用 / 停用种子(带审计)")
|
|
def batch_enable_seeds(
|
|
body: OpsMarqueeSeedBatchEnable,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> dict:
|
|
n = ops_marquee.batch_set_enabled(db, body.ids, body.enabled, commit=False)
|
|
write_audit(
|
|
db, admin, action="ops_marquee_seed.batch_enable", target_type="ops_marquee_seed",
|
|
target_id=None, detail={"ids": body.ids, "enabled": body.enabled, "updated": n},
|
|
ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
return {"updated": n}
|
|
|
|
|
|
@router.patch("/{seed_id}", response_model=OpsMarqueeSeedOut, summary="改轮播种子(带审计)")
|
|
def update_seed(
|
|
seed_id: int,
|
|
body: OpsMarqueeSeedUpdate,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> OpsMarqueeSeedOut:
|
|
try:
|
|
seed = ops_marquee.update_seed(
|
|
db,
|
|
seed_id,
|
|
masked_user=body.masked_user,
|
|
min_cents=body.min_cents,
|
|
max_cents=body.max_cents,
|
|
enabled=body.enabled,
|
|
sort_order=body.sort_order,
|
|
commit=False,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404 if "不存在" in str(e) else 400, detail=str(e)) from e
|
|
write_audit(
|
|
db, admin, action="ops_marquee_seed.update", target_type="ops_marquee_seed",
|
|
target_id=seed_id, detail=body.model_dump(exclude_none=True),
|
|
ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
db.refresh(seed)
|
|
return _out(seed)
|
|
|
|
|
|
@router.delete("/{seed_id}", summary="删轮播种子(带审计)")
|
|
def delete_seed(
|
|
seed_id: int,
|
|
request: Request,
|
|
admin: Annotated[AdminUser, Depends(require_role("operator"))],
|
|
db: AdminDb,
|
|
) -> dict:
|
|
ok = ops_marquee.delete_seed(db, seed_id, commit=False)
|
|
if not ok:
|
|
raise HTTPException(status_code=404, detail="种子不存在")
|
|
write_audit(
|
|
db, admin, action="ops_marquee_seed.delete", target_type="ops_marquee_seed",
|
|
target_id=seed_id, detail=None, ip=get_client_ip(request), commit=False,
|
|
)
|
|
db.commit()
|
|
return {"deleted": True}
|