当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。 - api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿 - 上传成绩链路已真机跑通,报文与真机抓包逐字节一致 - 开发文档见 docs 分支;source/ 与 logs/ 不入库
532 lines
23 KiB
Python
532 lines
23 KiB
Python
# -*- coding: utf-8 -*-
|
||
r"""
|
||
upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
|
||
================================================================================
|
||
|
||
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
|
||
|
||
对应接口:UpsertUserChargelogApi
|
||
请求体 :UserChargelogRequestVO(4 字段,顺序照 C# 声明)
|
||
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
|
||
public ulong userId;
|
||
public UserChargelog userChargelog;
|
||
public UserCharge userCharge;
|
||
public long loginDateTime;
|
||
响应 :{returnCode, apiName}
|
||
|
||
--------------------------------------------------------------------------------
|
||
一、真机的购买流程(源码逐行可查)
|
||
--------------------------------------------------------------------------------
|
||
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
|
||
├─ ticket.ticketKind.id == 1(付费票)
|
||
├─ 从 OperationManager.GetGameChargeDataList() 找该 chargeId 的 price
|
||
│ (= GetGameChargeApi 的响应)
|
||
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
|
||
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
|
||
│ └─ 没有 -> 新增一条 stock=1
|
||
└─ TicketManager.cs:234 RequestUserCharge(playerId)
|
||
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
|
||
│ -> 取**购买后**(stock > 0)的那条 UserCharge
|
||
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
|
||
│ -> chargeId = ticketId
|
||
│ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price
|
||
│ purchaseDate = TimeManager.GetNowDateString()
|
||
│ playCount = 玩家总游玩次数
|
||
│ playerRating = 玩家 Rating
|
||
│ placeId / regionId / clientId = 当前机台
|
||
└─ 发 UpsertUserChargelogApi
|
||
|
||
★ 成功后 TicketManager.cs:255 RequestSuccess 会把本地 stock 再 --,
|
||
因为那张票马上就被这一局用掉了。本模块只负责「买」,
|
||
所以默认传的是**购买后**的库存(现有 + 1)。
|
||
|
||
--------------------------------------------------------------------------------
|
||
二、票的有效期算法
|
||
--------------------------------------------------------------------------------
|
||
Manager/TicketManager.cs:564
|
||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||
|
||
真机验证(source/2026-10-05.log:123 的 userChargeList):
|
||
chargeId=5 purchaseDate="2026-10-04 18:22:10"
|
||
-> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02
|
||
-> validDate="2027-01-02 04:00:00" ✅ 完全吻合
|
||
|
||
--------------------------------------------------------------------------------
|
||
三、ticket.creditNum 从哪来
|
||
--------------------------------------------------------------------------------
|
||
游戏本地主数据:
|
||
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
|
||
已抄进 config.TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
|
||
|
||
--------------------------------------------------------------------------------
|
||
四、安全闸门
|
||
--------------------------------------------------------------------------------
|
||
本接口会**真实写入一条购买记录**,所以默认不发包:
|
||
UpsertUserChargelog(...) -> 抛 ChargelogConfirmError
|
||
UpsertUserChargelog(..., dry_run=True) -> 只构造报文
|
||
UpsertUserChargelog(..., confirm=True) -> 真实发送
|
||
全局开关:config.UPSERT_CHARGELOG_CONFIRM(默认 False)
|
||
|
||
--------------------------------------------------------------------------------
|
||
五、调用示例
|
||
--------------------------------------------------------------------------------
|
||
from api import setup_logging, ParseQRCode, UserLogin, UserLogout,
|
||
LogoutType, UpsertUserChargelog
|
||
|
||
setup_logging("买票")
|
||
user_id, token = ParseQRCode(qr)
|
||
login = UserLogin(user_id, token)
|
||
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
|
||
UserLogout(user_id, token, login.loginDateTime, logout_type=LogoutType.TestIn)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any, Dict, List, Optional, Union
|
||
|
||
import config
|
||
from config import (
|
||
CLIENT_ID,
|
||
PLACE_ID,
|
||
REGION_ID,
|
||
TICKET_CREDIT_NUM,
|
||
TICKET_NAMES,
|
||
TICKET_VALID_DAYS,
|
||
TICKET_VALID_HOUR_OFFSET,
|
||
TIMEOUT_MS,
|
||
UPSERT_CHARGELOG_CONFIRM,
|
||
)
|
||
|
||
from .log_setup import log_debug, log_error, log_info, log_warning
|
||
from .maimai_net import MaimaiNetClient, request_dict
|
||
from .upsert_user_all import UpsertResponseVO, resolve_api_func
|
||
|
||
|
||
class ChargelogConfirmError(RuntimeError):
|
||
"""有副作用的接口被安全闸门拦下(此时**未发包**)。"""
|
||
|
||
|
||
class ChargelogGuardError(RuntimeError):
|
||
"""
|
||
安全检查未通过(此时**未发包**)。
|
||
|
||
★ 目前唯一的触发条件:**账号当前已经持有功能票**。
|
||
|
||
真机行为:`UseTicketPrepare` 只在「本局要用这张票」时才走购买分支,
|
||
而一张票买到手后会立刻被本局用掉(`RequestSuccess` 里 stock--)。
|
||
所以正常状态下 `userChargeList` 里的 stock 全是 0。
|
||
|
||
如果账号**已经持有**一张功能票还去发购买请求,
|
||
服务端看到的是「重复购买 / 状态矛盾」,有**封号风险**。
|
||
所以本模块默认硬拦。
|
||
"""
|
||
|
||
|
||
# =============================================================================
|
||
# 一、VO(字段顺序照 source/Assembly-CSharp/Net.VO.Mai2/*.cs 声明)
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class UserChargeVO:
|
||
"""UserCharge —— 一张票的持有状态(UserCharge.cs,4 字段)。"""
|
||
chargeId: int = 0
|
||
stock: int = 0
|
||
purchaseDate: str = ""
|
||
validDate: str = ""
|
||
|
||
|
||
@dataclass
|
||
class UserChargelogVO:
|
||
"""UserChargelog —— 一条购买记录(UserChargelog.cs,8 字段)。"""
|
||
chargeId: int = 0
|
||
price: int = 0
|
||
purchaseDate: str = ""
|
||
playCount: int = 0
|
||
playerRating: int = 0
|
||
placeId: int = 0
|
||
regionId: int = 0
|
||
clientId: str = ""
|
||
|
||
|
||
@dataclass
|
||
class UserChargelogRequestVO:
|
||
"""请求 VO(UserChargelogRequestVO.cs,4 字段,顺序照声明)。"""
|
||
userId: int
|
||
userChargelog: UserChargelogVO
|
||
userCharge: UserChargeVO
|
||
loginDateTime: int = 0
|
||
|
||
|
||
# =============================================================================
|
||
# 二、工具
|
||
# =============================================================================
|
||
|
||
def ticket_name(charge_id: int) -> str:
|
||
"""取票名(只用于日志)。"""
|
||
return TICKET_NAMES.get(int(charge_id), "未知票(" + str(charge_id) + ")")
|
||
|
||
|
||
def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
|
||
"""
|
||
取 userChargelog.price —— 即 ticket.creditNum。
|
||
|
||
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
|
||
数据来源 config.TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
|
||
"""
|
||
if override is not None:
|
||
return int(override)
|
||
if int(charge_id) not in TICKET_CREDIT_NUM:
|
||
log_warning("config.TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
|
||
+ " —— 按 0 处理,建议补进配置")
|
||
return 0
|
||
return int(TICKET_CREDIT_NUM[int(charge_id)])
|
||
|
||
|
||
#: 北京时间(CST = UTC+8)
|
||
#: 依据 Manager/TimeManager.cs:9 CSToffset = new TimeSpan(8, 0, 0)
|
||
CST = timezone(timedelta(hours=8))
|
||
|
||
|
||
def now_cst(now: Optional[datetime] = None) -> datetime:
|
||
"""
|
||
取「北京时间现在」;传了 now 就用它(视作已是北京时间)。
|
||
|
||
★ 为什么要显式 +8 而不是 datetime.now():
|
||
TimeManager.GetNowTime() 返回 **DateTime.UtcNow**,
|
||
GetDateString() 再 Add(CSToffset) —— 即游戏里的日期串**恒是北京时间**,
|
||
与本机时区无关。脚本跑在非东八区机器上时 datetime.now() 会错。
|
||
"""
|
||
if now is not None:
|
||
return now
|
||
return datetime.now(timezone.utc).astimezone(CST)
|
||
|
||
|
||
def _now_string(now: Optional[datetime] = None) -> str:
|
||
"""
|
||
TimeManager.GetNowDateString() 的等价物。
|
||
|
||
依据 Manager/TimeManager.cs:7,62-70:
|
||
DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"
|
||
GetDateString(unix) = GetDateTime(unix).ToString("yyyy-MM-dd HH:mm:ss.f")
|
||
|
||
★★ 注意末尾那个 **.f**(一位小数)—— 真机报文长这样:
|
||
"purchaseDate":"2026-10-06 13:20:10.0"
|
||
少写这个 ".0" 服务端会直接 returnCode=0(实测踩过)。
|
||
★ 而 validDate 用的是另一套格式(见 calc_valid_date),**不带**小数。
|
||
"""
|
||
d = now_cst(now)
|
||
return d.strftime("%Y-%m-%d %H:%M:%S") + "." + str(d.microsecond // 100000)
|
||
|
||
|
||
def calc_valid_date(
|
||
now: Optional[datetime] = None,
|
||
*,
|
||
hour_offset: int = TICKET_VALID_HOUR_OFFSET,
|
||
days: int = TICKET_VALID_DAYS,
|
||
) -> str:
|
||
"""
|
||
算票的有效期字符串。
|
||
|
||
依据 TicketManager.cs:562-565:
|
||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||
|
||
★ 注意这里用的是**显式格式串**(没有 .f),所以结果形如
|
||
"2027-01-04 04:00:00" —— 与 purchaseDate 的 "…10.0" 不同。
|
||
"""
|
||
base = now_cst(now)
|
||
d = (base + timedelta(hours=hour_offset) + timedelta(days=days)).date()
|
||
return d.strftime("%Y-%m-%d") + " 04:00:00"
|
||
|
||
|
||
def parse_wall_time(text: Any) -> Optional[datetime]:
|
||
"""
|
||
解析墙上时间串,兼容两种格式:
|
||
|
||
"2026-10-06 13:20:10.0" ← TimeManager.DateTimeFormat(一位小数,真机报文用这个)
|
||
"2026-10-06 13:20:10" ← 无小数(服务端回给 GetUserChargeApi 的是这个)
|
||
|
||
解析不出来返回 None。
|
||
"""
|
||
s = str(text or "").strip()
|
||
if not s:
|
||
return None
|
||
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
|
||
try:
|
||
return datetime.strptime(s, fmt)
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _find_charge(charge_list: Any, charge_id: int) -> Optional[Dict[str, Any]]:
|
||
"""在 GetUserChargeApi 的 userChargeList 里找指定 chargeId。"""
|
||
if not isinstance(charge_list, list):
|
||
return None
|
||
for c in charge_list:
|
||
if isinstance(c, dict) and int(c.get("chargeId", -1) or -1) == int(charge_id):
|
||
return c
|
||
return None
|
||
|
||
|
||
# =============================================================================
|
||
# 三、主函数
|
||
# =============================================================================
|
||
|
||
def UpsertUserChargelog(
|
||
user_id: int,
|
||
charge_id: int,
|
||
login_date_time: int,
|
||
*,
|
||
charge_list: Optional[List[Dict[str, Any]]] = None,
|
||
game_charge_list: Optional[List[Dict[str, Any]]] = None,
|
||
user_data: Optional[Dict[str, Any]] = None,
|
||
stock: Optional[int] = None,
|
||
price: Optional[int] = None,
|
||
purchase_date: Optional[str] = None,
|
||
valid_date: Optional[str] = None,
|
||
play_count: Optional[int] = None,
|
||
player_rating: Optional[int] = None,
|
||
place_id: int = PLACE_ID,
|
||
region_id: int = REGION_ID,
|
||
client_id: str = CLIENT_ID,
|
||
guard_no_stock: bool = True,
|
||
guard_purchasable: bool = True,
|
||
confirm: Optional[bool] = None,
|
||
dry_run: bool = False,
|
||
timeout_ms: Optional[int] = None,
|
||
client: Optional[MaimaiNetClient] = None,
|
||
) -> Union[UserChargelogRequestVO, UpsertResponseVO]:
|
||
"""
|
||
购买一张功能票(UpsertUserChargelogApi,★ 有副作用)。
|
||
|
||
参数:
|
||
user_id: 用户 ID(必填)
|
||
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
|
||
★ 付费票是 2/3/4/5/6(config.PAID_TICKET_IDS)
|
||
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
|
||
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
|
||
用来算「购买后库存」和是否已有该票。
|
||
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
|
||
用来填 playCount / playerRating。
|
||
stock: ★ 购买后该票的库存;不传 = 现有 + 1
|
||
price: ★ userChargelog.price;不传 = config.TICKET_CREDIT_NUM[charge_id]
|
||
purchase_date: 购买时刻字符串;不传 = 现在
|
||
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
|
||
play_count: 覆盖 playCount(不传则取 userData.playCount)
|
||
player_rating: 覆盖 playerRating(不传则取 userData.playerRating)
|
||
guard_purchasable: ★ 默认 True = **只允许买 GetGameChargeApi 列出来的票**
|
||
(不在清单里就抛 ChargelogGuardError,未发包)。
|
||
依据 TicketManager.cs:137-150 —— 真机也是先在
|
||
GetGameChargeDataList() 里找到该 chargeId 才发购买请求,
|
||
找不到直接 return false。服务端对未上架的票会 returnCode=0。
|
||
实测:本机台只上架 chargeId=2/3,买 5 会失败。
|
||
game_charge_list: 可购买清单(可选)。不传则自动调 GetGameChargeApi。
|
||
guard_no_stock: ★ 默认 True = **账号当前若已持有任何功能票就直接拒绝购买**
|
||
(抛 ChargelogGuardError,未发包)。
|
||
理由见 ChargelogGuardError 的 docstring —— 有封号风险。
|
||
确认要强行购买才传 False。
|
||
confirm: 副作用闸门;None 时取 config.UPSERT_CHARGELOG_CONFIRM
|
||
dry_run: True = 只构造并返回报文,绝不发包
|
||
|
||
返回:
|
||
dry_run=True -> UserChargelogRequestVO
|
||
dry_run=False -> UpsertResponseVO
|
||
"""
|
||
if not login_date_time:
|
||
raise ValueError("缺少 login_date_time:必须传 UserLoginApi 的 loginDateTime"
|
||
"(PacketUploadUserChargelog.cs:23)")
|
||
|
||
name = ticket_name(charge_id)
|
||
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
|
||
+ "(" + name + ")dry_run=" + str(dry_run))
|
||
|
||
if int(charge_id) not in tuple(config.PAID_TICKET_IDS):
|
||
log_warning("chargeId=" + str(charge_id) + " 不在 config.PAID_TICKET_IDS="
|
||
+ str(tuple(config.PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
|
||
|
||
# ---------- 1. 取当前状态 ----------
|
||
if charge_list is None:
|
||
log_info("未传 charge_list,自动抓取当前票券 ...")
|
||
raw = resolve_api_func("UserCharge")(
|
||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||
charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
||
|
||
if user_data is None:
|
||
log_info("未传 user_data,自动抓取当前档案 ...")
|
||
raw_ud = resolve_api_func("UserData")(
|
||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||
user_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {}
|
||
|
||
# ---------- 1.4 ★ 安全检查:这张票当前**上架了吗** ----------
|
||
# 依据 TicketManager.cs:137-150:真机先在 GetGameChargeDataList() 里找该
|
||
# chargeId,找到才 RequestUserCharge,找不到直接 return false(不买)。
|
||
# 服务端对未上架的票会 returnCode=0 —— 实测买 chargeId=5 就是这种情况。
|
||
if guard_purchasable:
|
||
if game_charge_list is None:
|
||
log_info("未传 game_charge_list,自动抓取可购买清单 ...")
|
||
_gc = resolve_api_func("GetGameCharge")(
|
||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||
game_charge_list = (_gc.get("gameChargeList")
|
||
if isinstance(_gc, dict) else None) or []
|
||
avail = [int(g.get("chargeId")) for g in (game_charge_list or [])
|
||
if isinstance(g, dict) and g.get("chargeId") is not None]
|
||
log_info("当前可购买 chargeId:" + str(avail))
|
||
if int(charge_id) not in avail:
|
||
msg = ("安全检查未通过:chargeId=" + str(charge_id) + "(" + name
|
||
+ ")**不在当前可购买清单里**。\n"
|
||
" 可购买:" + (str(avail) if avail else "(空)") + "\n"
|
||
" 依据 TicketManager.cs:137-150 —— 真机也只买 GetGameChargeApi\n"
|
||
" 列出来的票,找不到就不发购买请求。\n"
|
||
" 服务端对未上架的票会 returnCode=0(实测过)。\n"
|
||
" 确需强行购买请传 guard_purchasable=False。")
|
||
log_error(msg)
|
||
raise ChargelogGuardError(msg)
|
||
log_info("安全检查通过:chargeId=" + str(charge_id) + " 在可购买清单里")
|
||
|
||
# ---------- 1.5 ★ 安全检查:账号当前是否已持有功能票 ----------
|
||
held = [c for c in (charge_list or [])
|
||
if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
||
if held:
|
||
desc = "、".join(
|
||
"chargeId=" + str(c.get("chargeId")) + "(" + ticket_name(c.get("chargeId"))
|
||
+ ") stock=" + str(c.get("stock")) + " validDate=" + str(c.get("validDate"))
|
||
for c in held)
|
||
log_warning("★ 账号当前已持有 " + str(len(held)) + " 张功能票:" + desc)
|
||
if guard_no_stock:
|
||
msg = ("安全检查未通过:账号当前**已持有功能票**(" + desc + "),拒绝再次购买。\n"
|
||
" 原因:真机只在「本局要用这张票」时才购买,且买到手会立刻用掉"
|
||
"(stock--),正常状态 stock 恒为 0。\n"
|
||
" 已经持票还发购买请求,服务端看到的是重复购买,有**封号风险**。\n"
|
||
" 确需强行购买请传 guard_no_stock=False。")
|
||
log_error(msg)
|
||
raise ChargelogGuardError(msg)
|
||
log_warning("guard_no_stock=False —— 已跳过该检查,继续购买")
|
||
else:
|
||
log_info("安全检查通过:账号当前没有持有任何功能票")
|
||
|
||
existing = _find_charge(charge_list, charge_id)
|
||
old_stock = int(existing.get("stock", 0) or 0) if existing else 0
|
||
if stock is None:
|
||
stock = old_stock + 1
|
||
stock = int(stock)
|
||
|
||
if price is None:
|
||
price = ticket_price(charge_id)
|
||
price = int(price)
|
||
|
||
if play_count is None:
|
||
play_count = int(user_data.get("playCount", 0) or 0)
|
||
if player_rating is None:
|
||
player_rating = int(user_data.get("playerRating", 0) or 0)
|
||
|
||
now = now_cst()
|
||
if not purchase_date:
|
||
purchase_date = _now_string(now)
|
||
if not valid_date:
|
||
# ★ 真机里 purchaseDate 与 validDate 都来自同一个 DateTime.Now
|
||
# (TicketManager.cs:216-219 与 :564),所以调用方若显式给了 purchaseDate,
|
||
# 有效期就该从**那个时刻**推,而不是从现在推。
|
||
base = parse_wall_time(purchase_date)
|
||
if base is None:
|
||
log_warning("purchase_date 解析不了(应为 'YYYY-MM-DD HH:MM:SS[.f]'),"
|
||
"validDate 改按「现在」计算:" + str(purchase_date))
|
||
base = now
|
||
valid_date = calc_valid_date(base)
|
||
|
||
log_info(" 票名 : " + name)
|
||
log_info(" 库存 : " + str(old_stock) + " -> " + str(stock))
|
||
log_info(" price : " + str(price) + "(= ticket.creditNum)")
|
||
log_info(" purchaseDate: " + purchase_date)
|
||
log_info(" validDate : " + valid_date)
|
||
|
||
# ---------- 2. 构造请求 ----------
|
||
req = UserChargelogRequestVO(
|
||
userId=int(user_id),
|
||
userChargelog=UserChargelogVO(
|
||
chargeId=int(charge_id),
|
||
price=price,
|
||
purchaseDate=purchase_date,
|
||
playCount=int(play_count),
|
||
playerRating=int(player_rating),
|
||
placeId=int(place_id),
|
||
regionId=int(region_id),
|
||
clientId=client_id,
|
||
),
|
||
userCharge=UserChargeVO(
|
||
chargeId=int(charge_id),
|
||
stock=stock,
|
||
purchaseDate=purchase_date,
|
||
validDate=valid_date,
|
||
),
|
||
loginDateTime=int(login_date_time),
|
||
)
|
||
|
||
# ---------- 3. dry-run ----------
|
||
if dry_run:
|
||
log_info("[dry-run] 已构造购买报文,未发包")
|
||
log_debug("dry-run 报文:" + __import__("json").dumps(
|
||
req, ensure_ascii=False, default=str))
|
||
return req
|
||
|
||
# ---------- 4. 副作用闸门 ----------
|
||
effective = UPSERT_CHARGELOG_CONFIRM if confirm is None else bool(confirm)
|
||
if not effective:
|
||
msg = ("UpsertUserChargelogApi 会**真实写入一条购买记录**(不可撤销)。"
|
||
"本脚本默认不放行。确认要发请传 confirm=True,"
|
||
"或改 config.UPSERT_CHARGELOG_CONFIRM = True。"
|
||
"只想看报文请传 dry_run=True。")
|
||
log_error(msg)
|
||
raise ChargelogConfirmError(msg)
|
||
|
||
# ---------- 5. 发包 ----------
|
||
log_info("闸门已放行,开始上传购买记录 ...")
|
||
raw = request_dict(
|
||
"UpsertUserChargelogApi", req,
|
||
user_id=user_id, client_id=client_id,
|
||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||
)
|
||
resp = UpsertResponseVO(
|
||
returnCode=int(raw.get("returnCode", 0) or 0),
|
||
apiName=str(raw.get("apiName", "") or ""),
|
||
)
|
||
if resp.returnCode != 1:
|
||
msg = ("购买失败:returnCode=" + str(resp.returnCode)
|
||
+ " apiName=" + resp.apiName)
|
||
log_error(msg)
|
||
raise RuntimeError(msg)
|
||
|
||
log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName)
|
||
log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock))
|
||
return resp
|
||
|
||
|
||
# =============================================================================
|
||
# 四、单独运行(调试)
|
||
# =============================================================================
|
||
|
||
if __name__ == "__main__":
|
||
from .log_setup import setup_logging, teardown_logging
|
||
|
||
def _main() -> int:
|
||
setup_logging("chargelog_test")
|
||
try:
|
||
log_info("构造一张 2 倍券的购买报文(不发包)")
|
||
req = UpsertUserChargelog(
|
||
12345678, 2, 1791159576,
|
||
charge_list=[], user_data={"playCount": 1260, "playerRating": 15000},
|
||
dry_run=True,
|
||
)
|
||
log_info("userChargelog = " + str(req.userChargelog))
|
||
log_info("userCharge = " + str(req.userCharge))
|
||
log_info("validDate 算法自检:")
|
||
log_info(" 2026-10-04 18:22:10 买 5 倍券 -> "
|
||
+ calc_valid_date(datetime(2026, 10, 4, 18, 22, 10))
|
||
+ "(真机是 2027-01-02 04:00:00)")
|
||
return 0
|
||
finally:
|
||
teardown_logging()
|
||
|
||
raise SystemExit(_main())
|