限制机器人外观资源池

This commit is contained in:
zhx 2026-06-24 18:56:14 +08:00
parent 9a19267c6a
commit 9c547faf0e
3 changed files with 894 additions and 8 deletions

View File

@ -0,0 +1,861 @@
#!/usr/bin/env python3
"""Refresh robot avatar frames and VIP badges.
The script is intentionally dry-run by default. With --yes it:
1. Builds the robot user set from users.source=game_robot plus robot-service pools.
2. Picks avatar frames priced at or below 2,000,000 COIN, excluding known reserved frames.
3. Picks one VIP strip badge and one VIP tile badge.
4. Ensures wallet entitlements exist, replaces equipped avatar/badge rows, and rebuilds activity badge display slots.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shlex
import subprocess
import time
import uuid
from pathlib import Path
from typing import Any
import pymysql
import yaml
APP_CODE_DEFAULT = "lalu"
ROBOT_SOURCE_DEFAULT = "game_robot"
GRANT_SOURCE = "game_robot_init"
RESOURCE_OUTBOX_ASSET = "RESOURCE"
OUTBOX_STATUS_PENDING = "pending"
ENTITLEMENT_STATUS_ACTIVE = "active"
GRANT_STATUS_DONE = "succeeded"
RESULT_ENTITLEMENT = "entitlement"
ROBOT_RESOURCE_DURATION_DAYS = 9999
ROBOT_RESOURCE_DURATION_MS = ROBOT_RESOURCE_DURATION_DAYS * 24 * 60 * 60 * 1000
ROBOT_AVATAR_FRAME_MAX_COIN_PRICE = 2_000_000
EXCLUDED_AVATAR_FRAME_IDS = {165, 175}
BADGE_DISPLAY_SLOTS = ("profile_strip", "profile_tile", "honor_wall")
def normalize_app_code(value: str) -> str:
return (value or "").strip().lower() or APP_CODE_DEFAULT
def stable_hash(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def prefixed_id(prefix: str, value: str) -> str:
return prefix + stable_hash(value)
def current_ms() -> int:
return int(time.time() * 1000)
def read_config_text(location: str) -> str:
location = location.strip()
if looks_like_ssh_location(location):
host, remote_path = location.split(":", 1)
return subprocess.check_output(
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", host, "cat " + shlex.quote(remote_path)],
text=True,
)
return Path(location).expanduser().read_text(encoding="utf-8")
def looks_like_ssh_location(location: str) -> bool:
if location.startswith("/") or location.startswith("./") or location.startswith("../") or "://" in location:
return False
head, sep, tail = location.partition(":")
return bool(sep and tail and ("@" in head or "." in head or head.replace("-", "").isalnum()))
def load_yaml(location: str) -> dict[str, Any]:
payload = yaml.safe_load(read_config_text(location)) or {}
if not isinstance(payload, dict):
raise RuntimeError(f"config must be a YAML object: {location}")
return payload
def parse_go_mysql_dsn(dsn: str) -> dict[str, Any]:
prefix, rest = dsn.split("@tcp(", 1)
user, password = prefix.split(":", 1) if ":" in prefix else (prefix, "")
addr, rest = rest.split(")/", 1)
host, port = addr.rsplit(":", 1) if ":" in addr else (addr, "3306")
database = rest.split("?", 1)[0]
return {
"host": host,
"port": int(port),
"user": user,
"password": password,
"database": database,
"charset": "utf8mb4",
"autocommit": False,
"connect_timeout": 8,
"read_timeout": 120,
"write_timeout": 120,
"cursorclass": pymysql.cursors.DictCursor,
}
def connect_from_config(path: str, dsn_key: str = "mysql_dsn"):
cfg = load_yaml(path)
dsn = str(cfg.get(dsn_key) or "").strip()
if not dsn:
raise RuntimeError(f"{dsn_key} is required in {path}")
return pymysql.connect(**parse_go_mysql_dsn(dsn))
def table_exists(conn, table_name: str) -> bool:
with conn.cursor() as cursor:
cursor.execute(
"SELECT COUNT(*) AS count FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
(table_name,),
)
return int(cursor.fetchone()["count"]) > 0
def fetch_user_source_robots(conn, app_code: str, source: str) -> dict[int, dict[str, Any]]:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT user_id, status, COALESCE(source, '') AS source
FROM users
WHERE app_code = %s AND source = %s
ORDER BY user_id ASC
""",
(app_code, source),
)
return {
int(row["user_id"]): {"user_status": row["status"], "sources": {f"users.source:{row['source']}"}}
for row in cursor.fetchall()
}
def fetch_robot_pool_users(conn, app_code: str) -> dict[int, set[str]]:
result: dict[int, set[str]] = {}
for table, label_column in (("robot_game_robots", "game_id"), ("robot_room_robots", "room_scene")):
if not table_exists(conn, table):
continue
with conn.cursor() as cursor:
cursor.execute(
f"""
SELECT user_id, status, {label_column} AS label
FROM {table}
WHERE app_code = %s
ORDER BY user_id ASC
""",
(app_code,),
)
for row in cursor.fetchall():
user_id = int(row["user_id"])
result.setdefault(user_id, set()).add(f"{table}:{row['label']}:{row['status']}")
return result
def merge_robot_sources(
user_robots: dict[int, dict[str, Any]],
pool_robots: dict[int, set[str]],
) -> dict[int, dict[str, Any]]:
merged = dict(user_robots)
for user_id, sources in pool_robots.items():
item = merged.setdefault(user_id, {"user_status": "", "sources": set()})
item["sources"].update(sources)
return merged
def filter_existing_users(conn, app_code: str, robot_map: dict[int, dict[str, Any]]) -> dict[int, dict[str, Any]]:
user_ids = sorted(robot_map)
if not user_ids:
return {}
existing: dict[int, str] = {}
for chunk in chunks(user_ids, 500):
placeholders = ",".join(["%s"] * len(chunk))
with conn.cursor() as cursor:
cursor.execute(
f"SELECT user_id, status FROM users WHERE app_code = %s AND user_id IN ({placeholders})",
[app_code, *chunk],
)
for row in cursor.fetchall():
existing[int(row["user_id"])] = str(row["status"])
filtered: dict[int, dict[str, Any]] = {}
for user_id in user_ids:
if user_id not in existing:
continue
item = robot_map[user_id]
if not item.get("user_status"):
item["user_status"] = existing[user_id]
filtered[user_id] = item
return filtered
def fetch_resources(conn, app_code: str) -> list[dict[str, Any]]:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT app_code, resource_id, resource_code, resource_type, name, status,
grantable, manager_grant_enabled, grant_strategy, wallet_asset_type,
wallet_asset_amount, price_type, coin_price, gift_point_amount,
COALESCE(CAST(usage_scope_json AS CHAR), '[]') AS usage_scope_json,
asset_url, preview_url, animation_url,
COALESCE(CAST(metadata_json AS CHAR), '{}') AS metadata_json,
sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
FROM resources
WHERE app_code = %s AND status = 'active'
ORDER BY sort_order ASC, resource_id ASC
""",
(app_code,),
)
return list(cursor.fetchall())
def parse_json_value(raw: str, fallback: Any) -> Any:
try:
return json.loads(raw or "")
except Exception:
return fallback
def badge_form(resource: dict[str, Any]) -> str:
payload = parse_json_value(str(resource.get("metadata_json") or "{}"), {})
if not isinstance(payload, dict):
return ""
return str(payload.get("badge_form") or "").strip().lower()
def is_vip_badge(resource: dict[str, Any]) -> bool:
code = str(resource.get("resource_code") or "").strip().lower()
name = str(resource.get("name") or "").strip().lower()
return str(resource.get("resource_type") or "") == "badge" and (code.startswith("vip") or name.startswith("vip"))
def build_catalog(resources: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
avatar_frames: list[dict[str, Any]] = []
long_badges: list[dict[str, Any]] = []
short_badges: list[dict[str, Any]] = []
for resource in resources:
resource_type = str(resource.get("resource_type") or "").strip()
resource_id = int(resource.get("resource_id") or 0)
if resource_type == "avatar_frame":
if resource_id in EXCLUDED_AVATAR_FRAME_IDS:
continue
if int(resource.get("coin_price") or 0) > ROBOT_AVATAR_FRAME_MAX_COIN_PRICE:
continue
avatar_frames.append(resource)
continue
if not is_vip_badge(resource):
continue
form = badge_form(resource)
if form in ("strip", "long"):
long_badges.append(resource)
elif form in ("tile", "short"):
short_badges.append(resource)
return {
"avatar_frame": avatar_frames,
"long_badge": long_badges,
"short_badge": short_badges,
}
def assert_catalog(catalog: dict[str, list[dict[str, Any]]]) -> None:
missing = [key for key, value in catalog.items() if not value]
if missing:
raise RuntimeError(f"robot appearance resource pool is incomplete: {', '.join(missing)}")
def choose_resource(items: list[dict[str, Any]], app_code: str, user_id: int, slot: str) -> dict[str, Any]:
index = int(stable_hash(f"{app_code}|{user_id}|{slot}")[:16], 16) % len(items)
return items[index]
def resource_snapshot(resource: dict[str, Any]) -> dict[str, Any]:
scopes = parse_json_value(str(resource.get("usage_scope_json") or "[]"), [])
if not isinstance(scopes, list):
scopes = []
return {
"AppCode": str(resource.get("app_code") or ""),
"ResourceID": int(resource.get("resource_id") or 0),
"ResourceCode": str(resource.get("resource_code") or ""),
"ResourceType": str(resource.get("resource_type") or ""),
"Name": str(resource.get("name") or ""),
"Status": str(resource.get("status") or ""),
"Grantable": bool(resource.get("grantable")),
"GrantStrategy": str(resource.get("grant_strategy") or ""),
"WalletAssetType": str(resource.get("wallet_asset_type") or ""),
"WalletAssetAmount": int(resource.get("wallet_asset_amount") or 0),
"PriceType": str(resource.get("price_type") or ""),
"CoinPrice": int(resource.get("coin_price") or 0),
"GiftPointAmount": int(resource.get("gift_point_amount") or 0),
"UsageScopes": [str(item) for item in scopes],
"AssetURL": str(resource.get("asset_url") or ""),
"PreviewURL": str(resource.get("preview_url") or ""),
"AnimationURL": str(resource.get("animation_url") or ""),
"MetadataJSON": str(resource.get("metadata_json") or "{}"),
"SortOrder": int(resource.get("sort_order") or 0),
"CreatedByUserID": int(resource.get("created_by_user_id") or 0),
"UpdatedByUserID": int(resource.get("updated_by_user_id") or 0),
"CreatedAtMS": int(resource.get("created_at_ms") or 0),
"UpdatedAtMS": int(resource.get("updated_at_ms") or 0),
"ManagerGrantEnabled": bool(resource.get("manager_grant_enabled")),
}
def grant_request_hash(app_code: str, user_id: int, resource_id: int, reason: str, operator_user_id: int) -> str:
return stable_hash(
f"resource_grant|{normalize_app_code(app_code)}|{user_id}|{resource_id}|1|{ROBOT_RESOURCE_DURATION_MS}|{reason}|{operator_user_id}|{GRANT_SOURCE}"
)
def resource_outbox_event_id(event_type: str, command_id: str, user_id: int, resource_id: int) -> str:
return "wev_" + stable_hash(f"{event_type}|{command_id}|{user_id}|{resource_id}")
def insert_wallet_outbox(
cursor,
*,
app_code: str,
event_type: str,
command_id: str,
user_id: int,
resource_id: int,
payload: dict[str, Any],
now_ms: int,
) -> None:
cursor.execute(
"""
INSERT IGNORE INTO wallet_outbox (
app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
available_delta, frozen_delta, payload, status, retry_count, created_at_ms, updated_at_ms
) VALUES (%s, %s, %s, '', %s, %s, %s, 0, 0, %s, %s, 0, %s, %s)
""",
(
app_code,
resource_outbox_event_id(event_type, command_id, user_id, resource_id),
event_type,
command_id,
user_id,
RESOURCE_OUTBOX_ASSET,
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
OUTBOX_STATUS_PENDING,
now_ms,
now_ms,
),
)
def active_entitlement(cursor, app_code: str, user_id: int, resource_id: int, now_ms: int) -> str:
cursor.execute(
"""
SELECT entitlement_id
FROM user_resource_entitlements
WHERE app_code = %s AND user_id = %s AND resource_id = %s
AND status = 'active'
AND effective_at_ms <= %s
AND (expires_at_ms = 0 OR expires_at_ms > %s)
AND remaining_quantity > 0
ORDER BY expires_at_ms DESC, created_at_ms DESC
LIMIT 1
FOR UPDATE
""",
(app_code, user_id, resource_id, now_ms, now_ms),
)
row = cursor.fetchone()
return str(row["entitlement_id"]) if row else ""
def ensure_entitlement(
cursor,
*,
app_code: str,
user_id: int,
resource: dict[str, Any],
slot: str,
operator_user_id: int,
reason: str,
run_id: str,
now_ms: int,
) -> tuple[str, bool, str]:
resource_id = int(resource["resource_id"])
existing = active_entitlement(cursor, app_code, user_id, resource_id, now_ms)
if existing:
return existing, False, ""
command_id = f"robot_appearance_refresh:{run_id}:{user_id}:{slot}:{resource_id}"
grant_id = prefixed_id("rgr_", f"{normalize_app_code(app_code)}|{command_id}")
entitlement_id = prefixed_id("ent_", f"{normalize_app_code(app_code)}|{grant_id}|{resource_id}|{now_ms}")
snapshot = resource_snapshot(resource)
snapshot_json = json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"))
expires_at_ms = now_ms + ROBOT_RESOURCE_DURATION_MS
cursor.execute(
"""
INSERT INTO resource_grants (
app_code, grant_id, command_id, target_user_id, grant_source, grant_subject_type,
grant_subject_id, status, request_hash, group_snapshot_json, reason,
operator_user_id, created_at_ms, updated_at_ms
) VALUES (%s, %s, %s, %s, %s, 'resource', %s, %s, %s, NULL, %s, %s, %s, %s)
""",
(
app_code,
grant_id,
command_id,
user_id,
GRANT_SOURCE,
str(resource_id),
GRANT_STATUS_DONE,
grant_request_hash(app_code, user_id, resource_id, reason, operator_user_id),
reason,
operator_user_id,
now_ms,
now_ms,
),
)
cursor.execute(
"""
INSERT INTO user_resource_entitlements (
app_code, entitlement_id, user_id, resource_id, status, quantity, remaining_quantity,
effective_at_ms, expires_at_ms, source_grant_id, created_at_ms, updated_at_ms
) VALUES (%s, %s, %s, %s, %s, 1, 1, %s, %s, %s, %s, %s)
""",
(app_code, entitlement_id, user_id, resource_id, ENTITLEMENT_STATUS_ACTIVE, now_ms, expires_at_ms, grant_id, now_ms, now_ms),
)
cursor.execute(
"""
INSERT INTO resource_grant_items (
app_code, grant_id, resource_id, resource_snapshot_json, quantity, duration_ms,
result_type, wallet_transaction_id, entitlement_id, created_at_ms
) VALUES (%s, %s, %s, %s, 1, %s, %s, '', %s, %s)
""",
(app_code, grant_id, resource_id, snapshot_json, ROBOT_RESOURCE_DURATION_MS, RESULT_ENTITLEMENT, entitlement_id, now_ms),
)
insert_wallet_outbox(
cursor,
app_code=app_code,
event_type="ResourceGranted",
command_id=command_id,
user_id=user_id,
resource_id=resource_id,
payload={"grant_id": grant_id, "resource": snapshot},
now_ms=now_ms,
)
return entitlement_id, True, grant_id
def entitlement_snapshot(
*,
app_code: str,
user_id: int,
entitlement_id: str,
resource: dict[str, Any],
grant_id: str,
now_ms: int,
) -> dict[str, Any]:
return {
"AppCode": app_code,
"EntitlementID": entitlement_id,
"UserID": user_id,
"ResourceID": int(resource["resource_id"]),
"Resource": resource_snapshot(resource),
"Status": ENTITLEMENT_STATUS_ACTIVE,
"Quantity": 1,
"RemainingQuantity": 1,
"EffectiveAtMS": now_ms,
"ExpiresAtMS": now_ms + ROBOT_RESOURCE_DURATION_MS,
"SourceGrantID": grant_id,
"CreatedAtMS": now_ms,
"UpdatedAtMS": now_ms,
"Equipped": True,
}
def replace_wallet_equipment(
conn,
*,
app_code: str,
user_id: int,
selected: dict[str, dict[str, Any]],
operator_user_id: int,
reason: str,
run_id: str,
) -> dict[str, Any]:
now_ms = current_ms()
created_grants = 0
equipped_rows = 0
cursor = conn.cursor()
try:
cursor.execute("START TRANSACTION")
cursor.execute(
"""
DELETE FROM user_resource_equipment
WHERE app_code = %s AND user_id = %s AND resource_type IN ('avatar_frame', 'badge')
""",
(app_code, user_id),
)
deleted_equipment = cursor.rowcount
for slot, resource in selected.items():
resource_type = str(resource["resource_type"])
resource_id = int(resource["resource_id"])
entitlement_id, created, grant_id = ensure_entitlement(
cursor,
app_code=app_code,
user_id=user_id,
resource=resource,
slot=slot,
operator_user_id=operator_user_id,
reason=reason,
run_id=run_id,
now_ms=now_ms,
)
if created:
created_grants += 1
cursor.execute(
"""
INSERT INTO user_resource_equipment (
app_code, user_id, resource_type, entitlement_id, resource_id, updated_at_ms
) VALUES (%s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE resource_id = VALUES(resource_id), updated_at_ms = VALUES(updated_at_ms)
""",
(app_code, user_id, resource_type, entitlement_id, resource_id, now_ms),
)
equipped_rows += 1
event_command_id = f"robot_appearance_equip:{run_id}:{user_id}:{slot}:{resource_id}"
insert_wallet_outbox(
cursor,
app_code=app_code,
event_type="UserResourceChanged",
command_id=event_command_id,
user_id=user_id,
resource_id=resource_id,
payload={
"action": "equip",
"app_code": app_code,
"user_id": user_id,
"resource_id": resource_id,
"resource_type": resource_type,
"entitlement": entitlement_snapshot(
app_code=app_code,
user_id=user_id,
entitlement_id=entitlement_id,
resource=resource,
grant_id=grant_id,
now_ms=now_ms,
),
"updated_at_ms": now_ms,
"event_command": event_command_id,
"source": "robot_appearance_refresh",
},
now_ms=now_ms,
)
conn.commit()
return {"created_grants": created_grants, "equipped_rows": equipped_rows, "deleted_equipment": deleted_equipment}
except Exception:
conn.rollback()
raise
finally:
cursor.close()
def replace_activity_badge_slots(
conn,
*,
app_code: str,
user_id: int,
selected: dict[str, dict[str, Any]],
wallet_conn,
) -> dict[str, int]:
now_ms = current_ms()
long_badge = selected["long_badge"]
short_badge = selected["short_badge"]
long_entitlement = latest_equipped_entitlement(wallet_conn, app_code, user_id, int(long_badge["resource_id"]))
short_entitlement = latest_equipped_entitlement(wallet_conn, app_code, user_id, int(short_badge["resource_id"]))
cursor = conn.cursor()
try:
cursor.execute("START TRANSACTION")
cursor.execute(
f"""
DELETE FROM user_badge_display_slots
WHERE app_code = %s AND user_id = %s AND slot IN ({','.join(['%s'] * len(BADGE_DISPLAY_SLOTS))})
""",
(app_code, user_id, *BADGE_DISPLAY_SLOTS),
)
deleted = cursor.rowcount
inserts = [
("profile_strip", 1, "strip", int(long_badge["resource_id"]), long_entitlement, "robot_appearance_refresh:long"),
("profile_tile", 1, "tile", int(short_badge["resource_id"]), short_entitlement, "robot_appearance_refresh:tile"),
("honor_wall", 1, "tile", int(short_badge["resource_id"]), short_entitlement, "robot_appearance_refresh:honor"),
]
for slot, position, form, resource_id, entitlement_id, source_id in inserts:
cursor.execute(
"""
INSERT INTO user_badge_display_slots (
app_code, user_id, slot, position, badge_form, resource_id,
entitlement_id, source_type, source_id, pin_mode, updated_at_ms
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'system_grant', %s, 'auto', %s)
ON DUPLICATE KEY UPDATE
badge_form = VALUES(badge_form),
resource_id = VALUES(resource_id),
entitlement_id = VALUES(entitlement_id),
source_type = VALUES(source_type),
source_id = VALUES(source_id),
pin_mode = VALUES(pin_mode),
updated_at_ms = VALUES(updated_at_ms)
""",
(app_code, user_id, slot, position, form, resource_id, entitlement_id, source_id, now_ms),
)
conn.commit()
return {"deleted_slots": deleted, "inserted_slots": len(inserts)}
except Exception:
conn.rollback()
raise
finally:
cursor.close()
def latest_equipped_entitlement(conn, app_code: str, user_id: int, resource_id: int) -> str:
with conn.cursor() as cursor:
cursor.execute(
"""
SELECT entitlement_id
FROM user_resource_equipment
WHERE app_code = %s AND user_id = %s AND resource_id = %s
ORDER BY updated_at_ms DESC
LIMIT 1
""",
(app_code, user_id, resource_id),
)
row = cursor.fetchone()
return str(row["entitlement_id"]) if row else ""
def equipment_summary(conn, app_code: str, user_ids: list[int], valid_frame_ids: set[int], valid_badge_ids: set[int]) -> dict[str, int]:
if not user_ids:
return {}
summary = {
"users_with_valid_avatar_frame": 0,
"invalid_avatar_frame_equipment_rows": 0,
"valid_badge_equipment_rows": 0,
"invalid_badge_equipment_rows": 0,
}
for chunk in chunks(user_ids, 500):
user_placeholders = ",".join(["%s"] * len(chunk))
frame_placeholders = ",".join(["%s"] * len(valid_frame_ids)) or "NULL"
badge_placeholders = ",".join(["%s"] * len(valid_badge_ids)) or "NULL"
with conn.cursor() as cursor:
cursor.execute(
f"""
SELECT
COUNT(DISTINCT CASE WHEN eq.resource_type = 'avatar_frame' AND eq.resource_id IN ({frame_placeholders}) THEN eq.user_id END) AS users_with_valid_avatar_frame,
SUM(CASE WHEN eq.resource_type = 'avatar_frame' AND eq.resource_id NOT IN ({frame_placeholders}) THEN 1 ELSE 0 END) AS invalid_avatar_frame_equipment_rows,
SUM(CASE WHEN eq.resource_type = 'badge' AND eq.resource_id IN ({badge_placeholders}) THEN 1 ELSE 0 END) AS valid_badge_equipment_rows,
SUM(CASE WHEN eq.resource_type = 'badge' AND eq.resource_id NOT IN ({badge_placeholders}) THEN 1 ELSE 0 END) AS invalid_badge_equipment_rows
FROM user_resource_equipment eq
WHERE eq.app_code = %s AND eq.user_id IN ({user_placeholders})
AND eq.resource_type IN ('avatar_frame', 'badge')
""",
[*valid_frame_ids, *valid_frame_ids, *valid_badge_ids, *valid_badge_ids, app_code, *chunk],
)
row = cursor.fetchone()
for key in summary:
summary[key] += int(row.get(key) or 0)
return summary
def activity_slot_summary(conn, app_code: str, user_ids: list[int], valid_long_ids: set[int], valid_short_ids: set[int]) -> dict[str, int]:
if not user_ids:
return {}
valid_by_slot = {
"profile_strip": valid_long_ids,
"profile_tile": valid_short_ids,
"honor_wall": valid_short_ids,
}
summary = {f"{slot}_valid_users": 0 for slot in BADGE_DISPLAY_SLOTS}
summary.update({f"{slot}_invalid_rows": 0 for slot in BADGE_DISPLAY_SLOTS})
for slot, valid_ids in valid_by_slot.items():
for chunk in chunks(user_ids, 500):
user_placeholders = ",".join(["%s"] * len(chunk))
valid_placeholders = ",".join(["%s"] * len(valid_ids)) or "NULL"
with conn.cursor() as cursor:
cursor.execute(
f"""
SELECT
COUNT(DISTINCT CASE WHEN resource_id IN ({valid_placeholders}) THEN user_id END) AS valid_users,
SUM(CASE WHEN resource_id NOT IN ({valid_placeholders}) THEN 1 ELSE 0 END) AS invalid_rows
FROM user_badge_display_slots
WHERE app_code = %s AND user_id IN ({user_placeholders}) AND slot = %s
""",
[*valid_ids, *valid_ids, app_code, *chunk, slot],
)
row = cursor.fetchone()
summary[f"{slot}_valid_users"] += int(row.get("valid_users") or 0)
summary[f"{slot}_invalid_rows"] += int(row.get("invalid_rows") or 0)
return summary
def chunks(values: list[int], size: int):
for index in range(0, len(values), size):
yield values[index : index + size]
def sample_assignments(app_code: str, user_ids: list[int], catalog: dict[str, list[dict[str, Any]]], limit: int = 10) -> list[dict[str, Any]]:
samples = []
for user_id in user_ids[:limit]:
selected = select_for_user(app_code, user_id, catalog)
samples.append(
{
"user_id": user_id,
"avatar_frame": compact_resource(selected["avatar_frame"]),
"long_badge": compact_resource(selected["long_badge"]),
"short_badge": compact_resource(selected["short_badge"]),
}
)
return samples
def select_for_user(app_code: str, user_id: int, catalog: dict[str, list[dict[str, Any]]]) -> dict[str, dict[str, Any]]:
return {
"avatar_frame": choose_resource(catalog["avatar_frame"], app_code, user_id, "avatar_frame"),
"long_badge": choose_resource(catalog["long_badge"], app_code, user_id, "long_badge"),
"short_badge": choose_resource(catalog["short_badge"], app_code, user_id, "short_badge"),
}
def compact_resource(resource: dict[str, Any]) -> dict[str, Any]:
return {
"resource_id": int(resource["resource_id"]),
"resource_code": resource["resource_code"],
"name": resource["name"],
"coin_price": int(resource.get("coin_price") or 0),
"badge_form": badge_form(resource),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Refresh all robot avatar frames and VIP badges.")
parser.add_argument("--user-config", default="services/user-service/configs/config.yaml")
parser.add_argument("--wallet-config", default="services/wallet-service/configs/config.yaml")
parser.add_argument("--robot-config", default="services/robot-service/configs/config.yaml")
parser.add_argument("--activity-config", default="services/activity-service/configs/config.yaml")
parser.add_argument("--app-code", default=APP_CODE_DEFAULT)
parser.add_argument("--robot-source", default=ROBOT_SOURCE_DEFAULT)
parser.add_argument("--operator-user-id", type=int, default=0)
parser.add_argument("--reason", default="refresh robot avatar frames and vip badges")
parser.add_argument("--progress-every", type=int, default=50)
parser.add_argument("--skip-activity", action="store_true")
parser.add_argument("--yes", action="store_true", help="Actually write wallet/activity changes.")
return parser.parse_args()
def main() -> int:
args = parse_args()
app_code = normalize_app_code(args.app_code)
if args.yes and args.operator_user_id <= 0:
raise RuntimeError("--operator-user-id is required with --yes")
if args.yes and not args.reason.strip():
raise RuntimeError("--reason is required with --yes")
user_conn = connect_from_config(args.user_config)
wallet_conn = connect_from_config(args.wallet_config)
robot_conn = connect_from_config(args.robot_config) if args.robot_config.strip() else None
activity_conn = None if args.skip_activity else connect_from_config(args.activity_config)
try:
source_robots = fetch_user_source_robots(user_conn, app_code, args.robot_source.strip())
pool_robots = fetch_robot_pool_users(robot_conn, app_code) if robot_conn else {}
robots = filter_existing_users(user_conn, app_code, merge_robot_sources(source_robots, pool_robots))
user_ids = sorted(robots)
resources = fetch_resources(wallet_conn, app_code)
catalog = build_catalog(resources)
assert_catalog(catalog)
valid_frame_ids = {int(item["resource_id"]) for item in catalog["avatar_frame"]}
valid_long_ids = {int(item["resource_id"]) for item in catalog["long_badge"]}
valid_short_ids = {int(item["resource_id"]) for item in catalog["short_badge"]}
valid_badge_ids = valid_long_ids | valid_short_ids
before_wallet = equipment_summary(wallet_conn, app_code, user_ids, valid_frame_ids, valid_badge_ids)
before_activity = (
activity_slot_summary(activity_conn, app_code, user_ids, valid_long_ids, valid_short_ids) if activity_conn else {}
)
summary = {
"mode": "execute" if args.yes else "dry_run",
"app_code": app_code,
"robot_user_count": len(user_ids),
"source_user_count": len(source_robots),
"robot_pool_user_count": len(pool_robots),
"resource_pool": {key: [compact_resource(item) for item in value] for key, value in catalog.items()},
"before_wallet": before_wallet,
"before_activity": before_activity,
"sample_assignments": sample_assignments(app_code, user_ids, catalog),
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
if not args.yes:
print("dry-run only; rerun with --yes --operator-user-id <id> to write changes")
return 0
run_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + "_" + uuid.uuid4().hex[:12]
changed = {
"run_id": run_id,
"processed": 0,
"created_grants": 0,
"equipped_rows": 0,
"deleted_equipment": 0,
"deleted_activity_slots": 0,
"inserted_activity_slots": 0,
}
for user_id in user_ids:
selected = select_for_user(app_code, user_id, catalog)
wallet_result = replace_wallet_equipment(
wallet_conn,
app_code=app_code,
user_id=user_id,
selected=selected,
operator_user_id=args.operator_user_id,
reason=args.reason.strip(),
run_id=run_id,
)
changed["created_grants"] += wallet_result["created_grants"]
changed["equipped_rows"] += wallet_result["equipped_rows"]
changed["deleted_equipment"] += wallet_result["deleted_equipment"]
if activity_conn:
activity_result = replace_activity_badge_slots(
activity_conn,
app_code=app_code,
user_id=user_id,
selected=selected,
wallet_conn=wallet_conn,
)
changed["deleted_activity_slots"] += activity_result["deleted_slots"]
changed["inserted_activity_slots"] += activity_result["inserted_slots"]
changed["processed"] += 1
if args.progress_every > 0 and (changed["processed"] % args.progress_every == 0 or changed["processed"] == len(user_ids)):
print(json.dumps(changed, ensure_ascii=False))
after_wallet = equipment_summary(wallet_conn, app_code, user_ids, valid_frame_ids, valid_badge_ids)
after_activity = (
activity_slot_summary(activity_conn, app_code, user_ids, valid_long_ids, valid_short_ids) if activity_conn else {}
)
print(
json.dumps(
{
"ok": True,
**changed,
"after_wallet": after_wallet,
"after_activity": after_activity,
},
ensure_ascii=False,
indent=2,
)
)
return 0
finally:
if robot_conn:
robot_conn.close()
if activity_conn:
activity_conn.close()
wallet_conn.close()
user_conn.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -16,6 +16,7 @@ const (
robotResourceDurationMS = int64(robotResourceDurationDays) * int64(24*time.Hour/time.Millisecond)
robotMinDisplayLevel = 5
robotMaxDisplayLevel = 30
robotMaxAvatarFrameCoins = 2_000_000
)
var excludedRobotAvatarFrameResourceIDs = map[int64]struct{}{
@ -83,6 +84,11 @@ func (h *Handler) loadRobotAppearanceCatalog(ctx context.Context, requestID stri
}
catalog := robotAppearanceCatalog{avatarFrames: avatarFrames, vehicles: vehicles}
for _, badge := range badges {
if !isRobotVIPBadgeResource(badge) {
// 机器人批量账号只使用 VIP 体系徽章;普通活动、成就和运营徽章仍留给真实用户或明确活动链路,
// 避免机器人大量占用非 VIP 资源造成客户端误判身份来源。
continue
}
switch robotBadgeForm(badge.GetMetadataJson()) {
case "strip", "long":
catalog.longBadges = append(catalog.longBadges, badge)
@ -115,6 +121,11 @@ func (h *Handler) listRobotResources(ctx context.Context, requestID string, appC
// 真人发放、商城和其他后台场景不受这个创建机器人专用策略影响。
continue
}
if resourceType == "avatar_frame" && item.GetCoinPrice() > robotMaxAvatarFrameCoins {
// 头像框用金币价格做机器人随机池硬边界;价格高于 2000000 的资源通常是稀有或运营保留展示,
// 创建机器人时不应自动发放,线上批量修复脚本也复用同一条规则。
continue
}
resources = append(resources, item)
}
return resources, nil
@ -128,6 +139,15 @@ func isExcludedRobotAppearanceResource(resourceType string, resourceID int64) bo
return excluded
}
func isRobotVIPBadgeResource(resource *walletv1.Resource) bool {
if resource == nil || resource.GetResourceType() != "badge" {
return false
}
code := strings.ToLower(strings.TrimSpace(resource.GetResourceCode()))
name := strings.ToLower(strings.TrimSpace(resource.GetName()))
return strings.HasPrefix(code, "vip") || strings.HasPrefix(name, "vip")
}
func (h *Handler) grantAndEquipRobotResource(ctx context.Context, requestID string, appCode string, actorUserID int64, userID int64, slot string, resourceID int64, equip bool) error {
grant, err := h.wallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
CommandId: fmt.Sprintf("game_robot_init:%s:%d:%s:%d", requestID, userID, slot, resourceID),

View File

@ -75,16 +75,19 @@ func (c *fakeRobotWalletClient) ListResources(_ context.Context, req *walletv1.L
switch req.GetResourceType() {
case "avatar_frame":
return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{
{ResourceId: 165, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false},
{ResourceId: 175, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false},
{ResourceId: 101, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false},
{ResourceId: 165, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false, CoinPrice: 1_000_000},
{ResourceId: 175, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false, CoinPrice: 1_500_000},
{ResourceId: 101, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false, CoinPrice: robotMaxAvatarFrameCoins},
{ResourceId: 176, ResourceType: "avatar_frame", Status: "active", ManagerGrantEnabled: false, CoinPrice: robotMaxAvatarFrameCoins + 1},
}}, nil
case "vehicle":
return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{{ResourceId: 201, ResourceType: "vehicle", Status: "active", ManagerGrantEnabled: false}}}, nil
case "badge":
return &walletv1.ListResourcesResponse{Resources: []*walletv1.Resource{
{ResourceId: 301, ResourceType: "badge", Status: "active", ManagerGrantEnabled: false, MetadataJson: `{"badge_form":"strip"}`},
{ResourceId: 302, ResourceType: "badge", Status: "active", ManagerGrantEnabled: false, MetadataJson: `{"badge_form":"tile"}`},
{ResourceId: 301, ResourceCode: "event_host_strip", ResourceType: "badge", Name: "Host Badge", Status: "active", ManagerGrantEnabled: false, MetadataJson: `{"badge_form":"strip"}`},
{ResourceId: 302, ResourceCode: "activity_tile", ResourceType: "badge", Name: "Activity Badge", Status: "active", ManagerGrantEnabled: false, MetadataJson: `{"badge_form":"tile"}`},
{ResourceId: 303, ResourceCode: "VIP1_Long_Badge", ResourceType: "badge", Name: "VIP1_Long Badge", Status: "active", ManagerGrantEnabled: false, MetadataJson: `{"badge_form":"strip"}`},
{ResourceId: 304, ResourceCode: "vip1badge", ResourceType: "badge", Name: "VIP1badge", Status: "active", ManagerGrantEnabled: false, MetadataJson: `{"badge_form":"tile"}`},
}}, nil
default:
return &walletv1.ListResourcesResponse{}, nil
@ -178,9 +181,11 @@ func TestGenerateDiceRobotsUsesLikeiProfiles(t *testing.T) {
}
}
for _, resourceID := range append(wallets.grants, wallets.equips...) {
// 165 和 175 是资源库里的有效头像框,但创建机器人不能随机抽中,避免批量机器人展示这两款运营保留资源。
if resourceID == 165 || resourceID == 175 {
t.Fatalf("robot appearance must exclude avatar frame %d from random grants/equips", resourceID)
// 165/175 是资源库里的有效头像框但属于运营保留资源176 超过机器人头像框价格上限;
// 301/302 是非 VIP 徽章。创建机器人随机池必须同时排除这些资源,避免新账号继续带出旧规则。
switch resourceID {
case 165, 175, 176, 301, 302:
t.Fatalf("robot appearance must exclude resource %d from random grants/equips", resourceID)
}
}
}