器材手持机换账号后,旧离线草稿还能重试吗?C#范围与会话代次复现

举报
yd_299226675 发表于 2026/10/22 22:11:30 2026/10/22
【摘要】 先核对草稿创建时的服务、租户和账号,再核对发起上传时的会话代次。归属不符时保留原草稿,旧回调不能修改当前会话;即使客户端拒收了回调,仍要核对服务端是否已经受理。

先核对草稿创建时的服务、租户和账号,再核对发起上传时的会话代次。归属不符时保留原草稿,旧回调不能修改当前会话;即使客户端拒收了回调,仍要核对服务端是否已经受理。

用一个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;运行结束看到的是最终状态,不能把它当作前一步拒收后的快照。

独立示例里切换操作员后的Synced=false与回原范围重试后的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/租户解析、所有模块草稿、服务端事务或生产行为。

接真实链路时,用专用测试账号和一条演示草稿,固定“发起上传—切换账号—交付旧结果”的顺序,分别核对原草稿、当前页面和服务端原操作记录。再测同一用户重新登录,以及回原范围带原标识重试。范围不符要保留原记录;恢复处理要核真实受理结果,不能改成本地强行认领,也不能换个新标识就重做业务。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。