Skip to content

Commit 85e86af

Browse files
committed
feat(core): 添加历史记录导出及测试
在 Core 中引入历史记录导出功能:添加 IHistoryExportService 与 HistoryExportService(支持点名/抽奖两类历史的 XLSX(明细+汇总)与 CSV(明细)导出),导出使用来自调用方的 HistoryExportLabels 与 IHistoryQueryService 快照,不会切换或重写活动配置文件;导出仅包含用户可见字段(不含内部 RecordId 等)。 同时新增大量单元测试(HistoryExportTests、GenderOptionResolverTests)并扩展现有测试(CoreRuntimeServicesTests、ProofChainTests),并更新 AGENTS.md 与 CHANGELOG 描述导出功能与行为要点。
1 parent 894a267 commit 85e86af

32 files changed

Lines changed: 1970 additions & 78 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Keep this map short and stable. When code moves, AI agents should re-read the mo
115115
| `IArchivePostImportHooks` | seam | `SecRandom.Core/Services/Archive/IArchivePostImportHooks.cs` | Platform follow-up after archive import; Core registers Null hooks, desktop overrides them. |
116116
| `ProfileCatalogManager` | domain service | `SecRandom.Core/Services/Profiles/ProfileCatalogManager.cs` | List/profile CRUD and student/prize history clearing behind `IProfileCatalogManager`. |
117117
| `RosterImportParser` | parser | `SecRandom.Core/Services/Profiles/RosterImportParser.cs` | Shared roster spreadsheet parsing and column mapping for desktop/mobile imports. |
118+
| `IHistoryExportService` / `HistoryExportService` | domain service | `SecRandom.Core/Services/HistoryQuery/HistoryExportService.cs` | Exports one history kind (roll-call or lottery, one list/pool or all of them) to XLSX (detail + summary sheets) or CSV (detail only) from `IHistoryQueryService` snapshots; user-facing headers, cell values, and sheet names come from the caller's `HistoryExportLabels`, never from Core. |
118119
| `IRosterQrCameraCapture` / `IRosterQrCameraCaptureFactory` | app service seam | `SecRandom/Services/RosterTransfer/IRosterQrCameraCapture.cs` | Common in-memory frame, permission, error, and lifetime boundary for list-import QR cameras. It resolves named `IPlatformCameraDeviceCatalog` devices from Host: Windows uses DirectShow/OpenCV for physical and virtual cameras, while macOS/Linux use AVFoundation/V4L2 indexes and Android maps Camera2 camera IDs to CameraView facing. |
119120
| `IPlatformCameraDeviceCatalog` | platform contract | `SecRandom.Platforms.Abstractions/IPlatformCameraDeviceCatalog.cs` | Supplies the active platform's camera device ID, display name, capture index, and facing hint without leaking native APIs into the app layer. |
120121
| `MobileRollCallService` | mobile service | `SecRandom/Services/Mobile/MobileRollCallService.cs` | Mobile list/scope/count snapshots, multi-member draws, remaining list, and scoped temporary reset without changing the Core session contract. |
@@ -161,7 +162,7 @@ Keep this map short and stable. When code moves, AI agents should re-read the mo
161162
- File persistence saves are atomic replacements: `FileConfigService.SaveConfig` and `DrawTemporaryRecordService.SaveState` write a temporary file and atomically replace the target; new file-persistence code must follow the same pattern instead of overwriting files in place.
162163
- Draw proofs use the SecRandom v3 algorithm ID and an `algorithmEngineVersion`. Engine v3.1 commits the sampling mode into the request: student draws use history-balanced weighted sampling, count-based lottery inventory uses equal-probability partial permutation when no internal rule applies, and pan or internal-rule lottery draws use weighted sampling without replacement. Keep the verifier's replay logic byte-compatible with each supported proof engine version.
163164
- Ordinary draws immediately save an `OfflineReproducible` proof, then submit that newly generated proof to `fair.sectl.cn` for background replay/signing. A submission must use the proof object captured at draw time and never a re-read of the proof file: `DrawProofAttestationService` keeps the pending item in memory, persists only its path, proof ID, and canonical proof hash to `data/proofs/attestation-queue.json`, retries failures with bounded exponential backoff, resumes queued items after a restart, and refuses plus reports an item whose file no longer matches the recorded hash (it then keeps the receipt in the queue instead of overwriting the changed file). Startup must never scan or re-attest historical proof files that were never queued. A current server receipt signs a canonical hash of all proof fields except the receipt itself, plus the proof ID, input hash, payload hash, anonymous audit-payload hash, and mode; public sharing requires that receipt. This detects changes after the signed submission, but it does not attest to an unmodified local executable, an authentic real-world candidate pool, or absence of pre-draw result selection. Do not call it a pre-draw server witness. Local proof retention applies both the configured age and total storage limits; after every new save, delete oldest `.srproof.json` files until the configured storage cap is met.
164-
- `data/proofs` carries a local append-only evidence chain: `DrawProof.Chain` stores `index`/`prevHash`/`selfHash` (`selfHash = SHA256("SecRandomProof/v3/chain" ‖ index ‖ prevHash ‖ canonicalProofHash)`), and `ProofChainStore` keeps the head plus a `retainedFromIndex` floor in `data/proofs/chain-head.json`. Retention and storage-limit cleanup must record every proof they delete through `RecordRemovedIndices`, so an absence below the floor is explained and only an unrecorded prefix/interior gap is reported; `ProofIntegrityVerifier` is the only reader that judges the chain. Chain fields and `witness.timestampToken` must never enter `WitnessClient.ComputeAttestedProofHash` — the server recomputes that hash, so adding fields there invalidates every receipt. A local chain buys visibility, not protection: an attacker who controls the machine can rebuild chain and head consistently.
165+
- `data/proofs` carries a local append-only evidence chain: `DrawProof.Chain` stores `index`/`prevHash`/`selfHash` (`selfHash = SHA256("SecRandomProof/v3/chain" ‖ index ‖ prevHash ‖ canonicalProofHash)`), and `ProofChainStore` keeps the head plus a `retainedFromIndex` floor in `data/proofs/chain-head.json`. Retention and storage-limit cleanup must record every proof they delete through `RecordRemovedIndices`, so an absence below the floor is explained and only an unrecorded prefix/interior gap is reported; the floor may only advance across a contiguous prefix of recorded removals, because a sparse deletion (removing index 10 while 1-9 survive) would otherwise explain away every hole below it; storage-limit cleanup must also never delete the proof the same save just wrote, since that leaves the chain head pointing at a file that never landed and hands the attestation queue a path that no longer exists; `ProofIntegrityVerifier` is the only reader that judges the chain. Chain fields and `witness.timestampToken` must never enter `WitnessClient.ComputeAttestedProofHash` — the server recomputes that hash, so adding fields there invalidates every receipt. A local chain buys visibility, not protection: an attacker who controls the machine can rebuild chain and head consistently.
165166
- The anonymous audit payload is validated by `fair.sectl.cn` against a fixed property allow-list (`ValidateAnonymousAuditPayload` / `ValidateFairness`), so adding a field there requires the server change to land first — the client must never ship a new audit field before the service accepts it, or every ordinary attestation fails with `audit_payload_invalid`. `rosterDigest` was added that way: `RosterDigest.Compute` hashes the canonical RecordId-to-display mapping (entries sorted by record id, so reordering is not a change while renaming, renumbering, regrouping, or disabling is) of the roster the draw was taken from, and `VerificationDrawCoordinator` omits it when the active profile is not the drawn list. It is the only commitment that ties a winning record id back to a person, because the candidate pool itself is anonymous — without it, editing the list after the draw silently re-points a winning record id at someone else while every receipt, replay check, and time stamp still validates. Only the digest leaves the device, never names or numbers; the digest of a low-entropy roster does allow someone who already knows nearly the whole list to confirm a membership guess. RFC 3161 time stamping is always on and has no user-facing setting: `TimestampAuthorityClient` sends only the canonical proof hash to the fixed authority `https://tsa.wotrus.com/`, stores the returned token in `witness.timestampToken`, and validates its signature plus the certificate chain, accepting either the operating-system trust store or the pinned WoTrus (沃通) root it embeds (revocation is not checked so validation stays offline-capable). The authority is deliberately not configurable — a user-facing URL would let a broken or hostile endpoint silently disable the anchor — and it is reachable from mainland networks, unlike the free public authorities. A time stamp proves the digest existed no later than the stamped time and never that the draw happened then.
166167
- `fair.sectl.cn` anchors the local proof chain: `DrawProofAttestationService` submits the proof including `chain`, and the attest response's `chain.status` (`genesis`/`continuous`/`duplicate`/`broken`/`gap`/`behind`/`conflict`) is surfaced as `ProofAttestationStatus.ChainAlertCount` and logged. A `behind` status means the service already holds a higher chain position for this device, which is how a deleted-and-rebuilt local chain becomes visible; the server-side anchor never moves backwards. Receipt and time-stamp failures are counted separately (`ReceiptFailedCount`/`TimestampFailedCount`) so an unavailable third-party authority never reads as a failed attestation.
167168
- Formal notarization is the explicit alternative to ordinary replay attestation: the client sends a zero-seed anonymous request, `fair.sectl.cn` durably locks it, persists server random material after the lock, calculates the result, and returns an immutable `OnlineWitnessed` proof. Formal mode waits for that response and must never substitute a local draw after an error; its ledger is independent of retention-limited public proof sharing. It protects the locked flow against local code/seed/proof replacement, but cannot establish real-world roster authenticity, completeness, or pre-submission pool integrity.

