Files
live-hub-py/web/backend/routers/huya/collab.py
T

607 lines
22 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,
HuyaCollabMetricOut,
HuyaCollabShopCreate,
HuyaCollabShopOut,
HuyaCollabShopUpdate,
HuyaExternalOrderActivityItemOut,
HuyaExternalOrderActivityQueryOut,
HuyaExternalOrderActivityQueryRequest,
HuyaExternalOrderAuditOut,
HuyaExternalOrderCkRequest,
HuyaExternalOrderCkStatusRequest,
HuyaExternalOrderCreate,
HuyaExternalOrderImagesRequest,
HuyaExternalOrderImportRequest,
HuyaExternalOrderListOut,
HuyaExternalOrderOut,
HuyaExternalOrderOwnerOut,
HuyaExternalOrderStatusBatchOut,
HuyaExternalOrderStatusBatchRequest,
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])
metrics = collab.latest_metrics(db, [order])
return collab.serialize_order(
order,
users=collab.users_by_id(db, [order]),
buyer_cluster=clusters.get(int(order.id)),
last_metric=metrics.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)
# 「同一买家的其它单」+「最近一次积分/兑换快照」整页一次算完,别按行发 SQL
clusters = collab.buyer_clusters(db, rows)
metrics = collab.latest_metrics(db, rows)
return {
"items": [
collab.serialize_order(
row,
users=names,
buyer_cluster=clusters.get(int(row.id)),
last_metric=metrics.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/status/batch", response_model=HuyaExternalOrderStatusBatchOut
)
def batch_update_external_order_status(
req: HuyaExternalOrderStatusBatchRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""勾选多行一次改业务状态(单个也走这里)。
人工切换允许**往前跳步**(待处理 → 已上脚本),回退要带 ``rollback=true``;
某一行前置校验没过(如没主页截图不能进「已确认绑定」)只记在这一条的 ``message`` 里。
"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
result = collab.set_status_batch(
db,
current,
orders,
req.status,
reason=req.reason,
rollback=req.rollback,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
result["target_label"] = collab.ORDER_STATUS_LABELS.get(req.status, req.status)
return result
@router.post(
"/external-orders/activity/query", response_model=HuyaExternalOrderActivityQueryOut
)
def batch_query_external_order_activity(
req: HuyaExternalOrderActivityQueryRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""勾选多行查询积分 + 兑换记录(**串行带间隔**,防风控)。
每行都会落一条生命周期快照;「已上脚本」的单按查询结果自动改状态
(查到礼包 → 抢购成功;没查到 → 兑换失败),其它状态只记录不动状态。
"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
result = collab.query_activity_batch(db, current, orders)
except collab.CollabError as exc:
raise _http_error(exc) from exc
for item in result["results"]:
item["status_label"] = collab.ORDER_STATUS_LABELS.get(
item.get("status", ""), item.get("status", "")
)
auto = item.get("auto_status") or ""
item["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto)
return result
@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}/activity/query",
response_model=HuyaExternalOrderActivityItemOut,
)
def query_external_order_activity(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:edit")),
):
"""查这一单的积分 + 兑换记录(单行「查询」按钮):写一条生命周期快照。
「已上脚本」的单按查询结果自动改状态:查到这一单的礼包 → 抢购成功;
接口通了但没查到 → 兑换失败;接口没通 → 不动(数据不够不许瞎判)。
"""
try:
order = collab.get_order(db, order_id)
result = collab.query_order_activity(db, current, order)
except collab.CollabError as exc:
raise _http_error(exc) from exc
result["status_label"] = collab.ORDER_STATUS_LABELS.get(
result.get("status", ""), result.get("status", "")
)
auto = result.get("auto_status") or ""
result["auto_status_label"] = collab.ORDER_STATUS_LABELS.get(auto, auto)
return result
@router.get(
"/external-orders/{order_id}/metrics", response_model=list[HuyaCollabMetricOut]
)
def list_external_order_metrics(
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
rows = collab.list_metrics(db, order)
names = collab.user_names(db, [row.created_by for row in rows])
return [collab.serialize_metric(row, users=names) for row in rows]
@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"]}