严格校验换股证据并原子提交公司行为批次
This commit is contained in:
@@ -4,6 +4,30 @@ use crate::{
|
||||
};
|
||||
use chrono::NaiveDate;
|
||||
|
||||
pub(crate) fn validate_action<'a>(
|
||||
action: &'a crate::CorporateAction,
|
||||
data: &DataSet,
|
||||
) -> Result<Option<(&'a str, f64, f64)>, String> {
|
||||
let terms = action.validated_successor_terms()?;
|
||||
crate::finite_serialization::validate(action).map_err(|error| {
|
||||
format!(
|
||||
"corporate_action_invalid_number: symbol={} action_date={} detail={error}",
|
||||
action.symbol, action.date
|
||||
)
|
||||
})?;
|
||||
if let Some((successor, _, _)) = terms {
|
||||
for (symbol, role) in [(&*action.symbol, "source"), (successor, "successor")] {
|
||||
if data.instrument(symbol).is_none() {
|
||||
return Err(format!(
|
||||
"corporate_action_{role}_instrument_missing: symbol={symbol} action_date={} source_symbol={}",
|
||||
action.date, action.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(terms)
|
||||
}
|
||||
|
||||
/// One corporate-action calculation for normal processing and audited replay.
|
||||
pub(crate) fn apply(
|
||||
date: NaiveDate,
|
||||
@@ -12,6 +36,44 @@ pub(crate) fn apply(
|
||||
notes: &mut Vec<String>,
|
||||
cash_dividends_enabled: bool,
|
||||
cash_dividend_adjusts_cost_basis: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let actions = data.corporate_actions_on(date);
|
||||
for action in actions {
|
||||
validate_action(action, data).map_err(BacktestError::Execution)?;
|
||||
}
|
||||
if !actions.iter().any(|action| {
|
||||
action.has_effect()
|
||||
&& portfolio
|
||||
.position(&action.symbol)
|
||||
.is_some_and(|position| position.quantity > 0)
|
||||
}) {
|
||||
return Ok(BrokerExecutionReport::default());
|
||||
}
|
||||
// An entire settlement batch is a single ledger update. A later invalid
|
||||
// cash leg must not leave an earlier split, receivable, target unit, or
|
||||
// note applied to the observed account.
|
||||
let mut next = portfolio.clone();
|
||||
let mut recorded = Vec::new();
|
||||
let report = apply_inner(
|
||||
date,
|
||||
data,
|
||||
&mut next,
|
||||
&mut recorded,
|
||||
cash_dividends_enabled,
|
||||
cash_dividend_adjusts_cost_basis,
|
||||
)?;
|
||||
*portfolio = next;
|
||||
notes.extend(recorded);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn apply_inner(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
cash_dividends_enabled: bool,
|
||||
cash_dividend_adjusts_cost_basis: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
for action in data.corporate_actions_on(date) {
|
||||
@@ -73,6 +135,16 @@ pub(crate) fn apply(
|
||||
|
||||
let split_ratio = action.split_ratio();
|
||||
if (split_ratio - 1.0).abs() > f64::EPSILON {
|
||||
checked_quantity(
|
||||
&action.symbol,
|
||||
date,
|
||||
portfolio
|
||||
.position(&action.symbol)
|
||||
.expect("position exists for split")
|
||||
.quantity,
|
||||
split_ratio,
|
||||
0,
|
||||
)?;
|
||||
portfolio
|
||||
.adjust_stock_pool_split(&action.symbol, split_ratio)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
@@ -101,25 +173,33 @@ pub(crate) fn apply(
|
||||
}
|
||||
}
|
||||
|
||||
if action.has_successor_conversion() {
|
||||
let successor_symbol = action
|
||||
.successor_symbol
|
||||
.as_deref()
|
||||
.expect("successor symbol checked");
|
||||
if let Some((successor_symbol, ratio, cash_per_share)) = action
|
||||
.validated_successor_terms()
|
||||
.map_err(BacktestError::Execution)?
|
||||
{
|
||||
checked_quantity(
|
||||
&action.symbol,
|
||||
date,
|
||||
portfolio
|
||||
.position(&action.symbol)
|
||||
.expect("position exists for conversion")
|
||||
.quantity,
|
||||
ratio,
|
||||
portfolio
|
||||
.position(successor_symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
)?;
|
||||
let Some(outcome) = portfolio.apply_successor_conversion(
|
||||
&action.symbol,
|
||||
successor_symbol,
|
||||
action.successor_ratio_value(),
|
||||
action.successor_cash_value(),
|
||||
ratio,
|
||||
cash_per_share,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let reason = format!(
|
||||
"successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}",
|
||||
outcome.old_symbol,
|
||||
outcome.new_symbol,
|
||||
action.successor_ratio_value(),
|
||||
action.successor_cash_value()
|
||||
outcome.old_symbol, outcome.new_symbol, ratio, cash_per_share
|
||||
);
|
||||
notes.push(reason.clone());
|
||||
report.position_events.push(PositionEvent {
|
||||
@@ -159,6 +239,26 @@ pub(crate) fn apply(
|
||||
portfolio.prune_flat_positions();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn checked_quantity(
|
||||
symbol: &str,
|
||||
date: NaiveDate,
|
||||
quantity: u32,
|
||||
ratio: f64,
|
||||
merged: u32,
|
||||
) -> Result<(), BacktestError> {
|
||||
let scaled = (f64::from(quantity) * ratio).round();
|
||||
if !scaled.is_finite()
|
||||
|| scaled < 0.
|
||||
|| scaled > f64::from(i32::MAX)
|
||||
|| scaled + f64::from(merged) > f64::from(u32::MAX)
|
||||
{
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"corporate_action_quantity_overflow: symbol={symbol} action_date={date}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Preserve the declared fee-free accounting allocation model; this does not
|
||||
/// submit a market order or use a later opening quote as an earlier fact.
|
||||
pub(crate) fn settle_receivables(
|
||||
@@ -390,4 +490,196 @@ mod tests {
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(notes[0].contains("dividend_reinvestment_not_applied"));
|
||||
}
|
||||
|
||||
fn conversion() -> crate::CorporateAction {
|
||||
crate::CorporateAction {
|
||||
date: date(),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: Some("000002.SZ".into()),
|
||||
successor_ratio: Some(1.5),
|
||||
successor_cash: Some(0.5),
|
||||
}
|
||||
}
|
||||
|
||||
fn conversion_data(actions: Vec<crate::CorporateAction>, include_successor: bool) -> DataSet {
|
||||
let mut instruments = data(false)
|
||||
.instruments()
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if include_successor {
|
||||
let mut successor = instruments[0].clone();
|
||||
successor.symbol = "000002.SZ".into();
|
||||
instruments.push(successor);
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
instruments,
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: date(),
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
actions,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn conversion_book() -> PortfolioState {
|
||||
let mut book = PortfolioState::new(1000.);
|
||||
book.position_mut("000001.SZ")
|
||||
.buy(date().pred_opt().unwrap(), 100, 10.);
|
||||
book
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_without_frozen_instrument_metadata_is_not_an_implicit_new_security() {
|
||||
let mut action = conversion();
|
||||
action.share_cash = 1.;
|
||||
action.share_bonus = 1.;
|
||||
let data = conversion_data(vec![action], false);
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = vec!["prior".into()];
|
||||
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
|
||||
assert!(error.to_string().contains("successor_instrument_missing"));
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_successor_terms_cannot_be_replaced_with_one_share_or_zero_cash() {
|
||||
let base = conversion();
|
||||
let mut cases = Vec::new();
|
||||
for ratio in [
|
||||
None,
|
||||
Some(0.),
|
||||
Some(-1.),
|
||||
Some(f64::NAN),
|
||||
Some(f64::INFINITY),
|
||||
] {
|
||||
let mut row = base.clone();
|
||||
row.successor_ratio = ratio;
|
||||
cases.push(row);
|
||||
}
|
||||
for symbol in [
|
||||
None,
|
||||
Some(""),
|
||||
Some(" "),
|
||||
Some("000001.SZ"),
|
||||
Some(" 000002.SZ"),
|
||||
] {
|
||||
let mut row = base.clone();
|
||||
row.successor_symbol = symbol.map(str::to_owned);
|
||||
cases.push(row);
|
||||
}
|
||||
for cash in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut row = base.clone();
|
||||
row.successor_cash = Some(cash);
|
||||
cases.push(row);
|
||||
}
|
||||
for action in cases {
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let data = conversion_data(vec![action.clone()], true);
|
||||
let mut notes = Vec::new();
|
||||
assert!(
|
||||
apply(date(), &data, &mut book, &mut notes, true, true).is_err(),
|
||||
"accepted {action:?}"
|
||||
);
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_corporate_batch_failure_keeps_prior_cash_positions_and_notes() {
|
||||
let mut dividend = conversion();
|
||||
dividend.successor_symbol = None;
|
||||
dividend.successor_ratio = None;
|
||||
dividend.successor_cash = None;
|
||||
dividend.share_cash = 1.;
|
||||
dividend.share_bonus = 1.;
|
||||
let mut failure = conversion();
|
||||
failure.successor_cash = Some(1e100);
|
||||
let data = conversion_data(vec![dividend, failure], true);
|
||||
let mut book = conversion_book();
|
||||
let mut state = crate::stock_pool_state::StockPoolExecutionState {
|
||||
last_execution_date: date().pred_opt(),
|
||||
..Default::default()
|
||||
};
|
||||
state.position_action_bases.insert(
|
||||
"000001.SZ".into(),
|
||||
crate::stock_pool_state::StockPoolPositionActionBasis {
|
||||
generation: "original".into(),
|
||||
first_execution_date: date().pred_opt().unwrap(),
|
||||
quantity: rust_decimal::Decimal::from(100),
|
||||
},
|
||||
);
|
||||
state.last_target_weights.insert("000001.SZ".into(), 10000);
|
||||
book.set_stock_pool_execution_state("pool", state.clone())
|
||||
.unwrap();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = vec!["prior".into()];
|
||||
assert!(apply(date(), &data, &mut book, &mut notes, true, true).is_err());
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert_eq!(book.stock_pool_execution_state("pool"), state);
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_quantity_overflow_fails_without_saturation_or_a_negative_delta() {
|
||||
for split in [false, true] {
|
||||
let mut action = conversion();
|
||||
if split {
|
||||
action.share_bonus = 1e100;
|
||||
} else {
|
||||
action.successor_ratio = Some(1e100);
|
||||
}
|
||||
let data = conversion_data(vec![action], true);
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = Vec::new();
|
||||
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("corporate_action_quantity_overflow")
|
||||
);
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_conversion_preserves_lots_without_creating_orders_or_fills() {
|
||||
let data = conversion_data(vec![conversion()], true);
|
||||
let mut book = conversion_book();
|
||||
book.position_mut("000002.SZ").buy(date(), 50, 20.);
|
||||
let mut notes = Vec::new();
|
||||
let report = apply(date(), &data, &mut book, &mut notes, true, true).unwrap();
|
||||
assert!(book.position("000001.SZ").is_none());
|
||||
let successor = book.position("000002.SZ").unwrap();
|
||||
assert_eq!(successor.quantity, 200);
|
||||
assert_eq!(successor.opened_date(), date().pred_opt());
|
||||
assert_eq!(successor.last_buy_date(), Some(date()));
|
||||
assert_eq!(book.cash(), 1050.);
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(report.order_events.is_empty());
|
||||
assert_eq!(report.position_events.len(), 2);
|
||||
assert!(notes[0].contains("ratio=1.500000"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,19 +459,29 @@ impl CorporateAction {
|
||||
self.successor_symbol
|
||||
.as_ref()
|
||||
.is_some_and(|symbol| !symbol.trim().is_empty())
|
||||
&& self.successor_ratio_value() > 0.0
|
||||
}
|
||||
|
||||
pub fn successor_ratio_value(&self) -> f64 {
|
||||
self.successor_ratio
|
||||
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
pub fn successor_cash_value(&self) -> f64 {
|
||||
self.successor_cash
|
||||
.filter(|cash| cash.is_finite())
|
||||
.unwrap_or(0.0)
|
||||
/// A code mapping alone is not evidence for a 1:1 financial conversion.
|
||||
/// An absent cash component means no declared cash leg; an invalid one
|
||||
/// must never be replaced with zero.
|
||||
pub(crate) fn validated_successor_terms(&self) -> Result<Option<(&str, f64, f64)>, String> {
|
||||
let fail = |reason: &str| format!(
|
||||
"corporate_action_{reason}: symbol={} action_date={}", self.symbol, self.date);
|
||||
let Some(symbol) = self.successor_symbol.as_deref() else {
|
||||
if self.successor_ratio.is_some() || self.successor_cash.is_some() {
|
||||
return Err(fail("successor_symbol_missing"));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
if symbol.is_empty() || symbol.trim() != symbol || symbol == self.symbol
|
||||
|| self.symbol.is_empty() || self.symbol.trim() != self.symbol {
|
||||
return Err(fail("successor_symbol_invalid"));
|
||||
}
|
||||
let ratio = self.successor_ratio.filter(|ratio| ratio.is_finite() && *ratio > 0.)
|
||||
.ok_or_else(|| fail("successor_ratio_missing_or_invalid"))?;
|
||||
let cash = self.successor_cash.unwrap_or(0.);
|
||||
if !cash.is_finite() { return Err(fail("successor_cash_invalid")); }
|
||||
Ok(Some((symbol, ratio, cash)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,24 +101,15 @@ impl ManualCorporateReplay {
|
||||
if !symbols.contains(&action.symbol) {
|
||||
continue;
|
||||
}
|
||||
crate::finite_serialization::validate(action).map_err(|error| error.to_string())?;
|
||||
let successor_terms = crate::corporate_book::validate_action(action, data)?;
|
||||
let effective = (action.split_ratio() - 1.).abs() > f64::EPSILON
|
||||
|| action.has_successor_conversion()
|
||||
|| (self.cash_dividends && action.share_cash.abs() > f64::EPSILON);
|
||||
if !effective {
|
||||
continue;
|
||||
}
|
||||
if let Some(successor) = action
|
||||
.successor_symbol
|
||||
.as_ref()
|
||||
.filter(|_| action.has_successor_conversion())
|
||||
{
|
||||
if data.instrument(successor).is_none() {
|
||||
return Err(format!(
|
||||
"manual corporate successor is absent from frozen source data: symbol={successor} action_date={date}"
|
||||
));
|
||||
}
|
||||
symbols.insert(successor.clone());
|
||||
if let Some((successor, _, _)) = successor_terms {
|
||||
symbols.insert(successor.to_owned());
|
||||
}
|
||||
actions.push(ManualCorporateActionReference {
|
||||
date: *date,
|
||||
|
||||
@@ -18,6 +18,10 @@ enum Action {
|
||||
}
|
||||
|
||||
fn data(action: Action) -> DataSet {
|
||||
data_with_successor_metadata(action, true)
|
||||
}
|
||||
|
||||
fn data_with_successor_metadata(action: Action, include_successor: bool) -> DataSet {
|
||||
let days = [10, 11, 14, 15].map(date);
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
@@ -88,6 +92,7 @@ fn data(action: Action) -> DataSet {
|
||||
DataSet::from_components_with_actions(
|
||||
["000001.SZ", "000002.SZ"]
|
||||
.into_iter()
|
||||
.filter(|symbol| include_successor || *symbol != "000002.SZ")
|
||||
.map(|symbol| Instrument {
|
||||
symbol: symbol.into(),
|
||||
name: symbol.into(),
|
||||
@@ -280,6 +285,31 @@ fn delayed_sale_does_not_keep_unearned_corporate_entitlements() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_and_broker_observations_require_the_same_frozen_successor_scope() {
|
||||
for adapter in ["paper", "gt", "qmt"] {
|
||||
for delayed in [false, true] {
|
||||
for sell in [false, true] {
|
||||
let mut replay = source(delayed, sell);
|
||||
for action in &mut replay.actions {
|
||||
for order in &mut action.orders { order.source_adapter = Some(adapter.into()); }
|
||||
}
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
replay.validate().unwrap();
|
||||
let complete = run_custom(data(Action::Successor), replay.clone(), Hold, true, true).unwrap();
|
||||
assert_eq!(complete.holdings_summary[0].symbol, "000002.SZ");
|
||||
assert_eq!(complete.holdings_summary[0].quantity, 200);
|
||||
assert!(complete.fills.is_empty());
|
||||
assert!(complete.order_events.is_empty());
|
||||
let error = run_custom(data_with_successor_metadata(Action::Successor, false),
|
||||
replay, Hold, true, true).unwrap_err();
|
||||
assert!(error.to_string().contains("successor_instrument_missing"),
|
||||
"{adapter} delayed={delayed} sell={sell}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() {
|
||||
struct ExistingActivity {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 换股证据与公司行为批次原子性
|
||||
|
||||
2026-09-14。开发候选,未发布生产;完整目标保持未完成。
|
||||
|
||||
## 已复现的错误
|
||||
|
||||
1. 正常公司行为入口未核对后继证券是否存在于冻结数据:源持仓100股即使缺后继资料,仍可先派息、送转并生成300股后继持仓。手工校正入口此前虽有单独检查,但正常入口没有,两个路径口径不同。
|
||||
2. `successor_ratio_value` 将缺失、零、负数和非有限比例静默替换为1;非法现金字段也可能替换为0,错误输入因此成为看似成功的换股。
|
||||
3. 批次先更新派息/送转、后处理换股现金。当后续现金超出金额合同而失败时,旧证券已经消失,新证券和应收款仍留在账本;目标股数调整及日志也可能部分提交。
|
||||
|
||||
负向测试在修复前实际失败。测试代码初次访问私有日期字段的编译错误先改为正式访问方法;这不是业务反例。用于金额超界的首个有限大数仍在金额合同范围内,已改用确实超界的有限值后复现部分更新,不将未超界样例称为错误。
|
||||
|
||||
## 处理合同
|
||||
|
||||
- 正常和手工权益校正使用同一换股条款/冻结证券资料校验。必须提供明确、有限、正的换股比例;代码不能缺失、含首尾空格或指向自身。孤立的比例/现金字段也拒绝。没有声明现金组成时仍代表没有现金腿,但明确提供的非法数字不得补0。
|
||||
- 缺少源证券或后继证券资料时明确报告代码及公司行为日期,不生成隐式证券,不把价格行当成证券资料,更不把后继代码加入策略候选。
|
||||
- 有经济影响的公司行为在独立账本副本中完成整个批次,成功后一次更新资金、持仓、应收、任务目标单位及说明。失败原状态不变;没有涉及实际持仓的正常日不克隆整个账本。
|
||||
- 送转/换股股数超过事件和持仓的表示范围时明确失败,不能靠浮点转整数的饱和转换或负数delta继续运行。
|
||||
- 保留已声明条款、原取得日期及最近买入日期、成本和原策略配置;不伪造委托/市场成交,不把换股当作重新选股。没有重写已提交订单或猜测新目标权重。
|
||||
|
||||
## 验证及剩余工作
|
||||
|
||||
本机Core907、Trading625、Runner463/API129通过;原9/63/16项ignore分别保留,不计通过。本次未改UI,也没有重复上一轮UI测试。
|
||||
|
||||
新增缺后继资料、13类坏条款、金额失败全批回滚(含原目标单位/权重)、送转/换股数量溢出、有效换股保留两类取得日期等专项。整段引擎覆盖Paper/GT/QMT三种已审计来源、及时/迟到及买/卖12组合:完整资料均保留200股后继持仓且没有新增市场订单/成交;缺资料均明确失败。这是隔离回放,不是券商连接/真实委托验收。
|
||||
|
||||
Source当前公司行为Arrow字段及Runner `CorporateActionRowRecord`仍未提供换股条款,日快照仍显式为无换股字段。这是独立的正式数据能力缺口,不能以本轮核心校验冒充已完成后继证券自动取数。完整范围闭包、目标状态跨公司行为语义、清空后参考、ETF跨模式及实际Source/Runner仍需继续验证。Source运行版本冻结保持,不改旧合同SHA、不注册替代合同或启用交易;后端发布还未准入。
|
||||
|
||||
Linux使用本轮新快照单独验收,收据完成后追加,不复用旧reinvestment/AEGDYL/UUx5ru/FewUWP结果。
|
||||
Reference in New Issue
Block a user