Files
live-hub-py/web/backend/routers/huya/collab.py
T
yml2213 21f7ee3f3f feat(huya): 礼包名默认空白,改用「常用礼包」备选按钮
新建协作单以前会把「上次选的礼包」自动填进礼包名(localStorage
huya.collab.lastGift)。连开几单同一个礼包是省事,但每开一单都顶着上一次的
选择,稍不留神就按错礼包建单;而且这个自动值会被草稿自动保存吃进去,
下次打开又「恢复」出来。

- 新建弹窗**不再预填礼包名**:删掉 lastGift 的读写,字段默认空白。
- 草稿恢复只带订单号 / 店铺 / CK / 截图 / 备注,礼包名留空;
  hasDraftContent() 也不再认「只有礼包名」的草稿(否则被旧逻辑自动填过的
  那份草稿会一直弹「已恢复上次未提交的草稿」)。
- 新增 GET /external-orders/gifts/frequent:按台账里的使用次数排序
  (次数相同看最近一次使用),新建与补资料弹窗在字段上方渲染最多 6 个按钮,
  只显示**当前礼包缓存里还有的**(活动换 SID 后老礼包会整批下架)。
- 新增 5 例测试,并做了 3 次反向验证(去掉次数排序 / 改成升序 / 去掉空名过滤)。

补资料弹窗的初值仍是「这一单自己的礼包」,详情抽屉编辑也不动 —— 那不是
「上次选的」。
2026-10-18 10:53:04 +08:00

