2026-05-23 18:16:50 +08:00
|
|
|
|
using CloudPrint.Client.Models;
|
|
|
|
|
|
using CloudPrint.Client.Services.Grpc;
|
|
|
|
|
|
using CloudPrint.Client.Services.Abstractions;
|
|
|
|
|
|
using Google.Protobuf;
|
|
|
|
|
|
using Grpc.Core;
|
|
|
|
|
|
using Grpc.Net.Client;
|
|
|
|
|
|
using System.IO;
|
|
|
|
|
|
|
|
|
|
|
|
namespace CloudPrint.Client.Services;
|
|
|
|
|
|
|
|
|
|
|
|
public sealed class PrintJobReceivedEventArgs : EventArgs
|
|
|
|
|
|
{
|
|
|
|
|
|
public PrintJobReceivedEventArgs(PrintJob job)
|
|
|
|
|
|
{
|
|
|
|
|
|
Job = job;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public PrintJob Job { get; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 真实 gRPC 通信服务,负责连接后端、注册打印机、订阅任务、下载文件和状态上报。
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public sealed class GrpcService : IPrintBackendClient
|
|
|
|
|
|
{
|
|
|
|
|
|
private CancellationTokenSource? _subscriptionCts;
|
|
|
|
|
|
private GrpcChannel? _channel;
|
|
|
|
|
|
private Cloudprint.PrintService.PrintServiceClient? _printClient;
|
|
|
|
|
|
private Cloudprint.LibraryService.LibraryServiceClient? _libraryClient;
|
|
|
|
|
|
private Cloudprint.FileService.FileServiceClient? _fileClient;
|
|
|
|
|
|
private Cloudprint.AdminService.AdminServiceClient? _adminClient;
|
|
|
|
|
|
private string _apiKey = string.Empty;
|
|
|
|
|
|
|
|
|
|
|
|
public event EventHandler<PrintJobReceivedEventArgs>? PrintJobReceived;
|
|
|
|
|
|
public event EventHandler<AppLogEntry>? LogReceived;
|
|
|
|
|
|
|
|
|
|
|
|
public bool IsConnected { get; private set; }
|
|
|
|
|
|
public int PrinterId { get; private set; }
|
|
|
|
|
|
public string Token { get; private set; } = string.Empty;
|
|
|
|
|
|
public AdminSession AdminSession { get; private set; } = new();
|
|
|
|
|
|
public bool AllowInsecureGrpc { get; set; } = true;
|
|
|
|
|
|
|
|
|
|
|
|
public async Task ConnectAsync(string address, string apiKey, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(address))
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ArgumentException("服务器地址不能为空", nameof(address));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(apiKey))
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ArgumentException("API Key 不能为空", nameof(apiKey));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && AllowInsecureGrpc)
|
|
|
|
|
|
{
|
|
|
|
|
|
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 23:26:37 +08:00
|
|
|
|
ResetConnectionState(clearCredentials: true);
|
|
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
_apiKey = apiKey;
|
|
|
|
|
|
_channel = GrpcChannel.ForAddress(address);
|
|
|
|
|
|
_printClient = new Cloudprint.PrintService.PrintServiceClient(_channel);
|
|
|
|
|
|
_libraryClient = new Cloudprint.LibraryService.LibraryServiceClient(_channel);
|
|
|
|
|
|
_fileClient = new Cloudprint.FileService.FileServiceClient(_channel);
|
|
|
|
|
|
_adminClient = new Cloudprint.AdminService.AdminServiceClient(_channel);
|
|
|
|
|
|
IsConnected = true;
|
|
|
|
|
|
Log(AppLogLevel.Success, $"已连接 gRPC 服务:{address}");
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
ResetConnectionState(clearCredentials: true);
|
|
|
|
|
|
throw;
|
|
|
|
|
|
}
|
2026-05-23 18:16:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<AdminSession> LoginAdminAsync(string username, string password, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(username))
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ArgumentException("管理员用户名不能为空", nameof(username));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(password))
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ArgumentException("管理员密码不能为空", nameof(password));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var response = await EnsureAdminClient().LoginAsync(new Cloudprint.AdminLoginRequest
|
|
|
|
|
|
{
|
|
|
|
|
|
Username = username,
|
|
|
|
|
|
Password = password
|
|
|
|
|
|
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
|
|
|
|
|
|
|
|
|
|
|
AdminSession = new AdminSession
|
|
|
|
|
|
{
|
|
|
|
|
|
AdminId = response.Admin?.Id ?? 0,
|
|
|
|
|
|
Username = response.Admin?.Username ?? username,
|
|
|
|
|
|
Nickname = response.Admin?.Nickname ?? string.Empty,
|
|
|
|
|
|
Role = response.Admin?.Role ?? 0,
|
|
|
|
|
|
AccessToken = response.AccessToken
|
|
|
|
|
|
};
|
|
|
|
|
|
Log(AppLogLevel.Success, $"管理员登录成功:{AdminSession.DisplayName}");
|
|
|
|
|
|
return AdminSession;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void SetAdminToken(string username, string accessToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
AdminSession = new AdminSession
|
|
|
|
|
|
{
|
|
|
|
|
|
Username = username,
|
|
|
|
|
|
Nickname = username,
|
|
|
|
|
|
AccessToken = accessToken
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void LogoutAdmin()
|
|
|
|
|
|
{
|
|
|
|
|
|
AdminSession = new AdminSession();
|
|
|
|
|
|
Log(AppLogLevel.Warning, "管理员已退出登录");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task RegisterPrinterAsync(PrinterInfo printer, string apiKey, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
ArgumentNullException.ThrowIfNull(printer);
|
|
|
|
|
|
var client = EnsurePrintClient();
|
|
|
|
|
|
var response = await client.RegisterPrinterAsync(new Cloudprint.RegisterPrinterRequest
|
|
|
|
|
|
{
|
|
|
|
|
|
Name = printer.Name,
|
|
|
|
|
|
DisplayName = printer.DisplayName,
|
|
|
|
|
|
ApiKey = apiKey
|
|
|
|
|
|
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
|
|
|
|
|
|
|
|
|
|
|
PrinterId = response.PrinterId;
|
|
|
|
|
|
Token = response.Token;
|
|
|
|
|
|
Log(AppLogLevel.Success, $"已注册打印机:{printer.DisplayName},PrinterId={PrinterId}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task SubscribePrintJobsAsync(PrinterInfo printer, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!IsConnected)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new InvalidOperationException("请先连接 gRPC 服务");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_subscriptionCts?.Cancel();
|
|
|
|
|
|
_subscriptionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
|
|
|
|
var token = _subscriptionCts.Token;
|
|
|
|
|
|
|
|
|
|
|
|
Log(AppLogLevel.Info, $"开始订阅打印任务流:{printer.DisplayName}");
|
|
|
|
|
|
_ = Task.Run(async () => await ReadPrintJobStreamAsync(token).ConfigureAwait(false), CancellationToken.None);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<string> DownloadPrintFileAsync(PrintJob job, string cacheRoot, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
ArgumentNullException.ThrowIfNull(job);
|
|
|
|
|
|
|
|
|
|
|
|
if (job.FileId <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new InvalidOperationException("打印任务缺少 file_id,无法下载文件");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var root = string.IsNullOrWhiteSpace(cacheRoot)
|
|
|
|
|
|
? Path.Combine(Path.GetTempPath(), "CloudPrint.Client", "print-cache")
|
|
|
|
|
|
: cacheRoot;
|
|
|
|
|
|
Directory.CreateDirectory(root);
|
|
|
|
|
|
|
|
|
|
|
|
var fileName = string.IsNullOrWhiteSpace(job.FileName) ? $"{job.JobNo}.bin" : job.FileName;
|
|
|
|
|
|
var targetPath = PathHelper.CreateUniqueFilePath(root, fileName);
|
|
|
|
|
|
using var call = EnsureFileClient().GetFile(new Cloudprint.GetFileRequest { FileId = job.FileId }, headers: CreateMetadata(), cancellationToken: cancellationToken);
|
|
|
|
|
|
await using var output = File.Create(targetPath);
|
|
|
|
|
|
|
|
|
|
|
|
await foreach (var chunk in call.ResponseStream.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
|
|
|
|
|
{
|
|
|
|
|
|
if (chunk.Data.Length > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
chunk.Data.WriteTo(output);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Log(AppLogLevel.Success, $"打印文件已下载:{targetPath}");
|
|
|
|
|
|
return targetPath;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task UploadLibraryDocumentAsync(CloudPrintDocument document, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
ArgumentNullException.ThrowIfNull(document);
|
|
|
|
|
|
if (!IsConnected)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new InvalidOperationException("请先连接 gRPC 服务");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await UploadLibraryDocumentByGrpcAsync(document, cancellationToken).ConfigureAwait(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task CancelPrintJobAsync(PrintJob job, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
ArgumentNullException.ThrowIfNull(job);
|
|
|
|
|
|
if (!IsConnected)
|
|
|
|
|
|
{
|
|
|
|
|
|
job.MarkCancelled();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await EnsurePrintClient().CancelPrintJobAsync(new Cloudprint.CancelPrintJobRequest
|
|
|
|
|
|
{
|
|
|
|
|
|
JobNo = job.JobNo
|
|
|
|
|
|
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
|
|
|
|
|
|
|
|
|
|
|
job.MarkCancelled();
|
|
|
|
|
|
Log(AppLogLevel.Warning, $"已取消打印任务:{job.JobNo}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task SendStatusUpdateAsync(PrintJob job, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
ArgumentNullException.ThrowIfNull(job);
|
|
|
|
|
|
if (IsConnected)
|
|
|
|
|
|
{
|
|
|
|
|
|
await EnsurePrintClient().ReportJobStatusAsync(new Cloudprint.ReportJobStatusRequest
|
|
|
|
|
|
{
|
|
|
|
|
|
JobNo = job.JobNo,
|
|
|
|
|
|
Status = CloudPrintGrpcMapper.ToBackendStatus(job.Status),
|
|
|
|
|
|
Progress = job.Progress,
|
|
|
|
|
|
ErrorMsg = job.ErrorMessage
|
|
|
|
|
|
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Log(AppLogLevel.Info, $"上报任务状态:{job.JobNo} / {job.StatusText} / {job.Progress}%");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task SendHeartbeatAsync(PrinterInfo printer, CancellationToken cancellationToken = default)
|
|
|
|
|
|
{
|
|
|
|
|
|
ArgumentNullException.ThrowIfNull(printer);
|
|
|
|
|
|
if (IsConnected)
|
|
|
|
|
|
{
|
|
|
|
|
|
await EnsurePrintClient().PrinterHeartbeatAsync(new Cloudprint.PrinterHeartbeatRequest
|
|
|
|
|
|
{
|
|
|
|
|
|
PrinterId = PrinterId == 0 ? printer.Id : PrinterId,
|
|
|
|
|
|
Status = (int)printer.Status,
|
|
|
|
|
|
Token = Token
|
|
|
|
|
|
}, headers: CreateMetadata(), cancellationToken: cancellationToken).ResponseAsync.ConfigureAwait(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Log(AppLogLevel.Info, $"心跳:{printer.DisplayName} / {printer.StatusText}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public void Disconnect()
|
|
|
|
|
|
{
|
2026-05-28 23:26:37 +08:00
|
|
|
|
ResetConnectionState(clearCredentials: true);
|
2026-05-23 18:16:50 +08:00
|
|
|
|
Log(AppLogLevel.Warning, "已断开 gRPC 连接");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public ValueTask DisposeAsync()
|
|
|
|
|
|
{
|
|
|
|
|
|
_subscriptionCts?.Cancel();
|
|
|
|
|
|
_subscriptionCts?.Dispose();
|
|
|
|
|
|
_channel?.Dispose();
|
|
|
|
|
|
return ValueTask.CompletedTask;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async Task ReadPrintJobStreamAsync(CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
using var call = EnsurePrintClient().SubscribeJobs(new Cloudprint.SubscribeJobsRequest
|
|
|
|
|
|
{
|
|
|
|
|
|
PrinterId = PrinterId,
|
|
|
|
|
|
Token = Token
|
|
|
|
|
|
}, headers: CreateMetadata(), cancellationToken: cancellationToken);
|
|
|
|
|
|
|
|
|
|
|
|
await foreach (var backendJob in call.ResponseStream.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
|
|
|
|
|
{
|
|
|
|
|
|
var job = CloudPrintGrpcMapper.ToPrintJob(backendJob);
|
|
|
|
|
|
PrintJobReceived?.Invoke(this, new PrintJobReceivedEventArgs(job));
|
|
|
|
|
|
Log(AppLogLevel.Info, $"收到打印任务:{job.JobNo}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (OperationCanceledException)
|
|
|
|
|
|
{
|
|
|
|
|
|
Log(AppLogLevel.Warning, "打印任务订阅已取消");
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
Log(AppLogLevel.Error, $"打印任务订阅中断:{ex.Message}");
|
2026-05-28 23:26:37 +08:00
|
|
|
|
ResetConnectionState(clearCredentials: false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void ResetConnectionState(bool clearCredentials)
|
|
|
|
|
|
{
|
|
|
|
|
|
_subscriptionCts?.Cancel();
|
|
|
|
|
|
_subscriptionCts?.Dispose();
|
|
|
|
|
|
_subscriptionCts = null;
|
|
|
|
|
|
_channel?.Dispose();
|
|
|
|
|
|
_channel = null;
|
|
|
|
|
|
_printClient = null;
|
|
|
|
|
|
_libraryClient = null;
|
|
|
|
|
|
_fileClient = null;
|
|
|
|
|
|
_adminClient = null;
|
|
|
|
|
|
IsConnected = false;
|
|
|
|
|
|
PrinterId = 0;
|
|
|
|
|
|
Token = string.Empty;
|
|
|
|
|
|
|
|
|
|
|
|
if (clearCredentials)
|
|
|
|
|
|
{
|
|
|
|
|
|
_apiKey = string.Empty;
|
2026-05-23 18:16:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async Task UploadLibraryDocumentByGrpcAsync(CloudPrintDocument document, CancellationToken cancellationToken)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!File.Exists(document.FilePath))
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new FileNotFoundException("文库文件不存在", document.FilePath);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
document.SyncStatus = "上传中";
|
|
|
|
|
|
using var call = EnsureLibraryClient().UploadFile(headers: CreateMetadata(), cancellationToken: cancellationToken);
|
|
|
|
|
|
const int bufferSize = 64 * 1024;
|
|
|
|
|
|
var buffer = new byte[bufferSize];
|
|
|
|
|
|
await using var stream = File.OpenRead(document.FilePath);
|
|
|
|
|
|
|
|
|
|
|
|
while (true)
|
|
|
|
|
|
{
|
|
|
|
|
|
var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(true);
|
|
|
|
|
|
if (read == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await call.RequestStream.WriteAsync(new Cloudprint.UploadLibraryFileRequest
|
|
|
|
|
|
{
|
|
|
|
|
|
CategoryId = document.CategoryId,
|
|
|
|
|
|
Title = document.Title,
|
|
|
|
|
|
Description = document.Description,
|
|
|
|
|
|
FileName = document.FileName,
|
|
|
|
|
|
FileType = document.FileType,
|
|
|
|
|
|
Data = ByteString.CopyFrom(buffer, 0, read)
|
|
|
|
|
|
}, cancellationToken).ConfigureAwait(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await call.RequestStream.CompleteAsync().ConfigureAwait(true);
|
|
|
|
|
|
_ = await call.ResponseAsync.ConfigureAwait(true);
|
|
|
|
|
|
document.IsUploaded = true;
|
|
|
|
|
|
document.SyncStatus = "已同步";
|
|
|
|
|
|
Log(AppLogLevel.Success, $"文库文件已上传:{document.Title}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private Cloudprint.PrintService.PrintServiceClient EnsurePrintClient()
|
|
|
|
|
|
{
|
|
|
|
|
|
return _printClient ?? throw new InvalidOperationException("gRPC 打印服务未连接");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private Cloudprint.LibraryService.LibraryServiceClient EnsureLibraryClient()
|
|
|
|
|
|
{
|
|
|
|
|
|
return _libraryClient ?? throw new InvalidOperationException("gRPC 文库服务未连接");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private Cloudprint.FileService.FileServiceClient EnsureFileClient()
|
|
|
|
|
|
{
|
|
|
|
|
|
return _fileClient ?? throw new InvalidOperationException("gRPC 文件服务未连接");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private Cloudprint.AdminService.AdminServiceClient EnsureAdminClient()
|
|
|
|
|
|
{
|
|
|
|
|
|
return _adminClient ?? throw new InvalidOperationException("gRPC 管理员服务未连接");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private global::Grpc.Core.Metadata CreateMetadata()
|
|
|
|
|
|
{
|
|
|
|
|
|
return GrpcMetadataFactory.Create(_apiKey, Token, AdminSession);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void Log(AppLogLevel level, string message)
|
|
|
|
|
|
{
|
|
|
|
|
|
LogReceived?.Invoke(this, new AppLogEntry
|
|
|
|
|
|
{
|
|
|
|
|
|
Level = level,
|
|
|
|
|
|
Message = message
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-05-28 23:26:37 +08:00
|
|
|
|
}
|