‎CHANGELOG/v3/v3.0.0-release.1/CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- 新增 **第三方时间戳与名单摘要**,每份证明都会附上时间戳,并记录抽取时所用名单的摘要;抽完再改名单、把中签记录指向别人同样会被发现(只上传摘要,不发送名单和抽取内容)
2121
- 新增 **使用统计上报**,只统计每天的点名、抽奖与启动次数,不带时间、账号或位置信息,在隐私设置中选择「停止」即不再上报;另外每次启动会匿名上报一次版本号与设备标识,仅用于统计各版本使用人数
2222
- 新增 **云备份可选包含抽取证明**,默认关闭;本地证明被删除时,云端备份就是最后一份证据
23+
- 新增 **历史记录导出**,在设置的历史记录管理中可把点名历史或抽奖历史导出为 Excel(`.xlsx`)或 CSV 文件,可选单个名单/奖池或一次导出全部;Excel 分为抽取记录与汇总两张工作表,只导出可见字段,文件名带时间戳
2324

2425
## 💡 功能优化
2526

@@ -29,6 +30,7 @@
2930
- 优化 **密钥与外部地址限制**,语音服务密钥与接口地址绑定,更换地址后需要重新填写;头像下载只允许访问官方地址,且不会带上登录令牌
3031
- 优化 **安全凭据保护**,存放密码与免密 TOTP 种子的文件夹会收紧为只有当前用户可打开,同一台电脑上的其他账户读不到里面的文件(管理员与系统账户仍可访问),已有安装在下次启动时自动收紧;TOTP 种子被改坏时按验证失败处理,不再中断验证流程
3132
- 优化 **设置防篡改校验**,在完整性确认弹窗挂起期间不再执行敏感命令与 secrandom:// 链接
33+
- 优化 **公平性自检工具**,改为直接使用软件实际抽取时所用的算法,并在报告开头写明「能证明什么、不能证明什么」,避免把自检结果误当成对真实抽取过程的担保
3234

