Files
live-hub-py/web/backend/routers/huya/collab.py
T
yml2213 aac264b639 feat(huya): 协作台账店铺字典·新建强校验·同买家多单 + uid 放开(迁移 0057/0058)
- 店铺字典(迁移 0057):大按钮点选 + 「新店」自定义自动登记;重命名可级联、
  停用/备注;列表行内改、详情、筛选条共用同一个 picker
- 新建强校验:订单编号/店铺/CK/礼包名/主页截图必填;贴 CK 即探针预览,
  读不到绑定角色禁止提交;礼包用工作台配置的活动 SID 现拉(浏览器缓存 + 强制刷新)
- 图片:绑定图停用、「绑定斗鱼号」下线(绑定关系改由探针读 bind_game_*),
  主页截图改为新建必填(弹窗内先传 OSS 再随表单落库)
- 同买家多单软识别:按「绑定账号 + 绑定角色」整批一次查询,列表「同买家」列 +
  详情分组,点单号直接切单
- uid 放开唯一性(迁移 0058):同一份 CK 可服务多单,原 409 冲突改为提示 + 审计
- 列表体验:序号列、搜索扩到 11 个字段、CK 状态列截断、新建弹窗放大并去滚动条
- 验证:pytest 932 passed / 6 skipped;接口冒烟 56 项全绿;tsc+eslint+build 通过
2026-10-16 21:29:08 +08:00

