862 lines
34 KiB
Python
Executable File
862 lines
34 KiB
Python
Executable File
#!/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())
|