器材手持机换账号后,旧离线草稿还能重试吗?C#范围与会话代次复现
先核对草稿创建时的服务、租户和账号,再核对发起上传时的会话代次。归属不符时保留原草稿,旧回调不能修改当前会话;即使客户端拒收了回调,仍要核对服务端是否已经受理。
用一个MCN器材交接场景举例:白班场务在同一台手持机留下相机归还草稿,晚班换账号继续工作。白班发起的上传还没收到响应,账号已经切换。IT此时要排查两件事:晚班能不能上传白班的旧草稿,以及白班的结果晚到后会不会把当前状态改掉。下面使用演示身份复现,不是客户事故记录。
器材交接场景示意:同一台手持机轮换使用,草稿归属仍要按创建时的范围核对。
换了账号,为什么不能只检查当前租户?
同一租户内也会换操作员,同一个操作员还可能退出后重新登录。仅凭“租户没变”或“用户名还是原来那个”,无法判断一个旧异步任务是否属于现在这次会话。
| 需要核对的对象 | 它回答什么 | 单独不能证明什么 |
|---|---|---|
| 业务缓存的活动租户 | 这份本地业务数据属于哪个租户 | 当前操作员能否上传某条旧草稿 |
| 草稿创建者范围 | 草稿由哪个服务、租户和用户创建 | 这次登录是否仍是发起上传的那次登录 |
| 上传捕获的会话代次 | 回调是否仍属于同一轮会话 | 服务端是否已执行、请求是否有业务权限 |
因此,草稿归属应当来自创建时的可信上下文,上传时再核对当前会话。范围键是定位和比较依据,不能替代真实认证、接口授权,也不等于每个账号都有独立数据库或加密文件。
当前归还链路在哪里拦住旧范围?
我参与AssetaGuard工程资料核对,这里只取当前移动端的选定归还草稿路径作为工程案例;没有把源码存在当作真机验收结果。
业务缓存这一层,当前实现保存一个活动租户名称和ID。纯分类函数遇到同名且ID兼容时允许继续;跨租户而仍有待上传内容时阻止清理切换。它不接收账号或服务地址,不能用这一个函数推断所有本地数据都按账号、服务器物理分开。当前代码还保留一侧租户ID缺失的兼容分支,不能把它描述成“两边ID始终齐全并完全一致”。
草稿这一层,当前范围键由服务地址、租户和用户组合后生成摘要索引。选定归还草稿在创建时记录该范围;同步入口再核对它。一个容易误读的细节是:底层归属辅助方法对旧草稿的空范围保留兼容,但完整上传入口另有空归属拦截,会保留原草稿并记录draft_scope_unknown。不能只截取辅助方法的返回值,就把未知归属的草稿自动认给新账号。
异步上传这一层,当前路径在发起前捕获范围和会话代次,在定位借单、提交归还等异步工作返回后再次核对。会话保存或清理会改变代次,所以同一用户重新登录也可能使原任务失效。普通令牌刷新与建立新会话不能混为一谈,须按实际调用路径核对。
会话变化后,选定路径保留原草稿,提示回原账号核对后重试;确认业务回执后才移除草稿。登录页判断能否切换租户时,还会计入保留的永久失败草稿,而不是只数自动重试队列。
这里的两次检查回答“旧任务还能否继续处理本地状态”,没有撤销远端业务的能力。假如远端已经完成归还,客户端随后发现会话变化,丢弃回调也不能让那次归还倒退。
怎样把晚到回执做成可重复的输入?
让模拟端先记录受理,让接收任务实际等待尚未完成的回执,切换会话后才释放回执,就能固定复现顺序,不必依赖随机断网或固定延时。
下面使用TaskCompletionSource<Receipt>控制回执何时完成,语义见微软TaskCompletionSource文档。它只控制任务完成,不会替你建立账号权限或撤回已执行的工作。
示例把切换会话、检查票据和写入结果放在同一实例的专用object锁下,避免在检查与写入之间插入本实例的切换动作;await在锁外。锁的互斥范围见微软C# lock说明。这仍不是多进程文件锁,也没有验证所有并发时序。
示例与项目有几处刻意分开:
- 演示范围直接使用三元组,不计算摘要;比较采用精确字符串相等,没有复刻产品所有地址和租户规范化规则。
Switch只模拟新会话,身份由测试驱动直接指定;它不是登录实现。模拟端信任测试票据,不实现生产认证和授权,不能直接拿来做接口。- 项目选定成功路径移除已确认草稿;示例保留
Synced=true,方便从JSON回读结果。 - 模拟端只在内存里按“范围+草稿标识”复用结果,用来观察客户端拒收与模拟受理之间的区别;没有实现数据库事务、真正网络回放或完整服务端幂等协议。
怎样在独立文件夹运行这份示例?
只需下面两个源文件,无需项目仓库、Unity或额外NuGet包。先保存ScopeReplayDemo.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup><Compile Include="Program.cs" /></ItemGroup>
</Project>
再保存完整的Program.cs:
using System.Text.Json;
if (args.Length != 1) return 2;
var output = Path.Combine(Path.GetFullPath(args[0]), "run-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(output);
var checks = new List<Check>();
void Check(string name, bool passed) => checks.Add(new(name, passed));
var a = new Scope("https://service-a.invalid", "tenant-a", "operator-a");
var b = a with { User = "operator-b" };
static async Task<string> Receive(Gate gate, Ticket ticket, Task<Receipt> response)
=> gate.Apply(ticket, await response);
// 演示身份由测试驱动直接指定;本程序没有登录、真实认证或网络请求。
(Gate gate, FakeServer server) Fixture(string name, Scope? owner = null, bool legacy = false)
{
var draft = new Draft("return-demo", legacy ? null : owner ?? a, false);
return (new Gate(Path.Combine(output, name + ".json"), draft, a), new FakeServer());
}
var normal = Fixture("normal");
var normalTicket = normal.gate.Start();
var normalResponse = normal.server.Accept(normalTicket);
var normalCompletion = Receive(normal.gate, normalTicket, normalResponse.Task);
normalResponse.Release();
Check("same_session_receipt_applied", await normalCompletion == "applied");
Check("same_session_synced_persisted", normal.gate.ReadFile().Synced);
Check("same_session_owner_preserved", normal.gate.ReadFile().Owner == a);
Check("same_session_one_business_effect", normal.server.BusinessEffects == 1);
var switched = Fixture("changed-user");
var oldTicket = switched.gate.Start();
var oldResponse = switched.server.Accept(oldTicket); // 模拟业务先受理,回执尚未交给客户端。
var oldCompletion = Receive(switched.gate, oldTicket, oldResponse.Task);
Check("other_user_receiver_waits_for_release", !oldCompletion.IsCompleted);
switched.gate.Switch(b);
oldResponse.Release();
Check("other_user_late_callback_rejected", await oldCompletion == "scope_changed");
Check("other_user_old_draft_not_synced", !switched.gate.ReadFile().Synced);
Check("other_user_cannot_start_old_draft", !switched.gate.CanStart());
Check("client_discard_does_not_undo_server_effect", switched.server.BusinessEffects == 1);
Check("original_owner_still_persisted", switched.gate.ReadFile().Owner == a);
// 回原范围核对后,用原草稿标识重试;模拟端按范围+草稿标识复用原结果。
switched.gate.Switch(a);
var retryTicket = switched.gate.Start();
var retryResponse = switched.server.Accept(retryTicket);
var retryCompletion = Receive(switched.gate, retryTicket, retryResponse.Task);
retryResponse.Release();
Check("original_scope_retry_receipt_applied", await retryCompletion == "applied");
Check("original_scope_retry_synced_persisted", switched.gate.ReadFile().Synced);
Check("retry_two_requests", switched.server.Requests == 2);
Check("retry_still_one_business_effect", switched.server.BusinessEffects == 1);
foreach (var change in new[] { a with { Tenant = "tenant-b" }, a with { Server = "https://service-b.invalid" } })
{
var name = change.Tenant == a.Tenant ? "changed-server" : "changed-tenant";
var f = Fixture(name);
var ticket = f.gate.Start();
var response = f.server.Accept(ticket);
var completion = Receive(f.gate, ticket, response.Task);
f.gate.Switch(change);
response.Release();
Check(name + "_callback_rejected", await completion == "scope_changed");
Check(name + "_original_draft_retained", !f.gate.ReadFile().Synced && f.gate.ReadFile().Owner == a);
Check(name + "_cannot_start_original_draft", !f.gate.CanStart());
}
var relogin = Fixture("same-user-relogin");
var beforeRelogin = relogin.gate.Start();
var reloginResponse = relogin.server.Accept(beforeRelogin);
var reloginCompletion = Receive(relogin.gate, beforeRelogin, reloginResponse.Task);
Check("same_user_receiver_waits_for_release", !reloginCompletion.IsCompleted);
relogin.gate.Switch(a); // 相同服务、租户、用户,新登录仍是新会话代次。
reloginResponse.Release();
Check("same_user_old_generation_rejected", await reloginCompletion == "session_changed");
Check("same_user_old_generation_not_persisted", !relogin.gate.ReadFile().Synced);
Check("same_user_relogin_server_effect_unchanged", relogin.server.BusinessEffects == 1);
var logout = Fixture("logout");
var logoutTicket = logout.gate.Start();
var logoutResponse = logout.server.Accept(logoutTicket);
var logoutCompletion = Receive(logout.gate, logoutTicket, logoutResponse.Task);
logout.gate.Switch(null);
logoutResponse.Release();
Check("logout_late_callback_rejected", await logoutCompletion == "scope_changed");
Check("logout_cannot_start", !logout.gate.CanStart());
Check("logout_original_draft_retained", !logout.gate.ReadFile().Synced);
var legacy = Fixture("unknown-owner", legacy: true);
Check("unknown_owner_cannot_start", !legacy.gate.CanStart());
var legacyBlocked = false;
try { legacy.gate.Start(); } catch (InvalidOperationException e) { legacyBlocked = e.Message == "draft_scope_unknown"; }
Check("unknown_owner_start_rejected", legacyBlocked);
Check("unknown_owner_not_adopted", legacy.gate.ReadFile().Owner is null && !legacy.gate.ReadFile().Synced);
Check("unknown_owner_no_server_request", legacy.server.Requests == 0);
var badReceipt = Fixture("wrong-receipt-owner");
var badTicket = badReceipt.gate.Start();
Check("wrong_receipt_owner_rejected", badReceipt.gate.Apply(badTicket, new Receipt(badTicket.DraftId, b)) == "receipt_mismatch");
Check("wrong_receipt_owner_not_persisted", !badReceipt.gate.ReadFile().Synced);
var wrongId = Fixture("wrong-receipt-id");
var rightTicket = wrongId.gate.Start();
Check("wrong_receipt_id_rejected", wrongId.gate.Apply(rightTicket, new Receipt("another-draft", a)) == "receipt_mismatch");
Check("wrong_receipt_id_not_persisted", !wrongId.gate.ReadFile().Synced);
foreach (var check in checks)
Console.WriteLine($"event=replay.check case={check.Name} status={(check.Passed ? "PASS" : "FAIL")}");
var report = new { total = checks.Count, passed = checks.Count(x => x.Passed), failed = checks.Count(x => !x.Passed),
httpRequests = 0, unityRuntimeTests = 0, deviceWrites = 0, productionRequests = 0, checks };
File.WriteAllText(Path.Combine(output, "results.json"), JsonSerializer.Serialize(report, Gate.Json));
Console.WriteLine($"event=replay.summary total={report.total} passed={report.passed} failed={report.failed} http=0 unity=0 device=0");
Console.WriteLine("Output: " + output);
return report.failed == 0 ? 0 : 1;
record Check(string Name, bool Passed);
record Scope(string Server, string Tenant, string User);
record Draft(string Id, Scope? Owner, bool Synced);
record Ticket(string DraftId, Scope Owner, long Generation);
record Receipt(string DraftId, Scope Owner);
sealed class Gate
{
public static readonly JsonSerializerOptions Json = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private readonly object sync = new();
private readonly string path;
private Draft draft;
private Scope? current;
private long generation = 1;
public Gate(string path, Draft draft, Scope current)
{
this.path = path; this.draft = draft; this.current = current;
File.WriteAllText(path, JsonSerializer.Serialize(draft, Json));
}
public void Switch(Scope? next)
{
lock (sync) { current = next; generation++; }
}
public bool CanStart()
{
lock (sync) return current is not null && draft.Owner == current && !draft.Synced;
}
public Ticket Start()
{
lock (sync)
{
if (draft.Owner is null) throw new InvalidOperationException("draft_scope_unknown");
if (current is null || draft.Owner != current) throw new InvalidOperationException("scope_changed");
if (draft.Synced) throw new InvalidOperationException("already_synced");
return new Ticket(draft.Id, current, generation);
}
}
public string Apply(Ticket ticket, Receipt receipt)
{
lock (sync)
{
if (current is null || ticket.Owner != current || draft.Owner != current) return "scope_changed";
if (ticket.Generation != generation) return "session_changed";
if (ticket.DraftId != draft.Id || receipt.DraftId != draft.Id || receipt.Owner != draft.Owner) return "receipt_mismatch";
var next = draft with { Synced = true };
File.WriteAllText(path, JsonSerializer.Serialize(next, Json));
draft = next;
return "applied";
}
}
public Draft ReadFile() => JsonSerializer.Deserialize<Draft>(File.ReadAllText(path), Json)!;
}
sealed class PendingReceipt(Receipt receipt)
{
private readonly TaskCompletionSource<Receipt> source = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<Receipt> Task => source.Task;
public void Release() => source.SetResult(receipt);
}
// 单进程、内存中的受控模拟端:不实现真实认证、数据库事务或网络。
sealed class FakeServer
{
private readonly Dictionary<(Scope Owner, string DraftId), Receipt> completed = new();
public int Requests { get; private set; }
public int BusinessEffects => completed.Count;
public PendingReceipt Accept(Ticket ticket)
{
Requests++;
var key = (ticket.Owner, ticket.DraftId);
if (!completed.TryGetValue(key, out var receipt))
completed.Add(key, receipt = new Receipt(ticket.DraftId, ticket.Owner));
return new PendingReceipt(receipt);
}
}
在该文件夹执行:
dotnet run --project ./ScopeReplayDemo.csproj --artifacts-path ./build-artifacts -- ./demo-output
本次使用SDK 10.0.203及.NET 8运行时执行net8.0目标,没有验证所有SDK、操作系统和存储组合。程序在指定目录下新建一个run-随机标识子目录,保存各场景JSON与results.json;日志输出用例名、通过状态、汇总及本地输出目录,不输出真实账号、令牌或业务行。
程序末段实际输出:
event=replay.summary total=35 passed=35 failed=0 http=0 unity=0 device=0
35项断言分别观察到了什么?
正常会话能写入,变更会话后原草稿仍保留;两条正反路径都要成立,不能用“一律拒绝”掩盖问题。
| 演示输入 | Apply或启动判断 |
JSON回读及模拟端结果 |
|---|---|---|
| 相同范围、同一会话交付回执 | applied |
原创建者保留,Synced=true,模拟效果数1 |
| 发起后切换操作员,尚未回原范围重试 | scope_changed |
此阶段回读原草稿Synced=false,新操作员不能启动它;模拟效果数仍为1 |
| 发起后切换租户或服务地址 | scope_changed |
原草稿及原创建者保留,新范围不能启动它 |
| 同一用户重新登录,范围不变 | session_changed |
旧回调不能写入,Synced=false |
| 发起后退出登录 | scope_changed |
原草稿保留,不能新启动 |
| 草稿创建者范围未知 | 启动被draft_scope_unknown拒绝 |
不自动认领,没有调用模拟端 |
| 回执归属或草稿标识不匹配 | receipt_mismatch |
不写入已同步状态 |
| 回到原范围核对后,用原草稿标识重试 | applied |
模拟端累计2次请求、1个效果;原草稿写入已同步 |
例如“换操作员”后、回原范围重试前,程序从JSON回读到原来的演示创建者和Synced=false,但模拟端已经记录1个效果。客户端没采用回执,不等于远端没有执行。 随后回原范围重试成功,同一个changed-user.json会被写为Synced=true;运行结束看到的是最终状态,不能把它当作前一步拒收后的快照。
独立.NET示例中的两个观察时点。换操作员后的中间回读与原范围重试后的最终文件,不能混为同一个状态。
JSON序列化和文件回读采用微软System.Text.Json示例中的基础方式。File.WriteAllText和回读只证明本次进程中实际写入、读到了这些内容;这个示例没有实现断电原子替换、跨进程锁、磁盘故障恢复或加密存储。
如果漏掉会话代次检查,测试能发现吗?
能发现本例这一处失效。把工程复制到另一个独立文件夹,只删除Apply中的这一行:
if (ticket.Generation != generation) return "session_changed";
本次受控副本得到33项通过、2项失败,退出码1。失败的是same_user_old_generation_rejected和same_user_old_generation_not_persisted:服务、租户、用户都相同,新登录与旧登录只差代次;删除比较后,旧回调被采用,JSON也被写成已同步。正常工程保持未改,35项全部通过。
这说明断言能抓住这个反例,不代表能发现所有竞态。当前示例没有实际并发压测、进程重启或Unity生命周期试验。
把同一组输入接到真实上传链路时,怎样判定通过?
先分清已经执行的验证层,再把同样的状态变化接到真实端点上。
| 本次材料 | 实际结果 | 结论范围 |
|---|---|---|
| 三个当前生产纯C#逻辑文件 | 44项限定检查通过;隔离副本移除快照Apply代次校验后1项失败 |
租户分类、草稿失败归属与订阅快照状态;不是完整上传链路 |
| 上述独立.NET 8工程 | 35项断言通过,场景JSON已回读 | 演示身份、可控晚到回执、实例内会话切换、模拟结果复用和本地文件状态 |
| 独立工程代次失效反例 | 删除一处检查后2项失败 | 本例同人重新登录后的旧回调和错误写入可被检测 |
44项与35项不能相加为“产品通过79项隔离验收”。其中订阅快照的revision用于拒绝过时刷新结果,同一会话刷新时还区分已开始工作与新工作;它不是本示例归还回执的版本字段。当前稿件没有验证设备登录、真实JWT/租户解析、所有模块草稿、服务端事务或生产行为。
接真实链路时,用专用测试账号和一条演示草稿,固定“发起上传—切换账号—交付旧结果”的顺序,分别核对原草稿、当前页面和服务端原操作记录。再测同一用户重新登录,以及回原范围带原标识重试。范围不符要保留原记录;恢复处理要核真实受理结果,不能改成本地强行认领,也不能换个新标识就重做业务。
- 点赞
- 收藏
- 关注作者
评论(0)