494 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""虎牙外部 CK 协作台账接口。
设计见 ``docs/设计-虎牙外部CK协作台账.md``。这一层只做三件事:
参数校验、权限、异常翻译;业务规则全在 ``services/huya_collab_service.py``。
权限:
- 查看 = ``huya:collab:view``(运营 + 客服)
- 编辑 = ``huya:collab:edit``(运营 + 客服;共享表,改动靠审计留痕)
- 删除 = ``huya:collab:manage``(**只给运营**)
图片上传不在这里:前端直接调 ``POST /api/files/upload``,把返回的 object_key
提交给本模块的 images 接口。
CK 验证是**只读探针**(``POST .../verify`` 单条、``POST .../ck/verify`` 批量):
人工触发、串行带间隔,不建 ``HuyaAccount``、不改业务状态(设计文档 §5.2)。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ...database import get_db
from ...deps import require_permission
from ...models import AuditLog, HuyaExternalOrder, User
from ...schemas import (
HuyaCollabCkPreviewOut,
HuyaCollabCkPreviewRequest,
HuyaCollabShopCreate,
HuyaCollabShopOut,
HuyaCollabShopUpdate,
HuyaExternalOrderAuditOut,
HuyaExternalOrderCkRequest,
HuyaExternalOrderCkStatusRequest,
HuyaExternalOrderCreate,
HuyaExternalOrderImagesRequest,
HuyaExternalOrderImportRequest,
HuyaExternalOrderListOut,
HuyaExternalOrderOut,
HuyaExternalOrderOwnerOut,
HuyaExternalOrderStatusRequest,
HuyaExternalOrderSummaryOut,
HuyaExternalOrderUpdate,
HuyaExternalOrderVerifyOut,
HuyaExternalOrderVerifyRequest,
)
from ...services import huya_collab_service as collab
router = APIRouter()
_AUDIT_TARGET_PREFIX = "huya_external_order:"
_AUDIT_TIMELINE_LIMIT = 100
def _http_error(exc: collab.CollabError) -> HTTPException:
return HTTPException(status_code=exc.status_code, detail=exc.detail)
def _serialize_one(db: Session, order: HuyaExternalOrder) -> dict:
clusters = collab.buyer_clusters(db, [order])
return collab.serialize_order(
order,
users=collab.users_by_id(db, [order]),
buyer_cluster=clusters.get(int(order.id)),
)
# --------------------------------------------------------------------------
# 字面量路径必须声明在 /{order_id} 之前,否则 "summary" 会被当成 id
# --------------------------------------------------------------------------
@router.get("/external-orders/summary", response_model=HuyaExternalOrderSummaryOut)
def external_orders_summary(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""页头统计条:各业务状态计数 + CK 失效数。"""
return collab.summary(db)
@router.get("/external-orders/owners", response_model=list[HuyaExternalOrderOwnerOut])
def external_orders_owners(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""负责人候选:全部活跃用户(不是权限边界,只是筛选/指派用)。"""
rows = (
db.query(User.id, User.username)
.filter(User.is_active.is_(True))
.order_by(User.username)
.all()
)
return [{"id": int(row[0]), "username": str(row[1])} for row in rows]
@router.get("/external-orders/shops", response_model=list[str])
def external_orders_shops(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""店铺名列表(现在就是**店铺字典**里的名字;筛选下拉用)。"""
return collab.list_shops(db)
# --------------------------------------------------------------------------
# 店铺字典(新建协作单时点选;也支持自定义输入后自动登记)
# --------------------------------------------------------------------------
@router.get("/collab-shops", response_model=list[HuyaCollabShopOut])
def list_collab_shops(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""全部店铺(**含停用的**:管理弹窗要看到;新建下拉由前端按 `is_active` 过滤)。"""
shops = collab.list_shop_records(db)
names = collab.user_names(
db,
[shop.created_by for shop in shops] + [shop.updated_by for shop in shops],
)
return [collab.serialize_shop(shop, users=names) for shop in shops]
@router.post("/collab-shops", response_model=HuyaCollabShopOut)
def create_collab_shop(
req: HuyaCollabShopCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""新增店铺(重名 409)。"""
try:
shop = collab.create_shop(
db, current, name=req.name, sort_order=req.sort_order, remark=req.remark
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return collab.serialize_shop(shop)
@router.put("/collab-shops/{shop_id}", response_model=HuyaCollabShopOut)
def update_collab_shop(
shop_id: int,
req: HuyaCollabShopUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""改店铺:名字 / 排序 / 停用启用 / 备注。
``rename_orders``(默认 true)为真时,改名会级联改掉已有订单里的旧名字。
"""
payload = req.model_dump(exclude_unset=True)
rename_orders = bool(payload.pop("rename_orders", True))
try:
shop = collab.get_shop(db, shop_id)
collab.update_shop(db, current, shop, payload, rename_orders=rename_orders)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return collab.serialize_shop(shop)
@router.delete("/collab-shops/{shop_id}")
def delete_collab_shop(
shop_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:manage")),
):
"""从字典里删掉(**只给运营**)。不动订单:订单存的是店铺名,历史单子照旧。"""
try:
shop = collab.get_shop(db, shop_id)
snapshot = collab.delete_shop(db, current, shop)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return {"success": True, "name": snapshot["name"]}
@router.get("/external-orders", response_model=HuyaExternalOrderListOut)
def list_external_orders(
shop: str = Query("", max_length=64),
owner_id: int | None = Query(None),
status: str = Query("", max_length=16),
ck_status: str = Query("", max_length=16),
keyword: str = Query("", max_length=128),
include_void: bool = Query(False, description="是否包含已作废的行(默认隐藏)"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""共享视图:默认不按人过滤(负责人只是筛选维度,不是权限边界)。"""
rows, total = collab.list_orders(
db,
shop=shop,
owner_id=owner_id,
status=status,
ck_status=ck_status,
keyword=keyword,
include_void=include_void,
page=page,
page_size=page_size,
)
names = collab.users_by_id(db, rows)
# 「同一买家的其它单」整页一次算完(按绑定账号 + 绑定角色软识别,只读派生)
clusters = collab.buyer_clusters(db, rows)
return {
"items": [
collab.serialize_order(
row, users=names, buyer_cluster=clusters.get(int(row.id))
)
for row in rows
],
"total": total,
"page": page,
"page_size": page_size,
}
@router.post("/external-orders", response_model=HuyaExternalOrderOut)
def create_external_order(
req: HuyaExternalOrderCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""新建一行(可同时贴一份外部 CK)。订单号唯一,重复会 409。
``require_verified``(默认 true):人工新建表单的严格模式 —— 订单编号 / 店铺 / CK /
礼包名 / 主页截图必填,且 CK 必须探针通并读到绑定角色,否则 400
(粘贴导入走另一个接口,不受影响)。
"""
try:
order = collab.create_order(
db,
current,
order_no=req.order_no,
shop=req.shop,
owner_id=req.owner_id,
ck_text=req.ck_text,
bind_douyu_account=req.bind_douyu_account,
gift_name=req.gift_name,
home_images=req.home_images,
remark=req.remark,
require_verified=req.require_verified,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return _serialize_one(db, order)
@router.post("/external-orders/import")
def import_external_orders(
req: HuyaExternalOrderImportRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""Excel 粘贴批量导入。单行失败只记在 ``errors`` 里,不影响其他行。"""
owners = {
str(name): int(user_id)
for user_id, name in db.query(User.id, User.username)
.filter(User.is_active.is_(True))
.all()
}
return collab.import_orders(db, current, req.text, owner_by_name=owners)
@router.post("/external-orders/ck/preview", response_model=HuyaCollabCkPreviewOut)
def preview_external_order_ck(
req: HuyaCollabCkPreviewRequest,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:edit")),
):
"""粘贴 CK 后的**即时预览**(只读):探针 + 顺带拉活动礼包,一行都不写库。
新建弹窗据此决定能不能提交:``ok`` 且 ``has_role`` 才允许
(服务端在创建时还会再校验一次,见 ``POST /external-orders``)。
礼包用**工作台配置里的活动 SID**(``huya_config.sid``)拉:SID 就是活动 ID,
换活动就得换它,写死会出现「接口 200 但礼包列表是空的」(真机 2026-10-16)。
"""
try:
preview = collab.preview_ck(
req.ck_text,
with_gifts=req.with_gifts,
sid=collab.resolve_gifts_sid(db),
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
# 「这个虎牙号已经在哪些单上」:允许同一份 CK 服务多单,这里只提示、不挡
preview["uid_used_by"] = collab.uids_in_use(db, preview.get("uid"))
return preview
@router.post("/external-orders/ck/verify", response_model=HuyaExternalOrderVerifyOut)
def verify_external_orders_ck(
req: HuyaExternalOrderVerifyRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""批量验证 CK(只读探针):人工触发、串行、带间隔,不做定时轮询。
单条失败只记在 ``results`` 里,不影响其他条(AGENTS §2)。探针只读,
**不会**建 ``HuyaAccount``、也不会改业务状态。
"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
return collab.verify_ck_batch(db, current, orders)
except collab.CollabError as exc:
raise _http_error(exc) from exc
# --------------------------------------------------------------------------
# 单行操作
# --------------------------------------------------------------------------
@router.get("/external-orders/{order_id}", response_model=HuyaExternalOrderOut)
def get_external_order(
order_id: int,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
try:
order = collab.get_order(db, order_id)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return _serialize_one(db, order)
@router.get(
"/external-orders/{order_id}/timeline",
response_model=list[HuyaExternalOrderAuditOut],
)
def external_order_timeline(
order_id: int,
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""操作时间线:直接读 ``audit_logs``,不另建事件表。"""
rows = (
db.query(AuditLog)
.filter(AuditLog.target == f"{_AUDIT_TARGET_PREFIX}{order_id}")
.order_by(AuditLog.id.desc())
.limit(_AUDIT_TIMELINE_LIMIT)
.all()
)
return [
{
"id": row.id,
"action": row.action,
"username": row.username or "",
"detail": row.detail or "",
"success": row.success,
"created_at": row.created_at,
}
for row in rows
]
@router.put("/external-orders/{order_id}", response_model=HuyaExternalOrderOut)
def update_external_order(
order_id: int,
req: HuyaExternalOrderUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""编辑(部分字段)。带 ``expected_version`` 做乐观锁,冲突返回 409。"""
try:
order = collab.get_order(db, order_id)
payload = req.model_dump(exclude_unset=True)
expected_version = payload.pop("expected_version", None)
collab.update_order(
db, current, order, payload, expected_version=expected_version
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return _serialize_one(db, order)
@router.post("/external-orders/{order_id}/status", response_model=HuyaExternalOrderOut)
def update_external_order_status(
order_id: int,
req: HuyaExternalOrderStatusRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""推进/回退业务状态。回退必须显式带 ``rollback=true``。"""
try:
order = collab.get_order(db, order_id)
collab.assert_version(order, req.expected_version)
collab.set_status(
db,
current,
order,
req.status,
reason=req.reason,
rollback=req.rollback,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return _serialize_one(db, order)
@router.post("/external-orders/{order_id}/ck", response_model=HuyaExternalOrderOut)
def update_external_order_ck(
order_id: int,
req: HuyaExternalOrderCkRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""贴入/换 CK(``action=paste``)或从 raw_cookie 重建(``action=rebuild``)。"""
try:
order = collab.get_order(db, order_id)
collab.assert_version(order, req.expected_version)
if req.action == "rebuild":
collab.rebuild_ck(db, current, order)
else:
collab.set_ck(db, current, order, req.ck_text)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return _serialize_one(db, order)
@router.post(
"/external-orders/{order_id}/ck/status", response_model=HuyaExternalOrderOut
)
def update_external_order_ck_status(
order_id: int,
req: HuyaExternalOrderCkStatusRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""人工停用 / 恢复这份 CK(``disabled`` / ``unverified``)。
``valid`` / ``invalid`` **只能**由只读探针给(``POST .../verify``):
人工标「有效」等于绕过验证(设计文档 §4.2)。
"""
try:
order = collab.get_order(db, order_id)
collab.assert_version(order, req.expected_version)
collab.set_manual_ck_status(db, current, order, req.status, message=req.message)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return _serialize_one(db, order)
@router.post("/external-orders/{order_id}/verify", response_model=HuyaExternalOrderOut)
def verify_external_order_ck(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""验证 CK(只读探针):写 ``ck_status`` / ``ck_checked_at`` / ``ck_check_message``。
成功时顺手补 ``bind_douyu_account`` / ``bind_douyu_uid``(**只补空值**)。
CK 失效**不会**自动改业务状态,列表上只做显式提示。
"""
try:
order = collab.get_order(db, order_id)
collab.verify_ck(db, current, order)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return _serialize_one(db, order)
@router.post("/external-orders/{order_id}/images", response_model=HuyaExternalOrderOut)
def update_external_order_images(
order_id: int,
req: HuyaExternalOrderImagesRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""整槽替换图片(提交 ``/api/files/upload`` 返回的 object_key 数组)。"""
try:
order = collab.get_order(db, order_id)
collab.assert_version(order, req.expected_version)
collab.set_images(db, current, order, req.slot, req.images)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return _serialize_one(db, order)
@router.delete("/external-orders/{order_id}")
def delete_external_order(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:manage")),
):
"""删除一行(**只给运营**)。只删行,不删 OSS 里的图(交给孤儿清理脚本)。"""
try:
order = collab.get_order(db, order_id)
snapshot = collab.delete_order(db, current, order)
except collab.CollabError as exc:
raise _http_error(exc) from exc
return {"success": True, "order_no": snapshot["order_no"]}