Skip to content

Commit 6c1cb2c

Browse files
Merge
2 parents e0b1912 + 85e86af commit 6c1cb2c

52 files changed

Lines changed: 3495 additions & 261 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 9 additions & 5 deletions
Large diffs are not rendered by default.

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

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,37 @@
1515

1616
## 🚀 主要更新
1717

18-
- 新增 **Sudo 模式**,验证通过后可在设定时长内跳过抽取、窗口等敏感操作的重复验证(默认 60 秒,可在安全设置中调整为 10 至 600 秒);设置页面有独立有效期,关闭设置窗口即失效,配置 TOTP 仍始终需要重新验证;托盘菜单新增「退出 Sudo 模式」,只清除全局免验证状态[#283](https://github.com/SECTL/SecRandom/pull/283)
19-
- 新增 **证明链与提交队列**,抽取证明按追加式哈希链保存,普通模式的证明会立即保存并在后台提交服务端重放验证,失败自动按退避重试、断网或重启后继续提交;事后修改、删除旧证明、删光重建以及提交期间证明被改动都能被发现(服务端会记住每台设备见过的链头),「抽取验证」页可查看链的缺口、断链、被改动、超出链头、未落盘与过期,以及队列的待提交、失败、冲突与告警计数
20-
- 新增 **时间戳与名单摘要**,每份证明都会申请 RFC 3161 第三方时间戳,并承诺抽取时所用名单的「记录 ID → 姓名/学号/组别」映射摘要,抽取之后再改名单、把中签记录指向别人同样会被发现;两者都只发送摘要,不发送名单或抽取内容
21-
- 新增 **使用统计上报**,统计点名、抽奖与启动次数,上报字段与每日/每周/每月/总计四个周期口径均与 v2 一致;在隐私设置中选择「停止」后不再上报
22-
- 新增 **云备份可选包含抽取证明**,默认关闭,因为本地证明被删除后云端才是最后一份证据
18+
- 新增 **Sudo 模式**,验证一次后,设定时间内(默认 60 秒,可在安全设置中调为 10 至 600 秒)抽取、开窗口等操作不用再反复验证;设置页单独计时、关闭即失效,配置 TOTP 仍需重新验证;托盘菜单新增「退出 Sudo 模式」[#283](https://github.com/SECTL/SecRandom/pull/283)
19+
- 新增 **抽取证明链与后台验证**,每份证明都会连成一条链,并自动在后台提交官方验证,断网或重启后继续重试;事后修改证明、删除旧证明、删光重建都能被发现,「抽取验证」页可查看链状态与提交队列
20+
- 新增 **第三方时间戳与名单摘要**,每份证明都会附上时间戳,并记录抽取时所用名单的摘要;抽完再改名单、把中签记录指向别人同样会被发现(只上传摘要,不发送名单和抽取内容)
21+
- 新增 **使用统计上报**,只统计每天的点名、抽奖与启动次数,不带时间、账号或位置信息,在隐私设置中选择「停止」即不再上报;另外每次启动会匿名上报一次版本号与设备标识,仅用于统计各版本使用人数
22+
- 新增 **云备份可选包含抽取证明**,默认关闭;本地证明被删除时,云端备份就是最后一份证据
23+
- 新增 **历史记录导出**,在设置的历史记录管理中可把点名历史或抽奖历史导出为 Excel(`.xlsx`)或 CSV 文件,可选单个名单/奖池或一次导出全部;Excel 分为抽取记录与汇总两张工作表,只导出可见字段,文件名带时间戳
2324

2425
## 💡 功能优化
2526

26-
- 优化 **在线状态上报**,不再由客户端上报公网 IP 与粗略地区(也不再请求第三方 IP 查询服务),位置改由服务端按访问地址统计,客户端只上报设备标识与设备类型
27-
- 优化 **归档与插件包校验**,为更新下载、插件市场索引与插件包引入压缩/解压/条目/清单大小预算,边读边计数以阻止解压炸弹与超大响应,被拒绝的插件包会标记为 .rejected;云端备份的清单、分片与整个归档增加长度上限、SHA-256 一致性与溢出保护
28-
- 优化 **路径与文件名校验**,名单的创建、重命名、导入与切换会拒绝相对路径、Windows 保留设备名与非法字符,Android 文件访问统一按 canonical 路径判断可访问性与可变性,安全凭据与语音凭据目录不可删除、重命名或移动
29-
- 优化 **凭据与外部地址限制**,OmniTTS 密钥与提供商及其规范化接口地址绑定,更换接口地址需要重新填写,旧版只按提供商保存的密钥不再自动信任;头像下载仅允许固定的 SECTL HTTPS 地址,且不会携带登录令牌
30-
- 优化 **设置文件完整性校验**,在完整性确认弹窗挂起期间阻止执行敏感命令与 secrandom:// 协议调用
27+
- 优化 **在线状态上报**,不再上传 IP 与地区,位置改由服务器按访问地址统计,客户端只上报设备标识与设备类型
28+
- 优化 **文件安全检查**,下载更新、安装插件、导入备份时都会限制文件大小并校验完整性,异常的超大或损坏文件会被直接拒绝,被拒绝的插件包会标记出来
29+
- 优化 **名单名称与文件访问校验**,创建、重命名、导入名单时会拒绝非法字符、相对路径和系统保留名;Android 上安全凭据与语音凭据目录不能再被删除、重命名或移动
30+
- 优化 **密钥与外部地址限制**,语音服务密钥与接口地址绑定,更换地址后需要重新填写;头像下载只允许访问官方地址,且不会带上登录令牌
31+
- 优化 **安全凭据保护**,存放密码与免密 TOTP 种子的文件夹会收紧为只有当前用户可打开,同一台电脑上的其他账户读不到里面的文件(管理员与系统账户仍可访问),已有安装在下次启动时自动收紧;TOTP 种子被改坏时按验证失败处理,不再中断验证流程
32+
- 优化 **设置防篡改校验**,在完整性确认弹窗挂起期间不再执行敏感命令与 secrandom:// 链接
33+
- 优化 **公平性自检工具**,改为直接使用软件实际抽取时所用的算法,并在报告开头写明「能证明什么、不能证明什么」,避免把自检结果误当成对真实抽取过程的担保
3134

3235
## 🐛 修复问题
3336

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

3850
## 🙏 贡献者
3951

‎DownStream_Change.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
| 6 | CI 与构建修复 | `build_publish.yml`、Android 版本号、密钥脚本 | **高**(同一文件多处改) |
2828
| 7 | TSA 时间戳改为可显式关闭 + 沃通隐私提示 | 验证设置页、时间戳客户端、OOBE 隐私政策提示 | 中高(改了验证链路的网络行为开关) |
2929
| 8 | 新增「自有参考链(Own)」补充声明 | 新增 `*.ownproof.json`、自有链头、完整性报告字段 | **高**(新增落盘格式) |
30-
| 9 | 跨境数据传输闸门 + 抽取上传改为无默认二选一 | 新增枚举/配置、OOBE 与设置页、6 类 SECTL 出网路径 | **高**(改动了所有出境路径的开关语义) |
30+
| 9 | 跨境数据传输闸门 + 抽取上传改为无默认二选一 | 新增枚举/配置、OOBE 与设置页、6 类 SECTL 出网路径、`SectlAuthService`/`SectlHeartbeatService`/`PlatformVersionReportService` 构造函数 | **高**(改动了所有出境路径的开关语义,并与上游的令牌轮换重写叠加) |
3131

3232
---
3333

@@ -246,6 +246,9 @@
246246

247247
1. **先读本文件**,再 merge/rebase 上游。
248248
2. 高冲突文件:`build_publish.yml`、`SecRandom/App.axaml.cs`、`PrivacySettingsConfig.cs`、`UpdateCenterService.cs`、`VerificationDrawCoordinator.cs`、`WitnessClient.cs`、`FirstRunOobeViewModel.cs`、`FirstRunOobeWindow.axaml`、各 `Resources*.resx`。
249+
上游 2026-10 同步后新增的高冲突面(已实际发生过冲突):`SecRandom/Services/Verification/DrawProofExportService.cs`(上游给 `RemoveProofsOverStorageLimit` 加了 `protectedPath`,与自有参考证明的成对删除同处一个方法)、`SecRandom/Services/Verification/ProofChainStore.cs`(`RecordRemovedIndices` 的「只能跨连续前缀推进」收紧)、`SecRandom/Services/Auth/SectlAuthService.cs`(上游重写为「单次使用的轮换刷新令牌 + 单飞刷新 + 跨进程锁 `sectl-auth.lock`」,构造函数新增 `SectlTokenStore` 与 `ILogger`,与我们的 `MainConfigHandler` 出境闸门参数叠加)、`SecRandom/Services/Auth/SectlTokenStore.cs`(上游新增),以及三个构造 `SectlAuthService` 的测试文件(`SectlAuthServiceTests`、`SectlCloudStorageClientTests`、`CloudBackupServiceTests`)。
250+
合并口径:**两侧都要保留**——上游的令牌轮换/链规则与本分支的 `MainConfigHandler` + `SectlTrafficPolicy` 出境闸门必须同时存在于构造函数与调用点。
251+
AGENTS 类文件同样属于高冲突面:`SecRandom.Core/AGENTS.md`、`SecRandom/AGENTS.md`、`SecRandom/Views/SettingsPages/AGENTS.md` 由上游同步更新;本分支的补充段落需逐条确认仍然存在,尤其 §3(双链)、§4(隐私默认)、§10(TSA 开关)、§12(跨境闸门)。
249252
3. 上游若新增/改动隐私政策或 OOBE 版本,需同步更新 `SecRandomPrivacyPolicy` 的「引用自 SecRandom vX.Y.Z」前置说明与 `CurrentPrivacyPolicyVersion`。
250253
4. 上游若改动 `PluginApiVersions.Current` 主号,CI 回退版本会自动跟随;无需修改工作流。
251254
5. 上游若重新启用/调整更新通道,需重新评估 `GlobalConstants.UpdatesEnabled` 的取值与端点替换。
@@ -257,7 +260,7 @@
257260

258261
- 新增改动:追加到对应主题,并在「§0 变更索引」表格更新范围/风险。
259262
- 与 `ChangeLog.md` 的分工:本文件是**面向工程的分歧台账**(改了什么文件、为什么必须活下来),`ChangeLog.md` 是**面向用户的发版日志**(按版本发布时间、只追加)。发版时两边都要有:`ChangeLog.md` 写用户可见效果,本文件登记分歧与实现边界。上游同步带来的核心日志进 `CHANGELOG/`,不要写进本文件,也不要写进 `ChangeLog.md`。
260-
- 新增主题:在 §1–§11 之后追加一节,并更新索引。
263+
- 新增主题:在 §1–§12 之后追加一节,并更新索引。
261264
- 删除或回退某改动:保留条目并标注「已回退(日期/原因)」,不要直接删除历史。
262265
- 此后每次改动请**就地更新**,不要依赖事后重建。
263266

‎SecRandom.Android/MobileEntryPoint.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Avalonia.Android;
99
using CameraView;
1010
using CameraView.Platforms.Android;
11+
using SecRandom.Core;
1112
using SecRandom.Core.Abstraction;
1213
using SecRandom.Platforms;
1314
using SecRandom.Platforms.Abstractions;
@@ -28,6 +29,9 @@ protected MobileApplication(nint javaReference, JniHandleOwnership transfer)
2829

2930
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
3031
{
32+
// Android 没有托管 Main,Assembly.GetEntryAssembly() 恒为 null;不显式发布头程序集时,
33+
// 版本元数据会回退到不含 git 信息的 SecRandom.Core 并显示为 v0.0.0.0
34+
GlobalConstants.SetVersionAssembly(typeof(MobileApplication).Assembly);
3135
RegisterUnhandledExceptionHooks();
3236
var screenLayout = Resources?.Configuration?.ScreenLayout ?? ScreenLayout.SizeNormal;
3337
var isTablet = (screenLayout & ScreenLayout.SizeMask) >= ScreenLayout.SizeLarge;

‎SecRandom.Core.Tests/CloudBackupServiceTests.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,8 @@ private CloudBackupService CreateService(RecordingHandler handler, byte[]? archi
500500
// The fork's cross-border consent gates every SECTL call; the test exercises the enabled behaviour.
501501
configHandler.Data.General.Basic.AcceptedCrossBorderTransferVersion = 1;
502502
var deviceUuidStore = new DeviceUuidStore(configHandler, NullLogger<DeviceUuidStore>.Instance);
503-
var authService = new SectlAuthService(factory, deviceUuidStore, configHandler);
503+
var authService = new SectlAuthService(TestTokenStore.Create(), factory, deviceUuidStore,
504+
NullLogger<SectlAuthService>.Instance, configHandler);
504505
SetToken(authService, new SectlToken("access-token", "refresh-token", "user-1", 3600));
505506
var cloudClient = new SectlCloudStorageClient(authService, factory, NullLogger<SectlCloudStorageClient>.Instance);
506507
return new CloudBackupService(authService, cloudClient, configHandler, new FakeImportExportService(archive ?? []),

‎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)