# -*- coding: utf-8 -*- r""" UpsertUserChargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi) ================================================================================ 功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。 对应接口:UpsertUserChargelogApi 请求体 :UserChargelogRequestVO(4 字段,顺序照 C# 声明) source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs public ulong userId; public UserChargelog userChargelog; public GetUserCharge 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)的那条 GetUserCharge ├─ 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 已抄进 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, TIMEOUT_MS, UPSERT_CHARGELOG_CONFIRM, ) # ============================================================================= # 功能票(Ticket)主数据(原在 config.py,2026-10-06 下沉到本模块) # ============================================================================= # # 依据:游戏本地主数据 # Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml # 源码侧 Manager.MaiStudio.TicketData.creditNum # # ★ 为什么需要它: # UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是 # **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price: # source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410 # result.price = ticket.creditNum; # # ticketKind 含义:1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara #: chargeId -> creditNum TICKET_CREDIT_NUM = { 0: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1 10005: 0, 10105: 0, 10205: 0, # 活动票 11001: 0, # 免费 1.5 倍券 30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票 } #: 票名(只用于日志好看,不影响协议) TICKET_NAMES = { 0: "使用しない", 2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット", 10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2", 10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット", 30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票", } #: 付费票(ticketKind=1)—— 只有这些能在机台购买 PAID_TICKET_IDS = (2, 3, 4, 5, 6) #: 票有效期算法参数 #: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564 #: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00") #: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10 #: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00" TICKET_VALID_HOUR_OFFSET = -4 TICKET_VALID_DAYS = 90 from .ChargeGuard import ( has_fresh_read, last_read_age, note_charge_purchase, purchased_charge_ids, ) from .LogSetup import log_debug, log_error, log_info, log_warning from .MaimaiNet import MaimaiNetClient, request_dict from .UpsertUserAll 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: """GetUserCharge —— 一张票的持有状态(GetUserCharge.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; 数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。 """ if override is not None: return int(override) if int(charge_id) not in TICKET_CREDIT_NUM: log_warning("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, guard_require_charge_read: bool = True, guard_single_purchase: 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(PAID_TICKET_IDS) login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime) charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。 用来算「购买后库存」和是否已有该票。 user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。 用来填 playCount / playerRating。 stock: ★ 购买后该票的库存;不传 = 现有 + 1 price: ★ userChargelog.price;不传 = 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。 guard_require_charge_read: ★ 默认 True = **运行本函数之前必须先调用过 GetUserCharge** (本进程内、10 分钟内),否则直接拒绝购买。 目的:防止「没查票就买票」造成同一账号重复购买。 显式传了 charge_list 也算「查过了」。 实现见 api/ChargeGuard.py。 guard_single_purchase: ★ 默认 True = **同一账号在同一进程里只允许买一次票**。 真机买完票会立刻被本局用掉(stock 恒为 0), 连着买两张属于异常行为。实现见 api/ChargeGuard.py。 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(PAID_TICKET_IDS): log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS=" + str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票") # ---------- 0. ★ 前置闸门:必须先跑过 GetUserCharge ---------- # ★ 为什么放在「取当前状态」之前:下面那一步在 charge_list 为空时会 # 自己调一次 GetUserCharge —— 闸门若放在后面,它自己就把自己放行了。 if guard_require_charge_read and charge_list is None: if not has_fresh_read(user_id): msg = ("安全检查未通过:本次购买之前**没有调用过 GetUserCharge**。\n" " 买票前必须先查一次账号当前票券(有没有持票),否则无法排除" "「重复购买」——服务端看到的是状态矛盾,有**封号风险**。\n" " 正确顺序:GetUserCharge(user_id) -> UpsertUserChargelog(...)\n" " 或者直接把查到的票券列表传进来:charge_list=[...]\n" " 见 api/ChargeGuard.py。") log_error(msg) raise ChargelogGuardError(msg) log_info("前置检查通过:本次购买前已调用 GetUserCharge(" + str(round(float(last_read_age(user_id) or 0.0), 1)) + " 秒前)") # ---------- 0.5 ★ 前置闸门:同一账号本进程内只允许买一次 ---------- if guard_single_purchase: done = purchased_charge_ids(user_id) if done: msg = ("安全检查未通过:本进程内该账号(user_id=" + str(user_id) + ")**已经买过功能票**(chargeId=" + str(done) + ")。\n" " 真机买完票会立刻被这一局用掉,正常状态 stock 恒为 0;" "连着买两张属于异常行为,有**封号风险**。\n" " 确需强行购买请传 guard_single_purchase=False。") log_error(msg) raise ChargelogGuardError(msg) # ---------- 1. 取当前状态 ---------- if charge_list is None: log_info("未传 charge_list,自动抓取当前票券 ...") raw = resolve_api_func("GetUserCharge")( 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("GetUserData")( 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)) # ★ 记一笔「这个账号本进程已经买过票」,同进程再买会被前置闸门拦下 note_charge_purchase(user_id, charge_id) return resp # ============================================================================= # 四、单独运行(调试) # ============================================================================= if __name__ == "__main__": from .LogSetup 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())