using System.Globalization; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace SimpleAdmin.Application; /// /// 直接调用契约锁开放平台,不依赖旧版 .NET Framework SDK。 /// public class QiyuesuoClient : IQiyuesuoClient { private const string SignatureHmacSha256 = "HMACSHA256"; private const string AccessTokenHeader = "x-qys-open-accesstoken"; private const string TimestampHeader = "x-qys-open-timestamp"; private const string NonceHeader = "x-qys-open-nonce"; private const string SignatureHeader = "x-qys-open-signature"; private const string SignatureVersionHeader = "x-qys-open-signature-version"; private const string RequestBodySignatureKey = "x-qys-open-body"; private static readonly HttpClient HttpClient = new() { Timeout = Timeout.InfiniteTimeSpan }; private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); private readonly QiyuesuoSettingOptions _setting; public QiyuesuoClient() { _setting = App.GetConfig( "QiyuesuoSetting", true) ?? new QiyuesuoSettingOptions(); } /// public async Task CreateDraft( QiyuesuoCreateDraftInput input) { return await PostJson( "/v2/contract/draft", QiyuesuoContractRequestBuilder.BuildDraft(input)); } /// public async Task AddDocument( QiyuesuoAddDocumentInput input) { var fields = new Dictionary { ["contractId"] = input.ContractId, ["bizId"] = input.BizId, ["title"] = input.Title, ["fileSuffix"] = "docx", ["documentSort"] = input.DocumentSort.ToString( CultureInfo.InvariantCulture) }; return await PostFile( "/v2/document/addbyfile", fields, input.FileBytes, input.FileName); } /// public Task Send( QiyuesuoSendContractInput input) { return PostJson( "/v2/contract/send", new { contractId = input.ContractId, bizId = input.BizId, tenantName = input.CompanyName, // 入职合同的签署位置由系统统一预设,禁止签署人自行增加位置, // 避免同一份材料出现“预设签名 + 手动新增签名”的重复签名。 stamperRule = "MUST_FORBID", locateAllStamperKeywords = false, stampers = input.Stampers }); } /// public async Task GetSignPageUrl( QiyuesuoSignPageInput input) { var result = await PostJsonElement( "/v2/contract/pageurl", new { contractId = input.ContractId, user = new { contact = input.EmployeeMobile, contactType = "MOBILE" }, callbackPage = NullIfBlank(input.CallbackPage) }); if (result.ValueKind == JsonValueKind.String) return result.GetString(); if (TryGetString(result, "url", out var url) || TryGetString(result, "pageUrl", out url) || TryGetString(result, "signUrl", out url)) { return url; } throw Oops.Bah("契约锁未返回员工签署页面地址"); } /// public async Task GetViewPageUrl( QiyuesuoViewPageInput input) { var result = await GetJsonElement( "/v2/contract/viewurl", new Dictionary { ["contractId"] = input.ContractId, ["documentId"] = input.DocumentId, ["tenantName"] = input.CompanyName, ["downloadButton"] = "false", ["printButton"] = "false" }); if (result.ValueKind == JsonValueKind.String) return result.GetString(); if (TryGetString(result, "url", out var url) || TryGetString(result, "viewUrl", out url) || TryGetString(result, "pageUrl", out url)) { return url; } throw Oops.Bah("契约锁未返回合同浏览页面地址"); } /// public Task GetContractDetail(string contractId) { return GetJson( "/v2/contract/detail", new Dictionary { ["contractId"] = contractId, ["queryActionOperate"] = "true", ["queryActionOperator"] = "true", ["queryLocation"] = "true" }); } /// public async Task Notice(string contractId) { await PostJsonElement( "/v2/contract/notice", new { contractId, onlySigning = true }); } /// public async Task CompanySign(QiyuesuoCompanySignInput input) { await PostJsonElement( "/v2/contract/companysign", new { contractId = input.ContractId, tenantName = input.CompanyName, sealId = input.SealId }); } /// public async Task Invalid(string contractId, string sealId, string reason) { await PostJsonElement( "/v2/contract/invalid", new { contractId, sealId, reason, deleteDoc = false, autoSign = true }); } /// public Task DownloadDocument(string documentId) { return Download( "/v2/document/download", new Dictionary { ["documentId"] = documentId }); } /// public Task DownloadArchive(string contractId) { return Download( "/v2/contract/download", new Dictionary { ["contractId"] = contractId, ["downloadItems"] = "CONTRACT,SIGNLOG", ["needCompressForOneFile"] = "true" }); } private async Task PostJson(string path, object body) { var result = await PostJsonElement(path, body); return result.Deserialize(JsonOptions) ?? throw Oops.Bah("契约锁返回数据为空"); } private async Task PostJsonElement( string path, object body) { ValidateSettings(); var json = JsonSerializer.Serialize(body, JsonOptions); using var request = new HttpRequestMessage( HttpMethod.Post, BuildUrl(path)); request.Content = new StringContent( json, Encoding.UTF8, "application/json"); AddAuthorizationHeaders( request, path, HttpMethod.Post, jsonBody: json); return await SendForJsonResult(request); } private async Task PostFile( string path, Dictionary fields, byte[] fileBytes, string fileName) { ValidateSettings(); using var content = new MultipartFormDataContent(); foreach (var field in fields) { content.Add( new StringContent(field.Value ?? string.Empty, Encoding.UTF8), field.Key); } var fileContent = new ByteArrayContent(fileBytes); fileContent.Headers.ContentType = new MediaTypeHeaderValue( "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); content.Add(fileContent, "file", fileName); using var request = new HttpRequestMessage( HttpMethod.Post, BuildUrl(path)) { Content = content }; AddAuthorizationHeaders( request, path, HttpMethod.Post, fields, fileBytes); var result = await SendForJsonResult(request); return result.Deserialize(JsonOptions) ?? throw Oops.Bah("契约锁返回数据为空"); } private async Task GetJson( string path, Dictionary query) { var result = await GetJsonElement(path, query); return result.Deserialize(JsonOptions) ?? throw Oops.Bah("契约锁返回数据为空"); } private async Task GetJsonElement( string path, Dictionary query) { ValidateSettings(); using var request = new HttpRequestMessage( HttpMethod.Get, BuildUrl(path, query)); AddAuthorizationHeaders( request, path, HttpMethod.Get, query); return await SendForJsonResult(request); } private async Task Download( string path, Dictionary query) { ValidateSettings(); using var request = new HttpRequestMessage( HttpMethod.Get, BuildUrl(path, query)); AddAuthorizationHeaders( request, path, HttpMethod.Get, query); using var response = await Send(request); var bytes = await response.Content.ReadAsByteArrayAsync(); if (!response.IsSuccessStatusCode || LooksLikeJson(response, bytes)) { ThrowProviderError(bytes, response.StatusCode); } if (bytes.Length == 0) throw Oops.Bah("契约锁下载文件内容为空"); return bytes; } private async Task SendForJsonResult( HttpRequestMessage request) { using var response = await Send(request); var responseText = await response.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(responseText)) throw Oops.Bah("契约锁返回内容为空"); using var document = JsonDocument.Parse(responseText); var root = document.RootElement; var hasCode = root.TryGetProperty("code", out var codeElement); var code = hasCode ? ReadCode(codeElement) : 0; var hasResponseCode = TryGetString( root, "responseCode", out var responseCode); var responseCodeSucceeded = !hasResponseCode || responseCode == "0" || responseCode == "00000000"; if (!response.IsSuccessStatusCode || (hasCode && code != 0) || !responseCodeSucceeded) { var hasMessage = TryGetString( root, "message", out var providerMessage) || TryGetString( root, "responseMessage", out providerMessage); var message = hasMessage ? providerMessage : $"HTTP {(int)response.StatusCode},响应码 {responseCode ?? code.ToString()}"; var providerCode = hasResponseCode ? responseCode : hasCode ? code.ToString(CultureInfo.InvariantCulture) : ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture); throw Oops.Bah($"契约锁请求失败:{message}(响应码:{providerCode})"); } if (!root.TryGetProperty("result", out var result)) return root.Clone(); return result.Clone(); } private async Task Send( HttpRequestMessage request) { using var timeout = new CancellationTokenSource( TimeSpan.FromSeconds(Math.Max(10, _setting.TimeoutSeconds))); try { return await HttpClient.SendAsync( request, HttpCompletionOption.ResponseHeadersRead, timeout.Token); } catch (OperationCanceledException) { throw Oops.Bah("契约锁请求超时,请稍后重试"); } catch (HttpRequestException exception) { throw Oops.Bah($"无法连接契约锁:{exception.Message}"); } } private void AddAuthorizationHeaders( HttpRequestMessage request, string path, HttpMethod method, Dictionary parameters = null, byte[] fileBytes = null, string jsonBody = null) { var timestamp = DateTimeOffset.UtcNow .ToUnixTimeMilliseconds() .ToString(CultureInfo.InvariantCulture); var nonce = Guid.NewGuid().ToString(); var signature = string.Equals( _setting.SignatureVersion, SignatureHmacSha256, StringComparison.OrdinalIgnoreCase) ? BuildHmacSha256Signature( path, method, timestamp, nonce, parameters, fileBytes, jsonBody) : BuildMd5Signature(timestamp, nonce); request.Headers.TryAddWithoutValidation( AccessTokenHeader, _setting.AppToken.Trim()); request.Headers.TryAddWithoutValidation(TimestampHeader, timestamp); request.Headers.TryAddWithoutValidation(NonceHeader, nonce); request.Headers.TryAddWithoutValidation(SignatureHeader, signature); request.Headers.TryAddWithoutValidation("version", "OnboardX-1.0"); if (!string.IsNullOrWhiteSpace(_setting.SignatureVersion)) { request.Headers.TryAddWithoutValidation( SignatureVersionHeader, _setting.SignatureVersion.Trim()); } } private string BuildMd5Signature(string timestamp, string nonce) { var source = _setting.AppToken.Trim() + _setting.AppSecret.Trim() + timestamp + nonce; return Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(source))) .ToLowerInvariant(); } private string BuildHmacSha256Signature( string path, HttpMethod method, string timestamp, string nonce, Dictionary parameters, byte[] fileBytes, string jsonBody) { var values = new List> { new(AccessTokenHeader, _setting.AppToken.Trim()), new(TimestampHeader, timestamp), new(NonceHeader, nonce), new(SignatureVersionHeader, SignatureHmacSha256) }; if (!string.IsNullOrWhiteSpace(jsonBody)) values.Add(new KeyValuePair( RequestBodySignatureKey, jsonBody)); if (parameters != null) { values.AddRange(parameters.Where(item => !string.IsNullOrWhiteSpace(item.Key) && !string.IsNullOrWhiteSpace(item.Value))); } if (fileBytes != null) { values.Add(new KeyValuePair( "file", Convert.ToHexString(MD5.HashData(fileBytes)) .ToLowerInvariant())); } var builder = new StringBuilder() .Append("requestMethod=") .Append(method.Method) .Append("&requestUrl=") .Append(path); foreach (var value in values .OrderBy(item => item.Key, StringComparer.Ordinal)) { builder.Append('&') .Append(value.Key.Trim()) .Append('=') .Append(value.Value.Trim()); } using var hmac = new HMACSHA256( Encoding.UTF8.GetBytes(_setting.AppSecret.Trim())); return Convert.ToHexString( hmac.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()))) .ToLowerInvariant(); } private string BuildUrl( string path, Dictionary query = null) { var url = $"{_setting.ServerUrl.Trim().TrimEnd('/')}/{path.TrimStart('/')}"; if (query == null || query.Count == 0) return url; var queryString = string.Join("&", query.Select(item => Uri.EscapeDataString(item.Key) + "=" + Uri.EscapeDataString(item.Value ?? string.Empty))); return url + "?" + queryString; } private void ValidateSettings() { if (!_setting.Enabled) throw Oops.Bah("契约锁电子签功能尚未启用"); if (string.IsNullOrWhiteSpace(_setting.ServerUrl)) throw Oops.Bah("请配置契约锁 ServerUrl"); if (string.IsNullOrWhiteSpace(_setting.AppToken)) throw Oops.Bah("请配置契约锁 AppToken"); if (string.IsNullOrWhiteSpace(_setting.AppSecret)) throw Oops.Bah("请配置契约锁 AppSecret"); } private static int ReadCode(JsonElement element) { if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out var numericCode)) { return numericCode; } return int.TryParse(element.ToString(), out var textCode) ? textCode : -1; } private static bool TryGetString( JsonElement element, string propertyName, out string value) { value = null; if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property) || property.ValueKind == JsonValueKind.Null) { return false; } value = property.ValueKind == JsonValueKind.String ? property.GetString() : property.ToString(); return !string.IsNullOrWhiteSpace(value); } private static bool LooksLikeJson( HttpResponseMessage response, byte[] bytes) { var contentType = response.Content.Headers.ContentType?.MediaType; if (!string.IsNullOrWhiteSpace(contentType) && contentType.Contains("json", StringComparison.OrdinalIgnoreCase)) { return true; } var first = bytes.FirstOrDefault(item => !char.IsWhiteSpace((char)item)); return first == '{' || first == '['; } private static void ThrowProviderError( byte[] bytes, global::System.Net.HttpStatusCode statusCode) { var text = Encoding.UTF8.GetString(bytes); try { using var document = JsonDocument.Parse(text); var root = document.RootElement; var hasMessage = TryGetString( root, "message", out var providerMessage) || TryGetString( root, "responseMessage", out providerMessage); var message = hasMessage ? providerMessage : text; throw Oops.Bah($"契约锁下载失败:{message}"); } catch (JsonException) { throw Oops.Bah( $"契约锁下载失败:HTTP {(int)statusCode}"); } } private static string NullIfBlank(string value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } /// /// 构造兼容契约锁长整型 Id 和字符串 Id 两种返回形式的 JSON 配置。 /// private static JsonSerializerOptions CreateJsonOptions() { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; options.Converters.Add(new FlexibleStringJsonConverter()); return options; } /// /// 将第三方响应中的字符串或数字统一读取为字符串,避免长整型 Id 精度丢失。 /// private sealed class FlexibleStringJsonConverter : JsonConverter { public override string Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return reader.TokenType switch { JsonTokenType.String => reader.GetString(), JsonTokenType.Number => GetRawNumber(ref reader), JsonTokenType.True => bool.TrueString, JsonTokenType.False => bool.FalseString, _ => reader.GetString() }; } public override void Write( Utf8JsonWriter writer, string value, JsonSerializerOptions options) { writer.WriteStringValue(value); } private static string GetRawNumber(ref Utf8JsonReader reader) { if (reader.TryGetInt64(out var integer)) return integer.ToString(CultureInfo.InvariantCulture); return reader.GetDecimal().ToString(CultureInfo.InvariantCulture); } } }