增加相关配置

This commit is contained in:
zhx 2026-06-02 16:05:38 +08:00
parent f5f2b828c6
commit b24cc5a336
28 changed files with 1470 additions and 70 deletions

View File

@ -8991,6 +8991,9 @@ type VipRewardItem struct {
Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"`
Quantity int64 `protobuf:"varint,5,opt,name=quantity,proto3" json:"quantity,omitempty"` Quantity int64 `protobuf:"varint,5,opt,name=quantity,proto3" json:"quantity,omitempty"`
ExpiresAtMs int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"` ExpiresAtMs int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"`
AssetUrl string `protobuf:"bytes,7,opt,name=asset_url,json=assetUrl,proto3" json:"asset_url,omitempty"`
PreviewUrl string `protobuf:"bytes,8,opt,name=preview_url,json=previewUrl,proto3" json:"preview_url,omitempty"`
AnimationUrl string `protobuf:"bytes,9,opt,name=animation_url,json=animationUrl,proto3" json:"animation_url,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -9067,6 +9070,27 @@ func (x *VipRewardItem) GetExpiresAtMs() int64 {
return 0 return 0
} }
func (x *VipRewardItem) GetAssetUrl() string {
if x != nil {
return x.AssetUrl
}
return ""
}
func (x *VipRewardItem) GetPreviewUrl() string {
if x != nil {
return x.PreviewUrl
}
return ""
}
func (x *VipRewardItem) GetAnimationUrl() string {
if x != nil {
return x.AnimationUrl
}
return ""
}
type VipLevel struct { type VipLevel struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"` Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"`
@ -13451,7 +13475,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\n" + "\n" +
"withdrawal\x18\x01 \x01(\v2\".hyapp.wallet.v1.WithdrawalRequestR\n" + "withdrawal\x18\x01 \x01(\v2\".hyapp.wallet.v1.WithdrawalRequestR\n" +
"withdrawal\x127\n" + "withdrawal\x127\n" +
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\"\xce\x01\n" + "\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\"\xb1\x02\n" +
"\rVipRewardItem\x12\x1f\n" + "\rVipRewardItem\x12\x1f\n" +
"\vresource_id\x18\x01 \x01(\x03R\n" + "\vresource_id\x18\x01 \x01(\x03R\n" +
"resourceId\x12#\n" + "resourceId\x12#\n" +
@ -13459,7 +13483,11 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\rresource_type\x18\x03 \x01(\tR\fresourceType\x12\x12\n" + "\rresource_type\x18\x03 \x01(\tR\fresourceType\x12\x12\n" +
"\x04name\x18\x04 \x01(\tR\x04name\x12\x1a\n" + "\x04name\x18\x04 \x01(\tR\x04name\x12\x1a\n" +
"\bquantity\x18\x05 \x01(\x03R\bquantity\x12\"\n" + "\bquantity\x18\x05 \x01(\x03R\bquantity\x12\"\n" +
"\rexpires_at_ms\x18\x06 \x01(\x03R\vexpiresAtMs\"\xfc\x04\n" + "\rexpires_at_ms\x18\x06 \x01(\x03R\vexpiresAtMs\x12\x1b\n" +
"\tasset_url\x18\a \x01(\tR\bassetUrl\x12\x1f\n" +
"\vpreview_url\x18\b \x01(\tR\n" +
"previewUrl\x12#\n" +
"\ranimation_url\x18\t \x01(\tR\fanimationUrl\"\xfc\x04\n" +
"\bVipLevel\x12\x14\n" + "\bVipLevel\x12\x14\n" +
"\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" + "\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +

View File

@ -1007,6 +1007,9 @@ message VipRewardItem {
string name = 4; string name = 4;
int64 quantity = 5; int64 quantity = 5;
int64 expires_at_ms = 6; int64 expires_at_ms = 6;
string asset_url = 7;
string preview_url = 8;
string animation_url = 9;
} }
message VipLevel { message VipLevel {

View File

@ -0,0 +1,92 @@
{"at_ms":1780381127156,"mode":"dry-run","to_account":"424242","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"12345678","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"880585934","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"880585935","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"881586014","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"881586015","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"882586332","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"882586333","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990000001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990000002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686004","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686005","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686006","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686007","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686008","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686009","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686010","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687004","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687005","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687006","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687007","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687008","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687009","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687010","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688004","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688005","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688006","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688007","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688008","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688009","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688010","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971589","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971590","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971617","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971618","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971718","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971719","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651195","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651196","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651197","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651198","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935471","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935472","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935473","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935474","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936181","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936182","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936183","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936184","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"930000000001","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"930000000002","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975824912277","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975824912278","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975825864464","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975825864465","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975827047904","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975827047905","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975828064123","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975828064124","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990778061358","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990778062287","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"991205120003","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778070991803","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778070991804","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778071196761","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778071196762","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865153","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865154","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865155","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865156","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865157","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865158","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865159","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865160","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865161","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865162","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318705991371722752","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707093894860800","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707097803952128","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707100643495936","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707103256547328","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707105987039232","status":"dry_run"}
{"at_ms":1780381127156,"mode":"dry-run","to_account":"320078559995498496","status":"dry_run"}

View File

@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Grant COIN to every current production user.
This script intentionally creates a fresh command_id for every run. Running it
twice credits users twice. If a run fails halfway, re-running it credits the
already processed users again, matching the requested non-idempotent behavior.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import shlex
import subprocess
import time
import uuid
from pathlib import Path
from typing import Any
import pymysql
import yaml
ASSET_COIN = "COIN"
BIZ_TYPE_MANUAL_CREDIT = "manual_credit"
OUTBOX_STATUS_PENDING = "pending"
TRANSACTION_STATUS_SUCCEEDED = "succeeded"
MAX_INT64 = 2**63 - 1
def normalize_app_code(value: str) -> str:
value = (value or "").strip().lower()
return value or "lalu"
def stable_hash(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def transaction_id(app_code: str, command_id: str) -> str:
return "wtx_" + stable_hash(f"{normalize_app_code(app_code)}|{command_id}")
def event_id(tx_id: str, event_type: str, user_id: int, asset_type: str) -> str:
return "wev_" + stable_hash(f"{tx_id}|{event_type}|{user_id}|{asset_type}")
def admin_credit_request_hash(
app_code: str,
target_user_id: int,
asset_type: str,
amount: int,
operator_user_id: int,
reason: str,
evidence_ref: str,
) -> str:
return stable_hash(
f"admin_credit|{normalize_app_code(app_code)}|{target_user_id}|{asset_type}|{amount}|{operator_user_id}|{reason}|{evidence_ref}"
)
def load_yaml(path: str) -> dict[str, Any]:
payload = yaml.safe_load(read_config_text(path)) or {}
if not isinstance(payload, dict):
raise RuntimeError(f"config must be a YAML object: {path}")
return payload
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 parse_go_mysql_dsn(dsn: str) -> dict[str, Any]:
# Supports the project form: USER:PASSWORD@tcp(HOST:PORT)/DATABASE?params.
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": 60,
"write_timeout": 60,
}
def connect_from_config(path: str):
cfg = load_yaml(path)
dsn = str(cfg.get("mysql_dsn") or "").strip()
if not dsn:
raise RuntimeError(f"mysql_dsn is required in {path}")
return pymysql.connect(**parse_go_mysql_dsn(dsn))
def current_ms() -> int:
return int(time.time() * 1000)
def fetch_users(conn, app_code: str, statuses: list[str]) -> list[int]:
where = ["app_code = %s"]
args: list[Any] = [app_code]
if statuses:
where.append("status IN (" + ",".join(["%s"] * len(statuses)) + ")")
args.extend(statuses)
with conn.cursor() as cursor:
cursor.execute(f"SELECT user_id FROM users WHERE {' AND '.join(where)} ORDER BY user_id ASC", args)
return [int(row[0]) for row in cursor.fetchall()]
def fetch_user_status_counts(conn, app_code: str) -> dict[str, int]:
with conn.cursor() as cursor:
cursor.execute("SELECT status, COUNT(*) FROM users WHERE app_code = %s GROUP BY status ORDER BY status", (app_code,))
return {str(status): int(count) for status, count in cursor.fetchall()}
def grant_one(conn, *, app_code: str, user_id: int, amount: int, operator_user_id: int, reason: str, evidence_ref: str, run_id: str) -> tuple[str, int]:
command_id = f"bulk_coin_grant:{run_id}:{user_id}"
tx_id = transaction_id(app_code, command_id)
req_hash = admin_credit_request_hash(app_code, user_id, ASSET_COIN, amount, operator_user_id, reason, evidence_ref)
now_ms = current_ms()
metadata = {
"app_code": app_code,
"target_user_id": user_id,
"asset_type": ASSET_COIN,
"amount": amount,
"operator_user_id": operator_user_id,
"reason": reason,
"evidence_ref": evidence_ref,
}
cursor = conn.cursor()
try:
cursor.execute("START TRANSACTION")
# Positive manual credit may create a missing COIN account; version then advances exactly like wallet-service.
cursor.execute(
"""
INSERT IGNORE INTO wallet_accounts (
app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms
) VALUES (%s, %s, %s, 0, 0, 1, %s, %s)
""",
(app_code, user_id, ASSET_COIN, now_ms, now_ms),
)
cursor.execute(
"""
SELECT available_amount, frozen_amount, version
FROM wallet_accounts
WHERE app_code = %s AND user_id = %s AND asset_type = %s
FOR UPDATE
""",
(app_code, user_id, ASSET_COIN),
)
row = cursor.fetchone()
if row is None:
raise RuntimeError(f"wallet account creation failed for user_id={user_id}")
available, frozen, version = int(row[0]), int(row[1]), int(row[2])
available_after = available + amount
if available_after < 0 or available_after > MAX_INT64 - frozen:
raise RuntimeError(f"wallet balance overflow for user_id={user_id}")
cursor.execute(
"""
INSERT INTO wallet_transactions (
app_code, transaction_id, command_id, biz_type, status, request_hash, external_ref,
metadata_json, created_at_ms, updated_at_ms
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
app_code,
tx_id,
command_id,
BIZ_TYPE_MANUAL_CREDIT,
TRANSACTION_STATUS_SUCCEEDED,
req_hash,
evidence_ref,
json.dumps(metadata, separators=(",", ":"), ensure_ascii=False),
now_ms,
now_ms,
),
)
cursor.execute(
"""
UPDATE wallet_accounts
SET available_amount = %s, frozen_amount = %s, version = version + 1, updated_at_ms = %s
WHERE app_code = %s AND user_id = %s AND asset_type = %s AND version = %s
""",
(available_after, frozen, now_ms, app_code, user_id, ASSET_COIN, version),
)
if cursor.rowcount != 1:
raise RuntimeError(f"wallet account version conflict for user_id={user_id}")
cursor.execute(
"""
INSERT INTO wallet_entries (
app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta,
available_after, frozen_after, counterparty_user_id, room_id, created_at_ms
) VALUES (%s, %s, %s, %s, %s, 0, %s, %s, 0, '', %s)
""",
(app_code, tx_id, user_id, ASSET_COIN, amount, available_after, frozen, now_ms),
)
event_payload = {
"transaction_id": tx_id,
"command_id": command_id,
"user_id": user_id,
"asset_type": ASSET_COIN,
"available_delta": amount,
"frozen_delta": 0,
"available_after": available_after,
"frozen_after": frozen,
"balance_version": version + 1,
"metadata": metadata,
"created_at_ms": now_ms,
}
cursor.execute(
"""
INSERT 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, 'WalletBalanceChanged', %s, %s, %s, %s, %s, 0, %s, %s, 0, %s, %s)
""",
(
app_code,
event_id(tx_id, "WalletBalanceChanged", user_id, ASSET_COIN),
tx_id,
command_id,
user_id,
ASSET_COIN,
amount,
json.dumps(event_payload, separators=(",", ":"), ensure_ascii=False),
OUTBOX_STATUS_PENDING,
now_ms,
now_ms,
),
)
conn.commit()
return tx_id, available_after
except Exception:
conn.rollback()
raise
finally:
cursor.close()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Non-idempotently grant COIN to every current user.")
parser.add_argument(
"--user-config",
default="services/user-service/configs/config.yaml",
help="YAML containing user-service mysql_dsn. Supports local path or user@host:/path/config.yaml.",
)
parser.add_argument(
"--wallet-config",
default="services/wallet-service/configs/config.yaml",
help="YAML containing wallet-service mysql_dsn. Supports local path or user@host:/path/config.yaml.",
)
parser.add_argument("--app-code", default="lalu", help="Tenant app_code to grant.")
parser.add_argument("--amount", type=int, default=1_000_000, help="COIN amount credited to each user per execution.")
parser.add_argument("--operator-user-id", type=int, default=0, help="Required with --yes; stored in manual credit metadata.")
parser.add_argument("--reason", default="bulk coin grant to all users", help="Manual credit reason stored with every transaction.")
parser.add_argument("--evidence-ref-prefix", default="ops-bulk-coin-grant", help="Evidence reference prefix; run_id and user_id are appended.")
parser.add_argument("--user-status", action="append", default=[], help="Optional repeatable users.status filter. Omit to include all users.")
parser.add_argument("--progress-every", type=int, default=100, help="Print progress every N credited users.")
parser.add_argument("--yes", action="store_true", help="Actually write wallet credits. Omit for dry-run.")
return parser.parse_args()
def main() -> int:
args = parse_args()
app_code = normalize_app_code(args.app_code)
if args.amount <= 0 or args.amount > MAX_INT64:
raise RuntimeError("--amount must be a positive int64")
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)
try:
status_counts = fetch_user_status_counts(user_conn, app_code)
users = fetch_users(user_conn, app_code, [status.strip() for status in args.user_status if status.strip()])
summary = {
"mode": "execute" if args.yes else "dry_run",
"app_code": app_code,
"amount_per_user": args.amount,
"target_user_count": len(users),
"total_coin_delta": len(users) * args.amount,
"user_status_counts": status_counts,
"user_status_filter": args.user_status,
}
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 credit users")
return 0
run_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + "_" + uuid.uuid4().hex[:12]
credited = 0
for user_id in users:
evidence_ref = f"{args.evidence_ref_prefix}:{run_id}:{user_id}"
grant_one(
wallet_conn,
app_code=app_code,
user_id=user_id,
amount=args.amount,
operator_user_id=args.operator_user_id,
reason=args.reason.strip(),
evidence_ref=evidence_ref,
run_id=run_id,
)
credited += 1
if args.progress_every > 0 and (credited % args.progress_every == 0 or credited == len(users)):
print(json.dumps({"run_id": run_id, "credited": credited, "total": len(users)}, ensure_ascii=False))
print(json.dumps({"ok": True, "run_id": run_id, "credited": credited, "amount_per_user": args.amount}, ensure_ascii=False, indent=2))
return 0
finally:
user_conn.close()
wallet_conn.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -21,6 +21,7 @@ import (
const ( const (
uploadSniffBytes = 512 uploadSniffBytes = 512
maxBatchFiles = 10
) )
var ( var (
@ -62,6 +63,10 @@ func (h *Handler) UploadImage(c *gin.Context) {
h.uploadObject(c, imageUploadPolicy) h.uploadObject(c, imageUploadPolicy)
} }
func (h *Handler) UploadImagesBatch(c *gin.Context) {
h.uploadObjects(c, imageUploadPolicy)
}
func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) { func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) {
if h.uploader == nil { if h.uploader == nil {
response.ServerError(c, "文件上传未配置") response.ServerError(c, "文件上传未配置")
@ -109,9 +114,72 @@ func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) {
ObjectKey: objectKey, ObjectKey: objectKey,
ContentType: contentType, ContentType: contentType,
SizeBytes: header.Size, SizeBytes: header.Size,
Filename: header.Filename,
}) })
} }
func (h *Handler) uploadObjects(c *gin.Context, policy uploadPolicy) {
if h.uploader == nil {
response.ServerError(c, "文件上传未配置")
return
}
headers, ok := parseUploadFiles(c)
if !ok {
return
}
defer func() {
if c.Request.MultipartForm != nil {
_ = c.Request.MultipartForm.RemoveAll()
}
}()
actor := shared.ActorFromContext(c)
results := make([]uploadResult, 0, len(headers))
for _, header := range headers {
if header.Size <= 0 {
response.BadRequest(c, "文件大小不正确")
return
}
file, err := header.Open()
if err != nil {
response.BadRequest(c, "文件参数不正确")
return
}
head, contentType, detectedContentType, sniffOK := sniffUploadContent(c, file, header)
if !sniffOK {
_ = file.Close()
return
}
if policy.requireImage {
contentType = resolveImageUploadContentType(header.Filename, contentType, detectedContentType)
}
if !validateUploadContent(policy, header.Filename, contentType) {
_ = file.Close()
response.BadRequest(c, "文件类型不支持")
return
}
objectKey := buildUploadObjectKey(h.objectPrefix, policy, actor.UserID, header.Filename, contentType, time.Now())
objectURL, err := h.uploader.PutObject(c.Request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType)
_ = file.Close()
if err != nil {
response.ServerError(c, "文件上传失败")
return
}
results = append(results, uploadResult{
URL: objectURL,
ObjectKey: objectKey,
ContentType: contentType,
SizeBytes: header.Size,
Filename: header.Filename,
})
}
shared.OperationLogWithResourceID(c, h.audit, "batch-upload-file", "cos_objects", "batch", "success",
fmt.Sprintf("kind=%s count=%d", policy.kind, len(results)))
response.OK(c, results)
}
func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, bool) { func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, bool) {
if err := c.Request.ParseMultipartForm(1 << 20); err != nil { if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
response.BadRequest(c, "文件参数不正确") response.BadRequest(c, "文件参数不正确")
@ -127,6 +195,33 @@ func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, boo
return nil, nil, false return nil, nil, false
} }
func parseUploadFiles(c *gin.Context) ([]*multipart.FileHeader, bool) {
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
response.BadRequest(c, "文件参数不正确")
return nil, false
}
if c.Request.MultipartForm == nil || len(c.Request.MultipartForm.File) == 0 {
response.BadRequest(c, "文件参数不正确")
return nil, false
}
headers := make([]*multipart.FileHeader, 0, maxBatchFiles)
for _, field := range []string{"files", "file", "images", "image"} {
for _, header := range c.Request.MultipartForm.File[field] {
headers = append(headers, header)
}
}
if len(headers) == 0 {
response.BadRequest(c, "文件参数不正确")
return nil, false
}
if len(headers) > maxBatchFiles {
response.BadRequest(c, "一次最多上传 10 个素材")
return nil, false
}
return headers, true
}
func sniffUploadContent(c *gin.Context, file multipart.File, header *multipart.FileHeader) ([]byte, string, string, bool) { func sniffUploadContent(c *gin.Context, file multipart.File, header *multipart.FileHeader) ([]byte, string, string, bool) {
head := make([]byte, uploadSniffBytes) head := make([]byte, uploadSniffBytes)
n, err := io.ReadFull(file, head) n, err := io.ReadFull(file, head)

View File

@ -20,6 +20,7 @@ import (
type fakeUploader struct { type fakeUploader struct {
contentType string contentType string
key string key string
keys []string
sizeBytes int64 sizeBytes int64
body []byte body []byte
} }
@ -30,6 +31,7 @@ func (u *fakeUploader) PutObject(_ context.Context, key string, reader io.Reader
return "", err return "", err
} }
u.key = key u.key = key
u.keys = append(u.keys, key)
u.body = body u.body = body
u.sizeBytes = sizeBytes u.sizeBytes = sizeBytes
u.contentType = contentType u.contentType = contentType
@ -138,6 +140,53 @@ func TestUploadImageAllowsPayloadOverPreviousImageLimit(t *testing.T) {
} }
} }
func TestUploadImagesBatchUploadsSeriallyAndLimitsCount(t *testing.T) {
uploader := &fakeUploader{}
handler := New(uploader, "admin", fakeAudit{})
router := ginRouter(handler)
body, contentType := multipartBatchUploadBody(t, []string{"one.png", "two.pag"})
req := httptest.NewRequest(http.MethodPost, "/upload-images-batch", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
if len(uploader.keys) != 2 {
t.Fatalf("expected 2 uploads, got %d", len(uploader.keys))
}
var envelope struct {
Code int `json:"code"`
Data []struct {
Filename string `json:"filename"`
URL string `json:"url"`
} `json:"data"`
}
if err := json.NewDecoder(rec.Body).Decode(&envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(envelope.Data) != 2 || envelope.Data[0].Filename != "one.png" || envelope.Data[1].Filename != "two.pag" {
t.Fatalf("unexpected response order: %+v", envelope.Data)
}
tooMany := make([]string, 11)
for index := range tooMany {
tooMany[index] = "asset.png"
}
body, contentType = multipartBatchUploadBody(t, tooMany)
req = httptest.NewRequest(http.MethodPost, "/upload-images-batch", body)
req.Header.Set("Content-Type", contentType)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("too many status = %d body=%s", rec.Code, rec.Body.String())
}
}
func ginRouter(handler *Handler) http.Handler { func ginRouter(handler *Handler) http.Handler {
router := gin.New() router := gin.New()
router.Use(func(c *gin.Context) { router.Use(func(c *gin.Context) {
@ -146,6 +195,7 @@ func ginRouter(handler *Handler) http.Handler {
c.Next() c.Next()
}) })
router.POST("/upload-image", handler.UploadImage) router.POST("/upload-image", handler.UploadImage)
router.POST("/upload-images-batch", handler.UploadImagesBatch)
return router return router
} }
@ -168,3 +218,31 @@ func multipartUploadBody(t *testing.T, fieldName string, filename string, conten
} }
return body, writer.FormDataContentType() return body, writer.FormDataContentType()
} }
func multipartBatchUploadBody(t *testing.T, filenames []string) (*bytes.Buffer, string) {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for _, filename := range filenames {
contentType := "image/png"
payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, 8)...)
if strings.HasSuffix(filename, ".pag") {
contentType = "application/octet-stream"
payload = []byte("PAG animated asset")
}
part, err := writer.CreatePart(map[string][]string{
"Content-Disposition": {`form-data; name="files"; filename="` + filename + `"`},
"Content-Type": {contentType},
})
if err != nil {
t.Fatalf("create multipart part: %v", err)
}
if _, err := part.Write(payload); err != nil {
t.Fatalf("write multipart part: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
return body, writer.FormDataContentType()
}

View File

@ -11,4 +11,5 @@ type uploadResult struct {
ObjectKey string `json:"object_key"` ObjectKey string `json:"object_key"`
ContentType string `json:"content_type"` ContentType string `json:"content_type"`
SizeBytes int64 `json:"size_bytes"` SizeBytes int64 `json:"size_bytes"`
Filename string `json:"filename,omitempty"`
} }

View File

@ -13,4 +13,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/files/upload", middleware.RequirePermission("upload:create"), h.UploadFile) protected.POST("/admin/files/upload", middleware.RequirePermission("upload:create"), h.UploadFile)
protected.POST("/admin/files/image/upload", middleware.RequirePermission("upload:create"), h.UploadImage) protected.POST("/admin/files/image/upload", middleware.RequirePermission("upload:create"), h.UploadImage)
protected.POST("/admin/files/image/batch-upload", middleware.RequirePermission("upload:create"), h.UploadImagesBatch)
} }

View File

@ -197,24 +197,3 @@ CREATE TABLE IF NOT EXISTS game_outbox (
KEY idx_game_outbox_retention(app_code, status, updated_at_ms, event_id), KEY idx_game_outbox_retention(app_code, status, updated_at_ms, event_id),
KEY idx_game_outbox_order(app_code, order_id) KEY idx_game_outbox_order(app_code, order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏统计事件 outbox 表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏统计事件 outbox 表';
INSERT INTO game_platforms (
app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo', 'Demo Games', 'active', 'https://game.example.local/h5', 10, 0, 0
) ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name);
INSERT INTO game_catalog (
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo_rocket_001', 'demo', 'rocket_001', 'Rocket', 'casual',
'https://cdn.example.com/game/rocket.png', 'https://cdn.example.com/game/rocket-cover.png',
'h5_popup', 'portrait', 100, 'active', 10, JSON_ARRAY('demo','voice_room'), 0, 0
) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name);
INSERT INTO game_display_rules (
app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo_rocket_voice_default', 'demo_rocket_001', 'voice_room', 0, '', '', 1, 1, 10, 0, 0
) ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled);

View File

@ -263,7 +263,7 @@ func (s *Service) handleBaishunGetSSToken(ctx context.Context, app string, confi
} }
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
if err != nil { if err != nil {
return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeFromError(err), baishunMessageFromError(err), true, "") return baishunAdapterError(req.GetMeta().GetRequestId(), baishunUserSnapshotCodeFromError(err), baishunUserSnapshotMessageFromError(err), true, "")
} }
response := map[string]any{ response := map[string]any{
"code": baishunCodeOK, "code": baishunCodeOK,
@ -289,7 +289,7 @@ func (s *Service) handleBaishunUserInfo(ctx context.Context, app string, config
} }
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID) snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
if err != nil { if err != nil {
return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeFromError(err), baishunMessageFromError(err), true, "") return baishunAdapterError(req.GetMeta().GetRequestId(), baishunUserSnapshotCodeFromError(err), baishunUserSnapshotMessageFromError(err), true, "")
} }
raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeOK, baishunDefaultSuccessMessage, baishunUserInfoData(session, config, snapshot)) raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeOK, baishunDefaultSuccessMessage, baishunUserInfoData(session, config, snapshot))
return raw, contentType, strconv.Itoa(baishunCodeOK), true, "", nil return raw, contentType, strconv.Itoa(baishunCodeOK), true, "", nil
@ -466,3 +466,17 @@ func baishunMessageFromError(err error) string {
return "api server error" return "api server error"
} }
} }
func baishunUserSnapshotCodeFromError(err error) int {
if xerr.IsCode(err, xerr.NotFound) {
return baishunCodeInvalidData
}
return baishunCodeFromError(err)
}
func baishunUserSnapshotMessageFromError(err error) string {
if xerr.IsCode(err, xerr.NotFound) {
return "token invalid"
}
return baishunMessageFromError(err)
}

View File

@ -704,13 +704,14 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) { if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
// 百顺前端规范以 NativeBridge.getConfig 为准;这里把同一份配置也拼到 URL方便 Flutter/H5 壳层读取。 // 百顺前端规范以 NativeBridge.getConfig 为准;这里把同一份配置也拼到 URL方便 Flutter/H5 壳层读取。
config := baishunConfigFromPlatform(game) config := baishunConfigFromPlatform(game)
query.Set("bridge_keys", "appChannel,appId,code,gameMode,gsp,language,roomId,sceneMode") query.Set("bridge_keys", "appChannel,appId,userId,code,gameMode,gsp,language,roomId,sceneMode,currencyIcon,isDataByUrl")
query.Set("appChannel", config.AppChannel) query.Set("appChannel", config.AppChannel)
if config.AppID > 0 { if config.AppID > 0 {
query.Set("appId", strconv.FormatInt(config.AppID, 10)) query.Set("appId", strconv.FormatInt(config.AppID, 10))
} }
query.Set("userId", externalUserID(session, config.UIDMode)) query.Set("userId", externalUserID(session, config.UIDMode))
query.Set("code", token) query.Set("code", token)
query.Set("isDataByUrl", "1")
if session.RoomID != "" { if session.RoomID != "" {
query.Set("roomId", session.RoomID) query.Set("roomId", session.RoomID)
} }

View File

@ -323,7 +323,8 @@ func TestLaunchGameBuildsBaishunURL(t *testing.T) {
"gameMode": "3", "gameMode": "3",
"language": "2", "language": "2",
"sceneMode": "1", "sceneMode": "1",
"bridge_keys": "appChannel,appId,code,gameMode,gsp,language,roomId,sceneMode", "isDataByUrl": "1",
"bridge_keys": "appChannel,appId,userId,code,gameMode,gsp,language,roomId,sceneMode,currencyIcon,isDataByUrl",
"currencyIcon": "https://cdn.example/coin.png", "currencyIcon": "https://cdn.example/coin.png",
"gsp": "101", "gsp": "101",
} }
@ -1049,6 +1050,51 @@ func TestHandleBaishunAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
} }
} }
func TestHandleBaishunGetSSTokenReturnsTokenInvalidWhenUserMissing(t *testing.T) {
secret := "baishun-test-app-key"
token := "app_access_token_baishun"
repo := &fakeRepository{
platform: gamedomain.Platform{
PlatformCode: "baishun",
AdapterType: gamedomain.AdapterBaishunV1,
CallbackSecretCiphertext: secret,
AdapterConfigJSON: `{"app_id":21397507,"app_channel":"mesh","uid_mode":"display_user_id"}`,
},
session: gamedomain.LaunchSession{
AppCode: "lalu",
SessionID: "game_sess_baishun_missing_user",
UserID: 42,
DisplayUserID: "420001",
RoomID: "room_1",
PlatformCode: "baishun",
GameID: "baishun_greedy",
ProviderGameID: "1006",
LaunchTokenHash: stableHash(token),
Status: gamedomain.SessionActive,
ExpiresAtMS: 1700086400000,
},
}
svc := New(Config{}, repo, &fakeWallet{}, &fakeUser{err: xerr.New(xerr.NotFound, "user not found")})
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
Meta: &gamev1.RequestMeta{RequestId: "req-baishun-missing-user", AppCode: "lalu"},
PlatformCode: "baishun",
Operation: "get_sstoken",
RawBody: baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","code":"app_access_token_baishun"}`, 1700000000),
RemoteAddr: "3.1.174.194:443",
})
if err != nil {
t.Fatalf("HandleCallback get_sstoken failed: %v", err)
}
if contentType != "application/json" || !strings.Contains(string(raw), `"code":1001`) || !strings.Contains(string(raw), `"message":"token invalid"`) {
t.Fatalf("baishun missing user response mismatch: %s", raw)
}
if strings.Contains(string(raw), `"code":1002`) || strings.Contains(string(raw), "read data failed") {
t.Fatalf("baishun missing user must not look like read data failure: %s", raw)
}
}
func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) { func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) {
repo := &fakeRepository{ repo := &fakeRepository{
launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}}, launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}},
@ -1313,10 +1359,14 @@ func (f *fakeActivity) ConsumeLevelEvent(_ context.Context, req *activityv1.Cons
type fakeUser struct { type fakeUser struct {
lastGet *userv1.GetUserRequest lastGet *userv1.GetUserRequest
err error
} }
func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) { func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
f.lastGet = req f.lastGet = req
if f.err != nil {
return nil, f.err
}
return &userv1.GetUserResponse{User: &userv1.User{ return &userv1.GetUserResponse{User: &userv1.User{
UserId: req.GetUserId(), UserId: req.GetUserId(),
DisplayUserId: "420001", DisplayUserId: "420001",

View File

@ -285,7 +285,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_retention(app_code, status, updated_at_ms, event_id)"); err != nil { if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_retention(app_code, status, updated_at_ms, event_id)"); err != nil {
return err return err
} }
return r.seedDefaults(ctx) return nil
} }
func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error { func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error {
@ -352,33 +352,6 @@ func equalStringSlices(left []string, right []string) bool {
return true return true
} }
func (r *Repository) seedDefaults(ctx context.Context) error {
if _, err := r.db.ExecContext(ctx,
`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms)
VALUES ('lalu', 'demo', 'Demo Games', 'active', 'https://game.example.local/h5', 10, 0, 0)
ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name)`); err != nil {
return err
}
if _, err := r.db.ExecContext(ctx,
`INSERT INTO game_catalog (
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo_rocket_001', 'demo', 'rocket_001', 'Rocket', 'casual',
'https://cdn.example.com/game/rocket.png', 'https://cdn.example.com/game/rocket-cover.png',
'h5_popup', 'portrait', 100, 'active', 10, JSON_ARRAY('demo','voice_room'), 0, 0
) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name)`); err != nil {
return err
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO game_display_rules (
app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms
) VALUES (
'lalu', 'demo_rocket_voice_default', 'demo_rocket_001', 'voice_room', 0, '', '', 1, 1, 10, 0, 0
) ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled)`)
return err
}
func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) { func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
rows, err := r.db.QueryContext(ctx, rows, err := r.db.QueryContext(ctx,
`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category, `SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,

View File

@ -15,6 +15,8 @@ cors:
- "http://127.0.0.1:3000" - "http://127.0.0.1:3000"
- "http://localhost:5173" - "http://localhost:5173"
- "http://127.0.0.1:5173" - "http://127.0.0.1:5173"
- "http://localhost:30000"
- "http://127.0.0.1:30000"
# 本地工具页会用 10300/8787/8788 承载静态 HTML精确放行测试端口避免用 "*" 破坏凭证请求边界。 # 本地工具页会用 10300/8787/8788 承载静态 HTML精确放行测试端口避免用 "*" 破坏凭证请求边界。
- "http://localhost:10300" - "http://localhost:10300"
- "http://127.0.0.1:10300" - "http://127.0.0.1:10300"

View File

@ -72,6 +72,10 @@ func (h *Handler) UploadAvatar(writer http.ResponseWriter, request *http.Request
h.uploadAvatar(writer, request) h.uploadAvatar(writer, request)
} }
func (h *Handler) ProxyEffectMedia(writer http.ResponseWriter, request *http.Request) {
h.proxyEffectMedia(writer, request)
}
func (h *Handler) HandlePushToken(writer http.ResponseWriter, request *http.Request) { func (h *Handler) HandlePushToken(writer http.ResponseWriter, request *http.Request) {
h.handlePushToken(writer, request) h.handlePushToken(writer, request)
} }

View File

@ -0,0 +1,94 @@
package appapi
import (
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const effectMediaProxyMaxBytes int64 = 30 << 20
var (
effectMediaProxyHTTPClient = &http.Client{Timeout: 15 * time.Second}
effectMediaProxyHosts = map[string]struct{}{
"media.haiyihy.com": {},
}
effectMediaProxyExtensions = map[string]string{
".pag": "application/octet-stream",
".svga": "application/octet-stream",
}
)
// proxyEffectMedia streams trusted binary animation assets through gateway so
// browser-only SVGA/PAG runtimes can read them even when the media CDN has no CORS.
func (h *Handler) proxyEffectMedia(writer http.ResponseWriter, request *http.Request) {
rawURL := strings.TrimSpace(request.URL.Query().Get("url"))
sourceURL, contentType, ok := validateEffectMediaProxyURL(rawURL)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
upstreamRequest, err := http.NewRequestWithContext(request.Context(), http.MethodGet, sourceURL.String(), nil)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
upstreamRequest.Header.Set("Accept", "application/octet-stream,*/*")
upstreamRequest.Header.Set("User-Agent", "hyapp-gateway-effect-media-proxy/1.0")
upstream, err := effectMediaProxyHTTPClient.Do(upstreamRequest)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
defer upstream.Body.Close()
if upstream.StatusCode < http.StatusOK || upstream.StatusCode >= http.StatusMultipleChoices {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
if upstream.ContentLength > effectMediaProxyMaxBytes {
httpkit.WriteError(writer, request, http.StatusRequestEntityTooLarge, httpkit.CodeInvalidArgument, "invalid argument")
return
}
if value := strings.TrimSpace(upstream.Header.Get("Content-Type")); value != "" {
contentType = value
}
header := writer.Header()
header.Set("Content-Type", contentType)
header.Set("Cache-Control", "public, max-age=86400")
header.Set("X-Content-Type-Options", "nosniff")
if upstream.ContentLength >= 0 {
header.Set("Content-Length", strconv.FormatInt(upstream.ContentLength, 10))
}
writer.WriteHeader(http.StatusOK)
_, _ = io.Copy(writer, io.LimitReader(upstream.Body, effectMediaProxyMaxBytes+1))
}
func validateEffectMediaProxyURL(rawURL string) (*url.URL, string, bool) {
sourceURL, err := url.Parse(rawURL)
if err != nil || sourceURL == nil {
return nil, "", false
}
if sourceURL.Scheme != "https" {
return nil, "", false
}
host := strings.ToLower(sourceURL.Hostname())
if _, ok := effectMediaProxyHosts[host]; !ok {
return nil, "", false
}
contentType, ok := effectMediaProxyExtensions[strings.ToLower(path.Ext(sourceURL.Path))]
if !ok {
return nil, "", false
}
return sourceURL, contentType, true
}

View File

@ -0,0 +1,46 @@
package appapi
import "testing"
func TestValidateEffectMediaProxyURL(t *testing.T) {
tests := []struct {
name string
url string
ok bool
}{
{
name: "svga media host",
url: "https://media.haiyihy.com/admin/files/1/20260602/file.svga",
ok: true,
},
{
name: "pag media host",
url: "https://media.haiyihy.com/admin/files/1/20260602/file.pag?sign=1",
ok: true,
},
{
name: "reject http",
url: "http://media.haiyihy.com/admin/files/1/file.svga",
ok: false,
},
{
name: "reject untrusted host",
url: "https://example.com/admin/files/1/file.svga",
ok: false,
},
{
name: "reject regular image",
url: "https://media.haiyihy.com/admin/files/1/file.png",
ok: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, _, ok := validateEffectMediaProxyURL(test.url)
if ok != test.ok {
t.Fatalf("ok mismatch: got=%t want=%t", ok, test.ok)
}
})
}
}

View File

@ -57,6 +57,7 @@ type AppHandlers struct {
HandleTencentRTCCallback http.HandlerFunc HandleTencentRTCCallback http.HandlerFunc
UploadFile http.HandlerFunc UploadFile http.HandlerFunc
UploadAvatar http.HandlerFunc UploadAvatar http.HandlerFunc
ProxyEffectMedia http.HandlerFunc
HandlePushToken http.HandlerFunc HandlePushToken http.HandlerFunc
} }
@ -276,6 +277,7 @@ func (r routes) registerAppRoutes() {
r.public("/tencent-rtc/callback", "", h.HandleTencentRTCCallback) r.public("/tencent-rtc/callback", "", h.HandleTencentRTCCallback)
r.auth("/files/upload", "", h.UploadFile) r.auth("/files/upload", "", h.UploadFile)
r.auth("/files/avatar/upload", "", h.UploadAvatar) r.auth("/files/avatar/upload", "", h.UploadAvatar)
r.public("/media/effects/proxy", http.MethodGet, h.ProxyEffectMedia)
r.profile("/devices/push-token", "", h.HandlePushToken) r.profile("/devices/push-token", "", h.HandlePushToken)
} }

View File

@ -149,6 +149,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
HandleTencentRTCCallback: callbackAPI.HandleTencentRTCCallback, HandleTencentRTCCallback: callbackAPI.HandleTencentRTCCallback,
UploadFile: appAPI.UploadFile, UploadFile: appAPI.UploadFile,
UploadAvatar: appAPI.UploadAvatar, UploadAvatar: appAPI.UploadAvatar,
ProxyEffectMedia: appAPI.ProxyEffectMedia,
HandlePushToken: appAPI.HandlePushToken, HandlePushToken: appAPI.HandlePushToken,
}, },
User: userHandlers, User: userHandlers,

View File

@ -175,6 +175,10 @@ type vipRewardItemData struct {
Name string `json:"name"` Name string `json:"name"`
Quantity int64 `json:"quantity"` Quantity int64 `json:"quantity"`
ExpiresAtMS int64 `json:"expires_at_ms"` ExpiresAtMS int64 `json:"expires_at_ms"`
CoverURL string `json:"cover_url"`
AssetURL string `json:"asset_url"`
PreviewURL string `json:"preview_url"`
AnimationURL string `json:"animation_url"`
} }
// getWalletOverview 返回钱包二级页摘要;我的页首屏使用更轻的 WalletValueSummary。 // getWalletOverview 返回钱包二级页摘要;我的页首屏使用更轻的 WalletValueSummary。

View File

@ -5,9 +5,10 @@ import (
"net/http" "net/http"
"strings" "strings"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth" "hyapp/services/gateway-service/internal/auth"
walletv1 "hyapp.local/api/proto/wallet/v1"
) )
// getMyVIP 返回当前用户 VIP 状态。 // getMyVIP 返回当前用户 VIP 状态。
@ -135,7 +136,21 @@ func vipRewardItemsFromProto(items []*walletv1.VipRewardItem) []vipRewardItemDat
Name: item.GetName(), Name: item.GetName(),
Quantity: item.GetQuantity(), Quantity: item.GetQuantity(),
ExpiresAtMS: item.GetExpiresAtMs(), ExpiresAtMS: item.GetExpiresAtMs(),
CoverURL: vipRewardCoverURL(item),
AssetURL: item.GetAssetUrl(),
PreviewURL: item.GetPreviewUrl(),
AnimationURL: item.GetAnimationUrl(),
}) })
} }
return resp return resp
} }
func vipRewardCoverURL(item *walletv1.VipRewardItem) string {
if item == nil {
return ""
}
if strings.TrimSpace(item.GetPreviewUrl()) != "" {
return item.GetPreviewUrl()
}
return item.GetAssetUrl()
}

View File

@ -504,6 +504,9 @@ type VipRewardItem struct {
Name string Name string
Quantity int64 Quantity int64
ExpiresAtMS int64 ExpiresAtMS int64
AssetURL string
PreviewURL string
AnimationURL string
} }
// VipLevel 是可购买 VIP 等级配置。 // VipLevel 是可购买 VIP 等级配置。

View File

@ -2486,6 +2486,15 @@ func TestApplyWithdrawalFreezesUSDBalance(t *testing.T) {
func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) { func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
repository.SetBalance(51001, 20000) repository.SetBalance(51001, 20000)
repository.SeedVIPRewardGroup(1, 71001, []mysqltest.VipRewardResourceSeed{
{
ResourceID: 7100101,
ResourceCode: "vip1_avatar_frame",
ResourceType: resourcedomain.TypeAvatarFrame,
Name: "VIP1 Avatar Frame",
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
},
})
svc := walletservice.New(repository) svc := walletservice.New(repository)
receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{ receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
@ -2554,6 +2563,80 @@ func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
} }
} }
func TestPurchaseVipRenewalAlignsRewardEntitlementExpiry(t *testing.T) {
repository := mysqltest.NewRepository(t)
repository.SetBalance(51004, 20000)
resources := []mysqltest.VipRewardResourceSeed{
{
ResourceID: 7100401,
ResourceCode: "vip_renew_frame",
ResourceType: resourcedomain.TypeAvatarFrame,
Name: "VIP Renew Frame",
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
},
{
ResourceID: 7100402,
ResourceCode: "vip_renew_mic",
ResourceType: resourcedomain.TypeMicSeatAnimation,
Name: "VIP Renew Mic",
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
},
{
ResourceID: 7100403,
ResourceCode: "vip_renew_badge",
ResourceType: resourcedomain.TypeBadge,
Name: "VIP Renew Badge",
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
},
}
repository.SeedVIPRewardGroup(1, 71004, resources)
svc := walletservice.New(repository)
first, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip-renew-expiry-first",
UserID: 51004,
Level: 1,
})
if err != nil {
t.Fatalf("first VIP purchase failed: %v", err)
}
if len(first.RewardItems) != len(resources) {
t.Fatalf("first reward item count mismatch: %+v", first.RewardItems)
}
for _, resource := range resources {
expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID)
if expiresAt != first.Vip.ExpiresAtMS {
t.Fatalf("first entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, first.Vip.ExpiresAtMS)
}
}
renew, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
CommandID: "cmd-vip-renew-expiry-second",
UserID: 51004,
Level: 1,
})
if err != nil {
t.Fatalf("VIP renewal failed: %v", err)
}
if renew.Vip.ExpiresAtMS != first.Vip.ExpiresAtMS+604800000 {
t.Fatalf("renewed VIP expiry mismatch: first=%d renewed=%d", first.Vip.ExpiresAtMS, renew.Vip.ExpiresAtMS)
}
if len(renew.RewardItems) != len(resources) {
t.Fatalf("renew reward item count mismatch: %+v", renew.RewardItems)
}
for _, item := range renew.RewardItems {
if item.ExpiresAtMS != renew.Vip.ExpiresAtMS {
t.Fatalf("renew reward response expiry mismatch for %d: got %d want %d", item.ResourceID, item.ExpiresAtMS, renew.Vip.ExpiresAtMS)
}
}
for _, resource := range resources {
expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID)
if expiresAt != renew.Vip.ExpiresAtMS {
t.Fatalf("renew entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, renew.Vip.ExpiresAtMS)
}
}
}
// TestGrantVipUsesUnifiedActivationAndNoticeOutbox 验证后台和活动赠送 VIP 走统一激活入口并写 IM outbox。 // TestGrantVipUsesUnifiedActivationAndNoticeOutbox 验证后台和活动赠送 VIP 走统一激活入口并写 IM outbox。
func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) { func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)

View File

@ -1067,6 +1067,11 @@ func (r *Repository) applyVipRewardGroup(ctx context.Context, tx *sql.Tx, comman
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if inserted.EntitlementID != "" {
if err := r.alignEntitlementExpiresAt(ctx, tx, inserted.EntitlementID, expiresAtMS, nowMs); err != nil {
return nil, "", err
}
}
rewards = append(rewards, ledger.VipRewardItem{ rewards = append(rewards, ledger.VipRewardItem{
ResourceID: item.Resource.ResourceID, ResourceID: item.Resource.ResourceID,
ResourceCode: item.Resource.ResourceCode, ResourceCode: item.Resource.ResourceCode,
@ -1074,6 +1079,9 @@ func (r *Repository) applyVipRewardGroup(ctx context.Context, tx *sql.Tx, comman
Name: item.Resource.Name, Name: item.Resource.Name,
Quantity: inserted.Quantity, Quantity: inserted.Quantity,
ExpiresAtMS: expiresAtMS, ExpiresAtMS: expiresAtMS,
AssetURL: item.Resource.AssetURL,
PreviewURL: item.Resource.PreviewURL,
AnimationURL: item.Resource.AnimationURL,
}) })
} }
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
@ -1104,6 +1112,9 @@ func (r *Repository) vipRewardItems(ctx context.Context, groupID int64, expiresA
Name: item.Resource.Name, Name: item.Resource.Name,
Quantity: item.Quantity, Quantity: item.Quantity,
ExpiresAtMS: expiresAtMS, ExpiresAtMS: expiresAtMS,
AssetURL: item.Resource.AssetURL,
PreviewURL: item.Resource.PreviewURL,
AnimationURL: item.Resource.AnimationURL,
}) })
} }
return rewards, nil return rewards, nil
@ -1151,6 +1162,9 @@ func vipRewardItemsNoticePayload(items []ledger.VipRewardItem) []map[string]any
"name": item.Name, "name": item.Name,
"quantity": item.Quantity, "quantity": item.Quantity,
"expires_at_ms": item.ExpiresAtMS, "expires_at_ms": item.ExpiresAtMS,
"asset_url": item.AssetURL,
"preview_url": item.PreviewURL,
"animation_url": item.AnimationURL,
}) })
} }
return payload return payload
@ -1217,7 +1231,8 @@ func (r *Repository) rewardItemsByGrant(ctx context.Context, tx *sql.Tx, grantID
return nil, nil return nil, nil
} }
rows, err := tx.QueryContext(ctx, ` rows, err := tx.QueryContext(ctx, `
SELECT i.resource_id, r.resource_code, r.resource_type, r.name, i.quantity SELECT i.resource_id, r.resource_code, r.resource_type, r.name, i.quantity,
COALESCE(r.asset_url, ''), COALESCE(r.preview_url, ''), COALESCE(r.animation_url, '')
FROM resource_grant_items i FROM resource_grant_items i
JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id
WHERE i.app_code = ? AND i.grant_id = ? AND i.result_type = ? WHERE i.app_code = ? AND i.grant_id = ? AND i.result_type = ?
@ -1231,7 +1246,16 @@ func (r *Repository) rewardItemsByGrant(ctx context.Context, tx *sql.Tx, grantID
items := make([]ledger.VipRewardItem, 0) items := make([]ledger.VipRewardItem, 0)
for rows.Next() { for rows.Next() {
var item ledger.VipRewardItem var item ledger.VipRewardItem
if err := rows.Scan(&item.ResourceID, &item.ResourceCode, &item.ResourceType, &item.Name, &item.Quantity); err != nil { if err := rows.Scan(
&item.ResourceID,
&item.ResourceCode,
&item.ResourceType,
&item.Name,
&item.Quantity,
&item.AssetURL,
&item.PreviewURL,
&item.AnimationURL,
); err != nil {
return nil, err return nil, err
} }
item.ExpiresAtMS = expiresAtMS item.ExpiresAtMS = expiresAtMS

View File

@ -1135,18 +1135,18 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou
durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond) durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond)
coinBalanceAfter := account.AvailableAmount - item.CoinPrice coinBalanceAfter := account.AvailableAmount - item.CoinPrice
metadata := map[string]any{ metadata := map[string]any{
"app_code": command.AppCode, "app_code": command.AppCode,
"order_id": orderID, "order_id": orderID,
"user_id": command.UserID, "user_id": command.UserID,
"shop_item_id": item.ShopItemID, "shop_item_id": item.ShopItemID,
"resource_id": item.ResourceID, "resource_id": item.ResourceID,
"resource_code": item.Resource.ResourceCode, "resource_code": item.Resource.ResourceCode,
"resource_type": item.Resource.ResourceType, "resource_type": item.Resource.ResourceType,
"duration_days": item.DurationDays, "duration_days": item.DurationDays,
"duration_ms": durationMS, "duration_ms": durationMS,
"price_type": item.PriceType, "price_type": item.PriceType,
"coin_spent": item.CoinPrice, "coin_spent": item.CoinPrice,
"balance_after": coinBalanceAfter, "balance_after": coinBalanceAfter,
"resource_grant_id": grantID, "resource_grant_id": grantID,
} }
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil { if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil {
@ -1563,11 +1563,25 @@ func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID in
return entitlementID, nil return entitlementID, nil
} }
func (r *Repository) alignEntitlementExpiresAt(ctx context.Context, tx *sql.Tx, entitlementID string, expiresAtMS int64, nowMs int64) error {
if strings.TrimSpace(entitlementID) == "" {
return nil
}
_, err := tx.ExecContext(ctx, `
UPDATE user_resource_entitlements
SET expires_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND entitlement_id = ?`,
expiresAtMS, nowMs, appcode.FromContext(ctx), entitlementID,
)
return err
}
func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMs int64) (resourcedomain.UserResourceEntitlement, bool, error) { func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMs int64) (resourcedomain.UserResourceEntitlement, bool, error) {
row := tx.QueryRowContext(ctx, ` row := tx.QueryRowContext(ctx, `
SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status, SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms, e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms,
e.source_grant_id, e.created_at_ms, e.updated_at_ms, e.source_grant_id, e.created_at_ms, e.updated_at_ms,
FALSE AS equipped,
`+resourceColumnsWithAlias("r")+` `+resourceColumnsWithAlias("r")+`
FROM user_resource_entitlements e FROM user_resource_entitlements e
JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id

View File

@ -21,6 +21,15 @@ type Repository struct {
schema *mysqlschema.Schema schema *mysqlschema.Schema
} }
type VipRewardResourceSeed struct {
ResourceID int64
ResourceCode string
ResourceType string
Name string
GrantStrategy string
SortOrder int
}
// CountRows returns a simple count from a known wallet table for persistence assertions. // CountRows returns a simple count from a known wallet table for persistence assertions.
func (r *Repository) CountRows(table string, where string, args ...any) int { func (r *Repository) CountRows(table string, where string, args ...any) int {
r.t.Helper() r.t.Helper()
@ -36,6 +45,22 @@ func (r *Repository) CountRows(table string, where string, args ...any) int {
return count return count
} }
func (r *Repository) ActiveResourceEntitlement(userID int64, resourceID int64) (expiresAtMS int64, quantity int64, remainingQuantity int64) {
r.t.Helper()
err := r.schema.DB.QueryRowContext(context.Background(), `
SELECT expires_at_ms, quantity, remaining_quantity
FROM user_resource_entitlements
WHERE user_id = ? AND resource_id = ? AND status = 'active'
ORDER BY expires_at_ms DESC, created_at_ms DESC
LIMIT 1
`, userID, resourceID).Scan(&expiresAtMS, &quantity, &remainingQuantity)
if err != nil {
r.t.Fatalf("query active resource entitlement failed: %v", err)
}
return expiresAtMS, quantity, remainingQuantity
}
// HostSalarySettlementIDs returns the persisted settlement IDs for one host/cycle in creation order. // HostSalarySettlementIDs returns the persisted settlement IDs for one host/cycle in creation order.
func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string { func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string {
r.t.Helper() r.t.Helper()
@ -191,6 +216,74 @@ func (r *Repository) SetBalance(userID int64, balance int64) {
r.SetAssetBalance(userID, "COIN", balance) r.SetAssetBalance(userID, "COIN", balance)
} }
func (r *Repository) SeedVIPRewardGroup(level int32, groupID int64, resources []VipRewardResourceSeed) {
r.t.Helper()
nowMs := time.Now().UnixMilli()
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO resource_groups (
group_id, group_code, name, status, description, sort_order,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 'active', '', 0, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)
`, groupID, fmt.Sprintf("vip_test_group_%d", groupID), fmt.Sprintf("VIP test group %d", groupID), nowMs, nowMs)
if err != nil {
r.t.Fatalf("seed vip reward group failed: %v", err)
}
if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM resource_group_items WHERE group_id = ?`, groupID); err != nil {
r.t.Fatalf("clear vip reward group items failed: %v", err)
}
for index, resource := range resources {
sortOrder := resource.SortOrder
if sortOrder == 0 {
sortOrder = (index + 1) * 10
}
name := resource.Name
if name == "" {
name = resource.ResourceCode
}
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO resources (
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,
usage_scope_json, asset_url, preview_url, animation_url,
metadata_json, sort_order, created_by_user_id, updated_by_user_id,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 'active', TRUE, TRUE, ?, '', 0, '', 0, 0,
JSON_ARRAY(), '', '', '', NULL, ?, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE
resource_type = VALUES(resource_type),
name = VALUES(name),
status = VALUES(status),
grant_strategy = VALUES(grant_strategy),
sort_order = VALUES(sort_order),
updated_at_ms = VALUES(updated_at_ms)
`, resource.ResourceID, resource.ResourceCode, resource.ResourceType, name, resource.GrantStrategy, sortOrder, nowMs, nowMs)
if err != nil {
r.t.Fatalf("seed vip reward resource failed: %v", err)
}
_, err = r.schema.DB.ExecContext(context.Background(), `
INSERT INTO resource_group_items (
group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
) VALUES (?, 'resource', ?, '', 0, 1, 0, ?, ?, ?)
`, groupID, resource.ResourceID, sortOrder, nowMs, nowMs)
if err != nil {
r.t.Fatalf("seed vip reward group item failed: %v", err)
}
}
if _, err := r.schema.DB.ExecContext(context.Background(), `
UPDATE vip_levels
SET reward_resource_group_id = ?, updated_at_ms = ?
WHERE level = ?
`, groupID, nowMs, level); err != nil {
r.t.Fatalf("update vip level reward group failed: %v", err)
}
}
// SetAssetBalance prepares a sender account for debit tests. // SetAssetBalance prepares a sender account for debit tests.
func (r *Repository) SetAssetBalance(userID int64, assetType string, balance int64) { func (r *Repository) SetAssetBalance(userID int64, assetType string, balance int64) {
r.t.Helper() r.t.Helper()

View File

@ -621,6 +621,9 @@ func vipRewardItemsToProto(items []ledger.VipRewardItem) []*walletv1.VipRewardIt
Name: item.Name, Name: item.Name,
Quantity: item.Quantity, Quantity: item.Quantity,
ExpiresAtMs: item.ExpiresAtMS, ExpiresAtMs: item.ExpiresAtMS,
AssetUrl: item.AssetURL,
PreviewUrl: item.PreviewURL,
AnimationUrl: item.AnimationURL,
}) })
} }
return resp return resp

