feat(print): cloud↔local print-agent foundation (hub, pairing, registry)
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
First phase of auto-discovered printing for cloud-hosted cafés whose printers are
on the local network (the cloud can't reach a LAN/USB printer directly). Adds:
- PrintAgent + PrintDevice entities (+ additive migration) — a per-café local
bridge and the printers it reports.
- PrintAgentHub (/hubs/print-agent): agents connect outbound, authenticated by a
token in access_token (not the user JWT); ReportPrinters upserts devices,
PrintJob is pushed to the agent, JobResult/Heartbeat come back.
- PrintAgentRegistry (singleton): tracks connected agents and dispatches a job to
one, awaiting its ack with a timeout.
- Pairing: POST /cafes/{id}/print-agents/pairing-code (ManagePrintSettings) issues
a short one-time code; anonymous POST /print-agent/claim redeems it for a
long-lived token (only its SHA-256 hash is stored). List + revoke endpoints,
online status from the registry.
Inert until Phase 2 routes jobs through it and the agent app (Phase 3) connects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Meezi.API.Hubs;
|
||||
|
||||
namespace Meezi.API.Services.Printing;
|
||||
|
||||
public record PrintJobRequest(string PrinterSystemName, byte[] Payload);
|
||||
public record PrintJobOutcome(bool Success, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which print agents are currently connected (by SignalR connection) and
|
||||
/// dispatches print jobs to them, awaiting the agent's acknowledgement. In-memory:
|
||||
/// a dropped process simply means agents reconnect and re-register.
|
||||
/// </summary>
|
||||
public interface IPrintAgentRegistry
|
||||
{
|
||||
void Register(string connectionId, string agentId, string cafeId);
|
||||
void Unregister(string connectionId);
|
||||
(string AgentId, string CafeId)? Resolve(string connectionId);
|
||||
bool IsOnline(string agentId);
|
||||
IReadOnlySet<string> OnlineAgentIds();
|
||||
Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default);
|
||||
void CompleteJob(string jobId, bool success, string? error);
|
||||
}
|
||||
|
||||
public class PrintAgentRegistry : IPrintAgentRegistry
|
||||
{
|
||||
private readonly IHubContext<PrintAgentHub> _hub;
|
||||
private readonly ConcurrentDictionary<string, (string AgentId, string CafeId)> _byConnection = new();
|
||||
private readonly ConcurrentDictionary<string, string> _agentConnection = new(); // agentId -> connectionId
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<PrintJobOutcome>> _pending = new();
|
||||
|
||||
public PrintAgentRegistry(IHubContext<PrintAgentHub> hub) => _hub = hub;
|
||||
|
||||
public void Register(string connectionId, string agentId, string cafeId)
|
||||
{
|
||||
_byConnection[connectionId] = (agentId, cafeId);
|
||||
_agentConnection[agentId] = connectionId;
|
||||
}
|
||||
|
||||
public void Unregister(string connectionId)
|
||||
{
|
||||
if (!_byConnection.TryRemove(connectionId, out var info)) return;
|
||||
// Only drop the agent→connection mapping if it still points at this socket
|
||||
// (a fast reconnect may already have replaced it with a newer one).
|
||||
if (_agentConnection.TryGetValue(info.AgentId, out var current) && current == connectionId)
|
||||
_agentConnection.TryRemove(info.AgentId, out _);
|
||||
}
|
||||
|
||||
public (string AgentId, string CafeId)? Resolve(string connectionId) =>
|
||||
_byConnection.TryGetValue(connectionId, out var info) ? info : null;
|
||||
|
||||
public bool IsOnline(string agentId) => _agentConnection.ContainsKey(agentId);
|
||||
|
||||
public IReadOnlySet<string> OnlineAgentIds() => _agentConnection.Keys.ToHashSet();
|
||||
|
||||
public async Task<PrintJobOutcome> SendJobAsync(string agentId, PrintJobRequest job, CancellationToken ct = default)
|
||||
{
|
||||
if (!_agentConnection.TryGetValue(agentId, out var connectionId))
|
||||
return new PrintJobOutcome(false, "AGENT_OFFLINE");
|
||||
|
||||
var jobId = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<PrintJobOutcome>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[jobId] = tcs;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Client(connectionId).SendAsync(
|
||||
"PrintJob", jobId, job.PrinterSystemName, Convert.ToBase64String(job.Payload), ct);
|
||||
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
using var reg = timeout.Token.Register(() => tcs.TrySetResult(new PrintJobOutcome(false, "TIMEOUT")));
|
||||
return await tcs.Task;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PrintJobOutcome(false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(jobId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteJob(string jobId, bool success, string? error)
|
||||
{
|
||||
if (_pending.TryGetValue(jobId, out var tcs))
|
||||
tcs.TrySetResult(new PrintJobOutcome(success, error));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user