feat(huya): 增加协作单礼包字典与名称编辑

This commit is contained in:
yml2213
2026-11-07 16:54:01 +08:00
parent afcbff2207
commit efa8bf2a46
12 changed files with 1183 additions and 44 deletions
@@ -0,0 +1,51 @@
"""虎牙外部单协作的礼包字典。
虎牙实时礼包接口可能拿不到活动商品,历史礼包名也会随活动下线。这里维护一份
管理员可控的标准名称与积分:新建 / 补资料时可直接选择。
订单里仍存礼包名字符串(``huya_external_orders.gift_name``),不是 id。
改名会级联更新历史订单上的礼包名;改积分只影响之后新建或换礼包时的默认值,
不回填历史订单的 ``required_points`` 快照。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261107_0067"
down_revision: str | None = "20261106_0066"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "huya_collab_gifts"
def upgrade() -> None:
bind = op.get_bind()
if TABLE in set(sa.inspect(bind).get_table_names()):
return
op.create_table(
TABLE,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("points", sa.Integer(), nullable=False, server_default="0"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("remark", sa.String(length=255), nullable=False, server_default=""),
sa.Column("created_by", sa.Integer(), nullable=True),
sa.Column("updated_by", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
)
op.create_index(f"ix_{TABLE}_name", TABLE, ["name"], unique=True)
op.create_index(f"ix_{TABLE}_is_active", TABLE, ["is_active"])
def downgrade() -> None:
bind = op.get_bind()
if TABLE in set(sa.inspect(bind).get_table_names()):
# MySQL 删表会连索引一起删。
op.drop_table(TABLE)
+33
View File
@@ -1423,6 +1423,39 @@ class HuyaCollabShop(Base):
)
class HuyaCollabGiftCatalog(Base):
"""虎牙外部单协作的礼包字典(管理员维护标准名称与所需积分)。
订单里仍存礼包名字符串;改名会级联历史订单,积分只作为之后新建/换礼包时的
默认值,不回填历史订单的 ``required_points`` 快照。
"""
__tablename__ = "huya_collab_gifts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(
String(128), unique=True, nullable=False, index=True
)
points: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False
)
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="1", index=True
)
remark: Mapped[str] = mapped_column(String(255), default="", server_default="")
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
updated_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class HuyaExternalOrder(Base):
"""虎牙外部 CK 协作台账(替代 Excel 多人协同表)。
+82
View File
@@ -37,6 +37,9 @@ from ...schemas import (
HuyaCollabCkPreviewOut,
HuyaCollabCkPreviewRequest,
HuyaCollabFrequentGiftOut,
HuyaCollabGiftCatalogCreate,
HuyaCollabGiftCatalogOut,
HuyaCollabGiftCatalogUpdate,
HuyaCollabMetricOut,
HuyaCollabShopCreate,
HuyaCollabShopOut,
@@ -97,6 +100,11 @@ def _publish_change(current: User, *, scope: str, action: str) -> None:
collab_events.publish(scope=scope, action=action, actor_id=int(current.id))
def _gift_out(db: Session, gift) -> dict:
names = collab.user_names(db, [gift.created_by, gift.updated_by])
return collab.serialize_gift(gift, users=names)
# --------------------------------------------------------------------------
# 字面量路径必须声明在 /{order_id} 之前,否则 "summary" 会被当成 id
# --------------------------------------------------------------------------
@@ -239,6 +247,80 @@ def delete_collab_shop(
return {"success": True, "name": snapshot["name"]}
@router.get("/collab-gifts", response_model=list[HuyaCollabGiftCatalogOut])
def list_collab_gifts(
db: Session = Depends(get_db),
_current: User = Depends(require_permission("huya:collab:view")),
):
"""全部礼包字典项(含停用的:管理弹窗要看到;新建时前端按 is_active 过滤)。"""
gifts = collab.list_gift_records(db)
names = collab.user_names(
db,
[gift.created_by for gift in gifts] + [gift.updated_by for gift in gifts],
)
return [collab.serialize_gift(gift, users=names) for gift in gifts]
@router.post("/collab-gifts", response_model=HuyaCollabGiftCatalogOut)
def create_collab_gift(
req: HuyaCollabGiftCatalogCreate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:manage")),
):
"""新增标准礼包(重名 409;写操作仅管理员)。"""
try:
gift = collab.create_gift(
db,
current,
name=req.name,
points=req.points,
sort_order=req.sort_order,
remark=req.remark,
)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="gifts", action="gift.create")
return _gift_out(db, gift)
@router.put("/collab-gifts/{gift_id}", response_model=HuyaCollabGiftCatalogOut)
def update_collab_gift(
gift_id: int,
req: HuyaCollabGiftCatalogUpdate,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:manage")),
):
"""改礼包字典;改名会同步所有历史协作单上的礼包名。"""
try:
gift = collab.get_gift(db, gift_id)
collab.update_gift(db, current, gift, req.model_dump(exclude_unset=True))
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="all", action="gift.update")
return _gift_out(db, gift)
@router.delete("/collab-gifts/{gift_id}")
def delete_collab_gift(
gift_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:collab:manage")),
):
"""删除未被历史订单引用的字典项;仍在使用时改为停用。"""
try:
gift = collab.get_gift(db, gift_id)
result = collab.delete_gift(db, current, gift)
except collab.CollabError as exc:
raise _http_error(exc) from exc
_publish_change(current, scope="gifts", action="gift.delete")
return {
"success": True,
"id": gift_id,
"name": result["name"],
"points": result["points"],
}
@router.get("/external-orders", response_model=HuyaExternalOrderListOut)
def list_external_orders(
shop: str = Query("", max_length=64),
+34
View File
@@ -1184,6 +1184,40 @@ class HuyaCollabShopOut(BaseModel):
updated_at: datetime | None = None
class HuyaCollabGiftCatalogCreate(BaseModel):
"""新增虎牙协作礼包字典项。"""
name: str = Field(..., min_length=1, max_length=128)
points: int = Field(..., ge=0)
sort_order: int = 0
remark: str = Field("", max_length=255)
class HuyaCollabGiftCatalogUpdate(BaseModel):
"""修改礼包名称 / 积分 / 排序 / 启停 / 备注(只提交要改的字段)。"""
name: str | None = Field(None, min_length=1, max_length=128)
points: int | None = Field(None, ge=0)
sort_order: int | None = None
is_active: bool | None = None
remark: str | None = Field(None, max_length=255)
class HuyaCollabGiftCatalogOut(BaseModel):
"""礼包字典项(与实时活动礼包 ``HuyaCollabGiftOut`` 分开)。"""
id: int
name: str
points: int = 0
sort_order: int = 0
is_active: bool = True
remark: str = ""
created_by_name: str = ""
updated_by_name: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
# ---- 虎牙外部 CK 协作台账(见 docs/设计-虎牙外部CK协作台账.md) ----
class HuyaCollabGiftOut(BaseModel):
"""活动礼包(用一份有效 CK 现拉,不落库)。"""
+271 -2
View File
@@ -36,6 +36,7 @@ from ..huya_defaults import (
HUYA_DEFAULT_SID,
)
from ..models import (
HuyaCollabGiftCatalog,
HuyaCollabShop,
HuyaConfig,
HuyaExternalOrder,
@@ -2579,6 +2580,7 @@ def export_cks(
# --------------------------------------------------------------------------
SHOP_NAME_LIMIT = 64
GIFT_NAME_LIMIT = 128
def normalize_shop_name(value: Any) -> str:
@@ -2831,6 +2833,257 @@ def list_shops(db: Session) -> list[str]:
return sorted(shop.name for shop in list_shop_records(db))
def _gift_audit_target(gift: HuyaCollabGiftCatalog) -> str:
return f"huya_collab_gift:{gift.id}"
def _touch_gift(gift: HuyaCollabGiftCatalog, current: User | None) -> None:
gift.updated_by = current.id if current else None
gift.updated_at = _now()
def _clean_catalog_points(value: Any) -> int:
points = _clean_required_points(value)
if points is None:
raise CollabError("礼包所需积分不能为空", code="gift_points_invalid")
return points
def _catalog_points_for_name(db: Session, name: str) -> int | None:
clean = str(name or "").strip()
if not clean:
return None
gift = (
db.query(HuyaCollabGiftCatalog)
.filter(HuyaCollabGiftCatalog.name == clean)
.first()
)
return int(gift.points or 0) if gift is not None else None
def serialize_gift(
gift: HuyaCollabGiftCatalog, *, users: Mapping[int, str] | None = None
) -> dict:
names = users or {}
def name_of(value: int | None) -> str:
return names.get(int(value), "") if value else ""
return {
"id": gift.id,
"name": gift.name or "",
"points": int(gift.points or 0),
"sort_order": int(gift.sort_order or 0),
"is_active": bool(gift.is_active),
"remark": gift.remark or "",
"created_by_name": name_of(gift.created_by),
"updated_by_name": name_of(gift.updated_by),
"created_at": gift.created_at,
"updated_at": gift.updated_at,
}
def list_gift_records(db: Session) -> list[HuyaCollabGiftCatalog]:
"""全部礼包字典项(含停用;前端按 ``is_active`` 决定新建时是否可选)。"""
return (
db.query(HuyaCollabGiftCatalog)
.order_by(
HuyaCollabGiftCatalog.sort_order.asc(),
HuyaCollabGiftCatalog.name.asc(),
)
.all()
)
def get_gift(db: Session, gift_id: int) -> HuyaCollabGiftCatalog:
gift = (
db.query(HuyaCollabGiftCatalog)
.filter(HuyaCollabGiftCatalog.id == gift_id)
.first()
)
if gift is None:
raise CollabError(
"这个礼包不存在(可能已被别人删除)",
status_code=404,
code="gift_not_found",
)
return gift
def _assert_gift_name_available(
db: Session, name: str, *, exclude_id: int | None = None
) -> None:
query = db.query(HuyaCollabGiftCatalog).filter(HuyaCollabGiftCatalog.name == name)
if exclude_id is not None:
query = query.filter(HuyaCollabGiftCatalog.id != exclude_id)
other = query.first()
if other is not None:
raise CollabError(
f"礼包「{name}」已经在字典里了(第 {other.id} 行)",
status_code=409,
code="gift_name_conflict",
)
def create_gift(
db: Session,
current: User | None,
*,
name: Any,
points: Any,
sort_order: int = 0,
remark: Any = "",
commit: bool = True,
) -> HuyaCollabGiftCatalog:
clean_name = clean_text(name, field="gift_name", max_length=GIFT_NAME_LIMIT)
if not clean_name:
raise CollabError("礼包名不能为空", code="gift_name_required")
_assert_gift_name_available(db, clean_name)
gift = HuyaCollabGiftCatalog(
name=clean_name,
points=_clean_catalog_points(points),
sort_order=int(sort_order or 0),
is_active=True,
remark=clean_text(remark, field="gift_remark", max_length=255),
created_by=current.id if current else None,
updated_by=current.id if current else None,
)
db.add(gift)
db.flush()
record_audit(
db,
current,
action="huya:collab:gift_create",
target=_gift_audit_target(gift),
detail={
"name": gift.name,
"points": gift.points,
"sort_order": gift.sort_order,
},
)
if commit:
db.commit()
db.refresh(gift)
return gift
def update_gift(
db: Session,
current: User | None,
gift: HuyaCollabGiftCatalog,
payload: Mapping[str, Any],
*,
commit: bool = True,
) -> dict:
"""修改礼包字典,返回字段级 diff。
改名会级联所有历史订单上的 ``gift_name``;积分只改字典,不回填订单里
已经形成的 ``required_points`` 快照。
"""
changes: dict[str, dict[str, Any]] = {}
old_name = gift.name or ""
renamed_orders = 0
if "name" in payload:
new_name = clean_text(
payload["name"], field="gift_name", max_length=GIFT_NAME_LIMIT
)
if not new_name:
raise CollabError("礼包名不能为空", code="gift_name_required")
if new_name != old_name:
_assert_gift_name_available(db, new_name, exclude_id=gift.id)
changes["name"] = {"old": old_name, "new": new_name}
gift.name = new_name
if "points" in payload:
value = _clean_catalog_points(payload["points"])
if value != int(gift.points or 0):
changes["points"] = {"old": int(gift.points or 0), "new": value}
gift.points = value
if "sort_order" in payload:
value = int(payload["sort_order"] or 0)
if value != int(gift.sort_order or 0):
changes["sort_order"] = {"old": int(gift.sort_order or 0), "new": value}
gift.sort_order = value
if "is_active" in payload:
value = bool(payload["is_active"])
if value != bool(gift.is_active):
changes["is_active"] = {"old": bool(gift.is_active), "new": value}
gift.is_active = value
if "remark" in payload:
value = clean_text(payload["remark"], field="gift_remark", max_length=255)
if value != (gift.remark or ""):
changes["remark"] = {"old": gift.remark or "", "new": value}
gift.remark = value
if not changes:
return {}
if "name" in changes:
renamed_orders = (
db.query(HuyaExternalOrder)
.filter(HuyaExternalOrder.gift_name == old_name)
.update(
{HuyaExternalOrder.gift_name: changes["name"]["new"]},
synchronize_session=False,
)
)
_touch_gift(gift, current)
record_audit(
db,
current,
action="huya:collab:gift_update",
target=_gift_audit_target(gift),
detail={"changes": changes, "renamed_orders": renamed_orders},
)
if commit:
db.commit()
db.refresh(gift)
return changes
def delete_gift(
db: Session,
current: User | None,
gift: HuyaCollabGiftCatalog,
*,
commit: bool = True,
) -> dict:
"""删除未被订单引用的字典项;仍在使用时明确拒绝,避免历史名称失去标准口径。"""
gift_name = gift.name or ""
used = (
db.query(func.count(HuyaExternalOrder.id))
.filter(HuyaExternalOrder.gift_name == gift_name)
.scalar()
or 0
)
if used:
raise CollabError(
f"还有 {used} 个协作单使用「{gift_name}」,请先停用或改名",
code="gift_in_use",
)
snapshot = {
"id": gift.id,
"name": gift_name,
"points": int(gift.points or 0),
}
record_audit(
db,
current,
action="huya:collab:gift_delete",
target=_gift_audit_target(gift),
detail=snapshot,
)
db.delete(gift)
if commit:
db.commit()
return snapshot
def list_gift_names(db: Session) -> list[str]:
"""台账里出现过的礼包/皮肤名(去重、去空;筛选下拉用)。"""
rows = db.query(HuyaExternalOrder.gift_name).distinct().all()
@@ -2935,13 +3188,20 @@ def create_order(
# 草稿单不验 CK、不跑探针:先落一行占住订单号,资料到了在详情里补。
require_verified = False
clean_gift_name = clean_text(
gift_name, field="gift_name", max_length=GIFT_NAME_LIMIT
)
clean_required_points = _clean_required_points(required_points)
if clean_gift_name and clean_required_points is None:
clean_required_points = _catalog_points_for_name(db, clean_gift_name)
order = HuyaExternalOrder(
order_no=order_no,
shop=clean_text(shop, field="shop"),
owner_id=_clean_owner_id(owner_id) if owner_id is not None else current.id,
bind_douyu_account=clean_text(bind_douyu_account, field="bind_douyu_account"),
gift_name=clean_text(gift_name, field="gift_name"),
required_points=_clean_required_points(required_points),
gift_name=clean_gift_name,
required_points=clean_required_points,
remark=clean_text(remark, field="remark"),
bind_images=[],
home_images=home_images_keys,
@@ -3077,6 +3337,15 @@ def update_order(
# 改成字典里没有的名字也允许(自定义),顺手登记,筛选下拉不会漏。
ensure_shops_registered(db, current, [changes["shop"]["new"]])
if "gift_name" in changes and "required_points" not in payload:
catalog_points = _catalog_points_for_name(db, changes["gift_name"]["new"])
if catalog_points is not None and catalog_points != order.required_points:
changes["required_points"] = {
"old": order.required_points,
"new": catalog_points,
}
order.required_points = catalog_points
# 先记「人工改了什么」,再跑自动状态迁移:否则时间线会出现
# status_auto 排在 update 前面,像状态是被后一次操作改的。
record_audit(