rust: Proxy Pattern个人经验分享
作者:geovindu
还在为项目结构混乱而烦恼吗?本文总结个人实战经验,分享如何设计清晰高效的项目结构,涵盖代码组织、开发规范等核心要点,帮你快速提升项目可维护性,少走弯路,适合新手和想优化项目结构的开发者阅读
rust: Proxy Pattern
项目结构

//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : access_role.rs
//! # 访问角色 —— 开放型标签
//!
//! ## 为什么不是枚举
//!
//! 角色会新增:楼面主管、区域财务、加盟商、外部审计师……
//! 枚举每加一个都要改本文件。用 `const fn new` 结构体后,
//! 扩展者在自己代码里就能定义新角色(本工程第八幕实证)。
//!
//! ## 角色 = 访问范围 + 两条资源上限
//!
//! 资源上限(单次查询最大天数、最大返回行数)**刻意放在角色上**
//! 而不是放在代理的全局配置里。理由:不同角色对后端的压力完全不同——
//! 门店店员查 30 天只有 30 行,而总部审计查 30 天有 3,600 行。
//! 用一个全局阈值,要么卡死审计(阈值按店员定),
//! 要么对店员形同虚设(阈值按审计定)。
//!
//! 这也是「代理必须理解被保护对象的语义」的又一例证:
//! 一个只会做「计数限流」的通用代理做不到这件事,
//! 因为它不知道「行数」对这两个角色的含义差了 120 倍。
use super::access_scope::{AccessScope, StoreVisibility};
use super::metric_kind::MetricKind;
use super::sensitivity_level::SensitivityLevel;
/// 一个访问角色(开放型)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccessRole {
/// 角色编码(如 `"STORE_CLERK"`)。参与相等比较,作为角色身份。
code: &'static str,
/// 中文名称(如 `"门店店员"`),仅用于展示。
display_name: &'static str,
/// 访问范围。
scope: AccessScope,
/// 单次查询允许的最大区间天数(闭区间口径)。
maximum_query_span_days: u32,
/// 单次查询允许返回的最大明细行数。
maximum_result_rows: usize,
}
impl AccessRole {
/// 构造一个访问角色。
///
/// 参数 `code` / `display_name` / `scope` / `maximum_query_span_days`
/// / `maximum_result_rows`。
/// 返回:角色。
pub const fn new(
code: &'static str,
display_name: &'static str,
scope: AccessScope,
maximum_query_span_days: u32,
maximum_result_rows: usize,
) -> AccessRole {
AccessRole {
code,
display_name,
scope,
maximum_query_span_days,
maximum_result_rows,
}
}
/// 角色编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn display_name(&self) -> &'static str {
self.display_name
}
/// 访问范围。
pub const fn scope(&self) -> AccessScope {
self.scope
}
/// 单次查询允许的最大区间天数。
pub const fn maximum_query_span_days(&self) -> u32 {
self.maximum_query_span_days
}
/// 单次查询允许返回的最大明细行数。
pub const fn maximum_result_rows(&self) -> usize {
self.maximum_result_rows
}
/// 返回 `区域经理(REGIONAL_MANAGER)` 形式的展示文本。
pub fn formatted(&self) -> String {
format!("{}({})", self.display_name, self.code)
}
/// 返回本角色**最终可见**的指标列表(白名单 ∩ 敏感级)。
///
/// 参数 `candidates`:候选指标列表。
/// 返回:满足两条判断的指标。
///
/// 这个方法让「角色能看到什么」在代码层可枚举——
/// 报表可以据此打印权限矩阵,体检可以据此算「可见指标数」。
/// 若只在保护代理内部做判断,这份信息就永远无法被观测到。
pub fn visible_metrics(&self, candidates: &[MetricKind]) -> Vec<MetricKind> {
candidates
.iter()
.copied()
.filter(|metric| self.scope.allows_metric(metric))
.collect()
}
}
/// 门店店员。
///
/// 只能看本店的客流与营业额,且只能查 31 天以内。
/// 敏感级上限为「内部」——**看不到毛利率与毛利额**。
///
/// 白名单里刻意**不含客单价**:它可由「营业额 ÷ 客流」推出,
/// 而客单价是内部级、不高于本角色上限,因此**不构成跨级泄露**。
/// 这条边界正是本工程推理泄露规则的适用范围:
/// 规则只拦「敏感级高于角色上限」的推导,
/// 同级别内的推导不拦(否则任何两条可见指标都能推出第三条,规则会失效)。
pub const ROLE_STORE_CLERK: AccessRole = AccessRole::new(
"STORE_CLERK",
"门店店员",
AccessScope::new(
StoreVisibility::HomeStoreOnly,
&["FOOTFALL", "REVENUE"],
SensitivityLevel::Internal,
1,
),
31,
62,
);
/// 门店店长。
///
/// 比店员多一个客单价,其余相同。
pub const ROLE_STORE_MANAGER: AccessRole = AccessRole::new(
"STORE_MANAGER",
"门店店长",
AccessScope::new(
StoreVisibility::HomeStoreOnly,
&["FOOTFALL", "BASKET", "REVENUE"],
SensitivityLevel::Internal,
1,
),
31,
100,
);
/// 区域经理(华南区)。
///
/// ## ★ 这是本工程「推理泄露」的现场
///
/// 白名单里有 `PROFIT`(毛利额)却**没有** `MARGIN`(毛利率),
/// 意图是「让区域经理判断盈利贡献,但别让他反推成本结构」。
/// 敏感级上限设为「内部」,而 `MARGIN` 是「机密」——
/// 配置者以为这样就把毛利率挡住了。
///
/// **但这是做不到的**:毛利额 ÷ 营业额 = 毛利率。
/// 只要能同时看到 `PROFIT` 与 `REVENUE`,机密级毛利率就等价于公开。
///
/// 保护代理会检测到这一点,并**额外剥离 `PROFIT`**(保留客流、客单价、营业额),
/// 同时留下一条可被体检抓到的记录。这是本工程第五幕的核心演示。
///
/// 为什么选择「剥离来源指标」而不是「拒绝整个请求」:
/// 拒绝会让区域经理连营业额都看不到,业务不可用;
/// 剥离则保住了 80% 的业务价值,同时**显式**暴露配置缺陷
/// (脱敏记录里会写明「因推理泄露而额外隐藏了 PROFIT」)。
/// 唯一不可接受的是**静默降级**——那才是真正的安全缺陷。
pub const ROLE_REGIONAL_MANAGER: AccessRole = AccessRole::new(
"REGIONAL_MANAGER",
"区域经理",
AccessScope::new(
StoreVisibility::ByRegion(&["SOUTH"]),
&["FOOTFALL", "BASKET", "REVENUE", "PROFIT"],
SensitivityLevel::Internal,
1,
),
31,
1200,
);
/// 总部审计。
///
/// 唯一能看到全部五个指标(含机密级)的角色。
pub const ROLE_HEADQUARTERS_AUDITOR: AccessRole = AccessRole::new(
"HQ_AUDITOR",
"总部审计",
AccessScope::new(
StoreVisibility::All,
&["FOOTFALL", "BASKET", "REVENUE", "MARGIN", "PROFIT"],
SensitivityLevel::Confidential,
1,
),
60,
4000,
);
/// 外部供应商(第三方合作方)。
///
/// 只能拿到**汇总**(看不到任何门店明细),且只能看营业额。
/// 这是 `SummaryOnly` 的典型用法:合作方需要知道「这个品类的总体规模」,
/// 但不该知道任何单店流水。
pub const ROLE_EXTERNAL_VENDOR: AccessRole = AccessRole::new(
"EXTERNAL_VENDOR",
"外部供应商",
AccessScope::new(
StoreVisibility::SummaryOnly,
&["REVENUE"],
SensitivityLevel::Internal,
10,
),
31,
1,
);
/// 全部内置角色,顺序即报表的默认行序。
pub const BUILTIN_ROLES: [AccessRole; 5] = [
ROLE_STORE_CLERK,
ROLE_STORE_MANAGER,
ROLE_REGIONAL_MANAGER,
ROLE_HEADQUARTERS_AUDITOR,
ROLE_EXTERNAL_VENDOR,
];
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : access_scope.rs
//! # 访问范围 —— 「这个角色能看到什么」的完整声明
//!
//! ## 为什么把四条约束收进一个结构体
//!
//! 保护代理要做四类判断:行级(哪些门店)、列级(哪些指标)、
//! 等级级(敏感级上限)、资源级(一次能查多大)。
//! 若把它们拆成四个参数散落在角色定义里,新增一条约束时
//! 就要同时改角色结构体、代理构造函数、装配代码三处——
//! 而漏改任何一处都不会报错(参数顺序相同、类型相同的字段很容易接错)。
//!
//! 收进一个结构体后,新增约束只需改两处:本结构体 + 保护代理的一处判断。
//! 更重要的是:`AccessScope` 可以作为一个整体被传递、被打印、被体检,
//! 报表里「这个角色的权限是什么」就是打印这一个对象。
//!
//! ## 五条约束分别对应什么攻击面
//!
//! | 约束 | 挡住什么 |
//! |---|---|
//! | `store_visibility` | **横向越权**:店员查别家门店的流水 |
//! | `allowed_metric_codes` | **纵向越权**:基层查成本结构 |
//! | `maximum_sensitivity` | **等级越权**:白名单写错时仍有一道兜底 |
//! | `minimum_aggregate_store_count` | **小样本反推**:把汇总范围缩到一家店 |
//! | `role.maximum_*` | **资源滥用**:一次拉全表把后端拖垮 |
//!
//! 前四条在本结构体里,第五条在角色上(因为它同时约束「天数」与「行数」,
//! 与「可见性」不是一类概念)。
//!
//! 五条约束**同时**判断,且是「与」关系。任何一条不满足即拒绝。
//! 这与同系列 Builder 工程「一次报全」的纪律一致:
//! 一个请求可能同时踩多条红线,只报第一条会让排查者改一次跑一次。
use super::metric_kind::MetricKind;
use super::region_tag::RegionTag;
use super::sensitivity_level::SensitivityLevel;
use super::store_code::StoreCode;
/// 门店可见性模式。
///
/// 用封闭枚举:模式决定代码分支(四种模式各自走不同的过滤逻辑),
/// 且模式的种类是**有限且稳定**的——不会有人新增「第 5 种可见性模式」。
/// 真正会增长的是**模式里的参数**(区域列表),那部分用 `&'static [&'static str]`。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreVisibility {
/// 只能取汇总,看不到任何门店明细。
SummaryOnly,
/// 只能看会话所属门店。
///
/// 「所属门店」不写在本枚举里,而由查询请求携带——
/// 因为它是**会话属性**(谁在什么位置登录),不是**角色属性**。
/// 把所属门店固化进角色定义,等于同一个「店员」角色在 120 家门店
/// 要定义 120 次。
HomeStoreOnly,
/// 按区域可见(区域编码列表)。
ByRegion(&'static [&'static str]),
/// 全部门店可见。
All,
}
impl StoreVisibility {
/// 中文标签。
pub const fn label(&self) -> &'static str {
match self {
StoreVisibility::SummaryOnly => "仅汇总",
StoreVisibility::HomeStoreOnly => "仅本店",
StoreVisibility::ByRegion(_) => "按区域",
StoreVisibility::All => "全部",
}
}
/// 是否允许看到门店明细。
pub const fn allows_detail(&self) -> bool {
!matches!(self, StoreVisibility::SummaryOnly)
}
}
/// 一个角色的访问范围。
///
/// 全部字段私有,只能经 `new` 构造——保证「五条约束总是成套存在」,
/// 不会出现「只配了指标白名单、忘了配敏感级上限」的半成品。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccessScope {
/// 门店可见性。
store_visibility: StoreVisibility,
/// 可见指标编码白名单。
allowed_metric_codes: &'static [&'static str],
/// 可见的最高敏感级。
maximum_sensitivity: SensitivityLevel,
/// 汇总模式下的最小样本门店数。
///
/// ## 这一条挡住的是「小样本反推」
///
/// 一个只能取汇总的角色,若允许它在**任意门店范围**上取汇总,
/// 那么它只要把范围缩到一家门店,拿到的「汇总」就是那家店的明细。
/// 汇总权限等于凭空变成了明细权限。
///
/// 因此汇总必须绑定一个最小样本数:请求涉及的门店数低于这个值时拒绝。
/// 这是统计披露控制(statistical disclosure control)里最基本的
/// 「最小单元格规模」规则,本工程把它显式建成一条约束。
///
/// 明细角色(非 `SummaryOnly`)此值填 1 即可——它们本来就能看明细,
/// 不存在反推问题。
minimum_aggregate_store_count: usize,
}
impl AccessScope {
/// 构造一个访问范围。
///
/// 参数 `store_visibility` / `allowed_metric_codes` / `maximum_sensitivity`
/// / `minimum_aggregate_store_count`。
/// 返回:访问范围。
pub const fn new(
store_visibility: StoreVisibility,
allowed_metric_codes: &'static [&'static str],
maximum_sensitivity: SensitivityLevel,
minimum_aggregate_store_count: usize,
) -> AccessScope {
AccessScope {
store_visibility,
allowed_metric_codes,
maximum_sensitivity,
minimum_aggregate_store_count,
}
}
/// 门店可见性。
pub const fn store_visibility(&self) -> StoreVisibility {
self.store_visibility
}
/// 可见指标编码白名单。
pub const fn allowed_metric_codes(&self) -> &'static [&'static str] {
self.allowed_metric_codes
}
/// 可见的最高敏感级。
pub const fn maximum_sensitivity(&self) -> SensitivityLevel {
self.maximum_sensitivity
}
/// 汇总模式下的最小样本门店数。
pub const fn minimum_aggregate_store_count(&self) -> usize {
self.minimum_aggregate_store_count
}
/// 判断某家门店是否可见。
///
/// 参数 `store`:待判定的门店;`home_store_code`:调用方所属门店编码(可为 `None`)。
/// 返回:可见时为 `true`。
///
/// ⚠️ `HomeStoreOnly` 在 `home_store_code` 为 `None` 时**返回 `false`**。
/// 这个方向不能反:若返回 `true`,一个没有携带所属门店的请求
/// (例如伪造的网关请求)就能看到任意门店的数据。
/// 「缺省即拒绝」(fail-closed)是访问控制的铁律——
/// 缺省即放行(fail-open)的安全缺陷在真实系统中屡见不鲜。
pub fn allows_store(&self, store: &StoreCode, home_store_code: Option<&str>) -> bool {
match self.store_visibility {
StoreVisibility::SummaryOnly => false,
StoreVisibility::HomeStoreOnly => match home_store_code {
Some(code) => store.code() == code,
// 缺省即拒绝。
None => false,
},
StoreVisibility::ByRegion(allowed_region_codes) => allowed_region_codes
.iter()
.any(|candidate| *candidate == store.region().code()),
StoreVisibility::All => true,
}
}
/// 判断某个指标是否可见。
///
/// 参数 `metric`:待判定的指标。
/// 返回:可见时为 `true`。
///
/// 两道判断是**与**关系:
/// 1. 指标编码在白名单里(业务上「这个角色需要看它」);
/// 2. 指标的敏感级不超过权限上限(安全上「这个角色配看它」)。
///
/// 为什么两道的与关系很重要:单靠白名单,一旦配置者手滑把
/// `MARGIN` 写进了基层角色的白名单,机密数据当场泄露;
/// 单靠敏感级,则任何等级足够的角色都会看到**全部**指标,
/// 无法表达「区域经理不需要看库存周转」这类业务偏好。
/// 两道一起用,白名单负责业务意图、敏感级负责安全兜底。
pub fn allows_metric(&self, metric: &MetricKind) -> bool {
let in_whitelist: bool = self
.allowed_metric_codes
.iter()
.any(|candidate| *candidate == metric.code());
let within_level: bool = self.maximum_sensitivity.allows(metric.sensitivity());
in_whitelist && within_level
}
/// 判断某个区域是否在可见范围内(供远程代理做代价估算)。
///
/// 与 `allows_store` 的区别:本函数回答「这个区域整体是否需要转发」,
/// 不涉及具体门店。`SummaryOnly` 返回 `false`——
/// 汇总模式下不需要拉取任何门店明细,也就没有跨区往返。
pub fn covers_region(&self, region: RegionTag) -> bool {
match self.store_visibility {
StoreVisibility::SummaryOnly => false,
StoreVisibility::HomeStoreOnly => false,
StoreVisibility::ByRegion(allowed_region_codes) => allowed_region_codes
.iter()
.any(|candidate| *candidate == region.code()),
StoreVisibility::All => true,
}
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : currency_amount.rs
//! # 货币与金额 —— 整数「分」的存储
//!
//! ## 为什么金额必须是整数
//!
//! 本工程要计算「营业额合计」「毛利额」并逐项打印出来核对。若用 `f64` 存金额:
//! 1. **累加会漂移**:`0.1 + 0.2 != 0.3`,上百家门店 × 30 天累加后误差肉眼可见;
//! 2. **相等不可靠**:本工程要把「查询条件」做成缓存键,若键里含金额,
//! 浮点相等在 `NaN` 与 `-0.0` 上有陷阱,会让同一个查询偶尔命中偶尔不命中;
//! 3. **展示口径会分叉**:「¥12.3」还是「¥12.30」,浮点要靠格式化规则去猜。
//!
//! 因此本类型用 `i64` 存**最小单位**(人民币的「分」),
//! 并用 `currency` 字段记录币种。总部用人民币、中国香港门店用港币,
//! 两种币种不能直接相加——`add` 会在币种不一致时给出 `None`。
//!
//! ## 「分」而不是「元」的乘法保护
//!
//! 「客单价 × 客流 = 营业额」是核心运算之一(本工程的指标之间存在
//! 可手算核对的推导关系)。若用「元」为单位,
//! 一个 ¥328.50 的客单价乘 1,200 人会先得到浮点中间值,再四舍五入——
//! 每一步都可能丢分,而「营业额 = 客流 × 客单价」这个核对关系就再也对不上。
//! 用「分」后:`32850 分` 与 `1200` 相乘,中间量用 `i128` 承载,
//! 最后一次性落到「分」,**全程只有一次舍入**。这是本工程
//! 「指标之间可以互相验算」的前提。
//!
//! ## 关于与 `ratio` 的同层互相引用
//!
//! 本文件需要 `Ratio`(用于 `ratio_of` 计算占比、毛利率),
//! 而 `ratio.rs` 需要 `CurrencyAmount`(用于 `Ratio::apply_to`)。
//! 两个同层模块互相引用在 Rust 里完全合法,也**不违反分层约束**——
//! 依赖方向检查关心的是「层与层之间」,同层内的模块互引不构成环。
//!
//! 但互相引用仍要克制:若同层模块之间形成密集网,说明这一层
//! 应该再切成两个层。本层只有这一处双向引用,且是两个紧邻概念
//! (金额与比率)之间的天然耦合,可以接受。
// 同层引用:万分比分母常量与比率类型。
use super::ratio::{Ratio, BASIS_POINTS_DENOMINATOR};
/// 币种标签(开放型)。
///
/// ## 为什么不是枚举
///
/// 连锁珠宝零售商会在新地区开店(新加坡、马来西亚、日本……),
/// 币种数量会增长。枚举每加一种都要改本文件,违反「工程外可扩展」。
/// 用 `const fn new` 结构体后,扩展者在自己代码里就能定义新币种:
///
/// ```ignore
/// // 工程外(main.rs 的扩展区),不需要改 domain 层
/// const CURRENCY_SINGAPORE_DOLLAR: Currency =
/// Currency::new("SGD", "新加坡元", 100, "S$");
/// ```
///
/// 字段私有 + `const fn new` 的代价是「无法在编译期阻止拼错币种编码」,
/// 但收益是「新增币种零侵入」。对本工程而言,后者更重要——
/// 这正是开放型标签与封闭枚举的取舍标准。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Currency {
/// 币种编码(如 `"CNY"`、`"HKD"`)。参与相等比较,作为币种身份。
code: &'static str,
/// 中文名称(如 `"人民币"`),仅用于展示。
display_name: &'static str,
/// 一个主单位包含多少个最小单位(人民币为 100,即 100 分)。
minor_units_per_major_unit: i64,
/// 货币符号(如 `"¥"`),仅用于展示。
symbol: &'static str,
}
impl Currency {
/// 构造一个币种。
///
/// 参数 `code` / `display_name` / `minor_units_per_major_unit` / `symbol`。
/// 返回:币种标签。
///
/// `const fn` 使本函数可用于定义 `const` 常量——
/// 这是「工程外零侵入扩展」能成立的技术前提。
pub const fn new(
code: &'static str,
display_name: &'static str,
minor_units_per_major_unit: i64,
symbol: &'static str,
) -> Currency {
Currency {
code,
display_name,
minor_units_per_major_unit,
symbol,
}
}
/// 币种编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn display_name(&self) -> &'static str {
self.display_name
}
/// 一个主单位包含多少个最小单位。
pub const fn minor_units_per_major_unit(&self) -> i64 {
self.minor_units_per_major_unit
}
/// 货币符号。
pub const fn symbol(&self) -> &'static str {
self.symbol
}
}
/// 人民币。
pub const CURRENCY_CHINESE_YUAN: Currency = Currency::new("CNY", "人民币", 100, "¥");
/// 港币(中国香港门店使用)。
pub const CURRENCY_HONG_KONG_DOLLAR: Currency = Currency::new("HKD", "港币", 100, "HK$");
/// 一笔金额。
///
/// `minor_units` 是最小单位数(人民币即「分」)。
/// 允许为负:退款、成本冲销等场景需要负金额。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CurrencyAmount {
/// 最小单位数(人民币为「分」)。
minor_units: i64,
/// 币种。
currency: Currency,
}
impl CurrencyAmount {
/// 由最小单位数构造。
///
/// 参数 `minor_units` / `currency`。
/// 返回:金额。
pub const fn from_minor_units(minor_units: i64, currency: Currency) -> CurrencyAmount {
CurrencyAmount {
minor_units,
currency,
}
}
/// 由「主单位 + 最小单位」构造(如 `(12, 34)` → `¥12.34`)。
///
/// 参数 `major_units` / `minor_unit_part` / `currency`。
/// 返回:金额。
///
/// 中间量用 `i128`:`major_units` 若接近 `i64::MAX`,
/// 乘以 100 会溢出。虽然实际业务不会出现这种值,
/// 但用一个更宽的中间类型只是写起来多几个字符,却能免除一整类溢出风险。
pub const fn from_major_and_minor(
major_units: i64,
minor_unit_part: i64,
currency: Currency,
) -> CurrencyAmount {
let total_minor_units: i128 =
major_units as i128 * currency.minor_units_per_major_unit as i128
+ minor_unit_part as i128;
CurrencyAmount {
minor_units: clamp_i128_to_i64(total_minor_units),
currency,
}
}
/// 零金额。
pub const fn zero(currency: Currency) -> CurrencyAmount {
CurrencyAmount::from_minor_units(0, currency)
}
/// 最小单位数。
pub const fn minor_units(&self) -> i64 {
self.minor_units
}
/// 币种。
pub const fn currency(&self) -> Currency {
self.currency
}
/// 是否为零。
pub const fn is_zero(&self) -> bool {
self.minor_units == 0
}
/// 是否为负(退款、冲销)。
pub const fn is_negative(&self) -> bool {
self.minor_units < 0
}
/// 取绝对值。
///
/// 注意 `i64::MIN` 的绝对值仍是 `i64::MIN`(溢出环绕),
/// 因此这里用 `saturating_abs`:极端值下钳到 `i64::MAX` 而非回绕成负数。
/// 业务上不会出现 `i64::MIN` 分(约 9.2e16 元),但用饱和运算只是代价极小的保险。
pub const fn absolute(&self) -> CurrencyAmount {
CurrencyAmount {
minor_units: self.minor_units.saturating_abs(),
currency: self.currency,
}
}
/// 相加。币种不一致时返回 `None`。
///
/// 参数 `other`:另一笔金额。
/// 返回:同币种时返回和,否则 `None`。
///
/// ## 为什么返回 `Option` 而不是 panic 或静默按本方币种处理
///
/// 把人民币和港币相加在业务上是**错误**,不是「需要兜底」的情况。
/// panic 会让整个报表拿不到(一个门店币种配错,全表都看不见);
/// 静默混算会产出看似合理的错误数字(最危险)。
/// 返回 `None` 让错误**在调用点显式暴露**,由调用方决定是跳过还是报错。
pub fn add(&self, other: &CurrencyAmount) -> Option<CurrencyAmount> {
if self.currency != other.currency {
return None;
}
// 用 i128 中间量,避免两个接近 i64::MAX 的金额相加溢出。
let sum: i128 = self.minor_units as i128 + other.minor_units as i128;
Some(CurrencyAmount {
minor_units: clamp_i128_to_i64(sum),
currency: self.currency,
})
}
/// 相减。币种不一致时返回 `None`。
pub fn subtract(&self, other: &CurrencyAmount) -> Option<CurrencyAmount> {
if self.currency != other.currency {
return None;
}
let difference: i128 = self.minor_units as i128 - other.minor_units as i128;
Some(CurrencyAmount {
minor_units: clamp_i128_to_i64(difference),
currency: self.currency,
})
}
/// 乘以一个整数倍数。
///
/// 参数 `quantity`:倍数(如「几件」「几人」)。
/// 返回:乘积金额。
pub fn multiply_by_quantity(&self, quantity: i64) -> CurrencyAmount {
let product: i128 = self.minor_units as i128 * quantity as i128;
CurrencyAmount {
minor_units: clamp_i128_to_i64(product),
currency: self.currency,
}
}
/// 按万分比缩放(如「毛利额 = 营业额 × 毛利率 18.42%」写作 `1842` 万分点)。
///
/// 参数 `basis_points`:万分点。
/// 返回:缩放后的金额。
///
/// 这是**本工程的毛利额计算入口**:`营业额 × 毛利率`。
/// 例如营业额 ¥393,600.00(39,360,000 分)乘以毛利率 1842 万分点:
/// `39360000 * 1842 / 10000 = 7250112` 分 = ¥72,501.12。
///
/// 与 `Ratio::apply_to` 的关系:后者是本函数的类型安全包装。
/// 调用方**应当**优先写 `ratio.apply_to(&amount)`,
/// 只有手上只有一个裸万分点整数时才直接用本函数。
pub fn scale_by_basis_points(&self, basis_points: i64) -> CurrencyAmount {
// 三步:乘 → 除 → 舍入。中间量用 i128 承载乘积。
let scaled: i128 = self.minor_units as i128 * basis_points as i128;
let rounded: i128 = divide_rounded_away_from_zero(scaled, BASIS_POINTS_DENOMINATOR as i128);
CurrencyAmount {
minor_units: clamp_i128_to_i64(rounded),
currency: self.currency,
}
}
/// 除以一个正整数数量,得到「单位金额」(如 `营业额 ÷ 客流 = 客单价`)。
///
/// 参数 `count`:除数(客流人数、订单笔数……)。
/// 返回:单位金额。`count <= 0` 时返回零金额(**不 panic**)。
///
/// ## 为什么不需要 `scale_by_minute_fraction` 那类换算入口
///
/// 同系列 Flyweight 工程需要一个「时薪 × 分钟数 / 60」的入口,
/// 因为它的业务里有时间单位换算。**本工程没有时间维度的金额换算**,
/// 因此那个入口在此处不会存在——「不写用不到的方法」也是扩展性纪律的一部分:
/// 一个从未被使用的公开方法,会在下一次代码审查时被误认为是「预留的扩展点」。
///
/// ## 为什么除零返回零而不是 panic
///
/// 客流为 0 的门店是**真实存在**的(新店筹备期、整修停业)。
/// 此时「客单价」在数学上无定义。若 panic,一家停业门店会让整张报表拿不到;
/// 若返回 0,报表能完整打印,而「客单价 ¥0.00 且客流 0」这个组合
/// 在人工核对时一眼就能看出是「无客流」而不是「客单价真的为零」。
/// **错误要可见,不要致命**——与 `add` 返回 `Option` 是同一条原则。
pub fn divide_by_count(&self, count: i64) -> CurrencyAmount {
if count <= 0 {
return CurrencyAmount::from_minor_units(0, self.currency);
}
// 中间量用 i128 承载:先放大再除,避免整数除法提前丢精度。
let numerator: i128 = self.minor_units as i128;
let rounded: i128 = divide_rounded_away_from_zero(numerator, count as i128);
CurrencyAmount {
minor_units: clamp_i128_to_i64(rounded),
currency: self.currency,
}
}
/// 计算 `self` 占 `other` 的比例(如 `毛利额 / 营业额 = 毛利率`)。
///
/// 参数 `other`:分母金额。
/// 返回:万分比。分母为零或币种不一致时返回 [`Ratio::zero`]。
///
/// ## 这是本工程最重要的一条「可验算关系」
///
/// 真实主题生成演示数据时,是**先**独立生成客流与客单价,
/// 再派生出营业额与毛利额(`Range::apply_to`);而报表要做的是
/// **反推**:用 `毛利额 / 营业额` 还原出毛利率,与生成时用的那个
/// 毛利率比对。两条路径一个乘、一个除,若都能对上,
/// 说明整条链路的金额运算没有丢精度。
///
/// 若在调用点自己写 `(a * 10000) / b`:
/// - 分子会先溢出(本工程营业额在 `i64` 内,但乘以 10000 就可能越界);
/// - 舍入方向不明确(整数除法向零截断,与仓库生成时的"远离零"不一致),
/// 导致核对时**总是差 1 万分点**,而这种差又小到容易被当成舍入噪声放过。
/// 把口径收进本函数是唯一可靠的做法。
pub fn ratio_of(&self, other: &CurrencyAmount) -> Ratio {
if self.currency != other.currency || other.minor_units == 0 {
return Ratio::zero();
}
// 先放大到万分点尺度再除,中间量用 i128。
let numerator: i128 = self.minor_units as i128 * BASIS_POINTS_DENOMINATOR as i128;
let rounded: i128 = divide_rounded_away_from_zero(numerator, other.minor_units as i128);
Ratio::from_basis_points(clamp_i128_to_i64(rounded))
}
/// 返回 `¥1,234.56` 形式的展示文本。
///
/// 实现要点:
/// 1. 负数先分离符号,绝对值参与分拆(否则 `-1234 % 100` 会得到 `-34`);
/// 2. 按币种的 `minor_units_per_major_unit` 拆分主单位与最小单位
/// ——**不要硬编码 100**。日元没有最小单位(`minor_units_per_major_unit = 1`),
/// 硬编码 100 会把 ¥1000 显示成 ¥10.00;
/// 3. 千分位由自由函数插入。
pub fn formatted(&self) -> String {
let sign: &str = if self.minor_units < 0 { "-" } else { "" };
let absolute_value: i64 = self.minor_units.saturating_abs();
let unit_scale: i64 = self.currency.minor_units_per_major_unit();
// 主单位部分与最小单位部分。
let major_part: i64 = absolute_value / unit_scale;
let minor_part: i64 = absolute_value % unit_scale;
// 最小单位的补零宽度由 `unit_scale` 决定:100 → 2 位,1 → 0 位,1000 → 3 位。
let minor_digits: usize = decimal_digits_of(unit_scale);
if minor_digits == 0 {
// 无最小单位(如日元):不输出小数点。
format!(
"{}{}{}",
sign,
self.currency.symbol(),
insert_thousands_separator(major_part)
)
} else {
format!(
"{}{}{}.{:0width$}",
sign,
self.currency.symbol(),
insert_thousands_separator(major_part),
minor_part,
width = minor_digits
)
}
}
/// 返回 `1234.56 CNY` 形式的展示文本(金额 + 币种编码)。
///
/// 用于「多币种并列」的场景:符号 `¥` 与 `HK$` 并排时容易看混,
/// 加上币种编码可以消歧。本工程的总部与中国香港子公司数据合并报表会用到。
pub fn formatted_with_currency_code(&self) -> String {
let sign: &str = if self.minor_units < 0 { "-" } else { "" };
let absolute_value: i64 = self.minor_units.saturating_abs();
let unit_scale: i64 = self.currency.minor_units_per_major_unit();
let major_part: i64 = absolute_value / unit_scale;
let minor_part: i64 = absolute_value % unit_scale;
let minor_digits: usize = decimal_digits_of(unit_scale);
if minor_digits == 0 {
format!(
"{}{} {}",
sign,
insert_thousands_separator(major_part),
self.currency.code()
)
} else {
format!(
"{}{}.{:0width$} {}",
sign,
insert_thousands_separator(major_part),
minor_part,
self.currency.code(),
width = minor_digits
)
}
}
}
/// 把 `i128` 安全钳制到 `i64` 范围。
///
/// 参数 `value`:待钳制的值。
/// 返回:落在 `i64::MIN..=i64::MAX` 内的值。
///
/// 这是本工程唯一的「溢出兜底」函数,所有 `i128 → i64` 的回落都走它,
/// 避免在多处各写一遍边界判断(那样迟早会漏掉一处)。
pub const fn clamp_i128_to_i64(value: i128) -> i64 {
if value > i64::MAX as i128 {
i64::MAX
} else if value < i64::MIN as i128 {
i64::MIN
} else {
value as i64
}
}
/// 整数除法,**远离零方向**四舍五入。
///
/// 参数 `numerator` / `denominator`。
/// 返回:舍入后的商。
///
/// ## 为什么不用 `(a + b/2) / b`
///
/// 那个写法有四个问题,本工程的金额核算依赖此函数,因此必须写对:
/// 1. **只对正数成立**:`-7/2` 用该式得到 `-3`(向零),而「远离零」应为 `-4`;
/// 2. **中途相加可能溢出**:`a + b/2` 在 `a` 接近上界时会溢出;
/// 3. **b 为负时不成立**;
/// 4. **它不是显式的**:读者要从表达式反推舍入方向。
///
/// 本实现用「取商 + 取余 + 按余数修正」的显式步骤,
/// 每一步的舍入方向都写在注释里,可被逐行核对。
pub const fn divide_rounded_away_from_zero(numerator: i128, denominator: i128) -> i128 {
if denominator == 0 {
// 除零:返回 0 而非 panic。业务上不会有零分母,
// 但报表崩掉比出现一个可疑的 0 更难排查——错误要可见。
return 0;
}
// 第一步:取商的整数部分(Rust 的整数除法天然向零截断)。
let quotient: i128 = numerator / denominator;
// 第二步:取余数,判断是否需要进位/借位。
let remainder: i128 = numerator % denominator;
if remainder == 0 {
// 整除,无舍入。
return quotient;
}
// 第三步:符号一致时「远离零」,即商的绝对值加 1。
// `numerator` 与 `denominator` 同号 ⇒ 结果为正 ⇒ 加 1;
// 异号 ⇒ 结果为负 ⇒ 减 1。两者都是「远离零」。
let same_sign: bool = (numerator > 0) == (denominator > 0);
if same_sign {
quotient + 1
} else {
quotient - 1
}
}
/// 返回一个整数对应的十进制位数(用于最小单位的补零宽度)。
///
/// 参数 `value`:正整数(传入 `1` / `100` / `1000`)。
/// 返回:位数(`1` → 0,`100` → 2,`1000` → 3)。
///
/// 定义:`value = 10^n` 时返回 `n`;其余情况返回 `1` 作为兜底。
/// 用 `match` 精确列出而非 `log10` 取对数——`log10` 会引入浮点,
/// 而本工程「全程无浮点」是硬性纪律。
pub const fn decimal_digits_of(value: i64) -> usize {
match value {
1 => 0,
10 => 1,
100 => 2,
1_000 => 3,
10_000 => 4,
// 非常见取值范围:按 1 位处理,保证不 panic。
_ => 1,
}
}
/// 给整数的十进制文本插入千分位分隔符。
///
/// 参数 `value`:非负整数。
/// 返回:形如 `1,234,567` 的文本。
///
/// 实现思路(**从右往左**每三位一插):
/// 先把数字转字符串,再从个位方向每 3 位打一个逗号。
/// 用「临时字符数组 + 反向读取」而不是「按位置切片拼接」,
/// 后者在字符串长度不是 3 的倍数时要处理前导不足,分支更多。
fn insert_thousands_separator(value: i64) -> String {
let digits: String = value.to_string();
let digit_bytes: &[u8] = digits.as_bytes();
let mut buffer: Vec<char> = Vec::with_capacity(digit_bytes.len() + digit_bytes.len() / 3);
// 反向遍历:`reversed_index` 是「从右数第几位」(0 基)。
let mut reversed_index: usize = 0;
while reversed_index < digit_bytes.len() {
// 不是最右一位、且已满 3 的倍数 → 先放一个逗号。
if reversed_index > 0 && reversed_index % 3 == 0 {
buffer.push(',');
}
// 取「从右数第 reversed_index 位」的字符(ASCII 数字,直接按字节取即可)。
let byte_position: usize = digit_bytes.len() - 1 - reversed_index;
buffer.push(digit_bytes[byte_position] as char);
reversed_index += 1;
}
// buffer 是反的,倒回来。
buffer.reverse();
buffer.into_iter().collect()
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : metric_kind.rs
//! # 经营指标标签 —— 开放型标签
//!
//! ## 为什么不是枚举
//!
//! 经营指标是最典型的「会不断新增」维度:退货率、坪效、会员复购率、
//! 连带率……每一项都是独立业务需求,随时可能加。
//! 枚举每加一个都要改本文件,违反「工程外可扩展」。
//! 因此用 `const fn new` 结构体——扩展者在 `main.rs` 里就能定义新指标。
//!
//! ## 敏感级是**指标的属性**,不是角色的属性
//!
//! 「毛利率」无论谁来看都是机密级。把敏感级挂在角色上会立刻出错:
//! 同一个角色看不同指标时敏感级不同,挂一处表达不了。
//! 因此本结构体自带 `sensitivity` 字段,保护代理的判据是
//! 「角色的权限上限 `allows` 指标的敏感级」。
//!
//! ## 值的存储口径
//!
//! 所有指标的原始值统一用 `i64` 存,单位由 [`MetricUnit`] 决定:
//! - 金额类 → 整数「分」;
//! - 计数类 → 整数「人」;
//! - 比率类 → 整数「万分点」。
//!
//! 这样做的好处是**行数据只有一个 `Vec<i64>`**,不需要为每种指标各造一个
//! 变体类型(那会让报表、缓存、审计三处都要写一遍 `match`)。
//! 单位信息集中在指标定义里,格式化时按 `unit()` 分派一次即可。
//!
//! ## ⚠️ 指标之间存在可推导关系(本工程的一条安全红线)
//!
//! 营业额 = 客流 × 客单价;毛利额 = 营业额 × 毛利率。
//! 这意味着**只做列级脱敏是不够的**:
//! 一个能看「毛利额」和「营业额」的角色,即使被禁止看「毛利率」,
//! 也能自己除一下算出毛利率——机密级数据就这样泄露了。
//!
//! 因此本模块除了定义指标,还定义了 [`DERIVATION_RULES`]:
//! 「哪些指标可以被哪些指标推导出来」。保护代理据此检查
//! 「可见集合的推导闭包是否包含不可见的敏感指标」。
//! 这条检查是本工程最有价值的一条设计,详见第五幕。
//!
//! 这也是 Proxy 与 Decorator 的一个**实质差异**:
//! 装饰器增加功能,不理解数据语义;代理要控制访问,
//! 就**必须理解被保护对象的语义**——否则「保护」只是形式上的。
use super::sensitivity_level::SensitivityLevel;
/// 指标值的单位。
///
/// 用封闭枚举:单位决定格式化分支(金额要带符号与千分位、计数要带「人」、
/// 比率要带百分号),而单位的种类是**有限且稳定**的——
/// 不会有人新增「第 4 种数值单位」。按分层图的判定标准,这种
/// 「取值决定代码分支」的维度就该用枚举。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MetricUnit {
/// 金额(整数「分」)。
CurrencyMinorUnits,
/// 计数(整数「人」或「笔」)。
PersonCount,
/// 比率(整数「万分点」)。
BasisPoints,
}
impl MetricUnit {
/// 中文标签,用于报表列头。
pub const fn label(&self) -> &'static str {
match self {
MetricUnit::CurrencyMinorUnits => "金额",
MetricUnit::PersonCount => "人数",
MetricUnit::BasisPoints => "比率",
}
}
}
/// 一个经营指标(开放型)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MetricKind {
/// 指标编码(如 `"REVENUE"`)。参与相等比较,作为指标身份。
code: &'static str,
/// 中文名称(如 `"营业额"`),仅用于展示。
display_name: &'static str,
/// 数据敏感级。
sensitivity: SensitivityLevel,
/// 值的单位。
unit: MetricUnit,
}
impl MetricKind {
/// 构造一个指标。
///
/// 参数 `code` / `display_name` / `sensitivity` / `unit`。
/// 返回:指标标签。
pub const fn new(
code: &'static str,
display_name: &'static str,
sensitivity: SensitivityLevel,
unit: MetricUnit,
) -> MetricKind {
MetricKind {
code,
display_name,
sensitivity,
unit,
}
}
/// 指标编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn display_name(&self) -> &'static str {
self.display_name
}
/// 数据敏感级。
pub const fn sensitivity(&self) -> SensitivityLevel {
self.sensitivity
}
/// 值的单位。
pub const fn unit(&self) -> MetricUnit {
self.unit
}
/// 是否是指标登记表里的内置指标。
///
/// 本函数存在的意义与同系列工程里的 `is_shift_registered` 相同:
/// 它让「工程外新增的指标」与「内置指标」在**代码上可区分**。
/// 报表据此打标,读者才知道某个指标是不是演示数据自带的。
pub fn is_builtin(&self) -> bool {
BUILTIN_METRICS
.iter()
.any(|candidate| candidate.code == self.code)
}
/// 返回 `营业额(REVENUE)` 形式的展示文本。
///
/// 用半角括号而非全角「()」:本文本会出现在表格单元里,
/// 半角括号宽度恒为 1 列,便于列宽预估。
pub fn formatted(&self) -> String {
format!("{}({})", self.display_name, self.code)
}
}
/// 客流(公共指标,可对外披露)。
///
/// 本工程把它设为 `Public` 是有意的:它给了「店员」这个层级
/// 一个**能看但看不到别的**的对照物——若所有指标都是机密级,
/// 「脱敏是否真的发生」就无法从输出上区分。
pub const METRIC_FOOTFALL: MetricKind = MetricKind::new(
"FOOTFALL",
"客流",
SensitivityLevel::Public,
MetricUnit::PersonCount,
);
/// 客单价。
pub const METRIC_BASKET: MetricKind = MetricKind::new(
"BASKET",
"客单价",
SensitivityLevel::Internal,
MetricUnit::CurrencyMinorUnits,
);
/// 营业额(= 客流 × 客单价,派生指标)。
pub const METRIC_REVENUE: MetricKind = MetricKind::new(
"REVENUE",
"营业额",
SensitivityLevel::Internal,
MetricUnit::CurrencyMinorUnits,
);
/// 毛利率(机密级——泄露即等于泄露成本结构)。
pub const METRIC_GROSS_MARGIN: MetricKind = MetricKind::new(
"MARGIN",
"毛利率",
SensitivityLevel::Confidential,
MetricUnit::BasisPoints,
);
/// 毛利额(= 营业额 × 毛利率,派生指标)。
///
/// ## ⚠️ 它的敏感级刻意设为「内部」,而毛利率是「机密」——这是本工程的教学现场
///
/// 直觉上「毛利额」比「毛利率」更敏感(是一个更大的绝对金额)。
/// 但把它们设成**同级**会掩盖掉本工程最重要的那个安全缺陷:
/// 一个角色若能同时看到毛利额与营业额,就能算出毛利率
/// (`毛利率 = 毛利额 ÷ 营业额`),于是「机密级」的保密措施形同虚设。
///
/// 企业里真实存在的配置正是这个样子:业务上需要「盈利贡献金额」这个数,
/// 于是把毛利额放进区域经理的白名单,同时把**比率**(成本结构)
/// 定为机密。配置者以为挡住比率就够了。
///
/// 因此这里把敏感级设置成有落差:
/// - 毛利额 → [`SensitivityLevel::Internal`](可以被放进中层角色白名单)
/// - 毛利率 → [`SensitivityLevel::Confidential`](只有总部审计能看)
///
/// 落差使「列级脱敏通过、但推导关系仍然泄露」成为现实,
/// 保护代理的推理泄露检查因此**不是装饰性代码**——
/// 它必须真的剥掉毛利额,并在脱敏记录里显式说明。
pub const METRIC_GROSS_PROFIT: MetricKind = MetricKind::new(
"PROFIT",
"毛利额",
SensitivityLevel::Internal,
MetricUnit::CurrencyMinorUnits,
);
/// 全部内置指标,顺序即报表的默认列序。
///
/// 顺序刻意按「基础 → 派生」排列:客流与客单价在前(可直接观测),
/// 营业额与毛利额在后(由前者推出)。报表按此列序打印时,
/// 读者从左往右读就是「数据是怎么长出来的」。
pub const BUILTIN_METRICS: [MetricKind; 5] = [
METRIC_FOOTFALL,
METRIC_BASKET,
METRIC_REVENUE,
METRIC_GROSS_MARGIN,
METRIC_GROSS_PROFIT,
];
/// 一条「可推导关系」:目标指标可由源指标集合算出。
///
/// ## 为什么这个结构必须是领域知识而不是代理的实现细节
///
/// 「毛利额 ÷ 营业额 = 毛利率」是**业务事实**,不是访问控制的实现细节。
/// 把它写进代理里,等于让代理层承担业务语义;换个业务就要改代理。
/// 放进领域层后,代理只做「按规则表检查」这件事——
/// 新增一个可推导关系时,代理代码一行不动。
///
/// 这与同系列 Builder 工程「约束声明归实现方、校验器只做比对」是同一手法。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DerivationRule {
/// 可被推导出来的指标编码。
pub target_code: &'static str,
/// 推出目标所需的指标编码集合。
pub source_codes: &'static [&'static str],
}
/// 全部可推导关系。
///
/// 双向都写了:营业额可由〈客流, 客单价〉推出(乘法),
/// 客流也可由〈营业额, 客单价〉推出(除法)。
/// **只写正向是不够的**——推理泄露恰恰来自「反向除一下」。
pub const DERIVATION_RULES: [DerivationRule; 5] = [
// 营业额 = 客流 × 客单价
DerivationRule {
target_code: "REVENUE",
source_codes: &["FOOTFALL", "BASKET"],
},
// 客流 = 营业额 ÷ 客单价
DerivationRule {
target_code: "FOOTFALL",
source_codes: &["REVENUE", "BASKET"],
},
// 客单价 = 营业额 ÷ 客流
DerivationRule {
target_code: "BASKET",
source_codes: &["REVENUE", "FOOTFALL"],
},
// 毛利额 = 营业额 × 毛利率
DerivationRule {
target_code: "PROFIT",
source_codes: &["REVENUE", "MARGIN"],
},
// 毛利率 = 毛利额 ÷ 营业额 ← 这条是机密泄露的主路径
DerivationRule {
target_code: "MARGIN",
source_codes: &["PROFIT", "REVENUE"],
},
];
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : ratio.rs
//! # 比率 —— 整数万分比
//!
//! ## 为什么是「万分比」而不是「百分比」
//!
//! 经营数据里出现的比率精度差异很大:
//! - 毛利率:`18.42%` → 需要 2 位小数(1842 万分点);
//! - 库存周转:`2.35 次` → 用倍数表达更直观(23500 万分点);
//! - 阈值/占比:`85.5%` → 需要 1 位小数(8550 万分点)。
//!
//! 若用百分比(分母 100),`18.42%` 就还要额外约定「小数后保留几位」;
//! 而万一将来要表达 `1.125` 倍这类量,百分比根本无法承载。
//! 万分比的精度足以覆盖所有常见业务比率,且**分母固定为 10000**,
//! 所有比率的乘除都走同一个常量,将来改精度只改一处。
//!
//! ## 「零比率」也要走统一运算路径
//!
//! 「本期无毛利」(毛利率 0)这类场景需要零比率。**不要**在调用点写成
//! `CurrencyAmount::zero(...)` 绕过本类型——那样将来「零」的口径变化
//! (比如要求负毛利也计入)就要翻遍所有调用点。
//! 正确做法是写 `Ratio::zero().apply_to(amount)`,
//! 让「零」也经过统一路径。这条经验来自同系列 Abstract Factory 工程。
//!
//! ## 为什么比率必须能安全地作为缓存键的一部分
//!
//! 本工程要把「查询条件」做成缓存键,而缓存键必须 `Eq + Hash` 可靠。
//! 若比率用 `f64` 存储,`NaN != NaN`、`-0.0 == 0.0` 这类浮点语义
//! 会让「同一个查询」偶尔命中、偶尔不命中——**这是最难排查的一类缓存 bug**。
//! 用整数万分点后,相等语义就是整数相等,没有歧义。
// 同层引用:`apply_to` 需要金额类型。同层模块互引不违反分层约束
// (依赖方向检查只看层与层),但不要形成密集的双向网。
use super::currency_amount::CurrencyAmount;
/// 万分比表示的一个比率。
///
/// `basis_points` 为 10000 时表示 1 倍(100%)。
/// 可为负:负毛利门店(清库存亏本促销)会出现负比率。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Ratio {
/// 万分点。10000 = 1 倍 = 100%。
basis_points: i64,
}
/// 万分比的分母。
///
/// 本工程所有比率运算共用此常量。它被导出的唯一理由是:
/// 「分析层」在把万分点转成展示文本时需要它,
/// 而「硬编码 10000」在分析层出现两遍以上就会成为漂移源。
pub const BASIS_POINTS_DENOMINATOR: i64 = 10_000;
impl Ratio {
/// 由万分点构造。
///
/// 参数 `basis_points`:万分点。
/// 返回:比率。
pub const fn from_basis_points(basis_points: i64) -> Ratio {
Ratio { basis_points }
}
/// 零比率(0%)。
pub const fn zero() -> Ratio {
Ratio { basis_points: 0 }
}
/// 一倍(100%)。
pub const fn one() -> Ratio {
Ratio {
basis_points: BASIS_POINTS_DENOMINATOR,
}
}
/// 万分点值。
pub const fn basis_points(&self) -> i64 {
self.basis_points
}
/// 是否为零。
pub const fn is_zero(&self) -> bool {
self.basis_points == 0
}
/// 是否大于另一比率。
pub const fn is_greater_than(&self, other: &Ratio) -> bool {
self.basis_points > other.basis_points
}
/// 是否大于 1 倍(100%)。
///
/// 经营报表用这个判定标出「高于基准」的项(如某门店毛利率高于集团基准),
/// 与「低于基准」项区分开。
pub const fn is_above_one(&self) -> bool {
self.basis_points > BASIS_POINTS_DENOMINATOR
}
/// 把一个金额按本比率缩放。
///
/// 参数 `amount`:待缩放的金额。
/// 返回:缩放后的金额。
///
/// 这是**唯一**的比率→金额运算路径。
/// 调用方不要自己写 `amount.minor_units() * bp / 10000`——
/// 那样的表达式会绕过舍入口径(`divide_rounded_away_from_zero`),
/// 造成「同一笔钱在不同代码路径下差 1 分」的问题。
pub fn apply_to(&self, amount: &CurrencyAmount) -> CurrencyAmount {
amount.scale_by_basis_points(self.basis_points)
}
/// 返回百分比文本,如 `125.00%`。
///
/// 拆法:整数部分 = `基点数 / 100`,小数部分 = `基点数 % 100`。
/// 也就是「基点数 / 10000」的分数形式换算成百分比后的两位小数。
pub fn as_percent_text(&self) -> String {
// 取绝对值参与分拆,符号单独处理(否则 -12500 % 100 得 -0,展示成 "-125.-0%")。
let absolute_value: i64 = self.basis_points.abs();
let whole_part: i64 = absolute_value / 100;
let fractional_part: i64 = absolute_value % 100;
let sign: &str = if self.basis_points < 0 { "-" } else { "" };
format!("{}{}.{:02}%", sign, whole_part, fractional_part)
}
/// 返回倍数文本,如 `1.2500 倍`。
///
/// 小数部分直接取万分点的后四位,得到 4 位小数。
/// 与 `as_percent_text` 的区别在于:百分比把 10000 当整体(100%),
/// 倍数把 10000 当 1。报表里两个口径都会用到,
/// 「库存周转」用倍数更直观(`2.35 倍`),「毛利率」用百分比更直观(`18.42%`)。
pub fn as_multiplier_text(&self) -> String {
let absolute_value: i64 = self.basis_points.abs();
let whole_part: i64 = absolute_value / BASIS_POINTS_DENOMINATOR;
let fractional_part: i64 = absolute_value % BASIS_POINTS_DENOMINATOR;
let sign: &str = if self.basis_points < 0 { "-" } else { "" };
// 小数部分补零到 4 位(万分位)。
format!("{}{}.{:04} 倍", sign, whole_part, fractional_part)
}
/// 返回原始万分点文本,如 `12500 万分点`。
///
/// 调试与核对时用:看到 `12500` 就能立刻确认是 1.25 倍,
/// 不会被百分比与倍数两套口径绕晕。
pub fn as_basis_points_text(&self) -> String {
format!("{} 万分点", self.basis_points)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : region_tag.rs
//! # 区域标签 —— 开放型标签
//!
//! ## 为什么不是枚举
//!
//! 珠宝集团会开新区域(华中、西北、海外……),区域数量会增长。
//! 枚举每加一个都要改本文件,违反「工程外可扩展」。
//! 用 `const fn new` 结构体后,扩展者在自己代码里就能定义新区域:
//!
//! ```ignore
//! // 工程外(main.rs 的扩展区),不需要改 domain 层
//! pub const REGION_CENTRAL: RegionTag = RegionTag::new("CENTRAL", "华中");
//! ```
//!
//! ## 区域标签在代理模式里扮演什么角色
//!
//! 区域是本工程**远程代理**的判据:总部机房只对本地(华南)免收往返开销,
//! 其余区域的查询必须经过跨区域网关转发。
//! 因此区域标签要能参与**相等比较**(「这次查询涉及的区域是否只有本地」),
//! 这也是它用 `&'static str` 编码而非结构体比较的原因。
//!
//! 注意:区域标签**不记录「哪个区域是本地」**。
//! 「本地」是**部署位置**的属性,不是区域本身的属性——
//! 把 `is_local` 塞进区域标签,等于把「部署在哪里」固化进领域层,
//! 上海机房部署时就要改领域层文件,扩展性当场失败。
//! 这个判断由远程代理在装配时接收参数决定。
/// 区域标签(开放型)。
///
/// 派生 `Ord` 是为了让持有它的 [`StoreCode`](super::store_code::StoreCode)
/// 也能排序——报表要按门店编码稳定输出,而排序键里含区域。
/// 排序语义按 `code` 与 `display_name` 的**字段声明顺序**逐字段比较,
/// 对本工程只是「确定性输出」的手段,不对应任何业务上的区域大小关系。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RegionTag {
/// 区域编码(如 `"SOUTH"`)。参与相等比较,作为区域身份。
code: &'static str,
/// 中文名称(如 `"华南"`),仅用于展示。
display_name: &'static str,
}
impl RegionTag {
/// 构造一个区域标签。
///
/// 参数 `code` / `display_name`。
/// 返回:区域标签。
///
/// `const fn` 使本函数可用于定义 `const` 常量——
/// 这是「工程外零侵入扩展」能成立的技术前提。
pub const fn new(code: &'static str, display_name: &'static str) -> RegionTag {
RegionTag {
code,
display_name,
}
}
/// 区域编码。
pub const fn code(&self) -> &'static str {
self.code
}
/// 中文名称。
pub const fn display_name(&self) -> &'static str {
self.display_name
}
}
/// 华南区(总部与主数据中心所在地)。
pub const REGION_SOUTH: RegionTag = RegionTag::new("SOUTH", "华南");
/// 华东区。
pub const REGION_EAST: RegionTag = RegionTag::new("EAST", "华东");
/// 华北区。
pub const REGION_NORTH: RegionTag = RegionTag::new("NORTH", "华北");
/// 西南区。
pub const REGION_WEST: RegionTag = RegionTag::new("WEST", "西南");
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : sensitivity_level.rs
//! # 数据敏感级 —— 封闭枚举
//!
//! ## 为什么这个维度用枚举而不是开放型结构体
//!
//! 本工程的分层纪律是「凡会新增取值的维度一律用 `const fn new` 结构体」。
//! 但本维度是**例外**,理由是分层图里写明的判定标准:
//!
//! | 判定 | 落点 |
//! |---|---|
//! | 该维度会被**新增取值**吗? | 用开放型结构体 |
//! | 该维度的取值**决定代码分支**吗? | 用封闭枚举 |
//!
//! 敏感级**同时**满足两侧,因此要挑一个更重要的收益。
//! 挑「决定代码分支」——因为本工程的核心安全断言是
//! 「交付给调用方的每个字段都必须 `敏感级 <= 角色上限`」。
//! 这个比较若能退化成字符串比较,就失去了编译期穷尽性检查:
//! 一旦有人新增一个字符串敏感级并写错比较方向,越权不会被编译器拦住。
//! 用枚举后,`allows` 里的 `match` 强迫实现者为新档位显式表态。
//!
//! ## 档位顺序即权限顺序
//!
//! `Public < Internal < Confidential`。这个顺序被 `rank()` 编码,
//! 而 `rank()` 是 `allows` 的唯一依据。**顺序错了就是越权**,
//! 因此 `rank` 的取值写死在 `match` 里、不靠枚举声明顺序推导
//! (Rust 的枚举判别值虽可显式指定,但显式 `match` 更易审计)。
/// 数据敏感级。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SensitivityLevel {
/// 公开级:可以对外披露(如客流总量)。
Public,
/// 内部级:限集团内部(如营业额、客单价)。
Internal,
/// 机密级:限高层与审计(如毛利率、毛利额——泄露即等于泄露成本结构)。
Confidential,
}
impl SensitivityLevel {
/// 权限等级(数值越大权限越高)。
///
/// 用显式 `match` 而非 `as u8` 推导:枚举的声明顺序是**编写顺序**,
/// 而权限顺序是**业务约定**。两者恰好一致只是巧合,
/// 一旦有人为了「按字母排序好看」调整了声明顺序,
/// 用 `as u8` 的实现会静默地把公开级变成最高级。
pub const fn rank(&self) -> u8 {
match self {
SensitivityLevel::Public => 0,
SensitivityLevel::Internal => 1,
SensitivityLevel::Confidential => 2,
}
}
/// 中文标签。
pub const fn label(&self) -> &'static str {
match self {
SensitivityLevel::Public => "公开",
SensitivityLevel::Internal => "内部",
SensitivityLevel::Confidential => "机密",
}
}
/// 报表用的单字符标记(便于扫读)。
///
/// 用几何图形而非颜色词:纯文本环境下颜色不可见,
/// 而「○ ◐ ●」的「空心 → 半实 → 全实」本身就是渐进的直觉。
pub const fn marker(&self) -> &'static str {
match self {
SensitivityLevel::Public => "○",
SensitivityLevel::Internal => "◐",
SensitivityLevel::Confidential => "●",
}
}
/// 判断本等级是否有权访问指定等级的数据。
///
/// 参数 `required`:数据本身要求的等级。
/// 返回:本等级不低于 `required` 时为 `true`。
///
/// 语义方向容易被写反:**权限等级高的一方才能看敏感级高的数据**。
/// 因此实现是 `self.rank() >= required.rank()` 而不是 `<=`。
/// 这一行写反就是**完全反转的越权**(机密角色反而看不到公开数据,
/// 而基层角色能看到机密数据),且功能测试里
/// 「管理员能看全部」这条用例**仍然会通过**——最难发现的那种错误。
/// 因此本函数必须有专门的对照演示(本工程第五幕)。
pub const fn allows(&self, required: SensitivityLevel) -> bool {
self.rank() >= required.rank()
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : store_code.rs
//! # 门店编码 —— 主体标识
//!
//! ## 为什么门店用 `String` 而区域用 `&'static str`
//!
//! 同系列工程里,凡是「工程外可扩展」的维度都用 `const fn new` 的
//! `&'static str` 标签。**门店是例外**,理由有两条:
//!
//! 1. **门店数量是运行时才知道的**:120 家门店来自数据源,
//! 不可能(也不应该)在领域层写 120 个 `const`。若硬写成常量,
//! 新开一家店就要改领域层文件——那恰恰破坏了扩展性。
//! 2. **门店编码是数据,不是类型**:区域(华南/华东)是**分类维度**,
//! 它决定代码分支(远程代理按区域判断是否转发);
//! 而具体门店只是**数据行标识**,不决定任何分支。
//!
//! 判据可以总结成一句话:
//! **「决定分支的维度」用标签常量;「构成数据集的主体」用字符串**。
//!
//! ## 为什么带 `region` 而不是只存区域编码字符串
//!
//! 远程代理需要判断「这次查询是否跨出本地区域」。若只存区域编码字符串,
//! 代理就得自己维护一张「编码 → 区域标签」的表——那张表放哪都是错的:
//! 放代理里则代理承担了领域知识;放领域层则又变成了一张需要同步维护的映射。
//! 让门店**直接持有区域标签**,这个问题根本不存在。
use super::region_tag::RegionTag;
/// 一家门店的标识。
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct StoreCode {
/// 门店编码(如 `"LF0001"`)。参与相等比较,作为门店身份。
code: String,
/// 中文名称(如 `"华南01店"`),仅用于展示。
display_name: String,
/// 所属区域。
region: RegionTag,
}
impl StoreCode {
/// 构造一个门店标识。
///
/// 参数 `code` / `display_name` / `region`。
/// 返回:门店标识。
pub fn new(code: String, display_name: String, region: RegionTag) -> StoreCode {
StoreCode {
code,
display_name,
region,
}
}
/// 门店编码。
pub fn code(&self) -> &str {
&self.code
}
/// 中文名称。
pub fn display_name(&self) -> &str {
&self.display_name
}
/// 所属区域。
pub const fn region(&self) -> RegionTag {
self.region
}
/// 所属区域的中文名称。
pub const fn region_name(&self) -> &'static str {
self.region.display_name()
}
/// 返回 `华南01店(LF0001)` 形式的展示文本。
pub fn formatted(&self) -> String {
format!("{}({})", self.display_name, self.code)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : workload_units.rs
//! # 工作负载单元 —— 把「做多少事」变成一个可累加、可核对的整数
//!
//! ## 为什么不能直接用 `u64`
//!
//! 本工程的核心论证是「每一种代理各挡掉了多少代价」。若各处直接用裸 `u64`:
//! 1. **单位会混**:真实主题的扫描单元、远程网关的往返单元、
//! 缓存命中的零单元——三种量纲相同的整数混在一起相加,
//! 报表读起来就像把「米」和「秒」相加;
//! 2. **加减方向会错**:有人写 `剩余 = 总量 - 已用`,
//! 有人写 `总计 = 本地 + 远程`,同一个名字两种语义;
//! 3. **无法声明口径**:报表要写清「1 单元 = 扫描 1 个 门店×日×指标 交叉点」,
//! 而裸整数没有地方挂这条说明。
//!
//! 因此用一个**新类型**包起来。它不是「为了好看」——
//! 而是让「把两种不同来源的代价相加」在类型层面仍然合法、
//! 但在**代码审查时一眼可见是同一量纲**。
//!
//! ## 为什么不用 `i64`
//!
//! 工作负载是**不会为负**的:它度量「做了多少事」。
//! 用无符号类型可让「减法写反导致负数」这类错误无法表达。
//! 需要「节省了多少」时,用带符号的派生量(在分析层算),
//! 而不是让本类型变负。
/// 工作负载的计量单位:**一个「单元格」**。
///
/// 口径定义(必须写清,否则数字没有可比性):
/// 真实主题扫描一个「门店 × 自然日 × 指标」的交叉点算 1 单元;
/// 跨区域网关的一次转发按固定常量折算(见 `proxy::cross_region_proxy`)。
///
/// 这是本工程全部代价数字的**唯一共同尺度**。两个不同来源的代价能相加,
/// 前提就是它们都被折算成这个尺度——报表里必须把这句口径说明打印出来。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WorkloadUnits {
/// 单元数。
units: u64,
}
impl WorkloadUnits {
/// 零负载。
pub const fn zero() -> WorkloadUnits {
WorkloadUnits { units: 0 }
}
/// 由单元数构造。
pub const fn from_units(units: u64) -> WorkloadUnits {
WorkloadUnits { units }
}
/// 单元数。
pub const fn units(&self) -> u64 {
self.units
}
/// 是否为零负载。
pub const fn is_zero(&self) -> bool {
self.units == 0
}
/// 相加。
///
/// 参数 `other`:另一份负载。
/// 返回:两份负载之和。
///
/// 用 `saturating_add` 而非 `+`:本工程的单元数远达不到 `u64` 上限,
/// 但「溢出即回绕成小数字」会让报表出现**看起来很正常的小值**,
/// 而饱和至少会把异常暴露成一个大得离谱的数。
/// 这与「错误要可见」是同一条原则。
pub const fn plus(&self, other: &WorkloadUnits) -> WorkloadUnits {
WorkloadUnits {
units: self.units.saturating_add(other.units),
}
}
/// 乘以一个整数倍数(用于「这个动作重复了 N 次」)。
pub const fn times(&self, factor: u64) -> WorkloadUnits {
WorkloadUnits {
units: self.units.saturating_mul(factor),
}
}
/// 返回带千分位的展示文本。
///
/// 本类型自带排版(而非交给表示层)是**刻意的**:
/// 「13200 单元」与「13,200 单元」在核对时的心智负担完全不同,
/// 而工作负载数字会在诊断文本、报表、体检结论三处出现。
/// 与其在三处各写一遍千分位,不如让值对象自己给出规范形式。
/// 这与「排版统一归表示层」的原则**不冲突**:那条原则针对的是
/// 列宽对齐与表格拼接,而这里只是把一个数写成它唯一正确的写法。
///
/// 注意这里**不依赖** `support::text_layout`——
/// 领域层是零依赖的,千分位是纯整数运算,本模块自己实现。
pub fn formatted(&self) -> String {
with_thousands_separator(self.units as i64)
}
}
/// 给整数的十进制文本插入千分位分隔符。
///
/// 参数 `value`:非负整数。
/// 返回:形如 `1,234,567` 的文本。
///
/// 实现思路(**从右往左**每三位一插):
/// 先把数字转字符串,再从个位方向每 3 位打一个逗号。
/// 用「临时字符数组 + 反向读取」而不是「按位置切片拼接」,
/// 后者在字符串长度不是 3 的倍数时要处理前导不足,分支更多。
fn with_thousands_separator(value: i64) -> String {
// 负数先分离符号:若让负号参与分位,`-1234` 会变成 `-,1,234` 这种怪样子。
let sign: &str = if value < 0 { "-" } else { "" };
let digits: String = value.saturating_abs().to_string();
let digit_bytes: &[u8] = digits.as_bytes();
let mut buffer: Vec<char> = Vec::with_capacity(digit_bytes.len() + digit_bytes.len() / 3);
// 反向遍历:`reversed_index` 是「从右数第几位」(0 基)。
let mut reversed_index: usize = 0;
while reversed_index < digit_bytes.len() {
// 不是最右一位、且已满 3 的倍数 → 先放一个逗号。
if reversed_index > 0 && reversed_index % 3 == 0 {
buffer.push(',');
}
// 取「从右数第 reversed_index 位」的字符(ASCII 数字,直接按字节取即可)。
let byte_position: usize = digit_bytes.len() - 1 - reversed_index;
buffer.push(digit_bytes[byte_position] as char);
reversed_index += 1;
}
// buffer 是反的,倒回来。
buffer.reverse();
format!("{}{}", sign, buffer.into_iter().collect::<String>())
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : access_audit_proxy.rs
//! # 审计代理 —— 记录每一次访问
//!
//! ## 它为什么必须在最外层
//!
//! 这是本工程「顺序敏感」的第二个实证点。
//!
//! 审计代理只做一件事:把每次调用写进日志。看起来放在哪一层都一样,
//! 但只要链上有**任何一层会在不向下转发的情况下直接返回**,
//! 位置就变得致命。本工程恰好有两处这样的层:
//!
//! | 层 | 什么情况下不向下转发 |
//! |---|---|
//! | 缓存代理 | 命中时 |
//! | 访问控制代理 | 拒绝时 |
//!
//! 若审计代理被放在缓存代理的**内侧**,那么每一次缓存命中都不会被记录。
//! 报表上的审计条数会**小于**实际调用条数——而这恰恰是最需要审计的场景:
//! 「谁反复查了同一批数据」这个问题,答案全在缓存命中里。
//!
//! 同理,若放在访问控制代理的内侧,每一次**越权尝试**都不会被记录。
//! 越权尝试是安全审计里最重要的事件,漏记它等于审计形同虚设。
//!
//! 因此装配方必须把本层放在最外面。体检规则
//! `AUDIT_MUST_BE_OUTERMOST` 会核对日志条数与调用条数是否一致。
//!
//! ## 为什么日志的载体是 `Rc<RefCell<..>>`
//!
//! 这是链上**必须共享出去**的一类状态的代表。判据只有一条:
//! **装配完成后,除了本层之外还有谁会读它?**
//!
//! 链装配完成后,外部拿到的是最外层的 `Box<dyn OperatingDataSubject>`,
//! 也就是说中间每一层都被 `Box` 封住了、**够不到**。
//! 因此凡是体检/报表要看的数字,都必须在装配期以共享句柄注入,
//! 装配方保留一份克隆。
//!
//! 本层要共享的是日志;同一条理由也适用于虚拟代理的装载计数、
//! 缓存代理的命中计数与条目摘要、保护代理的推理泄露事件。
//! 反过来,只在层内使用的状态**不应该**共享——
//! 例如缓存体本身(`HashMap<CacheKey, Report>`,键类型是私有的)
//! 就留在层内,外部要的只是「几条、每条多大、是否脱敏过」这三项摘要。
//! **共享最小必要状态**,而不是把整个内部结构端出去。
//!
//! 载体形态也各有取舍:
//! - 纯计数(转发次数、装载次数)用 `Cell<Copy 结构体>`——不会撞借用;
//! - 需要交出内部借用的(缓存体、事件列表、日志)用 `RefCell`;
//! - 再加上「要共享」这一条,就是 `Rc<RefCell<..>>`。
//!
//! ## 记录的是「调用」还是「响应」?
//!
//! 本层记录的是**完整的调用结果**(含响应来源、交付行列数、拒绝原因、
//! 消耗负载)。它在内层返回之后才写日志,因此拿到的是完整信息。
//! 代价是:若内层 panic,「这次调用发生过」这件事会丢。
//! 真实系统里这需要用「先写意图、后写结果」的两阶段日志解决;
//! 本工程的演示数据不会 panic,因此不引入这个复杂度,
//! 但把这条局限写在这里,免得被误认为「已经处理了」。
use std::cell::RefCell;
use std::rc::Rc;
use super::access_journal::{AccessJournal, AccessJournalDraft};
use super::query_request::OperatingQuery;
use super::subject::{OperatingDataSubject, QueryOutcome, SubjectDescriptor};
/// 审计代理。
pub struct AccessAuditProxy {
/// 内层主体。
inner: Box<dyn OperatingDataSubject>,
/// 与外部分享的审计日志。
journal: Rc<RefCell<AccessJournal>>,
}
impl AccessAuditProxy {
/// 构造审计代理。
///
/// 参数 `inner`:内层主体;`journal`:与外部分享的日志句柄。
/// 返回:代理。
pub fn new(inner: Box<dyn OperatingDataSubject>, journal: Rc<RefCell<AccessJournal>>) -> AccessAuditProxy {
AccessAuditProxy { inner, journal }
}
}
impl OperatingDataSubject for AccessAuditProxy {
fn query(&self, request: &OperatingQuery) -> QueryOutcome {
// 先取到内层的完整响应——日志要记录的是**结果**,不只是意图。
let outcome: QueryOutcome = self.inner.query(request);
let draft: AccessJournalDraft = AccessJournalDraft {
role_code: request.role().code(),
role_name: request.role().display_name(),
requested_store_count: request.requested_store_codes().len(),
requested_metric_count: request.requested_metric_codes().len(),
start_date: request.start_date(),
end_date: request.end_date(),
served_by: outcome.served_by,
served_by_layer_code: outcome.served_by_layer.layer_code,
delivered_row_count: outcome.delivered_row_count(),
delivered_metric_count: outcome.delivered_metric_count(),
denial_codes: outcome
.denials
.iter()
.map(|denial| denial.rule_code)
.collect(),
workload: outcome.workload,
};
// 借用在语句结束即释放——不要把 `borrow_mut()` 的结果绑定到变量上
// 再跨语句使用,那是最容易撞出 `already borrowed` 的写法。
self.journal.borrow_mut().record(draft);
outcome
}
fn descriptor(&self) -> SubjectDescriptor {
SubjectDescriptor::new(
"AUDIT",
"访问审计代理",
self.inner.descriptor().wrapping_depth + 1,
)
}
fn role_description(&self) -> &'static str {
"记录每一次调用的结果;必须位于最外层,否则缓存命中与越权尝试都不会被记录"
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : access_control_proxy.rs
//! # 保护代理 —— 鉴权与脱敏
//!
//! ## 它为什么必须「理解数据语义」
//!
//! 一个通用的限流/鉴权中间件只看得到「谁在调、调了多少次」。
//! 本层必须理解三件业务语义才能做出正确判断:
//!
//! 1. **门店属于哪个区域** —— 否则「按区域可见」无从判断;
//! 2. **指标之间存在推导关系** —— 否则「禁看毛利率」是形同虚设的;
//! 3. **汇总的范围有多大** —— 否则「只给汇总」会被缩范围反推成明细。
//!
//! 第 2 条是本工程最有价值的发现,见下文「推理泄露」。
//! 这也是 Proxy 与 Decorator 的又一处实质差异:
//! **装饰器只关心「加什么行为」,代理必须关心「数据的含义」**。
//!
//! ## 一次报全,不提前返回
//!
//! 所有校验统一为 `(&请求, &范围, &mut Vec<AccessDenial>) -> ()`,
//! 每个 `check_*` 只负责往列表里追加、从不提前 `return`。
//! 理由与同系列 Builder 工程一致:一个请求可能同时踩多条红线
//! (既超门店范围、又超指标敏感级、还超天数),
//! 只报第一条会让排查者「改一次、跑一次」。
//!
//! ## 三级脱敏
//!
//! | 级别 | 做什么 | 触发条件 |
//! |---|---|---|
//! | 行级 | 剔除授权范围外的门店 | 请求含范围外门店 |
//! | 列级 | 剔除白名单外 / 敏感级超限的指标 | 请求含不可见指标 |
//! | 聚合级 | 坍缩为单行汇总 | 角色是「仅汇总」模式 |
//!
//! 另有**第四级**——「推理泄露补救」,它不是按规则裁剪,
//! 而是发现配置缺陷后的补救,因此单独记账。
//!
//! ## ⚠️ 推理泄露:列级脱敏为什么不够
//!
//! 假设一个角色的白名单里有「毛利额」和「营业额」,但没有「毛利率」,
//! 敏感级上限也挡住了「毛利率」。配置者以为毛利率被保护住了。
//!
//! **但毛利额 ÷ 营业额 = 毛利率。** 机密级数据等价于公开。
//!
//! 本层的对策:在算出最终可见集合后,检查**推导闭包**——
//! 若某个不可见指标能由可见指标推出,就移除造成这一推导的**来源指标**。
//! 选择「移除来源」而不是「拒绝整个请求」的理由:
//! 拒绝会让该角色连营业额都拿不到,业务不可用;
//! 移除来源保住了大部分业务价值,同时通过脱敏记录**显式暴露**配置缺陷。
//! 唯一不可接受的是静默降级。
//!
//! ### 移除哪一个来源?
//!
//! 多条来源时要有确定性规则。本工程用「**优先移除更派生的指标**」:
//! 派生指标可以由基础指标重建(在权限允许时),而基础指标不能。
//! 具体判据取该指标在 [`BUILTIN_METRICS`] 里的下标——该数组刻意按
//! 「基础 → 派生」排列,下标越大越派生。规则确定性、可复现、可解释。
//!
//! ## 观测状态为什么用 `Rc<RefCell<..>>` 共享出去
//!
//! 因为它要记录的是**推理泄露事件**——这是全工程唯一一处
//! 「不产生拒绝的安全隐患」。请求照常成功、报表照常交付,
//! 只是某一列被拿掉了;这条信息在正常业务输出里**完全没有痕迹**。
//! 它必须能被子系统之外的体检读到,而装配完成后的链是嵌套 `Box`,
//! 外部够不到内层。因此句柄在装配期注入,装配方保留克隆。
//!
//! 用 `RefCell` 而不是 `Cell`:事件是 `Vec`,不是 `Copy` 标量,
//! 必须交出内部 `Vec` 的可变借用来 `push`。
use std::cell::RefCell;
use std::rc::Rc;
use crate::domain::{
MetricKind, StoreCode, WorkloadUnits, BUILTIN_METRICS, DERIVATION_RULES,
};
use super::access_journal::{
AccessDenial, RULE_AGGREGATE_SAMPLE_TOO_SMALL, RULE_EMPTY_METRIC_SELECTION,
RULE_EMPTY_STORE_SELECTION, RULE_INFERENCE_LEAK, RULE_INVALID_DATE_RANGE,
RULE_METRIC_OUT_OF_SCOPE, RULE_METRIC_SENSITIVITY_EXCEEDED, RULE_QUERY_SPAN_EXCEEDED,
RULE_RESULT_ROW_LIMIT_EXCEEDED, RULE_STORE_OUT_OF_SCOPE, RULE_UNKNOWN_METRIC_CODE,
RULE_UNKNOWN_STORE_CODE,
};
use super::operating_report::{
OperatingReport, OperatingRow, RedactionRecord, AGGREGATE_ROW_STORE_CODE,
AGGREGATE_ROW_STORE_NAME,
};
use super::query_request::OperatingQuery;
use super::store_catalog::StoreCatalog;
use super::subject::{OperatingDataSubject, QueryOutcome, SubjectDescriptor};
/// 一次推理泄露事件。
///
/// 记录在保护代理的观测状态里供体检读取。之所以要单独建类型而不是只留下一条拒绝原因:
/// 推理泄露**不会导致调用失败**(我们剥离来源指标、正常交付),
/// 因此它不会出现在任何「拒绝统计」里。若不做成独立事件,
/// 这个安全隐患在报表上完全不可见——而它恰恰是最该被修的那一类问题。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InferenceLeakEvent {
/// 发起查询的角色编码。
pub role_code: &'static str,
/// 被保护住(未泄露)的敏感指标编码。
pub protected_metric_code: &'static str,
/// 为阻断泄露而被移除的来源指标编码。
pub removed_source_codes: Vec<&'static str>,
}
impl InferenceLeakEvent {
/// 返回可读说明。
pub fn formatted(&self) -> String {
format!(
"角色 {} 的可见集合可推出敏感指标 {},已移除来源指标 [{}]",
self.role_code,
self.protected_metric_code,
self.removed_source_codes.join("、")
)
}
/// 供报表排序的稳定键(角色 → 受保护指标 → 首个被移除来源)。
///
/// 事件是随查询顺序累积的,若不排序,两次运行的事件列表顺序
/// 会跟着「哪一批请求先跑」变化。本工程的验收标准之一是
/// 两次运行逐字节一致,因此所有列表型观测数据都必须有排序键。
pub fn sort_key(&self) -> (&'static str, &'static str, String) {
(
self.role_code,
self.protected_metric_code,
self.removed_source_codes.join("+"),
)
}
}
/// 保护代理与外部分享的可观测状态。
///
/// ## 为什么泄露事件必须共享出去
///
/// 它是本工程**唯一一处「不产生拒绝的安全隐患」**:
/// 请求照常成功、报表照常交付,只是某一列被悄悄拿掉了。
/// 换句话说,这条信息**在正常业务输出里完全没有痕迹**。
///
/// 若把它留在代理内部,装配完成后外部(体检、报表)就再也读不到它——
/// 而「本层被包在若干层 `Box` 里」正是装配完成后的状态。
/// 因此它必须在装配期以 `Rc<RefCell<..>>` 注入,
/// 装配方保留一份克隆。
///
/// 这里额外记录 `evaluated_query_count`:不是每条被检查的查询都会
/// 产生一个事件,因此「事件数 0」有两种可能——「没有被检查过」
/// 与「检查了但都干净」。这两个数字放在一起才能区分它们。
#[derive(Debug, Clone, Default)]
pub struct AccessControlObservation {
/// 累积的推理泄露事件(按发生顺序)。
pub inference_leak_events: Vec<InferenceLeakEvent>,
/// 实际执行过推理泄露检查的查询次数。
pub evaluated_query_count: usize,
}
/// 保护代理。
pub struct AccessControlProxy {
/// 内层主体。
inner: Box<dyn OperatingDataSubject>,
/// 门店主数据(用于解析与校验,**不触发昂贵的装载**)。
catalog: Rc<StoreCatalog>,
/// 与外部分享的观测状态。
observation: Rc<RefCell<AccessControlObservation>>,
}
impl AccessControlProxy {
/// 构造保护代理。
///
/// 参数 `inner`:内层主体;`catalog`:门店主数据;
/// `observation`:与外部分享的观测状态句柄。
/// 返回:代理。
pub fn new(
inner: Box<dyn OperatingDataSubject>,
catalog: Rc<StoreCatalog>,
observation: Rc<RefCell<AccessControlObservation>>,
) -> AccessControlProxy {
AccessControlProxy {
inner,
catalog,
observation,
}
}
// ★ 已删除 `observation()` 访问器:调用方拿不到 `&AccessControlProxy`
// (链是嵌套 `Box`),因此这个窗口永远打不开。
// 需要观测数据的地方在装配期就把 `Rc<RefCell<AccessControlObservation>>`
// 交了出去,走那条路即可。
/// 计算「请求 + 角色范围」下的**推理泄露补救**。
///
/// 参数 `visible_metrics`:已通过白名单与敏感级的指标;
/// `out_of_reach_metrics`:角色的可见等级之外的指标(即真正要保护的)。
/// 返回:需要额外移除的指标编码(去重、按内置顺序)。
///
/// ## 算法
///
/// 对每个「够不着的」指标 `T`:
/// 1. 找出所有以 `T` 为目标、且来源**全部落在当前可见集合**里的推导规则;
/// 2. 若存在这样的规则,说明 `T` 可被推出 → 从来源里挑一个移除;
/// 3. 挑法:优先移除更派生的(`BUILTIN_METRICS` 下标更大者)。
///
/// 移除后**重新计算**可见集合,再继续下一个 `T`——
/// 因为一次移除可能让另一条推导链也断开(这是好消息),
/// 也可能让新的推导链暴露出来(那不是问题,因为新暴露的必然
/// 也在已检查过的集合里)。这里选择「每轮重算、直到稳定」,
/// 虽然计算量大一点,但正确性容易论证。
fn compute_inference_removals(
&self,
role_code: &'static str,
visible_metric_codes: &mut Vec<&'static str>,
out_of_reach_metrics: &[MetricKind],
) -> Vec<InferenceLeakEvent> {
let mut events: Vec<InferenceLeakEvent> = Vec::new();
// 逐个「够不着的」指标检查,直到一轮不再有移除发生。
let mut changed: bool = true;
while changed {
changed = false;
for target in out_of_reach_metrics {
// 若目标其实可见(可能因白名单变更),跳过。
if visible_metric_codes.contains(&target.code()) {
continue;
}
// 找出「来源全部可见」的推导规则。
for rule in DERIVATION_RULES.iter() {
if rule.target_code != target.code() {
continue;
}
let all_sources_visible: bool = rule
.source_codes
.iter()
.all(|code| visible_metric_codes.contains(code));
if !all_sources_visible {
continue;
}
// 挑选要移除的来源:优先移除更派生的(内置下标更大者)。
let victim: Option<&'static str> = pick_most_derived_source(rule.source_codes);
let Some(victim) = victim else { continue };
// 移除该来源。
visible_metric_codes.retain(|code| *code != victim);
events.push(InferenceLeakEvent {
role_code,
protected_metric_code: target.code(),
removed_source_codes: vec![victim],
});
changed = true;
}
}
}
events
}
/// 校验并构造收窄后的请求。
///
/// 返回:`Ok(收窄后的请求)` 或 `Err(拒绝原因列表)`。
#[allow(clippy::type_complexity)]
fn build_narrowed_request(
&self,
request: &OperatingQuery,
) -> Result<(OperatingQuery, RedactionRecord), Vec<AccessDenial>> {
let role = request.role();
let scope = role.scope();
let mut denials: Vec<AccessDenial> = Vec::new();
// ---------- 第 1 组:输入合法性 ----------
if !request.has_valid_date_range() {
denials.push(AccessDenial::new(
RULE_INVALID_DATE_RANGE,
format!(
"结束日 {} 早于开始日 {}",
request.end_date().formatted(),
request.start_date().formatted()
),
));
}
if request.requested_store_codes().is_empty() {
denials.push(AccessDenial::new(
RULE_EMPTY_STORE_SELECTION,
"未指定任何门店".to_string(),
));
}
if request.requested_metric_codes().is_empty() {
denials.push(AccessDenial::new(
RULE_EMPTY_METRIC_SELECTION,
"未指定任何指标".to_string(),
));
}
let unknown_stores: Vec<String> = self.catalog.unknown_codes(request.requested_store_codes());
if !unknown_stores.is_empty() {
denials.push(AccessDenial::new(
RULE_UNKNOWN_STORE_CODE,
format!("系统不认识的门店编码:{}", unknown_stores.join("、")),
));
}
let unknown_metrics: Vec<&'static str> = request.unknown_metric_codes();
if !unknown_metrics.is_empty() {
denials.push(AccessDenial::new(
RULE_UNKNOWN_METRIC_CODE,
format!("系统不认识的指标编码:{}", unknown_metrics.join("、")),
));
}
// 输入层面就有问题时不继续——继续算只会产出更多派生性的噪声拒绝。
// 这是「一次报全」的一个必要例外:**输入不合法时,
// 后续判断的前提不成立**,继续报下去只是把同一件事换个说法。
if !denials.is_empty() {
return Err(denials);
}
// ---------- 第 2 组:门店范围 ----------
let known_stores: Vec<StoreCode> = self.catalog.resolve_all(request.requested_store_codes());
let home_store_code: Option<&str> = request.caller_home_store_code();
let summary_mode: bool = !scope.store_visibility().allows_detail();
let mut allowed_stores: Vec<StoreCode> = Vec::new();
let mut denied_stores: Vec<StoreCode> = Vec::new();
if summary_mode {
// 汇总模式:不授予任何门店明细,但仍需知道「要汇总哪些门店」。
// 因此全部已知门店都进入 `allowed_stores`(用于聚合),
// 并在后面用「最小样本数」约束它——否则缩到一家店就等价于明细。
allowed_stores = known_stores.clone();
} else {
for store in &known_stores {
if scope.allows_store(store, home_store_code) {
allowed_stores.push(store.clone());
} else {
denied_stores.push(store.clone());
}
}
}
if !summary_mode && allowed_stores.is_empty() {
denials.push(AccessDenial::new(
RULE_STORE_OUT_OF_SCOPE,
format!(
"请求的 {} 家门店全部不在授权范围内(所属门店:{})",
known_stores.len(),
home_store_code.unwrap_or("未提供")
),
));
}
if summary_mode {
// 汇总模式的最小样本数检查。
let minimum: usize = scope.minimum_aggregate_store_count();
if allowed_stores.len() < minimum {
denials.push(AccessDenial::new(
RULE_AGGREGATE_SAMPLE_TOO_SMALL,
format!(
"汇总范围仅涉及 {} 家门店,低于最小样本量 {}(防止缩小范围反推单店明细)",
allowed_stores.len(),
minimum
),
));
}
}
// ---------- 第 3 组:指标范围与敏感级 ----------
let requested_metrics: Vec<MetricKind> = request.resolved_metrics();
let mut visible_metric_codes: Vec<&'static str> = Vec::new();
let mut out_of_whitelist: usize = 0;
let mut out_of_sensitivity: usize = 0;
for metric in &requested_metrics {
let in_whitelist: bool = scope
.allowed_metric_codes()
.iter()
.any(|code| *code == metric.code());
let within_level: bool = scope.maximum_sensitivity().allows(metric.sensitivity());
if in_whitelist && within_level {
visible_metric_codes.push(metric.code());
} else if !in_whitelist {
out_of_whitelist += 1;
} else {
out_of_sensitivity += 1;
}
}
if visible_metric_codes.is_empty() {
denials.push(AccessDenial::new(
RULE_METRIC_OUT_OF_SCOPE,
format!(
"请求的 {} 个指标无一可用(白名单外 {} 个、敏感级超限 {} 个)",
requested_metrics.len(),
out_of_whitelist,
out_of_sensitivity
),
));
// ⚠️ 只有在「一个指标都不剩」时,敏感级超限才是**致命**的。
//
// 这一条是本工程踩过的一个真坑:早先的写法是
// 「只要 out_of_sensitivity > 0 就 push 一条拒绝」,
// 结果区域经理那笔查询(白名单含 PROFIT、敏感级上限为内部)
// 会因为「请求里有一个低权限指标」而被**整笔拒绝**——
// 而设计意图本来是「剥掉那一个、其余照给」。
// 更糟的是:拒绝会触发下面的提前 return,
// **推理泄露检查整段被跳过**,于是本工程最重要的那个演示
// 永远走不到(报表上连一条泄露事件都没有,看起来「很干净」)。
//
// 教训:把「部分裁剪」写成「致命拒绝」,不只是行为错,
// 还会**静默吞掉**后续的检查步骤——这种错误不会有任何报错。
if out_of_sensitivity > 0 {
denials.push(AccessDenial::new(
RULE_METRIC_SENSITIVITY_EXCEEDED,
format!(
"全部 {} 个指标的敏感级均高于角色上限 {}",
requested_metrics.len(),
scope.maximum_sensitivity().label()
),
));
}
}
// 部分指标被敏感级挡住时**不产生拒绝**,只记进脱敏记录
// (`metrics_dropped_by_sensitivity`)——请求正常交付其余指标。
// 这样「越权尝试」仍然可见:它出现在报表的脱敏摘要里。
// ---------- 第 4 组:方案一报全后统一返回 ----------
if !denials.is_empty() {
return Err(denials);
}
// ---------- 第 5 组:推理泄露补救 ----------
// 找出「够不着的」指标:既包括请求里明确要但不可见的,
// 也包括**没被请求但属于敏感级更高的**(攻击者可以用两次查询拼出来)。
// 后者是必要的:若只检查「请求里出现的」,那么
// 「先查 A,再查 B」两笔合规请求合起来就能推出 C。
// 本工程采取「以角色能看到的全集为边界」的保守口径。
let mut out_of_reach: Vec<MetricKind> = BUILTIN_METRICS
.iter()
.copied()
.filter(|metric| {
!visible_metric_codes.contains(&metric.code())
&& !scope.maximum_sensitivity().allows(metric.sensitivity())
})
.collect();
// 保持内置顺序,确保补救顺序确定。
out_of_reach.sort_by_key(|metric| builtin_index_of(metric.code()));
let leak_events: Vec<InferenceLeakEvent> = self.compute_inference_removals(
role.code(),
&mut visible_metric_codes,
&out_of_reach,
);
// 无论有没有事件都要记账「检查过几次」:
// 「事件数 0」既可以表示「所有角色都干净」,也可以表示
// 「根本没走到这一步(全被前面的规则拒了)」。
// 少了这个计数,体检就无法区分「安全」与「没查」——
// 而「没查」被当成「安全」是安全检查最经典的假阴性。
//
// 借用在同一个语句里用完即释放(`borrow_mut()` 的结果不绑定到变量),
// 避免跨语句持有导致后续内层调用撞上 `already borrowed`。
{
let mut observation = self.observation.borrow_mut();
observation.evaluated_query_count += 1;
for event in &leak_events {
observation.inference_leak_events.push(event.clone());
}
}
// 补救后若一个指标都不剩(理论上不会,但要有兜底),
// 把请求里出现过的基础指标还回去——宁可少做一次补救,也不要交付空表。
if visible_metric_codes.is_empty() {
if let Some(first) = requested_metrics.first() {
visible_metric_codes.push(first.code());
}
}
// 按内置顺序排列,保证列序稳定且与真实主题的输出口径一致。
visible_metric_codes.sort_by_key(|code| builtin_index_of(code));
visible_metric_codes.dedup();
// ---------- 第 6 组:资源上限 ----------
if request.span_days() > role.maximum_query_span_days() {
denials.push(AccessDenial::new(
RULE_QUERY_SPAN_EXCEEDED,
format!(
"区间 {} 天超过上限 {} 天",
request.span_days(),
role.maximum_query_span_days()
),
));
}
let estimated_rows: usize = if summary_mode {
1
} else {
allowed_stores.len().saturating_mul(request.span_days() as usize)
};
if estimated_rows > role.maximum_result_rows() {
denials.push(AccessDenial::new(
RULE_RESULT_ROW_LIMIT_EXCEEDED,
format!(
"预估返回 {} 行超过上限 {} 行,请收窄门店或区间",
estimated_rows,
role.maximum_result_rows()
),
));
}
if !denials.is_empty() {
return Err(denials);
}
// ---------- 构造收窄后的请求 ----------
let narrowed_store_codes: Vec<String> = allowed_stores
.iter()
.map(|store| store.code().to_string())
.collect();
let narrowed: OperatingQuery = OperatingQuery::new(
role,
request.caller_home_store_code().map(|code| code.to_string()),
narrowed_store_codes,
visible_metric_codes.clone(),
request.start_date(),
request.end_date(),
);
// ---------- 脱敏记录 ----------
let redaction: RedactionRecord = RedactionRecord {
rows_dropped_by_store_scope: denied_stores.len(),
metrics_dropped_out_of_scope: out_of_whitelist,
metrics_dropped_by_sensitivity: out_of_sensitivity,
metrics_dropped_by_inference_leak: leak_events
.iter()
.flat_map(|event| event.removed_source_codes.iter().copied())
.collect(),
// 坍缩标记在 `query` 里根据 `summary_mode` 补齐
// (本函数不知道最终是否真的坍缩了)。
collapsed_to_summary: false,
detail_store_count_before_collapse: allowed_stores.len(),
};
// 推理泄露事件中,`RULE_INFERENCE_LEAK` 也要能被审计看到,
// 因此把它作为一条**非致命**记录附加在脱敏里;真正的拒绝在此不产生。
let _ = RULE_INFERENCE_LEAK;
Ok((narrowed, redaction))
}
}
impl OperatingDataSubject for AccessControlProxy {
fn query(&self, request: &OperatingQuery) -> QueryOutcome {
let summary_mode: bool = !request.role().scope().store_visibility().allows_detail();
let (narrowed, mut redaction): (OperatingQuery, RedactionRecord) =
match self.build_narrowed_request(request) {
Ok(pair) => pair,
Err(denials) => {
// 被拒绝时不消耗任何后端资源——这是保护代理的另一半价值:
// **它把非法请求挡在昂贵的真实主题之外**。
return QueryOutcome::denied(denials, WorkloadUnits::zero(), self.descriptor());
}
};
let mut outcome: QueryOutcome = self.inner.query(&narrowed);
// ---------- 聚合级脱敏:坍缩为汇总 ----------
if summary_mode {
if let Some(report) = outcome.report.take() {
let collapsed: OperatingReport = collapse_to_aggregate(report, &mut redaction);
outcome.report = Some(collapsed);
}
} else if let Some(report) = outcome.report.as_mut() {
// 行级/列级脱敏已经通过「收窄请求」实现,这里只把记录填进去。
report.redaction = redaction;
}
outcome
}
fn descriptor(&self) -> SubjectDescriptor {
SubjectDescriptor::new(
"GUARD",
"访问控制代理",
self.inner.descriptor().wrapping_depth + 1,
)
}
fn role_description(&self) -> &'static str {
"鉴权与三级脱敏(行/列/聚合)+ 推理泄露补救;非法请求在进入昂贵主体之前就被挡下"
}
}
/// 把一张报表坍缩成单行汇总。
///
/// 参数 `report`:原报表;`redaction`:脱敏记录(会就地补上坍缩标记)。
/// 返回:单行汇总报表。
///
/// ## 为什么汇总行放在「区间结束日」
///
/// 汇总代表的是**整段区间的累计**,它不对应任何单独一天。
/// 用区间结束日作为它日期,是为了让「按日期排序」这类下游操作
/// 不会把汇总行排到无关的位置(它会稳定地排在最后)。
/// 这个选择必须写下来——否则下一个人会以为日期算错了。
fn collapse_to_aggregate(
report: OperatingReport,
redaction: &mut RedactionRecord,
) -> OperatingReport {
let metric_count: usize = report.metric_count();
let mut totals: Vec<i64> = vec![0; metric_count];
for row in &report.rows {
for (index, value) in row.values.iter().enumerate() {
totals[index] += *value;
}
}
redaction.collapsed_to_summary = true;
let aggregate_row: OperatingRow = OperatingRow {
store_code: AGGREGATE_ROW_STORE_CODE.to_string(),
store_name: AGGREGATE_ROW_STORE_NAME.to_string(),
// 汇总行不属于任何区域,用 "ALL" 作为区域编码。
region_code: "ALL",
date: report.end_date,
values: totals,
};
OperatingReport::new(
report.metrics,
vec![aggregate_row],
report.start_date,
report.end_date,
redaction.clone(),
)
}
/// 取一个指标在 [`BUILTIN_METRICS`] 中的下标(找不到时返回一个很大的值)。
///
/// 下标越大 = 越派生。这个次序被两处使用:
/// 1. 推理泄露补救时「优先移除更派生的来源」;
/// 2. 收窄请求后按内置顺序排列指标列,保证列序稳定。
fn builtin_index_of(metric_code: &str) -> usize {
BUILTIN_METRICS
.iter()
.position(|metric| metric.code() == metric_code)
// 找不到时返回一个比任何合法下标都大的值,让它排在最后,
// 而不是回到 0(那会把它排到最前面,扰乱列序)。
.unwrap_or(BUILTIN_METRICS.len())
}
/// 在一组来源指标里挑出「最派生」的一个。
///
/// 判据:`BUILTIN_METRICS` 下标更大者更派生;下标相同时取编码字典序较大者,
/// 保证结果确定(不依赖输入顺序)。
fn pick_most_derived_source(source_codes: &[&'static str]) -> Option<&'static str> {
source_codes
.iter()
.copied()
.max_by_key(|code| (builtin_index_of(code), *code))
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : access_journal.rs
//! # 访问审计日志 —— 代理链的旁路记录
//!
//! ## 为什么审计日志定义在 proxy 层而不是 client 层
//!
//! 直觉上「日志写到哪里」应该是客户端的事(客户端决定持久化策略)。
//! 但本工程的依赖方向是 `client → proxy`,**反向不允许**。
//! 若把 `AccessJournal` 定义在 client 层,审计代理(proxy 层)
//! 就必须 `use crate::client::...` —— 依赖当场反向,分层破裂。
//!
//! 因此日志类型定义在 proxy 层,客户端只**读取**它。
//! 将来要换持久化介质(文件、数据库),做法是给日志加一个导出方法,
//! 而不是把日志类型搬走。
//!
//! 这条约束是 Proxy 模式与分层架构叠加时的一个真实张力点:
//! 「代理需要把观测结果交给某个外部载体,但那个载体不能在上层」。
//! 记在这里,免得后来者重新踩一遍。
//!
//! ## 时刻由日志自己推进,不读系统时钟
//!
//! 每条记录的时刻 = `起始时刻 + 序号 × 访问间隔`。
//! 这样两次运行的时间戳完全一致。若读系统时钟,
//! 本工程「两次运行逐字节一致」的验收标准当场失效。
use crate::domain::WorkloadUnits;
use crate::support::{CalendarDate, ClockTime};
use super::subject::ServingOrigin;
// ---------------------------------------------------------------------------
// 拒绝规则编码
// ---------------------------------------------------------------------------
// 用 `&'static str` 常量而非枚举:规则会随业务扩张新增
// (限流、数据脱敏策略、地理围栏……),且它们只在报表与体检里做字符串比较,
// 不决定任何代码分支。按分层图的判定标准,这种维度用开放型常量。
/// 请求的门店超出授权范围。
pub const RULE_STORE_OUT_OF_SCOPE: &str = "STORE_OUT_OF_SCOPE";
/// 请求的指标不在授权白名单内。
pub const RULE_METRIC_OUT_OF_SCOPE: &str = "METRIC_OUT_OF_SCOPE";
/// 请求的指标敏感级超过角色权限上限。
pub const RULE_METRIC_SENSITIVITY_EXCEEDED: &str = "METRIC_SENSITIVITY_EXCEEDED";
/// 查询区间超过角色的单次最大天数。
pub const RULE_QUERY_SPAN_EXCEEDED: &str = "QUERY_SPAN_EXCEEDED";
/// 预估返回行数超过角色的单次上限。
pub const RULE_RESULT_ROW_LIMIT_EXCEEDED: &str = "RESULT_ROW_LIMIT_EXCEEDED";
/// 可见指标的推导闭包中出现了权限外的敏感指标(推理泄露)。
pub const RULE_INFERENCE_LEAK: &str = "INFERENCE_LEAK";
/// 汇总请求涉及的门店数少于最小样本量(小样本反推)。
pub const RULE_AGGREGATE_SAMPLE_TOO_SMALL: &str = "AGGREGATE_SAMPLE_TOO_SMALL";
/// 没有指定任何门店。
pub const RULE_EMPTY_STORE_SELECTION: &str = "EMPTY_STORE_SELECTION";
/// 没有指定任何指标。
pub const RULE_EMPTY_METRIC_SELECTION: &str = "EMPTY_METRIC_SELECTION";
/// 日期区间非法(结束早于开始)。
pub const RULE_INVALID_DATE_RANGE: &str = "INVALID_DATE_RANGE";
/// 请求了系统中不存在的门店编码。
pub const RULE_UNKNOWN_STORE_CODE: &str = "UNKNOWN_STORE_CODE";
/// 请求了系统中不存在的指标编码。
pub const RULE_UNKNOWN_METRIC_CODE: &str = "UNKNOWN_METRIC_CODE";
/// 一条拒绝原因。
///
/// 用「规则编码 + 可读说明」两条信息:编码供报表与体检做稳定比较,
/// 说明供人阅读。只给编码则报表里全是 `STORE_OUT_OF_SCOPE` 这样的术语,
/// 只给说明则统计时要做字符串解析。两者都要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessDenial {
/// 规则编码(取本模块的 `RULE_*` 常量)。
pub rule_code: &'static str,
/// 可读说明(含具体的越界值,便于直接定位)。
pub message: String,
}
impl AccessDenial {
/// 构造。
pub fn new(rule_code: &'static str, message: String) -> AccessDenial {
AccessDenial { rule_code, message }
}
/// 返回 `STORE_OUT_OF_SCOPE: 请求 3 家门店,授权 1 家` 形式。
pub fn formatted(&self) -> String {
format!("{}: {}", self.rule_code, self.message)
}
}
/// 一条访问记录(**草稿**:序号与时刻由日志在收录时补上)。
///
/// ## 为什么用草稿结构体而不是让代理自己填序号
///
/// 序号与时序是**日志的属性**,不是调用方的属性。
/// 若让每个代理自己算序号,同一个序号可能被两处使用,
/// 或者出现「先记录的两条序号却更大」。让日志统一分配,
/// 这类错误在类型层面就不可能出现——代理根本没有地方填序号。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessJournalDraft {
/// 调用方角色编码。
pub role_code: &'static str,
/// 调用方角色名称。
pub role_name: &'static str,
/// 请求的门店数量。
pub requested_store_count: usize,
/// 请求的指标数量。
pub requested_metric_count: usize,
/// 区间开始日。
pub start_date: CalendarDate,
/// 区间结束日。
pub end_date: CalendarDate,
/// 响应来源。
pub served_by: ServingOrigin,
/// 产生响应的层编码。
pub served_by_layer_code: &'static str,
/// 实际交付的明细行数。
pub delivered_row_count: usize,
/// 实际交付的指标列数。
pub delivered_metric_count: usize,
/// 拒绝规则编码列表(空表示未被拒绝)。
pub denial_codes: Vec<&'static str>,
/// 本次调用实际产生的工作负载。
pub workload: WorkloadUnits,
}
/// 一条已收录的访问记录。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessJournalEntry {
/// 访问序号(从 1 开始)。
pub sequence: u32,
/// 访问时刻。
pub at_time: ClockTime,
/// 调用方角色编码。
pub role_code: &'static str,
/// 调用方角色名称。
pub role_name: &'static str,
/// 请求的门店数量。
pub requested_store_count: usize,
/// 请求的指标数量。
pub requested_metric_count: usize,
/// 区间开始日。
pub start_date: CalendarDate,
/// 区间结束日。
pub end_date: CalendarDate,
/// 响应来源。
pub served_by: ServingOrigin,
/// 产生响应的层编码。
pub served_by_layer_code: &'static str,
/// 实际交付的明细行数。
pub delivered_row_count: usize,
/// 实际交付的指标列数。
pub delivered_metric_count: usize,
/// 拒绝规则编码列表。
pub denial_codes: Vec<&'static str>,
/// 本次调用实际产生的工作负载。
pub workload: WorkloadUnits,
}
impl AccessJournalEntry {
/// 是否被拒绝。
pub fn is_denied(&self) -> bool {
self.served_by == ServingOrigin::DeniedBeforeSubject
}
/// 首个拒绝规则编码(未被拒绝时返回 `None`)。
pub fn first_denial_code(&self) -> Option<&'static str> {
self.denial_codes.first().copied()
}
/// 查询区间天数(闭区间)。
pub fn span_days(&self) -> u32 {
self.start_date.span_days_inclusive(&self.end_date)
}
}
/// 访问审计日志。
#[derive(Debug, Clone)]
pub struct AccessJournal {
/// 已收录的记录。
entries: Vec<AccessJournalEntry>,
/// 下一条记录将被分配的序号。
next_sequence: u32,
/// 下一次访问的时刻。
next_access_time: ClockTime,
/// 相邻两次访问之间的间隔分钟数。
access_interval_minutes: u32,
}
impl AccessJournal {
/// 构造一本日志。
///
/// 参数 `start_time`:首次访问的时刻;`access_interval_minutes`:访问间隔。
/// 返回:空日志。
pub fn new(start_time: ClockTime, access_interval_minutes: u32) -> AccessJournal {
AccessJournal {
entries: Vec::new(),
next_sequence: 1,
next_access_time: start_time,
access_interval_minutes,
}
}
/// 收录一条记录,并自动分配序号与时刻。
///
/// 参数 `draft`:代理填写的草稿。
pub fn record(&mut self, draft: AccessJournalDraft) {
let entry: AccessJournalEntry = AccessJournalEntry {
sequence: self.next_sequence,
at_time: self.next_access_time,
role_code: draft.role_code,
role_name: draft.role_name,
requested_store_count: draft.requested_store_count,
requested_metric_count: draft.requested_metric_count,
start_date: draft.start_date,
end_date: draft.end_date,
served_by: draft.served_by,
served_by_layer_code: draft.served_by_layer_code,
delivered_row_count: draft.delivered_row_count,
delivered_metric_count: draft.delivered_metric_count,
denial_codes: draft.denial_codes,
workload: draft.workload,
};
self.entries.push(entry);
// 序号与时刻各自推进;两者独立,不互相推导。
self.next_sequence += 1;
self.next_access_time = self.next_access_time.add_minutes(self.access_interval_minutes);
}
/// 全部记录(按收录顺序)。
pub fn entries(&self) -> &[AccessJournalEntry] {
&self.entries
}
/// 记录条数。
pub fn len(&self) -> usize {
self.entries.len()
}
/// 是否为空。
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// 统计某种响应来源的记录条数。
///
/// 参数 `origin`:响应来源。
/// 返回:条数。
pub fn count_of_origin(&self, origin: ServingOrigin) -> usize {
self.entries
.iter()
.filter(|entry| entry.served_by == origin)
.count()
}
/// 统计被拒绝的记录条数。
pub fn denied_count(&self) -> usize {
self.entries.iter().filter(|entry| entry.is_denied()).count()
}
/// 全部记录的工作负载之和。
pub fn total_workload(&self) -> WorkloadUnits {
let mut total: WorkloadUnits = WorkloadUnits::zero();
for entry in &self.entries {
total = total.plus(&entry.workload);
}
total
}
/// 某个规则编码触发的次数。
///
/// 参数 `rule_code`:规则编码。
/// 返回:次数。
pub fn denial_count_of_rule(&self, rule_code: &str) -> usize {
self.entries
.iter()
.flat_map(|entry| entry.denial_codes.iter())
.filter(|code| **code == rule_code)
.count()
}
/// 全部出现过的拒绝规则编码(去重后排序)。
///
/// 排序是必须的:本方法用 `Vec` 收集而不是 `HashMap` 遍历,
/// 但即使如此也要显式排序,保证报表输出与调用顺序无关。
pub fn distinct_denial_codes(&self) -> Vec<&'static str> {
let mut codes: Vec<&'static str> = self
.entries
.iter()
.flat_map(|entry| entry.denial_codes.iter().copied())
.collect();
codes.sort_unstable();
codes.dedup();
codes
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : cross_region_proxy.rs
//! # 远程代理 —— 跨区域访问的本地代表
//!
//! ## 它代表什么
//!
//! 在真实的企业架构里,集团总部机房与区域机房之间隔着一条广域网。
//! 查询落在本地区域的数据是「本地访存」,跨区域则要走网关转发,
//! 每次都有固定的往返开销(网络握手、鉴权、序列化、排队)。
//!
//! 本层就是那条链路的**本地代表**:调用方以为自己在跟一个普通主体说话,
//! 实际上每次请求背后可能有一次跨区往返。
//!
//! ## 为什么按「区域」而不是按「距离」建模
//!
//! 因为区域是**部署拓扑的属性**,而距离是物理属性。
//! 用距离建模会引入浮点坐标、需要地图数据、且结果不可复现。
//! 用区域建模只需一个标签比较:**请求涉及的门店是否都在本地区域内**。
//! 判定是布尔运算,可复现、可手算,且与真实系统里
//! 「数据是否在本机房」这个实际判据一致。
//!
//! ## 「本地」是装配参数,不是数据属性
//!
//! 本代理在构造时接收 `local_region_code`。
//! 同一个代理类在上海机房装配时传 `EAST`、
//! 在深圳机房装配时传 `SOUTH`——**代码一行不改**。
//! 这正是分层图里把「区域」定义成开放型标签、
//! 而**不给它加 `is_local` 字段**的原因(见 `domain::region_tag` 的说明)。
//!
//! ## 记账口径
//!
//! 每次**跨区转发**记 [`ROUND_TRIP_UNITS`] 个单元,与内层的工作负载相加。
//! 为什么要用一个显式常量而不是「乘以某个系数」:
//! 系数会与内层负载耦合(内层负载变了,往返也跟着变),
//! 而往返开销在真实系统里**与查询规模无关**——它是一次固定成本。
//! 固定成本用固定常量,这条区分必须做对,否则
//! 「优化查询规模就能省往返」这个错误结论会从报表里推出来。
//!
//! ## 为什么本层的计数用 `Cell` 而不是 `RefCell`
//!
//! 本层的观测状态是**两个纯计数**(转发几次、本地几次),
//! 读写一个 `Copy` 结构体不需要交出内部值的借用。
//! 用 `Cell` 的收益是实打实的:`Cell` 没有借用计数、
//! 不会在嵌套调用里撞出 `already borrowed`。
//! 链上越靠近真实主题的层越容易被嵌套调用(外层每拒绝一次就少进一层,
//! 但一次「未拒绝」的查询会穿过所有层),因此
//! **能在这一层避免 `RefCell` 就在这一层避免**。
use std::cell::Cell;
use std::rc::Rc;
use crate::domain::WorkloadUnits;
use super::query_request::OperatingQuery;
use super::store_catalog::StoreCatalog;
use super::subject::{OperatingDataSubject, QueryOutcome, SubjectDescriptor};
/// 一次跨区往返折算的工作负载单元数。
///
/// 取 300:与「扫描 300 个交叉点」等价。这个值是可任选的标定值,
/// 但要写下来——它是本工程全部跨区代价数字的唯一来源,
/// 改了它,所有 `远程` 相关的数值都要重算。
pub const ROUND_TRIP_UNITS: u64 = 300;
/// 远程代理与外部分享的可观测状态。
///
/// 它必须是 `Copy` 的,才能放进 `Cell` —— 这对诊断数据是合适的:
/// 两个整数就是本层的全部对外事实,没有需要保持唯一性的内容。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CrossRegionObservation {
/// 走到跨区链路的请求次数。
pub forwarded_count: u32,
/// 全程落在本地区域的请求次数。
pub local_count: u32,
}
impl CrossRegionObservation {
/// 总请求次数。
pub const fn total_count(&self) -> u32 {
self.forwarded_count + self.local_count
}
/// 跨区请求占比(万分点)。总数为零时返回零。
pub fn forwarded_ratio_basis_points(&self) -> i64 {
let total: u32 = self.total_count();
if total == 0 {
return 0;
}
(self.forwarded_count as i64) * 10_000 / (total as i64)
}
}
/// 跨区域访问代理。
pub struct CrossRegionProxy {
/// 内层主体。
inner: Box<dyn OperatingDataSubject>,
/// 门店主数据(用于判断门店属于哪个区域)。
catalog: Rc<StoreCatalog>,
/// 本机房所在区域编码。
///
/// 只留**编码**不留名称:转发判定用的是编码(与门店的 `region().code()`
/// 比较),而名称纯粹是给人看的,那一份由装配器持有并进报表。
/// 同一个事实存两处,就会出现「一处改了另一处没改」的隐患。
local_region_code: &'static str,
/// 与外部分享的观测状态。
///
/// `Cell<..>`:只做「读—改—写」,不交出内部借用。
/// 用 `Rc` 共享是因为**装配完成后外部够不到本层**
/// (本层被包在若干层 `Box` 里面),诊断数据必须在装配期注入。
observation: Rc<Cell<CrossRegionObservation>>,
}
impl CrossRegionProxy {
/// 构造远程代理。
///
/// 参数 `inner` / `catalog` / `local_region_code`;
/// `observation`:与外部分享的观测状态句柄。
/// 返回:代理。
pub fn new(
inner: Box<dyn OperatingDataSubject>,
catalog: Rc<StoreCatalog>,
local_region_code: &'static str,
observation: Rc<Cell<CrossRegionObservation>>,
) -> CrossRegionProxy {
CrossRegionProxy {
inner,
catalog,
local_region_code,
observation,
}
}
// ★ 已删除:`local_region_code()` / `local_region_name()` / `observation()`
// 三个访问器,以及 `local_region_name` 这个**字段**。
//
// 三个访问器都拿不到:调用方持有的是 `Box<dyn OperatingDataSubject>`,
// 得不到 `&CrossRegionProxy`。
//
// `local_region_name` 字段连内部都不需要——转发判定只用**编码**
// (与门店的 `region().code()` 比较),名称纯粹是给人看的,
// 而给人看的那一份由装配器自己持有并进报表。
// 同一个事实存两处,就会出现「一处改了另一处没改」的隐患。
/// 判断请求是否需要跨区转发。
///
/// 参数 `request`:查询请求。
/// 返回:**只要有任何一家请求门店不在本地区域**,就返回 `true`。
///
/// ## 为什么是「任意一家」而不是「全部都不是」
///
/// 因为一次跨区查询在真实系统里是**整体**走网关的:
/// 网关要么接受这次请求、要么拒绝,不会「一半本地一半远程」。
/// 因此只要涉及一家外部门店,整次请求就必须走远程链路。
/// 这是「按请求而非按行」计费的口径,与 `WorkloadUnits` 的
/// 「按交叉点计费」是两套不同的口径——两者必须在报表里分开标注,
/// 否则读者会以为往返开销也能按门店数摊薄。
fn requires_forwarding(&self, request: &OperatingQuery) -> bool {
request.requested_store_codes().iter().any(|code| {
match self.catalog.find(code) {
Some(store) => store.region().code() != self.local_region_code,
// 未知门店:无法判定归属,保守地按「需要转发」处理。
// 保守方向不能反——若按「本地」处理,
// 伪造的编码就能绕过远程链路的记录与限流。
None => true,
}
})
}
}
impl OperatingDataSubject for CrossRegionProxy {
fn query(&self, request: &OperatingQuery) -> QueryOutcome {
let needs_forwarding: bool = self.requires_forwarding(request);
let mut outcome: QueryOutcome = self.inner.query(request);
// 用 `Cell::get` 取一份副本、改好、再 `set` 回去。
// 不要把 `self.observation.get()` 的结果当作「引用」——
// 它是 Copy 出来的值,改动它不会自动写回 `Cell`。
// 这是 `Cell` 最容易踩的一个坑,也因此它最适合只放计数。
let mut observed: CrossRegionObservation = self.observation.get();
if needs_forwarding {
// 记账:固定往返 + 内层实际负载。
outcome.workload = outcome
.workload
.plus(&WorkloadUnits::from_units(ROUND_TRIP_UNITS));
observed.forwarded_count += 1;
} else {
observed.local_count += 1;
}
self.observation.set(observed);
// 响应仍然由内层产生(远程代理只是传输通道),
// 因此 `served_by_layer` 不改。
outcome
}
fn descriptor(&self) -> SubjectDescriptor {
SubjectDescriptor::new(
"REMOTE",
"跨区域访问代理",
self.inner.descriptor().wrapping_depth + 1,
)
}
fn role_description(&self) -> &'static str {
"请求涉及本区域之外的门店时按固定 300 单元记账一次跨区往返"
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : data_warehouse.rs
//! # 真实主题 —— 昂贵的经营数据仓库
//!
//! ## 它「昂贵」在哪里,怎么度量
//!
//! 本工程的全部代理价值都建立在一个前提上:**真实主题确实很贵**。
//! 因此这里必须把「贵」变成一个可核对、可复现的整数,而不是一句形容。
//!
//! 度量口径(与 [`WorkloadUnits`](crate::domain::WorkloadUnits) 的定义一致):
//! **扫描一个「门店 × 自然日 × 指标」的交叉点算 1 个单元**。
//!
//! | 动作 | 单元数公式 | 本工程的值 |
//! |---|---|---|
//! | **装载**(`open`) | 门店数 × 天数 × 指标数 | 120 × 30 × 5 = **18,000** |
//! | **查询**(`query`) | 命中门店数 × 天数 × 请求指标数 | 视请求而定 |
//!
//! ## 装载为什么贵
//!
//! `open` 把「每个门店每天都有的三个基础观测值」展开成一张完整的表:
//! 读原始流水、按门店与日期排序、建索引、算派生指标。
//! 这是「数据仓库启动」的代价——**在真实系统里它可能是几十秒到几分钟**。
//! 本工程用整数把它折算成 18,000 个单元,好处是**可以被手算核对**:
//! 120 家门店 × 30 天 × 5 个指标 = 18,000,一眼就能验算。
//!
//! 虚拟代理存在的唯一理由就是「如果没人查,这 18,000 就不该付」。
//!
//! ## 为什么只存三个基础指标
//!
//! 五个指标里有两个是派生的:
//! - `REVENUE`(营业额)= `FOOTFALL`(客流)× `BASKET`(客单价)
//! - `PROFIT`(毛利额)= `REVENUE` × `MARGIN`(毛利率)
//!
//! 只存三个基础值、另两个现算,带来一个重要的副产品:
//! **派生关系天然成立,可以拿来交叉验算**。
//! 若把五个值各存一份,「营业额 = 客流 × 客单价」这条关系
//! 只能靠生成时的巧合成立,一旦有人改了某一列,矛盾会静默存在。
//!
//! ## 不变式:到达本层的指标编码一定都是已知的
//!
//! 保护代理在转发之前会把未知指标编码拒掉。因此本层的
//! `metric_value` 遇到不认识的编码时**不会发生**——但代码里仍然返回 0
//! 而不是 panic。理由与到处一样的「错误要可见不要致命」:
//! 但这里多一层考虑——**这条不变式是由另一层保证的**,
//! 而「依赖另一层保证的不变式」在重构中最容易被破坏。
//! 返回 0 会让问题表现为「某列全是 0」(显眼),
//! panic 会让问题表现为「报表拿不到」(诊断时缺上下文)。
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use crate::domain::{
clamp_i128_to_i64, divide_rounded_away_from_zero, MetricKind, StoreCode, WorkloadUnits,
BASIS_POINTS_DENOMINATOR, BUILTIN_METRICS, METRIC_BASKET, METRIC_FOOTFALL, METRIC_GROSS_MARGIN,
METRIC_GROSS_PROFIT, METRIC_REVENUE,
};
use crate::support::{build_seed, map_hash_to_range, CalendarDate};
use super::operating_report::{OperatingReport, OperatingRow, RedactionRecord};
use super::query_request::OperatingQuery;
use super::store_catalog::StoreCatalog;
use super::subject::{
OperatingDataSubject, QueryOutcome, ServingOrigin, SubjectDescriptor,
};
/// 客流(人)的取值下界。
const FOOTFALL_MINIMUM: i64 = 120;
/// 客流(人)的取值上界。
const FOOTFALL_MAXIMUM: i64 = 880;
/// 客单价(分)的取值下界(¥180.00)。
const BASKET_MINIMUM_MINOR_UNITS: i64 = 18_000;
/// 客单价(分)的取值上界(¥520.00)。
const BASKET_MAXIMUM_MINOR_UNITS: i64 = 52_000;
/// 毛利率(万分点)的取值下界(15.00%)。
const MARGIN_MINIMUM_BASIS_POINTS: i64 = 1_500;
/// 毛利率(万分点)的取值上界(32.00%)。
const MARGIN_MAXIMUM_BASIS_POINTS: i64 = 3_200;
/// 数据的「地平线」天数。
///
/// 仓库只装载 2026 年 9 月这 30 天。区间落在九月之外的日期不会产生行——
/// 这与真实数仓「只到某个日期为止」的行为一致。
pub const DATA_HORIZON_DAYS: u32 = 30;
/// 基础观测值的列数(客流 / 客单价 / 毛利率)。
///
/// 不等于指标总数(5)——另外两个是派生的。
const BASE_OBSERVATION_COLUMNS: usize = 3;
/// 真实主题与外部分享的观测。
///
/// ## 为什么连真实主题也要把状态交出去
///
/// 本工程要把总负载拆成**装载 / 扫描 / 跨区往返**三笔账,
/// 而这三笔分别由三种不同的角色记账:虚拟代理(装载)、
/// 真实主题(扫描)、远程代理(往返)。
///
/// 前两者的句柄都能在装配期注入,只有扫描量——
/// 它发生在真实主题内部,而真实主题是**由虚拟代理的工厂在第一次查询时**
/// 才创建出来的。因此句柄必须由工厂闭包一起带进去,
/// 而工厂又是由装配器构造的。**装配期是唯一还来得及的地方。**
///
/// ## ⚠️ 为什么装载量必须由真实主题也记一份(本工程踩过的坑)
///
/// 最初装载量只记在虚拟代理身上([`LazyObservation`](super::lazy_initialization_proxy::LazyObservation))。
/// 这在标准配方上没问题,但**裸链上没有虚拟代理**——
/// 于是裸链的「装载账」读出来是 0,而它的响应里明明付了 18,000。
/// 结果是「节省量」算成 **−14,089**(负的节省率 −295.98%),
/// 意思是「加了代理反而贵了」。
///
/// 错在哪里?**装载的付款人是真实主题,不是虚拟代理。**
/// 虚拟代理记的是「这次装载是它按需触发的」,那是**归因**;
/// 而「装载花了多少」是**账目**。把账目记在归因方身上,
/// 一旦归因方不存在(裸链),账目就凭空消失。
///
/// 现在两处都记:真实主题记账目(任何链都能读到),
/// 虚拟代理记归因(用于证明「没查就不装载」)。
/// 两者的差值是**一个必须为零的核对项**——
/// 若不为零,说明虚拟代理的记账与实际装载脱节。
///
/// ## 为什么还要记「逐笔扫描量」
///
/// 成本归因要回答「这一笔省了多少」,就必须把某笔调用与对照组
/// **同下标**的那一笔相减。只记总数的话,能算「总共省了多少」,
/// 但算不出「省在哪一类请求上」。
///
/// 逐笔值只由真实主题产生,因此它与「哪些调用真的到达了真实主题」
/// 一一对应——分析层按 `served_by == RealComputation` 依次取用即可对齐,
/// 不需要把调用下标藏进这个结构体里。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WarehouseObservation {
/// 真实主题被创建了几次。
pub open_count: u32,
/// 累计付出的装载单元(**账目**,任何链都能读到)。
pub open_units: u64,
/// 真实主题执行过的查询次数。
pub query_count: u32,
/// 累计扫描的交叉点单元数。
pub scanned_cells: u64,
/// 逐笔扫描量(按下标顺序,只含真正到达真实主题的那些调用)。
pub per_query_scanned_cells: Vec<u64>,
}
/// 昂贵的经营数据仓库(真实主题)。
pub struct OperatingDataWarehouse {
/// 门店主数据(轻量,装配时即已就绪)。
catalog: Rc<StoreCatalog>,
/// 数据地平线起始日。
start_date: CalendarDate,
/// 数据地平线天数。
day_count: u32,
/// 基础观测值矩阵,下标 = `门店下标 × day_count + 日下标`。
///
/// 每行三个 `i64`:客流、客单价(分)、毛利率(万分点)。
/// 用「扁平一维 + 手工算下标」而不是 `Vec<Vec<[i64; 3]>>`:
/// 后者是 120 个 `Vec`,堆分配 120 次,而本工程要论证的正是
/// 「装载有多贵」——把代价做实比写得好看重要。
base_values: Vec<[i64; BASE_OBSERVATION_COLUMNS]>,
/// 装载耗费的工作负载。
open_workload: WorkloadUnits,
/// 与外部分享的观测句柄。
///
/// 用 `RefCell` 而不是 `Cell`:观测里含一个 `Vec`(逐笔扫描量),
/// 它不是 `Copy`,必须交出内部值的可变引用才能 `push`。
/// 「值是不是 `Copy`」是选择载体的第一条判据。
observation: Rc<RefCell<WarehouseObservation>>,
}
impl OperatingDataWarehouse {
/// 打开数据仓库(**昂贵操作**)。
///
/// 参数 `catalog`:门店主数据;`start_date`:地平线起始日;
/// `day_count`:地平线天数;`observation`:与外部分享的观测句柄。
/// 返回:已装载的仓库。
///
/// 本函数内部逐格生成 `门店 × 日 × 基础观测列`,
/// 并把「装载代价」按 `门店 × 日 × 指标总数` 记账。
/// 记账用指标总数(5)而不是基础列数(3),因为派生指标
/// 在装载阶段也被展开并校验过一次——这是真实数仓的常见做法
/// (预聚合表在构建时就会把派生列算出来)。
pub fn open(
catalog: Rc<StoreCatalog>,
start_date: CalendarDate,
day_count: u32,
observation: Rc<RefCell<WarehouseObservation>>,
) -> OperatingDataWarehouse {
let store_count: usize = catalog.len();
let mut base_values: Vec<[i64; BASE_OBSERVATION_COLUMNS]> =
Vec::with_capacity(store_count * day_count as usize);
for store in catalog.stores() {
for day_offset in 0..day_count {
let date: CalendarDate = start_date.add_days(day_offset as i64);
// 逐列生成基础观测值。用确定性哈希派生,
// 保证「同一门店同一天同一指标」永远是同一个数。
let footfall: i64 = derive_base_value(
store,
&date,
METRIC_FOOTFALL.code(),
FOOTFALL_MINIMUM,
FOOTFALL_MAXIMUM,
);
let basket: i64 = derive_base_value(
store,
&date,
METRIC_BASKET.code(),
BASKET_MINIMUM_MINOR_UNITS,
BASKET_MAXIMUM_MINOR_UNITS,
);
let margin: i64 = derive_base_value(
store,
&date,
METRIC_GROSS_MARGIN.code(),
MARGIN_MINIMUM_BASIS_POINTS,
MARGIN_MAXIMUM_BASIS_POINTS,
);
base_values.push([footfall, basket, margin]);
}
}
// 装载代价 = 门店 × 天数 × 指标总数。
let total_cells: u64 =
(store_count as u64) * (day_count as u64) * (BUILTIN_METRICS.len() as u64);
// 装载的**付款人**在这里,所以账目也记在这里——
// 而不是记在「按需触发装载」的虚拟代理身上。
// 理由见 [`WarehouseObservation`] 的说明:记在归因方会让
// 裸链(没有虚拟代理)的装载账凭空消失。
{
let mut shared = observation.borrow_mut();
shared.open_count += 1;
shared.open_units += total_cells;
}
OperatingDataWarehouse {
catalog,
start_date,
day_count,
base_values,
open_workload: WorkloadUnits::from_units(total_cells),
observation,
}
}
/// 装载耗费的工作负载。
pub const fn open_workload(&self) -> WorkloadUnits {
self.open_workload
}
// ★ 已删除 `start_date()` / `day_count()` / `catalog()` 三个访问器。
//
// 真实主题的实例是在虚拟代理的工厂闭包内部创建的,
// 装配完成后**没有任何人持有 `&OperatingDataWarehouse`**
// (链是嵌套 `Box`)。因此这三个窗口永远打不开。
//
// 需要这些信息的场合各有出路:数据地平线的起点与天数
// 由装配器自己持有(它才是构造参数的主人),
// 门店主数据则通过 `AssembledProxyChain::catalog()` 拿到。
/// 取某个门店某天的基础观测值。
fn base_observation(&self, store_index: usize, day_offset: u32) -> [i64; BASE_OBSERVATION_COLUMNS] {
let flat_index: usize = store_index * self.day_count as usize + day_offset as usize;
// 越界时返回全零而不是 panic:调用方在装载后本不该越界,
// 但「靠调用方保证的下标」是崩溃的常见来源。零值会让异常
// 表现为「某行的值全是 0」(显眼),而不是整张报表拿不到。
self.base_values.get(flat_index).copied().unwrap_or([0; 3])
}
/// 按指标对象取一个交叉点的值。
///
/// 派生指标在此**现算**,而不是查表——这正是
/// 「营业额 = 客流 × 客单价」这条关系永远成立的原因。
fn metric_value(
&self,
store_index: usize,
day_offset: u32,
metric: &MetricKind,
) -> i64 {
let observation: [i64; BASE_OBSERVATION_COLUMNS] = self.base_observation(store_index, day_offset);
let footfall: i64 = observation[0];
let basket: i64 = observation[1];
let margin: i64 = observation[2];
match metric.code() {
code if code == METRIC_FOOTFALL.code() => footfall,
code if code == METRIC_BASKET.code() => basket,
code if code == METRIC_GROSS_MARGIN.code() => margin,
code if code == METRIC_REVENUE.code() => {
// 营业额 = 客流 × 客单价。用 i128 中间量防止极端值溢出。
clamp_i128_to_i64(footfall as i128 * basket as i128)
}
code if code == METRIC_GROSS_PROFIT.code() => {
// 毛利额 = 营业额 × 毛利率 ÷ 10000,舍入口径与其余金额运算一致。
let revenue: i128 = footfall as i128 * basket as i128;
let profit: i128 =
divide_rounded_away_from_zero(revenue * margin as i128, BASIS_POINTS_DENOMINATOR as i128);
clamp_i128_to_i64(profit)
}
// 见模块头部的「不变式」说明:这条分支理论上不可达。
_ => 0,
}
}
}
/// 由确定性哈希派生一个基础观测值。
///
/// 参数 `store` / `date` / `metric_code` / `minimum` / `maximum`。
/// 返回:落在闭区间内的值。
///
/// 分隔符由 `build_seed` 负责插入——若直接拼接
/// `"LF0001" + "2026-09-01" + "FOOTFALL"`,不同组合可能撞出同一个字符串。
fn derive_base_value(
store: &StoreCode,
date: &CalendarDate,
metric_code: &str,
minimum: i64,
maximum: i64,
) -> i64 {
let seed: u64 = build_seed(&[store.code(), &date.formatted(), metric_code]);
map_hash_to_range(seed, minimum, maximum)
}
impl OperatingDataSubject for OperatingDataWarehouse {
fn query(&self, request: &OperatingQuery) -> QueryOutcome {
// ---------- 解析请求 ----------
// 门店:`catalog.stores()` 已按编码升序,直接带下标枚举即可得到
// 「稳定顺序 + 矩阵下标」两样东西。**不要**先收集再回头用
// `position()` 反查下标——那会变成 O(n²),而且一旦有人改了排序
// 条件,下标就可能与实际矩阵行错位(静默的数据串行)。
let requested_store_codes: &[String] = request.requested_store_codes();
let resolved_stores: Vec<(usize, &StoreCode)> = self
.catalog
.stores()
.iter()
.enumerate()
.filter(|(_index, store)| {
requested_store_codes
.iter()
.any(|code| code == store.code())
})
.collect();
// 指标列:按内置指标表的**规范顺序**输出,而不是按请求里的书写顺序。
// 否则同一个查询换个书写顺序就会得到不同的列序,
// 缓存键不同、报表不可比。规范顺序由 `BUILTIN_METRICS` 定义。
let requested_metric_codes: Vec<&'static str> = request.requested_metric_codes().to_vec();
let metrics: Vec<MetricKind> = BUILTIN_METRICS
.iter()
.copied()
.filter(|metric| requested_metric_codes.iter().any(|code| *code == metric.code()))
.collect();
// ---------- 扫描 ----------
// 日期区间与数据地平线求交。
let mut rows: Vec<OperatingRow> = Vec::new();
let mut scanned_days: u32 = 0;
for day_offset in 0..self.day_count {
let date: CalendarDate = self.start_date.add_days(day_offset as i64);
// 落在请求区间内才产出:与地平线的交集天然由这个判断给出。
let after_start: bool = date.days_from(&request.start_date()) >= 0;
let before_end: bool = request.end_date().days_from(&date) >= 0;
if !after_start || !before_end {
continue;
}
scanned_days += 1;
for (store_index, store) in &resolved_stores {
let mut values: Vec<i64> = Vec::with_capacity(metrics.len());
for metric in &metrics {
values.push(self.metric_value(*store_index, day_offset, metric));
}
rows.push(OperatingRow {
store_code: store.code().to_string(),
store_name: store.display_name().to_string(),
region_code: store.region().code(),
date,
values,
});
}
}
// ---------- 记账 ----------
// 扫描单元 = 门店数 × 命中天数 × 请求指标数。
// 注意:**没有算上装载代价**——装载由虚拟代理单独记账,
// 否则同一个 18,000 会在每次查询里被重复计入。
let workload: WorkloadUnits = WorkloadUnits::from_units(
(resolved_stores.len() as u64) * (scanned_days as u64) * (metrics.len() as u64),
);
// 把这一次的扫描量累加进共享观测。
// 与「报表负载」分开记一份的必要性见 [`WarehouseObservation`] 的说明:
// 它是三笔账里唯一发生在真实主题内部的一笔,
// 靠减法推导会掩盖另外两笔的错误。
//
// ⚠️ 这里用的是「本次实际扫描的单元数」而不是 `workload` 变量:
// 两者此刻相等,但将来若在真实主题内部再加一笔内部开销
// (例如结果集的物化),`workload` 会变而扫描量不该变。
// 现在写成两个独立的量,是为了让那次改动只改一处。
let scanned: u64 = (resolved_stores.len() as u64)
* (scanned_days as u64)
* (metrics.len() as u64);
{
let mut shared = self.observation.borrow_mut();
shared.query_count += 1;
shared.scanned_cells += scanned;
// 逐笔值:让分析层能把某笔调用与对照组的同下标调用相减。
shared.per_query_scanned_cells.push(scanned);
}
// 真实主题不做任何脱敏:它照着请求给。所有裁剪都发生在它外面。
let report: OperatingReport = OperatingReport::new(
metrics,
rows,
request.start_date(),
request.end_date(),
RedactionRecord::default(),
);
QueryOutcome {
report: Some(report),
denials: Vec::new(),
workload,
served_by: ServingOrigin::RealComputation,
served_by_layer: self.descriptor(),
}
}
fn descriptor(&self) -> SubjectDescriptor {
// 真实主题是链的锚点,深度恒为 0。
SubjectDescriptor::new("WAREHOUSE", "经营数据仓库(真实主题)", 0)
}
fn role_description(&self) -> &'static str {
"真正扫描并计算数据的实体;装载代价 120×30×5=18000 单元,每次查询按命中单元计费"
}
}
/// 统计用的门店下标映射辅助(供报表快速定位行)。
///
/// 之所以单独给出而不是藏在 `query` 里:报表需要「按门店聚合」时
/// 要能独立地做一遍分组,再与 `query` 的结果对照——
/// 两条独立路径得到同一个数,才说明两边都没错。
pub fn group_row_counts_by_store(report: &OperatingReport) -> HashMap<String, usize> {
let mut tally: HashMap<String, usize> = HashMap::new();
for row in &report.rows {
*tally.entry(row.store_code.clone()).or_insert(0) += 1;
}
tally
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : lazy_initialization_proxy.rs
//! # 虚拟代理 —— 延迟创建昂贵的真实主题
//!
//! ## 这一层是「Proxy 不是 Decorator」的分水岭
//!
//! Decorator 与 Proxy 的结构图几乎一样(都是「持有同接口的对象并转发」),
//! 差别全在**谁创建被包裹的对象、什么时候创建**:
//!
//! | 维度 | Decorator | Proxy |
//! |---|---|---|
//! | 被包裹对象从哪来 | **由调用方创建并传入** | **由代理自己创建** |
//! | 生命周期归属 | 调用方 | **代理** |
//! | 调用方是否需要知道被包裹对象的类型 | 需要(构造时要写出来) | **不需要** |
//!
//! 本层把这条差异做成了**可静态检查**的东西:它的构造函数接收的
//! 不是一个已建好的真实主题,而是一个**工厂闭包**
//! (`Box<dyn FnOnce() -> OperatingDataWarehouse>`)。
//! 调用方无法在构造时提供一个真实主题实例——类型上就不接受。
//!
//! 于是产生了一个可以拿来验证的推论:
//! **工程外的调用方代码里绝不出现 `OperatingDataWarehouse` 这个名字**。
//! 第三幕会用两条**都能编译通过**的写法对照这一点。
//!
//! ## 为什么用 `FnOnce` 而不是 `Fn`
//!
//! 工厂**只会被调用一次**(初始化后就不需要了)。用 `FnOnce`
//! 把这条约束写进类型:多调一次在类型层面不可能发生。
//! 代价是要用 `Option<..>::take()` 把它取出来——而这个代价恰好换来一个
//! 额外的好处:**「工厂已被消耗」与「真实主题已就绪」是同一个事实**,
//! 不会出现「初始化失败了但工厂已经没了」这种半状态。
//!
//! ## 三种内部可变性载体,为什么各层选得不一样
//!
//! `query` 取 `&self`,因此链上凡是需要改状态的层都得用**内部可变性**。
//! 但「用哪个载体」不是随手挑的,判据只有两条:
//! **①要改的值是不是 `Copy` 标量;②这个状态装配完成后外部要不要读到。**
//!
//! | 载体 | 用在哪 | 为什么是它 |
//! |---|---|---|
//! | `Cell<T>`(`T: Copy`) | 本层装载计数、远程代理的转发/本地计数 | 纯计数。`Cell` 没有借用计数,**不可能**撞出 `already borrowed` |
//! | `RefCell<T>`(私有) | 缓存代理的缓存体 | 需要交出内部值的可变借用(`HashMap::insert`),但只有本层使用 |
//! | `Rc<RefCell<T>>`(共享) | 本层装载观测、缓存观测、保护代理泄露事件、审计日志 | 装配完成后外部要读;链是嵌套 `Box`,不注入就永远读不到 |
//!
//! 关键在于:**`Rc<RefCell<..>>` 的每一处都是被「可观测性」逼出来的**,
//! 不是设计偏好。若某个状态只在层内使用,就不要共享它——
//! 少一个 `Rc` 就少一处「谁还持有它、为什么还没释放」的疑问。
//! 反过来,凡是体检/报表要看的数字,都必须共享,
//! 否则会出现「报表说缓存命中 0 次」这种看起来像业务结论、
//! 实际只是「读不到」的错误输出。
use std::cell::{Cell, Ref, RefCell};
use std::rc::Rc;
use crate::domain::WorkloadUnits;
use super::data_warehouse::OperatingDataWarehouse;
use super::query_request::OperatingQuery;
use super::subject::{OperatingDataSubject, QueryOutcome, SubjectDescriptor};
/// 虚拟代理与外部分享的可观测状态。
///
/// ## 为什么 `open_count` 是虚拟代理价值的**唯一证据**
///
/// 「全拒绝场景下真实主题创建 0 次」这句话,只有这个计数能证明。
/// 如果它留在代理内部,装配完成后就谁也看不到——
/// 而「第一次查询之前不创建」这个 Proxy 的核心承诺,
/// 恰恰必须在装配完成后的运行期才能观察。
///
/// ## `accumulated_open_workload_units` 与查询记账是两笔账
///
/// 装载代价通过 `pending_open_workload` 加到**触发的这一次查询**上
/// (那样报表里「哪一笔请求付出了装载成本」才有答案)。
/// 本字段另外单独累计一次,是为了回答「整个生命周期里
/// 一共为装载付了多少」——它在多次装配/多次实验的对照里才是可比的口径。
/// 两个数字用途不同,因此都要留下,而不是用一个推导另一个。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LazyObservation {
/// 真实主题被创建的总次数。
pub open_count: u32,
/// 累计付掉的装载工作量(各次装载之和)。
pub accumulated_open_workload_units: u64,
}
// ★ 已删除 `LazyObservation::never_opened()`:
// 「是否从未创建」用 `open_count == 0` 表达已经足够清楚,
// 多一个同义构造器只会让读者犹豫「该用哪个」。
// 派生 `Default` 已经给出了初值,不需要第二个入口。
/// 延迟初始化代理(虚拟代理)。
pub struct LazyInitializationProxy {
/// 真实主题的工厂(**只会被调用一次**)。
///
/// 用 `RefCell<Option<..>>` 包起来是为了能 `take()` 出所有权——
/// `FnOnce` 不能通过共享引用调用。
factory: RefCell<Option<Box<dyn FnOnce() -> OperatingDataWarehouse>>>,
/// 已创建的真实主题。
inner: RefCell<Option<Box<dyn OperatingDataSubject>>>,
/// 与外部分享的观测状态。
///
/// `Cell<LazyObservation>`:只做「读—改—写」,不交出内部借用。
/// `LazyObservation` 刻意做成 `Copy` 的,正是为了能用 `Cell`——
/// 本层已经有 `RefCell<Option<..>>`(必须交出内部借用才能调用真实主题),
/// **再叠一层 `RefCell` 只会增加嵌套借用撞车的风险**。
observation: Rc<Cell<LazyObservation>>,
/// 待记账的装载代价。
///
/// 初始化时被写入真实主题的装载单元数;**下一次 `query` 取走并清零**。
/// 清零是必须的:否则同一个 18,000 会在每一次查询里被重复计入,
/// 而报表上的工作负载总和会随查询次数线性膨胀——
/// 这种错误在数字上看起来只是「有点大」,很难被发现。
///
/// 它是**瞬态**数据(取走即清零),不是诊断口径,
/// 因此留在本层、不进共享观测结构体。
pending_open_workload: Cell<u64>,
}
impl LazyInitializationProxy {
/// 构造一个延迟初始化代理。
///
/// 参数 `factory`:真实主题的工厂闭包;
/// `observation`:与外部分享的观测状态句柄。
/// 返回:代理。
pub fn new(
factory: Box<dyn FnOnce() -> OperatingDataWarehouse>,
observation: Rc<Cell<LazyObservation>>,
) -> LazyInitializationProxy {
LazyInitializationProxy {
factory: RefCell::new(Some(factory)),
inner: RefCell::new(None),
observation,
// 尚未初始化,待记账为零。
pending_open_workload: Cell::new(0),
}
}
// ★ 已删除:`is_initialized()` 与 `observation()` 两个访问器。
//
// 两者都不可能被调用——调用方拿到的是 `Box<dyn OperatingDataSubject>`,
// 永远拿不到一个 `&LazyInitializationProxy`(链是嵌套 `Box`,外部够不到内层)。
//
// 「真实主题是否已创建」这个信息由 `LazyObservation::open_count` 表达
// (0 表示从未创建),报表侧的观测句柄在装配期注入并保留,
// 因此不需要在代理本身上再开一个窗口。
// 一个永远调不到的窗口只是死重量,还会误导读者以为「也许能拿到内层实例」。
/// 确保真实主题已创建,并返回其借用。
///
/// 返回 `Ref` 而不是 `&dyn Subject`:`RefCell` 借出的引用生命周期
/// 绑定在 `Ref` 上,无法作为裸引用返回(编译器会拒绝)。
/// 这不是限制而是保护——它保证调用期间借用一直有效。
fn ensure_initialized(&self) -> Ref<'_, Option<Box<dyn OperatingDataSubject>>> {
// 第一步:在**独立作用域**里判断是否需要初始化。
// 若把 `self.inner.borrow()` 与后面的 `borrow_mut()` 写在同一个
// 表达式里,前一个借用还没释放就尝试可变借用 → 运行期 panic。
let needs_initialization: bool = {
let guard = self.inner.borrow();
guard.is_none()
};
if needs_initialization {
// 第二步:取出工厂(只能取一次)。
let factory: Option<Box<dyn FnOnce() -> OperatingDataWarehouse>> =
self.factory.borrow_mut().take();
if let Some(factory) = factory {
// 第三步:调用工厂——**这里才是真正付装载代价的时刻**。
//
// 注意这一行不在任何 `RefCell` 借用期内:
// `factory()` 内部可能构造 `StoreCatalog`、读一批门店主数据,
// 若此时仍持有 `self.inner` 的借用,任何对 `inner` 的再访问
// 都会在运行期炸掉。**先取出来,再调用**,顺序不能反。
let warehouse: OperatingDataWarehouse = factory();
let open_units: u64 = warehouse.open_workload().units();
// 记录装载代价,留给下一次 `query` 取走(报表口径)。
self.pending_open_workload.set(open_units);
// 第四步:写回内部状态。
*self.inner.borrow_mut() = Some(Box::new(warehouse));
// 第五步:更新共享观测状态——
// 这是「虚拟代理确实生效」的证据,也是外部唯一能看到的事实。
// `get` 出来改、再 `set` 回去,因为 `Cell::get` 给的是副本。
let mut observed: LazyObservation = self.observation.get();
observed.open_count += 1;
observed.accumulated_open_workload_units += open_units;
self.observation.set(observed);
}
}
self.inner.borrow()
}
}
impl OperatingDataSubject for LazyInitializationProxy {
fn query(&self, request: &OperatingQuery) -> QueryOutcome {
// 借用真实主题期间,`Ref` 一直有效(见 `ensure_initialized`)。
let guard: Ref<'_, Option<Box<dyn OperatingDataSubject>>> = self.ensure_initialized();
let inner: &Box<dyn OperatingDataSubject> = match guard.as_ref() {
Some(subject) => subject,
// 工厂已被取走却仍无内部主题:理论上不可达(两者同时发生)。
// 不 panic:报表拿不到比拿到一份「空响应」更难排查。
None => {
return QueryOutcome::denied(
Vec::new(),
WorkloadUnits::zero(),
self.descriptor(),
)
}
};
let mut outcome: QueryOutcome = inner.query(request);
// 若本次调用刚刚触发了初始化,把装载代价记在**这一次**调用上。
// `replace(0)` 保证它只会被记一次。
let pending_open: u64 = self.pending_open_workload.replace(0);
if pending_open > 0 {
outcome.workload = outcome.workload.plus(&WorkloadUnits::from_units(pending_open));
}
// `served_by_layer` 保持不变:本次响应确实由真实主题产生,
// 本代理只是「让它晚一点出生」,并没有产生响应内容。
// 把它改成本层是**不诚实的**——那会让报表把装载延迟
// 误读成「响应来自虚拟代理」。
outcome
}
fn descriptor(&self) -> SubjectDescriptor {
// 深度 = 内层深度 + 1。真实主题为 0,故本层为 1。
// 用「内层深度 + 1」而不是硬编码,是为了让装配配方增删层时
// 所有深度自动跟上,不需要手工维护。
let inner_depth: usize = self
.inner
.borrow()
.as_ref()
.map(|subject| subject.descriptor().wrapping_depth)
.unwrap_or(0);
SubjectDescriptor::new("LAZY", "延迟初始化代理", inner_depth + 1)
}
fn role_description(&self) -> &'static str {
"真实主题在第一次被查询之前不创建;装载代价只在触发那一次调用上记账"
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : operating_report.rs
//! # 查询报表 —— 真实主题与各层代理共同操作的交付物
//!
//! ## 为什么报表类型定义在 proxy 层
//!
//! 它是 `OperatingDataSubject::query` 的返回内容,也就是**接口契约**的一部分。
//! 契约类型必须与被契约约束的双方(代理与真实主题)同层,
//! 否则要么代理依赖真实主题、要么真实主题依赖代理,两者都是错误方向。
//!
//! ## 报表是**不可变交付物**
//!
//! 保护代理会对它做脱敏(裁行、裁列)。关键约束是:
//! **脱敏必须产出一份新报表,绝不能修改真实主题返回的那一份**。
//! 理由有两个,第二个更隐蔽:
//!
//! 1. 真实主题的返回值若被就地修改,后续请求会看到被裁过的数据;
//! 2. **缓存代理存的是报表对象的副本**。若保护代理在缓存**外侧**
//! (正确顺序),缓存里存的是未脱敏的全量;若保护代理在缓存**内侧**
//! (错误顺序),缓存里存的就可能是被脱敏过的版本,
//! 于是下一个角色命中缓存时会拿到**别人的可见范围**。
//!
//! 第 2 点就是本工程第五幕演示的「代理顺序改变语义」。
//! 因此本文件的类型只提供只读访问器,
//! 脱敏通过 `RedactionRecord` + 构造新报表的方式完成。
//!
//! ## 汇总行的表示
//!
//! 只允许取汇总的角色(如外部供应商)拿到的是**单行**报表,
//! 该行的门店编码是 [`AGGREGATE_ROW_STORE_CODE`]。
//! 用一个不可能与真实门店编码冲突的哨兵值(前后双下划线),
//! 让「这是汇总行」在数据层面即可判定,
//! 而不需要额外传一个 `is_summary` 布尔值穿越所有报表代码。
use crate::domain::MetricKind;
use crate::support::CalendarDate;
/// 汇总行的门店编码哨兵值。
///
/// 真实门店编码形如 `LF0001`(字母数字),因此带下划线的哨兵值
/// 不可能与之冲突。这个「命名空间隔离」是本工程能安全地用
/// 单行表达汇总的前提。
pub const AGGREGATE_ROW_STORE_CODE: &str = "__AGGREGATE__";
/// 汇总行的门店名称。
pub const AGGREGATE_ROW_STORE_NAME: &str = "授权范围汇总";
/// 报表中的一行(一家门店某一天的指标值)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperatingRow {
/// 门店编码。汇总行为 [`AGGREGATE_ROW_STORE_CODE`]。
pub store_code: String,
/// 门店名称。
pub store_name: String,
/// 所属区域编码。
pub region_code: &'static str,
/// 数据日期。汇总行取区间结束日(代表「截至该日的区间累计」)。
pub date: CalendarDate,
/// 指标值,顺序与报表的 `metrics` 一一对应。
pub values: Vec<i64>,
}
impl OperatingRow {
/// 是否为汇总行。
pub fn is_aggregate(&self) -> bool {
self.store_code == AGGREGATE_ROW_STORE_CODE
}
/// 取某个指标下标上的值。
pub fn value_at(&self, metric_index: usize) -> Option<i64> {
self.values.get(metric_index).copied()
}
}
/// 一次脱敏的记录。
///
/// ## 为什么脱敏必须留下记录
///
/// 脱敏是**静默的减法**:报表交付出去以后,没有人能看出少了什么。
/// 若不留下记录,一次「指标白名单配错」导致的长期数据缺失
/// 可能几个月都不会被发现。因此本工程把「裁掉了什么」变成报表的一部分,
/// 并由体检规则检查它。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RedactionRecord {
/// 因门店不在授权范围而被裁掉的行数。
pub rows_dropped_by_store_scope: usize,
/// 因指标不在白名单而被裁掉的列数。
pub metrics_dropped_out_of_scope: usize,
/// 因敏感级超过权限上限而被裁掉的列数。
pub metrics_dropped_by_sensitivity: usize,
/// 因推理泄露而被额外裁掉的指标编码。
///
/// 这一项与前三项并列而不是合并:前三项是**按规则正常裁剪**,
/// 本项是**发现配置缺陷后的补救**。两者性质不同,
/// 混在一起会让体检无法区分「一切正常」与「配置有洞」。
pub metrics_dropped_by_inference_leak: Vec<&'static str>,
/// 是否因无明细权限而坍缩成汇总。
pub collapsed_to_summary: bool,
/// 坍缩前本可获得明细的门店数。
pub detail_store_count_before_collapse: usize,
}
impl RedactionRecord {
/// 是否发生过任何脱敏。
pub fn is_clean(&self) -> bool {
self.rows_dropped_by_store_scope == 0
&& self.metrics_dropped_out_of_scope == 0
&& self.metrics_dropped_by_sensitivity == 0
&& self.metrics_dropped_by_inference_leak.is_empty()
&& !self.collapsed_to_summary
}
/// 被裁掉的指标列总数。
pub fn dropped_metric_total(&self) -> usize {
self.metrics_dropped_out_of_scope
+ self.metrics_dropped_by_sensitivity
+ self.metrics_dropped_by_inference_leak.len()
}
/// 返回一句话摘要(供报表打印)。
pub fn summary_text(&self) -> String {
if self.is_clean() {
return "无脱敏".to_string();
}
let mut parts: Vec<String> = Vec::new();
if self.rows_dropped_by_store_scope > 0 {
parts.push(format!("裁行 {}", self.rows_dropped_by_store_scope));
}
if self.metrics_dropped_out_of_scope > 0 {
parts.push(format!("白名单外列 {}", self.metrics_dropped_out_of_scope));
}
if self.metrics_dropped_by_sensitivity > 0 {
parts.push(format!("敏感级超限列 {}", self.metrics_dropped_by_sensitivity));
}
if !self.metrics_dropped_by_inference_leak.is_empty() {
parts.push(format!(
"推理泄露补救列 {}",
self.metrics_dropped_by_inference_leak.len()
));
}
if self.collapsed_to_summary {
parts.push("坍缩为汇总".to_string());
}
parts.join("、")
}
}
/// 一次查询交付的报表。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperatingReport {
/// 指标列(顺序即列序)。
pub metrics: Vec<MetricKind>,
/// 数据行。
pub rows: Vec<OperatingRow>,
/// 区间开始日。
pub start_date: CalendarDate,
/// 区间结束日。
pub end_date: CalendarDate,
/// 脱敏记录。
pub redaction: RedactionRecord,
}
impl OperatingReport {
/// 构造一张报表。
pub fn new(
metrics: Vec<MetricKind>,
rows: Vec<OperatingRow>,
start_date: CalendarDate,
end_date: CalendarDate,
redaction: RedactionRecord,
) -> OperatingReport {
OperatingReport {
metrics,
rows,
start_date,
end_date,
redaction,
}
}
/// 指标列数。
pub fn metric_count(&self) -> usize {
self.metrics.len()
}
/// 数据行数。
pub fn row_count(&self) -> usize {
self.rows.len()
}
/// 是否为汇总报表(单行且是汇总行)。
pub fn is_summary(&self) -> bool {
self.rows.len() == 1 && self.rows[0].is_aggregate()
}
/// 全部指标编码。
pub fn metric_codes(&self) -> Vec<&'static str> {
self.metrics.iter().map(|metric| metric.code()).collect()
}
/// 查找某个指标编码对应的列下标。
pub fn metric_index(&self, metric_code: &str) -> Option<usize> {
self.metrics
.iter()
.position(|metric| metric.code() == metric_code)
}
/// 求某一列的合计。
///
/// 参数 `metric_code`:指标编码。
/// 返回:该列全部行之和;指标不存在时返回 `None`。
///
/// 用 `i128` 中间量累加再落回 `i64`:本工程的数据规模远不会溢出,
/// 但「报表合计」是最不该出错的一个数字,用更宽的中间类型只是几个字符的代价。
pub fn total_of(&self, metric_code: &str) -> Option<i64> {
let index: usize = self.metric_index(metric_code)?;
let mut total: i128 = 0;
for row in &self.rows {
if let Some(value) = row.value_at(index) {
total += value as i128;
}
}
Some(crate::domain::clamp_i128_to_i64(total))
}
/// 返回 `2026-09-01~2026-09-30` 形式的区间文本。
pub fn range_text(&self) -> String {
format!("{}~{}", self.start_date.formatted(), self.end_date.formatted())
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : query_request.rs
//! # 查询请求 —— 进代理链的那张「工单」
//!
//! ## 为什么请求要携带「调用方所属门店」
//!
//! 授权判断需要两样东西:**角色能看什么**(角色属性)
//! 与**调用方在哪里**(会话属性)。
//! 「门店店员」这个角色在 120 家门店是同一份定义,
//! 但每家店的店员只能看**自己那家**。
//! 因此所属门店必须由请求携带,而不是写进角色。
//!
//! 这一点直接决定了请求结构体的形态:它同时承载
//! **业务条件**(查什么)与**身份上下文**(谁在查)。
//! 有些设计会把两者拆成两个参数传递(`query(request, identity)`),
//! 但那样每一次转发都要求两个参数同时被正确传递——
//! 任何一层代理漏传或传错 `identity`,鉴权就会静默失效。
//! 收进一个结构体后,转发只有一条路径,漏传在类型层面不可能发生。
//!
//! ## 为什么请求里存的是「编码」而不是「对象」
//!
//! 门店用 `Vec<String>` 存编码、指标用 `Vec<&'static str>` 存编码,
//! 而不是直接存 `Vec<StoreCode>` / `Vec<MetricKind>`。
//! 原因是:**请求是外部输入**,它可能包含系统里根本不存在的编码。
//! 存编码后,「这个编码是否存在」变成一个可以被显式检查的问题
//! (`unknown_store_codes()`),并产生一条可读的拒绝原因。
//! 若在构造请求时就把编码解析成对象,那些非法的编码只能在构造时 panic
//! 或者被静默丢弃——前者让报表拿不到,后者让用户以为查成功了。
use crate::domain::{AccessRole, MetricKind, BUILTIN_METRICS};
use crate::support::CalendarDate;
/// 一次经营数据查询请求。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OperatingQuery {
/// 发起查询的角色。
role: AccessRole,
/// 调用方所属门店编码。
///
/// `None` 表示「这是一个不归属任何门店的调用方」(如总部系统账号、
/// 供应商网关)。对 `HomeStoreOnly` 角色而言,`None` 会导致鉴权失败——
/// **缺省即拒绝**,这个方向不能反。
caller_home_store_code: Option<String>,
/// 请求的门店编码列表。
requested_store_codes: Vec<String>,
/// 请求的指标编码列表。
requested_metric_codes: Vec<&'static str>,
/// 区间开始日。
start_date: CalendarDate,
/// 区间结束日。
end_date: CalendarDate,
}
impl OperatingQuery {
/// 构造一个查询请求。
///
/// 参数依次为:角色、所属门店编码、门店编码列表、指标编码列表、
/// 区间开始日、区间结束日。
/// 返回:请求。
///
/// 构造函数**不做任何合法性检查**——检查归保护代理。
/// 理由:若构造函数拒绝非法请求,那么「非法请求长什么样」
/// 就无法在演示里被观察到;而本工程第七幕恰恰要演示
/// 「一批非法请求如何被一次报全」。校验发生在**使用点**(代理链入口),
/// 而不是**构造点**,才能让这些错误可被观测。
pub fn new(
role: AccessRole,
caller_home_store_code: Option<String>,
requested_store_codes: Vec<String>,
requested_metric_codes: Vec<&'static str>,
start_date: CalendarDate,
end_date: CalendarDate,
) -> OperatingQuery {
OperatingQuery {
role,
caller_home_store_code,
requested_store_codes,
requested_metric_codes,
start_date,
end_date,
}
}
/// 发起查询的角色。
pub const fn role(&self) -> AccessRole {
self.role
}
/// 调用方所属门店编码。
pub fn caller_home_store_code(&self) -> Option<&str> {
self.caller_home_store_code.as_deref()
}
/// 请求的门店编码列表。
pub fn requested_store_codes(&self) -> &[String] {
&self.requested_store_codes
}
/// 请求的指标编码列表。
pub fn requested_metric_codes(&self) -> &[&'static str] {
&self.requested_metric_codes
}
/// 区间开始日。
pub const fn start_date(&self) -> CalendarDate {
self.start_date
}
/// 区间结束日。
pub const fn end_date(&self) -> CalendarDate {
self.end_date
}
/// 查询区间的天数(闭区间口径)。
pub fn span_days(&self) -> u32 {
self.start_date.span_days_inclusive(&self.end_date)
}
/// 区间是否合法(结束日不早于开始日)。
pub fn has_valid_date_range(&self) -> bool {
self.end_date.days_from(&self.start_date) >= 0
}
/// 把请求的指标编码解析成指标对象,**忽略无法识别的编码**。
///
/// 返回:按请求顺序排列的已识别指标。
///
/// 无法识别的编码由 [`unknown_metric_codes`](Self::unknown_metric_codes)
/// 单独报告,而不是在本方法里被吞掉——
/// 「解析结果」与「无法解析的输入」是两条独立信息,
/// 混在一起输出会让调用方无法区分「请求没给」与「请求给了但系统不认识」。
pub fn resolved_metrics(&self) -> Vec<MetricKind> {
self.requested_metric_codes
.iter()
.filter_map(|code| {
BUILTIN_METRICS
.iter()
.copied()
.find(|metric| metric.code() == *code)
})
.collect()
}
/// 请求中系统无法识别的指标编码(去重后排序)。
pub fn unknown_metric_codes(&self) -> Vec<&'static str> {
let mut unknown: Vec<&'static str> = self
.requested_metric_codes
.iter()
.copied()
.filter(|code| {
!BUILTIN_METRICS
.iter()
.any(|metric| metric.code() == *code)
})
.collect();
unknown.sort_unstable();
unknown.dedup();
unknown
}
/// 返回一份用于生成稳定排序文本的键。
///
/// 供报表按「角色 + 区间 + 门店数」排序使用。
/// 不参与缓存(缓存键在缓存代理里单独构造,因为缓存键
/// **必须显式决定是否包含角色**——那是本工程第五幕的核心论点)。
pub fn report_sort_key(&self) -> String {
format!(
"{}|{}|{}|{:03}",
self.role.code(),
self.start_date.formatted(),
self.end_date.formatted(),
self.requested_store_codes.len()
)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : result_cache_proxy.rs
//! # 结果缓存代理 —— 相同查询不再重复计算
//!
//! ## 它在链上的位置为什么是本工程的核心论点
//!
//! 缓存代理只做一件事:**把「查询 → 结果」的映射记下来**。
//! 但「记下来的那一份结果是什么」完全取决于它**站在链的哪一层**。
//!
//! 理解这一点的关键是先看清保护代理的脱敏机制:
//! 它**不是**「算完之后裁掉几列」,而是**先把请求收窄**
//! (剔除范围外门店、剔除非白名单与超敏感级指标),
//! 再把收窄后的请求转给内层。因此——
//!
//! ### 站在保护代理**内侧**(靠近真实主题):正确
//!
//! 它拿到的是**收窄后的请求**。于是:
//! - 缓存键天然只含「收窄后的门店与指标」,**不需要角色**;
//! - 两个不同角色若收窄出同一个请求(例如请求的都是双方共有指标),
//! 就命中**同一条**缓存,可以安全地跨角色复用——
//! 它们本来就会得到一模一样的结果;
//! - 保护代理在返回路上会把脱敏记录重写成**本次请求**的,
//! 因此命中者不会读到别人的脱敏记录。
//!
//! ### 站在保护代理**外侧**(靠近调用方):危险
//!
//! 它拿到的是**原始请求**,存的是保护代理**已经处理完的响应**。于是:
//! - 两个角色的原始请求相同、但可见范围不同时,会命中同一条缓存,
//! **后一个角色拿到前一个角色的可见子集**;
//! - 键里**必须**包含角色,否则一定串味——但加了角色以后,
//! 缓存条目数按角色数放大,且同一份数据被重复存了很多遍。
//!
//! 两条都能「跑通」,也都能「命中缓存」,但语义完全不同。
//! 因此「缓存要放在哪一层」不是性能调优问题,而是**正确性问题**。
//! 本工程第六幕把三种装配(内侧 / 外侧不含角色 / 外侧含角色)
//! 跑同一批请求,用「交付列数」这一个数字把差异摆出来。
//!
//! ## 一个容易被忽略的次要结论:缓存命中还能省掉跨区往返
//!
//! 缓存必须位于**远程代理外侧**(也就是被更早地咨询),
//! 否则每一次命中都要先付一次 300 单元的跨区往返——
//! 那会让「缓存省下的负载」这个数字明显偏小,
//! 而且这个偏差不会有任何报错,只会让结论偏保守。
//!
//! ## 本代理不猜自己在哪一层
//!
//! 它忠实缓存「收到的什么就存什么」。把「该不该含角色」做成
//! 一个显式开关([`include_role_in_key`](ResultCacheProxy::new)),
//! 装配方必须主动表态,而不是靠代理悄悄猜。
//! 一个会「自动判断该不该含角色」的缓存代理看起来更聪明,
//! 但它把一个安全决策藏进了实现细节——那正是最危险的地方。
//!
//! ## 缓存条目要能被打印出来
//!
//! 本代理提供 [`CacheObservation::entries`]:
//! 逐条列出「键、行数、**列数、具体的指标编码**」。
//! 有了指标编码,「店员命中了含客单价的条目」这句话才是可核对的,
//! 而不是靠推断。若只提供命中率,这个问题根本无法回答。
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use crate::domain::WorkloadUnits;
use super::operating_report::OperatingReport;
use super::query_request::OperatingQuery;
use super::subject::{OperatingDataSubject, QueryOutcome, ServingOrigin, SubjectDescriptor};
/// 缓存键。
///
/// ## 为什么用「编码字符串列表」而不是对象
///
/// 键必须 `Eq + Hash` 可靠。门店用编码字符串(而不是 `StoreCode`,
/// 它含区域标签与展示名——那些字段与「这次查询要什么」无关,
/// 放进键只会让两个语义相同的查询产生不同的键)。
///
/// 指标同理,用 `&'static str` 编码。
///
/// ## 列表必须**排序后**入键
///
/// 请求里写 `["FOOTFALL", "REVENUE"]` 与 `["REVENUE", "FOOTFALL"]`
/// 是同一个查询。若不排序,两次书写会得到两个键、两次真实计算——
/// 缓存形同虚设,而且这种失效率**不会体现在命中率上被察觉**
/// (命中率低看起来只是「缓存效果一般」)。
///
/// ## ⚠️ 键里刻意**不含**门店的展示名、区域、也不含区间天数
///
/// 它们要么是无关字段(展示名),要么可由已有字段推出(天数 ← 起止日)。
/// 把可推导的字段放进键,是最隐蔽的「缓存永不命中」成因:
/// 命中率会下降,而任何一条日志都发现不了原因。
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct CacheKey {
/// 角色编码。`None` 表示「本缓存的键不含角色」。
///
/// 用 `Option` 而不是「空字符串哨兵」:`None` 与「有个叫空串的角色」
/// 在类型上不可混淆。缓存是否含角色是一个**必须显式表态**的决策。
role_code: Option<&'static str>,
/// 门店编码(已排序去重)。
store_codes: Vec<String>,
/// 指标编码(已排序)。
metric_codes: Vec<&'static str>,
/// 区间开始日文本。
start_date_text: String,
/// 区间结束日文本。
end_date_text: String,
}
impl CacheKey {
/// 由请求构造键。
///
/// 参数 `request`:查询请求;`include_role`:是否把角色并入键。
/// 返回:缓存键。
fn from_request(request: &OperatingQuery, include_role: bool) -> CacheKey {
// 门店编码排序去重:请求里的顺序、重复都不该影响键。
let mut store_codes: Vec<String> = request.requested_store_codes().to_vec();
store_codes.sort();
store_codes.dedup();
// 指标编码排序:顺序无关。
let mut metric_codes: Vec<&'static str> = request.requested_metric_codes().to_vec();
metric_codes.sort_unstable();
metric_codes.dedup();
CacheKey {
role_code: if include_role {
Some(request.role().code())
} else {
None
},
store_codes,
metric_codes,
start_date_text: request.start_date().formatted(),
end_date_text: request.end_date().formatted(),
}
}
/// 返回可读的键文本(供报表打印缓存内容)。
fn formatted(&self) -> String {
format!(
"角色={} 门店={} 指标={} 区间={}~{}",
self.role_code.unwrap_or("(未含)"),
self.store_codes.len(),
self.metric_codes.join("+"),
self.start_date_text,
self.end_date_text
)
}
}
/// 缓存计数。
///
/// 四个计数**分开累加、绝不用减法推导**:
/// 本工程后续版本可能加入「容量拒绝」这条路径,
/// 那时 `查找数 - 命中数 ≠ 未命中数`(还有被拒的)。
/// 减法推导在那一刻起就静默失真。
/// 这条纪律来自同系列 Flyweight 工程的池计数。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CacheCounters {
/// 查找次数。
pub lookup_count: usize,
/// 命中次数。
pub hit_count: usize,
/// 未命中次数(未命中后会转发给内层)。
pub miss_count: usize,
/// 因容量上限被拒绝写入的次数。
pub eviction_reject_count: usize,
}
impl CacheCounters {
/// 命中率(万分点)。查找数为零时返回零。
pub fn hit_ratio_basis_points(&self) -> i64 {
if self.lookup_count == 0 {
return 0;
}
(self.hit_count as i64) * 10_000 / (self.lookup_count as i64)
}
}
/// 一条缓存内容的摘要(供报表打印「缓存里到底存了什么」)。
///
/// ## ⚠️ 这里为什么**不**记录「是否被脱敏」
///
/// 一个直觉上很有价值的字段是 `was_redacted`(这条缓存内容是不是
/// 某个角色的可见子集)。但它是一个**误导性**字段,理由有两条:
///
/// 1. **脱敏记录属于单次请求,不属于缓存内容。** 保护代理在返回路上
/// 会把 `report.redaction` 重写成本次请求的记录。因此缓存里存的那份
/// 记录只是「第一位请求者看到的」,对后续命中者完全无效。
/// 把它当成缓存载荷的性质读,就会得出错误结论。
/// 2. **真正该回答的问题可以由结构回答。** 「缓存是否按角色隔离」
/// 是层顺序问题(缓存与保护代理谁在外),分析层的顺序规则
/// 直接核对层深度即可判定,不需要在每个条目上带一个布尔标记。
///
/// 因此摘要只记录**缓存内容本身**的事实:键、行数、指标列。
/// 「这一条是谁查出来的、当时被裁掉了什么」不在这里回答。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CacheEntrySummary {
/// 键的可读文本。
pub key_text: String,
/// 存下来的报表行数。
pub row_count: usize,
/// 存下来的报表列数。
pub metric_count: usize,
/// 存下来的列的具体指标编码(按报表列序)。
///
/// 有了它,对照实验就能直接把「某次请求命中的条目里有哪几列」
/// 印出来——「店员命中了含客单价的条目」这句话因此是可核对的,
/// 而不是靠推断。
pub metric_codes: Vec<&'static str>,
}
/// 缓存策略快照。
///
/// ## 为什么把「构造参数」也放进观测
///
/// 体检要检查的一条规则是:**缓存位于保护代理外侧时,键必须含角色**。
/// 而「键是否含角色」是构造时决定的,属于配置而非运行数据。
///
/// 装配完成后,缓存代理被包在若干层 `Box` 里,外部**既读不到它的
/// 运行数据,也读不到它的配置**。于是这条规则在装配后无法检查——
/// 除非配置也在装配期交出去一份。
///
/// 这与「可观测性必须在装配期注入」是同一条理由,
/// 只是被忽略的往往是「配置也算可观测状态」。一条无法被检查的规则
/// 等于不存在,因此这里把配置和计数一起交出去。
///
/// ⚠️ 它是一份**快照**,不会被后续运行修改。代理在构造时写入一次。
/// 若两个缓存代理共享同一个观测句柄(本工程不会这么做),
/// 后写入的策略会覆盖前一个——因此每个装配器各持一套句柄。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CachePolicy {
/// 缓存键是否包含角色编码。
pub include_role_in_key: bool,
/// 容量上限。
pub maximum_entry_count: usize,
}
/// 缓存代理与外部分享的可观测状态。
///
/// ## 为什么需要这个结构体(这是 Proxy 的一个结构性难题)
///
/// 代理链是**嵌套的 `Box`**:装配完成后,缓存代理被包在保护代理里、
/// 保护代理被包在远程代理里……外部只拿到最外层的一个
/// `Box<dyn OperatingDataSubject>`,**够不到任何内层**。
///
/// 于是「缓存命中率是多少」「缓存里存的是哪个口径的结果」
/// 这些问题在装配完成后就无法回答了。这不是本工程特有的问题,
/// 而是所有链式代理实现的共同难题。
///
/// 解法:让需要被观测的状态**在装配时以共享句柄注入**,
/// 装配方保留一份 `Rc` 克隆。本代理只往自己的字段里写,
/// 不碰别人的——这样即使多个代理共享同一个 `Rc<RefCell<..>>`,
/// 也不会出现嵌套借用。
///
/// ## 条目为什么按「加入顺序」存而不是按键排序
///
/// 排序职责归**读者**(分析层),不归写入方。理由:写入方每插入一条
/// 就排一次序是 O(n² log n),而读者只需要排一次。
/// 更重要的是:把顺序留下,读者才有可能发现「顺序本身就是信息」
/// (例如第一次装载是哪一条查询触发的)。
#[derive(Debug, Clone, Default)]
pub struct CacheObservation {
/// 装配时确定的策略快照。
pub policy: CachePolicy,
/// 计数。
pub counters: CacheCounters,
/// 缓存内容摘要(按加入顺序;读取方会自行排序)。
pub entries: Vec<CacheEntrySummary>,
}
impl CacheObservation {
/// 条目数。
pub fn entry_count(&self) -> usize {
self.entries.len()
}
/// 全部条目的键文本(已排序,供报表稳定输出)。
///
/// 排序在**读者**这一侧做:写入方保持插入顺序(那是事实),
/// 读者按需排序(那是呈现要求)。两件事分开,各自都能被单独验证。
pub fn sorted_key_texts(&self) -> Vec<String> {
let mut keys: Vec<String> = self
.entries
.iter()
.map(|entry| entry.key_text.clone())
.collect();
keys.sort();
keys
}
}
/// 结果缓存代理。
pub struct ResultCacheProxy {
/// 内层主体。
inner: Box<dyn OperatingDataSubject>,
/// 缓存体。
///
/// 这个 `HashMap` **不对外共享**:它的键类型是私有的,
/// 且外部只需要「有多少条、每条多大、是否被脱敏」,
/// 那三项已经由 `observation.entries` 提供。
/// 共享最小必要状态,是减少耦合的具体做法。
cache: RefCell<HashMap<CacheKey, OperatingReport>>,
/// 与外部共享的可观测状态。
observation: Rc<RefCell<CacheObservation>>,
/// 缓存键是否包含角色。
include_role_in_key: bool,
/// 缓存容量上限。
maximum_entry_count: usize,
}
impl ResultCacheProxy {
/// 构造缓存代理。
///
/// 参数 `inner`:内层主体;`include_role_in_key`:是否把角色并入缓存键;
/// `maximum_entry_count`:容量上限。
/// 返回:代理。
///
/// `include_role_in_key` 是一个**必须显式给出**的决策:
/// 当本代理位于保护代理**外侧**时必须为 `true`(否则串味);
/// 位于**内侧**时应为 `false`(可以安全地跨角色复用)。
/// 装配方若给错,体检规则 `CACHE_KEY_MUST_INCLUDE_ROLE_WHEN_OUTSIDE_GUARD`
/// 会报出来。
pub fn new(
inner: Box<dyn OperatingDataSubject>,
include_role_in_key: bool,
maximum_entry_count: usize,
observation: Rc<RefCell<CacheObservation>>,
) -> ResultCacheProxy {
// 先把装配决策写进共享观测——体检要在装配**之后**才能读到它。
{
let mut shared = observation.borrow_mut();
shared.policy = CachePolicy {
include_role_in_key,
maximum_entry_count,
};
}
ResultCacheProxy {
inner,
cache: RefCell::new(HashMap::new()),
observation,
include_role_in_key,
maximum_entry_count,
}
}
// ★ 已删除:`include_role_in_key()` / `maximum_entry_count()` /
// `observation()` / `clear()` 四个访问器。
//
// ## 为什么删而不是留着
//
// 这四项都**不可能被调用**:调用方拿到的永远是
// `Box<dyn OperatingDataSubject>`(链是嵌套 `Box`,外部够不到内层),
// 谁都得不到一个 `&ResultCacheProxy`。
// 一个永远调不到的方法不是 API,只是死重量——它还会让读者以为
// 「也许有办法拿到内层实例」。
//
// 真正需要这些信息的地方走 `CacheObservation`:
// 策略在 `observation.policy` 里(装配期注入),计数在
// `observation.counters` 里,内容在 `observation.entries` 里。
//
// ## `clear()` 的删除更值得记一笔
//
// 它原本的用途是「对照实验重复使用同一批请求前先清干净」。
// 但本工程后来把对照实验改成了**每个实验臂各新建一条链**
// (见 `analysis::order_experiment::run_arm`),
// 于是「清空复用」这条路径根本不存在了,脏状态问题也从
// 「要记得清」变成了「结构上不可能发生」。
//
// 这是一次**正确方向的重构留下的死代码**:问题被消灭之后,
// 为解决问题而写的代码就该跟着消失,否则它会误导后来者
// 以为还存在「复用一条链」这种用法。
}
impl OperatingDataSubject for ResultCacheProxy {
fn query(&self, request: &OperatingQuery) -> QueryOutcome {
let key: CacheKey = CacheKey::from_request(request, self.include_role_in_key);
// ---------- 第一步:查表(借用范围严格限制在花括号内)----------
//
// 为什么要单独开一个作用域?因为 `RefCell` 的借用是**运行期**检查的:
// 若在持有 `cache.borrow()` 的同时去 `self.inner.query(request)`,
// 而内层某个代理又碰巧访问了同一块缓存,就会在运行期 panic。
// 把「取值」和「用值」分成两步,借用只在第一步存在。
let cached: Option<OperatingReport> = {
let cache = self.cache.borrow();
cache.get(&key).cloned()
};
// ---------- 第二步:记账(查找 +1,并判定命中/未命中)----------
//
// 注意这里**只借一次、只改一次**:把「查找数 +1」和「命中数 +1」
// 合并到同一个借用会话里。若分成两次 `borrow_mut()`,
// 中途任何一次内层调用都会把计数写成「查找了但没判定」的中间态。
//
// 也不需要 `drop(observation)` 之类的手工释放:
// 下面 `if let Some(report) = cached` 分支里的返回值构造
// 不再触碰 `observation`,所以借用会在该语句结束时自动结束。
let is_hit: bool = {
let mut observation = self.observation.borrow_mut();
observation.counters.lookup_count += 1;
if cached.is_some() {
observation.counters.hit_count += 1;
true
} else {
observation.counters.miss_count += 1;
false
}
};
debug_assert!(
is_hit == cached.is_some(),
"命中标记与缓存查询结果不一致:这一定是记账逻辑写错了"
);
if is_hit {
// 命中:**不向下转发**,工作负载为零。
let report: OperatingReport =
cached.expect("命中分支必然持有报表;若为 None 说明上面的 is_hit 判定有误");
return QueryOutcome {
report: Some(report),
denials: Vec::new(),
// 关键:命中的调用不产生任何后端负载。
// 这正是缓存代理的价值,也是它与「虚拟代理」的分工——
// 虚拟代理省的是**一次性的装载**,缓存省的是**重复的查询**。
workload: WorkloadUnits::zero(),
served_by: ServingOrigin::CacheServed,
served_by_layer: self.descriptor(),
};
}
// ---------- 第三步:未命中 → 转发 ----------
let outcome: QueryOutcome = self.inner.query(request);
// 只缓存成功的、非空结果。被拒绝的响应**不入缓存**:
// 拒绝原因是随角色/参数而变的瞬态信息,
// 缓存它会让「换一个人来问」拿到别人的拒绝原因。
if let Some(report) = outcome.report.as_ref() {
let stored: Option<CacheEntrySummary> = {
let mut cache = self.cache.borrow_mut();
if cache.len() < self.maximum_entry_count {
cache.insert(key.clone(), report.clone());
Some(CacheEntrySummary {
key_text: key.formatted(),
row_count: report.row_count(),
metric_count: report.metric_count(),
metric_codes: report.metric_codes(),
})
} else {
// 容量已满:不驱逐、不覆盖,直接不写。
// 用「不写」而不是 LRU 驱逐,是为了让行为**确定且可复现**——
// LRU 需要访问序,而访问序会让两次运行的结果依赖时序。
// 本工程的验收标准之一是两次运行逐字节一致,
// 任何引入时序的淘汰策略都会破坏它。
None
}
};
// 借用已在上面的花括号内结束,这里再借一次写观测状态。
let mut observation = self.observation.borrow_mut();
match stored {
Some(summary) => observation.entries.push(summary),
None => observation.counters.eviction_reject_count += 1,
}
}
outcome
}
fn descriptor(&self) -> SubjectDescriptor {
SubjectDescriptor::new(
"CACHE",
"结果缓存代理",
self.inner.descriptor().wrapping_depth + 1,
)
}
fn role_description(&self) -> &'static str {
"按查询条件缓存结果,命中时不再向下转发(工作负载为零)"
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : store_catalog.rs
//! # 门店主数据 —— 轻量的门店清单
//!
//! ## 为什么必须与「经营明细」分成两个东西
//!
//! 本工程虚拟代理要证明的价值是「**没被真正查询之前,不付装载代价**」。
//! 但代理链上有另一件事需要门店清单:保护代理判断
//! 「请求的门店编码是否存在」「它属于哪个区域」。
//!
//! 若门店清单也要靠打开数据仓库才能拿到,那么虚拟代理的价值当场归零——
//! 每一次请求(哪怕随后会被拒绝)都得先把仓库打开。
//!
//! 因此在真实系统里天然存在的分工,在此处被显式建模:
//!
//! | 数据 | 规模 | 获取代价 | 谁需要 |
//! |---|---|---|---|
//! | **门店主数据**(编码/名称/区域) | 120 条 | 极低 | 保护代理(校验与解析) |
//! | **经营明细**(门店 × 日 × 指标) | 18,000 个交叉点 | 高 | 真实主题(实际算数) |
//!
//! 这不是为了演示而编造的区分。真实的企业系统里,
//! 门店主数据通常在**配置库**,经营明细在**分析库**,
//! 两者的可用性与访问代价差好几个数量级。把这条区分做进结构里,
//! 虚拟代理才能既保护住昂贵的明细,又不妨碍轻量的校验。
use std::collections::HashMap;
use crate::domain::StoreCode;
/// 门店主数据。
#[derive(Debug, Clone)]
pub struct StoreCatalog {
/// 全部门店,按编码升序。
///
/// **排序存储**是有意的:报表要按编码稳定输出,
/// 而「遍历顺序决定输出顺序」是本工程反复踩到的坑。
/// 在这里排好,下游全部受益。
stores: Vec<StoreCode>,
/// 编码 → 下标。
index: HashMap<String, usize>,
}
impl StoreCatalog {
/// 由门店列表构造。
///
/// 参数 `mut stores`:门店列表(本函数会就地把它们排序)。
/// 返回:主数据。
pub fn new(mut stores: Vec<StoreCode>) -> StoreCatalog {
// 先排序再建索引:保证输入顺序不影响任何输出。
stores.sort_by(|left, right| left.code().cmp(right.code()));
let mut index: HashMap<String, usize> = HashMap::new();
for (position, store) in stores.iter().enumerate() {
index.insert(store.code().to_string(), position);
}
StoreCatalog { stores, index }
}
/// 全部门店(按编码升序)。
pub fn stores(&self) -> &[StoreCode] {
&self.stores
}
/// 门店数量。
pub fn len(&self) -> usize {
self.stores.len()
}
/// 是否为空。
pub fn is_empty(&self) -> bool {
self.stores.is_empty()
}
/// 按编码查找门店。
pub fn find(&self, code: &str) -> Option<&StoreCode> {
self.index.get(code).map(|position| &self.stores[*position])
}
/// 编码是否存在。
pub fn contains(&self, code: &str) -> bool {
self.index.contains_key(code)
}
/// 把这些编码解析成门店,**忽略不存在的编码**。
///
/// 参数 `codes`:门店编码列表。
/// 返回:已识别的门店(按编码升序)。
///
/// 无法识别的编码由调用方另行报告(保护代理会把它变成一条拒绝原因)。
/// 「解析结果」与「无法解析的输入」分开返回,是本工程的通用做法——
/// 合并输出就再也分不清「没请求」与「请求了但不认识」。
pub fn resolve_all(&self, codes: &[String]) -> Vec<StoreCode> {
let mut resolved: Vec<StoreCode> = codes
.iter()
.filter_map(|code| self.find(code).cloned())
.collect();
// 去重后按编码排序:请求里重复写同一个门店是常见的手滑,
// 而重复会让「预估行数」翻倍,进而误触发行数上限。
resolved.sort_by(|left, right| left.code().cmp(right.code()));
resolved.dedup_by(|left, right| left.code() == right.code());
resolved
}
/// 找出这些编码里系统不认识的部分(去重后升序)。
pub fn unknown_codes(&self, codes: &[String]) -> Vec<String> {
let mut unknown: Vec<String> = codes
.iter()
.filter(|code| !self.contains(code))
.cloned()
.collect();
unknown.sort();
unknown.dedup();
unknown
}
/// 全部门店编码(升序)。
pub fn all_codes(&self) -> Vec<String> {
self.stores.iter().map(|store| store.code().to_string()).collect()
}
/// 统计每个区域的门店数(按区域编码升序输出)。
pub fn count_by_region(&self) -> Vec<(&'static str, usize)> {
let mut tally: HashMap<&'static str, usize> = HashMap::new();
for store in &self.stores {
*tally.entry(store.region().code()).or_insert(0) += 1;
}
// 出参必须排序:HashMap 的遍历顺序随哈希种子变化,
// 直接返回会让两次运行的报表行序不同。
let mut pairs: Vec<(&'static str, usize)> = tally.into_iter().collect();
pairs.sort_by(|left, right| left.0.cmp(right.0));
pairs
}
}
impl PartialEq for StoreCatalog {
fn eq(&self, other: &StoreCatalog) -> bool {
// 只比较门店列表:索引是派生数据,两者由同一个列表唯一决定。
self.stores == other.stores
}
}
impl Eq for StoreCatalog {}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : subject.rs
//! # 抽象主题 —— 代理与真实主题的共同接口
//!
//! ## 这个 trait 是整个模式的支点
//!
//! 代理模式能成立,靠的是**调用方只依赖这个 trait**:
//! 它拿到的是 `Box<dyn OperatingDataSubject>`,而链上有几层、
//! 每层做了什么,它一概不知。
//!
//! 因此有一件必须做到的事:**`main.rs`(调用方所在的工程外代码)
//! 里不得出现真实主题的类型名 `OperatingDataWarehouse`**。
//! 这不是风格偏好,而是可静态检查的红线——
//! 一旦调用方需要写出真实主题的类型名才能构造链,
//! 就说明「真实主题的生命周期由代理掌握」这一 Proxy 的决定性特征已经丢失,
//! 模式退化成了 Decorator(由调用方创建被装饰对象并传入)。
//! 本工程第三幕用两条**都能编译通过**的写法对照这一点。
//!
//! ## 为什么 `query` 取 `&self` 而不是 `&mut self`
//!
//! 链上的每一层都可能需要**在查询过程中改变自身状态**:
//! 缓存代理要写入缓存、审计代理要追加日志、虚拟代理要初始化真实主题。
//! 若 `query` 取 `&mut self`,那么:
//! - 整条链会变成 `&mut` 独占,无法被多个调用方共享;
//! - 每一层转发时都要拿到内层的 `&mut`,而 `Box<dyn Subject>` 的
//! 可变借用会让「同一层同时被两个请求使用」在编译期就不成立。
//!
//! 因此接口取 `&self`,需要改状态的地方用**内部可变性**
//! (`RefCell` / `Cell`)——这是单线程场景下的正当用法。
//! 这与同系列 Facade 工程的做法一致,区别是本工程要用到
//! **三种不同的**内部可变性载体(见各代理文件头部说明)。
//!
//! ## 为什么返回 `QueryOutcome` 而不是 `Result`
//!
//! 一次查询的「结果」不止「成功/失败」两态,它还要携带:
//! - 这次结果**由哪一层产生**(缓存命中 / 真实计算 / 未进入被拒绝);
//! - 花了多少**工作负载**;
//! - 被拒绝了哪些规则(可能多条)。
//!
//! 把这些塞进 `Result<Report, Error>` 会迫使调用方从 `Err` 里反解
//! 「是拒绝还是异常」,而「拒绝」是**正常的业务结果**,不是错误。
//! 用一个结构体显式表达,报表才能把「拒绝率」当成一等指标来统计。
use crate::domain::WorkloadUnits;
use super::access_journal::AccessDenial;
use super::operating_report::OperatingReport;
use super::query_request::OperatingQuery;
/// 一次查询的响应来源。
///
/// 用封闭枚举:它决定报表的统计分支,且种类有限。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ServingOrigin {
/// 由真实主题**实际计算**得出(可能经过了各层代理的加工)。
RealComputation,
/// 由缓存直接返回,**没有进入真实主题**。
CacheServed,
/// 在进入真实主题之前就被拒绝(鉴权失败或参数非法)。
DeniedBeforeSubject,
}
impl ServingOrigin {
/// 中文标签。
pub const fn label(&self) -> &'static str {
match self {
ServingOrigin::RealComputation => "真实计算",
ServingOrigin::CacheServed => "缓存命中",
ServingOrigin::DeniedBeforeSubject => "未达真实主题",
}
}
/// 报表用的短标记。
pub const fn marker(&self) -> &'static str {
match self {
ServingOrigin::RealComputation => "R",
ServingOrigin::CacheServed => "C",
ServingOrigin::DeniedBeforeSubject => "X",
}
}
}
/// 一层代理的自我描述。
///
/// ## 为什么需要「距真实主题的层数」
///
/// 它是**代理链深度**的可观测形式。调用方只看到 `Box<dyn Subject>`,
/// 无法知道链上有几层;而「链有几层」正是装配配方的直接体现。
/// 有了这个字段,报表就能打印「本次响应来自第 3 层(自外向内)的缓存代理」,
/// 从而把「代理顺序」这个抽象概念变成可核对的数字。
///
/// 计数方向约定:真实主题为 0,每向外包一层加 1。
/// 选择「自内向外」而不是「自外向内」计数,是因为**真实主题是唯一的锚点**——
/// 装配配方可以增删任意中间层,真实主题永远在链尾且深度为 0。
/// 若按自外向内计数,增删一层会让所有既有层的编号漂移。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SubjectDescriptor {
/// 层编码(如 `"CACHE"`),用于报表的稳定比较。
pub layer_code: &'static str,
/// 层名称(如 `"结果缓存代理"`),仅用于展示。
pub layer_name: &'static str,
/// 距真实主题的层数(真实主题为 0)。
pub wrapping_depth: usize,
}
impl SubjectDescriptor {
/// 构造。
pub const fn new(
layer_code: &'static str,
layer_name: &'static str,
wrapping_depth: usize,
) -> SubjectDescriptor {
SubjectDescriptor {
layer_code,
layer_name,
wrapping_depth,
}
}
/// 返回 `结果缓存代理(CACHE, 深度 2)` 形式的展示文本。
pub fn formatted(&self) -> String {
format!("{}({}, 深度 {})", self.layer_name, self.layer_code, self.wrapping_depth)
}
}
/// 一次查询的完整响应。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct QueryOutcome {
/// 交付给调用方的报表。被拒绝时为 `None`。
pub report: Option<OperatingReport>,
/// 本次调用的全部拒绝原因(**一次报全**,不是只报第一条)。
pub denials: Vec<AccessDenial>,
/// 本次调用实际产生的总工作负载(各层累加)。
pub workload: WorkloadUnits,
/// 响应来源。
pub served_by: ServingOrigin,
/// 产生本次响应的层。
pub served_by_layer: SubjectDescriptor,
}
impl QueryOutcome {
/// 构造一个「被拒绝」的响应。
///
/// 参数 `denials`:拒绝原因(至少一条);`workload`:已消耗的负载;
/// `layer`:做出拒绝判断的层。
/// 返回:响应。
pub fn denied(
denials: Vec<AccessDenial>,
workload: WorkloadUnits,
layer: SubjectDescriptor,
) -> QueryOutcome {
QueryOutcome {
report: None,
denials,
workload,
served_by: ServingOrigin::DeniedBeforeSubject,
served_by_layer: layer,
}
}
/// 是否被拒绝。
pub fn is_denied(&self) -> bool {
self.report.is_none()
}
/// 交付的明细行数(被拒绝时为 0)。
pub fn delivered_row_count(&self) -> usize {
self.report.as_ref().map(|report| report.rows.len()).unwrap_or(0)
}
/// 交付的指标列数(被拒绝时为 0)。
pub fn delivered_metric_count(&self) -> usize {
self.report
.as_ref()
.map(|report| report.metrics.len())
.unwrap_or(0)
}
}
/// 被代理对象的共同接口(抽象主题)。
///
/// 关于本 trait 的设计取舍,见本文件顶部的模块级说明。
pub trait OperatingDataSubject {
/// 执行一次经营数据查询。
///
/// 参数 `request`:查询请求。
/// 返回:完整响应(含报表、拒绝原因、工作负载、响应来源)。
fn query(&self, request: &OperatingQuery) -> QueryOutcome;
/// 本层的自我描述。
fn descriptor(&self) -> SubjectDescriptor;
/// 本层在链上承担的角色说明(用于报表打印装配配方)。
///
/// 返回一句中文,说明「这一层管控什么」。例如缓存代理返回
/// 「按查询条件缓存结果,命中的请求不再向下转发」。
/// 有了它,报表就能把装配配方打印成「一条链 + 每层在管什么」,
/// 而不只是列出层编码。
fn role_description(&self) -> &'static str;
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : calendar_date.rs
//! # 日历日 —— 查询区间的表示与运算
//!
//! ## 为什么自己实现
//!
//! 本工程要把「查询区间」当作**值对象**来用:它进缓存键、进审计日志、
//! 参与跨度校验(「区域经理单次最多查 30 天」)。这些用途都要求
//! 「同样两个日期 → 同样的键」「跨度天数可被逐日核算」。
//! 第三方日期库的表示宽度与内部语义(儒略日、时区、纳秒)都远超需求,
//! 会让「一次查询的区间键有多大」这个度量失真。
//!
//! ## ⚠️ 星期算法不能用算术近似式
//!
//! Facade 工程踩过这个坑,本工程直接沿用修正后的写法:
//! Sakamoto 算法的核心是**月份偏移表**
//! `[0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]`。
//! 有人会把它「化简」成 `(26*(m+1))/10`——这两者**在 3 月/9 月等多个月份不相等**,
//! 会导致**整年星期错一位**。有公认表格的算法,宁可把表写出来,
//! 也不要用「看起来等价」的算式。
//!
//! ## 全部运算都是整数
//!
//! 不经过任何浮点:闰年用整除判断、月天数用查表、日期差用「年内序号相减」。
//! 这保证「同样的输入永远得到同样的输出」,两次运行逐字节一致。
/// 每个月的天数(非闰年)。
///
/// 下标 0 占位(月份从 1 开始),便于直接用 `month` 索引而不用减一——
/// 少一次减法就少一个「忘了减一」的机会。
const DAYS_IN_MONTH_NON_LEAP: [u32; 13] = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/// Sakamoto 算法的月份偏移表。
///
/// 下标 0 占位(月份从 1 开始)。这张表是**公认表格**,不可改动,
/// 也不可用 `(26*(m+1))/10` 之类的算术式替代——两者在 3 月与 9 月不同。
const SAKAMOTO_MONTH_OFFSET: [i32; 13] = [0, 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
/// 星期名称(0 = 周日)。
const WEEKDAY_TEXT: [&str; 7] = ["日", "一", "二", "三", "四", "五", "六"];
/// 一个日历日。
///
/// 字段全部私有,只能经 `from_ymd` 构造,保证「构造出来的日期是合法的」
/// 这一不变量集中在构造函数里维护。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CalendarDate {
/// 公历年(如 `2026`)。
year: i32,
/// 月份(1..=12)。
month: u32,
/// 日(1..=该月天数)。
day: u32,
}
impl CalendarDate {
/// 由年、月、日构造。
///
/// 参数 `year` / `month` / `day`。
/// 返回:日历日。若入参非法(月份越界、日越界),
/// **回退到该年 1 月 1 日**而不是 panic——
/// 业务数据脏值时,报表崩掉比出现一个明显可疑的日期更难排查。
/// 错误要可见,不要致命。
pub fn from_ymd(year: i32, month: u32, day: u32) -> CalendarDate {
if !CalendarDate::is_valid(year, month, day) {
// 回退:非法输入统一落到该年 1 月 1 日,便于在输出里一眼看出异常。
return CalendarDate {
year,
month: 1,
day: 1,
};
}
CalendarDate { year, month, day }
}
// ★ 已删除:`year()` / `month()` / `day()` 三个字段访问器。
//
// 它们原先是为了「让外部读到年/月/日」而存在,但**没有任何调用方**:
// 本模块内部的算法(`day_ordinal` / `weekday_index` / `formatted` 等)
// 直接读私有字段,而模块外要展示日期时用的是
// `formatted()`(`2026-09-01`)、`chinese_text()`(`2026 年 9 月 1 日`)、
// `month_day_text()`(`9 月 1 日`)这三个**已经定好口径**的出口。
//
// 留着它们的害处不只是「多了三行」:`CalendarDate` 的设计前提是
// 「构造出来的日期一定合法」,一旦外部能拿到裸的年月日再自己拼字符串,
// 格式化口径就会在多处各写一遍(`2026-1-1` 还是 `2026-01-01`?),
// 而本工程对「输出逐字节一致」有硬要求。统一走三个出口,口径只有一处。
/// 判断是否闰年。
///
/// 规则:能被 4 整除,但不能被 100 整除;或者能被 400 整除。
/// 全部用整除判断,不引入浮点。
pub const fn is_leap_year(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
/// 某年某月的天数。
pub fn days_in_month(year: i32, month: u32) -> u32 {
if month < 1 || month > 12 {
return 0;
}
if month == 2 && CalendarDate::is_leap_year(year) {
// 闰年 2 月 29 天。
return 29;
}
DAYS_IN_MONTH_NON_LEAP[month as usize]
}
/// 校验年月日是否合法。
pub fn is_valid(year: i32, month: u32, day: u32) -> bool {
if month < 1 || month > 12 || day < 1 {
return false;
}
day <= CalendarDate::days_in_month(year, month)
}
/// 该日期在**年内**的序号(1 月 1 日为 1)。
///
/// 这是算「两个日期相差多少天」的基础:先各自折算成绝对值再相减。
/// 把所有日期先折算成同一个线性尺度,是处理日期差的通用做法——
/// 它避免了对「跨月」「跨年」「闰年 2 月」的逐段特殊处理。
pub fn day_ordinal(&self) -> i32 {
let mut ordinal: i32 = 0;
// 累加前面各整月的天数。
let mut earlier_month: u32 = 1;
while earlier_month < self.month {
ordinal += CalendarDate::days_in_month(self.year, earlier_month) as i32;
earlier_month += 1;
}
ordinal + self.day as i32
}
/// 把日期折算成「自 0001-01-01 起的天数」。
///
/// 用于跨年相减。这里用**逐年累加**而不是公式,因为本工程的年份范围
/// 很小(演示区间就在 2026 年附近),逐年循环几圈远比一个容易写错的
/// 复合公式更可靠——「看得懂的慢」优于「看不懂的快」。
fn absolute_day_number(&self) -> i64 {
let mut total_days: i64 = 0;
let mut year: i32 = 1;
while year < self.year {
total_days += if CalendarDate::is_leap_year(year) { 366 } else { 365 };
year += 1;
}
total_days + self.day_ordinal() as i64
}
/// 与另一日期相差的天数(`self - other`)。
///
/// 参数 `other`:被减日期。
/// 返回:天数差。`self` 晚于 `other` 时为正。
pub fn days_from(&self, other: &CalendarDate) -> i64 {
self.absolute_day_number() - other.absolute_day_number()
}
/// 计算两个日期之间**包含两端**的天数。
///
/// 参数 `other`:另一端日期。
/// 返回:闭区间天数。例如 9 月 1 日到 9 月 30 日为 30 天。
///
/// ⚠️ 为什么必须提供这个函数而不是让调用方写 `days_from + 1`:
/// 「区间天数」在业务上的定义是**闭区间**(含首尾两天),
/// 而 `days_from` 返回的是差值。两者差 1,
/// 而「单次最多查 30 天」这类校验恰好卡在这个 1 上——
/// 9 月 1 日到 9 月 30 日应当是 30 天(合法),
/// 若误用差值(29)就会把 9 月 1 日到 10 月 1 日(31 天,非法)也算成合法。
/// 把定义收进一个函数并写明闭区间语义,比让 12 个调用点各自 `+1` 安全得多。
pub fn span_days_inclusive(&self, other: &CalendarDate) -> u32 {
let difference: i64 = self.days_from(other).abs();
// 差值加一即为闭区间天数;差值不可能超过 u32 范围(本工程年份有限)。
(difference + 1) as u32
}
/// 向后推进若干天。
///
/// 参数 `days`:天数(必须非负)。
/// 返回:推进后的日期。
pub fn add_days(&self, days: i64) -> CalendarDate {
let target: i64 = self.absolute_day_number() + days;
CalendarDate::from_absolute_day_number(target)
}
/// 由「自 0001-01-01 起的天数」反推日期。
///
/// 与 `absolute_day_number` 互为逆运算。两者必须成对维护——
/// 只改一个就会出现「加 30 天再减 30 天回不到原日期」这种隐蔽错误。
fn from_absolute_day_number(mut day_number: i64) -> CalendarDate {
let mut year: i32 = 1;
loop {
let year_length: i64 = if CalendarDate::is_leap_year(year) { 366 } else { 365 };
if day_number <= year_length {
break;
}
day_number -= year_length;
year += 1;
}
// 此时 day_number 落在该年内(1 基)。
let mut month: u32 = 1;
loop {
let month_length: i64 = CalendarDate::days_in_month(year, month) as i64;
if day_number <= month_length {
break;
}
day_number -= month_length;
month += 1;
}
CalendarDate {
year,
month,
day: day_number as u32,
}
}
/// 星期序号(0 = 周日,1 = 周一,……,6 = 周六)。
///
/// 使用 **Sakamoto 算法**(查表版):
/// ```text
/// y = 年; 若 月 < 3 { y = y - 1 } // ★ 这一步最容易漏
/// 星期 = (y + y/4 - y/100 + y/400 + 月偏移[月] + 日) % 7
/// ```
/// 其中 `月偏移[月]` 取自 `SAKAMOTO_MONTH_OFFSET`。
///
/// ## ⚠️ 「月 < 3 时年份减一」不可省略
///
/// 这张偏移表是把 1 月与 2 月**当作上一年的 13 月与 14 月**来排的
/// (这是 Sakamoto 算法的定义方式),因此计算前必须先把年份退回一年。
/// 漏掉这一步会让**每年 1 月与 2 月的星期整体错一位**,
/// 而 3..12 月全部正确——正是这种「大部分正确」的缺陷最难被发现。
/// 本工程实测:漏掉时 2026-01-01 被算成周五(真实是周四)。
///
/// 校验方式:拿 `python -c "import datetime"` 的 `weekday()` 跑一批样例比对,
/// 样例必须**覆盖 1 月与 2 月**,否则这个 bug 根本不会暴露。
pub fn weekday_index(&self) -> u32 {
let mut year: i32 = self.year;
let month: u32 = self.month;
let day: i32 = self.day as i32;
// ★ 1 月与 2 月按「上一年的 13/14 月」处理,故年份退回一年。
if month < 3 {
year -= 1;
}
// 逐项相加:年份 + 闰年数修正 + 世纪修正 + 月份偏移 + 日。
let sum: i32 = year
+ year / 4
- year / 100
+ year / 400
+ SAKAMOTO_MONTH_OFFSET[month as usize]
+ day;
// 取模 7。Rust 的 `%` 对负数会返回负值,故先加 7 再取模保险。
((sum % 7) + 7) as u32 % 7
}
/// 星期文本(单字,如 `"三"`)。
pub fn weekday_text(&self) -> &'static str {
WEEKDAY_TEXT[self.weekday_index() as usize]
}
/// 是否周末(周六或周日)。
pub fn is_weekend(&self) -> bool {
let weekday: u32 = self.weekday_index();
weekday == 0 || weekday == 6
}
/// 返回 `2026-09-01` 形式。
pub fn formatted(&self) -> String {
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
/// 返回 `2026 年 9 月 1 日` 形式。
pub fn chinese_text(&self) -> String {
format!("{} 年 {} 月 {} 日", self.year, self.month, self.day)
}
/// 返回 `9 月 1 日` 形式(省略年份,用于表格内节省列宽)。
pub fn month_day_text(&self) -> String {
format!("{} 月 {} 日", self.month, self.day)
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : clock_time.rs
//! # 时刻 —— 审计日志的访问时间
//!
//! ## 为什么不读系统时钟
//!
//! 审计日志记录「第 N 次访问发生在几点几分」看起来理所当然地要读系统时钟。
//! 但本工程有一条硬性验收标准:**两次运行输出必须逐字节一致**。
//! 一旦读了系统时钟,两次运行的时间戳必然不同,这条标准当场失效。
//!
//! 因此本模块只提供「时刻」这个**值对象**,由调用方显式推进:
//! 例如「第 N 次访问发生在 `09:00 + N × 3 分钟`」。
//! 这样时序既真实(有先后、有间隔),又完全可复现。
//!
//! ## 为什么用「当天第几分钟」
//!
//! 内部只存一个 `u32`(0..=1439)。这带来三个好处:
//! 1. 相等比较与排序天然正确(就是一个整数);
//! 2. 加减只需一次整数运算,不涉及时分进位;
//! 3. 占用极小,进入审计日志条目时不会成为体积瓶颈。
//!
//! 反过来,若用「时 + 分」两个字段,每次加减都要手工处理「60 分进位/借位」,
//! 那正是跨零点场景(夜班盘点)最容易算错的地方。
/// 一天内的分钟数(24 × 60),用于取模。
const MINUTES_PER_DAY: u32 = 24 * 60;
/// 一天内的一个时刻。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ClockTime {
/// 从 00:00 起算的分钟数(0..=1439)。
minute_of_day: u32,
}
impl ClockTime {
/// 由「时 + 分」构造。
///
/// 参数 `hour` / `minute`。
/// 返回:时刻。越界值会被**环绕**到一天之内(`25 时` → `01 时`),
/// 而不是 panic:审计日志是旁路记录,它绝不能因为一个越界入参
/// 而让主流程崩掉。环绕语义在此是合适的,因为「加了很多分钟」本来就该绕圈。
pub const fn from_hour_minute(hour: u32, minute: u32) -> ClockTime {
let total: u32 = (hour * 60 + minute) % MINUTES_PER_DAY;
ClockTime {
minute_of_day: total,
}
}
/// 由「当天第几分钟」构造。
pub const fn from_minute_of_day(minute_of_day: u32) -> ClockTime {
ClockTime {
minute_of_day: minute_of_day % MINUTES_PER_DAY,
}
}
/// 时(0..=23)。
pub const fn hour(&self) -> u32 {
self.minute_of_day / 60
}
/// 分(0..=59)。
pub const fn minute(&self) -> u32 {
self.minute_of_day % 60
}
/// 当天第几分钟。
pub const fn minute_of_day(&self) -> u32 {
self.minute_of_day
}
/// 向后推进若干分钟(自动环绕到次日)。
pub const fn add_minutes(&self, minutes: u32) -> ClockTime {
ClockTime::from_minute_of_day(self.minute_of_day + minutes)
}
/// 返回 `09:30` 形式。
pub fn formatted(&self) -> String {
format!("{:02}:{:02}", self.hour(), self.minute())
}
/// 返回距离另一时刻的分钟数差(`self - other`)。
///
/// 参数 `other`:被减时刻。
/// 返回:分钟差(有符号)。**不做跨日环绕**——
/// 环绕会让「23:50 到 00:10」算成负数,掩盖真实的时间流逝。
/// 需要跨日语义时由调用方结合日期处理。
pub fn minutes_until(&self, other: &ClockTime) -> i32 {
self.minute_of_day as i32 - other.minute_of_day as i32
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : deterministic_code.rs
//! # 确定性编码 —— 由输入内容派生稳定标识
//!
//! ## 为什么需要「确定性」编码
//!
//! 本工程有两个地方需要从内容派生标识:
//! 1. **访问流水号**:审计日志要给每次访问一个编号,便于报表引用与核对;
//! 2. **演示指标值**:真实主题要给出「某门店某天某指标」的数值。
//!
//! 若用随机数或自增计数器:
//! - 随机数:每次运行数值都变,两次运行的报表无法对比;
//! - 自增:值取决于遍历顺序,一旦生成顺序调整(比如多跑一家门店),
//! **所有门店的营业额全变**,历史数据失去可比性。
//!
//! 本模块用 **FNV-1a 64 位哈希**从输入内容派生数值:同样的输入永远得到同样的输出,
//! 且与生成顺序无关。这个性质在「演示数据」上的收益尤其明显——
//! 「华南旗舰店 9 月 1 日的营业额」是一个**稳定的事实**,
//! 不管它第几个被算出来,值都一样。
//!
//! ## 为什么选 FNV-1a 而不是标准库的 `DefaultHasher`
//!
//! `std::collections::hash_map::DefaultHasher` 的输出**在 Rust 版本之间不保证稳定**
//! (官方明确说明「不应作为持久化格式」)。本工程要在报表里打印编码、
//! 并跨运行对比,因此必须用一个**自己写死、永不改变**的算法。
//! FNV-1a 只有三行,且常量是公开标准,最合适。
//!
//! ⚠️ 本模块**不是密码学哈希**,不要用于安全场景(如签名、口令)。
//! 它只保证「同输入同输出」与「广泛分布」,不保证抗碰撞攻击。
/// FNV-1a 哈希的 64 位偏移基准量。
///
/// 这个常量来自 FNV 规范(offset basis),改动它会使所有既有编码失效。
const FNV_OFFSET_BASIS_64: u64 = 0xcbf2_9ce4_8422_2325;
/// FNV-1a 哈希的 64 位质数。
///
/// 同样来自 FNV 规范(prime),不可改动。
const FNV_PRIME_64: u64 = 0x0000_0100_0000_01b3;
/// 对一个字节切片做 FNV-1a 哈希。
///
/// 参数 `bytes`:待哈希的字节。
/// 返回:64 位哈希值。
///
/// 算法(1a 变体,与 1 的区别在于「先异或再乘」):
/// 1. 从偏移基准量开始;
/// 2. 对每个字节:先与哈希值异或,再乘以质数;
/// 3. 全程使用 64 位**环绕**乘法(`wrapping_mul`),溢出即丢弃高位。
///
/// `wrapping_mul` 在这里不是「图省事」,而是算法定义的一部分——
/// 用普通乘法会在调试构建下 panic、释放构建下数值不同,
/// 造成「debug 与 release 输出不一致」这种最难查的问题。
pub const fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut hash_value: u64 = FNV_OFFSET_BASIS_64;
let mut index: usize = 0;
while index < bytes.len() {
// 第一步:异或当前字节(先异或是 1a 与 1 的唯一区别)。
hash_value ^= bytes[index] as u64;
// 第二步:乘以质数,允许溢出环绕。
hash_value = hash_value.wrapping_mul(FNV_PRIME_64);
index += 1;
}
hash_value
}
/// 把多个字符串拼成一个「种子」再做哈希。
///
/// 参数 `segments`:按顺序参与的字符串片段。
/// 返回:哈希值。
///
/// ## 分隔符不可省略
///
/// 若直接把 `["AB", "C"]` 与 `["A", "BC"]` 拼接成 `"ABC"`,
/// 两者哈希相同——这是**真实存在的碰撞**,不是理论担忧:
/// 本工程的指标值种子正是「门店编码 + 日期 + 指标编码」这种多段拼接,
/// 门店编码末尾与日期开头很容易黏连出歧义。
///
/// 因此本函数在每段之间插入 `\x1f`(ASCII Unit Separator)。
/// 这个字符不会出现在本工程的任何编码、日期或名称里
/// (编码是 `A-Z0-9_`,日期是数字与短横),因此能可靠分界。
pub fn build_seed(segments: &[&str]) -> u64 {
// 分隔符:ASCII Unit Separator,业务数据中不会出现。
const SEPARATOR: u8 = 0x1f;
let mut hash_value: u64 = FNV_OFFSET_BASIS_64;
for (position, segment) in segments.iter().enumerate() {
// 除第一段外,每段前先吃一个分隔符,保证「段边界」参与哈希。
if position > 0 {
hash_value ^= SEPARATOR as u64;
hash_value = hash_value.wrapping_mul(FNV_PRIME_64);
}
// 再把该段的每个字节并入。
for byte in segment.as_bytes() {
hash_value ^= *byte as u64;
hash_value = hash_value.wrapping_mul(FNV_PRIME_64);
}
}
hash_value
}
/// 由哈希值派生一个带前缀的 12 位大写十六进制编码。
///
/// 参数 `prefix`:编码前缀(如 `"ACC"`);`hash_value`:哈希值。
/// 返回:形如 `ACC-1A2B3C4D5E6F` 的字符串。
///
/// 取哈希低 48 位(12 个十六进制位):
/// - 48 位在演示规模(数百条访问记录)下碰撞概率极低;
/// - 12 位定长便于表格对齐(每行编号显示宽度完全相同)。
///
/// 为何不取全部 64 位?16 位十六进制会让报表一行放不下,
/// 而多出的 16 位对本工程的规模没有实际价值。这是**按用途裁剪**,
/// 不是「随便取几位」——注释里写清理由,后来者才好判断能否改动。
pub fn derive_code(prefix: &str, hash_value: u64) -> String {
// 取低 48 位:用掩码清掉高位。
let truncated_value: u64 = hash_value & 0x0000_FFFF_FFFF_FFFF;
format!("{}-{:012X}", prefix, truncated_value)
}
/// 由哈希值派生一个 8 位大写十六进制指纹。
///
/// 参数 `hash_value`:哈希值。
/// 返回:8 位十六进制文本。
///
/// 用于报表表头(「本次调用轨迹指纹 A1B2C3D4」):读者可以凭这 8 位
/// 确认「两次运行看到的是同一批调用」,而不必逐行比对上百条审计记录。
pub fn short_fingerprint(hash_value: u64) -> String {
// 取高 32 位(而非低位)——低位已被 derive_code 用于编号,
// 指纹取高位可让「编号相近的两条记录」在指纹上也有明显差异。
let upper_bits: u32 = (hash_value >> 32) as u32;
format!("{:08X}", upper_bits)
}
/// 把哈希值映射到给定闭区间内的一个整数。
///
/// 参数 `hash_value`:哈希值;`minimum` / `maximum`:闭区间端点(含)。
/// 返回:落在 `[minimum, maximum]` 内的值。
///
/// ## 为什么不用 `hash % (max - min + 1) + min`
///
/// 那个写法在 `maximum < minimum` 时会产生负数或 panic,
/// 且当区间宽度不是 2 的幂时低位分布不均。本函数用取模前先取高位、
/// 并对退化区间显式兜底,避免调用方各自处理边界。
///
/// ⚠️ 这不是「随机数生成器」——同样的 `hash_value` 永远得到同样的结果。
/// 本工程用它从「门店编码 + 日期 + 指标编码」的哈希派生一个**稳定**的指标值。
pub fn map_hash_to_range(hash_value: u64, minimum: i64, maximum: i64) -> i64 {
if maximum <= minimum {
// 退化区间:直接返回下界,不 panic。
return minimum;
}
let span: i64 = maximum - minimum + 1;
// 取哈希的高 48 位(低位已被编号占用,且高位的分布更均匀)。
let high_bits: u64 = hash_value >> 16;
let offset: i64 = (high_bits % span as u64) as i64;
minimum + offset
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : text_layout.rs
//! # CJK 宽度感知的文本排版
//!
//! ## 为什么这一层不可省略
//!
//! 本工程的报表要打印门店名(「华南旗舰店」)、角色名(「区域经理」)、
//! 指标名(「毛利率」)、拒绝原因(「请求门店超出授权范围」)——全是中文。
//! 而 Rust 的格式化填充 `{:<20}` 按**字符数**(`char` 个数)补空格,
//! 不是按**显示列数**。一个汉字在等宽终端里占 2 列,于是:
//!
//! ```text
//! {:<12} 的效果(错位) display_width 的效果(对齐)
//! 角色 指标 角色 指标
//! 区域经理 库存周转 区域经理 库存周转
//! ^ 前者只补到 12 个「字符」 ^ 前者补到 12 个「列」
//! ```
//!
//! 全工程禁止直接用 `{:<N}` 打印含中文的表格单元,一律走本模块。
//!
//! ## 区段表必须互不重叠
//!
//! 下面 `is_wide_character` 里的区间若出现重叠或交叠,
//! `matches!` 会报「不可达模式」(unreachable pattern)告警,
//! 或者更糟——悄悄漏判某一区段。写这类表时要一格一格核对边界。
/// 判断一个字符在等宽终端中是否占 2 个显示列。
///
/// 参数 `character`:待判定的字符。
/// 返回:占 2 列返回 `true`,占 1 列返回 `false`。
///
/// 这里采用**主区域判定**而非穷举全表:全量 Unicode 的宽字符表有上千个区段,
/// 但本工程的字符来源受控(预置常量 + 演示数据,都在 CJK 与拉丁范围内),
/// 因此覆盖以下区段即足够,且每一段都可人工核对:
///
/// | 区段 | 含义 | 本工程的实际来源 |
/// |---|---|---|
/// | `U+1100..=U+115F` | 谚文字母 | 未使用(预留) |
/// | `U+2E80..=U+303E` | CJK 部首补充、符号 | 「·」「─」等 |
/// | `U+3041..=U+33FF` | 平假名、片假名、CJK 注音 | 未使用(预留) |
/// | `U+3400..=U+4DBF` | CJK 扩展 A | 生僻字(预留) |
/// | `U+4E00..=U+9FFF` | CJK 基本区 | 门店名、角色名、指标名、拒绝原因 |
/// | `U+A000..=U+A4CF` | 彝文 | 未使用(预留) |
/// | `U+AC00..=U+D7A3` | 谚文音节 | 未使用(预留) |
/// | `U+F900..=U+FAFF` | CJK 兼容表意文字 | 未使用(预留) |
/// | `U+FE30..=U+FE4F` | CJK 兼容形式 | 「:」全角标点(预留) |
/// | `U+FF00..=U+FF60` | 全角 ASCII | 「()」「%」全角形式 |
/// | `U+FFE0..=U+FFE6` | 全角符号 | 「¢」「£」「¥」 |
///
/// 注意 `U+FF61..=U+FFDC`(半角片假名)**不被**判为宽字符——它们确实是 1 列。
pub fn is_wide_character(character: char) -> bool {
// 用 u32 比较,避免在每个分支里重复做 `as u32` 转换。
let code_point: u32 = character as u32;
matches!(
code_point,
0x1100..=0x115F
| 0x2E80..=0x303E
| 0x3041..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA000..=0xA4CF
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE30..=0xFE4F
| 0xFF00..=0xFF60
| 0xFFE0..=0xFFE6
)
}
/// 计算字符串在等宽终端中的显示列数。
///
/// 参数 `text`:待测量的字符串。
/// 返回:显示列数(汉字计 2,其余计 1)。
///
/// 这是本模块所有填充函数的基础。**不要**用 `text.len()`(那是 UTF-8 字节数,
/// 一个汉字 3 字节)或 `text.chars().count()`(那是字符数,一个汉字 1 个)。
pub fn display_width(text: &str) -> usize {
// 逐个字符累加宽度:宽字符 2 列,窄字符 1 列。
text.chars()
.map(|character| if is_wide_character(character) { 2 } else { 1 })
.sum()
}
/// 在右侧补空格,使结果达到指定**显示列数**。
///
/// 参数 `text`:原文本;`target_width`:目标显示列数。
/// 返回:补齐后的字符串。
///
/// **超出时不截断**:若 `text` 的显示宽度已超过 `target_width`,原样返回。
/// 这一点是刻意的——截断会丢信息,而「列宽估小了」是调用方的 bug,
/// 应该在输出里暴露出来(表格错位一眼可见),而不是被静默吃掉。
pub fn pad_right(text: &str, target_width: usize) -> String {
let current_width: usize = display_width(text);
if current_width >= target_width {
return text.to_string();
}
// 差额就是需要补的空格数(空格永远是 1 列宽)。
let padding_count: usize = target_width - current_width;
format!("{}{}", text, " ".repeat(padding_count))
}
/// 在左侧补空格,使结果达到指定**显示列数**。
///
/// 参数 `text`:原文本;`target_width`:目标显示列数。
/// 返回:补齐后的字符串。
///
/// 用于数字列的右对齐——金额、行数、工作负载单元数都靠它对齐。
pub fn pad_left(text: &str, target_width: usize) -> String {
let current_width: usize = display_width(text);
if current_width >= target_width {
return text.to_string();
}
let padding_count: usize = target_width - current_width;
format!("{}{}", " ".repeat(padding_count), text)
}
/// 居中对齐到指定显示列数。
///
/// 参数 `text`:原文本;`target_width`:目标显示列数。
/// 返回:两侧补空格后的字符串。
///
/// 差值分配规则:**左少右多**(左侧补 `floor(差值/2)`,右侧补剩余)。
/// 为什么不左右均分?因为当差值为奇数时无法均分,必须选一边多补一格。
/// 选「右多」是因为中文标题通常希望视觉重心略偏左,
/// 左边少一格会让标题看起来更靠中。这个选择要写下来,
/// 否则下一个人会以为是算错了。
pub fn pad_center(text: &str, target_width: usize) -> String {
let current_width: usize = display_width(text);
if current_width >= target_width {
return text.to_string();
}
let total_padding: usize = target_width - current_width;
let left_padding: usize = total_padding / 2;
let right_padding: usize = total_padding - left_padding;
format!(
"{}{}{}",
" ".repeat(left_padding),
text,
" ".repeat(right_padding)
)
}
/// 按显示列数截断字符串。
///
/// 参数 `text`:原文本;`maximum_width`:允许的最大显示列数。
/// 返回:在不超过 `maximum_width` 的前提下能容纳的最长前缀。
///
/// ## 为什么必须自己写这个函数
///
/// Rust 的 `&text[..n]` 按**字节**切片,切在汉字中间会 panic
/// (`byte index is not a char boundary`)。本函数按字符逐个累加宽度,
/// 天然保证切点落在字符边界上。
///
/// ## 放弃半个汉字
///
/// 若下一个字符是宽字符(2 列)而剩余宽度只有 1 列,就直接停下,
/// **不**用空格或半个字符填充。表格单元宁可短一列,
/// 也不能出现「只有左半边」的乱码。
pub fn truncate_to_width(text: &str, maximum_width: usize) -> String {
let mut accumulated_width: usize = 0;
// 收集能放下的字符;用 String 而非 &str 切片,天然按字符边界推进。
let mut result: String = String::new();
for character in text.chars() {
let character_width: usize = if is_wide_character(character) { 2 } else { 1 };
// 加上这个字符会超宽 → 停止(放弃这个字符,包括「放不下的宽字符」)。
if accumulated_width + character_width > maximum_width {
break;
}
result.push(character);
accumulated_width += character_width;
}
result
}
/// 生成一条横线,用于表格分隔。
///
/// 参数 `column_width`:横线的显示列数。
/// 返回:由制表符 `─` 拼成的字符串,**其 [`display_width`] 恰等于 `column_width`**。
///
/// ## ⚠️ 这条横线曾经只有一半长——原因是一个凭印象写下的码点区间
///
/// 本函数的原注释是这么写的:
///
/// > 用 `─`(U+2500,属于 `U+2E80..=U+303E` 区段,占 2 列)拼接……
///
/// 这句话是**错的**,而且错在最容易被跳过的那一步:U+2500 是十进制 9472,
/// 而 `0x2E80` 是 11904 —— **9472 比 11904 小**,根本不在那个区段里。
/// U+2500 的东亚宽度属性是 `A`(Ambiguous),本工程的 [`is_wide_character`]
/// 按 1 列处理(这也是终端里的常见渲染结果)。
///
/// 于是代码里的 `column_width / 2` 与本工程自己的宽度模型直接冲突:
/// `display_width("─")` 返回 1,而 `horizontal_rule(2)` 只产出 1 个字符。
/// 结果是**每一张表、每一个分节标题下的分隔线都只有标题行的一半长**。
///
/// 这个缺陷为什么难被发现:
///
/// 1. 它**不报错**——没有断言、没有 panic,没有任何东西会因此失败;
/// 2. 它**不影响对齐**——分隔线与数据行本来就不同字符集,短一截看起来
/// 只是「这条线有点短」,不像错位那样扎眼;
/// 3. 它**处处都在**,反而让人以为是设计如此。
///
/// 处置办法不是「把这条注释改对」就完事,而是给宽度模型加一条**自检**:
/// 见 `main.rs` 自查区里对 `display_width(horizontal_rule(n)) == n` 的核对。
/// 有公认表格/区间的算法与码点判断,宁可让机器算一遍,也不要凭印象写区间边界。
pub fn horizontal_rule(column_width: usize) -> String {
// 一条 `─` 占 1 列(与 `is_wide_character` 的口径一致),因此重复 `column_width` 次。
"─".repeat(column_width)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : compliance_check.rs
//! # 合规体检 —— 16 条可自动判定的规则
//!
//! ## 为什么需要「体检」而不是靠人看报表
//!
//! 本工程反复出现同一类问题:**出错的装配不会报错,只会产出看起来正常的报表**。
//!
//! - 缓存放在了保护代理外侧 → 报表照常打印,只是某一列多了/少了;
//! - 审计被挪到内侧 → 日志照常打印,只是条数少了;
//! - 远程代理在保护代理外侧 → 被拒请求照常被拒,只是负载从 0 变成 300。
//!
//! 这三件事都**不会**让程序崩溃、不会打印警告、不会让行数对不上。
//! 因此必须把它们做成可自动判定的规则,让「装配是否正确」
//! 变成一个和编译一样的检查动作。
//!
//! ## 规则的两种类型(这个区分很重要)
//!
//! | 类型 | 判定依据 | 例子 |
//! |---|---|---|
//! | **结构型** | 只看链上的层编码与深度 | R1 缓存须在保护代理内侧 |
//! | **结果型** | 看实际交付的响应 | R12 交付的指标必须落在角色白名单内 |
//!
//! 结果型规则更强:它能抓住**语义**错误。第六幕里「店员拿到了客单价」
//! 这个越权,结构上完全看不出来(链是合法装配,键也「有用」),
//! 只有比对「交付的列」与「角色的白名单」才会暴露。
//! 因此本模块两类都要有——只做结构检查会漏掉最危险的那类缺陷。
//!
//! ## 一次报全,不提前返回
//!
//! 每个 `check_*` 的签名统一为 `(&CheckContext, &mut Vec<CheckFinding>) -> ()`,
//! 只往列表里追加、从不提前 `return`。理由与同系列 Builder / Flyweight 工程一致:
//! 一条链可能同时违反多条规则,只报第一条会让排查者「改一次、跑一次」。
//!
//! 另外:「通过」的规则也要进列表(`passed: true`)。这样打印出来的是一份
//! **完整的体检清单**,读者能看出哪些维度被检查过。只报失败的写法下,
//! 「没有被检查」与「检查了且通过」在输出上无法区分——
//! 而安全审查里最危险的状态恰恰是「以为查过了,其实没有」。
use crate::client::{AssembledProxyChain, LabeledOutcome};
use crate::proxy::{
AccessJournal, CacheObservation, ServingOrigin, StoreCatalog, AGGREGATE_ROW_STORE_CODE,
};
use super::workload_profile::{build_breakdown, WorkloadBreakdown};
// ---------------------------------------------------------------------------
// 规则编码(开放型常量:规则会随业务扩张新增)
// ---------------------------------------------------------------------------
/// R1:缓存代理必须位于保护代理**内侧**。
pub const RULE_CACHE_INSIDE_GUARD: &str = "CACHE_INSIDE_GUARD";
/// R2:审计代理必须位于**最外层**。
pub const RULE_AUDIT_OUTERMOST: &str = "AUDIT_OUTERMOST";
/// R3:虚拟代理必须是**最内侧**的代理(紧邻真实主题)。
pub const RULE_LAZY_INNERMOST: &str = "LAZY_INNERMOST";
/// R4:远程代理必须位于保护代理**内侧**。
pub const RULE_REMOTE_INSIDE_GUARD: &str = "REMOTE_INSIDE_GUARD";
/// R5:缓存代理必须位于远程代理**外侧**(命中才省得掉往返)。
pub const RULE_CACHE_OUTSIDE_REMOTE: &str = "CACHE_OUTSIDE_REMOTE";
/// R6:缓存位于保护代理外侧时,键**必须**含角色。
pub const RULE_CACHE_KEY_ROLE_POLICY: &str = "CACHE_KEY_ROLE_POLICY";
/// R7:审计日志条数必须等于调用条数。
pub const RULE_AUDIT_COUNT: &str = "AUDIT_COUNT_EQUALS_CALLS";
/// R8:链尾必须是真实主题。
pub const RULE_REAL_SUBJECT_PRESENT: &str = "REAL_SUBJECT_PRESENT";
/// R9:层深度必须连续无洞(0..n)。
pub const RULE_DEPTH_CONTIGUOUS: &str = "LAYER_DEPTH_CONTIGUOUS";
/// R10:被拒绝的调用必须零负载。
pub const RULE_DENIED_ZERO_WORKLOAD: &str = "DENIED_ZERO_WORKLOAD";
/// R11:日志里记到的拒绝条数必须等于实际被拒条数。
pub const RULE_AUDIT_DENIALS: &str = "AUDIT_DENIALS_RECORDED";
/// R12:交付的每一列指标都必须在调用角色的可见范围内。
pub const RULE_DELIVERED_METRICS: &str = "DELIVERED_METRICS_WITHIN_ROLE";
/// R13:交付的每一行门店都必须在调用角色的授权范围内。
pub const RULE_DELIVERED_ROWS: &str = "DELIVERED_ROWS_WITHIN_ROLE";
/// R14:仅汇总角色只能收到汇总行。
pub const RULE_SUMMARY_ONLY_AGGREGATE: &str = "SUMMARY_ONLY_ROLE_GETS_AGGREGATE";
/// R15:每一条推理泄露事件都必须在对应报表的脱敏记录里留下痕迹。
pub const RULE_INFERENCE_LEDGER: &str = "INFERENCE_LEAK_LEDGERED";
/// R16:装载 + 扫描 + 往返 必须等于各次响应负载之和。
pub const RULE_WORKLOAD_IDENTITY: &str = "WORKLOAD_IDENTITY_HOLDS";
/// R17:缓存利用率说明(**提示级**,恒为通过,作用是把「为什么是 0」写清楚)。
pub const RULE_CACHE_UTILITY_NOTE: &str = "CACHE_UTILITY_NOTE";
/// 结论严重度。
///
/// 三档而不是两档:`Warning` 是「规则被违反了,但当前装配下不构成
/// 数据泄露或成本失控」——例如缓存满了导致写入被拒。把它与
/// `Fatal` 混在一起,会让严重问题淹没在噪声里。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
/// 违反会导致数据越权或成本失控。
Fatal,
/// 违反会影响效果或可观测性,但不直接造成安全后果。
Warning,
/// 信息性结论(含所有通过的规则)。
Info,
}
impl Severity {
/// 中文标签。
pub const fn label(&self) -> &'static str {
match self {
Severity::Fatal => "严重",
Severity::Warning => "警告",
Severity::Info => "信息",
}
}
}
/// 一条体检结论。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckFinding {
/// 规则编码。
pub rule_code: &'static str,
/// 规则的一句话说明(体检清单的「检查项」列)。
pub title: &'static str,
/// 违反时的严重度。
pub severity: Severity,
/// 是否通过。
pub passed: bool,
/// 结论的判定对象(层编码、角色编码、或「全链」)。
pub subject: String,
/// 判定依据(必须能被复算)。
pub detail: String,
}
impl CheckFinding {
/// 结论标记(通过的用 `OK`,未通过的用严重度标记)。
pub const fn verdict_marker(&self) -> &'static str {
if self.passed {
return "OK";
}
match self.severity {
Severity::Fatal => "FAIL",
Severity::Warning => "WARN",
Severity::Info => "NOTE",
}
}
}
/// 体检报告。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComplianceReport {
/// 链标签。
pub chain_label: &'static str,
/// 全部结论(含通过的)。
pub findings: Vec<CheckFinding>,
/// 检查项总数。
pub checked_count: usize,
/// 未通过数。
pub failed_count: usize,
/// 未通过且严重度最高的档位数。
pub fatal_count: usize,
}
impl ComplianceReport {
/// 结论文本(供报表一行打印)。
pub fn verdict_text(&self) -> String {
if self.failed_count == 0 {
format!("全部 {} 项通过", self.checked_count)
} else {
format!(
"{} 项未通过(其中严重 {} 项)/ 共 {} 项",
self.failed_count, self.fatal_count, self.checked_count
)
}
}
}
/// 体检上下文。
///
/// ## 为什么不在这里现算三分账
///
/// 曾有一版写成 `CheckContext::new` 内部构造 `breakdown` 并用
/// `Box::leak` 把它变成 `'static` —— 那是错的:体检每跑一次
/// 都会永久泄漏一块内存,而它**不会有任何报错**,
/// 只是内存缓慢增长。这类「看起来能编译的泄漏」最容易被放过。
///
/// 现在的做法是让调用方拥有 `breakdown`,上下文只借它。
/// 于是「谁负责释放」这个问题在类型上就有答案。
pub struct CheckContext<'a> {
/// 被检查的链。
pub chain: &'a AssembledProxyChain,
/// 该链跑出的响应。
pub results: &'a [LabeledOutcome],
/// 该链的负载三分账(由调用方持有)。
pub breakdown: &'a WorkloadBreakdown,
}
impl<'a> CheckContext<'a> {
/// 取某一层的深度(不存在时返回 `None`)。
fn depth_of(&self, layer_code: &str) -> Option<usize> {
self.chain
.layers()
.iter()
.find(|(descriptor, _)| descriptor.layer_code == layer_code)
.map(|(descriptor, _)| descriptor.wrapping_depth)
}
/// 链上最深的深度。
fn maximum_depth(&self) -> usize {
self.chain
.layers()
.iter()
.map(|(descriptor, _)| descriptor.wrapping_depth)
.max()
.unwrap_or(0)
}
/// 门店主数据。
fn catalog(&self) -> &StoreCatalog {
self.chain.catalog()
}
/// 缓存观测。
fn cache(&self) -> std::cell::Ref<'_, CacheObservation> {
self.chain.handles().cache().borrow()
}
/// 审计日志。
fn journal(&self) -> std::cell::Ref<'_, AccessJournal> {
self.chain.handles().journal().borrow()
}
}
/// 跑完一套体检。
///
/// 参数 `chain_label`:链标签(进入报告);`chain` / `results`。
/// 返回:体检报告。
pub fn run_compliance(
chain_label: &'static str,
chain: &AssembledProxyChain,
results: &[LabeledOutcome],
) -> ComplianceReport {
// 三分账由本函数持有,上下文只借它 —— 见 `CheckContext` 的说明。
let breakdown: WorkloadBreakdown = build_breakdown(chain, results);
let context: CheckContext<'_> = CheckContext {
chain,
results,
breakdown: &breakdown,
};
let mut findings: Vec<CheckFinding> = Vec::new();
check_cache_inside_guard(&context, &mut findings);
check_audit_outermost(&context, &mut findings);
check_lazy_innermost(&context, &mut findings);
check_remote_inside_guard(&context, &mut findings);
check_cache_outside_remote(&context, &mut findings);
check_cache_key_role_policy(&context, &mut findings);
check_audit_count(&context, &mut findings);
check_real_subject_present(&context, &mut findings);
check_depth_contiguous(&context, &mut findings);
check_denied_zero_workload(&context, &mut findings);
check_audit_denials(&context, &mut findings);
check_delivered_metrics(&context, &mut findings);
check_delivered_rows(&context, &mut findings);
check_summary_only_aggregate(&context, &mut findings);
check_inference_ledger(&context, &mut findings);
check_workload_identity(&context, &mut findings);
check_cache_utility_note(&context, &mut findings);
let checked_count: usize = findings.len();
let failed_count: usize = findings.iter().filter(|finding| !finding.passed).count();
let fatal_count: usize = findings
.iter()
.filter(|finding| !finding.passed && finding.severity == Severity::Fatal)
.count();
ComplianceReport {
chain_label,
findings,
checked_count,
failed_count,
fatal_count,
}
}
// ---------------------------------------------------------------------------
// 逐条规则
// ---------------------------------------------------------------------------
/// R1:缓存代理必须位于保护代理**内侧**。
fn check_cache_inside_guard(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let cache: Option<usize> = context.depth_of("CACHE");
let guard: Option<usize> = context.depth_of("GUARD");
let (passed, detail) = match (cache, guard) {
(None, _) => (true, "链上无缓存层,规则不适用".to_string()),
(Some(_), None) => (true, "链上无保护代理,无从比较(另见 R12 结果型检查)".to_string()),
(Some(cache_depth), Some(guard_depth)) => (
cache_depth < guard_depth,
format!(
"缓存深度 {} {} 保护深度 {}",
cache_depth,
if cache_depth < guard_depth { "<" } else { ">=" },
guard_depth
),
),
};
findings.push(CheckFinding {
rule_code: RULE_CACHE_INSIDE_GUARD,
title: "缓存代理须在保护代理内侧",
severity: Severity::Fatal,
passed,
subject: "CACHE/GUARD".to_string(),
detail,
});
}
/// R2:审计代理必须位于**最外层**。
fn check_audit_outermost(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let audit: Option<usize> = context.depth_of("AUDIT");
let maximum: usize = context.maximum_depth();
let (passed, detail) = match audit {
None => (true, "链上无审计层,规则不适用".to_string()),
Some(depth) => (
depth == maximum,
format!("审计深度 {},最外层深度 {}", depth, maximum),
),
};
findings.push(CheckFinding {
rule_code: RULE_AUDIT_OUTERMOST,
title: "审计代理须在最外层",
severity: Severity::Fatal,
passed,
subject: "AUDIT".to_string(),
detail,
});
}
/// R3:虚拟代理必须是**最内侧**的代理。
fn check_lazy_innermost(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let lazy: Option<usize> = context.depth_of("LAZY");
let (passed, detail) = match lazy {
None => (true, "链上无虚拟代理,规则不适用".to_string()),
// 「最内侧的代理」= 深度 1(深度 0 是真实主题本身)。
Some(depth) => (
depth == 1,
format!("虚拟代理深度 {}(应为 1,即紧邻真实主题)", depth),
),
};
findings.push(CheckFinding {
rule_code: RULE_LAZY_INNERMOST,
title: "虚拟代理须紧邻真实主题",
severity: Severity::Warning,
passed,
subject: "LAZY".to_string(),
detail,
});
}
/// R4:远程代理必须位于保护代理**内侧**。
fn check_remote_inside_guard(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let remote: Option<usize> = context.depth_of("REMOTE");
let guard: Option<usize> = context.depth_of("GUARD");
let (passed, detail) = match (remote, guard) {
(None, _) => (true, "链上无远程代理,规则不适用".to_string()),
(Some(_), None) => (true, "链上无保护代理,无从比较".to_string()),
(Some(remote_depth), Some(guard_depth)) => (
remote_depth < guard_depth,
format!(
"远程深度 {} {} 保护深度 {};在保护外侧意味着被拒请求也要付往返",
remote_depth,
if remote_depth < guard_depth { "<" } else { ">=" },
guard_depth
),
),
};
findings.push(CheckFinding {
rule_code: RULE_REMOTE_INSIDE_GUARD,
title: "远程代理须在保护代理内侧",
severity: Severity::Fatal,
passed,
subject: "REMOTE/GUARD".to_string(),
detail,
});
}
/// R5:缓存代理必须位于远程代理**外侧**。
fn check_cache_outside_remote(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let cache: Option<usize> = context.depth_of("CACHE");
let remote: Option<usize> = context.depth_of("REMOTE");
let (passed, detail) = match (cache, remote) {
(None, _) => (true, "链上无缓存层,规则不适用".to_string()),
(Some(_), None) => (true, "链上无远程代理,无从比较".to_string()),
(Some(cache_depth), Some(remote_depth)) => (
cache_depth > remote_depth,
format!(
"缓存深度 {} {} 远程深度 {};缓存更靠外才能在命中时省掉跨区往返",
cache_depth,
if cache_depth > remote_depth { ">" } else { "<=" },
remote_depth
),
),
};
findings.push(CheckFinding {
rule_code: RULE_CACHE_OUTSIDE_REMOTE,
title: "缓存代理须在远程代理外侧",
severity: Severity::Warning,
passed,
subject: "CACHE/REMOTE".to_string(),
detail,
});
}
/// R6:缓存位于保护代理外侧时,键**必须**含角色。
fn check_cache_key_role_policy(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let cache: Option<usize> = context.depth_of("CACHE");
let guard: Option<usize> = context.depth_of("GUARD");
let policy = context.cache().policy;
let (passed, detail) = match (cache, guard) {
(None, _) => (true, "链上无缓存层,规则不适用".to_string()),
(Some(_), None) => (true, "链上无保护代理,无从比较".to_string()),
(Some(cache_depth), Some(guard_depth)) => {
let cache_inside: bool = cache_depth < guard_depth;
if cache_inside {
(
true,
format!(
"缓存在保护内侧(缓存深度 {}),键{}含角色——内侧缓存键基于收窄后的请求,无需角色",
cache_depth,
if policy.include_role_in_key { "已" } else { "未" }
),
)
} else {
(
policy.include_role_in_key,
format!(
"缓存在保护外侧(缓存深度 {} ≥ 保护深度 {}),键{}含角色",
cache_depth,
guard_depth,
if policy.include_role_in_key { "已" } else { "未" }
),
)
}
}
};
findings.push(CheckFinding {
rule_code: RULE_CACHE_KEY_ROLE_POLICY,
title: "外侧缓存的键须含角色",
severity: Severity::Fatal,
passed,
subject: "CACHE".to_string(),
detail,
});
}
/// R7:审计日志条数必须等于调用条数。
fn check_audit_count(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let journal = context.journal();
let recorded: usize = journal.len();
let calls: usize = context.results.len();
let audit_present: bool = context.depth_of("AUDIT").is_some();
let (passed, detail) = if audit_present {
let missing: usize = calls.saturating_sub(recorded);
(
recorded == calls,
format!(
"记录 {} 条 / 调用 {} 条;差额 {} 条(缓存命中与越权尝试最容易被漏掉)",
recorded, calls, missing
),
)
} else {
(true, "链上无审计层,规则不适用".to_string())
};
findings.push(CheckFinding {
rule_code: RULE_AUDIT_COUNT,
title: "审计条数须等于调用条数",
severity: Severity::Fatal,
passed,
subject: "AUDIT".to_string(),
detail,
});
}
/// R8:链尾必须是真实主题。
fn check_real_subject_present(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let first = context.chain.layers().first();
let (passed, detail) = match first {
Some((descriptor, _)) if descriptor.wrapping_depth == 0 => (
true,
format!("深度 0 层为 {}({})", descriptor.layer_code, descriptor.layer_name),
),
Some((descriptor, _)) => (
false,
format!(
"深度 0 层为 {} 而不是真实主题 —— 链没有锚在真实对象上",
descriptor.layer_code
),
),
None => (false, "装配配方为空".to_string()),
};
findings.push(CheckFinding {
rule_code: RULE_REAL_SUBJECT_PRESENT,
title: "链尾须是真实主题",
severity: Severity::Fatal,
passed,
subject: "WAREHOUSE".to_string(),
detail,
});
}
/// R9:层深度必须连续无洞。
fn check_depth_contiguous(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let depths: Vec<usize> = context
.chain
.layers()
.iter()
.map(|(descriptor, _)| descriptor.wrapping_depth)
.collect();
let contiguous: bool = depths
.iter()
.enumerate()
.all(|(index, depth)| *depth == index);
let detail: String = if contiguous {
format!("深度序列 0..{} 连续", depths.len().saturating_sub(1))
} else {
format!("深度序列 {:?} 有洞或重号", depths)
};
findings.push(CheckFinding {
rule_code: RULE_DEPTH_CONTIGUOUS,
title: "层深度须连续无洞",
severity: Severity::Warning,
passed: contiguous,
subject: "全链".to_string(),
detail,
});
}
/// R10:被拒绝的调用必须零负载。
fn check_denied_zero_workload(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let denied_units: u64 = context.breakdown.denied_units;
let overflow: Vec<String> = context
.results
.iter()
.filter(|item| item.outcome.is_denied() && item.outcome.workload.units() > 0)
.map(|item| format!("{}={}", item.label, item.outcome.workload.units()))
.collect();
let detail: String = if overflow.is_empty() {
format!(
"{} 笔被拒调用合计消耗 {} 单元",
context.breakdown.denied_count, denied_units
)
} else {
format!("以下被拒调用仍在消耗负载:{}", overflow.join("、"))
};
findings.push(CheckFinding {
rule_code: RULE_DENIED_ZERO_WORKLOAD,
title: "被拒调用须零负载",
severity: Severity::Fatal,
passed: denied_units == 0,
subject: "全链".to_string(),
detail,
});
}
/// R11:日志里记到的拒绝条数必须等于实际被拒条数。
fn check_audit_denials(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let journal = context.journal();
let recorded: usize = journal.denied_count();
let actual: usize = context
.results
.iter()
.filter(|item| item.outcome.is_denied())
.count();
let audit_present: bool = context.depth_of("AUDIT").is_some();
let (passed, detail) = if audit_present {
(
recorded == actual,
format!("日志记到被拒 {} 条 / 实际被拒 {} 条", recorded, actual),
)
} else {
(true, "链上无审计层,规则不适用".to_string())
};
findings.push(CheckFinding {
rule_code: RULE_AUDIT_DENIALS,
title: "越权尝试须被审计记到",
severity: Severity::Fatal,
passed,
subject: "AUDIT".to_string(),
detail,
});
}
/// R12:交付的每一列指标都必须在调用角色的可见范围内。
///
/// ## 这是一条**结果型**规则,也是本模块最重要的一条
///
/// 它不看链的结构,只看「实际交出去的那份报表里有什么」。
/// 因此它能抓住结构检查抓不到的语义缺陷:
/// 缓存被放在保护代理外侧时,链本身完全合法(每层都在,
/// 深度也连续),但某个角色的报表里会凭空多出一列——
/// 那一列不在它的白名单里。
fn check_delivered_metrics(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let mut violations: Vec<String> = Vec::new();
let mut checked_reports: usize = 0;
for item in context.results {
let Some(report) = item.outcome.report.as_ref() else {
continue; // 被拒绝的调用没有报表,无需检查。
};
checked_reports += 1;
let scope = item.role().scope();
for metric in &report.metrics {
if !scope.allows_metric(metric) {
violations.push(format!(
"[{}] 交付了 {}({})",
item.label,
metric.display_name(),
metric.code()
));
}
}
}
// 去重:同一个缺陷可能在多笔查询上重复出现,报表里列一次就够。
violations.sort();
violations.dedup();
let detail: String = if violations.is_empty() {
format!("检查了 {} 份报表,全部列都在角色白名单与敏感级之内", checked_reports)
} else {
format!("越权交付 {} 处:{}", violations.len(), violations.join(";"))
};
findings.push(CheckFinding {
rule_code: RULE_DELIVERED_METRICS,
title: "交付的指标须在角色可见范围内",
severity: Severity::Fatal,
passed: violations.is_empty(),
subject: "全链".to_string(),
detail,
});
}
/// R13:交付的每一行门店都必须在调用角色的授权范围内。
fn check_delivered_rows(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let mut violations: Vec<String> = Vec::new();
let catalog: &StoreCatalog = context.catalog();
for item in context.results {
let Some(report) = item.outcome.report.as_ref() else {
continue;
};
let scope = item.role().scope();
let home: Option<&str> = item.home_store_code();
for row in &report.rows {
// 汇总行不代表任何具体门店,跳过——它由 R14 单独检查。
if row.store_code == AGGREGATE_ROW_STORE_CODE {
continue;
}
let Some(store) = catalog.find(&row.store_code) else {
violations.push(format!("[{}] 交付了系统不认识的门店 {}", item.label, row.store_code));
continue;
};
if !scope.allows_store(store, home) {
violations.push(format!(
"[{}] 交付了授权范围外的门店 {}({})",
item.label,
store.display_name(),
store.code()
));
}
}
}
violations.sort();
violations.dedup();
let detail: String = if violations.is_empty() {
"全部交付行的门店均在授权范围内".to_string()
} else {
format!("越权交付 {} 处:{}", violations.len(), violations.join(";"))
};
findings.push(CheckFinding {
rule_code: RULE_DELIVERED_ROWS,
title: "交付的门店须在角色授权范围内",
severity: Severity::Fatal,
passed: violations.is_empty(),
subject: "全链".to_string(),
detail,
});
}
/// R14:仅汇总角色只能收到汇总行。
fn check_summary_only_aggregate(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let mut violations: Vec<String> = Vec::new();
let mut checked: usize = 0;
for item in context.results {
let Some(report) = item.outcome.report.as_ref() else {
continue;
};
if item.role().scope().store_visibility().allows_detail() {
continue;
}
checked += 1;
if !report.is_summary() {
violations.push(format!(
"[{}] 收到 {} 行明细(应为 1 行汇总)",
item.label,
report.row_count()
));
}
}
let detail: String = if violations.is_empty() {
format!("检查了 {} 笔汇总角色查询,均为单行汇总", checked)
} else {
format!("异常 {} 处:{}", violations.len(), violations.join(";"))
};
findings.push(CheckFinding {
rule_code: RULE_SUMMARY_ONLY_AGGREGATE,
title: "仅汇总角色只应收到汇总行",
severity: Severity::Fatal,
passed: violations.is_empty(),
subject: "EXTERNAL_VENDOR".to_string(),
detail,
});
}
/// R15:每一条推理泄露事件都必须在对应报表的脱敏记录里留下痕迹。
///
/// ## 为什么必须成对检查
///
/// 推理泄露事件是「因为发现了配置缺陷而额外剥离了一列」。
/// 若只记事件、不在报表的脱敏记录里留痕,就会出现:
/// 调用方看到自己的报表少了一列,却**没有任何解释**。
/// 反过来若只留痕、不记事件,安全团队就不会知道有一个配置缺陷。
///
/// 因此这条规则核对的是「两边都有」——它检查的是一致性,
/// 而不是某一侧的绝对值。
fn check_inference_ledger(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let events = context.chain.handles().guard().borrow().inference_leak_events.clone();
let evaluated = context.chain.handles().guard().borrow().evaluated_query_count;
let mut unledgered: Vec<String> = Vec::new();
for event in &events {
// 找一份「同角色、且脱敏记录里确实写了被移除指标」的报表。
let matched: bool = context.results.iter().any(|item| {
let Some(report) = item.outcome.report.as_ref() else {
return false;
};
item.outcome.served_by != ServingOrigin::DeniedBeforeSubject
&& item.role_code() == event.role_code
&& event
.removed_source_codes
.iter()
.all(|code| report.redaction.metrics_dropped_by_inference_leak.contains(code))
});
if !matched {
unledgered.push(format!(
"角色 {} 的泄露事件({})在报表脱敏记录里没有对应痕迹",
event.role_code, event.protected_metric_code
));
}
}
let detail: String = if unledgered.is_empty() {
format!(
"检查过 {} 笔查询,{} 条泄露事件全部在脱敏记录里留痕",
evaluated,
events.len()
)
} else {
format!("{} 条事件缺少留痕:{}", unledgered.len(), unledgered.join(";"))
};
findings.push(CheckFinding {
rule_code: RULE_INFERENCE_LEDGER,
title: "推理泄露事件须在脱敏记录中留痕",
severity: Severity::Fatal,
passed: unledgered.is_empty(),
subject: "GUARD".to_string(),
detail,
});
}
/// R16:装载 + 扫描 + 往返 必须等于各次响应负载之和。
fn check_workload_identity(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let breakdown = context.breakdown;
let detail: String = format!(
"装载 {} + 扫描 {} + 往返 {} = {};响应负载之和 = {}",
breakdown.open_units,
breakdown.scan_units,
breakdown.round_trip_units,
breakdown.decomposed_total_units,
breakdown.charged_total_units
);
findings.push(CheckFinding {
rule_code: RULE_WORKLOAD_IDENTITY,
title: "负载三分账须与响应负载之和相等",
severity: Severity::Warning,
passed: breakdown.identity_holds,
subject: "全链".to_string(),
detail,
});
}
/// R17:缓存利用率说明(**提示级**,不是判定)。
///
/// ## 为什么需要一条「提示级」的检查
///
/// 前 16 条都在判断「对 / 错」。但有一类事实既不对也不错,
/// 却必须被看见:**本批查询根本没有重复**,于是缓存在这次运行里
/// 什么都没省。
///
/// 若把这种情况写成「未通过」,读者会以为链有问题(链其实没问题,
/// 是批次特性);若不写,`CACHE` 层在贡献报表上「省 0」就没人解释。
/// 因此它是一条提示:`passed` 恒为真,`severity` 为 [`Severity::Info`],
/// 作用是把「为什么是 0」写进体检清单。
///
/// 这一档也是 `Severity::Info` 在本工程里的**唯一用户**:
/// 保留一档没有任何实际用途的枚举值,比删掉它更容易误导后来者。
fn check_cache_utility_note(context: &CheckContext, findings: &mut Vec<CheckFinding>) {
let breakdown = context.breakdown;
let hit_ratio_basis_points: i64 = if breakdown.cache_lookup_count == 0 {
0
} else {
(breakdown.cache_hit_count as i64) * 10_000 / (breakdown.cache_lookup_count as i64)
};
let detail: String = if breakdown.cache_lookup_count == 0 {
"链上无缓存层,本项不适用".to_string()
} else if breakdown.cache_hit_count == 0 {
format!(
"查找 {} 次、命中 0 次 —— 本批查询没有重复,缓存这次不省任何负载;\
这是批次特性而非链的缺陷,要看缓存的贡献须换一批含重复查询的批次",
breakdown.cache_lookup_count
)
} else {
format!(
"查找 {} 次、命中 {} 次(命中率 {})—— 命中即为本批省下的扫描量",
breakdown.cache_lookup_count,
breakdown.cache_hit_count,
format_basis_points(hit_ratio_basis_points)
)
};
findings.push(CheckFinding {
rule_code: RULE_CACHE_UTILITY_NOTE,
title: "缓存利用率说明(提示级)",
severity: Severity::Info,
passed: true,
subject: "CACHE".to_string(),
detail,
});
}
/// 把万分点渲染成 `xx.xx%`。
///
/// 本函数与 `app::layout::ratio_text` **刻意重复**:分析层不能依赖表示层
/// (那会形成反向依赖),而这条提示文字需要百分比。
/// 为了消除 5 行重复而让分析层依赖 `app`,代价远大于收益。
fn format_basis_points(basis_points: i64) -> String {
let magnitude: i64 = basis_points.abs();
format!(
"{}{}.{:02}%",
if basis_points < 0 { "-" } else { "" },
magnitude / 100,
magnitude % 100
)
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : order_experiment.rs
//! # 顺序对照实验 —— 把某一层换个位置,差在哪里
//!
//! ## 为什么「代理顺序」必须用对照实验来论证
//!
//! 顺序错误有三个特点,决定了它只能靠对照实验暴露:
//!
//! 1. **不会报错**。三条错误配方都能编译、都能跑、都返回 200。
//! 2. **不会让报表变形**。行数对得上、列名都是对的,只是内容不同。
//! 3. **差异依赖具体数据**。本工程会跑出「店员多了一列客单价」,
//! 而如果白名单恰好覆盖了所有指标,同一段错误代码**什么都不会暴露**。
//!
//! 第 3 条最要命:它意味着「在一个测试数据集上通过」**不能**证明装配正确。
//! 因此实验批次是**针对缺陷设计的**(见
//! [`crate::client::build_cache_probe_queries`] 的说明),
//! 而不是随便抓一批查询。
//!
//! ## 每个实验只动一处
//!
//! | 实验 | 动的这一处 | 违反的规则 |
//! |---|---|---|
//! | 缓存位置 | 缓存从保护代理内侧移到外侧 | R1 / R6 |
//! | 审计位置 | 审计从最外层移到最内侧 | R2 / R7 / R11 |
//! | 远程位置 | 远程从保护代理内侧移到外侧 | R4 / R10 |
//!
//! ## 三档而不是两档(缓存实验)
//!
//! 缓存实验跑了三档:内侧(正确)、外侧不含角色、外侧含角色。
//! 之所以要第三档,是因为「外侧 + 含角色」是一个**看起来正确**的修法——
//! 它确实不再串味。只有把它也跑出来,才能说明
//! 「把角色塞进键」是在**绕开**问题而不是解决问题:
//! 条目数按角色数放大,且每个角色各存一份本该共享的数据。
use std::rc::Rc;
use crate::client::{
build_cache_probe_queries, build_denied_only_queries, build_demo_queries, run_labeled_queries,
AssembledProxyChain, LabeledOutcome, LabeledQuery, ProxyChainAssembler,
};
use crate::proxy::StoreCatalog;
use super::workload_profile::build_breakdown;
/// 一次实验臂(一条链跑完同一批查询)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExperimentArm {
/// 臂标签(如「内侧(正确)」)。
pub label: &'static str,
/// 层编码序列(自内向外)。
pub layer_codes: Vec<&'static str>,
/// 缓存条目数。
pub cache_entry_count: usize,
/// 缓存查找次数。
pub cache_lookup_count: usize,
/// 缓存命中次数。
pub cache_hit_count: usize,
/// 缓存键是否含角色。
pub cache_include_role_in_key: bool,
/// 审计日志条数。
pub audit_entry_count: usize,
/// 调用条数。
pub call_count: usize,
/// 被拒调用消耗的负载。
pub denied_units: u64,
/// 总负载(三笔账之和)。
pub total_units: u64,
/// 逐笔交付(关键证据)。
pub deliveries: Vec<ArmDelivery>,
/// 未通过的体检规则编码。
pub failed_rules: Vec<&'static str>,
}
impl ExperimentArm {
/// 越权交付的处数(交付了角色白名单之外的列)。
///
/// 这是缓存实验的**判定量**:正确装配必须为 0。
pub fn out_of_scope_delivery_count(&self) -> usize {
self.deliveries
.iter()
.map(|delivery| delivery.out_of_scope_metric_codes.len())
.sum()
}
/// 审计覆盖缺口(调用条数 − 日志条数)。
pub fn audit_gap(&self) -> usize {
self.call_count.saturating_sub(self.audit_entry_count)
}
}
/// 一次实验臂里某一笔查询的交付内容。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArmDelivery {
/// 查询标签。
pub label: &'static str,
/// 调用角色编码。
pub role_code: &'static str,
/// 交付的指标编码(按列序)。
pub metric_codes: Vec<&'static str>,
/// 交付的明细行数。
pub row_count: usize,
/// 交付内容里**不在**该角色白名单内的指标编码。
pub out_of_scope_metric_codes: Vec<&'static str>,
/// 响应来源标记。
pub served_by_marker: &'static str,
}
/// 一次位置对照实验。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlacementExperiment {
/// 实验标题。
pub title: &'static str,
/// 实验要回答的问题。
pub question: &'static str,
/// 相关性最强的体检规则编码。
pub rule_codes: Vec<&'static str>,
/// 各实验臂。
pub arms: Vec<ExperimentArm>,
/// 结论(由各臂数据**算出**,不是写死的文案)。
pub conclusion: String,
}
/// 跑「缓存位置」三档对照实验。
///
/// 参数 `catalog`:门店主数据。
/// 返回:实验结果。
pub fn run_cache_placement_experiment(catalog: &Rc<StoreCatalog>) -> PlacementExperiment {
let queries: Vec<LabeledQuery> = build_cache_probe_queries(catalog);
let mut arms: Vec<ExperimentArm> = Vec::new();
arms.push(run_arm(
"内侧(正确)",
&queries,
|| ProxyChainAssembler::new(catalog.clone()).assemble_canonical(),
));
arms.push(run_arm(
"外侧·键不含角色",
&queries,
|| {
ProxyChainAssembler::new(catalog.clone())
.assemble_cache_outside_guard(false)
},
));
arms.push(run_arm(
"外侧·键含角色",
&queries,
|| {
ProxyChainAssembler::new(catalog.clone())
.assemble_cache_outside_guard(true)
},
));
// 结论由数据算出:列出每一档的「越权交付处数 / 条目数 / 命中数」。
let mut parts: Vec<String> = Vec::new();
for arm in &arms {
parts.push(format!(
"{}:条目 {} 条、命中 {} 次、越权交付 {} 处",
arm.label,
arm.cache_entry_count,
arm.cache_hit_count,
arm.out_of_scope_delivery_count()
));
}
let contaminated: Vec<&ExperimentArm> = arms
.iter()
.filter(|arm| arm.out_of_scope_delivery_count() > 0)
.collect();
let conclusion: String = if contaminated.is_empty() {
format!("三档均无越权交付。{}", parts.join(";"))
} else {
format!(
"有 {} 档发生越权交付({})。{}",
contaminated.len(),
contaminated
.iter()
.map(|arm| arm.label)
.collect::<Vec<_>>()
.join("、"),
parts.join(";")
)
};
PlacementExperiment {
title: "缓存代理的位置",
question: "缓存放在保护代理外侧,会不会把别人的可见范围交出去?",
rule_codes: vec!["CACHE_INSIDE_GUARD", "CACHE_KEY_ROLE_POLICY"],
arms,
conclusion,
}
}
/// 跑「审计位置」对照实验。
///
/// 参数 `catalog`:门店主数据。
/// 返回:实验结果。
pub fn run_audit_placement_experiment(catalog: &Rc<StoreCatalog>) -> PlacementExperiment {
let queries: Vec<LabeledQuery> = build_demo_queries(catalog);
let mut arms: Vec<ExperimentArm> = Vec::new();
arms.push(run_arm("审计在最外层(正确)", &queries, || {
ProxyChainAssembler::new(catalog.clone()).assemble_canonical()
}));
arms.push(run_arm("审计在最内侧(错误)", &queries, || {
ProxyChainAssembler::new(catalog.clone()).assemble_audit_inside_cache()
}));
let parts: Vec<String> = arms
.iter()
.map(|arm| {
format!(
"{}:日志 {} 条 / 调用 {} 条(缺口 {} 条)",
arm.label,
arm.audit_entry_count,
arm.call_count,
arm.audit_gap()
)
})
.collect();
let gap: usize = arms.first().map(|arm| arm.audit_gap()).unwrap_or(0);
let conclusion: String = format!(
"正确装配零缺口;错误装配漏记 {} 条,缺口 = 缓存命中 + 越权拒绝。{}",
gap,
parts.join(";")
);
PlacementExperiment {
title: "审计代理的位置",
question: "审计被挪到内侧,会漏记哪两类事件?",
rule_codes: vec!["AUDIT_OUTERMOST", "AUDIT_COUNT_EQUALS_CALLS", "AUDIT_DENIALS_RECORDED"],
arms,
conclusion,
}
}
/// 跑「远程代理位置」对照实验。
///
/// 参数 `catalog`:门店主数据。
/// 返回:实验结果。
///
/// ## 用「只含非法请求」的批次跑
///
/// 因为这条规则的失效形态是「**被拒请求也在消耗负载**」。
/// 用常规批次跑,被拒请求的 300 会被合法请求的量淹掉;
/// 用全非法批次跑,「裸链 0 / 错误装配 300×5」这个对比才一眼可见。
pub fn run_remote_placement_experiment(catalog: &Rc<StoreCatalog>) -> PlacementExperiment {
let queries: Vec<LabeledQuery> = build_denied_only_queries(catalog);
let mut arms: Vec<ExperimentArm> = Vec::new();
arms.push(run_arm("远程在保护内侧(正确)", &queries, || {
ProxyChainAssembler::new(catalog.clone()).assemble_canonical()
}));
arms.push(run_arm("远程在保护外侧(错误)", &queries, || {
ProxyChainAssembler::new(catalog.clone()).assemble_remote_outside_guard()
}));
let parts: Vec<String> = arms
.iter()
.map(|arm| {
format!(
"{}:被拒 {} 笔共消耗 {} 单元",
arm.label,
arm.call_count,
arm.denied_units
)
})
.collect();
let contaminated: Vec<&ExperimentArm> = arms
.iter()
.filter(|arm| arm.denied_units > 0)
.collect();
let conclusion: String = if contaminated.is_empty() {
format!("两组被拒请求均零负载。{}", parts.join(";"))
} else {
format!(
"有 {} 组为非法请求记了账——攻击者只需发送未知编码即可让后端付费。{}",
contaminated.len(),
parts.join(";")
)
};
PlacementExperiment {
title: "远程代理的位置",
question: "远程代理放在保护代理外侧,非法请求要付钱吗?",
rule_codes: vec!["REMOTE_INSIDE_GUARD", "DENIED_ZERO_WORKLOAD"],
arms,
conclusion,
}
}
/// 跑一个实验臂。
///
/// 参数 `label`:臂标签;`queries`:批次;`build`:装配闭包。
/// 返回:臂结果。
///
/// ## 为什么装配是「闭包」而不是「已建好的链」
///
/// 每个臂**必须**用全新的装配器(一套全新的观测句柄)。
/// 若复用同一个装配器,前一个臂的缓存条目、审计日志、计数
/// 会带进下一个臂——而这种污染不会报错,只会让对照数字全部失真。
///
/// 用一个 `FnOnce() -> AssembledProxyChain` 而不是传进一条已建好的链,
/// 是为了让「每个臂各建一次」这件事**由类型保证**:
/// 调用方没有办法把一个 `&AssembledProxyChain` 传进来复用。
fn run_arm<F>(label: &'static str, queries: &[LabeledQuery], build: F) -> ExperimentArm
where
F: FnOnce() -> AssembledProxyChain,
{
let chain: AssembledProxyChain = build();
// 装配完成后,`chain` 持有观测句柄,因此运行期数据在跑完之后仍然可读。
let results: Vec<LabeledOutcome> = run_labeled_queries(chain.subject(), queries);
let cache = chain.handles().cache().borrow();
let journal = chain.handles().journal().borrow();
let breakdown = build_breakdown(&chain, &results);
let deliveries: Vec<ArmDelivery> = results
.iter()
.map(|item| {
let scope = item.role().scope();
let metric_codes: Vec<&'static str> = item
.outcome
.report
.as_ref()
.map(|report| report.metric_codes())
.unwrap_or_default();
// 逐个比对「交付的列」与「角色白名单 ∩ 敏感级」。这一比对本工程最关键的证据。
let out_of_scope: Vec<&'static str> = item
.outcome
.report
.as_ref()
.map(|report| {
report
.metrics
.iter()
.filter(|metric| !scope.allows_metric(metric))
.map(|metric| metric.code())
.collect()
})
.unwrap_or_default();
ArmDelivery {
label: item.label,
role_code: item.role_code(),
metric_codes,
row_count: item.outcome.delivered_row_count(),
out_of_scope_metric_codes: out_of_scope,
served_by_marker: item.outcome.served_by.marker(),
}
})
.collect();
// 体检:只留未通过的规则编码,供报表并排显示。
let compliance = super::compliance_check::run_compliance(label, &chain, &results);
let mut failed_rules: Vec<&'static str> = compliance
.findings
.iter()
.filter(|finding| !finding.passed)
.map(|finding| finding.rule_code)
.collect();
failed_rules.sort_unstable();
failed_rules.dedup();
ExperimentArm {
label,
layer_codes: chain.layer_codes(),
cache_entry_count: cache.entry_count(),
cache_lookup_count: cache.counters.lookup_count,
cache_hit_count: cache.counters.hit_count,
cache_include_role_in_key: cache.policy.include_role_in_key,
audit_entry_count: journal.len(),
call_count: results.len(),
denied_units: breakdown.denied_units,
total_units: breakdown.decomposed_total_units,
deliveries,
failed_rules,
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : proxy_contribution.rs
//! # 代理层贡献画像 —— 每一层省下了什么、花掉了什么
//!
//! ## 归因的统一口径:**所有层都对标同一个对照组,逐笔相减**
//!
//! 「缓存代理省下了 270 个单元」这句话本身没有意义——
//! 省多少取决于那几次命中本来会扫多少。
//!
//! 而且还有一个更隐蔽的问题:**不同层若各自对标不同的对照组,
//! 它们的贡献值就不能相加**。本工程第一版就是这样写的,
//! 结果「各层省下之和 4,155」与「整链节省 3,911」差 244,
//! 而这个差额既不来自计算错误、也不来自四舍五入,
//! 而是来自**跨区往返在两个对照组里方向相反**:
//! 相对裸链,本链多付了 300(裸链根本没有远程代理);
//! 相对「去缓存」的对照链,本链少付了 300(缓存省掉了一次往返)。
//! 两个 ±300 相加为零,而真实贡献是 −300。
//!
//! 因此现在的做法是:
//!
//! 1. **唯一对照 = 裸链**(没有任何代理)。它是唯一一个「什么都不做」的基准。
//! 2. **逐笔相减**:
//! - 装载账:`裸链装载 − 本链装载`
//! - 扫描账:对每一笔取 `裸链扫描[i] − 本链扫描[i]`,再按这笔调用的
//! **归宿**分类(被拒 → 保护代理;命中缓存 → 缓存代理;其余 → 保护代理收窄)
//! - 往返账:`裸链往返(恒为 0)− 本链往返`
//! 3. **恒等式**:三层贡献之和 **必须精确等于**整链节省。
//! 差值放进 [`ContributionReport::unattributed_units`],
//! 并且 `debug_assert` 要求它为零。残差不为零就说明归因漏了一类,
//! 而「有一个说不清的差额」正是成本分析最该拒绝交付的状态。
//!
//! ## 逐笔归因为什么可行
//!
//! 因为**扫描量可以逐笔对齐**:它由真实主题产生,
//! 而真实主题知道「这一次是谁在问我」——分析层按
//! `served_by == RealComputation` 依次取用即可,不需要把批次下标
//! 塞进观测结构体里(见 [`WarehouseObservation`] 的说明)。
//! 跨区往返则只能整链计数:一笔请求要么走网关、要么不走,
//! 而这个「要么」恰好可以按 `requires_forwarding` 的语义归到远程代理头上。
//!
//! [`WarehouseObservation`]: crate::proxy::WarehouseObservation
use crate::client::{AssembledProxyChain, LabeledOutcome};
use super::workload_profile::{build_breakdown, WorkloadBreakdown};
/// 一层代理「省下负载」的机制分类。
///
/// 用封闭枚举:机制种类有限且决定报表的分组与文案。
/// 每一档对应一句可核对的解释,而不是一个笼统的「优化」。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreventionKind {
/// 少付了一次性装载(虚拟代理)。
RepeatedLoad,
/// 整个请求被拒,后端什么都没做(保护代理)。
OutrightRejection,
/// 请求被收窄,少扫了授权范围外的门店与指标(保护代理)。
RequestNarrowing,
/// 重复查询被短路,既没扫也没跨区(缓存代理)。
RedundantScan,
/// 本层不省负载,只增加固定开销(远程代理)。
FixedOverhead,
/// 本层只做旁路记录,不参与数据路径(审计代理)。
SideEffectFree,
}
impl PreventionKind {
/// 中文标签(用于报表「机制」列)。
pub const fn label(&self) -> &'static str {
match self {
PreventionKind::RepeatedLoad => "省一次性装载",
PreventionKind::OutrightRejection => "整笔挡住",
PreventionKind::RequestNarrowing => "收窄请求少扫",
PreventionKind::RedundantScan => "短路重复查询",
PreventionKind::FixedOverhead => "只加固定开销",
PreventionKind::SideEffectFree => "旁路记录",
}
}
/// 这个机制下的「省下量」是否应当为正。
///
/// 报表据此把「本应为正却为零」的情况标出来——
/// 一个机制在整批数据上毫无作为,本身就是需要解释的事实
/// (例如本批没有重复查询,那么缓存的节省必然为 0)。
pub const fn expects_saving(&self) -> bool {
matches!(
self,
PreventionKind::RepeatedLoad
| PreventionKind::OutrightRejection
| PreventionKind::RequestNarrowing
| PreventionKind::RedundantScan
)
}
}
/// 一层代理的贡献。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayerContribution {
/// 层编码。
pub layer_code: &'static str,
/// 层名称。
pub layer_name: &'static str,
/// 距真实主题的层数。
pub wrapping_depth: usize,
/// 本层在链上的角色说明。
pub role_description: &'static str,
/// 不向下转发就返回的次数(空转短路)。
///
/// 虚拟代理恒为 0(它从不短路,只延后创建);
/// 缓存代理等于命中次数;保护代理等于拒绝次数。
/// 注意审计代理也是 0——它**总是**向下转发。
pub short_circuit_count: usize,
/// 本层在账目里记下的负载(真实主题 549、虚拟代理 18000、远程代理 300 这样)。
///
/// ⚠️ 它**不是**「本层让整条链多付的钱」。真实主题记的 549 是基准成本本身,
/// 不是任何代理带来的增量。要看增量请用 [`extra_cost_units`](Self::extra_cost_units)。
///
/// 之所以两个都要有:账目回答「这一层花了多少」,
/// 增量回答「这一层的存在让总账多了多少」。报表两个都印,
/// 因为它们对读者提出的问题不同。
pub accounted_units: u64,
/// 本层让整链**多付**的负载(相对裸链的增量)。
///
/// 本工程里只有远程代理非零:裸链不存在跨区往返,
/// 而远程代理为每次跨区加 300。其余层的增量都是 0——
/// 真实主题的扫描不是「增量」而是基准,代理们只是在减少它。
///
/// 这个字段是归因恒等式 `Σ省下 − Σ增量 == 整链节省` 的被减项。
pub extra_cost_units: u64,
/// 本层挡下的负载(逐笔相对**裸链**相减得出)。
pub prevented_units: u64,
/// 本层经手但**没有**省下的扫描量(供读者判断本层是否还有余量)。
///
/// 对保护代理而言,它是「收窄后仍在扫描范围内的单元」;
/// 这个数字的存在使「收窄到底省了多少」与「本来要扫多少」
/// 可以一起读出来,而不是只看到一个节省值。
pub passed_through_units: u64,
/// 省下量的机制分类。
pub prevention_kind: PreventionKind,
/// `prevented_units` 对标的对照组标签。
///
/// ⚠️ 它必须被印在报表上。「省了 270」不写对照组,读者无法复算。
/// 本工程全部层的对照组都是同一个(裸链),
/// 这是「各层贡献可以相加」的前提。
pub control_label: &'static str,
/// 本层净贡献(省下为正)。
pub net_units: i64,
}
/// 层贡献汇总。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContributionReport {
/// 批次标签。
pub batch_label: &'static str,
/// 链标签。
pub chain_label: &'static str,
/// 唯一对照组的标签。
pub control_label: &'static str,
/// 逐层贡献(按深度升序,即自内向外)。
pub layers: Vec<LayerContribution>,
/// 各层 `accounted_units` 之和。
///
/// 它应当等于整链总负载(装配链的三笔账之和)——
/// 这是报表可以顺手核对的一致性。
pub total_accounted_units: u64,
/// 各层 `extra_cost_units` 之和。
pub total_extra_cost_units: u64,
/// 各层 `prevented_units` 之和。
///
/// ⚠️ 与早期版本不同:这里的各层**共用同一个对照组**,
/// 因此这个和与 [`chain_saving`](Self::chain_saving) 相等
/// (差额只应来自 `unattributed_units`)。
pub summed_prevented_units: u64,
/// 整链节省(相对裸链)。
pub chain_saving: ChainSaving,
/// 未归因残差。
///
/// ## 它为什么必须存在、又必须为零
///
/// 「所有层的贡献加起来正好等于整链节省」这件事**不会自动成立**——
/// 它取决于归因分类是否穷尽。把这个差值算出来并打印,
/// 归因就从不透明的叙述变成了**可核对的分账**。
///
/// 本工程要求它恒为 0,并用 `debug_assert` 守住。
/// 若将来新增一类机制却忘了归因,残差会不为零,
/// debug 构建立刻炸——而不是让报表继续打印一个「差一点」的总额。
pub unattributed_units: i64,
}
/// 整链的总节省(相对裸链)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChainSaving {
/// 对照组标签。
pub baseline_label: &'static str,
/// 对照组总负载。
pub baseline_units: u64,
/// 候选链总负载。
pub candidate_units: u64,
/// 省下的单元数。
pub saved_units: i64,
/// 节省率(万分点)。
pub saved_ratio_basis_points: i64,
}
/// 归因所需的全部输入。
pub struct ContributionInputs<'a> {
/// 批次标签。
pub batch_label: &'static str,
/// 候选链标签。
pub chain_label: &'static str,
/// 候选链。
pub chain: &'a AssembledProxyChain,
/// 候选链的响应。
pub results: &'a [LabeledOutcome],
/// **唯一对照组**:完全没有代理的裸链。
///
/// 它是 `Option` 只为了允许「只想看某一层自身账目」的调用场景;
/// 没有它时所有 `prevented_units` 记 0 且 `chain_saving` 记 0,
/// 报表会直接显示「未提供对照组」,而不是伪造一个基准。
pub control_bare: Option<(&'static str, &'a AssembledProxyChain, &'a [LabeledOutcome])>,
}
/// 构造层贡献报告。
///
/// 参数 `inputs`:归因输入。
/// 返回:贡献报告。
pub fn build_contributions(inputs: &ContributionInputs) -> ContributionReport {
let chain: &AssembledProxyChain = inputs.chain;
let breakdown: WorkloadBreakdown = build_breakdown(chain, inputs.results);
let cache = chain.handles().cache().borrow();
let remote = chain.handles().remote().get();
let control_label: &'static str = inputs
.control_bare
.map(|(label, _, _)| label)
.unwrap_or("(未提供对照组)");
let bare_breakdown: Option<WorkloadBreakdown> = inputs
.control_bare
.map(|(_label, chain, results)| build_breakdown(chain, results));
// ---------- 逐笔扫描账的归因 ----------
// 对每一笔取「裸链扫描[i] − 本链扫描[i]」,按这笔调用的归宿分类。
// 未提供对照组时,这些累加器全为零。
let mut rejection_saved_units: u64 = 0;
let mut narrowing_saved_units: u64 = 0;
let mut hit_saved_units: u64 = 0;
let mut narrowing_passed_units: u64 = 0;
let mut hit_passed_units: u64 = 0;
if let Some(bare) = bare_breakdown.as_ref() {
// 裸链的装载记在第一笔到达真实主题的调用上,
// 因此逐笔比较扫描账时必须把它剔掉——装载是单独的账目。
let mut bare_open_billed: bool = false;
for (index, item) in inputs.results.iter().enumerate() {
let bare_scan: u64 = bare.scanned_per_call_units.get(index).copied().unwrap_or(0);
let chain_scan: u64 = breakdown.scanned_per_call_units.get(index).copied().unwrap_or(0);
// 裸链的装载账单会混进那一笔的响应负载,但 `scanned_per_call_units`
// 是真实主题单独记的扫描量,不含装载。因此这里不需要额外处理——
// `bare_open_billed` 仅作为「装载确实被记过」的自查标记。
if !bare_open_billed && item.outcome.served_by == crate::proxy::ServingOrigin::RealComputation {
bare_open_billed = true;
}
let saved: u64 = bare_scan.saturating_sub(chain_scan);
if item.outcome.is_denied() {
// 这笔在链上被整笔挡住 → 记账归保护代理的「整笔挡住」。
rejection_saved_units += saved;
} else if item.outcome.served_by == crate::proxy::ServingOrigin::CacheServed {
// 这笔命中缓存 → 记账归缓存代理的「短路重复查询」。
hit_saved_units += saved;
hit_passed_units += chain_scan;
} else {
// 其余(真的算了)若少扫了,唯一原因就是保护代理收窄了请求。
// 这个判断是可论证的:去掉保护代理后请求与裸链完全相同。
narrowing_saved_units += saved;
narrowing_passed_units += chain_scan;
}
}
}
// ---------- 往返账的归因 ----------
// 裸链没有远程代理(往返恒为 0),因此本链的往返全部算在远程代理头上,
// 且方向为负(它是成本,不是节省)。
let round_trip_added_units: u64 = breakdown.round_trip_units;
// ---------- 装载账的归因 ----------
let open_saved_units: u64 = bare_breakdown
.as_ref()
.map(|bare| bare.open_units.saturating_sub(breakdown.open_units))
.unwrap_or(0);
let mut layers: Vec<LayerContribution> = Vec::new();
for (descriptor, role_description) in chain.layers() {
let mut accounted_units: u64 = 0;
let mut extra_cost_units: u64 = 0;
let mut prevented_units: u64 = 0;
let mut passed_through_units: u64 = 0;
let mut short_circuit_count: usize = 0;
let mut prevention_kind: PreventionKind = PreventionKind::FixedOverhead;
match descriptor.layer_code {
// ---------- 虚拟代理:省的是「本会发生的装载」 ----------
"LAZY" => {
accounted_units = breakdown.open_units;
prevented_units = open_saved_units;
prevention_kind = PreventionKind::RepeatedLoad;
}
// ---------- 保护代理:两项机制合一 ----------
"GUARD" => {
short_circuit_count = breakdown.denied_count;
// 两项加在同一个值里,但机制列会分别说明;
// 之所以不拆成两个「层」,是因为它们是同一层代理的两种作用。
prevented_units = rejection_saved_units + narrowing_saved_units;
passed_through_units = narrowing_passed_units;
prevention_kind = PreventionKind::OutrightRejection;
}
// ---------- 缓存代理:省的是「重复查询的扫描」 ----------
"CACHE" => {
short_circuit_count = cache.counters.hit_count;
prevented_units = hit_saved_units;
passed_through_units = hit_passed_units;
prevention_kind = PreventionKind::RedundantScan;
}
// ---------- 远程代理:只加不减 ----------
"REMOTE" => {
accounted_units = round_trip_added_units;
// 裸链没有远程代理,因此它记的往返全部是**增量**。
extra_cost_units = round_trip_added_units;
prevention_kind = PreventionKind::FixedOverhead;
}
// ---------- 审计代理:旁路,不影响数据路径 ----------
"AUDIT" => {
// 审计条数是否等于调用条数,由体检规则 `RULE_AUDIT_COUNT` 核对,
// 不在这里断言:本函数也会被用于分析**故意装错**的对照链,
// 在那种链上断言会让「分析错误配置」这件事本身失败。
// 该报错的场合报错、该报告异常的场合报告异常,两者不能混。
prevention_kind = PreventionKind::SideEffectFree;
}
// ---------- 真实主题:它记账,不省 ----------
"WAREHOUSE" => {
// 真实主题记的是**基准成本**:代理们省下的正是它的一部分。
// 因此它的增量是 0 —— 把 549 记成「增量」会让归因恒等式
// 直接崩掉(少 549),而那个 549 本来就是被保护代理
// 与缓存代理减少的对象。
accounted_units = breakdown.scan_units;
prevention_kind = PreventionKind::FixedOverhead;
}
// ---------- 工程外扩展的新层 ----------
// 这条分支是「扩展性实证」的一部分:新增的层**不必**改本文件
// 就能被纳入报告——它带一条明确的机制说明,
// 而不是被静默忽略(静默忽略会让新层的成本从报表上消失)。
//
// ⚠️ 这里**不重新赋值** `prevention_kind`:它在循环开头已初始化为
// `FixedOverhead`,再写一次是冗余赋值(编译器会提示
// 「value assigned is never read」)。冗余赋值看起来无害,
// 但它会让读者以为「默认值是别的什么」。
_ => {}
}
layers.push(LayerContribution {
layer_code: descriptor.layer_code,
layer_name: descriptor.layer_name,
wrapping_depth: descriptor.wrapping_depth,
role_description,
short_circuit_count,
accounted_units,
extra_cost_units,
prevented_units,
passed_through_units,
prevention_kind,
control_label,
// 净贡献以「省下为正」。远程代理会得到负数——这是对的,
// 它的作用不是省,而是**把跨区代价变得可见**。
//
// 净贡献用的是 `extra_cost_units` 而不是 `accounted_units`:
// 后者含基准成本(真实主题的扫描、代理链按需付的装载),
// 用它会得出「虚拟代理净贡献 −18000」这种明显荒谬的结论。
net_units: prevented_units as i64 - extra_cost_units as i64,
});
}
layers.sort_by_key(|layer| layer.wrapping_depth);
let total_accounted_units: u64 = layers.iter().map(|layer| layer.accounted_units).sum();
let total_extra_cost_units: u64 = layers.iter().map(|layer| layer.extra_cost_units).sum();
let summed_prevented_units: u64 = layers.iter().map(|layer| layer.prevented_units).sum();
// ---------- 整链节省:只相对裸链算一次 ----------
let chain_saving: ChainSaving = match bare_breakdown.as_ref() {
Some(bare) => {
let saved: i64 =
bare.decomposed_total_units as i64 - breakdown.decomposed_total_units as i64;
ChainSaving {
baseline_label: control_label,
baseline_units: bare.decomposed_total_units,
candidate_units: breakdown.decomposed_total_units,
saved_units: saved,
saved_ratio_basis_points: if bare.decomposed_total_units == 0 {
0
} else {
saved * 10_000 / (bare.decomposed_total_units as i64)
},
}
}
None => ChainSaving {
baseline_label: control_label,
baseline_units: breakdown.decomposed_total_units,
candidate_units: breakdown.decomposed_total_units,
saved_units: 0,
saved_ratio_basis_points: 0,
},
};
// 恒等式:`Σ各层省下 − Σ各层花掉 == 整链节省`。
// 这是本模块的核心断言,也是「归因分类是否穷尽」的判定:
// 残差不为零 = 有一类成本没有被归到任何一层。
let unattributed_units: i64 = chain_saving.saved_units
- (summed_prevented_units as i64 - total_extra_cost_units as i64);
debug_assert_eq!(
unattributed_units, 0,
"层贡献无法归因到任何一层,残差 {}:\
各层省下 {} − 各层增量成本 {} = {},但整链节省是 {}。\
残差不为零说明 `PreventionKind` 的分类漏了一类成本。",
unattributed_units,
summed_prevented_units,
total_extra_cost_units,
summed_prevented_units as i64 - total_extra_cost_units as i64,
chain_saving.saved_units
);
// 账目一致性:各层记账之和必须等于整链的三笔账之和。
// 它是「每一笔负载都归属于某一层」的核对,与上面的归因恒等式
// 是两条独立的事实(一条查漏账,一条查漏归因)。
debug_assert_eq!(
total_accounted_units, breakdown.decomposed_total_units,
"各层记账之和 {} 与整链三笔账之和 {} 不等 —— 有负载没有记到任何一层",
total_accounted_units, breakdown.decomposed_total_units
);
// 两条便于自查的守卫。
debug_assert_eq!(
breakdown.cache_hit_units, 0,
"命中缓存的调用仍然消耗了 {} 个单元 —— 缓存没有短路,节省量结论无效",
breakdown.cache_hit_units
);
debug_assert_eq!(
breakdown.denied_units, 0,
"被拒调用仍然消耗了 {} 个单元 —— 有代理位于保护代理外侧,正在为非法请求记账",
breakdown.denied_units
);
debug_assert!(
breakdown.open_attribution_agrees(),
"虚拟代理报的装载量 {} 与真实主题报的账目 {} 不一致",
breakdown.lazy_attributed_open_units,
breakdown.open_units
);
let _ = remote;
ContributionReport {
batch_label: inputs.batch_label,
chain_label: inputs.chain_label,
control_label,
layers,
total_accounted_units,
total_extra_cost_units,
summed_prevented_units,
chain_saving,
unattributed_units,
}
}
//!# encoding: utf-8
//!# 版权所有 2026 ©涂聚文有限公司™ ®
//!# 许可信息查看:言語成了邀功盡責的功臣,還需要行爲每日來值班嗎
//!# 描述:代理模式Proxy Pattern
//!# Author : geovindu,Geovin Du 涂聚文.
//!# IDE : RustRover 2025.1.1 Rust rustc 1.98.1
//!# os : windows 10
//!# database : mysql 9.0 sql server 2025, postgreSQL 18.0 Oracle 21c Neo4j
//!# Datetime : 2026/10/9 22:04
//!# User : geovindu
//!# Product : RustRover
//!# Project : proxypattern
//!# File : workload_profile.rs
//! # 工作负载三分账 —— 装载 / 扫描 / 跨区往返
//!
//! ## 为什么必须是三笔独立累加的账
//!
//! 本工程的负载来自三个完全不同的地方,代价模型也各不相同:
//!
//! | 账目 | 谁记账 | 代价模型 | 本工程的值 |
//! |---|---|---|---|
//! | **装载** | 虚拟代理 | 一次性,与查询数无关 | 120×30×5 = 18,000 |
//! | **扫描** | 真实主题 | 按「门店 × 命中天数 × 指标数」 | 视请求而定 |
//! | **跨区往返** | 远程代理 | 固定 300,与规模无关 | 300 × 跨区次数 |
//!
//! 三者混成一个总数,就无法回答最关键的那个问题:
//! **「省下来的负载是哪一笔?」** 而三笔账的优化手段完全不同——
//! 省装载要靠延迟初始化,省扫描要靠收窄请求与缓存,
//! 省往返要靠把缓存放到远程代理外侧(命中就不必跨区)。
//!
//! ## ⚠️ 为什么不靠减法推导出扫描量
//!
//! `扫描 = 总负载 − 装载 − 往返` 在数学上成立(三笔互斥且穷尽),
//! 但这个写法有一个致命缺点:**它把扫描量变成一个派生量**。
//! 一旦装载或往返记错,扫描量会跟着错,而三者的和仍然自洽——
//! 错误被完全掩盖,报表上看不出任何异常。
//!
//! 因此三笔各自独立累加(句柄在装配期注入),
//! 另外把「三笔之和 == 各次响应负载之和」作为一条**可核对的恒等式**。
//! 独立测量 + 一条等式,才是这套账目的可信度来源。
//! 那条等式在 [`WorkloadBreakdown::identity_holds`] 里被真实计算,
//! 并由体检规则 `RULE_WORKLOAD_IDENTITY` 判定——它不是注释里的承诺。
//!
//! ## 节省量为什么必须有一个对照组
//!
//! 「缓存省了多少」不能由缓存自己回答。命中计数只说明「命中了 4 次」,
//! 而那 4 次本该扫多少单元,**只有把缓存拿掉重跑一遍才知道**。
//! 估计值在单笔成本差异巨大时毫无意义——本工程批次里单笔成本
//! 从 0 到 270 都有,平均值根本无法代表任何一笔。
//!
//! 因此 [`compare_savings`] 要求传入两组**同批次、只差一个装配决策**的结果。
use crate::client::{AssembledProxyChain, LabeledOutcome};
use crate::proxy::ROUND_TRIP_UNITS;
/// 一次运行的工作负载三分账。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkloadBreakdown {
/// 装载账:累计付出的装载单元。
pub open_units: u64,
/// 装载账:真实主题被创建了几次。
pub open_count: u32,
/// 扫描账:累计扫描的交叉点单元。
pub scan_units: u64,
/// 扫描账:真实主题执行过几次查询。
pub scan_query_count: u32,
/// 往返账:累计跨区往返单元。
pub round_trip_units: u64,
/// 往返账:跨区往返次数。
pub round_trip_count: u32,
/// 三笔账之和。
pub decomposed_total_units: u64,
/// 各次响应负载之和(独立来源:把每一笔响应报的负载加总)。
pub charged_total_units: u64,
/// 恒等式是否成立(`三笔账之和 == 响应负载之和`)。
///
/// 不成立时**必须当错误处理**,而不是「四舍五入误差」——
/// 三个来源各自独立累加,理论上没有任何理由不等。
pub identity_holds: bool,
/// 调用总次数。
pub call_count: usize,
/// 被拒绝的次数。
pub denied_count: usize,
/// 被拒绝的调用消耗的负载之和。
///
/// 单独统计它是有意的:这个值**应当是 0**(被拒请求什么都不该消耗)。
/// 若不为 0,说明某一层在保护代理的外侧做了记账——
/// 这正是顺序规则 R4 要抓的问题,也是成本放大攻击的形态。
pub denied_units: u64,
/// 缓存查找次数。
pub cache_lookup_count: usize,
/// 缓存命中次数。
pub cache_hit_count: usize,
/// 命中缓存的调用消耗的负载之和(**应为 0**,用于验证缓存确实短路)。
pub cache_hit_units: u64,
/// 交付给调用方的明细行数之和。
pub delivered_row_count: usize,
/// 每一笔响应负载(按批次顺序)。
///
/// 它与 `charged_total_units` 是「明细」与「合计」的关系;
/// 合计由明细加总得出,而不是另算一次——两个数字同源,
/// 就不会出现「合计与明细对不上」这种最难查的不一致。
pub charged_per_call_units: Vec<u64>,
/// 每一笔调用的**扫描量**(按批次顺序;未到达真实主题的调用为 0)。
///
/// 它是逐笔成本归因的基础:把某笔与对照组的同下标相减,
/// 差额就是「这一笔上某层代理省下的扫描量」。
/// 只有扫描量能这样逐笔对齐——跨区往返只发生在整条链上
/// (一笔请求要么走网关、要么不走),因此它按整链计数。
pub scanned_per_call_units: Vec<u64>,
/// 虚拟代理(归因方)报的累计装载量。
///
/// 与 [`open_units`](Self::open_units)(真实主题报的账目)必须相等。
/// 两者的关系是「谁触发的」与「花了多少」,
/// 相等则说明归因与账目一致。
pub lazy_attributed_open_units: u64,
/// 虚拟代理报的装载次数。
pub lazy_attributed_open_count: u32,
}
impl WorkloadBreakdown {
/// 虚拟代理的装载归因与真实主题的装载账目是否一致。
///
/// 两者不等只可能是记账缺陷:真实主题每次被创建都会同时
/// 写这两处(一处是它自己,一处由虚拟代理在触发时写)。
pub fn open_attribution_agrees(&self) -> bool {
self.lazy_attributed_open_units == self.open_units
}
/// 被拒绝调用的占比(万分点)。
pub fn denied_ratio_basis_points(&self) -> i64 {
if self.call_count == 0 {
return 0;
}
(self.denied_count as i64) * 10_000 / (self.call_count as i64)
}
/// 扫描账占总负载的比例(万分点)。
///
/// 分母用三笔账之和而不是响应负载之和:后者的正确性本身要靠
/// 恒等式验证,用它做分母会把「账记错了」传播到比例里。
pub fn scan_ratio_basis_points(&self) -> i64 {
if self.decomposed_total_units == 0 {
return 0;
}
(self.scan_units as i64) * 10_000 / (self.decomposed_total_units as i64)
}
}
/// 从装配结果与批次响应里汇总三分账。
///
/// 参数 `chain`:装配结果(观测句柄在其中);`results`:同批次的响应。
/// 返回:三分账。
///
/// ## 三个来源分别从哪里读
///
/// | 账目 | 读取位置 | 独立性 |
/// |---|---|---|
/// | 装载 | `chain.handles().lazy_snapshot()` | 虚拟代理在工厂被调用时写 |
/// | 扫描 | `chain.handles().scan_snapshot()` | 真实主题在每次查询时写 |
/// | 往返 | `chain.handles().remote_snapshot()` | 远程代理在每次转发时写 |
///
/// 三者都不是从 `results` 里推导出来的,因此与
/// `charged_total_units`(来自 `results`)构成两个独立的测量。
pub fn build_breakdown(
chain: &AssembledProxyChain,
results: &[LabeledOutcome],
) -> WorkloadBreakdown {
let handles = chain.handles();
let lazy = handles.lazy_snapshot();
let warehouse = handles.warehouse_snapshot();
let remote = handles.remote_snapshot();
let cache = handles.cache().borrow();
let per_call: Vec<u64> = results
.iter()
.map(|item| item.outcome.workload.units())
.collect();
let charged_total: u64 = per_call.iter().sum();
// ---------- 逐笔扫描量对齐 ----------
// 真实主题的逐笔值只对应「真正到达它的那些调用」,因此按
// `served_by == RealComputation` 依次取用。这个对齐方式不依赖
// 调用下标被藏进观测结构体里——真实主题本来就知道
// 「这一次是谁在问我」,但不知道「这是批次里的第几笔」,
// 而批次顺序是分析层的知识。让两边各管自己知道的事。
let mut scan_per_call: Vec<u64> = vec![0; results.len()];
let mut scan_cursor: usize = 0;
for (index, item) in results.iter().enumerate() {
if item.outcome.served_by == crate::proxy::ServingOrigin::RealComputation {
if let Some(value) = warehouse.per_query_scanned_cells.get(scan_cursor) {
scan_per_call[index] = *value;
}
scan_cursor += 1;
}
}
debug_assert_eq!(
scan_cursor,
warehouse.per_query_scanned_cells.len(),
"对齐用的扫描笔数与真实主题记录的笔数不一致:\
说明有代理把 `served_by` 改成了 RealComputation,或者某次扫描没被记账"
);
let round_trip_units: u64 = (remote.forwarded_count as u64) * ROUND_TRIP_UNITS;
// 装载账取**真实主题**的记账(付款人),而不是虚拟代理的记账(归因方)。
// 理由见 `proxy::WarehouseObservation` 的说明——记在归因方会让裸链的装载账消失。
let decomposed_total: u64 = warehouse.open_units + warehouse.scanned_cells + round_trip_units;
let denied: Vec<&LabeledOutcome> = results
.iter()
.filter(|item| item.outcome.is_denied())
.collect();
let denied_units: u64 = denied.iter().map(|item| item.outcome.workload.units()).sum();
let hits: Vec<&LabeledOutcome> = results
.iter()
.filter(|item| item.outcome.served_by == crate::proxy::ServingOrigin::CacheServed)
.collect();
let cache_hit_units: u64 = hits.iter().map(|item| item.outcome.workload.units()).sum();
let delivered_row_count: usize = results
.iter()
.map(|item| item.outcome.delivered_row_count())
.sum();
WorkloadBreakdown {
open_units: warehouse.open_units,
open_count: warehouse.open_count,
scan_units: warehouse.scanned_cells,
scan_query_count: warehouse.query_count,
round_trip_units,
round_trip_count: remote.forwarded_count,
decomposed_total_units: decomposed_total,
charged_total_units: charged_total,
identity_holds: decomposed_total == charged_total,
call_count: results.len(),
denied_count: denied.len(),
denied_units,
cache_lookup_count: cache.counters.lookup_count,
cache_hit_count: cache.counters.hit_count,
cache_hit_units,
delivered_row_count,
charged_per_call_units: per_call,
scanned_per_call_units: scan_per_call,
// 归因方(虚拟代理)报的装载量与付款方(真实主题)报的账目必须一致。
// 不一致说明「按需触发」的记账与实际装载脱节——
// 这种脱节不会有任何报错,只会让「虚拟代理省了 18,000」
// 这句话失去依据,因此必须显式核对。
lazy_attributed_open_units: lazy.accumulated_open_workload_units,
lazy_attributed_open_count: lazy.open_count,
}
}
/// 取每一笔响应负载(按批次顺序)。
///
/// 供「逐笔归因」使用:把某笔调用的负载与对照组的同一笔相减,
/// 差额就是「这一笔上某一层代理省下的量」。
///
/// 参数 `results`:批次响应。
/// 返回:与批次等长的负载列表。
pub fn per_query_units(results: &[LabeledOutcome]) -> Vec<u64> {
results
.iter()
.map(|item| item.outcome.workload.units())
.collect()
}
/// 两次运行的节省对照。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SavingsReport {
/// 对照组标签。
pub baseline_label: &'static str,
/// 对照组账目。
pub baseline: WorkloadBreakdown,
/// 候选组标签。
pub candidate_label: &'static str,
/// 候选组账目。
pub candidate: WorkloadBreakdown,
/// 省下的单元数(对照组 − 候选组;为负表示候选组更贵)。
pub saved_units: i64,
/// 节省率(万分点,分母为对照组总负载)。
pub saved_ratio_basis_points: i64,
/// 省下的跨区往返次数。
pub saved_round_trips: i32,
/// 省下的装载单元。
pub saved_open_units: i64,
}
/// 比较两次运行。
///
/// 参数 `baseline` / `candidate`:`(标签, 装配结果, 响应)` 三元组。
/// 返回:节省对照。
///
/// ⚠️ 调用方必须保证两组**跑同一批查询**。若批次不同,
/// 这个比较毫无意义——而它不会有任何报错,只会给出一个错的数字。
/// 本工程的做法是让分析与报表共用同一个 `Vec<LabeledQuery>`,
/// 从源头上不可能跑错批次。
pub fn compare_savings(
baseline_label: &'static str,
baseline_chain: &AssembledProxyChain,
baseline_results: &[LabeledOutcome],
candidate_label: &'static str,
candidate_chain: &AssembledProxyChain,
candidate_results: &[LabeledOutcome],
) -> SavingsReport {
debug_assert_eq!(
baseline_results.len(),
candidate_results.len(),
"节省对照的两组结果条数不同 —— 它们跑的不是同一批查询,比较结论无效"
);
let baseline: WorkloadBreakdown = build_breakdown(baseline_chain, baseline_results);
let candidate: WorkloadBreakdown = build_breakdown(candidate_chain, candidate_results);
let saved_units: i64 =
baseline.decomposed_total_units as i64 - candidate.decomposed_total_units as i64;
let saved_ratio: i64 = if baseline.decomposed_total_units == 0 {
0
} else {
saved_units * 10_000 / (baseline.decomposed_total_units as i64)
};
// 先把「相对量」算完再搬移两个账目——`WorkloadBreakdown` 不是 `Copy`,
// 顺序反了就会在编译期报「use of moved value」。
// 这不是绕开检查,而是让求值顺序与数据流的依赖方向一致。
let saved_round_trips: i32 = baseline.round_trip_count as i32 - candidate.round_trip_count as i32;
let saved_open_units: i64 = baseline.open_units as i64 - candidate.open_units as i64;
SavingsReport {
baseline_label,
baseline,
candidate_label,
candidate,
saved_units,
saved_ratio_basis_points: saved_ratio,
saved_round_trips,
saved_open_units,
}
}
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。
