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);
}
}
}