This commit is contained in:
wanghu
2026-09-02 09:38:45 +08:00
commit 8774b6cc44
128 changed files with 9382 additions and 0 deletions
@@ -0,0 +1,25 @@
namespace Yh.Platform.Abstractions;
/// <summary>
/// 通用接口响应包装,对应大多数后端接口返回的 <c>code/msg/data</c> 结构。
/// 纯数据载体,双序列化器兼容(Newtonsoft 大小写不敏感 / System.Text.Json camelCase)。
/// </summary>
public class ApiEnvelope<T>
{
/// <summary>业务状态码,0 表示成功</summary>
public int Code { get; set; }
/// <summary>提示信息</summary>
public string? Msg { get; set; }
/// <summary>业务数据</summary>
public T? Data { get; set; }
/// <summary>是否成功响应(Code == 0)</summary>
public bool IsSuccess => Code == 0;
}
/// <summary>非泛型信封(data 为通用对象/数组),用于信封感知解析前的判别。</summary>
public class ApiEnvelope : ApiEnvelope<object>
{
}
@@ -0,0 +1,74 @@
namespace Yh.Platform.Abstractions;
/// <summary>
/// 应用富信息载体。替代旧 bsch <c>BoaApp</c>。
/// 不含 appSecret(避免 oapi 泄露密钥);字段命名保留旧 bsch 风格。
/// </summary>
public class BoaApp
{
/// <summary>应用ID</summary>
public int? Id { get; set; }
/// <summary>应用名称</summary>
public string? Name { get; set; }
/// <summary>图标URL</summary>
public string? Icon { get; set; }
/// <summary>版本号</summary>
public string? Version { get; set; }
/// <summary>应用类型ID</summary>
public int? Type { get; set; }
/// <summary>PC端访问地址</summary>
public string? WebAddr { get; set; }
/// <summary>管理端地址</summary>
public string? AdminAddress { get; set; }
/// <summary>API根路径</summary>
public string? ApiRoot { get; set; }
/// <summary>appKey(不含 appSecret,避免泄露密钥)</summary>
public int? AppKey { get; set; }
/// <summary>排序号</summary>
public int? OrderNumber { get; set; }
/// <summary>创建人ID</summary>
public int? CreateUser { get; set; }
/// <summary>创建时间</summary>
public DateTime? CreateTime { get; set; }
/// <summary>移动端图标URL</summary>
public string? MobiIcon { get; set; }
/// <summary>支持的客户端类型(位掩码叠加值,对齐 UserBase ApplicationInfo.clients)</summary>
public int? Clients { get; set; }
/// <summary>集成方式</summary>
public int? IntegMode { get; set; }
/// <summary>内置标记:1=内置 0=非内置</summary>
public int? BuiltInTag { get; set; } = 0;
/// <summary>网页包地址</summary>
public string? WebPagePkgAddr { get; set; }
/// <summary>网页访问地址</summary>
public string? WebAddress { get; set; }
/// <summary>微信公众号地址</summary>
public string? WxgzhAddress { get; set; }
/// <summary>钉钉应用地址</summary>
public string? DingAddress { get; set; }
/// <summary>是否公开应用</summary>
public bool? IsPublic { get; set; }
/// <summary>模块列表</summary>
public List<BoaModule>? Modules { get; set; }
}
@@ -0,0 +1,16 @@
namespace Yh.Platform.Abstractions;
/// <summary>
/// 应用类型 + 该类型下挂的应用列表。平台级富信息载体,替代旧 bsch <c>BoaAppType</c>。
/// </summary>
public class BoaAppType
{
/// <summary>应用类型ID</summary>
public int? Id { get; set; }
/// <summary>应用类型名称</summary>
public string? Name { get; set; }
/// <summary>该类型下的应用列表</summary>
public List<BoaApp>? Apps { get; set; }
}
@@ -0,0 +1,32 @@
namespace Yh.Platform.Abstractions;
/// <summary>
/// 组织机构单元(部门 / 学校 / 教育局等)。对应旧 bsch 的 <c>com.bestsch.openapi.BoaGroup</c>。
/// </summary>
public class BoaGroup
{
public int? Id { get; set; }
public string? Name { get; set; }
public int? ParentId { get; set; }
/// <summary>节点类型:1=单位/教育局 2=学校 3=部门 等,由业务自定义</summary>
public int? Type { get; set; }
/// <summary>排序号(同层级升序;可空)</summary>
public int? Sort { get; set; }
/// <summary>子节点列表;仅树接口展开时填充,未展开时为 null</summary>
public List<BoaGroup>? Children { get; set; }
public BoaGroup()
{
}
public BoaGroup(int? id, string? name)
{
Id = id;
Name = name;
}
}
@@ -0,0 +1,21 @@
namespace Yh.Platform.Abstractions;
/// <summary>功能模块载体。与 Java <c>utils.model.BoaModule</c> 一比一。</summary>
public class BoaModule
{
public int? Id { get; set; }
public int? ParentId { get; set; }
public string? CustomId { get; set; }
public string? Name { get; set; }
public string? Path { get; set; }
public int? Level { get; set; }
public int? SortNo { get; set; }
public string? Icon { get; set; }
}
@@ -0,0 +1,26 @@
namespace Yh.Platform.Abstractions;
/// <summary>
/// 用户角色身份信息载体。与 Java <c>utils.model.BoaRoleInfo</c> 一比一:
/// 用户在某校某子节点的激活角色快照,由 UserBase oapi 端组装。
/// </summary>
public class BoaRoleInfo
{
/// <summary>角色ID</summary>
public int? RoleId { get; set; }
/// <summary>角色名称</summary>
public string? RoleName { get; set; }
/// <summary>学校ID(组ID)</summary>
public int? SchId { get; set; }
/// <summary>学校名称</summary>
public string? SchName { get; set; }
/// <summary>子节点ID(班级/学段等)</summary>
public int? ChildId { get; set; }
/// <summary>子节点名称</summary>
public string? ChildName { get; set; }
}
@@ -0,0 +1,79 @@
namespace Yh.Platform.Abstractions;
/// <summary>
/// 已登录用户的富信息载体(含部门/学校/角色)。与 Java <c>utils.model.BoaUser</c> 字段一比一,
/// 替代旧 bsch 的 <c>com.bestsch.openapi.BoaUser</c>。
/// 纯 POCO 零注解:Newtonsoft(大小写不敏感)与 System.Text.Json(camelCase)双兼容。
/// </summary>
public class BoaUser
{
/// <summary>用户主键</summary>
public int? Id { get; set; }
/// <summary>登录账号</summary>
public string? Account { get; set; }
/// <summary>姓名</summary>
public string? Name { get; set; }
public string? Idcard { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public int? Gender { get; set; }
public string? ProfilePicture { get; set; }
/// <summary>用户类型:1=教师 2=家长 3=学生 等,由业务自定义</summary>
public int? Type { get; set; }
/// <summary>状态:1=正常 0=禁用</summary>
public int? Status { get; set; }
/// <summary>所属部门</summary>
public BoaGroup? Dept { get; set; }
/// <summary>
/// 当前激活身份的学校(来自 sys_user_current_role.school_id,数值即学校组 sys_group.id)。
/// 由 UserBase 侧填充:优先取用户当前切换的学校,缺省兜底为主身份所在学校;
/// 区域组/学段组/根直属用户(无学校归属)时为 null。
/// 与 <see cref="Schools"/> 平行:本字段表示"当前",Schools 表示"全部可选"。
/// </summary>
public BoaGroup? School { get; set; }
/// <summary>所属教育局/主管单位</summary>
public BoaGroup? Bureau { get; set; }
/// <summary>多级组织路径(含父级)</summary>
public List<BoaGroup>? Groups { get; set; }
/// <summary>角色 id 列表</summary>
public List<int>? RoleIds { get; set; }
/// <summary>
/// 当前激活身份(角色 × 学校)。来自 sys_user_current_role(未切换时兜底 sys_user_role_rel.is_main=1 主身份)。
/// 由 UserBase 侧填充;用户无任何身份时为 null。子应用做"当前身份/当前学校"判断时优先读本字段。
/// </summary>
public BoaRoleInfo? CurrentRole { get; set; }
/// <summary>
/// 用户所有可选学校列表(来自 sys_user_role_rel 按 user_id 去重的 school_id,不含 school_id=0)。
/// 每个元素即一所学校(type=SCHOOL_GROUP 的 sys_group),本列表即"切换身份"页的可选学校集合。
/// 区域组/学段组/根直属用户(无学校归属)此列表为空。
/// 与 <see cref="School"/> 平行:School=当前激活学校,Schools=全部可选学校。
/// </summary>
public List<BoaGroup>? Schools { get; set; }
public BoaUser()
{
}
public BoaUser(int? id, string? account, string? name)
{
Id = id;
Account = account;
Name = name;
}
}
@@ -0,0 +1,16 @@
namespace Yh.Platform.Abstractions;
/// <summary>
/// 客户端来源(对齐 Java <c>ClientEnum</c>)。key 为 2 的幂,支持位运算叠加。
/// </summary>
[Flags]
public enum ClientEnum
{
PcBrowser = 1,
MobileApp = 2,
SystemModule = 4,
WechatOfficial = 8,
WorkWechat = 16,
DingTalk = 32,
StudentDevice = 64,
}
@@ -0,0 +1,91 @@
namespace Yh.Platform.Abstractions;
/// <summary>
/// 平台业务异常(对齐 Java <c>utils.exception.DException</c>)。
/// 携带 code/EnvelopeData/msg/rawException/httpError;/oapi 业务失败(HTTP 200 + code≠0 信封)由 OpenApi 包抛出本异常。
/// 注意:信封 data 属性命名为 <see cref="EnvelopeData"/> 而非 Data,避免遮蔽 <see cref="Exception.Data"/>(IDictionary 契约)。
/// </summary>
public class DException : Exception
{
public IErrorCode? Code { get; }
/// <summary>信封 data 原样携带(通常为 System.Text.Json.JsonElement);对应 Java 侧 data 字段。</summary>
public object? EnvelopeData { get; }
public Exception? RawException { get; }
public object? HttpError { get; }
public DException(IErrorCode? code)
: base(ResolveMessage(code, null))
{
Code = code;
}
/// <summary>便捷构造:默认 code = Custom(5),message 为纯 msg。</summary>
public DException(string message)
: this(ErrorCode.Custom, message)
{
}
public DException(IErrorCode? code, string? message)
: base(ResolveMessage(code, message))
{
Code = code;
}
public DException(IErrorCode? code, object? data)
: base(ResolveMessage(code, null))
{
Code = code;
EnvelopeData = data;
}
public DException(IErrorCode? code, Exception? rawException)
: base(ResolveMessage(code, null), rawException)
{
Code = code;
RawException = rawException;
}
public DException(IErrorCode? code, object? data, Exception? rawException)
: base(ResolveMessage(code, null), rawException)
{
Code = code;
EnvelopeData = data;
RawException = rawException;
}
public DException(IErrorCode? code, string? message, object? data)
: base(ResolveMessage(code, message))
{
Code = code;
EnvelopeData = data;
}
public DException(IErrorCode? code, string? message, object? data, Exception? rawException)
: base(ResolveMessage(code, message), rawException)
{
Code = code;
EnvelopeData = data;
RawException = rawException;
}
public DException(IErrorCode? code, string? message, object? data, Exception? rawException, object? httpError)
: base(ResolveMessage(code, message), rawException)
{
Code = code;
EnvelopeData = data;
RawException = rawException;
HttpError = httpError;
}
private static string? ResolveMessage(IErrorCode? code, string? message)
{
if (message != null)
{
return message;
}
return code?.Msg;
}
}
@@ -0,0 +1,18 @@
namespace Yh.Platform.Abstractions;
/// <summary>
/// 平台已知错误码(对齐 Java <c>ErrorCode</c> 枚举的常量值与默认文案)。
/// </summary>
public static class ErrorCode
{
public static readonly IErrorCode Success = new SimpleErrorCode(0, "");
public static readonly IErrorCode Error = new SimpleErrorCode(1, "未知错误");
public static readonly IErrorCode ParamError = new SimpleErrorCode(2, "错误的请求参数");
public static readonly IErrorCode Forbidden = new SimpleErrorCode(3, "非法操作,没有权限");
public static readonly IErrorCode Unauthorized = new SimpleErrorCode(4, "登录已经过期,请重新登录");
public static readonly IErrorCode Custom = new SimpleErrorCode(5, "");
public static readonly IErrorCode InternalServerError = new SimpleErrorCode(6, "内部服务错误");
public static readonly IErrorCode HttpMethodNotAllowed = new SimpleErrorCode(405, "HTTP方法不支持");
private sealed record SimpleErrorCode(int Code, string Msg) : IErrorCode;
}
@@ -0,0 +1,3 @@
// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛)
global using System;
global using System.Collections.Generic;
@@ -0,0 +1,11 @@
namespace Yh.Platform.Abstractions;
/// <summary>错误码抽象(对齐 Java <c>IErrorCode</c>)。</summary>
public interface IErrorCode
{
/// <summary>业务状态码</summary>
int Code { get; }
/// <summary>默认提示信息</summary>
string Msg { get; }
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Yh.Platform.Abstractions</PackageId>
<Description>yunhai 平台模型与信封(对应 Java yh-utils-core):BoaUser/BoaRoleInfo 等纯 POCO、ApiEnvelope、DException、错误码抽象</Description>
<Summary>平台模型与信封契约,双序列化器(Newtonsoft/System.Text.Json)兼容</Summary>
</PropertyGroup>
</Project>
@@ -0,0 +1,36 @@
namespace Yh.Platform.AppAuth;
/// <summary>
/// 应用签名鉴权配置(对齐 Java <c>yh.app-auth.*</c> 配置节)。
/// </summary>
public class AppAuthOptions
{
public const string SectionName = "yh:app-auth";
/// <summary>是否启用(默认 true)</summary>
public bool Enabled { get; set; } = true;
/// <summary>请求有效期(毫秒),默认 5 分钟</summary>
public long ExpireMs { get; set; } = SignUtil.DefaultExpireMs;
/// <summary>本应用的 appKey(客户端出站签名用)</summary>
public string? AppKey { get; set; }
/// <summary>本应用的 appSecret(客户端出站签名用)</summary>
public string? AppSecret { get; set; }
/// <summary>服务端受信应用表(appKey → appSecret),用于固定密钥验签场景</summary>
public Dictionary<string, string> TrustedApps { get; set; } = new(StringComparer.Ordinal);
/// <summary>Redis 连接串;为空时密钥解析链跳过 Redis 层、防重放退化为进程内实现</summary>
public string? RedisConnectionString { get; set; }
/// <summary>验签密钥在 Redis 中的键前缀(键空间与 Java 约定一致:sign:app:{appKey})</summary>
public string SignKeyRedisKeyPrefix { get; set; } = "sign:app:";
/// <summary>是否启用 UserBase 密钥兜底(GET {userbase}/oapi/internal/sign-key/{appKey})</summary>
public bool UserBaseSignKeyFallbackEnabled { get; set; }
/// <summary>UserBase 基址(兜底取密钥用),如 http://userbase 或 http://host/userbase</summary>
public string? UserBaseBaseUrl { get; set; }
}
@@ -0,0 +1,135 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Yh.Platform.AppAuth;
/// <summary>配置型密钥来源(对齐 Java <c>PropertyAppSecretProvider</c>):从 TrustedApps 映射取密钥。</summary>
public sealed class PropertyAppSecretProvider : IAppSecretProvider
{
private readonly AppAuthOptions _options;
public PropertyAppSecretProvider(AppAuthOptions options)
{
_options = options;
}
public Task<string?> GetSecretAsync(string appKey, CancellationToken ct = default)
=> Task.FromResult(_options.TrustedApps.TryGetValue(appKey, out var secret) ? secret : null);
}
/// <summary>Redis 密钥来源(对齐 Java <c>RedisAppSecretProvider</c>):委托 <see cref="SignKeyResolver"/> 三层解析链。</summary>
public sealed class SignKeyResolverAppSecretProvider : IAppSecretProvider
{
private readonly SignKeyResolver _resolver;
public SignKeyResolverAppSecretProvider(SignKeyResolver resolver)
{
_resolver = resolver;
}
public Task<string?> GetSecretAsync(string appKey, CancellationToken ct = default)
=> _resolver.GetSecretAsync(appKey, ct);
}
/// <summary>服务端验签器:请求级签名 + 过期 + 防重放一次性校验入口。</summary>
public class AppAuthVerifier
{
private readonly IAppSecretProvider _secretProvider;
private readonly IReplayGuard _replayGuard;
private readonly AppAuthOptions _options;
public AppAuthVerifier(IAppSecretProvider secretProvider, IReplayGuard replayGuard, IOptions<AppAuthOptions> options)
{
_secretProvider = secretProvider;
_replayGuard = replayGuard;
_options = options.Value;
}
/// <summary>
/// 校验签名三件套。
/// </summary>
/// <param name="appKey">X-App-Key</param>
/// <param name="timestamp">X-Timestamp</param>
/// <param name="signature">X-Signature</param>
/// <param name="method">HTTP 方法</param>
/// <param name="canonicalTarget">canonical target(path[?sortedQuery],见 <see cref="SignCanonical"/>)</param>
/// <param name="nonce">可选防重放 nonce;提供时参与防重放判定</param>
/// <param name="ct">取消令牌</param>
/// <returns>校验通过返回 true;签名错/过期/重放返回 false</returns>
public async Task<bool> VerifyAsync(string appKey, string timestamp, string signature,
string method, string canonicalTarget, string? nonce = null, CancellationToken ct = default)
{
var secret = await _secretProvider.GetSecretAsync(appKey, ct);
if (secret == null)
{
return false; // 未知 app 直接拒绝
}
if (!SignUtil.VerifyRequestWithExpire(method, canonicalTarget, timestamp, secret, signature, _options.ExpireMs))
{
return false;
}
if (nonce == null)
{
return true;
}
return await _replayGuard.TryBeginAsync(appKey, timestamp, nonce, ct);
}
}
public static class AppAuthServiceCollectionExtensions
{
/// <summary>
/// 注册应用签名鉴权(客户端 AppSignHandler + 服务端 SignKeyResolver/ReplayGuard/AppAuthVerifier)。
/// </summary>
public static IServiceCollection AddYhAppAuth(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<AppAuthOptions>()
.Bind(configuration.GetSection(AppAuthOptions.SectionName))
.PostConfigure(o =>
{
if (o.ExpireMs <= 0)
{
o.ExpireMs = SignUtil.DefaultExpireMs;
}
});
services.AddMemoryCache();
// Redis(可选):连接串为空时整条 Redis 链路自动降级
services.AddSingleton<Func<IDatabase?>>(sp =>
{
var options = sp.GetRequiredService<IOptions<AppAuthOptions>>().Value;
if (string.IsNullOrEmpty(options.RedisConnectionString))
{
return () => null;
}
var multiplexer = ConnectionMultiplexer.Connect(options.RedisConnectionString);
return () => multiplexer.GetDatabase();
});
// 密钥兜底:仅当配置开启时直调 UserBase internal 端点
services.AddSingleton<ISignKeyFallback>(sp =>
{
var options = sp.GetRequiredService<IOptions<AppAuthOptions>>().Value;
if (!options.UserBaseSignKeyFallbackEnabled || string.IsNullOrEmpty(options.UserBaseBaseUrl))
{
return NoOpSignKeyFallback.Instance;
}
var factory = sp.GetRequiredService<IHttpClientFactory>();
return new UserBaseSignKeyFallback(factory.CreateClient("yh-appauth-signkey"), options.UserBaseBaseUrl);
});
services.AddSingleton<SignKeyResolver>();
services.AddSingleton<IAppSecretProvider>(sp => new SignKeyResolverAppSecretProvider(sp.GetRequiredService<SignKeyResolver>()));
services.AddSingleton<IReplayGuard>(sp =>
{
var options = sp.GetRequiredService<IOptions<AppAuthOptions>>().Value;
return string.IsNullOrEmpty(options.RedisConnectionString)
? new MemoryReplayGuard(options.ExpireMs)
: new RedisReplayGuard(sp.GetRequiredService<Func<IDatabase?>>(), options.ExpireMs);
});
services.AddSingleton<AppAuthVerifier>();
return services;
}
}
@@ -0,0 +1,77 @@
using System.Net.Http;
namespace Yh.Platform.AppAuth;
/// <summary>
/// 客户端出站签名 <see cref="DelegatingHandler"/>(对齐 Java <c>AppSignInterceptor</c>):
/// 为请求自动附加 <c>X-App-Key / X-Timestamp / X-Signature</c> 三头;
/// 签名为请求级(canonical target 只取 path + 排序 query,Base64 输出)。
/// 签名前剥离 <paramref name="basePath"/>(对齐 Java stripBasePath):canonical 口径与服务端验签统一为 /oapi/**,
/// basePath 须与 UserBase 部署 context-path 一致(如 /userbase)。
/// </summary>
public class AppSignHandler : DelegatingHandler
{
public const string HeaderAppKey = "X-App-Key";
public const string HeaderTimestamp = "X-Timestamp";
public const string HeaderSignature = "X-Signature";
private readonly string _appKey;
private readonly string _appSecret;
private readonly string? _basePath;
public AppSignHandler(string appKey, string appSecret)
: this(appKey, appSecret, null, null)
{
}
/// <summary>
/// 构造签名处理器。innerHandler 为 null 时 InnerHandler 保持 null(DelegatingHandler 默认),
/// 由 HttpClientFactory 注入 primary handler(AddHttpMessageHandler 场景要求 InnerHandler 初始为 null);
/// 独立使用时显式传入内层 handler(见 BaseOpenApi / 单元测试)。
/// </summary>
public AppSignHandler(string appKey, string appSecret, string? basePath, HttpMessageHandler? innerHandler = null)
: base()
{
_appKey = appKey ?? throw new ArgumentNullException(nameof(appKey));
_appSecret = appSecret ?? throw new ArgumentNullException(nameof(appSecret));
_basePath = NormalizeBasePath(basePath);
if (innerHandler != null)
{
InnerHandler = innerHandler;
}
}
/// <summary>归一化 basePath:trim、去尾斜杠;null/空白返回 null(挂根路径,不剥离)。</summary>
internal static string? NormalizeBasePath(string? basePath)
{
if (string.IsNullOrWhiteSpace(basePath))
{
return null;
}
var trimmed = basePath.Trim().TrimEnd('/');
if (trimmed.Length == 0)
{
return null;
}
return trimmed.StartsWith('/') ? trimmed : "/" + trimmed;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is { IsAbsoluteUri: true } uri)
{
// 时钟源统一走 SignUtil.NowMillis(与 SignUtil 共用同一可注入时钟,测试可固定 ts 与基线逐字节对拍)
var ts = SignUtil.NowMillis().ToString();
var target = SignCanonical.BuildTarget(uri, _basePath);
var sign = SignUtil.SignRequest(request.Method.Method, target, ts, _appSecret);
var headers = request.Headers;
headers.Remove(HeaderAppKey);
headers.Remove(HeaderTimestamp);
headers.Remove(HeaderSignature);
headers.TryAddWithoutValidation(HeaderAppKey, _appKey);
headers.TryAddWithoutValidation(HeaderTimestamp, ts);
headers.TryAddWithoutValidation(HeaderSignature, sign);
}
return base.SendAsync(request, cancellationToken);
}
}
@@ -0,0 +1,8 @@
// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛)
global using System;
global using System.Collections.Concurrent;
global using System.Collections.Generic;
global using System.Linq;
global using System.Net.Http;
global using System.Threading;
global using System.Threading.Tasks;
@@ -0,0 +1,91 @@
using System.Collections.Concurrent;
using StackExchange.Redis;
namespace Yh.Platform.AppAuth;
/// <summary>
/// 防重放守卫:同一 (appKey, timestamp, nonce) 只允许提交一次,有效期同请求窗口(默认 5 分钟)。
/// </summary>
public interface IReplayGuard
{
/// <summary>true = 首次提交放行;false = 重复提交拒绝。</summary>
Task<bool> TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default);
}
/// <summary>进程内防重放(单实例部署或 Redis 未配置时使用)。</summary>
public sealed class MemoryReplayGuard : IReplayGuard
{
private readonly ConcurrentDictionary<string, DateTimeOffset> _seen = new(StringComparer.Ordinal);
private readonly long _windowMs;
private DateTimeOffset _lastSweep = DateTimeOffset.MinValue;
private readonly object _sweepLock = new();
public MemoryReplayGuard(long windowMs = SignUtil.DefaultExpireMs)
{
_windowMs = windowMs;
}
public Task<bool> TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default)
{
Sweep();
var key = appKey + ":" + timestamp + ":" + nonce;
var now = DateTimeOffset.UtcNow;
var expiry = now.AddMilliseconds(_windowMs);
// 原子插入:首次出现即放行;已存在且未过期 → 拒绝;已过期 → 允许覆盖重放窗口
if (!_seen.TryAdd(key, expiry))
{
if (_seen.TryGetValue(key, out var existing) && existing > now)
{
return Task.FromResult(false);
}
_seen[key] = expiry;
}
return Task.FromResult(true);
}
private void Sweep()
{
var now = DateTimeOffset.UtcNow;
lock (_sweepLock)
{
if (now - _lastSweep < TimeSpan.FromSeconds(30))
{
return;
}
_lastSweep = now;
}
foreach (var kv in _seen)
{
if (kv.Value <= now)
{
_seen.TryRemove(kv.Key, out _);
}
}
}
}
/// <summary>
/// Redis 防重放:SETNX 键 <c>sign:replay:{appKey}:{timestamp}:{nonce}</c>,TTL = 请求窗口。
/// </summary>
public sealed class RedisReplayGuard : IReplayGuard
{
private readonly Func<IDatabase?> _redisDatabaseFactory;
private readonly long _windowMs;
public RedisReplayGuard(Func<IDatabase?> redisDatabaseFactory, long windowMs = SignUtil.DefaultExpireMs)
{
_redisDatabaseFactory = redisDatabaseFactory;
_windowMs = windowMs;
}
public async Task<bool> TryBeginAsync(string appKey, string timestamp, string nonce, CancellationToken ct = default)
{
var redis = _redisDatabaseFactory();
if (redis == null)
{
return true; // Redis 未配置时不拦截
}
var key = "sign:replay:" + appKey + ":" + timestamp + ":" + nonce;
return await redis.StringSetAsync(key, "1", TimeSpan.FromMilliseconds(_windowMs), When.NotExists);
}
}
@@ -0,0 +1,13 @@
namespace Yh.Platform.AppAuth;
/// <summary>
/// 应用密钥载荷(对齐 Java <c>SignAppKeyPayload</c>,与 UserBase SignKeyInternalController 返回契约一致)。
/// </summary>
public class SignAppKeyPayload
{
public string? AppKey { get; set; }
public string? Secret { get; set; }
public bool Enabled { get; set; }
}
@@ -0,0 +1,125 @@
using System.Net;
namespace Yh.Platform.AppAuth;
/// <summary>
/// 请求签名规范化工具(调用方签名与服务端验签共用)。
/// canonical 的 target 部分只取 <b>path(不含 host/port/context-path)+ 排序后的 query</b>:
/// <c>METHOD + "\n" + path[?sortedQuery] + "\n" + timestamp</c>。
/// 只编码 path 可让服务发现/负载均衡解析前后两端 host 不一致时仍天然对齐。
/// </summary>
public static class SignCanonical
{
/// <summary>拼接 target:path +(有 query 时 ?sortedQuery)。</summary>
public static string BuildTarget(string path, string sortedQuery)
=> string.IsNullOrEmpty(sortedQuery) ? path : path + "?" + sortedQuery;
/// <summary>从 <see cref="Uri"/> 构建 canonical target(AbsolutePath + 排序 query),不剥离 basePath。</summary>
public static string BuildTarget(Uri uri)
=> BuildTarget(uri, null);
/// <summary>
/// 从 <see cref="Uri"/> 构建 canonical target,并在签名前剥离部署 basePath
/// (对齐 Java <c>AppSignInterceptor.stripBasePath</c>:客户端配置 base-path 与部署 context-path 一致时,
/// 签名口径与服务端 <c>AppAuthFilter</c> 剥 <c>request.getContextPath()</c> 后严格对齐,canonical 统一为 /oapi/**)。
/// </summary>
public static string BuildTarget(Uri uri, string? basePath)
{
var rawQuery = uri.Query;
if (rawQuery.StartsWith('?'))
{
rawQuery = rawQuery.Substring(1);
}
var path = string.IsNullOrEmpty(basePath) ? uri.AbsolutePath : StripBasePath(uri.AbsolutePath, basePath);
return BuildTarget(path, SortRawQuery(rawQuery));
}
/// <summary>
/// 从请求路径剥离部署基础路径(对齐 Java <c>AppSignInterceptor.stripBasePath</c>,含尾斜杠归一化边界):
/// path 不以 basePath 开头时原样返回;剥离后为空或丢失前导斜杠时补 "/"。
/// </summary>
public static string StripBasePath(string path, string? basePath)
{
if (string.IsNullOrEmpty(basePath))
{
return path;
}
var bp = basePath.EndsWith('/') ? basePath : basePath + "/";
var p = path.EndsWith('/') ? path : path + "/";
if (!p.StartsWith(bp, StringComparison.Ordinal))
{
return path;
}
var stripped = path.Substring(basePath.Length);
if (stripped.Length == 0 || stripped[0] != '/')
{
return "/" + stripped;
}
return stripped;
}
/// <summary>
/// 从原始 query 串(a=1&amp;b=2,可能已编码)解码并整体排序,输出 k=v&amp;k2=v2。
/// 与 Java <c>sortRawQuery</c> 一致:先拆 k/v、各自 URL 解码、按 "k=v" 整串排序后拼接。
/// </summary>
public static string SortRawQuery(string? rawQuery)
{
if (string.IsNullOrEmpty(rawQuery))
{
return "";
}
var pairs = new List<string>();
foreach (var part in rawQuery.Split('&'))
{
if (part.Length == 0)
{
continue;
}
var idx = part.IndexOf('=');
var k = idx < 0 ? part : part.Substring(0, idx);
var v = idx < 0 ? "" : part.Substring(idx + 1);
pairs.Add(UrlDecode(k) + "=" + UrlDecode(v));
}
pairs.Sort(StringComparer.Ordinal);
return string.Join("&", pairs);
}
/// <summary>
/// 从多值参数表排序,输出 k=v&amp;k2=v2(键排序、值也排序,保证顺序无关)。
/// 对齐 Java <c>sortMultiValue</c>。
/// </summary>
public static string SortMultiValue(IEnumerable<KeyValuePair<string, IEnumerable<string?>>> multiValueParams)
{
if (multiValueParams == null)
{
return "";
}
var pairs = new List<string>();
foreach (var (key, values) in multiValueParams)
{
var vals = (values ?? Enumerable.Empty<string?>())
.Select(v => v ?? "")
.OrderBy(v => v, StringComparer.Ordinal)
.ToList();
foreach (var v in vals)
{
pairs.Add(key + "=" + v);
}
}
pairs.Sort(StringComparer.Ordinal);
return string.Join("&", pairs);
}
private static string UrlDecode(string s)
{
try
{
return Uri.UnescapeDataString(s.Replace("+", " "));
}
catch (UriFormatException)
{
// 非法百分号编码(如 ?a=100%):原样参与排序即可,服务端不崩溃(验签必失败 → 401,而非 500)
return s;
}
}
}
@@ -0,0 +1,67 @@
namespace Yh.Platform.AppAuth;
/// <summary>服务端验签密钥来源(对齐 Java <c>AppSecretProvider</c>):未知应用返回 null。</summary>
public interface IAppSecretProvider
{
Task<string?> GetSecretAsync(string appKey, CancellationToken ct = default);
}
/// <summary>签名密钥兜底来源(对齐 Java <c>SignKeyFallback</c>)。</summary>
public interface ISignKeyFallback
{
Task<string?> ResolveAsync(string appKey, CancellationToken ct = default);
}
/// <summary>空实现兜底。</summary>
public sealed class NoOpSignKeyFallback : ISignKeyFallback
{
public static readonly NoOpSignKeyFallback Instance = new();
public Task<string?> ResolveAsync(string appKey, CancellationToken ct = default) => Task.FromResult<string?>(null);
}
/// <summary>
/// UserBase 密钥兜底:直调 <c>GET {baseUrl}/oapi/internal/sign-key/{appKey}</c>,
/// 解析 <see cref="SignAppKeyPayload"/>;仅当 enabled 且 secret 非空时返回;404/异常/超时均返回 null。
/// </summary>
public sealed class UserBaseSignKeyFallback : ISignKeyFallback
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
public UserBaseSignKeyFallback(HttpClient httpClient, string baseUrl)
{
_httpClient = httpClient;
_baseUrl = (baseUrl ?? "").TrimEnd('/');
}
public async Task<string?> ResolveAsync(string appKey, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(_baseUrl) || string.IsNullOrEmpty(appKey))
{
return null;
}
try
{
using var resp = await _httpClient.GetAsync(_baseUrl + "/oapi/internal/sign-key/" + Uri.EscapeDataString(appKey), ct);
if (!resp.IsSuccessStatusCode)
{
return null;
}
var json = await resp.Content.ReadAsStringAsync(ct);
var envelope = System.Text.Json.JsonSerializer.Deserialize<Yh.Platform.Abstractions.ApiEnvelope<SignAppKeyPayload>>(json, JsonOpts);
var payload = envelope?.Data;
if (payload == null || !payload.Enabled || string.IsNullOrEmpty(payload.Secret))
{
return null;
}
return payload.Secret;
}
catch
{
return null;
}
}
private static readonly System.Text.Json.JsonSerializerOptions JsonOpts = new(System.Text.Json.JsonSerializerDefaults.Web);
}
@@ -0,0 +1,125 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Yh.Platform.AppAuth;
/// <summary>
/// 验签密钥解析链(对齐 Java <c>SignKeyResolver</c>):
/// 配置内置应用(yh:app-auth AppKey/AppSecret)→ Redis(sign:app:{appKey})→ IMemoryCache → ISignKeyFallback(UserBase /oapi/internal/sign-key/{appKey});
/// 未知 app 返回 null(调用方直接拒绝)。
/// </summary>
public class SignKeyResolver
{
private static readonly System.Text.Json.JsonSerializerOptions JsonOpts = new(System.Text.Json.JsonSerializerDefaults.Web);
private readonly AppAuthOptions _options;
private readonly IMemoryCache _memoryCache;
private readonly ISignKeyFallback _fallback;
private readonly Func<IDatabase?>? _redisDatabaseFactory;
private IDatabase? _redis;
public SignKeyResolver(IOptions<AppAuthOptions> options, IMemoryCache memoryCache, ISignKeyFallback? fallback = null)
: this(options.Value, memoryCache, fallback ?? NoOpSignKeyFallback.Instance, null)
{
}
public SignKeyResolver(AppAuthOptions options, IMemoryCache memoryCache, ISignKeyFallback fallback, Func<IDatabase?>? redisDatabaseFactory)
{
_options = options;
_memoryCache = memoryCache;
_fallback = fallback;
_redisDatabaseFactory = redisDatabaseFactory;
}
/// <summary>解析调用方 appKey 对应的 appSecret;未知/不可用返回 null。</summary>
public async Task<string?> GetSecretAsync(string appKey, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(appKey))
{
return null;
}
// 0. 配置内置应用(自服务/联调):调用方 appKey 与本应用 yh:app-auth 配置一致时,
// 直接用配置的 AppSecret 验签。覆盖"系统 A 以自身 AppKey/AppSecret 调系统 B 的 /oapi":
// B 验签时 appKey==B 配置 AppKey → 用 B 配置 AppSecret 校验,无需 Redis/UserBase 预置密钥。
if (string.Equals(appKey, _options.AppKey, StringComparison.Ordinal))
{
return string.IsNullOrEmpty(_options.AppSecret) ? null : _options.AppSecret;
}
// 1. Redis 主路径(宕机/未配置时静默降级)
var redis = TryGetRedis();
if (redis != null)
{
try
{
// RedisValue 同时可隐式转换为 string / byte[],直接传给 Deserialize 会触发重载二义性,故先显式取 string
var json = (string?)await redis.StringGetAsync(RedisKey(appKey));
if (!string.IsNullOrEmpty(json))
{
var payload = System.Text.Json.JsonSerializer.Deserialize<SignAppKeyPayload>(json, JsonOpts);
var secret = Normalize(payload);
if (secret != null)
{
CacheLocal(appKey, secret);
return secret;
}
}
}
catch (RedisConnectionException)
{
// 降级到本地缓存/fallback
}
catch (System.Text.Json.JsonException)
{
// 脏数据视同未命中
}
}
// 2. 本地缓存
if (_memoryCache.TryGetValue(CacheKey(appKey), out string? cached) && cached != null)
{
return cached;
}
// 3. 兜底(UserBase),成功后回填本地缓存
var fallbackSecret = await _fallback.ResolveAsync(appKey, ct);
if (fallbackSecret != null)
{
CacheLocal(appKey, fallbackSecret);
}
return fallbackSecret;
}
private string RedisKey(string appKey) => _options.SignKeyRedisKeyPrefix + appKey;
private static string CacheKey(string appKey) => "yh:signkey:local:" + appKey;
private void CacheLocal(string appKey, string secret)
=> _memoryCache.Set(CacheKey(appKey), secret, TimeSpan.FromMinutes(5));
private static string? Normalize(SignAppKeyPayload? payload)
=> payload == null || !payload.Enabled || string.IsNullOrEmpty(payload.Secret) ? null : payload.Secret;
private IDatabase? TryGetRedis()
{
if (_redisDatabaseFactory == null)
{
return null;
}
if (_redis != null)
{
return _redis;
}
try
{
_redis = _redisDatabaseFactory();
}
catch
{
_redis = null;
}
return _redis;
}
}
@@ -0,0 +1,154 @@
using System.Security.Cryptography;
using System.Text;
namespace Yh.Platform.AppAuth;
/// <summary>
/// HmacSHA256 接口签名工具类(对齐 Java <c>utils.crypto.SignUtil</c>,输出格式逐字节一致)。
/// 双签名规则:
/// <list type="bullet">
/// <item>数据级:source = data + "|" + timeStamp,HmacSHA256 输出<b>小写十六进制</b>;</item>
/// <item>请求级:source = METHOD + "\n" + url + "\n" + timeStamp,HmacSHA256 输出 <b>Base64</b>。</item>
/// </list>
/// </summary>
public static class SignUtil
{
private const string AlgHmacSha256 = "HmacSHA256";
private const string SignSplitChar = "|";
/// <summary>签名密钥最低长度</summary>
public const int MinSecretLength = 16;
/// <summary>默认请求有效时长:5 分钟(毫秒)</summary>
public const long DefaultExpireMs = 5 * 60 * 1000;
// ====================== 数据级签名 ======================
/// <summary>生成数据级签名(data|ts → HmacSHA256 小写十六进制)。</summary>
public static string Sign(string data, string timeStamp, string secret)
{
CheckParam(data, timeStamp, secret);
return HmacSha256Hex(data + SignSplitChar + timeStamp, secret);
}
/// <summary>数据级验签(不校验时间戳,忽略大小写比对)。</summary>
public static bool Verify(string data, string timeStamp, string secret, string? clientSign)
{
if (string.IsNullOrWhiteSpace(clientSign))
{
return false;
}
var serverSign = Sign(data, timeStamp, secret);
return string.Equals(serverSign, clientSign, StringComparison.OrdinalIgnoreCase);
}
/// <summary>数据级验签 + 时间戳过期校验(默认 5 分钟)。</summary>
public static bool VerifyWithExpire(string data, string timeStamp, string secret, string? clientSign)
=> VerifyWithExpire(data, timeStamp, secret, clientSign, DefaultExpireMs);
public static bool VerifyWithExpire(string data, string timeStamp, string secret, string? clientSign, long expireMs)
{
if (!Verify(data, timeStamp, secret, clientSign))
{
return false;
}
return !IsExpired(timeStamp, expireMs);
}
// ====================== 请求级签名(统一方案) ======================
/// <summary>
/// 对一次 HTTP 请求签名,输出 Base64。
/// canonical = METHOD + "\n" + url + "\n" + timestamp;url 为 canonical target(path[?sortedQuery],见 <see cref="SignCanonical"/>)。
/// </summary>
public static string SignRequest(string method, string url, string? timeStamp, string secret)
{
if (string.IsNullOrWhiteSpace(method) || string.IsNullOrWhiteSpace(url))
{
throw new ArgumentException("method/url 不能为空");
}
CheckSecret(secret);
if (string.IsNullOrWhiteSpace(timeStamp))
{
timeStamp = NowMillis().ToString();
}
return HmacSha256Base64(method + "\n" + url + "\n" + timeStamp, secret);
}
/// <summary>请求级验签(不校验时间戳,精确比对)。</summary>
public static bool VerifyRequest(string method, string url, string timeStamp, string secret, string? clientSign)
{
if (string.IsNullOrWhiteSpace(clientSign))
{
return false;
}
var serverSign = SignRequest(method, url, timeStamp, secret);
return string.Equals(serverSign, clientSign, StringComparison.Ordinal);
}
/// <summary>请求级验签 + 时间戳过期校验(默认 5 分钟)。</summary>
public static bool VerifyRequestWithExpire(string method, string url, string timeStamp, string secret, string? clientSign)
=> VerifyRequestWithExpire(method, url, timeStamp, secret, clientSign, DefaultExpireMs);
public static bool VerifyRequestWithExpire(string method, string url, string timeStamp, string secret, string? clientSign, long expireMs)
{
if (!VerifyRequest(method, url, timeStamp, secret, clientSign))
{
return false;
}
return !IsExpired(timeStamp, expireMs);
}
// ====================== 内部工具 ======================
/// <summary>当前毫秒时间戳(供测试注入替代)。</summary>
public static Func<long> NowMillis { get; set; } = static () => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private static bool IsExpired(string timeStamp, long expireMs)
{
if (!long.TryParse(timeStamp, out var clientTs))
{
return true;
}
return Math.Abs(NowMillis() - clientTs) > expireMs;
}
private static void CheckParam(string data, string timeStamp, string secret)
{
if (data == null)
{
throw new ArgumentException("业务数据data不能为null");
}
if (string.IsNullOrWhiteSpace(timeStamp))
{
throw new ArgumentException("时间戳timeStamp不能为空");
}
CheckSecret(secret);
}
private static void CheckSecret(string secret)
{
if (string.IsNullOrWhiteSpace(secret))
{
throw new ArgumentException("签名密钥secret不能为空");
}
if (secret.Length < MinSecretLength)
{
throw new ArgumentException("签名密钥长度不能小于" + MinSecretLength + "位");
}
}
private static string HmacSha256Hex(string source, string secret)
{
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = mac.ComputeHash(Encoding.UTF8.GetBytes(source));
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string HmacSha256Base64(string source, string secret)
{
using var mac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = mac.ComputeHash(Encoding.UTF8.GetBytes(source));
return Convert.ToBase64String(hash);
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Yh.Platform.AppAuth</PackageId>
<Description>yunhai 平台应用签名与防重放(对应 Java yh-app-auth-starter):HmacSHA256 SignUtil、AppSignHandler、SignKeyResolver、ReplayGuard</Description>
<Summary>HMAC 应用签名(X-App-Key/X-Timestamp/X-Signature)与验签密钥三层解析链</Summary>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
</Project>
@@ -0,0 +1,94 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Yh.Platform.AppAuth;
using Yh.Platform.OpenApi;
namespace Yh.Platform.Auth;
/// <summary>认证装配扩展(对齐 Java <c>YhAuthAutoConfiguration</c> 与聚合 starter)。</summary>
public static class AuthServiceCollectionExtensions
{
/// <summary>
/// 注册 Cookie + CAS 认证与用户上下文依赖(含 AddYhOpenApi)。
/// 配置节:<c>yh:auth</c>(通用)与 <c>yh:sso</c>(CAS 服务端)。
/// </summary>
public static IServiceCollection AddYhAuth(this IServiceCollection services, IConfiguration configuration)
{
services.AddYhOpenApi(configuration);
// 会话票据表单例:Cookie TicketDataFormat 与 OnSigningOut 退出吊销必须共享同一实例(同一票据表)
var sessionTicketFormat = new SessionTicketDataFormat();
services.AddSingleton(sessionTicketFormat);
services.AddOptions<YhAuthOptions>().Bind(configuration.GetSection(YhAuthOptions.SectionName));
// 注:AddScheme 的 configure 才是 handler 实际读取的命名 options(AuthenticationHandler<TOptions>.Options);
// 必须在此绑定 yh:sso,否则 handler 拿到的 CasAuthenticationOptions 为空(Challenge 时 CasServerUrlPrefix 缺失)。
services.AddOptions<CasAuthenticationOptions>().Bind(configuration.GetSection("yh:sso"));
services.AddAuthentication(options =>
{
// DefaultScheme 必须为 CasScheme(而非 CookieScheme):
// UseAuthentication 每请求调用 AuthenticateAsync(默认 scheme),若默认是 CookieScheme,
// CAS 票据校验(CasAuthenticationHandler.HandleAuthenticateAsync 的 ticket 分支)永远没有触发点——
// 回调带 ticket 时认证不校验、SsoProtectionMiddleware 见未认证直接 401,CAS 登录永远失败。
// CasAuthenticationHandler.HandleAuthenticateAsync 第一步就是 Context.AuthenticateAsync(CookieScheme)
// 恢复 Cookie 登录态,因此 CasScheme 作为默认认证 scheme 语义等价"先 Cookie 恢复、后 ticket 校验"。
options.DefaultScheme = YhAuthDefaults.CasScheme;
options.DefaultChallengeScheme = YhAuthDefaults.CasScheme;
})
.AddCookie(YhAuthDefaults.CookieScheme, options =>
{
var auth = configuration.GetSection(YhAuthOptions.SectionName).Get<YhAuthOptions>() ?? new YhAuthOptions();
options.Cookie.Name = auth.CookieName;
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
// 会话级 Cookie + 服务端票据固定过期:
// - TicketDataFormat(SessionTicketDataFormat):票据存服务端,Cookie 只写 32 位 key
// (.NET 5 SessionStore 模式下 cookie 值仍是 Protect 的加密长串,无法满足 32 位,故自定义 DataFormat;
// 实例与 DI 单例同一对象——TicketDataFormat 与下方 OnSigningOut 事件必须操作同一票据表)
// - IsPersistent=false(CasAuthenticationHandler):会话 Cookie,关闭浏览器即失效
// - ExpireTimeSpan=1 分钟且 SlidingExpiration=false:服务端票据固定 1 分钟过期,不滑动续期
options.TicketDataFormat = sessionTicketFormat;
options.ExpireTimeSpan = TimeSpan.FromMinutes(auth.CookieExpireMinutes > 0 ? auth.CookieExpireMinutes : 1);
options.SlidingExpiration = false;
options.LoginPath = "/signin-cas";
options.ReturnUrlParameter = "returnUrl";
// 退出即吊销:SignOutAsync(如业务 GET /api/Logout)清除 Cookie 时,
// 从请求 Cookie 取 32 位 key 立即删除服务端票据——否则票据残留到 ExpireTimeSpan 过期才失效。
// ("关闭浏览器"本身服务端无法感知,依赖会话 Cookie 删除 + 1 分钟过期兜底;显式退出则双删。)
options.Events = new CookieAuthenticationEvents
{
OnSigningOut = context =>
{
if (context.HttpContext.Request.Cookies.TryGetValue(auth.CookieName, out var rawKey))
{
sessionTicketFormat.Revoke(rawKey);
}
return Task.CompletedTask;
},
};
})
.AddScheme<CasAuthenticationOptions, CasAuthenticationHandler>(YhAuthDefaults.CasScheme,
o => configuration.GetSection("yh:sso").Bind(o));
services.AddSingleton<CasServiceTicketValidator>(sp =>
{
var opts = sp.GetRequiredService<IOptionsMonitor<CasAuthenticationOptions>>().CurrentValue;
return new CasServiceTicketValidator(opts.CasServerUrlPrefix, opts.TicketValidateTimeoutMs);
});
services.AddScoped<IUserDetailsProvider, OpenApiUserDetailsProvider>();
return services;
}
/// <summary>
/// 聚合入口(等价 Java <c>yh-spring-boot-starter</c>):一次带齐 OpenApi + AppAuth + Auth。
/// </summary>
public static IServiceCollection AddYhPlatform(this IServiceCollection services, IConfiguration configuration)
=> services
.AddYhAppAuth(configuration)
.AddYhAuth(configuration);
}
@@ -0,0 +1,56 @@
using Yh.Platform.Abstractions;
namespace Yh.Platform.Auth;
/// <summary>登录用户载体(对齐 Java <c>LoginUser</c>)。</summary>
public class LoginUser
{
public string? UserId { get; set; }
public string? LoginName { get; set; }
public string? DisplayName { get; set; }
/// <summary>应用身份标识。普通用户登录态为 null;由应用签名鉴权写入调用方 appKey。</summary>
public string? AppKey { get; set; }
public LoginUser()
{
}
public LoginUser(string? userId)
{
UserId = userId;
}
}
/// <summary>
/// 当前请求的 <see cref="BoaUser"/> 持有器。
/// 对齐 Java <c>BoaUserContext</c>(ThreadLocal)语义;C# 侧用 <see cref="AsyncLocal{T}"/>,
/// 请求内任何层可取,请求结束由 <see cref="UserContextMiddleware"/> 清空(不串请求)。
/// </summary>
public static class BoaUserContext
{
private static readonly AsyncLocal<BoaUser?> Current = new();
public static void Set(BoaUser? boaUser) => Current.Value = boaUser;
public static BoaUser? Get() => Current.Value;
public static void Clear() => Current.Value = null;
}
/// <summary>
/// 当前请求的 <see cref="LoginUser"/> 持有器(对齐 Java <c>UserContextHolder</c>):
/// 认证中间件写入、业务读取、请求结束清理。
/// </summary>
public static class UserContextHolder
{
private static readonly AsyncLocal<LoginUser?> Current = new();
public static void Set(LoginUser? user) => Current.Value = user;
public static LoginUser? Get() => Current.Value;
public static void Clear() => Current.Value = null;
}
@@ -0,0 +1,140 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Yh.Platform.Auth;
/// <summary>
/// 自研 CAS 远程认证 handler(替换 AspNetCore.Security.CAS 本地 dll):
/// <list type="bullet">
/// <item>Challenge:302 到 CAS 登录页(service 指向本应用 <see cref="CasAuthenticationOptions.SignInCallbackPath"/>);</item>
/// <item>Authenticate:回调带 ticket 时调 /serviceValidate 校验,成功后写入 Cookie 登录态并回跳原地址。</item>
/// </list>
/// </summary>
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
{
private readonly CasServiceTicketValidator _validator;
#if NET8_0_OR_GREATER
public CasAuthenticationHandler(
IOptionsMonitor<CasAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
CasServiceTicketValidator validator)
: base(options, logger, encoder)
{
_validator = validator;
}
#else
public CasAuthenticationHandler(
IOptionsMonitor<CasAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
CasServiceTicketValidator validator)
: base(options, logger, encoder, clock)
{
_validator = validator;
}
#endif
private string BuildServiceUrl(string? returnUrl)
{
var request = Context.Request;
var root = Options.AppBaseUrl?.TrimEnd('/');
if (string.IsNullOrEmpty(root))
{
root = request.Scheme + "://" + request.Host.Value;
}
var callback = root + Options.SignInCallbackPath;
// 只允许本地相对路径回跳,杜绝开放重定向(returnUrl 不得为外站绝对地址 / 协议相对地址)
if (string.IsNullOrEmpty(returnUrl) || !IsLocalUrl(returnUrl))
{
return callback;
}
return callback + "?returnUrl=" + Uri.EscapeDataString(returnUrl);
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
var casBase = Options.CasServerUrlPrefix;
if (string.IsNullOrWhiteSpace(casBase))
{
Response.StatusCode = 500;
return Response.WriteAsync("未配置 CAS 服务端地址(yh:sso:cas-server-url-prefix)");
}
properties.Items.TryGetValue("returnUrl", out var returnUrl);
var service = BuildServiceUrl(returnUrl ?? properties.RedirectUri);
var loginUrl = (casBase!.EndsWith('/') ? casBase : casBase + "/") + "login?service=" + UrlEncoder.Encode(service);
Response.Redirect(loginUrl);
return Task.CompletedTask;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// 1. 已有 Cookie 登录态直接放行
var cookieResult = await Context.AuthenticateAsync(YhAuthDefaults.CookieScheme);
if (cookieResult.Succeeded && cookieResult.Principal != null)
{
return AuthenticateResult.Success(cookieResult.Ticket!);
}
// 2. CAS 回调:校验 ticket
var ticketValue = Context.Request.Query["ticket"].ToString();
if (string.IsNullOrEmpty(ticketValue))
{
return AuthenticateResult.NoResult();
}
// service 必须与登录时完全一致:剥掉本回调的 ticket 参数后重建
var service = BuildServiceUrl(Context.Request.Query["returnUrl"].ToString());
string? loginName;
try
{
loginName = await _validator.ValidateAsync(ticketValue, service, Context.RequestAborted);
}
catch (AuthException e)
{
return AuthenticateResult.Fail(e);
}
if (string.IsNullOrEmpty(loginName))
{
return AuthenticateResult.Fail("CAS ticket 校验失败或已失效");
}
var claims = new List<Claim>
{
new(ClaimTypes.Name, loginName!),
new(ClaimTypes.NameIdentifier, loginName!),
new("loginName", loginName!),
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
// 写入 Cookie 登录态(会话级:IsPersistent=false,关闭浏览器即失效;票据过期由 SessionStore + ExpireTimeSpan 控制),
// 然后回跳去掉 ticket 的原地址(仅限本地路径,防开放重定向)
var props = new AuthenticationProperties { IsPersistent = false };
var returnUrl = Context.Request.Query["returnUrl"].ToString();
await Context.SignInAsync(YhAuthDefaults.CookieScheme, principal, props);
// 同步当前请求上下文身份:SignInAsync 只写 Cookie 不改 context.User,
// 后续中间件(SsoProtectionMiddleware)需据此识别“刚登录成功”,避免被误判未认证再次 Challenge
Context.User = principal;
var redirect = IsLocalUrl(returnUrl) ? returnUrl : "/";
Response.Redirect(redirect);
return AuthenticateResult.NoResult();
}
/// <summary>仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。</summary>
private static bool IsLocalUrl(string? url)
{
if (string.IsNullOrEmpty(url) || url[0] != '/')
{
return false;
}
return !url.StartsWith("//", StringComparison.Ordinal)
&& !url.StartsWith("/\\", StringComparison.Ordinal);
}
}
@@ -0,0 +1,74 @@
namespace Yh.Platform.Auth;
/// <summary>
/// 轻量 CAS service ticket 校验器(对齐 Java <c>CasServiceTicketValidator</c>,不依赖 CAS 客户端库)。
/// 调用 CAS 的 <c>/serviceValidate</c> 端点,从响应 XML 中提取 <c>&lt;cas:user&gt;</c>。
/// </summary>
public class CasServiceTicketValidator
{
private readonly string? _casServerUrl;
private readonly int _timeoutMs;
public CasServiceTicketValidator(string? casServerUrl, int timeoutMs = 5000)
{
_casServerUrl = casServerUrl;
_timeoutMs = timeoutMs > 0 ? timeoutMs : 5000;
}
/// <summary>校验 ticket,成功返回登录账号,失败/无效返回 null。</summary>
public async Task<string?> ValidateAsync(string ticket, string service, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_casServerUrl))
{
throw new AuthException("未配置 CasServerUrlPrefix(yh:sso:cas-server-url-prefix),无法进行 ticket 校验");
}
var base_ = _casServerUrl!.EndsWith('/') ? _casServerUrl : _casServerUrl + "/";
var endpoint = base_ + "serviceValidate"
+ "?ticket=" + Uri.EscapeDataString(ticket ?? "")
+ "&service=" + Uri.EscapeDataString(service ?? "");
try
{
using var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromMilliseconds(_timeoutMs) };
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(_timeoutMs) };
using var resp = await http.GetAsync(endpoint, ct).ConfigureAwait(false);
var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode || body == null)
{
return null;
}
return ExtractUser(body);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
{
throw new AuthException("CAS serviceValidate 请求失败: " + e.Message, e);
}
}
/// <summary>
/// 从 CAS 响应 XML 提取用户名(公开以便对拍测试)。
/// 成功响应形如 &lt;cas:serviceResponse&gt;&lt;cas:authenticationSuccess&gt;&lt;cas:user&gt;NAME&lt;/cas:user&gt;...
/// </summary>
public static string? ExtractUser(string? xml)
{
if (string.IsNullOrEmpty(xml))
{
return null;
}
var start = xml.IndexOf("<cas:user>", StringComparison.Ordinal);
if (start < 0)
{
start = xml.IndexOf("<cas:user ", StringComparison.Ordinal); // 容错带属性
}
if (start < 0)
{
return null;
}
var tagEnd = xml.IndexOf('>', start);
var end = xml.IndexOf("</cas:user>", tagEnd, StringComparison.Ordinal);
if (tagEnd < 0 || end < 0)
{
return null;
}
return xml.Substring(tagEnd + 1, end - tagEnd - 1).Trim();
}
}
@@ -0,0 +1,7 @@
// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛)
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Net.Http;
global using System.Threading;
global using System.Threading.Tasks;
@@ -0,0 +1,105 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Authentication;
namespace Yh.Platform.Auth;
/// <summary>
/// 会话票据数据格式(自定义 <see cref="ISecureDataFormat{TData}"/>):
/// 登录票据(AuthenticationTicket)保存在服务端内存,Cookie 只写 32 位 key(Guid N 格式),
/// 实现「Cookie 短值 + 服务端可控过期」。
/// <para>
/// 为什么不用框架 SessionStore:.NET 5 的 CookieAuthenticationHandler 即便配置了 SessionStore,
/// cookie 值仍是 <c>TicketDataFormat.Protect(精简票据)</c> 的加密长串(CfDJ8…),无法满足"Cookie 32 位"。
/// 直接自定义 TicketDataFormat:Protect 落库返回 32 位 key,Unprotect 查表并校验过期。
/// </para>
/// <para>
/// 语义:
/// <list type="bullet">
/// <item>关闭浏览器失效:<c>AuthenticationProperties.IsPersistent=false</c>(会话 Cookie,无持久化头);</item>
/// <item>固定过期:服务端票据按 <c>Properties.ExpiresUtc</c>(由 ExpireTimeSpan 决定)过期,Unprotect 过期返回 null → 未认证;</item>
/// <item>显式退出立即失效:<see cref="Revoke"/> 删除指定 key(由 Cookie 注册的 OnSigningOut 事件触发,退出不等过期);</item>
/// <item>安全:key 为 128 位随机熵(Guid),不可枚举猜测;查表失败/过期即无效。</item>
/// </list>
/// </para>
/// </summary>
public class SessionTicketDataFormat : ISecureDataFormat<AuthenticationTicket>
{
private readonly ConcurrentDictionary<string, AuthenticationTicket> _tickets = new(StringComparer.Ordinal);
private long _lastCleanupUtc;
public string Protect(AuthenticationTicket data) => Protect(data, null);
public string Protect(AuthenticationTicket data, string? purpose)
{
var key = Guid.NewGuid().ToString("N"); // 32 个十六进制字符
_tickets[key] = data;
return key;
}
#if NET8_0_OR_GREATER
public AuthenticationTicket Unprotect(string? protectedText) => Unprotect(protectedText, null);
#else
public AuthenticationTicket Unprotect(string protectedText) => Unprotect(protectedText, null);
#endif
#if NET8_0_OR_GREATER
public AuthenticationTicket Unprotect(string? protectedText, string? purpose)
#else
public AuthenticationTicket Unprotect(string protectedText, string? purpose)
#endif
{
if (protectedText is null || !_tickets.TryGetValue(protectedText, out var ticket))
{
CleanupExpiredIfDue();
return null!;
}
// 服务端票据过期:视为无效并清理(配合 SlidingExpiration=false 实现固定 1 分钟过期)
if (ticket.Properties.ExpiresUtc.HasValue && ticket.Properties.ExpiresUtc.Value < DateTimeOffset.UtcNow)
{
_tickets.TryRemove(protectedText, out _);
return null!;
}
return ticket;
}
/// <summary>
/// 吊销指定票据:显式退出(SignOut,如 /api/Logout)时服务端票据立即失效,无需等待过期兜底。
/// 由 Cookie 注册处的 <c>OnSigningOut</c> 事件从请求 Cookie 读取 32 位 key 后调用。
/// </summary>
public void Revoke(string? key)
{
if (!string.IsNullOrEmpty(key))
{
_tickets.TryRemove(key, out _);
}
CleanupExpiredIfDue();
}
/// <summary>
/// 分钟级摊还清理过期票据,防内存膨胀:Unprotect/Revoke 路径触发,最多每分钟一次全量扫描,
/// 并发下仅一个线程进入(Interlocked.CompareExchange 门闩)。
/// </summary>
private void CleanupExpiredIfDue()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var last = Volatile.Read(ref _lastCleanupUtc);
if (now - last < 60)
{
return;
}
if (Interlocked.CompareExchange(ref _lastCleanupUtc, now, last) != last)
{
return;
}
foreach (var kvp in _tickets)
{
if (kvp.Value.Properties.ExpiresUtc is { } exp && exp < DateTimeOffset.UtcNow)
{
_tickets.TryRemove(kvp.Key, out _);
}
}
}
}
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Yh.Platform.OpenApi;
namespace Yh.Platform.Auth;
/// <summary>
/// 用户上下文中间件:认证成功后按登录名换取 <see cref="BoaUser"/> 并写入 <see cref="BoaUserContext"/>;
/// 请求结束(含异常)保证清空,防止上下文跨请求串扰。
/// 注意:<see cref="IUserDetailsProvider"/> 为 Scoped 依赖,必须走 InvokeAsync 方法注入(每请求解析);
/// 若放构造函数会被 UseMiddleware 启动时从根容器激活,抛出
/// "Cannot resolve scoped service ... from root provider"(ASP.NET Core 中间件约束)。
/// </summary>
public class UserContextMiddleware
{
private readonly RequestDelegate _next;
public UserContextMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context, IUserDetailsProvider userDetailsProvider)
{
try
{
if (context.User.Identity?.IsAuthenticated == true)
{
var loginName = context.User.FindFirst("loginName")?.Value
?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value;
var user = await userDetailsProvider.GetByLoginNameAsync(loginName ?? "", context.RequestAborted).ConfigureAwait(false);
if (user != null)
{
BoaUserContext.Set(user);
UserContextHolder.Set(new LoginUser(user.Id?.ToString())
{
LoginName = loginName,
DisplayName = user.Name,
});
}
}
await _next(context).ConfigureAwait(false);
}
finally
{
BoaUserContext.Clear();
UserContextHolder.Clear();
}
}
}
/// <summary>用户上下文中间件注册扩展。</summary>
public static class UserContextMiddlewareExtensions
{
public static IApplicationBuilder UseYhUserContext(this IApplicationBuilder app)
=> app.UseMiddleware<UserContextMiddleware>();
}
@@ -0,0 +1,52 @@
using Yh.Platform.Abstractions;
using Yh.Platform.OpenApi;
namespace Yh.Platform.Auth;
/// <summary>认证链路异常(对齐 Java <c>AuthException</c>)。</summary>
public class AuthException : Exception
{
public AuthException(string message) : base(message)
{
}
public AuthException(string message, Exception? innerException) : base(message, innerException)
{
}
}
/// <summary>
/// 用户明细 SPI(对齐 Java <c>UserDetailsProvider</c>):认证链路解析出登录名后,由本接口换取 <see cref="BoaUser"/>。
/// 默认实现 <see cref="OpenApiUserDetailsProvider"/> 走 <c>IOpenApiUserClient</c>;业务侧可替换为本地查询。
/// </summary>
public interface IUserDetailsProvider
{
Task<BoaUser?> GetByLoginNameAsync(string loginName, CancellationToken ct = default);
}
/// <summary>默认实现:调 UserBase /oapi/user/byLoginName。</summary>
public class OpenApiUserDetailsProvider : IUserDetailsProvider
{
private readonly IOpenApiUserClient _userClient;
public OpenApiUserDetailsProvider(IOpenApiUserClient userClient)
{
_userClient = userClient;
}
public async Task<BoaUser?> GetByLoginNameAsync(string loginName, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(loginName))
{
return null;
}
try
{
return await _userClient.GetUserByLoginNameAsync(loginName, ct).ConfigureAwait(false);
}
catch (DException)
{
return null; // 用户不存在(code=4/5 信封)视同未找到,交由认证链路按未认证处理
}
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Yh.Platform.Auth</PackageId>
<Description>yunhai 平台认证与用户上下文(对应 Java yh-auth-starter):CAS SSO 认证 handler、BoaUserContext、IUserDetailsProvider、AddYhAuth/AddYhPlatform 聚合入口</Description>
<Summary>CAS 单点登录 + BoaUser 请求上下文(AsyncLocal),替换 AspNetCore.Security.CAS 本地 dll</Summary>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj" />
<ProjectReference Include="..\Yh.Platform.OpenApi\Yh.Platform.OpenApi.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -0,0 +1,50 @@
using System.Net;
using Microsoft.AspNetCore.Authentication;
namespace Yh.Platform.Auth;
/// <summary>
/// 认证方案名常量。
/// </summary>
public static class YhAuthDefaults
{
/// <summary>Cookie 认证方案(登录态载体,替代 GrowthHealth 既有 Cookie scheme)</summary>
public const string CookieScheme = "YhCookie";
/// <summary>CAS 认证方案(challenge 时重定向 CAS 登录页)</summary>
public const string CasScheme = "YhCas";
}
/// <summary>
/// CAS 认证配置(对齐 GrowthHealth <c>AddCasSso()</c> 与 Java <c>yh.sso.*</c>)。
/// </summary>
public class CasAuthenticationOptions : AuthenticationSchemeOptions
{
/// <summary>CAS 服务端地址前缀,如 https://sso.example.com/cas</summary>
public string? CasServerUrlPrefix { get; set; }
/// <summary>本应用根地址(用于拼 service 回调地址),为空时从当前请求推断</summary>
public string? AppBaseUrl { get; set; }
/// <summary>ticket 回调路径</summary>
public string SignInCallbackPath { get; set; } = "/signin-cas";
/// <summary>ticket 校验超时(毫秒),对齐 Java 轻量校验器 5 秒</summary>
public int TicketValidateTimeoutMs { get; set; } = 5000;
}
/// <summary>通用鉴权配置(对齐 Java <c>yh.auth.*</c>)。</summary>
public class YhAuthOptions
{
public const string SectionName = "yh:auth";
/// <summary>是否启用(默认 true)</summary>
public bool Enabled { get; set; } = true;
/// <summary>登录态有效期(分钟),默认 30 分钟(固定过期,不滑动续期)</summary>
public int CookieExpireMinutes { get; set; } = 30;
/// <summary>Cookie 名</summary>
public string CookieName { get; set; } = ".yh.auth";
}
@@ -0,0 +1,55 @@
using Yh.Platform.AppAuth;
using Yh.Platform.Abstractions;
namespace Yh.Platform.OpenApi;
/// <summary>
/// 实例形态门面:复刻旧 BschOpenApi 的 <c>new BaseOpenApi(host, appKey, appSecret)</code> 构造习惯,
/// GrowthHealth 等消费方迁移时改 using 即可。行为与静态门面 <see cref="YschBaseOpenApi"/> 一致。
/// </summary>
public class BaseOpenApi
{
public IOpenApiUserClient User { get; }
public IOpenApiGroupClient Group { get; }
public IOpenApiAppClient App { get; }
/// <summary>旧 SDK 同构构造:host + appKey + appSecret(basePath 默认 /userbase)。</summary>
public BaseOpenApi(string host, string appKey, string appSecret, string? basePath = "/userbase")
: this(new OpenApiOptions
{
BaseHost = host,
BasePath = basePath,
AppKey = appKey,
AppSecret = appSecret,
})
{
}
public BaseOpenApi(OpenApiOptions options, HttpMessageHandler? handler = null)
{
var signHandler = new AppSignHandler(options.AppKey ?? "", options.AppSecret ?? "", options.BasePath, handler);
var httpClient = new HttpClient(signHandler)
{
Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000),
};
User = new OpenApiUserClient(httpClient, options);
Group = new OpenApiGroupClient(httpClient, options);
App = new OpenApiAppClient(httpClient, options);
}
// ============================ 旧 BaseOpenApi 高频方法直通 ============================
/// <summary>按登录账号查询用户明细(旧 SDK 直通方法)。</summary>
public Task<BoaUser?> GetUserInfoByLoginNameAsync(string loginName, CancellationToken ct = default)
=> User.GetUserInfoAsync(loginName, ct);
/// <summary>按用户主键查询用户信息(旧 SDK 直通方法)。</summary>
public Task<BoaUser?> GetUserInfoByIdAsync(int userId, CancellationToken ct = default)
=> User.GetUserInfoAsync(userId, ct);
/// <summary>按用户查询角色身份列表(旧 SDK 直通方法)。</summary>
public Task<List<BoaRoleInfo>> GetUserRoleListAsync(int userId, CancellationToken ct = default)
=> User.GetUserRoleListAsync(userId, ct);
}
@@ -0,0 +1,8 @@
// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛)
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Net.Http;
global using System.Text.Json;
global using System.Threading;
global using System.Threading.Tasks;
@@ -0,0 +1,45 @@
using Yh.Platform.Abstractions;
namespace Yh.Platform.OpenApi;
/// <summary>
/// 组织/组域开放接口客户端契约(对齐 UserBase /oapi/group/**,镜像 Java <c>OpenApiGroupClient</c>)。
/// </summary>
public interface IOpenApiGroupClient
{
/// <summary>获取整个平台的组树结构(GET /oapi/group/tree)</summary>
Task<BoaGroup?> GetGroupTreeAsync(CancellationToken ct = default);
/// <summary>按组ID查询单个组;withSub=true 时递归返回整棵子树(GET /oapi/group/byId)</summary>
Task<BoaGroup?> GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default);
/// <summary>按多个组ID(逗号/空白分隔)批量查询(GET /oapi/group/list)</summary>
Task<List<BoaGroup>> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default);
/// <summary>新增组(POST /oapi/group/add)</summary>
Task<BoaGroup?> AddGroupAsync(SysGroupRequest request, CancellationToken ct = default);
/// <summary>编辑组(路径 id 权威;body 不含 id):POST /oapi/group/edit/{id}</summary>
Task<BoaGroup?> EditGroupAsync(int id, SysGroupRequest request, CancellationToken ct = default);
/// <summary>删除组(组下存在子组或用户时服务端拒绝):POST /oapi/group/delete/{id}/{schoolId}</summary>
Task DeleteGroupAsync(int id, int schoolId, CancellationToken ct = default);
/// <summary>批量新增子组(POST /oapi/group/batchAdd)</summary>
Task<List<BoaGroup>> BatchAddGroupsAsync(SysGroupBatchRequest request, CancellationToken ct = default);
/// <summary>移动组(学校限定):POST /oapi/group/move/{schoolId},body 仅含 moveId/pid</summary>
Task MoveGroupAsync(int schoolId, SysGroupMoveRequest request, CancellationToken ct = default);
}
/// <summary>
/// 应用域开放接口客户端契约(对齐 UserBase /oapi/app/**,镜像 Java <c>OpenApiAppClient</c>)。
/// </summary>
public interface IOpenApiAppClient
{
/// <summary>按应用ID查询应用信息(GET /oapi/app/{appId};无 byId 端点时返回 404 错误信封)</summary>
Task<System.Text.Json.JsonElement?> GetAppByIdAsync(string appId, CancellationToken ct = default);
/// <summary>按用户 + 客户端类型查询有权访问的应用列表(按应用类型分组):GET /oapi/app/list</summary>
Task<List<BoaAppType>> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default);
}
@@ -0,0 +1,76 @@
using Yh.Platform.Abstractions;
namespace Yh.Platform.OpenApi;
/// <summary>
/// 用户域开放接口客户端契约(对齐 UserBase /oapi/user/**,镜像 Java <c>OpenApiUserClient</c>)。
/// 基址取自 <see cref="OpenApiOptions.ResolveBaseUrl"/>。
/// </summary>
public interface IOpenApiUserClient
{
/// <summary>按用户主键查询(GET /oapi/user/byId?id=)</summary>
Task<BoaUser?> GetUserByIdAsync(string userId, CancellationToken ct = default);
/// <summary>按登录账号查询用户明细(GET /oapi/user/byLoginName?loginName=)</summary>
Task<BoaUser?> GetUserByLoginNameAsync(string loginName, CancellationToken ct = default);
/// <summary>按手机号查询用户明细(GET /oapi/user/byPhone?phone=)</summary>
Task<BoaUser?> GetUserByPhoneAsync(string phone, CancellationToken ct = default);
/// <summary>按用户主键(int)查询用户信息(新接口,与旧 byId 并存)</summary>
Task<BoaUser?> GetUserInfoAsync(int userId, CancellationToken ct = default);
/// <summary>按登录账号查询用户信息(新接口,与旧 byLoginName 并存)</summary>
Task<BoaUser?> GetUserInfoAsync(string loginName, CancellationToken ct = default);
/// <summary>按用户查询模块列表(GET /oapi/user/modules?userId=&amp;schId=)</summary>
Task<List<BoaModule>> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default);
/// <summary>新增用户(POST /oapi/user/add)</summary>
Task<BoaUser?> AddUserAsync(SysUserRequest request, CancellationToken ct = default);
/// <summary>编辑用户(POST /oapi/user/edit/{id};路径 id 权威,自动写入 request.Id)</summary>
Task<BoaUser?> EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default);
/// <summary>删除用户(按组解绑):POST /oapi/user/delete/{id}/{groupId}/{schoolId}</summary>
Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default);
/// <summary>批量删除用户-组关系(POST /oapi/user/deleteUsers,请求体为数组)</summary>
Task DeleteUsersAsync(IReadOnlyList<SysUserGroupDeleteItem> items, CancellationToken ct = default);
/// <summary>批量新增用户,整批回滚(POST /oapi/user/batchAdd,请求体为裸数组)</summary>
Task<List<BoaUser>> BatchAddUsersAsync(IReadOnlyList<SysUserRequest> requests, CancellationToken ct = default);
/// <summary>移动用户到指定组(同校内,覆盖式替换主组):POST /oapi/user/moveToGroup</summary>
Task<BoaUser?> MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default);
/// <summary>按用户查询角色身份列表(GET /oapi/user/roles?userId=)</summary>
Task<List<BoaRoleInfo>> GetUserRoleListAsync(int userId, CancellationToken ct = default);
/// <summary>查询用户当前激活身份(未切换时兜底 is_main 主身份):GET /oapi/identity/current?userId=</summary>
Task<BoaRoleInfo?> GetCurrentIdentityAsync(int userId, CancellationToken ct = default);
/// <summary>查询用户可切换的身份列表(多角色迁移配套):GET /oapi/identity/available?userId=</summary>
Task<List<BoaRoleInfo>> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default);
/// <summary>切换身份(调用方须在 UserBase 侧白名单内):POST /oapi/identity/switch</summary>
Task<BoaRoleInfo?> SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default);
/// <summary>修改用户密码(先校验旧密码):POST /oapi/user/password</summary>
Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default);
/// <summary>设置用户在指定组上的状态(转出/恢复):POST /oapi/user/groupState</summary>
Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default);
/// <summary>查询用户在指定组上的状态:0=正常,1=转出(GET /oapi/user/groupState)</summary>
Task<int?> GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default);
/// <summary>跨校转入:POST /oapi/user/transferToSchool</summary>
Task<BoaUser?> TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default);
/// <summary>设置单个用户状态(启用/禁用):POST /oapi/user/changeStatus</summary>
Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default);
/// <summary>批量设置用户状态(任一 userId 无效则整体拒绝):POST /oapi/user/batchChangeStatus</summary>
Task BatchChangeUserStatusAsync(IReadOnlyList<int> userIds, bool disabled, CancellationToken ct = default);
}
@@ -0,0 +1,176 @@
using System.Text;
using System.Text.Json;
using Yh.Platform.Abstractions;
namespace Yh.Platform.OpenApi;
/// <summary>
/// 开放接口客户端基类(对齐 Java <c>AbstractOpenApiClient</c>):
/// 统一持有 HttpClient / 配置,沉淀请求构造与"信封感知"的响应解析单点。
/// 响应解析约定(UserBase oapi):成功为裸数据对象/数组;业务/校验失败为 {code:4/5, msg, data:null} 但 HTTP 仍为 200。
/// </summary>
public abstract class OpenApiClientBase
{
/// <summary>平台统一开放接口前缀(与 UserBase Config.OPEN_API_PATH 严格一致)</summary>
protected const string Prefix = "/oapi";
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
protected HttpClient HttpClient { get; }
protected OpenApiOptions Options { get; }
protected OpenApiClientBase(HttpClient httpClient, OpenApiOptions options)
{
HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
Options = options ?? throw new ArgumentNullException(nameof(options));
}
// ====================== 请求构造 ======================
protected static Dictionary<string, string?> Params(params (string key, object? value)[] kv)
{
var m = new Dictionary<string, string?>(StringComparer.Ordinal);
if (kv == null)
{
return m;
}
foreach (var (key, value) in kv)
{
if (key != null && value != null)
{
m[key] = value.ToString();
}
}
return m;
}
private static string BuildQuery(Dictionary<string, string?>? query)
{
if (query == null || query.Count == 0)
{
return "";
}
var sb = new StringBuilder();
foreach (var kv in query)
{
if (string.IsNullOrEmpty(kv.Key) || kv.Value == null)
{
continue;
}
sb.Append(sb.Length == 0 ? '?' : '&')
.Append(Uri.EscapeDataString(kv.Key))
.Append('=')
.Append(Uri.EscapeDataString(kv.Value));
}
return sb.ToString();
}
// ====================== 信封感知解析 ======================
/// <summary>发送请求并解析:返回信封感知后的裸数据节点(可能为 null)。</summary>
protected async Task<JsonElement?> SendAsync(HttpMethod method, string path, Dictionary<string, string?>? query = null, object? body = null, CancellationToken ct = default)
{
var url = Options.ResolveBaseUrl() + path + BuildQuery(query);
using var request = new HttpRequestMessage(method, url);
if (body != null)
{
request.Content = new StringContent(JsonSerializer.Serialize(body, JsonOpts), Encoding.UTF8, "application/json");
}
using var response = await HttpClient.SendAsync(request, ct).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
throw new OpenApiException("OpenAPI HTTP " + (int)response.StatusCode + ": " + text, (int)response.StatusCode, text);
}
try
{
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(text) ? "null" : text);
return Unwrap(doc.RootElement);
}
catch (JsonException e)
{
throw new OpenApiException("OpenAPI 响应解析失败: " + e.Message, (int)response.StatusCode, text);
}
}
/// <summary>裸数据反序列化为 <typeparamref name="T"/>;含 code 且非 0 时抛 <see cref="DException"/>。</summary>
protected async Task<T?> ResolveAsync<T>(HttpMethod method, string path, Dictionary<string, string?>? query = null, object? body = null, CancellationToken ct = default)
{
var data = await SendAsync(method, path, query, body, ct).ConfigureAwait(false);
if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(data.Value.GetRawText(), JsonOpts);
}
catch (JsonException e)
{
throw new OpenApiException("OpenAPI 反序列化失败: " + typeof(T).Name, e);
}
}
/// <summary>裸数组/单对象反序列化为列表;含 code 且非 0 时抛 <see cref="DException"/>。</summary>
protected async Task<List<T>> ResolveListAsync<T>(HttpMethod method, string path, Dictionary<string, string?>? query = null, object? body = null, CancellationToken ct = default)
{
var data = await SendAsync(method, path, query, body, ct).ConfigureAwait(false);
var result = new List<T>();
if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return result;
}
try
{
if (data.Value.ValueKind == JsonValueKind.Array)
{
foreach (var item in data.Value.EnumerateArray())
{
if (item.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined))
{
result.Add(JsonSerializer.Deserialize<T>(item.GetRawText(), JsonOpts)!);
}
}
}
else
{
result.Add(JsonSerializer.Deserialize<T>(data.Value.GetRawText(), JsonOpts)!);
}
}
catch (JsonException e)
{
throw new OpenApiException("OpenAPI 列表反序列化失败: " + typeof(T).Name, e);
}
return result;
}
/// <summary>信封感知 void 解析:含 code 且非 0 时抛 <see cref="DException"/>,否则忽略 data。</summary>
protected async Task ResolveVoidAsync(HttpMethod method, string path, Dictionary<string, string?>? query = null, object? body = null, CancellationToken ct = default)
{
await SendAsync(method, path, query, body, ct).ConfigureAwait(false);
}
/// <summary>
/// 信封感知:对象含 code 字段且非 0 → 抛 <see cref="DException"/>(原样携带服务端 code/msg/data);
/// code==0 → 返回 data 节点(可能为 null);非信封 → 原样返回。
/// </summary>
private static JsonElement? Unwrap(JsonElement root)
{
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("code", out var codeEl) && codeEl.ValueKind == JsonValueKind.Number && codeEl.TryGetInt32(out var code))
{
if (code != 0)
{
var msg = root.TryGetProperty("msg", out var msgEl) && msgEl.ValueKind == JsonValueKind.String ? msgEl.GetString() : null;
msg = string.IsNullOrEmpty(msg) ? "OpenAPI 业务错误" : msg;
JsonElement? data = root.TryGetProperty("data", out var dataEl) && dataEl.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) ? dataEl.Clone() : null;
object? dataObj = data?.ValueKind == JsonValueKind.String || data?.ValueKind == JsonValueKind.Number || data?.ValueKind == JsonValueKind.True || data?.ValueKind == JsonValueKind.False
? data.Value.ToString()
: data;
throw new DException(new OpenApiErrorCode(code, msg!), msg, dataObj);
}
return root.TryGetProperty("data", out var dataOk) && dataOk.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) ? dataOk.Clone() : null;
}
return root.Clone();
}
}
@@ -0,0 +1,20 @@
using Yh.Platform.Abstractions;
namespace Yh.Platform.OpenApi;
/// <summary>
/// 业务错误码载体(对齐 Java <c>OpenApiErrorCode</c>):原样保存服务端返回的 code 与 msg,
/// 由业务失败信封解析后携带给 <see cref="DException"/> 的调用方。
/// </summary>
public class OpenApiErrorCode : IErrorCode
{
public int Code { get; }
public string Msg { get; }
public OpenApiErrorCode(int code, string msg)
{
Code = code;
Msg = msg;
}
}
@@ -0,0 +1,31 @@
using Yh.Platform.Abstractions;
namespace Yh.Platform.OpenApi;
/// <summary>
/// HTTP 层异常(对齐 Java <c>OpenApiException</c>):非 2xx 响应或反序列化失败时抛出,保留 HTTP 状态码与响应体。
/// 业务失败(HTTP 200 + code≠0 信封)抛 <see cref="DException"/>。
/// </summary>
public class OpenApiException : Exception
{
public int? HttpStatus { get; }
public string? ResponseBody { get; }
public OpenApiException(string message)
: base(message)
{
}
public OpenApiException(string message, Exception? rawException)
: base(message, rawException)
{
}
public OpenApiException(string message, int? httpStatus, string? responseBody)
: base(message)
{
HttpStatus = httpStatus;
ResponseBody = responseBody;
}
}
@@ -0,0 +1,55 @@
using System.Text.Json;
using Yh.Platform.Abstractions;
namespace Yh.Platform.OpenApi;
/// <summary><see cref="IOpenApiGroupClient"/> 的 HTTP 实现(镜像 Java <c>DefaultOpenApiGroupClient</c>)。</summary>
public class OpenApiGroupClient : OpenApiClientBase, IOpenApiGroupClient
{
public OpenApiGroupClient(HttpClient httpClient, OpenApiOptions options)
: base(httpClient, options)
{
}
public Task<BoaGroup?> GetGroupTreeAsync(CancellationToken ct = default)
=> ResolveAsync<BoaGroup>(HttpMethod.Get, Prefix + "/group/tree", ct: ct);
public Task<BoaGroup?> GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default)
=> ResolveAsync<BoaGroup>(HttpMethod.Get, Prefix + "/group/byId",
Params(("groupId", groupId), ("withSub", withSub)), ct: ct);
public Task<List<BoaGroup>> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default)
=> ResolveListAsync<BoaGroup>(HttpMethod.Get, Prefix + "/group/list",
Params(("ids", ids), ("withSub", withSub)), ct: ct);
public Task<BoaGroup?> AddGroupAsync(SysGroupRequest request, CancellationToken ct = default)
=> ResolveAsync<BoaGroup>(HttpMethod.Post, Prefix + "/group/add", body: request, ct: ct);
public Task<BoaGroup?> EditGroupAsync(int id, SysGroupRequest request, CancellationToken ct = default)
=> ResolveAsync<BoaGroup>(HttpMethod.Post, Prefix + "/group/edit/" + id, body: request, ct: ct);
public Task DeleteGroupAsync(int id, int schoolId, CancellationToken ct = default)
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/group/delete/" + id + "/" + schoolId, ct: ct);
public Task<List<BoaGroup>> BatchAddGroupsAsync(SysGroupBatchRequest request, CancellationToken ct = default)
=> ResolveListAsync<BoaGroup>(HttpMethod.Post, Prefix + "/group/batchAdd", body: request, ct: ct);
public Task MoveGroupAsync(int schoolId, SysGroupMoveRequest request, CancellationToken ct = default)
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/group/move/" + schoolId, body: request, ct: ct);
}
/// <summary><see cref="IOpenApiAppClient"/> 的 HTTP 实现(镜像 Java <c>DefaultOpenApiAppClient</c>)。</summary>
public class OpenApiAppClient : OpenApiClientBase, IOpenApiAppClient
{
public OpenApiAppClient(HttpClient httpClient, OpenApiOptions options)
: base(httpClient, options)
{
}
public Task<JsonElement?> GetAppByIdAsync(string appId, CancellationToken ct = default)
=> SendAsync(HttpMethod.Get, Prefix + "/app/" + Uri.EscapeDataString(appId), ct: ct);
public Task<List<BoaAppType>> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default)
=> ResolveListAsync<BoaAppType>(HttpMethod.Get, Prefix + "/app/list",
Params(("userId", userId), ("clientType", (int)clientType)), ct: ct);
}
@@ -0,0 +1,61 @@
namespace Yh.Platform.OpenApi;
/// <summary>
/// 基础平台开放接口(UserBase)客户端配置,配置节 <c>yh:open</c>(对齐 Java <c>yh.open</c>)。
/// </summary>
public class OpenApiOptions
{
public const string SectionName = "yh:open";
/// <summary>是否启用(默认 true)</summary>
public bool Enabled { get; set; } = true;
/// <summary>UserBase 基础地址,例如 http://base.bestsch.com</summary>
public string? BaseHost { get; set; }
/// <summary>UserBase 基础路径(默认 /userbase;代理改挂其他路径时配置兜底)</summary>
public string? BasePath { get; set; } = "/userbase";
/// <summary>连接超时(毫秒),对齐 Java 默认 1500ms</summary>
public long ConnectTimeoutMs { get; set; } = 1500;
/// <summary>读超时(毫秒),对齐 Java 默认 5000ms</summary>
public long ReadTimeoutMs { get; set; } = 5000;
/// <summary>写超时(毫秒),对齐 Java 默认 5000ms</summary>
public long WriteTimeoutMs { get; set; } = 5000;
/// <summary>调用方在 UserBase 注册的应用标识(用于签名)</summary>
public string? AppKey { get; set; }
/// <summary>调用方在 UserBase 注册的密钥(用于签名)</summary>
public string? AppSecret { get; set; }
/// <summary>拼接 baseHost + basePath 为统一开放接口基址。</summary>
public string ResolveBaseUrl()
{
var baseHost = BaseHost?.Trim() ?? "";
if (baseHost.EndsWith('/'))
{
baseHost = baseHost.TrimEnd('/');
}
return baseHost + (NormalizeBasePath(BasePath) ?? "");
}
/// <summary>
/// 归一化 basePath:trim、去尾斜杠、无前导斜杠补 /;null/空白返回 null(挂载在根路径)。
/// </summary>
public static string? NormalizeBasePath(string? path)
{
if (path == null)
{
return null;
}
var trimmed = path.Trim().TrimEnd('/');
if (trimmed.Length == 0)
{
return null;
}
return trimmed.StartsWith('/') ? trimmed : "/" + trimmed;
}
}
@@ -0,0 +1,86 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Yh.Platform.AppAuth;
namespace Yh.Platform.OpenApi;
/// <summary>静态门面自动装配宿主服务(等价 Java starter 启动时 Set*Client 注入)。</summary>
internal sealed class YschFacadeWiring : IHostedService
{
private readonly IServiceProvider _provider;
public YschFacadeWiring(IServiceProvider provider)
{
_provider = provider;
}
public Task StartAsync(CancellationToken cancellationToken)
{
YschBaseOpenApi.SetUserClient(_provider.GetRequiredService<OpenApiUserClient>());
YschBaseOpenApi.SetGroupClient(_provider.GetRequiredService<OpenApiGroupClient>());
YschBaseOpenApi.SetAppClient(_provider.GetRequiredService<OpenApiAppClient>());
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
public static class OpenApiServiceCollectionExtensions
{
public const string HttpClientName = "yh-openapi";
/// <summary>
/// 注册 /oapi 客户端(对齐 Java <c>OpenApiAutoConfiguration</c>):
/// IHttpClientFactory + typed client + 签名 DelegatingHandler,并在宿主启动时装配静态门面。
/// </summary>
public static IServiceCollection AddYhOpenApi(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<OpenApiOptions>()
.Bind(configuration.GetSection(OpenApiOptions.SectionName))
.PostConfigure(o => o.BasePath = OpenApiOptions.NormalizeBasePath(o.BasePath));
services.AddHttpClient(HttpClientName)
.ConfigureHttpClient((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<OpenApiOptions>>().Value;
client.Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000);
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var options = sp.GetRequiredService<IOptions<OpenApiOptions>>().Value;
return new SocketsHttpHandler
{
ConnectTimeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs > 0 ? options.ConnectTimeoutMs : 1500),
};
})
.AddHttpMessageHandler(sp =>
{
var options = sp.GetRequiredService<IOptions<OpenApiOptions>>().Value;
return new AppSignHandler(options.AppKey ?? "", options.AppSecret ?? "", options.BasePath);
});
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
return new OpenApiUserClient(factory.CreateClient(HttpClientName), sp.GetRequiredService<IOptions<OpenApiOptions>>().Value);
});
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
return new OpenApiGroupClient(factory.CreateClient(HttpClientName), sp.GetRequiredService<IOptions<OpenApiOptions>>().Value);
});
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
return new OpenApiAppClient(factory.CreateClient(HttpClientName), sp.GetRequiredService<IOptions<OpenApiOptions>>().Value);
});
services.AddSingleton<IOpenApiUserClient>(sp => sp.GetRequiredService<OpenApiUserClient>());
services.AddSingleton<IOpenApiGroupClient>(sp => sp.GetRequiredService<OpenApiGroupClient>());
services.AddSingleton<IOpenApiAppClient>(sp => sp.GetRequiredService<OpenApiAppClient>());
services.AddHostedService<YschFacadeWiring>();
return services;
}
}
@@ -0,0 +1,87 @@
using Yh.Platform.Abstractions;
namespace Yh.Platform.OpenApi;
/// <summary><see cref="IOpenApiUserClient"/> 的 HTTP 实现(镜像 Java <c>DefaultOpenApiUserClient</c>)。</summary>
public class OpenApiUserClient : OpenApiClientBase, IOpenApiUserClient
{
public OpenApiUserClient(HttpClient httpClient, OpenApiOptions options)
: base(httpClient, options)
{
}
public Task<BoaUser?> GetUserByIdAsync(string userId, CancellationToken ct = default)
=> ResolveAsync<BoaUser>(HttpMethod.Get, Prefix + "/user/byId", Params(("id", userId)), ct: ct);
public Task<BoaUser?> GetUserByLoginNameAsync(string loginName, CancellationToken ct = default)
=> ResolveAsync<BoaUser>(HttpMethod.Get, Prefix + "/user/byLoginName", Params(("loginName", loginName)), ct: ct);
public Task<BoaUser?> GetUserByPhoneAsync(string phone, CancellationToken ct = default)
=> ResolveAsync<BoaUser>(HttpMethod.Get, Prefix + "/user/byPhone", Params(("phone", phone)), ct: ct);
public Task<BoaUser?> GetUserInfoAsync(int userId, CancellationToken ct = default)
=> ResolveAsync<BoaUser>(HttpMethod.Get, Prefix + "/user/byId", Params(("id", userId)), ct: ct);
public Task<BoaUser?> GetUserInfoAsync(string loginName, CancellationToken ct = default)
=> GetUserByLoginNameAsync(loginName, ct);
public Task<List<BoaModule>> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default)
=> ResolveListAsync<BoaModule>(HttpMethod.Get, Prefix + "/user/modules",
Params(("userId", userId), ("schId", schoolId)), ct: ct);
public Task<BoaUser?> AddUserAsync(SysUserRequest request, CancellationToken ct = default)
=> ResolveAsync<BoaUser>(HttpMethod.Post, Prefix + "/user/add", body: request, ct: ct);
public Task<BoaUser?> EditUserAsync(int id, SysUserRequest request, CancellationToken ct = default)
{
request.Id = id;
return ResolveAsync<BoaUser>(HttpMethod.Post, Prefix + "/user/edit/" + id, body: request, ct: ct);
}
public Task DeleteUserAsync(int id, int groupId, int schoolId, CancellationToken ct = default)
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/delete/" + id + "/" + groupId + "/" + schoolId, ct: ct);
public Task DeleteUsersAsync(IReadOnlyList<SysUserGroupDeleteItem> items, CancellationToken ct = default)
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/deleteUsers", body: items, ct: ct);
public Task<List<BoaUser>> BatchAddUsersAsync(IReadOnlyList<SysUserRequest> requests, CancellationToken ct = default)
=> ResolveListAsync<BoaUser>(HttpMethod.Post, Prefix + "/user/batchAdd", body: requests, ct: ct);
public Task<BoaUser?> MoveUserToGroupAsync(SysUserMoveRequest request, CancellationToken ct = default)
=> ResolveAsync<BoaUser>(HttpMethod.Post, Prefix + "/user/moveToGroup", body: request, ct: ct);
public Task<List<BoaRoleInfo>> GetUserRoleListAsync(int userId, CancellationToken ct = default)
=> ResolveListAsync<BoaRoleInfo>(HttpMethod.Get, Prefix + "/user/roles", Params(("userId", userId)), ct: ct);
public Task<BoaRoleInfo?> GetCurrentIdentityAsync(int userId, CancellationToken ct = default)
=> ResolveAsync<BoaRoleInfo>(HttpMethod.Get, Prefix + "/identity/current", Params(("userId", userId)), ct: ct);
public Task<List<BoaRoleInfo>> GetAvailableIdentitiesAsync(int userId, CancellationToken ct = default)
=> ResolveListAsync<BoaRoleInfo>(HttpMethod.Get, Prefix + "/identity/available", Params(("userId", userId)), ct: ct);
public Task<BoaRoleInfo?> SwitchIdentityAsync(int userId, int roleId, int schoolId, CancellationToken ct = default)
=> ResolveAsync<BoaRoleInfo>(HttpMethod.Post, Prefix + "/identity/switch",
body: new Dictionary<string, object> { ["userId"] = userId, ["roleId"] = roleId, ["schoolId"] = schoolId }, ct: ct);
public Task UpdateUserPasswordAsync(int userId, string oldPassword, string newPassword, CancellationToken ct = default)
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/password",
body: new Dictionary<string, object> { ["userId"] = userId, ["oldPassword"] = oldPassword, ["newPassword"] = newPassword }, ct: ct);
public Task SetUserGroupStateAsync(SysUserGroupStateRequest request, CancellationToken ct = default)
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/groupState", body: request, ct: ct);
public Task<int?> GetUserGroupStateAsync(int userId, int groupId, int schoolId, CancellationToken ct = default)
=> ResolveAsync<int?>(HttpMethod.Get, Prefix + "/user/groupState",
Params(("userId", userId), ("groupId", groupId), ("schoolId", schoolId)), ct: ct);
public Task<BoaUser?> TransferUserToSchoolAsync(SysUserTransferSchoolRequest request, CancellationToken ct = default)
=> ResolveAsync<BoaUser>(HttpMethod.Post, Prefix + "/user/transferToSchool", body: request, ct: ct);
public Task ChangeUserStatusAsync(int userId, bool disabled, CancellationToken ct = default)
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/changeStatus",
body: new Dictionary<string, object> { ["userId"] = userId, ["disabled"] = disabled }, ct: ct);
public Task BatchChangeUserStatusAsync(IReadOnlyList<int> userIds, bool disabled, CancellationToken ct = default)
=> ResolveVoidAsync(HttpMethod.Post, Prefix + "/user/batchChangeStatus",
body: new Dictionary<string, object> { ["userIds"] = userIds, ["disabled"] = disabled }, ct: ct);
}
@@ -0,0 +1,45 @@
namespace Yh.Platform.OpenApi;
/// <summary>新增/编辑组请求(对齐 UserBase SysGroupRequest)。编辑以路径 id 为准;Id 字段对齐服务端契约键(快照对拍 2026-08-28)。</summary>
public class SysGroupRequest
{
/// <summary>组 ID(新增忽略;编辑时以路径 id 为权威,此字段仅保持契约键完整)</summary>
public int? Id { get; set; }
public string? Name { get; set; }
public int? ParentId { get; set; }
public int? Type { get; set; }
public int? Sort { get; set; }
}
/// <summary>批量新增子组请求:在 Pid 下生成一批同类型子组。</summary>
public class SysGroupBatchRequest
{
/// <summary>父组 ID(挂载点,必须存在)</summary>
public int? Pid { get; set; }
/// <summary>应用于本次生成的所有子组的类型</summary>
public int? Type { get; set; }
/// <summary>子组列表,每项仅需 Name</summary>
public List<BatchItem>? Items { get; set; }
/// <summary>单个子组项,仅需 Name</summary>
public class BatchItem
{
public string? Name { get; set; }
}
}
/// <summary>移动组请求:MoveId 移动到 Pid 下,两者都必须落在 schoolId 子树内;schoolId 走路径参数。</summary>
public class SysGroupMoveRequest
{
/// <summary>待移动节点ID</summary>
public int? MoveId { get; set; }
/// <summary>目标父节点ID(移动后的挂载点)</summary>
public int? Pid { get; set; }
}
@@ -0,0 +1,111 @@
namespace Yh.Platform.OpenApi;
/// <summary>新增/编辑用户请求(对齐 UserBase SysUserRequest)。编辑时 Id 必填且与路径 id 一致。</summary>
public class SysUserRequest
{
/// <summary>用户 ID(编辑必填,新增忽略)</summary>
public int? Id { get; set; }
/// <summary>初始归属组 ID;新增必填,编辑不校验</summary>
public int? GroupId { get; set; }
/// <summary>登录账号(唯一)</summary>
public string? Account { get; set; }
/// <summary>登录密码(明文,服务端 BCrypt 加密存储)</summary>
public string? Password { get; set; }
/// <summary>确认密码,需与 Password 一致</summary>
public string? ConfirmPassword { get; set; }
/// <summary>姓名</summary>
public string? RealName { get; set; }
/// <summary>手机号</summary>
public string? Phone { get; set; }
/// <summary>身份证号</summary>
public string? Idcard { get; set; }
/// <summary>邮箱</summary>
public string? Email { get; set; }
/// <summary>性别</summary>
public int? Gender { get; set; }
/// <summary>生日(YYYY-MM-DD)</summary>
public string? Birthday { get; set; }
/// <summary>头像 URL</summary>
public string? ProfilePicture { get; set; }
/// <summary>归属学校组 ID(服务端 SysUserRequest.roleIds/schoolId 契约;与 GroupId 二选一维度,见 UserBase 文档)</summary>
public int? SchoolId { get; set; }
/// <summary>角色 ID 列表(契约键为 roleIds,对齐 UserBase 导出快照 2026-08-28 对拍)</summary>
public List<int>? RoleIds { get; set; }
/// <summary>禁用状态(0=正常,1=禁用;不传默认启用)</summary>
public int? Disabled { get; set; }
/// <summary>是否过期;不传默认 false</summary>
public bool? Expired { get; set; }
}
/// <summary>批量删除用户-组关系单项(对齐 UserBase SysUserGroupDeleteItem)。</summary>
public class SysUserGroupDeleteItem
{
/// <summary>待解绑用户 ID</summary>
public int? UserId { get; set; }
/// <summary>待解绑的组 ID(必须属于 schoolId 子树,且不能等于 schoolId)</summary>
public int? GroupId { get; set; }
/// <summary>学校组 ID:限定该条解绑的学校范围</summary>
public int? SchoolId { get; set; }
}
/// <summary>移动用户到指定组(同校内,覆盖式替换主组)请求。</summary>
public class SysUserMoveRequest
{
/// <summary>待移动用户 ID</summary>
public int? UserId { get; set; }
/// <summary>目标组 ID(移动后的主归属组)</summary>
public int? GroupId { get; set; }
/// <summary>学校组 ID:限定移动范围,仅允许同一学校内移动</summary>
public int? SchoolId { get; set; }
}
/// <summary>设置用户组状态请求:state=1 转出、0=恢复正常;groupId 必须属于 schoolId 子树。</summary>
public class SysUserGroupStateRequest
{
/// <summary>用户 ID</summary>
public int? UserId { get; set; }
/// <summary>组 ID(用户所在的具体组)</summary>
public int? GroupId { get; set; }
/// <summary>学校组 ID:限定操作范围</summary>
public int? SchoolId { get; set; }
/// <summary>目标状态:0=恢复正常,1=转出</summary>
public int? State { get; set; }
}
/// <summary>跨校转入请求:前置为源学校子树内存在至少一条「转出」状态组关系。</summary>
public class SysUserTransferSchoolRequest
{
/// <summary>待迁移用户 ID</summary>
public int? UserId { get; set; }
/// <summary>原学校组 ID(源学校)</summary>
public int? FromSchoolId { get; set; }
/// <summary>目标学校组 ID</summary>
public int? SchoolId { get; set; }
/// <summary>目标组 ID(迁移后的主归属组)</summary>
public int? GroupId { get; set; }
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Yh.Platform.OpenApi</PackageId>
<Description>yunhai 平台 /oapi 开放接口客户端(对应 Java yh-openapi-client):用户/组/应用三域客户端、信封感知解析、静态门面 YschBaseOpenApi 与实例门面 BaseOpenApi</Description>
<Summary>UserBase /oapi 同构客户端 SDK,签名请求自动附加三头</Summary>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Yh.Platform.Abstractions\Yh.Platform.Abstractions.csproj" />
<ProjectReference Include="..\Yh.Platform.AppAuth\Yh.Platform.AppAuth.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
</Project>
@@ -0,0 +1,69 @@
using System.Text.Json;
using Yh.Platform.Abstractions;
namespace Yh.Platform.OpenApi;
/// <summary>
/// 基础平台开放接口统一门面(静态入口,对齐 Java <c>YschBaseOpenApi</c>)。
/// 下游业务只依赖本类;内部按域聚合三客户端,由 <see cref="OpenApiServiceCollectionExtensions.AddYhOpenApi"/>
/// 在宿主启动时自动注入(或手动 Set*Client)。
/// 门面准入原则:仅保留高频读接口;低频写操作请注入 <see cref="IOpenApiUserClient"/> / <see cref="IOpenApiGroupClient"/>。
/// </summary>
public static class YschBaseOpenApi
{
private static volatile IOpenApiUserClient? _userClient;
private static volatile IOpenApiGroupClient? _groupClient;
private static volatile IOpenApiAppClient? _appClient;
private static readonly object Lock = new();
public static void SetUserClient(IOpenApiUserClient? client) { lock (Lock) { _userClient = client; } }
public static void SetGroupClient(IOpenApiGroupClient? client) { lock (Lock) { _groupClient = client; } }
public static void SetAppClient(IOpenApiAppClient? client) { lock (Lock) { _appClient = client; } }
// ============================ 用户域 ============================
public static Task<BoaUser?> GetUserInfoAsync(int userId, CancellationToken ct = default) => User().GetUserInfoAsync(userId, ct);
public static Task<BoaUser?> GetUserInfoAsync(string loginName, CancellationToken ct = default) => User().GetUserInfoAsync(loginName, ct);
public static Task<BoaUser?> GetUserByIdAsync(string userId, CancellationToken ct = default) => User().GetUserByIdAsync(userId, ct);
public static Task<BoaUser?> GetUserByLoginNameAsync(string loginName, CancellationToken ct = default) => User().GetUserByLoginNameAsync(loginName, ct);
public static Task<BoaUser?> GetUserByPhoneAsync(string phone, CancellationToken ct = default) => User().GetUserByPhoneAsync(phone, ct);
public static Task<List<BoaModule>> GetUserModuleAsync(int userId, int? schoolId = null, CancellationToken ct = default) => User().GetUserModuleAsync(userId, schoolId, ct);
public static Task<List<BoaRoleInfo>> GetUserRoleListAsync(int userId, CancellationToken ct = default) => User().GetUserRoleListAsync(userId, ct);
/// <summary>查询用户当前激活身份(未切换时兜底 is_main 主身份;无任何身份返回 null)。</summary>
public static Task<BoaRoleInfo?> GetCurrentIdentityAsync(int userId, CancellationToken ct = default) => User().GetCurrentIdentityAsync(userId, ct);
// ============================ 组织/组域 ============================
public static Task<BoaGroup?> GetGroupTreeAsync(CancellationToken ct = default) => Group().GetGroupTreeAsync(ct);
public static Task<BoaGroup?> GetGroupByIdAsync(int? groupId, bool? withSub = null, CancellationToken ct = default) => Group().GetGroupByIdAsync(groupId, withSub, ct);
public static Task<List<BoaGroup>> GetGroupListAsync(string ids, bool? withSub = null, CancellationToken ct = default) => Group().GetGroupListAsync(ids, withSub, ct);
// ============================ 应用域 ============================
public static Task<JsonElement?> GetAppByIdAsync(string appId, CancellationToken ct = default) => App().GetAppByIdAsync(appId, ct);
public static Task<List<BoaAppType>> GetUserAppAsync(int userId, ClientEnum clientType, CancellationToken ct = default) => App().GetUserAppAsync(userId, clientType, ct);
private static IOpenApiUserClient User()
=> _userClient ?? throw new InvalidOperationException(
"YschBaseOpenApi 尚未初始化(UserClient 缺失):请确保已集成 AddYhOpenApi() 且配置了 yh:open:Enabled=true,或在启动后手动调用 YschBaseOpenApi.SetUserClient(...)。");
private static IOpenApiGroupClient Group()
=> _groupClient ?? throw new InvalidOperationException(
"YschBaseOpenApi 尚未初始化(GroupClient 缺失):请确保已集成 AddYhOpenApi() 且配置了 yh:open:Enabled=true,或在启动后手动调用 YschBaseOpenApi.SetGroupClient(...)。");
private static IOpenApiAppClient App()
=> _appClient ?? throw new InvalidOperationException(
"YschBaseOpenApi 尚未初始化(AppClient 缺失):请确保已集成 AddYhOpenApi() 且配置了 yh:open:Enabled=true,或在启动后手动调用 YschBaseOpenApi.SetAppClient(...)。");
}
@@ -0,0 +1,12 @@
// 跨 TFM 显式全局 using(net5.0 不支持 ImplicitUsings,统一在此收敛)
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Net.Http;
global using System.Text;
global using System.Text.Json;
global using System.Threading;
global using System.Threading.Tasks;
// Java 侧 Map<String,Object> 兼容行的 .NET 承载类型(net5.0 可用,不依赖 net6+ 的 JsonNode)
global using SchClassRow = System.Collections.Generic.Dictionary<string, System.Text.Json.JsonElement>;
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Yh.SchClass.OpenApi.Model;
/// <summary>
/// 学校视图对象(镜像 Java <c>com.yunhai.bschclassmanage.model.vo.oa.SchInfoVO</c>)。
/// Java 侧以 <c>@JsonProperty</c> 显式指定键名,故此处按原样锚定。
/// </summary>
public class SchInfoVo
{
/// <summary>学校外部序列号</summary>
[JsonPropertyName("SchSerId")]
public string? SchSerId { get; set; }
/// <summary>用户中心学校 ID</summary>
[JsonPropertyName("UserSchSerId")]
public int? UserSchSerId { get; set; }
/// <summary>学校名称</summary>
[JsonPropertyName("SchName")]
public string? SchName { get; set; }
}
@@ -0,0 +1,82 @@
using System.Text.Json.Serialization;
namespace Yh.SchClass.OpenApi.Model;
/// <summary>
/// 教师信息视图对象(镜像 Java <c>com.yunhai.bschclassmanage.model.oa.TeaInfoVO</c>)。
/// <para>
/// Java 侧为 Lombok <c>@Data</c>,序列化键名由 Jackson 从 getter 推导;本客户端大小写不敏感反序列化,
/// 故按 Java 字段原名锚定即可双向兼容。
/// </para>
/// </summary>
public class TeaInfoVo
{
/// <summary>学校外部序列号</summary>
[JsonPropertyName("SchSerID")] public string? SchSerId { get; set; }
/// <summary>政治面貌</summary>
[JsonPropertyName("Teapolitical")] public string? TeaPolitical { get; set; }
/// <summary>参加工作日期</summary>
[JsonPropertyName("TeaWorkStart")] public string? TeaWorkStart { get; set; }
/// <summary>登录账户</summary>
[JsonPropertyName("Account")] public string? Account { get; set; }
/// <summary>学校序列号(多校场景,当前与 SchSerID 相同)</summary>
[JsonPropertyName("SchSerIDs")] public string? SchSerIds { get; set; }
/// <summary>角色</summary>
[JsonPropertyName("role")] public int? Role { get; set; }
/// <summary>教育局</summary>
[JsonPropertyName("JiaoYuJu")] public string? JiaoYuJu { get; set; }
/// <summary>性别(文本)</summary>
[JsonPropertyName("TeaSex")] public string? TeaSex { get; set; }
/// <summary>是否中层以上干部</summary>
[JsonPropertyName("IsLeadingMiddle")] public int? IsLeadingMiddle { get; set; }
/// <summary>籍贯</summary>
[JsonPropertyName("TeaOriginPlace")] public string? TeaOriginPlace { get; set; }
/// <summary>头像</summary>
[JsonPropertyName("Photo")] public string? Photo { get; set; }
/// <summary>身份证号</summary>
[JsonPropertyName("TeaIDCard")] public string? TeaIdCard { get; set; }
/// <summary>姓名</summary>
[JsonPropertyName("Name")] public string? Name { get; set; }
/// <summary>分组 ID</summary>
[JsonPropertyName("GroupID")] public int? GroupId { get; set; }
/// <summary>学校名称</summary>
[JsonPropertyName("SchName")] public string? SchName { get; set; }
/// <summary>从教日期</summary>
[JsonPropertyName("TeaTeachStart")] public string? TeaTeachStart { get; set; }
/// <summary>教师序列号(teaId 的字符串形式)</summary>
[JsonPropertyName("TeaSerID")] public string? TeaSerId { get; set; }
/// <summary>用户 ID</summary>
[JsonPropertyName("UserID")] public int? UserId { get; set; }
/// <summary>民族</summary>
[JsonPropertyName("TeaNation")] public string? TeaNation { get; set; }
/// <summary>电话</summary>
[JsonPropertyName("Tel")] public string? Tel { get; set; }
/// <summary>工作类型</summary>
[JsonPropertyName("TeaWorkType")] public string? TeaWorkType { get; set; }
/// <summary>教师编号</summary>
[JsonPropertyName("TeaNo")] public int? TeaNo { get; set; }
/// <summary>出生日期</summary>
[JsonPropertyName("TeaBirth")] public string? TeaBirth { get; set; }
}
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace Yh.SchClass.OpenApi.Model;
/// <summary>
/// 用户信息视图对象(镜像 Java <c>com.yunhai.bschclassmanage.model.oa.UserInfoVO</c>)。
/// <para>
/// Java 侧为 Lombok <c>@Data</c>,序列化键名由 Jackson 从 getter 推导(如 <c>getSchSerID()</c> → <c>schSerID</c>);
/// 本客户端使用大小写不敏感反序列化(<see cref="JsonSerializerDefaults.Web"/>),故此处按 Java 字段原名锚定即可双向兼容。
/// </para>
/// </summary>
public class UserInfoVo
{
/// <summary>学校外部序列号</summary>
[JsonPropertyName("SchSerID")] public string? SchSerId { get; set; }
/// <summary>编号(教师为 sort)</summary>
[JsonPropertyName("No")] public int? No { get; set; }
/// <summary>身份证号</summary>
[JsonPropertyName("IDCard")] public string? IdCard { get; set; }
/// <summary>用户 ID(教师为 teaId,学生为 stuId)</summary>
[JsonPropertyName("UserID")] public int? UserId { get; set; }
/// <summary>性别</summary>
[JsonPropertyName("sex")] public int? Sex { get; set; }
/// <summary>用户类型(教师 1 / 学生按 UserRole)</summary>
[JsonPropertyName("usertype")] public int? UserType { get; set; }
/// <summary>序列号(Java 侧恒为 null)</summary>
[JsonPropertyName("SerID")] public string? SerId { get; set; }
/// <summary>头像(Java 侧恒为 null)</summary>
[JsonPropertyName("Photo")] public string? Photo { get; set; }
/// <summary>电话(学生为 null)</summary>
[JsonPropertyName("Tel")] public string? Tel { get; set; }
/// <summary>登录账户</summary>
[JsonPropertyName("Uids")] public string? Uids { get; set; }
/// <summary>分组 ID</summary>
[JsonPropertyName("GroupID")] public int? GroupId { get; set; }
/// <summary>姓名</summary>
[JsonPropertyName("Name")] public string? Name { get; set; }
}
@@ -0,0 +1,217 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务(SchClass / bschclassmanage)开放接口客户端基类。
/// <para>
/// 与 <c>Yh.Platform.OpenApi.OpenApiClientBase</c> 同构,但刻意保持零项目依赖,便于单独打包引用。
/// </para>
/// <para><b>响应约定</b>(Java 侧 OA 兼容端点):成功时直接返回裸数据(对象 / 数组 / 字符串数组),无统一信封;
/// 部分端点以 HTTP 404 / 204 表示"无数据",以 HTTP 400 表示参数缺失。因此本基类默认把 404 与 204
/// 归一为 <c>null</c>(列表则为空列表),仅对 400 及其它非 2xx 抛 <see cref="SchClassOpenApiException"/>。
/// </para>
/// </summary>
public abstract class SchClassClientBase
{
/// <summary>教务开放接口统一前缀(与 Java <c>Config.OAPI</c> 严格一致)</summary>
protected const string Prefix = "/oapi";
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
protected HttpClient HttpClient { get; }
protected SchClassOpenApiOptions Options { get; }
protected SchClassClientBase(HttpClient httpClient, SchClassOpenApiOptions options)
{
HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
Options = options ?? throw new ArgumentNullException(nameof(options));
}
// ====================== 请求构造 ======================
/// <summary>构造查询参数;值为 null 的参数会被跳过(对应 Java 侧 required=false)。</summary>
protected static Dictionary<string, string?> Params(params (string key, object? value)[] kv)
{
var map = new Dictionary<string, string?>(StringComparer.Ordinal);
if (kv == null)
{
return map;
}
foreach (var (key, value) in kv)
{
if (key != null && value != null)
{
map[key] = value.ToString();
}
}
return map;
}
private static string BuildQuery(Dictionary<string, string?>? query)
{
if (query == null || query.Count == 0)
{
return "";
}
var sb = new StringBuilder();
foreach (var kv in query)
{
if (string.IsNullOrEmpty(kv.Key) || kv.Value == null)
{
continue;
}
sb.Append(sb.Length == 0 ? '?' : '&')
.Append(Uri.EscapeDataString(kv.Key))
.Append('=')
.Append(Uri.EscapeDataString(kv.Value));
}
return sb.ToString();
}
// ====================== 发送与解析 ======================
/// <summary>
/// 发送请求并返回裸数据节点(可能为 null)。
/// <para><paramref name="body"/> 为 <see cref="string"/> 时按 text/plain 原样提交(对应 Java <c>@RequestBody String</c>),
/// 其余类型按 application/json 序列化后提交。</para>
/// </summary>
/// <param name="notFoundAsNull">为 true 时把 HTTP 404 / 204 归一为 null(OA 端点惯用的"无数据"语义)。</param>
protected async Task<JsonElement?> SendAsync(HttpMethod method, string path,
Dictionary<string, string?>? query = null, object? body = null,
bool notFoundAsNull = true, CancellationToken ct = default)
{
var url = Options.ResolveBaseUrl() + path + BuildQuery(query);
using var request = new HttpRequestMessage(method, url);
if (body != null)
{
request.Content = body is string raw
? new StringContent(raw, Encoding.UTF8, "text/plain")
: new StringContent(JsonSerializer.Serialize(body, JsonOpts), Encoding.UTF8, "application/json");
}
using var response = await HttpClient.SendAsync(request, ct).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
var status = (int)response.StatusCode;
// OA 兼容端点惯用 404 / 204 表达"查无数据",不是调用失败
if ((status == 404 && notFoundAsNull) || status == 204 || status == 205)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
throw new SchClassOpenApiException("SchClass OpenAPI HTTP " + status + ": " + text, status, text);
}
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(text);
return Unwrap(doc.RootElement);
}
catch (JsonException e)
{
throw new SchClassOpenApiException("SchClass OpenAPI 响应解析失败: " + e.Message, status, text);
}
}
/// <summary>裸数据反序列化为 <typeparamref name="T"/>。</summary>
protected async Task<T?> ResolveAsync<T>(HttpMethod method, string path,
Dictionary<string, string?>? query = null, object? body = null,
bool notFoundAsNull = true, CancellationToken ct = default)
{
var data = await SendAsync(method, path, query, body, notFoundAsNull, ct).ConfigureAwait(false);
if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return default;
}
try
{
return JsonSerializer.Deserialize<T>(data.Value.GetRawText(), JsonOpts);
}
catch (JsonException)
{
throw new SchClassOpenApiException("SchClass OpenAPI 反序列化失败: " + typeof(T).Name, 0, data.Value.GetRawText());
}
}
/// <summary>裸数组 / 单对象反序列化为列表;无数据时返回空列表(不会为 null)。</summary>
protected async Task<List<T>> ResolveListAsync<T>(HttpMethod method, string path,
Dictionary<string, string?>? query = null, object? body = null,
bool notFoundAsNull = true, CancellationToken ct = default)
{
var data = await SendAsync(method, path, query, body, notFoundAsNull, ct).ConfigureAwait(false);
var result = new List<T>();
if (data == null || data.Value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
return result;
}
try
{
if (data.Value.ValueKind == JsonValueKind.Array)
{
foreach (var item in data.Value.EnumerateArray())
{
if (item.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined))
{
result.Add(JsonSerializer.Deserialize<T>(item.GetRawText(), JsonOpts)!);
}
}
}
else
{
result.Add(JsonSerializer.Deserialize<T>(data.Value.GetRawText(), JsonOpts)!);
}
}
catch (JsonException)
{
throw new SchClassOpenApiException("SchClass OpenAPI 列表反序列化失败: " + typeof(T).Name, 0, data.Value.GetRawText());
}
return result;
}
/// <summary>
/// 弱类型兼容行(Java <c>Map&lt;String,Object&gt;</c>)列表;无数据时返回空列表。
/// 每行键名与 Java 侧 <c>LinkedHashMap</c> 的 put 顺序完全一致。
/// </summary>
protected Task<List<SchClassRow>> ResolveRowsAsync(HttpMethod method, string path,
Dictionary<string, string?>? query = null, object? body = null,
bool notFoundAsNull = true, CancellationToken ct = default)
=> ResolveListAsync<SchClassRow>(method, path, query, body, notFoundAsNull, ct);
/// <summary>
/// 信封感知:仅当同时出现数字 <c>code</c> 与(<c>data</c> 或字符串 <c>msg</c>)时才认定为信封,
/// 避免把业务行里恰好叫 <c>code</c> 的字段(如学制等级的 code=gradeId)误判成错误响应。
/// </summary>
private static JsonElement? Unwrap(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object
|| !root.TryGetProperty("code", out var codeEl)
|| codeEl.ValueKind != JsonValueKind.Number)
{
return root.Clone();
}
var hasData = root.TryGetProperty("data", out var dataEl);
var hasMsg = root.TryGetProperty("msg", out var msgEl) && msgEl.ValueKind == JsonValueKind.String;
if (!hasData && !hasMsg)
{
// 业务行里恰好含 code 字段(如学制等级的 code=gradeId),不是错误信封
return root.Clone();
}
codeEl.TryGetInt32(out var code);
if (code != 0)
{
var msg = hasMsg ? msgEl.GetString() : null;
throw new SchClassOpenApiException(
string.IsNullOrEmpty(msg) ? "SchClass OpenAPI 业务错误" : msg!, 0, root.GetRawText());
}
return hasData && dataEl.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined)
? dataEl.Clone()
: null;
}
}
@@ -0,0 +1,23 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务 DbClass 接口客户端(镜像 Java <c>DbClassController</c>,路径前缀 <c>/oapi/DbClass/</c>)。
/// </summary>
public class SchClassDbClassClient : SchClassClientBase
{
private const string Root = Prefix + "/DbClass/";
public SchClassDbClassClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>
/// 查询学校教师名单(<c>GET /oapi/DbClass/GetSchteaList</c>)。
/// 每位教师的每个有效分组归属返回一行(同一教师多分组会重复出现)。
/// 行字段:<c>SchSerID / TeaTel / TeaName / UserTeaSerID / TeaIDCard / TeaNo / GroupID</c>。
/// </summary>
/// <param name="group">学校 ID(Java 参数名 <c>Group</c>),必须大于 0;&lt;=0 时服务端返回 400。</param>
public Task<List<SchClassRow>> GetSchteaListAsync(int group, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchteaList", Params(("Group", group)), ct: ct);
}
@@ -0,0 +1,29 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务家长兼容接口客户端(镜像 Java <c>FamilyCompatController</c>,路径前缀 <c>/oapi/Common/FamCom/</c>)。
/// 两个端点当前服务端均恒返回空数据,保留调用以兼容既有消费方。
/// </summary>
public class SchClassFamilyCompatClient : SchClassClientBase
{
private const string Root = Prefix + "/Common/FamCom/";
public SchClassFamilyCompatClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>
/// 查询学生家长用户 ID 列表,<c>POST /oapi/Common/FamCom/GetStuUserFamIDList</c>;
/// 请求体为逗号分隔的学生 ID(Java 侧 <c>@RequestBody String</c>,按 text/plain 提交)。
/// </summary>
public Task<List<string>> GetStuUserFamIdListAsync(string? stuIds, CancellationToken ct = default)
=> ResolveListAsync<string>(HttpMethod.Post, Root + "GetStuUserFamIDList", body: stuIds, ct: ct);
/// <summary>
/// 查询学生家庭成员,<c>POST /oapi/Common/FamCom/GetAllStuFamMem</c>;
/// 请求体为逗号分隔的学生 ID(text/plain)。
/// </summary>
public Task<List<SchClassRow>> GetAllStuFamMemAsync(string? stuIds, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Post, Root + "GetAllStuFamMem", body: stuIds, ct: ct);
}
@@ -0,0 +1,100 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务(SchClass)开放接口聚合门面:一个入口访问全部 OA 兼容端点。
/// <para>
/// 用法:
/// <code>
/// var api = SchClassOpenApiFactory.Create(new SchClassOpenApiOptions { BaseHost = "http://bschclassmanage" });
/// var schools = await api.SchInfo.AllListAsync();
/// </code>
/// </para>
/// </summary>
public sealed class SchClassOpenApi
{
public SchClassOpenApi(HttpClient httpClient, SchClassOpenApiOptions options)
{
if (httpClient == null) throw new ArgumentNullException(nameof(httpClient));
if (options == null) throw new ArgumentNullException(nameof(options));
Options = options;
HttpClient = httpClient;
DbClass = new SchClassDbClassClient(httpClient, options);
VClass = new SchClassVClassClient(httpClient, options);
Schooling = new SchClassSchoolingClient(httpClient, options);
UserInfo = new SchClassUserInfoClient(httpClient, options);
SchInfo = new SchClassSchInfoClient(httpClient, options);
TeaInfo = new SchClassTeaInfoClient(httpClient, options);
StuInfo = new SchClassStuInfoClient(httpClient, options);
TeachInfo = new SchClassTeachInfoClient(httpClient, options);
SubInfo = new SchClassSubInfoClient(httpClient, options);
FamilyCompat = new SchClassFamilyCompatClient(httpClient, options);
}
/// <summary>当前生效的配置。</summary>
public SchClassOpenApiOptions Options { get; }
/// <summary>底层 HttpClient 的 BaseAddress(配置为空时为 null)。</summary>
public Uri? HttpBaseAddress => HttpClient.BaseAddress;
private HttpClient HttpClient { get; }
/// <summary>DbClass:学校教师名单(<c>/oapi/DbClass/</c>)。</summary>
public SchClassDbClassClient DbClass { get; }
/// <summary>VSchClass:班级(<c>/oapi/VSchClass/</c>)。</summary>
public SchClassVClassClient VClass { get; }
/// <summary>Schoolen:学制与年级(<c>/oapi/Schoolen/</c>)。</summary>
public SchClassSchoolingClient Schooling { get; }
/// <summary>UserInfo:师生统一用户(<c>/oapi/UserInfo/</c>)。</summary>
public SchClassUserInfoClient UserInfo { get; }
/// <summary>SchInfo:学校(<c>/oapi/SchInfo/</c>)。</summary>
public SchClassSchInfoClient SchInfo { get; }
/// <summary>OATea:教师(<c>/oapi/OATea/</c>)。</summary>
public SchClassTeaInfoClient TeaInfo { get; }
/// <summary>OAStu:学生(<c>/oapi/OAStu/</c>)。</summary>
public SchClassStuInfoClient StuInfo { get; }
/// <summary>TeachInfo:任课(<c>/oapi/TeachInfo/</c>)。</summary>
public SchClassTeachInfoClient TeachInfo { get; }
/// <summary>SubInfo:科目(<c>/oapi/SubInfo/</c>)。</summary>
public SchClassSubInfoClient SubInfo { get; }
/// <summary>FamCom:家长兼容(<c>/oapi/Common/FamCom/</c>)。</summary>
public SchClassFamilyCompatClient FamilyCompat { get; }
}
/// <summary><see cref="SchClassOpenApi"/> 的轻量工厂(不依赖 DI 容器)。</summary>
public static class SchClassOpenApiFactory
{
/// <summary>
/// 按配置创建 <see cref="HttpClient"/>:预设 BaseAddress 与读超时。
/// 生产环境建议改用 <c>IHttpClientFactory</c> 注入实例,本方法仅用于快速接入与测试。
/// </summary>
public static HttpClient CreateHttpClient(SchClassOpenApiOptions options)
{
if (options == null) throw new ArgumentNullException(nameof(options));
var client = new HttpClient
{
Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs <= 0 ? 5000 : options.ReadTimeoutMs)
};
var baseUrl = options.ResolveBaseUrl();
if (!string.IsNullOrEmpty(baseUrl))
{
client.BaseAddress = new Uri(baseUrl, UriKind.Absolute);
}
return client;
}
/// <summary>按配置创建一个完整的门面实例(内部自建 HttpClient,调用方负责复用而非频繁创建)。</summary>
public static SchClassOpenApi Create(SchClassOpenApiOptions options)
=> new SchClassOpenApi(CreateHttpClient(options), options);
}
@@ -0,0 +1,18 @@
namespace Yh.SchClass.OpenApi;
/// <summary>教务开放接口调用异常(HTTP 非 2xx 或响应解析失败)。</summary>
public class SchClassOpenApiException : Exception
{
/// <summary>HTTP 状态码;非 HTTP 层错误时为 0。</summary>
public int StatusCode { get; }
/// <summary>原始响应文本(若有)。</summary>
public string? ResponseBody { get; }
public SchClassOpenApiException(string message, int statusCode = 0, string? responseBody = null)
: base(message)
{
StatusCode = statusCode;
ResponseBody = responseBody;
}
}
@@ -0,0 +1,107 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 云海教务(SchClass / bschclassmanage)开放接口客户端配置,配置节 <c>yh:schclass:open</c>。
/// 与 UserBase 的 OpenApiOptions 同构,但独立配置节,便于分别指向不同后端。
/// </summary>
public class SchClassOpenApiOptions
{
public const string SectionName = "yh:schclass:open";
/// <summary>
/// 共享开放接口配置节(与 UserBase 的 OpenApiOptions 同节):
/// 其中 <c>BaseHost</c> 与 <c>SchClassPath</c> 可作为教务客户端的缺省来源。
/// <code>
/// "yh": {
/// "open": {
/// "BaseHost": "http://base.yunhaixingtong.com",
/// "BasePath": "/userbase", ← UserBase 用
/// "SchClassPath": "/bschclass" ← 教务(SchClass)用
/// }
/// }
/// </code>
/// </summary>
public const string SharedSectionName = "yh:open";
/// <summary>共享节里教务路径的键名。</summary>
public const string SchClassPathKey = "SchClassPath";
/// <summary>HTTP 客户端逻辑名(IHttpClientFactory)。</summary>
public const string HttpClientName = "yh-schclass-openapi";
/// <summary>是否启用(默认 true)</summary>
public bool Enabled { get; set; } = true;
/// <summary>教务开放接口基础地址,例如 http://base.yunhaixingtong.com</summary>
public string? BaseHost { get; set; }
/// <summary>基础路径(默认 null,挂载在根路径);联调环境为 /bschclass</summary>
public string? BasePath { get; set; }
/// <summary>连接超时(毫秒),默认 1500</summary>
public long ConnectTimeoutMs { get; set; } = 1500;
/// <summary>读超时(毫秒),默认 5000</summary>
public long ReadTimeoutMs { get; set; } = 5000;
/// <summary>写超时(毫秒),默认 5000</summary>
public long WriteTimeoutMs { get; set; } = 5000;
/// <summary>调用方在教务平台注册的应用标识(用于签名,可选)</summary>
public string? AppKey { get; set; }
/// <summary>调用方在教务平台注册的密钥(用于签名,可选)</summary>
public string? AppSecret { get; set; }
/// <summary>拼接 baseHost + basePath 为统一开放接口基址。</summary>
public string ResolveBaseUrl()
{
var baseHost = BaseHost?.Trim() ?? "";
if (baseHost.EndsWith('/'))
{
baseHost = baseHost.TrimEnd('/');
}
return baseHost + (NormalizeBasePath(BasePath) ?? "");
}
/// <summary>归一化 basePath:trim、去尾斜杠、无前导斜杠补 /;null/空白返回 null。</summary>
public static string? NormalizeBasePath(string? path)
{
if (path == null)
{
return null;
}
var trimmed = path.Trim().TrimEnd('/');
if (trimmed.Length == 0)
{
return null;
}
return trimmed.StartsWith('/') ? trimmed : "/" + trimmed;
}
/// <summary>
/// 从共享节(<see cref="SharedSectionName"/>,即 <c>yh:open</c>)按键读取,回填本选项中**未显式配置**的项:
/// <para><c>BaseHost</c> ← <c>yh:open:BaseHost</c>;<c>BasePath</c> ← <c>yh:open:SchClassPath</c>(注意不是 BasePath —— 那是 UserBase 的路径)。</para>
/// <paramref name="readShared"/> 传相对键名(如 "BaseHost"),返回 null/空白视为未配置。
/// </summary>
public void FillFromSharedSection(Func<string, string?> readShared)
{
if (string.IsNullOrWhiteSpace(BaseHost))
{
BaseHost = readShared("BaseHost");
}
if (string.IsNullOrWhiteSpace(BasePath))
{
BasePath = readShared(SchClassPathKey);
}
if (string.IsNullOrWhiteSpace(AppKey))
{
AppKey = readShared("AppKey");
}
if (string.IsNullOrWhiteSpace(AppSecret))
{
AppSecret = readShared("AppSecret");
}
BasePath = NormalizeBasePath(BasePath);
}
}
@@ -0,0 +1,65 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Yh.SchClass.OpenApi;
/// <summary>
/// DI 注册扩展:从配置读取地址并装配全部教务客户端。
/// <para>
/// 配置来源(优先级从高到低):
/// 1. 独立节 <c>yh:schclass:open</c>(整节绑定,适合教务单独部署、地址与 UserBase 无关的场景);
/// 2. 共享节 <c>yh:open</c> 回填:<c>BaseHost</c> ← <c>yh:open:BaseHost</c>、
/// <c>BasePath</c> ← <c>yh:open:SchClassPath</c>(注意共享节的 <c>BasePath=/userbase</c> 是 UserBase 的,不会被教务采用)、
/// <c>AppKey</c>/<c>AppSecret</c> 同名回填。
/// </para>
/// </summary>
public static class SchClassOpenApiServiceCollectionExtensions
{
/// <summary>
/// 注册教务 /oapi 全部客户端:IHttpClientFactory + typed client + 聚合门面 <see cref="SchClassOpenApi"/>(Singleton)。
/// <code>
/// builder.Services.AddSchClassOpenApi(builder.Configuration);
/// // 注入使用:
/// public class X(SchClassOpenApi sch) { ... sch.SchInfo.AllListAsync() ... }
/// </code>
/// </summary>
public static IServiceCollection AddSchClassOpenApi(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<SchClassOpenApiOptions>()
.Bind(configuration.GetSection(SchClassOpenApiOptions.SectionName))
.PostConfigure(o =>
{
var shared = configuration.GetSection(SchClassOpenApiOptions.SharedSectionName);
o.FillFromSharedSection(key => shared[key]);
});
services.AddHttpClient(SchClassOpenApiOptions.HttpClientName)
.ConfigureHttpClient((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<SchClassOpenApiOptions>>().Value;
client.Timeout = TimeSpan.FromMilliseconds(options.ReadTimeoutMs > 0 ? options.ReadTimeoutMs : 5000);
var baseUrl = options.ResolveBaseUrl();
if (!string.IsNullOrEmpty(baseUrl))
{
client.BaseAddress = new Uri(baseUrl, UriKind.Absolute);
}
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var options = sp.GetRequiredService<IOptions<SchClassOpenApiOptions>>().Value;
return new SocketsHttpHandler
{
ConnectTimeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs > 0 ? options.ConnectTimeoutMs : 1500),
};
});
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
return new SchClassOpenApi(factory.CreateClient(SchClassOpenApiOptions.HttpClientName),
sp.GetRequiredService<IOptions<SchClassOpenApiOptions>>().Value);
});
return services;
}
}
@@ -0,0 +1,54 @@
using Yh.SchClass.OpenApi.Model;
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务 SchInfo(学校)接口客户端(镜像 Java <c>SchInfoController</c>,路径前缀 <c>/oapi/SchInfo/</c>)。
/// </summary>
public class SchClassSchInfoClient : SchClassClientBase
{
private const string Root = Prefix + "/SchInfo/";
public SchClassSchInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>按多个学校 ID 查询学校列表,<c>GET /oapi/SchInfo/GetSchlist</c>。</summary>
/// <param name="schIdArr">逗号分隔的学校 ID 列表(Java 参数名 <c>SchIDArr</c>);为 null 时等价于查询全部。</param>
public Task<List<SchInfoVo>> GetSchListAsync(string? schIdArr, CancellationToken ct = default)
=> ResolveListAsync<SchInfoVo>(HttpMethod.Get, Root + "GetSchlist",
Params(("SchIDArr", schIdArr)), ct: ct);
/// <summary>查询全部学校列表,<c>GET /oapi/SchInfo/AllList</c>。</summary>
public Task<List<SchInfoVo>> AllListAsync(CancellationToken ct = default)
=> ResolveListAsync<SchInfoVo>(HttpMethod.Get, Root + "AllList", ct: ct);
/// <summary>按学校外部序列号查询单个学校,<c>GET /oapi/SchInfo/GetSchFirst</c>;未找到返回 null。</summary>
/// <param name="schId">学校外部序列号(Java 参数名 <c>SchID</c>)。</param>
public Task<SchInfoVo?> GetSchFirstAsync(string? schId, CancellationToken ct = default)
=> ResolveAsync<SchInfoVo>(HttpMethod.Get, Root + "GetSchFirst", Params(("SchID", schId)), ct: ct);
/// <summary>按用户中心学校 ID 查询单个学校,<c>GET /oapi/SchInfo/GetUserSchFirst</c>;未找到返回 null。</summary>
/// <param name="userSchId">用户中心学校 ID(Java 参数名 <c>UserSchID</c>)。</param>
public Task<SchInfoVo?> GetUserSchFirstAsync(int? userSchId, CancellationToken ct = default)
=> ResolveAsync<SchInfoVo>(HttpMethod.Get, Root + "GetUserSchFirst",
Params(("UserSchID", userSchId)), ct: ct);
/// <summary>按多个用户中心学校 ID 查询学校列表,<c>GET /oapi/SchInfo/GetUserSchlist</c>。</summary>
/// <param name="userSchIds">逗号分隔的用户中心学校 ID 列表(Java 参数名 <c>UserSchIds</c>)。</param>
public Task<List<SchInfoVo>> GetUserSchListAsync(string? userSchIds, CancellationToken ct = default)
=> ResolveListAsync<SchInfoVo>(HttpMethod.Get, Root + "GetUserSchlist",
Params(("UserSchIds", userSchIds)), ct: ct);
/// <summary>按用户中心组 ID 查询学校列表,<c>GET /oapi/SchInfo/GetSchoolByGroupId</c>;0 或空时返回空列表。</summary>
/// <param name="groupId">用户中心组 ID(Java 参数名 <c>groupId</c>)。</param>
public Task<List<SchInfoVo>> GetSchoolByGroupIdAsync(int? groupId, CancellationToken ct = default)
=> ResolveListAsync<SchInfoVo>(HttpMethod.Get, Root + "GetSchoolByGroupId",
Params(("groupId", groupId)), ct: ct);
/// <summary>按学制 ID 查询学校,<c>GET /oapi/SchInfo/GetSchoolBySchoolenId</c>;当前服务端恒返回空列表。</summary>
public Task<List<SchClassRow>> GetSchoolBySchoolenIdAsync(int? groupId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchoolBySchoolenId",
Params(("groupId", groupId)), ct: ct);
}
@@ -0,0 +1,45 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务 Schoolen(学制 / 年级)接口客户端(镜像 Java <c>SchoolingController</c>,路径前缀 <c>/oapi/Schoolen/</c>)。
/// <para>
/// 年级行字段:<c>Id / Name / OwnerId / Levell / code / sx</c>;
/// 学制行字段:<c>Id / Name / FormerName / Type</c>。
/// </para>
/// </summary>
public class SchClassSchoolingClient : SchClassClientBase
{
private const string Root = Prefix + "/Schoolen/";
public SchClassSchoolingClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>按学校查询年级(学制层级)列表,<c>GET /oapi/Schoolen/GetSchLenLevellist</c>。</summary>
/// <param name="schSerId">学校外部序列号(Java 参数名 <c>SchSerID</c>)。</param>
public Task<List<SchClassRow>> GetSchLenLevelListAsync(string? schSerId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchLenLevellist", Params(("SchSerID", schSerId)), ct: ct);
/// <summary>按学制 ID 查询年级列表,<c>GET /oapi/Schoolen/GetLevelIdlist</c>。</summary>
/// <param name="schooling">学制 ID(Java 参数名 <c>Schooling</c>);0 或空时服务端返回空列表。</param>
public Task<List<SchClassRow>> GetLevelIdListAsync(int? schooling, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetLevelIdlist", Params(("Schooling", schooling)), ct: ct);
/// <summary>按年级 ID 查询单个年级,<c>GET /oapi/Schoolen/GetSchLenLevelFirst</c>;未找到返回 null。</summary>
/// <param name="gradeId">年级 ID(Java 参数名 <c>GradeId</c>,字符串形式)。</param>
public Task<SchClassRow?> GetSchLenLevelFirstAsync(string? gradeId, CancellationToken ct = default)
=> ResolveAsync<SchClassRow>(HttpMethod.Get, Root + "GetSchLenLevelFirst",
Params(("GradeId", gradeId)), ct: ct);
/// <summary>按学校查询学制列表,<c>GET /oapi/Schoolen/GetSchLenlist</c>。</summary>
public Task<List<SchClassRow>> GetSchLenListAsync(string? schSerId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchLenlist", Params(("SchSerID", schSerId)), ct: ct);
/// <summary>
/// 查询班级科目,<c>GET /oapi/Schoolen/GetAllClassSubject</c>;当前服务端恒返回空列表。
/// </summary>
public Task<List<SchClassRow>> GetAllClassSubjectAsync(int? schId, string? schserid, string? startTime, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetAllClassSubject",
Params(("schId", schId), ("schserid", schserid), ("startTime", startTime)), ct: ct);
}
@@ -0,0 +1,119 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务 OAStu(学生)接口客户端(镜像 Java <c>StuInfoController</c>,路径前缀 <c>/oapi/OAStu/</c>)。
/// <para>
/// 学生行字段(<c>OaCompatSupport.student</c>):
/// <c>Account / UserStuSerID / StuName / StuIDCard / StuSex / StuStatus / ClassID / ClassStuID /
/// GroupID / StuPhoto / CardID / StuCurrentTel / invitecode / SchSerID / Graduated / isjiedu</c>;
/// 走班(Elective)行在此基础上追加 <c>electiveId / schoolId</c> 且 <c>StuSex</c> 为数值。
/// </para>
/// <para>
/// 历史学生行字段:<c>stuId / stuName / classId / className / grade / name / school_system_id / system_grade</c>。
/// </para>
/// </summary>
public class SchClassStuInfoClient : SchClassClientBase
{
private const string Root = Prefix + "/OAStu/";
public SchClassStuInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>查询全部学生,<c>GET /oapi/OAStu/GetAllStulist</c>。</summary>
public Task<List<SchClassRow>> GetAllStuListAsync(CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetAllStulist", ct: ct);
/// <summary>查询全部走班学生,<c>GET /oapi/OAStu/GetEleAllStulist</c>。</summary>
public Task<List<SchClassRow>> GetEleAllStuListAsync(CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleAllStulist", ct: ct);
/// <summary>按学校查询学生,<c>GET /oapi/OAStu/GetSchStulist</c>。</summary>
/// <param name="schserid">学校外部序列号(Java 参数名 <c>schserid</c>,全小写)。</param>
/// <param name="schgroupid">学校分组 ID(Java 参数名 <c>schgroupid</c>)。</param>
public Task<List<SchClassRow>> GetSchStuListAsync(string? schserid, string? schgroupid, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchStulist",
Params(("schserid", schserid), ("schgroupid", schgroupid)), ct: ct);
/// <summary>按学校查询走班学生,<c>GET /oapi/OAStu/GetEleSchStulist</c>。</summary>
public Task<List<SchClassRow>> GetEleSchStuListAsync(string? schserid, string? schgroupid, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchStulist",
Params(("schserid", schserid), ("schgroupid", schgroupid)), ct: ct);
/// <summary>按班级查询学生,<c>GET /oapi/OAStu/GetClaStulist</c>。</summary>
/// <param name="classId">班级 ID(Java 参数名 <c>ClassID</c>)。</param>
/// <param name="claGroupId">班级分组 ID(Java 参数名 <c>clagroupid</c>)。</param>
public Task<List<SchClassRow>> GetClaStuListAsync(string? classId, string? claGroupId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetClaStulist",
Params(("ClassID", classId), ("clagroupid", claGroupId)), ct: ct);
/// <summary>按班级查询走班学生,<c>GET /oapi/OAStu/GetEleClaStulist</c>。</summary>
public Task<List<SchClassRow>> GetEleClaStuListAsync(string? classId, string? claGroupId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleClaStulist",
Params(("ClassID", classId), ("clagroupid", claGroupId)), ct: ct);
/// <summary>按多个班级查询学生,<c>GET /oapi/OAStu/GetClaArrStulist</c>。</summary>
/// <param name="classIds">逗号分隔的班级 ID 列表(Java 参数名 <c>ClassIDs</c>)。</param>
/// <param name="claGroupIds">逗号分隔的班级分组 ID 列表(Java 参数名 <c>clagroupids</c>)。</param>
public Task<List<SchClassRow>> GetClaArrStuListAsync(string? classIds, string? claGroupIds, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetClaArrStulist",
Params(("ClassIDs", classIds), ("clagroupids", claGroupIds)), ct: ct);
/// <summary>按多个班级查询走班学生,<c>GET /oapi/OAStu/GetEleClaArrStulist</c>。</summary>
public Task<List<SchClassRow>> GetEleClaArrStuListAsync(string? classIds, string? claGroupIds, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleClaArrStulist",
Params(("ClassIDs", classIds), ("clagroupids", claGroupIds)), ct: ct);
/// <summary>按多个学生 ID 查询学生,<c>GET /oapi/OAStu/GetStuIdslist</c>。</summary>
/// <param name="stuids">逗号分隔的学生 ID 列表(Java 参数名 <c>stuids</c>,全小写)。</param>
public Task<List<SchClassRow>> GetStuIdsListAsync(string? stuids, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetStuIdslist", Params(("stuids", stuids)), ct: ct);
/// <summary>按多个学生 ID 查询走班学生,<c>GET /oapi/OAStu/GetEleStuIdslist</c>。</summary>
public Task<List<SchClassRow>> GetEleStuIdsListAsync(string? stuids, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleStuIdslist", Params(("stuids", stuids)), ct: ct);
/// <summary>查询学生家长用户 ID 列表,<c>GET /oapi/OAStu/GetStuUserFamIDList</c>;当前服务端恒返回空列表。</summary>
public Task<List<string>> GetStuUserFamIdListAsync(string? stuids, CancellationToken ct = default)
=> ResolveListAsync<string>(HttpMethod.Get, Root + "GetStuUserFamIDList",
Params(("stuids", stuids)), ct: ct);
/// <summary>按邀请码查询学生,<c>GET /oapi/OAStu/GetFamStulist</c>。</summary>
/// <param name="invitecodes">逗号分隔的邀请码列表(Java 参数名 <c>invitecodes</c>)。</param>
public Task<List<SchClassRow>> GetFamStuListAsync(string? invitecodes, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetFamStulist",
Params(("invitecodes", invitecodes)), ct: ct);
/// <summary>查询学生家庭成员,<c>GET /oapi/OAStu/GetStuFamMem</c>;当前服务端恒返回空列表。</summary>
public Task<List<SchClassRow>> GetStuFamMemAsync(string? schSerId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetStuFamMem", Params(("SchSerID", schSerId)), ct: ct);
/// <summary>按多个学校序列号查询学生,<c>GET /oapi/OAStu/GetSchBySchIds</c>。</summary>
/// <param name="schserids">逗号分隔的学校序列号列表(Java 参数名 <c>schserids</c>,全小写)。</param>
public Task<List<SchClassRow>> GetSchBySchIdsAsync(string? schserids, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchBySchIds", Params(("schserids", schserids)), ct: ct);
/// <summary>按多个学校序列号查询走班学生,<c>GET /oapi/OAStu/GetEleSchIdsStulist</c>。</summary>
public Task<List<SchClassRow>> GetEleSchIdsStuListAsync(string? schserids, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchIdsStulist", Params(("schserids", schserids)), ct: ct);
/// <summary>按多个学校序列号查询走班学生,<c>GET /oapi/OAStu/GetEleSchBySchIds</c>。</summary>
public Task<List<SchClassRow>> GetEleSchBySchIdsAsync(string? schserids, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchBySchIds", Params(("schserids", schserids)), ct: ct);
/// <summary>按选课 ID 查询学生,<c>GET /oapi/OAStu/GetStudentsByElectiveId</c>;当前服务端恒返回空列表。</summary>
public Task<List<SchClassRow>> GetStudentsByElectiveIdAsync(int? electiveIds, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetStudentsByElectiveId",
Params(("electiveIds", electiveIds)), ct: ct);
/// <summary>按学校 + 年度查询历史学生,<c>GET /oapi/OAStu/getHistoryStudentsBySchoolId</c>。</summary>
public Task<List<SchClassRow>> GetHistoryStudentsBySchoolIdAsync(int schoolId, int year, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "getHistoryStudentsBySchoolId",
Params(("schoolId", schoolId), ("year", year)), ct: ct);
/// <summary>按学校 + 年度查询历史班级,<c>GET /oapi/OAStu/getHistoryClass</c>;当前服务端恒返回空列表。</summary>
public Task<List<SchClassRow>> GetHistoryClassAsync(int schoolId, int year, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "getHistoryClass",
Params(("schoolId", schoolId), ("year", year)), ct: ct);
}
@@ -0,0 +1,41 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务 SubInfo(科目)接口客户端(镜像 Java <c>SchSubController</c>,路径前缀 <c>/oapi/SubInfo/</c>)。
/// <para>科目行字段:<c>SchSerID / SubID / SubName / No</c>。</para>
/// </summary>
public class SchClassSubInfoClient : SchClassClientBase
{
private const string Root = Prefix + "/SubInfo/";
public SchClassSubInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>按学校 + 学制查询科目列表,<c>GET /oapi/SubInfo/GetSchSublist</c>。</summary>
/// <param name="schId">学校外部序列号(Java 参数名 <c>SchID</c>)。</param>
/// <param name="schooling">学制 ID(Java 参数名 <c>schooling</c>,全小写)。</param>
/// <param name="gradeId">年级 ID(Java 参数名 <c>gradeId</c>;当前服务端未使用该参数)。</param>
public Task<List<SchClassRow>> GetSchSubListAsync(string? schId, int? schooling, int? gradeId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchSublist",
Params(("SchID", schId), ("schooling", schooling), ("gradeId", gradeId)), ct: ct);
/// <summary>按多个学校查询科目列表,<c>GET /oapi/SubInfo/GetSchIdsSublist</c>。</summary>
/// <param name="schIds">逗号分隔的学校序列号列表(Java 参数名 <c>SchIds</c>)。</param>
public Task<List<SchClassRow>> GetSchIdsSubListAsync(string? schIds, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchIdsSublist", Params(("SchIds", schIds)), ct: ct);
/// <summary>按教师查询其任教的科目编码列表(去重字符串),<c>GET /oapi/SubInfo/GetSchTeaSublist</c>。</summary>
/// <param name="userTeaSerId">教师 ID(Java 参数名 <c>UserTeaSerID</c>,字符串形式)。</param>
public Task<List<string>> GetSchTeaSubListAsync(string? userTeaSerId, CancellationToken ct = default)
=> ResolveListAsync<string>(HttpMethod.Get, Root + "GetSchTeaSublist",
Params(("UserTeaSerID", userTeaSerId)), ct: ct);
/// <summary>
/// 按学校查询该校全部任课科目编码(小写去重字符串),<c>GET /oapi/SubInfo/GetSchTeachSublist</c>。
/// </summary>
public Task<List<string>> GetSchTeachSubListAsync(string? schId, CancellationToken ct = default)
=> ResolveListAsync<string>(HttpMethod.Get, Root + "GetSchTeachSublist",
Params(("SchID", schId)), ct: ct);
}
@@ -0,0 +1,49 @@
using Yh.SchClass.OpenApi.Model;
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务 OATea(教师)接口客户端(镜像 Java <c>TeainfoController</c>,路径前缀 <c>/oapi/OATea/</c>)。
/// </summary>
public class SchClassTeaInfoClient : SchClassClientBase
{
private const string Root = Prefix + "/OATea/";
public SchClassTeaInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>
/// 按教师 ID 查询教师(<c>GET /oapi/OATea/GetTeainfo</c>)。
/// 教师不存在或无分组归属时服务端返回 404,本方法返回 null。
/// </summary>
/// <param name="uid">教师 ID(Java 参数名 <c>uid</c>,全小写)。</param>
public Task<TeaInfoVo?> GetTeaInfoAsync(int uid, CancellationToken ct = default)
=> ResolveAsync<TeaInfoVo>(HttpMethod.Get, Root + "GetTeainfo", Params(("uid", uid)), ct: ct);
/// <summary>
/// 按多个教师 ID 查询教师列表(<c>POST /oapi/OATea/GetArrTeainfo</c>)。
/// 请求体为逗号分隔的教师 ID(Java 侧 <c>@RequestBody String</c>,按 text/plain 提交);
/// 参数缺失时服务端返回 400,查询为空时返回 204(本方法返回空列表)。
/// </summary>
public Task<List<TeaInfoVo>> GetArrTeaInfoAsync(string userIds, CancellationToken ct = default)
=> ResolveListAsync<TeaInfoVo>(HttpMethod.Post, Root + "GetArrTeainfo", body: userIds, ct: ct);
/// <summary>
/// 按学校查询教师列表(<c>GET /oapi/OATea/GetSchTeainfo</c>)。
/// <paramref name="schId"/> 与 <paramref name="schSerId"/> 至少提供一个,否则服务端返回 400。
/// </summary>
/// <param name="schSerId">学校外部序列号(Java 参数名 <c>SchSerID</c>)。</param>
/// <param name="schId">学校内部 ID(Java 参数名 <c>schId</c>,优先使用)。</param>
public Task<List<TeaInfoVo>> GetSchTeaInfoAsync(string? schSerId = null, int? schId = null, CancellationToken ct = default)
=> ResolveListAsync<TeaInfoVo>(HttpMethod.Get, Root + "GetSchTeainfo",
Params(("SchSerID", schSerId), ("schId", schId)), ct: ct);
/// <summary>
/// 按学校查询教师列表(<c>GET /oapi/OATea/GetSchTeaList</c>),与 <see cref="GetSchTeaInfoAsync"/> 等价。
/// </summary>
public Task<List<TeaInfoVo>> GetSchTeaListAsync(string? schSerId = null, int? schId = null, CancellationToken ct = default)
=> ResolveListAsync<TeaInfoVo>(HttpMethod.Get, Root + "GetSchTeaList",
Params(("SchSerID", schSerId), ("schId", schId)), ct: ct);
}
@@ -0,0 +1,60 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务 TeachInfo(任课)接口客户端(镜像 Java <c>TeaChesController</c>,路径前缀 <c>/oapi/TeachInfo/</c>)。
/// <para>
/// 任课行字段(<c>OaCompatSupport.teaching</c>):
/// <c>SerID / SchSerID / UserTeaSerID / SubID / ClassID / UserClassID / CreationTime / static</c>。
/// </para>
/// </summary>
public class SchClassTeachInfoClient : SchClassClientBase
{
private const string Root = Prefix + "/TeachInfo/";
public SchClassTeachInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>按学校 + 教师查询任课,<c>GET /oapi/TeachInfo/GetSchTeachlist</c>。</summary>
/// <param name="schId">学校外部序列号(Java 参数名 <c>SchID</c>)。</param>
/// <param name="userTeaSerId">教师 ID(Java 参数名 <c>UserTeaSerID</c>)。</param>
public Task<List<SchClassRow>> GetSchTeachListAsync(string? schId, int? userTeaSerId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchTeachlist",
Params(("SchID", schId), ("UserTeaSerID", userTeaSerId)), ct: ct);
/// <summary>按学校 + 教师查询走班任课,<c>GET /oapi/TeachInfo/GetEleSchTeachlist</c>。</summary>
public Task<List<SchClassRow>> GetEleSchTeachListAsync(string? schId, int? userTeaSerId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchTeachlist",
Params(("SchID", schId), ("UserTeaSerID", userTeaSerId)), ct: ct);
/// <summary>按多个学校查询任课,<c>GET /oapi/TeachInfo/GetSchArrTeachlist</c>。</summary>
/// <param name="schIdArr">逗号分隔的学校序列号列表(Java 参数名 <c>SchIDArr</c>)。</param>
public Task<List<SchClassRow>> GetSchArrTeachListAsync(string? schIdArr, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrTeachlist", Params(("SchIDArr", schIdArr)), ct: ct);
/// <summary>按多个学校查询走班任课,<c>GET /oapi/TeachInfo/GetEleSchArrTeachList</c>。</summary>
public Task<List<SchClassRow>> GetEleSchArrTeachListAsync(string? schIdArr, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrTeachList", Params(("SchIDArr", schIdArr)), ct: ct);
/// <summary>按班级查询任课,<c>GET /oapi/TeachInfo/GetSchClaTeachlist</c>。</summary>
/// <param name="classId">班级 ID(Java 参数名 <c>ClassID</c>);非法或为空时返回空列表。</param>
public Task<List<SchClassRow>> GetSchClaTeachListAsync(string? classId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchClaTeachlist", Params(("ClassID", classId)), ct: ct);
/// <summary>按班级查询走班任课,<c>GET /oapi/TeachInfo/GetEleSchClaTeachlist</c>。</summary>
public Task<List<SchClassRow>> GetEleSchClaTeachListAsync(string? classId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchClaTeachlist", Params(("ClassID", classId)), ct: ct);
/// <summary>按学校 + 年级查询选课班级,<c>GET /oapi/TeachInfo/GetElectiveClassByGrade</c>;当前服务端恒返回空列表。</summary>
public Task<List<SchClassRow>> GetElectiveClassByGradeAsync(int? schoolId, int? gradeId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetElectiveClassByGrade",
Params(("schoolId", schoolId), ("gradeId", gradeId)), ct: ct);
/// <summary>
/// 按多个教师查询选课班级,<c>POST /oapi/TeachInfo/GetElectiveClassByTeachers</c>;
/// 请求体为逗号分隔的教师 ID(text/plain),当前服务端恒返回空列表。
/// </summary>
public Task<List<SchClassRow>> GetElectiveClassByTeachersAsync(string? teacherIds, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Post, Root + "GetElectiveClassByTeachers", body: teacherIds, ct: ct);
}
@@ -0,0 +1,34 @@
using Yh.SchClass.OpenApi.Model;
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务 UserInfo(师生统一用户)接口客户端(镜像 Java <c>UserInfoController</c>,路径前缀 <c>/oapi/UserInfo/</c>)。
/// </summary>
public class SchClassUserInfoClient : SchClassClientBase
{
private const string Root = Prefix + "/UserInfo/";
public SchClassUserInfoClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>
/// 查询学校全部教师 + 学生(<c>GET /oapi/UserInfo/GetUserList</c>)。
/// 参数缺失或学校不存在时服务端返回 400;查询为空时返回 204(本方法返回空列表)。
/// </summary>
/// <param name="schSerId">学校外部序列号(Java 参数名 <c>SchSerID</c>)。</param>
public Task<List<UserInfoVo>> GetUserListAsync(string schSerId, CancellationToken ct = default)
=> ResolveListAsync<UserInfoVo>(HttpMethod.Get, Root + "GetUserList",
Params(("SchSerID", schSerId)), ct: ct);
/// <summary>
/// 按用户 ID 查询用户信息(<c>GET /oapi/UserInfo/getUserIdList</c>)。
/// 教师 / 学生均未命中时服务端返回 404,本方法返回空列表。
/// </summary>
/// <param name="userid">用户 ID(Java 参数名 <c>userid</c>,全小写)。</param>
public Task<List<UserInfoVo>> GetUserIdListAsync(int userid, CancellationToken ct = default)
=> ResolveListAsync<UserInfoVo>(HttpMethod.Get, Root + "getUserIdList",
Params(("userid", userid)), ct: ct);
}
@@ -0,0 +1,107 @@
namespace Yh.SchClass.OpenApi;
/// <summary>
/// 教务 VSchClass(班级)接口客户端(镜像 Java <c>VClassController</c>,路径前缀 <c>/oapi/VSchClass/</c>)。
/// <para>
/// 班级行字段(<c>OaCompatSupport.classInfo</c>):
/// <c>Id / Name / OwnerId / Levell / sx / SchSerID / ClassID / UserClassID / Schooling / MajorCode /
/// ClassCode / ClassMotto / ClassName / StartDate / FirstTeaUserID / SecondTeaUserID / UpgradeYear /
/// CurrentIndex / CreationTime / GradtionTime / GreduatedYear / Graduated</c>。
/// </para>
/// </summary>
public class SchClassVClassClient : SchClassClientBase
{
private const string Root = Prefix + "/VSchClass/";
public SchClassVClassClient(HttpClient httpClient, SchClassOpenApiOptions options)
: base(httpClient, options)
{
}
/// <summary>按学校查询班级(排除已毕业),<c>GET /oapi/VSchClass/GetVClassList</c>。</summary>
/// <param name="schSerId">学校外部序列号(Java 参数名 <c>SchSerID</c>)。</param>
public Task<List<SchClassRow>> GetVClassListAsync(string? schSerId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetVClassList", Params(("SchSerID", schSerId)), ct: ct);
/// <summary>按学校查询班级(含已毕业),<c>GET /oapi/VSchClass/GetEleVClassList</c>。</summary>
public Task<List<SchClassRow>> GetEleVClassListAsync(string? schSerId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleVClassList", Params(("SchSerID", schSerId)), ct: ct);
/// <summary>按多个学校查询班级(排除已毕业),<c>GET /oapi/VSchClass/GetSchArrVClassList</c>。</summary>
/// <param name="schIdArr">逗号分隔的学校序列号列表(Java 参数名 <c>SchIDArr</c>)。</param>
public Task<List<SchClassRow>> GetSchArrVClassListAsync(string? schIdArr, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrVClassList", Params(("SchIDArr", schIdArr)), ct: ct);
/// <summary>按多个学校查询班级(含已毕业),<c>GET /oapi/VSchClass/GetEleSchArrVClassList</c>。</summary>
public Task<List<SchClassRow>> GetEleSchArrVClassListAsync(string? schIdArr, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrVClassList", Params(("SchIDArr", schIdArr)), ct: ct);
/// <summary>
/// 按时间查询学校班级,<c>GET /oapi/VSchClass/GetFunTimeSchClass</c>。
/// 注意:Java 实现忽略 <paramref name="time"/>,实际按学校过滤。
/// </summary>
public Task<List<SchClassRow>> GetFunTimeSchClassAsync(string? time, string? schSerId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetFunTimeSchClass",
Params(("time", time), ("SchSerID", schSerId)), ct: ct);
/// <summary>按班级 ID 查询单个班级,<c>GET /oapi/VSchClass/GetVClassFirst</c>;未找到返回 null。</summary>
/// <param name="classId">班级 ID(Java 参数名 <c>ClassID</c>)。</param>
public Task<SchClassRow?> GetVClassFirstAsync(string? classId, CancellationToken ct = default)
=> ResolveAsync<SchClassRow>(HttpMethod.Get, Root + "GetVClassFirst", Params(("ClassID", classId)), ct: ct);
/// <summary>按班级 ID 查询单个班级(含已毕业),<c>GET /oapi/VSchClass/GetEleVClassFirst</c>。</summary>
public Task<SchClassRow?> GetEleVClassFirstAsync(string? classId, CancellationToken ct = default)
=> ResolveAsync<SchClassRow>(HttpMethod.Get, Root + "GetEleVClassFirst", Params(("ClassID", classId)), ct: ct);
/// <summary>按多个班级 ID 查询班级,<c>GET /oapi/VSchClass/GetVClassIdsList</c>。</summary>
/// <param name="classIds">逗号分隔的班级 ID 列表(Java 参数名 <c>ClassIds</c>)。</param>
public Task<List<SchClassRow>> GetVClassIdsListAsync(string? classIds, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetVClassIdsList", Params(("ClassIds", classIds)), ct: ct);
/// <summary>按多个班级 ID 查询班级(含已毕业),<c>GET /oapi/VSchClass/GetEleVClassIdsList</c>。</summary>
public Task<List<SchClassRow>> GetEleVClassIdsListAsync(string? classIds, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleVClassIdsList", Params(("ClassIds", classIds)), ct: ct);
/// <summary>按学校 + 学制 + 年级查询班级,<c>GET /oapi/VSchClass/GetGradeClassList</c>。</summary>
public Task<List<SchClassRow>> GetGradeClassListAsync(string? schSerId, int? schooling, int? gradeId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetGradeClassList",
Params(("SchSerID", schSerId), ("Schooling", schooling), ("GradeId", gradeId)), ct: ct);
/// <summary>查询教师任课的班级 ID 列表(去重后的字符串),<c>GET /oapi/VSchClass/GetTeachClassArr</c>。</summary>
/// <param name="userTeaSerId">教师 ID(Java 参数名 <c>UserTeaSerID</c>)。</param>
public Task<List<string>> GetTeachClassArrAsync(int? userTeaSerId, CancellationToken ct = default)
=> ResolveListAsync<string>(HttpMethod.Get, Root + "GetTeachClassArr",
Params(("UserTeaSerID", userTeaSerId)), ct: ct);
/// <summary>查询全部班级(排除已毕业),<c>GET /oapi/VSchClass/GetSchArrVClassListAll</c>。</summary>
public Task<List<SchClassRow>> GetSchArrVClassListAllAsync(CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetSchArrVClassListAll", ct: ct);
/// <summary>查询全部班级(含已毕业),<c>GET /oapi/VSchClass/GetEleSchArrVClassListAll</c>。</summary>
public Task<List<SchClassRow>> GetEleSchArrVClassListAllAsync(CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetEleSchArrVClassListAll", ct: ct);
/// <summary>
/// 以 POST 方式按多个学校查询班级,<c>POST /oapi/VSchClass/GetSchArrVClassListPost</c>。
/// 请求体为逗号分隔的学校序列号(Java 侧 <c>@RequestBody String</c>,按 text/plain 提交)。
/// </summary>
public Task<List<SchClassRow>> GetSchArrVClassListPostAsync(string? body, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Post, Root + "GetSchArrVClassListPost", body: body, ct: ct);
/// <summary>按选课 ID 查询班级,<c>GET /oapi/VSchClass/GetElectiveClassById</c>;当前服务端恒返回 null。</summary>
public Task<SchClassRow?> GetElectiveClassByIdAsync(int? electiveId, CancellationToken ct = default)
=> ResolveAsync<SchClassRow>(HttpMethod.Get, Root + "GetElectiveClassById",
Params(("electiveId", electiveId)), ct: ct);
/// <summary>按学校 + 年级查询选课班级,<c>GET /oapi/VSchClass/GetElectiveClassByGrade</c>;当前服务端恒返回空列表。</summary>
public Task<List<SchClassRow>> GetElectiveClassByGradeAsync(int? schoolId, int? gradeId, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Get, Root + "GetElectiveClassByGrade",
Params(("schoolId", schoolId), ("gradeId", gradeId)), ct: ct);
/// <summary>
/// 按多个教师查询选课班级,<c>POST /oapi/VSchClass/GetElectiveClassByTeachers</c>;
/// 请求体为逗号分隔的教师 ID(text/plain),当前服务端恒返回空列表。
/// </summary>
public Task<List<SchClassRow>> GetElectiveClassByTeachersAsync(string? teacherIds, CancellationToken ct = default)
=> ResolveRowsAsync(HttpMethod.Post, Root + "GetElectiveClassByTeachers", body: teacherIds, ct: ct);
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Yh.SchClass.OpenApi</PackageId>
<Description>云海教务(SchClass)开放接口客户端:对应 Java 教务 OpenApi 调用封装</Description>
<Summary>SchClass /oapi 同构客户端 SDK,支持 yh:open / yh:schclass:open 配置节绑定与 DI 注册</Summary>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
</ItemGroup>
</Project>