3335
## 🐛 修复问题
3436

@@ -38,6 +40,12 @@
3840
- 修复 **账号登录状态**,刷新登录令牌时先把新的访问令牌与刷新令牌一起原子写入磁盘再使用,多个请求同时刷新只会发出一次请求;服务端返回 `invalid_grant` 等终态错误时立即停止重试并清空本地令牌,访问令牌被吊销时不再白白消耗刷新令牌,限流与服务不可用则按提示退避后重试同一次刷新,避免旧令牌被反复提交导致账号被反复退出登录
3941
- 修复 **Android 版本号显示**,平板与手机上的版本号显示为 `v0.0.0.0`,导出的设置与备份也会因版本不符被自己拒绝导回的问题[#289](https://github.com/SECTL/SecRandom/issues/289)
4042
- 修复 **移动端更新检查**,无法识别 `3.0.0-beta.1` 这类预发布版本号,导致始终提示「已是最新」的问题
43+
- 修复 **开启「抽后排除」后抽不出来就报错**,如果剩下的成员全都还在排除时间里,点抽取会直接弹错误;现在会友好提示「没有可抽取的成员」或「没有可抽取的奖品」,等排除时间过去就能继续抽
44+
- 修复 **换一下筛选就能重复抽到同一个人**,点名时先按某个分组抽过的成员,切到别的分组或「全部」后会被当成没抽过;现在无论怎么切换性别、分组筛选,抽过的人都按名单的抽取记录计算,绕不过「不重复」(「重置」仍会清空该名单的点名记录)
45+
- 修复 **「抽取验证」把被删掉的证明当成正常过期**,只删掉其中几份证明时,被删的那几份会被算作「到期自动清理」而不报警;现在只有从最早开始连续的一整段才会算作正常清理,中间凭空少掉的一律提示异常
46+
- 修复 **刚抽完的证明马上被删掉**,当「本地证明存储上限」设得较小时,新保存的证明可能在保存后立刻被容量清理删掉,让「抽取验证」里出现没落盘的证明;现在刚保存的那份一定会保留
47+
- 修复 **用网址或联动命令切换性别**,以前只认中文「男」「女」,名单里的性别写的是 `M`/`F`、`Male`/`Female` 等写法时会提示参数错误;现在这些写法都能正常切换
48+
- 修复 **证明没有记录名单摘要时毫无提示**,当抽取用的名单不是当前打开的名单时,证明不会记录名单摘要(这类证明无法把中奖记录对应回具体成员),以前完全没有提示;现在会在日志中写明原因,方便区分「已记录名单」和「未记录名单」的证明
4149

4250
## 🙏 贡献者
4351

‎SecRandom.Core.Tests/CoreRuntimeServicesTests.cs‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
using Microsoft.Extensions.Logging;
44
using SecRandom.Core.Abstraction;
55
using SecRandom.Core.Abstraction.Services;
6+
using SecRandom.Core.Enums;
67
using SecRandom.Core.Enums.Configs;
78
using SecRandom.Core.Models;
89
using SecRandom.Core.Services;
910
using SecRandom.Core.Services.Config;
1011
using SecRandom.Core.Services.Draw;
12+
using SecRandom.Core.Services.Draw.Exceptions;
1113
using SecRandom.Core.Models.Draw;
1214
using SecRandom.Shared;
1315
using SecRandom.Shared.Models.Profile;
@@ -250,6 +252,75 @@ public void ProfileCatalogEditor_UsesRecordIdsAndPersistsMutations()
250252
Assert.Empty(profile.CurrentPrizeList!.Prizes);
251253
}
252254

