新建协作单以前会把「上次选的礼包」自动填进礼包名(localStorage huya.collab.lastGift)。连开几单同一个礼包是省事,但每开一单都顶着上一次的 选择,稍不留神就按错礼包建单;而且这个自动值会被草稿自动保存吃进去, 下次打开又「恢复」出来。 - 新建弹窗**不再预填礼包名**:删掉 lastGift 的读写,字段默认空白。 - 草稿恢复只带订单号 / 店铺 / CK / 截图 / 备注,礼包名留空; hasDraftContent() 也不再认「只有礼包名」的草稿(否则被旧逻辑自动填过的 那份草稿会一直弹「已恢复上次未提交的草稿」)。 - 新增 GET /external-orders/gifts/frequent:按台账里的使用次数排序 (次数相同看最近一次使用),新建与补资料弹窗在字段上方渲染最多 6 个按钮, 只显示**当前礼包缓存里还有的**(活动换 SID 后老礼包会整批下架)。 - 新增 5 例测试,并做了 3 次反向验证(去掉次数排序 / 改成升序 / 去掉空名过滤)。 补资料弹窗的初值仍是「这一单自己的礼包」,详情抽屉编辑也不动 —— 那不是 「上次选的」。
755 lines
28 KiB
Python
755 lines
28 KiB
Python
"""虎牙外部 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"]}
|