增加相关配置
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"`
|
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" +
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
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 (
|
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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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。
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@ -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 等级配置。
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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