755 lines
28 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
import asyncio
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
WebSocket,
WebSocketDisconnect,
)
from sqlalchemy.orm import Session
from ...database import get_db
from ...deps import authenticate_websocket, get_current_user, require_permission
from ...models import AuditLog, HuyaExternalOrder, User
from ...permissions import get_user_permissions
from ...schemas import (
HuyaCollabCkPreviewOut,
HuyaCollabCkPreviewRequest,
HuyaCollabFrequentGiftOut,
HuyaCollabMetricOut,
HuyaCollabShopCreate,
HuyaCollabShopOut,
HuyaCollabShopUpdate,
HuyaExternalOrderActivityItemOut,
HuyaExternalOrderActivityQueryOut,
HuyaExternalOrderActivityQueryRequest,
HuyaExternalOrderAuditOut,
HuyaExternalOrderCkExportOut,
HuyaExternalOrderCkExportRequest,
HuyaExternalOrderCkRequest,
HuyaExternalOrderCkStatusRequest,
HuyaExternalOrderCreate,
HuyaExternalOrderImagesRequest,
HuyaExternalOrderImportRequest,
HuyaExternalOrderListOut,
HuyaExternalOrderOut,
HuyaExternalOrderOwnerOut,
HuyaExternalOrderStatusBatchOut,
HuyaExternalOrderStatusBatchRequest,
HuyaExternalOrderStatusRequest,
HuyaExternalOrderSummaryOut,
HuyaExternalOrderUpdate,
HuyaExternalOrderVerifyOut,
HuyaExternalOrderVerifyRequest,
)
from ...services import huya_collab_service as collab
from ...services.huya_collab_events import collab_events
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])
views = collab.order_metric_views(db, [order])
return collab.serialize_order(
order,
users=collab.users_by_id(db, [order]),
buyer_cluster=clusters.get(int(order.id)),
metric_view=views.get(int(order.id)),
)
def _publish_change(current: User, *, scope: str, action: str) -> None:
"""写操作提交成功后发布失效事件。只带轻量信息,不推整行数据。"""
collab_events.publish(scope=scope, action=action, actor_id=int(current.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("/external-orders/gifts", response_model=list[str])
def external_orders_gifts(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""已用过的礼包/皮肤名(去重;筛选下拉用)。"""
return collab.list_gift_names(db)
@router.get(
"/external-orders/gifts/frequent",
response_model=list[HuyaCollabFrequentGiftOut],
)
def external_orders_frequent_gifts(
limit: int = Query(8, ge=1, le=20),
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""「常用礼包」:台账里用得最多的几个礼包名(新建/补资料时的快捷按钮)。
只回答"历史上哪些用得多";**当前活动还有没有这个礼包由前端拿礼包缓存比对**
—— 活动换 SID 后礼包列表会整批变,这里不该替前端猜。字号限制见 ``limit``。
"""
return collab.frequent_gift_names(db, limit=limit)
# --------------------------------------------------------------------------
# 店铺字典(新建协作单时点选;也支持自定义输入后自动登记)
# --------------------------------------------------------------------------
@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
_publish_change(current, scope="shops", action="shop.create")
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
_publish_change(current, scope="all", action="shop.update")
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
_publish_change(current, scope="shops", action="shop.delete")
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),
gift_name: str = Query("", max_length=128),
keyword: str = Query("", max_length=128),
include_void: bool = Query(False, description="是否包含已作废的行(默认隐藏)"),
sort_by: str = Query("seq", max_length=32, description="排序字段(白名单)"),
sort_order: str = Query("asc", max_length=8, description="asc / desc"),
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,
gift_name=gift_name,
keyword=keyword,
include_void=include_void,
sort_by=sort_by,
sort_order=sort_order,
page=page,
page_size=page_size,
)
names = collab.users_by_id(db, rows)
# 「同一买家的其它单」+「最近一次积分/兑换快照」整页一次算完,别按行发 SQL
clusters = collab.buyer_clusters(db, rows)
views = collab.order_metric_views(db, rows)
return {
"items": [
collab.serialize_order(
row,
users=names,
buyer_cluster=clusters.get(int(row.id)),
metric_view=views.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。
``draft=true``:先建单后补资料 —— 订单号 + 店铺即可,状态进「待补资料」,
CK / 礼包名 / 主页截图稍后在详情里补(不跑探针)。
``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,
draft=req.draft,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="order.create")
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()
}
result = collab.import_orders(db, current, req.text, owner_by_name=owners)
if result.get("created") or result.get("updated"):
_publish_change(current, scope="orders", action="order.import")
return result
@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)
_publish_change(current, scope="orders", action="orders.status_batch")
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")),
):
"""勾选多行查积分 / 兑换记录(**串行带间隔**,防风控)。
``scope`` 决定查哪一半(前端是两个独立按钮,见 2026-10-17 需求):
``both`` 两个接口都查、``score`` 只查积分、``records`` 只查兑换记录。
每行都会落一条生命周期快照;**只有本次查了兑换记录**且结论明确时,
「已上脚本」的单才会自动改状态(查到礼包 → 抢购成功;没查到 → 抢购失败),
其它情况只记录不动状态 —— 只查积分时连判定都没有,更不该改状态。
"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
result = collab.query_activity_batch(db, current, orders, scope=req.scope)
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)
# 空判定(只查积分)= 「未判定」,与「无法判定」区分开(见 VERDICT_LABELS)
verdict = item.get("verdict") or ""
item["verdict_label"] = collab.VERDICT_LABELS.get(verdict, verdict)
_publish_change(current, scope="orders", action="orders.activity_query")
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)
result = collab.verify_ck_batch(db, current, orders)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="orders", action="orders.ck_verify")
return result
@router.post("/external-orders/ck/export", response_model=HuyaExternalOrderCkExportOut)
def export_external_orders_ck(
req: HuyaExternalOrderCkExportRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:ck_export")),
):
"""勾选多行导出 **CK 明文**(前端写进剪贴板)。
单独一个权限点 ``huya:collab:ck_export``(默认只给运营):贴 CK 是「录入」,
导出是「把凭据带走」。逐行写审计(``huya:collab:ck_export``),但**只记单号**,
不把 CK 本体写进审计;没 CK 的行在 ``skipped`` 里显式回原因。
"""
try:
orders = collab.list_orders_by_ids(db, req.ids)
return collab.export_cks(db, current, orders)
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.post(
"/external-orders/{order_id}/ck/copy",
response_model=HuyaExternalOrderCkExportOut,
)
def copy_external_order_ck(
order_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""复制单行 CK 明文(**仅超级管理员**;前端直接写剪贴板)。
这个入口比勾选导出更敏感:它把「单行凭据」做成一键复制,因此只给管理员。
仍然复用 :func:`collab.export_cks`:逐行写 ``huya:collab:ck_export`` 审计,
但审计里只记单号,不写 CK 本体。
"""
if current.role != "super_admin":
raise HTTPException(status_code=403, detail="仅超级管理员可复制 CK")
try:
order = collab.get_order(db, order_id)
return collab.export_cks(db, current, [order])
except collab.CollabError as exc:
raise _http_error(exc) from exc
@router.websocket("/external-orders/ws")
async def ws_external_orders(websocket: WebSocket):
"""协作台账实时失效通知:客户端收到后按自己的筛选静默重拉。"""
user = authenticate_websocket(websocket)
if not user or "huya:collab:view" not in get_user_permissions(user):
await websocket.close(code=1008, reason="未授权")
return
await websocket.accept()
queue = collab_events.subscribe()
try:
await websocket.send_json(collab_events.ready_event())
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=25)
await websocket.send_json(event)
except TimeoutError:
await websocket.send_json(collab_events.ping_event())
except (WebSocketDisconnect, RuntimeError):
pass
finally:
collab_events.unsubscribe(queue)
# --------------------------------------------------------------------------
# 单行操作
# --------------------------------------------------------------------------
@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
_publish_change(current, scope="orders", action="order.update")
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
_publish_change(current, scope="orders", action="order.status")
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)
_publish_change(current, scope="orders", action="order.activity_query")
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
_publish_change(current, scope="orders", action="order.ck")
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
_publish_change(current, scope="orders", action="order.ck_status")
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
_publish_change(current, scope="orders", action="order.ck_verify")
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
_publish_change(current, scope="orders", action="order.images")
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
_publish_change(current, scope="orders", action="order.delete")
return {"success": True, "order_no": snapshot["order_no"]}