View File

@ -0,0 +1,354 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/binary"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"hyapp/pkg/configx"
"hyapp/pkg/tencentim"
)
const (
sendC2CMsgCommand = "v4/openim/sendmsg"
confirmSendValue = "SEND_REAL_TENCENT_IM_C2C"
)
type serviceConfig struct {
MySQLDSN string `yaml:"mysql_dsn"`
TencentIM tencentIMConfig `yaml:"tencent_im"`
}
type tencentIMConfig struct {
Enabled bool `yaml:"enabled"`
SDKAppID int64 `yaml:"sdk_app_id"`
SecretKey string `yaml:"secret_key"`
AdminIdentifier string `yaml:"admin_identifier"`
Endpoint string `yaml:"endpoint"`
}
type sendTextC2CRequest struct {
SyncOtherMachine int `json:"SyncOtherMachine"`
FromAccount string `json:"From_Account,omitempty"`
ToAccount string `json:"To_Account"`
MsgRandom uint32 `json:"MsgRandom"`
MsgBody []messageElement `json:"MsgBody"`
}
type messageElement struct {
MsgType string `json:"MsgType"`
MsgContent textMsgContent `json:"MsgContent"`
}
type textMsgContent struct {
Text string `json:"Text"`
}
type restResponse struct {
ActionStatus string `json:"ActionStatus"`
ErrorInfo string `json:"ErrorInfo"`
ErrorCode int `json:"ErrorCode"`
MsgKey string `json:"MsgKey,omitempty"`
}
type resultLine struct {
AtMS int64 `json:"at_ms"`
Mode string `json:"mode"`
ToAccount string `json:"to_account"`
Status string `json:"status"`
ErrorCode int `json:"error_code,omitempty"`
ErrorInfo string `json:"error_info,omitempty"`
MsgKey string `json:"msg_key,omitempty"`
}
func main() {
if err := run(context.Background(), os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string) error {
var userConfigPath string
var imConfigPath string
var fromAccount string
var appCode string
var message string
var outputPath string
var limit int
var sleepMS int
var timeoutSeconds int
var send bool
var confirmSend string
var includeSender bool
flags := flag.NewFlagSet("im-c2c-broadcast", flag.ContinueOnError)
flags.StringVar(&userConfigPath, "user-config", "services/user-service/configs/config.yaml", "user-service YAML config used for mysql_dsn")
flags.StringVar(&imConfigPath, "im-config", "services/notice-service/configs/config.yaml", "service YAML config used for tencent_im")
flags.StringVar(&fromAccount, "from-account", "123456", "Tencent IM From_Account")
flags.StringVar(&appCode, "app-code", "lalu", "users.app_code filter")
flags.StringVar(&message, "message", "", "plain C2C text message body")
flags.StringVar(&outputPath, "output", "", "JSONL audit output path")
flags.IntVar(&limit, "limit", 0, "maximum active users to process; 0 means all")
flags.IntVar(&sleepMS, "sleep-ms", 25, "sleep between real sends")
flags.IntVar(&timeoutSeconds, "timeout-seconds", 10, "Tencent REST request timeout")
flags.BoolVar(&send, "send", false, "perform real Tencent IM sends; omitted means dry-run")
flags.StringVar(&confirmSend, "confirm-send", "", "must equal "+confirmSendValue+" when --send is set")
flags.BoolVar(&includeSender, "include-sender", false, "include from-account when it is also an active numeric user")
if err := flags.Parse(args); err != nil {
return err
}
fromAccount = strings.TrimSpace(fromAccount)
appCode = strings.TrimSpace(appCode)
message = strings.TrimSpace(message)
if fromAccount == "" || appCode == "" || message == "" {
return errors.New("from-account, app-code and message are required")
}
if send && confirmSend != confirmSendValue {
return fmt.Errorf("--send requires --confirm-send=%s", confirmSendValue)
}
if sleepMS < 0 || timeoutSeconds <= 0 || limit < 0 {
return errors.New("sleep-ms, timeout-seconds and limit are invalid")
}
userCfg, err := loadConfig(userConfigPath)
if err != nil {
return fmt.Errorf("load user config: %w", err)
}
imCfg, err := loadConfig(imConfigPath)
if err != nil {
return fmt.Errorf("load im config: %w", err)
}
if strings.TrimSpace(userCfg.MySQLDSN) == "" {
return errors.New("user config mysql_dsn is empty")
}
if err := validateTencentIMConfig(imCfg.TencentIM); err != nil {
return err
}
db, err := sql.Open("mysql", userCfg.MySQLDSN)
if err != nil {
return err
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("ping mysql: %w", err)
}
targets, totalActive, err := listActiveUserIDs(ctx, db, appCode, fromAccount, includeSender, limit)
if err != nil {
return err
}
mode := "dry-run"
if send {
mode = "send"
}
if outputPath == "" {
outputPath = filepath.Join("run", fmt.Sprintf("im-c2c-broadcast-%s.jsonl", time.Now().UTC().Format("20060102-150405")))
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
output, err := os.Create(outputPath)
if err != nil {
return err
}
defer output.Close()
fmt.Printf("mode=%s app_code=%s sdk_app_id=%d endpoint=%s from_account=%s total_active=%d targets=%d output=%s\n",
mode, appCode, imCfg.TencentIM.SDKAppID, firstNonEmpty(imCfg.TencentIM.Endpoint, tencentim.DefaultEndpoint), fromAccount, totalActive, len(targets), outputPath)
if len(targets) > 0 {
fmt.Printf("first_target=%s last_target=%s\n", targets[0], targets[len(targets)-1])
}
if !send {
for _, target := range targets {
if err := writeResult(output, resultLine{AtMS: time.Now().UTC().UnixMilli(), Mode: mode, ToAccount: target, Status: "dry_run"}); err != nil {
return err
}
}
fmt.Println("dry_run_complete=true")
return nil
}
client := &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second}
for i, target := range targets {
response, err := sendTencentText(ctx, client, imCfg.TencentIM, fromAccount, target, message)
line := resultLine{AtMS: time.Now().UTC().UnixMilli(), Mode: mode, ToAccount: target, Status: "ok"}
if err != nil {
line.Status = "failed"
line.ErrorInfo = err.Error()
} else if response.ErrorCode != 0 || !strings.EqualFold(firstNonEmpty(response.ActionStatus, "OK"), "OK") {
line.Status = "failed"
line.ErrorCode = response.ErrorCode
line.ErrorInfo = response.ErrorInfo
} else {
line.MsgKey = response.MsgKey
}
if err := writeResult(output, line); err != nil {
return err
}
if line.Status != "ok" {
fmt.Printf("target=%s status=failed error_code=%d error=%s\n", target, line.ErrorCode, line.ErrorInfo)
}
if sleepMS > 0 && i+1 < len(targets) {
time.Sleep(time.Duration(sleepMS) * time.Millisecond)
}
}
fmt.Println("send_complete=true")
return nil
}
func loadConfig(path string) (serviceConfig, error) {
var cfg serviceConfig
err := configx.LoadYAML(strings.TrimSpace(path), &cfg)
return cfg, err
}
func validateTencentIMConfig(cfg tencentIMConfig) error {
if cfg.SDKAppID <= 0 || strings.TrimSpace(cfg.SecretKey) == "" || strings.TrimSpace(cfg.AdminIdentifier) == "" {
return errors.New("tencent_im sdk_app_id, secret_key and admin_identifier are required")
}
return nil
}
func listActiveUserIDs(ctx context.Context, db *sql.DB, appCode string, fromAccount string, includeSender bool, limit int) ([]string, int64, error) {
var totalActive int64
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE app_code = ? AND status = 'active'`, appCode).Scan(&totalActive); err != nil {
return nil, 0, err
}
excludeUserID := int64(0)
if !includeSender {
if value, err := strconv.ParseInt(fromAccount, 10, 64); err == nil && value > 0 {
excludeUserID = value
}
}
query := `SELECT user_id FROM users WHERE app_code = ? AND status = 'active'`
params := []any{appCode}
if excludeUserID > 0 {
query += ` AND user_id <> ?`
params = append(params, excludeUserID)
}
query += ` ORDER BY user_id ASC`
if limit > 0 {
query += ` LIMIT ?`
params = append(params, limit)
}
rows, err := db.QueryContext(ctx, query, params...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
targets := make([]string, 0)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, 0, err
}
targets = append(targets, strconv.FormatInt(userID, 10))
}
return targets, totalActive, rows.Err()
}
func sendTencentText(ctx context.Context, client *http.Client, cfg tencentIMConfig, fromAccount string, toAccount string, text string) (restResponse, error) {
requestBody := sendTextC2CRequest{
SyncOtherMachine: 2,
FromAccount: fromAccount,
ToAccount: toAccount,
MsgRandom: randomUint32(),
MsgBody: []messageElement{
{MsgType: "TIMTextElem", MsgContent: textMsgContent{Text: text}},
},
}
body, err := json.Marshal(requestBody)
if err != nil {
return restResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(cfg, sendC2CMsgCommand), bytes.NewReader(body))
if err != nil {
return restResponse{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return restResponse{}, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return restResponse{}, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return restResponse{}, fmt.Errorf("tencent im http status %d: %s", resp.StatusCode, string(respBody))
}
var parsed restResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return restResponse{}, err
}
return parsed, nil
}
func endpoint(cfg tencentIMConfig, command string) string {
adminSig, _ := tencentim.GenerateUserSig(tencentim.UserSigConfig{
SDKAppID: cfg.SDKAppID,
SecretKey: cfg.SecretKey,
TTL: 24 * time.Hour,
}, strings.TrimSpace(cfg.AdminIdentifier), time.Now().UTC())
values := url.Values{}
values.Set("sdkappid", strconv.FormatInt(cfg.SDKAppID, 10))
values.Set("identifier", strings.TrimSpace(cfg.AdminIdentifier))
values.Set("usersig", adminSig.UserSig)
values.Set("random", strconv.FormatUint(uint64(randomUint32()), 10))
values.Set("contenttype", "json")
return "https://" + strings.TrimSuffix(firstNonEmpty(cfg.Endpoint, tencentim.DefaultEndpoint), "/") + "/" + strings.TrimPrefix(command, "/") + "?" + values.Encode()
}
func writeResult(output *os.File, line resultLine) error {
body, err := json.Marshal(line)
if err != nil {
return err
}
if _, err := output.Write(append(body, '\n')); err != nil {
return err
}
return nil
}
func randomUint32() uint32 {
var payload [4]byte
if _, err := rand.Read(payload[:]); err != nil {
return uint32(time.Now().UnixNano())
}
return binary.BigEndian.Uint32(payload[:])
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}