feat(huya): 增加协作单礼包字典与名称编辑
This commit is contained in:
@@ -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)
|
||||
@@ -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 多人协同表)。
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 现拉,不落库)。"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user