255+
[Fact]
256+
public void StudentTemporaryRecords_AreNotHiddenBySwitchingTheDrawFilter()
257+
{
258+
using var provider = CreateProvider();
259+
var temporary = provider.GetRequiredService<IDrawTemporaryRecordService>();
260+
var student = new Student { Name = "Switch", RecordId = Guid.NewGuid() };
261+
262+
temporary.RecordStudents("filter-class", "男", "A组", [student]);
263+
264+
var recordId = student.RecordId.ToString("D");
265+
Assert.Equal(1, temporary.GetStudentCounts("filter-class", "男", "A组")[recordId]);
266+
// Switching the group/gender filter must not restart the repeat limit for an already drawn member.
267+
Assert.Equal(1, temporary.GetStudentCounts("filter-class", string.Empty, string.Empty)[recordId]);
268+
Assert.Equal(1, temporary.GetStudentCounts("filter-class", "女", "B组")[recordId]);
269+
}
270+
271+
[Fact]
272+
public void StudentTemporaryRecords_MergeLegacyFilterBucketsOnUpgrade()
273+
{
274+
using var provider = CreateProvider();
275+
var temporary = provider.GetRequiredService<IDrawTemporaryRecordService>();
276+
var student = new Student { Name = "Legacy", RecordId = Guid.NewGuid() };
277+
var recordId = ProfileRecordIdentity.EnsureRecordId(student);
278+
279+
var legacy = $$"""
280+
{
281+
"listName": "legacy-class",
282+
"updatedAt": "2026-01-02T00:00:00+00:00",
283+
"scopes": {
284+
"gender=男|group=A组": { "records": { "{{recordId}}": { "name": "Legacy", "id": "1", "count": 2, "lastDrawnTime": "2026-01-01T00:00:00+00:00" } } },
285+
"gender=女|group=B组": { "records": { "{{recordId}}": { "name": "Legacy", "id": "1", "count": 3, "lastDrawnTime": "2026-01-02T00:00:00+00:00" } } }
286+
}
287+
}
288+
""";
289+
var legacyPath = Utils.GetFilePath("TEMP", "roll_call_record_legacy-class.json");
290+
Directory.CreateDirectory(Path.GetDirectoryName(legacyPath)!);
291+
File.WriteAllText(legacyPath, legacy);
292+
293+
// The upgrade must sum the old per-filter buckets instead of silently restarting the count.
294+
Assert.Equal(5, temporary.GetStudentCounts("legacy-class", string.Empty, string.Empty)[recordId]);
295+
}
296+
297+
[Fact]
298+
public void VerificationInput_ReportsNoEligibleCandidatesWhenEveryMemberIsInsideTheShieldWindow()
299+
{
300+
using var provider = CreateProvider();
301+
var config = provider.GetRequiredService<MainConfigHandler>();
302+
config.Data.RollCallSettings.DefaultClass = "shield-class";
303+
config.Data.RollCallSettings.AlgorithmId = "builtin.fair";
304+
config.Data.FairDrawSettings.FairDraw = true;
305+
config.Data.FairDrawSettings.FairDrawGroup = false;
306+
config.Data.FairDrawSettings.FairDrawGender = false;
307+
config.Data.FairDrawSettings.ShieldEnabled = true;
308+
config.Data.FairDrawSettings.ShieldTime = 60;
309+
config.Data.FairDrawSettings.ShieldTimeUnit = ShieldTimeUnit.Minutes;
310+
config.Save();
311+
312+
var profile = provider.GetRequiredService<IProfileService>();
313+
var student = new Student { Name = "Shielded", RecordId = Guid.NewGuid() };
314+
profile.CurrentStudentList!.Students.Add(student);
315+
profile.SaveProfile();
316+
profile.RecordStudentHistory([student], DateTime.Now, 1);
317+
318+
var engine = provider.GetRequiredService<DrawEngine>();
319+
320+
Assert.Throws<NoEligibleCandidatesException>(() =>
321+
engine.CreateStudentVerificationInput(1, [student], DrawSettingsType.RollCall));
322+
}
323+
253324
public void Dispose()
254325
{
255326
ResetDataRootForTests();

0 commit comments

Comments
 (0)