diff --git a/docs/设计-虎牙外部CK协作台账.md b/docs/设计-虎牙外部CK协作台账.md index 09e0ca7..4e38f8b 100644 --- a/docs/设计-虎牙外部CK协作台账.md +++ b/docs/设计-虎牙外部CK协作台账.md @@ -1051,6 +1051,30 @@ unverified ──探针成功──▶ valid ──探针失败──▶ invalid 2. **不要让人工直写 `recharged` / `points_error`**:这两个状态必须由核账产生,否则积分快照与状态会各说各话。 3. **换 CK 不等于资料缺失**:`set_ck()` 只重置 `ck_status`;核账会强制重验,若绑定账号/角色变了整组报错,不会误落旧组。 +### 11.13 2026-11-07 第十轮:礼包名称字典与历史改名级联 + +需求原文:"斗鱼的外部协作单增加了一个礼包名称编辑功能,虎牙也需要这个功能。" + +虎牙原来只有「实时活动礼包缓存」和台账历史名:活动接口拉不到、旧礼包下线或名称录错时, +没有管理员可维护的标准口径。现在新增与斗鱼同级的礼包字典,同时保留实时礼包这条旧路径。 + +| 面 | 落地 | +| --- | --- | +| 数据模型 | 迁移 `20261107_0067`:`huya_collab_gifts`(名称唯一 / 所需积分 / 排序 / 启停 / 备注 / 操作人)。订单仍存礼包名字符串,不改成外键 | +| 后端 | `GET/POST /huya/collab-gifts`、`PUT/DELETE /huya/collab-gifts/{gift_id}`;查看走 `huya:collab:view`,写操作走 `huya:collab:manage` | +| 改名 | 字典改名会级联所有历史订单的 `gift_name`(含归档行),避免筛选里同时出现新旧两个名字;审计记录字段 diff 与实际同步行数 | +| 积分边界 | 改字典积分**不回填**历史订单的 `required_points` 快照;只影响之后新建或换礼包时自动带出的值 | +| 删除 | 仍有订单使用该礼包名时拒绝删除并提示改用停用;未被引用的字典行可删 | +| 前端 | 工具栏新增「礼包管理」;新建、补资料、详情和表格内联选择统一合并「实时活动礼包 + 启用的标准字典」,实时接口为空时仍可选字典项 | +| 保留旧路径 | 实时礼包缓存、强制刷新礼包、常用礼包按钮和现有订单历史值都保留;实时价格优先,缺失时回落到字典积分 | + +**四条边界**: + +1. **字典是标准口径,不是库存快照**:活动接口返回的实时价格 / 库存仍以接口为准,字典只补名称与积分兜底。 +2. **改名必须级联**:订单里存的是名称字符串;只改字典会让同一礼包在筛选和历史单里分成两个值。 +3. **积分不追改历史**:`required_points` 是下单时的核账快照,改字典不能改变已经完成的充值核账依据。 +4. **删不掉的礼包用停用**:停用只影响新选择,不隐藏历史订单,也不会让旧数据失去可读名称。 + --- ## 12. 风险 diff --git a/tests/test_huya_collab_gifts.py b/tests/test_huya_collab_gifts.py new file mode 100644 index 0000000..d087f8f --- /dev/null +++ b/tests/test_huya_collab_gifts.py @@ -0,0 +1,185 @@ +"""虎牙协作礼包字典:CRUD、改名级联、积分快照边界与权限。""" + +from __future__ import annotations + +import json + +import pytest + +from tests.support.db import get_test_engine, make_test_session +from web.backend.models import AuditLog, HuyaCollabGiftCatalog, HuyaExternalOrder, User +from web.backend.permissions import ROLE_PERMISSIONS +from web.backend.routers.huya import collab as collab_api +from web.backend.schemas import HuyaCollabGiftCatalogOut +from web.backend.services import huya_collab_service as collab + + +class TestHuyaCollabGiftCatalog: + def setup_method(self): + self.engine = get_test_engine() + self.db = make_test_session(self.engine) + self.user = User(username="gift_op", password_hash="h", role="operation") + self.support = User(username="gift_support", password_hash="h", role="support") + self.db.add_all([self.user, self.support]) + self.db.commit() + + def teardown_method(self): + self.db.close() + + def _gift(self, name: str, points: int = 8000, **kwargs) -> HuyaCollabGiftCatalog: + return collab.create_gift( + self.db, self.user, name=name, points=points, **kwargs + ) + + def _order(self, order_no: str, gift_name: str = "", **kwargs) -> HuyaExternalOrder: + return collab.create_order( + self.db, + self.user, + order_no=order_no, + gift_name=gift_name, + **kwargs, + ) + + def _audits(self, gift: HuyaCollabGiftCatalog) -> list[AuditLog]: + return ( + self.db.query(AuditLog) + .filter(AuditLog.target == f"huya_collab_gift:{gift.id}") + .order_by(AuditLog.id.asc()) + .all() + ) + + def test_create_list_and_serialize(self): + self._gift("乙礼包", points=900, sort_order=2) + self._gift("甲礼包", points=800, sort_order=1) + + rows = collab.list_gift_records(self.db) + + assert [row.name for row in rows] == ["甲礼包", "乙礼包"] + dumped = HuyaCollabGiftCatalogOut.model_validate( + collab.serialize_gift( + rows[0], users={int(self.user.id): self.user.username} + ) + ).model_dump() + assert dumped["name"] == "甲礼包" + assert dumped["points"] == 800 + assert dumped["is_active"] is True + assert dumped["created_by_name"] == self.user.username + assert [row.action for row in self._audits(rows[0])] == [ + "huya:collab:gift_create" + ] + + def test_create_rejects_duplicate_blank_and_invalid_points(self): + self._gift("甲礼包") + + with pytest.raises(collab.CollabError) as duplicate: + self._gift(" 甲礼包 ") + assert duplicate.value.status_code == 409 + assert duplicate.value.code == "gift_name_conflict" + + with pytest.raises(collab.CollabError) as blank: + self._gift(" ") + assert blank.value.code == "gift_name_required" + + with pytest.raises(collab.CollabError) as invalid: + self._gift("乙礼包", points="not-a-number") + assert invalid.value.code == "required_points_invalid" + + def test_create_order_uses_catalog_points_when_points_are_omitted(self): + gift = self._gift("标准礼包", points=4321) + + order = self._order("GIFT-POINTS-1", gift_name=gift.name) + + assert order.required_points == 4321 + + def test_changing_order_gift_name_uses_new_catalog_points(self): + first = self._gift("礼包A", points=200) + second = self._gift("礼包B", points=300) + order = self._order("GIFT-POINTS-2", gift_name=first.name) + + changes = collab.update_order( + self.db, self.user, order, {"gift_name": second.name} + ) + + assert changes["required_points"] == {"old": 200, "new": 300} + assert order.required_points == 300 + + def test_rename_cascades_but_points_change_does_not_backfill_orders(self): + gift = self._gift("旧礼包名", points=8000) + order = self._order("GIFT-RENAME-1", gift_name=gift.name) + + changes = collab.update_gift( + self.db, self.user, gift, {"name": "新礼包名", "points": 9000} + ) + + self.db.refresh(order) + assert changes["name"] == {"old": "旧礼包名", "new": "新礼包名"} + assert changes["points"] == {"old": 8000, "new": 9000} + assert order.gift_name == "新礼包名" + assert order.required_points == 8000 + audit = self._audits(gift)[-1] + assert json.loads(audit.detail)["renamed_orders"] == 1 + + def test_delete_is_blocked_while_orders_use_the_gift(self): + gift = self._gift("在用的礼包") + self._order("GIFT-DELETE-1", gift_name=gift.name) + + with pytest.raises(collab.CollabError) as excinfo: + collab.delete_gift(self.db, self.user, gift) + + assert excinfo.value.code == "gift_in_use" + assert self.db.query(HuyaCollabGiftCatalog).count() == 1 + + def test_unused_gift_can_be_deleted_with_audit(self): + gift = self._gift("可删礼包") + + snapshot = collab.delete_gift(self.db, self.user, gift) + + assert snapshot["name"] == "可删礼包" + assert self.db.query(HuyaCollabGiftCatalog).count() == 0 + audit = ( + self.db.query(AuditLog) + .filter(AuditLog.action == "huya:collab:gift_delete") + .one() + ) + assert "可删礼包" in audit.detail + + def test_inactive_gift_stays_in_catalog_for_management(self): + gift = self._gift("准备停用") + + collab.update_gift(self.db, self.user, gift, {"is_active": False}) + + rows = collab.list_gift_records(self.db) + assert len(rows) == 1 + assert rows[0].is_active is False + + def test_write_permission_is_operation_only_by_default(self): + assert "huya:collab:manage" in ROLE_PERMISSIONS["operation"] + assert "huya:collab:manage" not in ROLE_PERMISSIONS["support"] + assert "huya:collab:manage" in ROLE_PERMISSIONS["super_admin"] + + def test_routes_are_registered(self): + from web.backend.main import app + + paths = app.openapi()["paths"] + assert "/api/huya/collab-gifts" in paths + assert "get" in paths["/api/huya/collab-gifts"] + assert "post" in paths["/api/huya/collab-gifts"] + assert "/api/huya/collab-gifts/{gift_id}" in paths + assert "put" in paths["/api/huya/collab-gifts/{gift_id}"] + assert "delete" in paths["/api/huya/collab-gifts/{gift_id}"] + + def test_router_response_keeps_all_catalog_fields(self): + gift = self._gift("接口礼包", points=6600, remark="按兑换记录标准名") + + payload = collab_api.create_collab_gift( + req=collab_api.HuyaCollabGiftCatalogCreate(name="另一个礼包", points=7700), + db=self.db, + current=self.user, + ) + + assert payload["name"] == "另一个礼包" + assert payload["points"] == 7700 + assert payload["created_by_name"] == self.user.username + listed = collab_api.list_collab_gifts(db=self.db, _current=self.user) + by_name = {item["name"]: item for item in listed} + assert by_name[gift.name]["remark"] == "按兑换记录标准名" diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 65b000d..1d3fcc0 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261106_0066" +HEAD_REVISION = "20261107_0067" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/migrations/versions/20261107_0067_huya_collab_gifts.py b/web/backend/migrations/versions/20261107_0067_huya_collab_gifts.py new file mode 100644 index 0000000..7ab8e2d --- /dev/null +++ b/web/backend/migrations/versions/20261107_0067_huya_collab_gifts.py @@ -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) diff --git a/web/backend/models.py b/web/backend/models.py index 0339e90..bc71278 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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 多人协同表)。 diff --git a/web/backend/routers/huya/collab.py b/web/backend/routers/huya/collab.py index eccfdb8..33e0424 100644 --- a/web/backend/routers/huya/collab.py +++ b/web/backend/routers/huya/collab.py @@ -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), diff --git a/web/backend/schemas.py b/web/backend/schemas.py index e1b8518..660021f 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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 现拉,不落库)。""" diff --git a/web/backend/services/huya_collab_service.py b/web/backend/services/huya_collab_service.py index b369f7d..93af418 100644 --- a/web/backend/services/huya_collab_service.py +++ b/web/backend/services/huya_collab_service.py @@ -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( diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts index d547d07..7a05c11 100644 --- a/web/frontend/src/api/huya.ts +++ b/web/frontend/src/api/huya.ts @@ -43,6 +43,9 @@ import type { HuyaCollabCkRequest, HuyaCollabCkStatusRequest, HuyaCollabFrequentGift, + HuyaCollabGiftCatalog, + HuyaCollabGiftCatalogCreate, + HuyaCollabGiftCatalogUpdate, HuyaCollabImagesRequest, HuyaCollabImportResult, HuyaCollabMetric, @@ -243,6 +246,19 @@ export const huyaApi = { api.delete<{ success: boolean; name: string }, { success: boolean; name: string }>( `/huya/collab-shops/${id}`, ), + + // ---- 礼包字典(标准名称 + 所需积分;名称编辑会级联历史协作单)---- + listCollabGiftCatalog: () => + api.get('/huya/collab-gifts'), + createCollabGift: (data: HuyaCollabGiftCatalogCreate) => + api.post('/huya/collab-gifts', data), + updateCollabGift: (id: number, data: HuyaCollabGiftCatalogUpdate) => + api.put(`/huya/collab-gifts/${id}`, data), + deleteCollabGift: (id: number) => + api.delete< + { success: boolean; id: number; name: string; points: number }, + { success: boolean; id: number; name: string; points: number } + >(`/huya/collab-gifts/${id}`), getCollabOrder: (id: number) => api.get(`/huya/external-orders/${id}`), createCollabOrder: (data: HuyaCollabOrderCreate) => diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 421a4eb..202b346 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -1824,3 +1824,33 @@ export interface HuyaCollabShopUpdate { /** 改名时是否级联更新已有订单里的店铺名(默认 true) */ rename_orders?: boolean; } + +/** 协作台账的礼包字典项(与实时活动礼包 HuyaCollabGift 分开) */ +export interface HuyaCollabGiftCatalog { + id: number; + name: string; + points: number; + sort_order: number; + /** false = 停用(不再出现在可选项里;已有订单仍显示原值) */ + is_active: boolean; + remark: string; + created_by_name: string; + updated_by_name: string; + created_at: string | null; + updated_at: string | null; +} + +export interface HuyaCollabGiftCatalogCreate { + name: string; + points: number; + sort_order?: number; + remark?: string; +} + +export interface HuyaCollabGiftCatalogUpdate { + name?: string; + points?: number; + sort_order?: number; + is_active?: boolean; + remark?: string; +} diff --git a/web/frontend/src/components/HuyaCollabGiftModal.tsx b/web/frontend/src/components/HuyaCollabGiftModal.tsx new file mode 100644 index 0000000..7741e9b --- /dev/null +++ b/web/frontend/src/components/HuyaCollabGiftModal.tsx @@ -0,0 +1,368 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Col, + Empty, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Space, + Switch, + Table, + Tag, + Tooltip, + Typography, +} from 'antd'; +import type { TableProps } from 'antd'; +import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'; +import { huyaApi, type HuyaCollabGiftCatalog } from '../api/modules'; +import { message } from '../utils/antdMessage'; +import { getErrorMessage } from '../utils/error'; + +const { Text } = Typography; + +/** + * 虎牙协作礼包字典:管理员维护标准名称和所需积分,客服只能选择。 + * + * - 停用后不再出现在新建下拉里,历史订单继续显示原值; + * - 改名会同步所有历史订单上的礼包名; + * - 改积分只影响之后新建 / 换礼包时的默认值,不回填历史积分快照; + * - 删除只删字典行,仍有订单使用时会拒绝。 + */ +export default function HuyaCollabGiftModal({ + open, + gifts, + canManage, + onClose, + onChanged, +}: { + open: boolean; + gifts: HuyaCollabGiftCatalog[]; + canManage: boolean; + onClose: () => void; + onChanged: () => void; +}) { + const [newName, setNewName] = useState(''); + const [newPoints, setNewPoints] = useState(null); + const [busy, setBusy] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [editName, setEditName] = useState(''); + const [editPoints, setEditPoints] = useState(null); + const [editRemark, setEditRemark] = useState(''); + + useEffect(() => { + if (!open) { + setNewName(''); + setNewPoints(null); + setEditTarget(null); + } + }, [open]); + + const create = async () => { + const name = newName.trim(); + if (!name) { + message.warning('请先输入礼包名'); + return; + } + if (newPoints === null) { + message.warning('请填写礼包所需积分'); + return; + } + setBusy(true); + try { + await huyaApi.createCollabGift({ name, points: newPoints }); + message.success(`已添加礼包「${name}」`); + setNewName(''); + setNewPoints(null); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }; + + const patch = async ( + gift: HuyaCollabGiftCatalog, + payload: { sort_order?: number; is_active?: boolean; remark?: string }, + ) => { + setBusy(true); + try { + await huyaApi.updateCollabGift(gift.id, payload); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + onChanged(); + } finally { + setBusy(false); + } + }; + + const openEdit = (gift: HuyaCollabGiftCatalog) => { + setEditTarget(gift); + setEditName(gift.name); + setEditPoints(gift.points); + setEditRemark(gift.remark || ''); + }; + + const submitEdit = async () => { + if (!editTarget) return; + const name = editName.trim(); + if (!name) { + message.warning('礼包名不能为空'); + return; + } + if (editPoints === null) { + message.warning('请填写礼包所需积分'); + return; + } + setBusy(true); + try { + await huyaApi.updateCollabGift(editTarget.id, { + name, + points: editPoints, + remark: editRemark.trim(), + }); + message.success( + name === editTarget.name + ? `已保存礼包「${name}」` + : `已改名为「${name}」,历史协作单里的礼包名也已同步`, + ); + setEditTarget(null); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }; + + const remove = async (gift: HuyaCollabGiftCatalog) => { + setBusy(true); + try { + await huyaApi.deleteCollabGift(gift.id); + message.success(`已删除礼包「${gift.name}」`); + onChanged(); + } catch (error) { + message.error(getErrorMessage(error)); + } finally { + setBusy(false); + } + }; + + const columns: TableProps['columns'] = [ + { + title: '礼包名', + dataIndex: 'name', + width: 280, + render: (value: string, row) => ( + + {value} + {!row.is_active && 已停用} + {canManage && ( + + } + width={940} + destroyOnHidden + > + + + 先在这里维护标准礼包名和所需积分,新建 / 补资料时可以直接选择。礼包名请按兑换记录里的名称填写; + 停用只是不再出现在可选列表里,历史订单不受影响;改名会同步已有订单,改积分只影响之后新建或换礼包的单。 + + {canManage && ( + + + setNewName(event.target.value)} + onPressEnter={() => void create()} + /> + + + setNewPoints(value ?? null)} + /> + + + + + + )} + + rowKey="id" + size="small" + columns={columns} + dataSource={gifts} + pagination={false} + scroll={{ y: 340 }} + locale={{ + emptyText: ( + + ), + }} + /> + + + + setEditTarget(null)} + onOk={() => void submitEdit()} + confirmLoading={busy} + okText="保存" + destroyOnHidden + > + + setEditName(event.target.value)} + onPressEnter={() => void submitEdit()} + /> + setEditPoints(value ?? null)} + /> + setEditRemark(event.target.value)} + /> + + 改名会同步所有已有协作单上的礼包名;修改积分只影响之后新建或换礼包的单,不会改变历史订单。 + + + + + ); +} diff --git a/web/frontend/src/pages/HuyaCollabPage.tsx b/web/frontend/src/pages/HuyaCollabPage.tsx index 93e3392..fef403d 100644 --- a/web/frontend/src/pages/HuyaCollabPage.tsx +++ b/web/frontend/src/pages/HuyaCollabPage.tsx @@ -27,6 +27,7 @@ import { type HuyaCollabCkPreview, type HuyaCollabFrequentGift, type HuyaCollabGift, + type HuyaCollabGiftCatalog, type HuyaCollabImageSlot, type HuyaCollabMetric, type HuyaCollabOrder, @@ -40,6 +41,7 @@ import { import HuyaCollabShopModal from '../components/HuyaCollabShopModal'; import HuyaCollabShopPicker from '../components/HuyaCollabShopPicker'; import HuyaCollabHomeImageUploader from '../components/HuyaCollabHomeImageUploader'; +import HuyaCollabGiftModal from '../components/HuyaCollabGiftModal'; import { message } from '../utils/antdMessage'; import { getErrorMessage } from '../utils/error'; import { copyTextToClipboard } from '../utils/clipboard'; @@ -513,10 +515,12 @@ export default function HuyaCollabPage() { const [summary, setSummary] = useState(null); const [owners, setOwners] = useState([]); const [shops, setShops] = useState([]); + const [giftCatalog, setGiftCatalog] = useState([]); const [giftNames, setGiftNames] = useState([]); // 「常用礼包」:台账里用得最多的几个(新建/补资料的礼包名快捷按钮,见 2026-10-18 需求) const [frequentGifts, setFrequentGifts] = useState([]); const [shopModalOpen, setShopModalOpen] = useState(false); + const [giftModalOpen, setGiftModalOpen] = useState(false); const [filters, setFilters] = useState({ include_void: false, archive_scope: 'active', @@ -616,6 +620,7 @@ export default function HuyaCollabPage() { nextSummary, nextOwners, nextShops, + nextGiftCatalog, nextGiftNames, nextFrequentGifts, ] = await Promise.all([ @@ -632,6 +637,7 @@ export default function HuyaCollabPage() { }), withMeta ? huyaApi.collabOrderOwners() : Promise.resolve(null), withMeta ? huyaApi.listCollabShops() : Promise.resolve(null), + withMeta ? huyaApi.listCollabGiftCatalog() : Promise.resolve(null), withMeta ? huyaApi.collabOrderGifts() : Promise.resolve(null), withMeta ? huyaApi.collabFrequentGifts() : Promise.resolve(null), ]); @@ -640,6 +646,7 @@ export default function HuyaCollabPage() { setSummary(nextSummary); if (nextOwners) setOwners(nextOwners); if (nextShops) setShops(nextShops); + if (nextGiftCatalog) setGiftCatalog(nextGiftCatalog); if (nextGiftNames) setGiftNames(nextGiftNames); if (nextFrequentGifts) setFrequentGifts(nextFrequentGifts); // 选中是「当前页」的概念:刷新后剔除已经不在这页上的行,避免点到别人的脏 id。 @@ -657,7 +664,7 @@ export default function HuyaCollabPage() { // 暂停后连 WebSocket 也不建:既不自动重拉,也不做断线兜底轮询。 const realtimeSyncEnabled = realtimeEnabled && !realtimePaused; const collabModalOpen = Boolean( - createOpen || supplementTarget || detail || statusAction || batchStatusOpen || rechargeOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen, + createOpen || supplementTarget || detail || statusAction || batchStatusOpen || rechargeOpen || archiveOpen || ckTarget || ckExportOpen || shopModalOpen || giftModalOpen, ); const toggleRealtimeSync = (nextEnabled: boolean) => { @@ -761,12 +768,16 @@ export default function HuyaCollabPage() { } if (preview.ok && preview.has_role) { message.success(`CK 有效:绑定角色 ${preview.bind_game_role}`); - // 「记住的礼包」如果不在当前列表里就清掉重选;列表为空时不动它(允许手填) + // 「记住的礼包」如果不在实时列表、也不在标准字典里就清掉重选。 const options = preview.gifts.length > 0 ? preview.gifts : giftsRef.current; + const available = new Set([ + ...options.map((gift) => gift.name), + ...giftCatalog.filter((gift) => gift.is_active).map((gift) => gift.name), + ]); const picked = String(createForm.getFieldValue('gift_name') ?? '').trim(); - if (picked && options.length > 0 && !options.some((gift) => gift.name === picked)) { + if (picked && available.size > 0 && !available.has(picked)) { createForm.setFieldValue('gift_name', undefined); - setGiftHint(`上次选的「${picked}」不在当前的礼包列表里,已清空,请重新点选`); + setGiftHint(`上次选的「${picked}」不在实时礼包或标准字典里,已清空,请重新点选`); } else { setGiftHint(''); } @@ -778,7 +789,7 @@ export default function HuyaCollabPage() { if (seq === previewSeq.current) setPreviewing(false); } }, - [emptyPreview, createForm], + [emptyPreview, createForm, giftCatalog], ); /** @@ -1825,33 +1836,49 @@ export default function HuyaCollabPage() { : giftOptions.length > 0 ? `礼包用本地缓存(${giftOptions.length} 个${giftCacheLabel ? ` · ${giftCacheLabel}` : ''}),要更新点「强制刷新礼包」` : (ckPreview?.gifts_message || ''); + const hasSelectableGifts = giftOptions.length > 0 + || giftCatalog.some((gift) => gift.is_active); - /** - * 礼包下拉选项:只允许点选缓存里的礼包,不接受手输。 - * 当前值是历史/已下线礼包时补一个只读选项,保证界面能显示,但依然只能从下拉里选。 - */ + /** 礼包下拉:实时活动礼包 + 管理员维护的标准字典,二者按名称去重。 */ const giftSelectOptions = (current?: string | null) => { const value = String(current ?? '').trim(); - const options = giftOptions.map((gift) => ({ - value: gift.name, - label: [ - gift.category_name ? `[${gift.category_name}]` : '', - gift.name, - gift.price ? `· ${gift.price} 积分` : '', - gift.remain_text ? `· 剩 ${gift.remain_text}` : '', - ].filter(Boolean).join(' '), - })); - if (value && !giftOptions.some((gift) => gift.name === value)) { + const seen = new Set(); + const options = giftOptions + .filter((gift) => { + if (!gift.name || seen.has(gift.name)) return false; + seen.add(gift.name); + return true; + }) + .map((gift) => ({ + value: gift.name, + label: [ + gift.category_name ? `[${gift.category_name}]` : '', + gift.name, + gift.price ? `· ${gift.price} 积分` : '', + gift.remain_text ? `· 剩 ${gift.remain_text}` : '', + ].filter(Boolean).join(' '), + })); + for (const gift of giftCatalog) { + if (!gift.is_active || !gift.name || seen.has(gift.name)) continue; + seen.add(gift.name); + options.push({ + value: gift.name, + label: `${gift.name} · ${gift.points} 积分 · 标准字典`, + }); + } + if (value && !seen.has(value)) { options.unshift({ value, label: `${value}(历史值)` }); } return options; }; - /** 礼包名对应的积分单价;缓存里没有 / 没价格时返回 null(后端核账时还会按名称补一次)。 */ + /** 礼包名对应的积分:实时价格优先,其次标准字典;都没有时返回 null。 */ const giftRequiredPoints = (name?: string | null): number | null => { const value = String(name ?? '').trim(); if (!value) return null; - return giftOptions.find((gift) => gift.name === value)?.price ?? null; + return giftOptions.find((gift) => gift.name === value)?.price + ?? giftCatalog.find((gift) => gift.name === value)?.points + ?? null; }; /** @@ -1863,8 +1890,11 @@ export default function HuyaCollabPage() { * 点一个拉不到的名字等于白点(见设计文档 §11.4)。缓存为空时整行不显示。 */ const frequentGiftChoices = (): HuyaCollabFrequentGift[] => { - if (giftOptions.length === 0) return []; - const available = new Set(giftOptions.map((gift) => gift.name)); + const available = new Set([ + ...giftOptions.map((gift) => gift.name), + ...giftCatalog.filter((gift) => gift.is_active).map((gift) => gift.name), + ]); + if (available.size === 0) return []; return frequentGifts.filter((item) => available.has(item.name)).slice(0, 6); }; @@ -2107,7 +2137,7 @@ export default function HuyaCollabPage() { // 前缀单独一行、礼包名完整换行,别让单行省略号把名字吃掉 labelRender={() => renderGiftSelectedLabel(row.gift_name)} styles={{ content: { whiteSpace: 'normal', textOverflow: 'clip', lineHeight: 1.3 } }} - notFoundContent={giftOptions.length === 0 ? '礼包缓存为空:先在「新建」里贴 CK 拉一次' : '没有匹配的礼包'} + notFoundContent={hasSelectableGifts ? '没有匹配的礼包' : '实时礼包为空,标准字典也还没有配置'} onChange={(next: string) => void patchOrder(row, { gift_name: next, required_points: giftRequiredPoints(next), @@ -2528,6 +2558,9 @@ export default function HuyaCollabPage() { {canEdit && !isArchiveView && ( )} + {canManage && !isArchiveView && ( + + )} {canEdit && !isArchiveView && (