增加相关配置
This commit is contained in:
parent
f5f2b828c6
commit
b24cc5a336
@ -8991,6 +8991,9 @@ type VipRewardItem struct {
|
||||
Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,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"`
|
||||
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
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -9067,6 +9070,27 @@ func (x *VipRewardItem) GetExpiresAtMs() int64 {
|
||||
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 {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
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" +
|
||||
"withdrawal\x18\x01 \x01(\v2\".hyapp.wallet.v1.WithdrawalRequestR\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" +
|
||||
"\vresource_id\x18\x01 \x01(\x03R\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" +
|
||||
"\x04name\x18\x04 \x01(\tR\x04name\x12\x1a\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" +
|
||||
"\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
|
||||
|
||||
@ -1007,6 +1007,9 @@ message VipRewardItem {
|
||||
string name = 4;
|
||||
int64 quantity = 5;
|
||||
int64 expires_at_ms = 6;
|
||||
string asset_url = 7;
|
||||
string preview_url = 8;
|
||||
string animation_url = 9;
|
||||
}
|
||||
|
||||
message VipLevel {
|
||||
|
||||
92
run/im-c2c-broadcast-20260602-061847.jsonl
Normal file
92
run/im-c2c-broadcast-20260602-061847.jsonl
Normal 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"}
|
||||
343
scripts/ops/grant_all_users_coin.py
Executable file
343
scripts/ops/grant_all_users_coin.py
Executable 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())
|
||||
@ -21,6 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
uploadSniffBytes = 512
|
||||
maxBatchFiles = 10
|
||||
)
|
||||
|
||||
var (
|
||||
@ -62,6 +63,10 @@ func (h *Handler) UploadImage(c *gin.Context) {
|
||||
h.uploadObject(c, imageUploadPolicy)
|
||||
}
|
||||
|
||||
func (h *Handler) UploadImagesBatch(c *gin.Context) {
|
||||
h.uploadObjects(c, imageUploadPolicy)
|
||||
}
|
||||
|
||||
func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) {
|
||||
if h.uploader == nil {
|
||||
response.ServerError(c, "文件上传未配置")
|
||||
@ -109,9 +114,72 @@ func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) {
|
||||
ObjectKey: objectKey,
|
||||
ContentType: contentType,
|
||||
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) {
|
||||
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
|
||||
response.BadRequest(c, "文件参数不正确")
|
||||
@ -127,6 +195,33 @@ func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, boo
|
||||
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) {
|
||||
head := make([]byte, uploadSniffBytes)
|
||||
n, err := io.ReadFull(file, head)
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
type fakeUploader struct {
|
||||
contentType string
|
||||
key string
|
||||
keys []string
|
||||
sizeBytes int64
|
||||
body []byte
|
||||
}
|
||||
@ -30,6 +31,7 @@ func (u *fakeUploader) PutObject(_ context.Context, key string, reader io.Reader
|
||||
return "", err
|
||||
}
|
||||
u.key = key
|
||||
u.keys = append(u.keys, key)
|
||||
u.body = body
|
||||
u.sizeBytes = sizeBytes
|
||||
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 {
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
@ -146,6 +195,7 @@ func ginRouter(handler *Handler) http.Handler {
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/upload-image", handler.UploadImage)
|
||||
router.POST("/upload-images-batch", handler.UploadImagesBatch)
|
||||
return router
|
||||
}
|
||||
|
||||
@ -168,3 +218,31 @@ func multipartUploadBody(t *testing.T, fieldName string, filename string, conten
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
@ -11,4 +11,5 @@ type uploadResult struct {
|
||||
ObjectKey string `json:"object_key"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
}
|
||||
|
||||
@ -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/image/upload", middleware.RequirePermission("upload:create"), h.UploadImage)
|
||||
protected.POST("/admin/files/image/batch-upload", middleware.RequirePermission("upload:create"), h.UploadImagesBatch)
|
||||
}
|
||||
|
||||
@ -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_order(app_code, order_id)
|
||||
) 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);
|
||||
|
||||
@ -263,7 +263,7 @@ func (s *Service) handleBaishunGetSSToken(ctx context.Context, app string, confi
|
||||
}
|
||||
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
|
||||
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{
|
||||
"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)
|
||||
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))
|
||||
return raw, contentType, strconv.Itoa(baishunCodeOK), true, "", nil
|
||||
@ -466,3 +466,17 @@ func baishunMessageFromError(err error) string {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -704,13 +704,14 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
|
||||
// 百顺前端规范以 NativeBridge.getConfig 为准;这里把同一份配置也拼到 URL,方便 Flutter/H5 壳层读取。
|
||||
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)
|
||||
if config.AppID > 0 {
|
||||
query.Set("appId", strconv.FormatInt(config.AppID, 10))
|
||||
}
|
||||
query.Set("userId", externalUserID(session, config.UIDMode))
|
||||
query.Set("code", token)
|
||||
query.Set("isDataByUrl", "1")
|
||||
if session.RoomID != "" {
|
||||
query.Set("roomId", session.RoomID)
|
||||
}
|
||||
|
||||
@ -323,7 +323,8 @@ func TestLaunchGameBuildsBaishunURL(t *testing.T) {
|
||||
"gameMode": "3",
|
||||
"language": "2",
|
||||
"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",
|
||||
"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) {
|
||||
repo := &fakeRepository{
|
||||
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 {
|
||||
lastGet *userv1.GetUserRequest
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
|
||||
f.lastGet = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &userv1.GetUserResponse{User: &userv1.User{
|
||||
UserId: req.GetUserId(),
|
||||
DisplayUserId: "420001",
|
||||
|
||||
@ -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 {
|
||||
return err
|
||||
}
|
||||
return r.seedDefaults(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
|
||||
|
||||
@ -15,6 +15,8 @@ cors:
|
||||
- "http://127.0.0.1:3000"
|
||||
- "http://localhost:5173"
|
||||
- "http://127.0.0.1:5173"
|
||||
- "http://localhost:30000"
|
||||
- "http://127.0.0.1:30000"
|
||||
# 本地工具页会用 10300/8787/8788 承载静态 HTML;精确放行测试端口,避免用 "*" 破坏凭证请求边界。
|
||||
- "http://localhost:10300"
|
||||
- "http://127.0.0.1:10300"
|
||||
|
||||
@ -72,6 +72,10 @@ func (h *Handler) UploadAvatar(writer http.ResponseWriter, request *http.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) {
|
||||
h.handlePushToken(writer, request)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -57,6 +57,7 @@ type AppHandlers struct {
|
||||
HandleTencentRTCCallback http.HandlerFunc
|
||||
UploadFile http.HandlerFunc
|
||||
UploadAvatar http.HandlerFunc
|
||||
ProxyEffectMedia http.HandlerFunc
|
||||
HandlePushToken http.HandlerFunc
|
||||
}
|
||||
|
||||
@ -276,6 +277,7 @@ func (r routes) registerAppRoutes() {
|
||||
r.public("/tencent-rtc/callback", "", h.HandleTencentRTCCallback)
|
||||
r.auth("/files/upload", "", h.UploadFile)
|
||||
r.auth("/files/avatar/upload", "", h.UploadAvatar)
|
||||
r.public("/media/effects/proxy", http.MethodGet, h.ProxyEffectMedia)
|
||||
r.profile("/devices/push-token", "", h.HandlePushToken)
|
||||
}
|
||||
|
||||
|
||||
@ -149,6 +149,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
HandleTencentRTCCallback: callbackAPI.HandleTencentRTCCallback,
|
||||
UploadFile: appAPI.UploadFile,
|
||||
UploadAvatar: appAPI.UploadAvatar,
|
||||
ProxyEffectMedia: appAPI.ProxyEffectMedia,
|
||||
HandlePushToken: appAPI.HandlePushToken,
|
||||
},
|
||||
User: userHandlers,
|
||||
|
||||
@ -175,6 +175,10 @@ type vipRewardItemData struct {
|
||||
Name string `json:"name"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
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。
|
||||
|
||||
@ -5,9 +5,10 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
// getMyVIP 返回当前用户 VIP 状态。
|
||||
@ -135,7 +136,21 @@ func vipRewardItemsFromProto(items []*walletv1.VipRewardItem) []vipRewardItemDat
|
||||
Name: item.GetName(),
|
||||
Quantity: item.GetQuantity(),
|
||||
ExpiresAtMS: item.GetExpiresAtMs(),
|
||||
CoverURL: vipRewardCoverURL(item),
|
||||
AssetURL: item.GetAssetUrl(),
|
||||
PreviewURL: item.GetPreviewUrl(),
|
||||
AnimationURL: item.GetAnimationUrl(),
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func vipRewardCoverURL(item *walletv1.VipRewardItem) string {
|
||||
if item == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(item.GetPreviewUrl()) != "" {
|
||||
return item.GetPreviewUrl()
|
||||
}
|
||||
return item.GetAssetUrl()
|
||||
}
|
||||
|
||||
@ -504,6 +504,9 @@ type VipRewardItem struct {
|
||||
Name string
|
||||
Quantity int64
|
||||
ExpiresAtMS int64
|
||||
AssetURL string
|
||||
PreviewURL string
|
||||
AnimationURL string
|
||||
}
|
||||
|
||||
// VipLevel 是可购买 VIP 等级配置。
|
||||
|
||||
@ -2486,6 +2486,15 @@ func TestApplyWithdrawalFreezesUSDBalance(t *testing.T) {
|
||||
func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
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)
|
||||
|
||||
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。
|
||||
func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -1067,6 +1067,11 @@ func (r *Repository) applyVipRewardGroup(ctx context.Context, tx *sql.Tx, comman
|
||||
if err != nil {
|
||||
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{
|
||||
ResourceID: item.Resource.ResourceID,
|
||||
ResourceCode: item.Resource.ResourceCode,
|
||||
@ -1074,6 +1079,9 @@ func (r *Repository) applyVipRewardGroup(ctx context.Context, tx *sql.Tx, comman
|
||||
Name: item.Resource.Name,
|
||||
Quantity: inserted.Quantity,
|
||||
ExpiresAtMS: expiresAtMS,
|
||||
AssetURL: item.Resource.AssetURL,
|
||||
PreviewURL: item.Resource.PreviewURL,
|
||||
AnimationURL: item.Resource.AnimationURL,
|
||||
})
|
||||
}
|
||||
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,
|
||||
Quantity: item.Quantity,
|
||||
ExpiresAtMS: expiresAtMS,
|
||||
AssetURL: item.Resource.AssetURL,
|
||||
PreviewURL: item.Resource.PreviewURL,
|
||||
AnimationURL: item.Resource.AnimationURL,
|
||||
})
|
||||
}
|
||||
return rewards, nil
|
||||
@ -1151,6 +1162,9 @@ func vipRewardItemsNoticePayload(items []ledger.VipRewardItem) []map[string]any
|
||||
"name": item.Name,
|
||||
"quantity": item.Quantity,
|
||||
"expires_at_ms": item.ExpiresAtMS,
|
||||
"asset_url": item.AssetURL,
|
||||
"preview_url": item.PreviewURL,
|
||||
"animation_url": item.AnimationURL,
|
||||
})
|
||||
}
|
||||
return payload
|
||||
@ -1217,7 +1231,8 @@ func (r *Repository) rewardItemsByGrant(ctx context.Context, tx *sql.Tx, grantID
|
||||
return nil, nil
|
||||
}
|
||||
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
|
||||
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 = ?
|
||||
@ -1231,7 +1246,16 @@ func (r *Repository) rewardItemsByGrant(ctx context.Context, tx *sql.Tx, grantID
|
||||
items := make([]ledger.VipRewardItem, 0)
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
item.ExpiresAtMS = expiresAtMS
|
||||
|
||||
@ -1135,18 +1135,18 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou
|
||||
durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond)
|
||||
coinBalanceAfter := account.AvailableAmount - item.CoinPrice
|
||||
metadata := map[string]any{
|
||||
"app_code": command.AppCode,
|
||||
"order_id": orderID,
|
||||
"user_id": command.UserID,
|
||||
"shop_item_id": item.ShopItemID,
|
||||
"resource_id": item.ResourceID,
|
||||
"resource_code": item.Resource.ResourceCode,
|
||||
"resource_type": item.Resource.ResourceType,
|
||||
"duration_days": item.DurationDays,
|
||||
"duration_ms": durationMS,
|
||||
"price_type": item.PriceType,
|
||||
"coin_spent": item.CoinPrice,
|
||||
"balance_after": coinBalanceAfter,
|
||||
"app_code": command.AppCode,
|
||||
"order_id": orderID,
|
||||
"user_id": command.UserID,
|
||||
"shop_item_id": item.ShopItemID,
|
||||
"resource_id": item.ResourceID,
|
||||
"resource_code": item.Resource.ResourceCode,
|
||||
"resource_type": item.Resource.ResourceType,
|
||||
"duration_days": item.DurationDays,
|
||||
"duration_ms": durationMS,
|
||||
"price_type": item.PriceType,
|
||||
"coin_spent": item.CoinPrice,
|
||||
"balance_after": coinBalanceAfter,
|
||||
"resource_grant_id": grantID,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
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.source_grant_id, e.created_at_ms, e.updated_at_ms,
|
||||
FALSE AS equipped,
|
||||
`+resourceColumnsWithAlias("r")+`
|
||||
FROM user_resource_entitlements e
|
||||
JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
|
||||
|
||||
@ -21,6 +21,15 @@ type Repository struct {
|
||||
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.
|
||||
func (r *Repository) CountRows(table string, where string, args ...any) int {
|
||||
r.t.Helper()
|
||||
@ -36,6 +45,22 @@ func (r *Repository) CountRows(table string, where string, args ...any) int {
|
||||
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.
|
||||
func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string {
|
||||
r.t.Helper()
|
||||
@ -191,6 +216,74 @@ func (r *Repository) SetBalance(userID int64, balance int64) {
|
||||
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.
|
||||
func (r *Repository) SetAssetBalance(userID int64, assetType string, balance int64) {
|
||||
r.t.Helper()
|
||||
|
||||
@ -621,6 +621,9 @@ func vipRewardItemsToProto(items []ledger.VipRewardItem) []*walletv1.VipRewardIt
|
||||
Name: item.Name,
|
||||
Quantity: item.Quantity,
|
||||
ExpiresAtMs: item.ExpiresAtMS,
|
||||
AssetUrl: item.AssetURL,
|
||||
PreviewUrl: item.PreviewURL,
|
||||
AnimationUrl: item.AnimationURL,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
|
||||
354
tools/im-c2c-broadcast/main.go
Normal file
354
tools/im-c2c-broadcast/main.go
Normal 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 ""
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user