using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NewLife;
using NewLife.Data;
using NewLife.Log;
using NewLife.Remoting;
using NewLife.Remoting.Extensions;
using NewLife.Remoting.Models;
using NewLife.Remoting.Services;
using NewLife.Security;
using NewLife.Serialization;
using Stardust.Data;
using Stardust.Data.Configs;
using Stardust.Models;
using Stardust.Server.Services;
using WebSocket = System.Net.WebSockets.WebSocket;
namespace Stardust.Server.Controllers;
/// <summary>应用接口控制器</summary>
[ApiController]
[Route("[controller]")]
public class AppController(RegistryService registryService, ITokenService tokenService, DeployService deployService, AppSessionManager sessionManager, IServiceProvider serviceProvider, ITracer tracer, StarServerSetting setting) : BaseController(registryService, tokenService, serviceProvider)
{
#region 登录注销
[AllowAnonymous]
[HttpPost(nameof(Login))]
public ILoginResponse Login(AppModel model)
{
// 修正在2025-9-10错误修改rs.Code = device2.Secret,导致新应用重复注册为错误应用名
var autoReg = false;
var appId = model.AppId;
if (!appId.IsNullOrEmpty() && appId.Length == 16 && !model.Secret.IsNullOrEmpty())
{
// appId同时也是Secret的情况,说明是客户端传入错误,进行兼容处理
//var passwordProvider = serviceProvider.GetService<IPasswordProvider>();
//if (passwordProvider != null && passwordProvider.Verify(appId, model.Secret))
{
var app2 = App.FindBySecret(appId);
if (app2 != null)
{
// 找到对应应用,修正AppId
model.AppId = app2.Name;
autoReg = true;
}
}
}
var ip = UserHost;
var app = App.FindByName(model.AppId);
var oldSecret = app?.Secret;
Context.Device = app;
var request = model;
var rs = registryService.Login(Context, request, "Http");
app = Context.Device as App ?? throw new ApiException(ApiCode.Unauthorized, L.T("Application authentication failed."));
if (Context.Online is AppOnline online) deployService.UpdateDeployNode(online);
//rs.Time = inf.Node.Time;
rs.ServerTime = DateTime.UtcNow.ToLong();
// 动态注册的设备不可用时,不要发令牌,只发证书
if (app.Enable)
{
if (request.ClientId.IsNullOrEmpty()) Context.ClientId = request.ClientId = Rand.NextString(8);
var tm = tokenService.IssueToken(app.Name, request.ClientId);
rs.Token = tm.AccessToken;
rs.Expire = tm.ExpireIn;
}
// 动态注册,下发节点证书
if (app.Name != model.AppId || app.Secret != oldSecret || autoReg)
{
rs.Code = app.Name;
rs.Secret = app.Secret;
}
return rs;
}
/// <summary>应用注册。旧版客户端登录接口,新版已废弃,改用Login</summary>
/// <param name="inf"></param>
/// <returns></returns>
[HttpPost(nameof(Register))]
public String Register(AppModel inf)
{
var request = inf;
var rs = registryService.Login(Context, request, "Http");
var app = Context.Device as App ?? throw new ApiException(ApiCode.Unauthorized, L.T("Application authentication failed."));
if (Context.Online is AppOnline online) deployService.UpdateDeployNode(online);
return app?.ToString();
}
/// <summary>注销</summary>
/// <param name="reason">注销原因</param>
/// <returns></returns>
[HttpGet(nameof(Logout))]
[HttpPost(nameof(Logout))]
public LoginResponse Logout(String reason)
{
registryService.Logout(Context, reason, "Http");
return new LoginResponse
{
Name = Context.Device?.Name,
Token = null,
};
}
#endregion
#region 心跳保活
/// <summary>应用心跳保活。更新在线状态、性能指标与令牌续期</summary>
/// <param name="inf">应用信息</param>
/// <returns>心跳响应</returns>
[HttpGet(nameof(Ping))]
[HttpPost(nameof(Ping))]
public IPingResponse Ping(AppInfo inf)
{
var rs = registryService.Ping(Context, inf, null);
var online = Context.Online as AppOnline;
deployService.UpdateDeployNode(online);
if (Context.Device is App app)
{
AppMeter.WriteData(app, inf, "Ping", Context.ClientId, Context.UserHost, online?.NodeId ?? 0);
//rs.Period = app.Period;
// 令牌有效期检查,10分钟内到期的令牌,颁发新令牌,以获取业务的连续性。
var (jwt, ex) = tokenService.DecodeToken(Token);
if (ex == null && jwt != null && jwt.Expire < DateTime.Now.AddMinutes(10))
{
using var span = tracer?.NewSpan("RefreshAppToken", new { app.Name, app.DisplayName });
var tm = tokenService.IssueToken(app.Name, Context.ClientId);
rs.Token = tm.AccessToken;
}
//if (!app.Version.IsNullOrEmpty() && Version.TryParse(app.Version, out var ver))
//{
// // 拉取命令
// if (ver.Build >= 2024 && ver.Revision >= 801)
// rs.Commands = registryService.AcquireCommands(app.Id);
//}
}
return rs;
}
//[AllowAnonymous]
//[HttpGet(nameof(Ping))]
//public PingResponse Ping() => new() { Time = 0, ServerTime = DateTime.UtcNow.ToLong(), };
#endregion
#region 事件上报
/// <summary>批量上报事件</summary>
/// <param name="events">事件集合</param>
/// <returns></returns>
[HttpPost(nameof(PostEvents))]
public Int32 PostEvents(EventModel[] events) => registryService.PostEvents(Context, events);
#endregion
#region 下行通知
/// <summary>下行通知。通知应用刷新配置信息和服务信息等</summary>
/// <returns></returns>
[HttpGet("/app/notify")]
public async Task Notify()
{
if (HttpContext.WebSockets.IsWebSocketRequest)
{
using var socket = await HttpContext.WebSockets.AcceptWebSocketAsync();
await HandleNotify(Context, socket, HttpContext.RequestAborted);
}
else
{
HttpContext.Response.StatusCode = 400;
}
}
private async Task HandleNotify(DeviceContext context, WebSocket socket, CancellationToken cancellationToken)
{
var app = context.Device as App ?? throw new InvalidOperationException(L.T("Not logged in."));
// BaseController.OnAuthorize 中已通过 deviceService.GetOnline 设置 Context.Online,
// WebSocket 升级请求也走 OnAuthorize,后续心跳消息可直接复用。
using var span = tracer?.NewSpan("cmd:WsApp:Create", app.Name);
span?.Detach(HttpContext.Request.Headers);
try
{
using var session = new AppCommandSession(socket)
{
Code = $"{app.Name}@{Context.ClientId}",
Log = this,
SetOnline = online => registryService.SetOnline(Context, online),
ServiceProvider = serviceProvider,
Tracer = tracer,
};
sessionManager.Add(session);
context["HttpContext"] = HttpContext;
context["Span"] = span;
await session.WaitAsync(context, cancellationToken);
}
catch (Exception ex)
{
// 这里不一定命中,因 WaitAsync 内部会提前结束span
span?.SetError(ex, null);
throw;
}
}
/// <summary>向节点发送命令。通知应用刷新配置信息和服务信息等</summary>
/// <param name="model"></param>
/// <param name="token">应用令牌</param>
/// <returns></returns>
//[AllowAnonymous]
[HttpPost(nameof(SendCommand))]
public Task<CommandReplyModel> SendCommand(CommandInModel model)
{
if (model.Code.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Code), L.T("Application must be specified."));
if (model.Command.IsNullOrEmpty()) throw new ArgumentNullException(nameof(model.Command));
var code = model.Code;
var clientId = "";
var p = code.IndexOf('@');
if (p > 0)
{
clientId = code[(p + 1)..];
code = code[..p];
}
var target = App.FindByName(code) ?? throw new ArgumentOutOfRangeException(nameof(model.Code), L.T("Invalid application."));
var app = Context.Device as App;
if (app == null) throw new ApiException(ApiCode.Unauthorized, L.T("No permission to perform this operation."));
// 安全V-2:未配置密钥的应用可被匿名冒用,禁止其向其它应用下发控制命令
if (app.Secret.IsNullOrEmpty())
throw new ApiException(ApiCode.Forbidden, L.T("[{0}] has no secret configured; control commands are forbidden. Set a Secret for this application in the registry.", app));
if (!app.AllowControlNodes.IsNullOrEmpty())
{
if (app.AllowControlNodes != "*" && !target.Name.EqualIgnoreCase(app.AllowControlNodes.Split(",")))
throw new ApiException(ApiCode.Forbidden, L.T("[{0}] is not allowed to operate application [{1}].\nFor security, applications cannot send control commands to other applications by default.\nIn the registry, edit the controllable nodes of [{0}], add [{2}], or set it to * for all applications.", app, target, target.Name));
}
else if (!setting.AllowControlNodesWhenEmpty)
throw new ApiException(ApiCode.Unauthorized, L.T("No permission to perform this operation."));
return registryService.SendCommand(target, clientId, model, app + "");
}
/// <summary>设备端响应服务调用</summary>
/// <param name="model">服务</param>
/// <returns></returns>
[HttpPost(nameof(CommandReply))]
public Int32 CommandReply(CommandReplyModel model) => registryService.CommandReply(Context, model);
#endregion
#region 服务发布与消费
private Service GetService(String serviceName)
{
var info = Service.FindByName(serviceName);
if (info == null)
{
info = new Service { Name = serviceName, Enable = true };
info.Insert();
}
if (!info.Enable) throw new ApiException(ApiCode.Forbidden, L.T("Service [{0}] is disabled.", serviceName));
return info;
}
[HttpPost(nameof(RegisterService))]
public async Task<ServiceModel> RegisterService([FromBody] PublishServiceInfo model)
{
var app = Context.Device as App;
var info = GetService(model.ServiceName);
var online = Context.Online as AppOnline;
var (svc, changed) = registryService.RegisterService(app, info, model, online, UserHost);
// 发布消息通知消费者
if (changed)
{
await registryService.NotifyConsumers(svc, "registry/register", app + "");
}
return svc?.ToModel();
}
[HttpPost(nameof(UnregisterService))]
public async Task<ServiceModel> UnregisterService([FromBody] PublishServiceInfo model)
{
var app = Context.Device as App;
var info = GetService(model.ServiceName);
var (svc, changed) = registryService.UnregisterService(app, info, model, UserHost);
// 发布消息通知消费者
if (changed)
{
await registryService.NotifyConsumers(svc, "registry/unregister", app + "");
}
return svc?.ToModel();
}
[HttpPost(nameof(ResolveService))]
public ServiceModel[] ResolveService([FromBody] ConsumeServiceInfo model)
{
var app = Context.Device as App;
var info = GetService(model.ServiceName);
// 所有消费
var consumes = AppConsume.FindAllByService(info.Id);
var svc = consumes.FirstOrDefault(e => e.AppId == app.Id && e.Client == model.ClientId);
if (svc == null)
{
svc = new AppConsume
{
AppId = app.Id,
ServiceId = info.Id,
ServiceName = model.ServiceName,
Client = model.ClientId,
Enable = true,
CreateIP = UserHost,
};
consumes.Add(svc);
WriteLog("ResolveService", true, L.TLog("Consuming service [{0}] {1}", model.ServiceName, model.ToJson()));
}
// 节点信息
//var online = AppOnline.FindByClient(model.ClientId);
var online = Context.Online as AppOnline;
if (online != null) svc.NodeId = online.NodeId;
// 作用域
svc.Scope = AppRule.CheckScope(-1, UserHost, model.ClientId);
svc.PingCount++;
svc.Tag = model.Tag;
svc.MinVersion = model.MinVersion;
svc.Save();
info.Consumers = consumes.Count;
info.Save();
var models = registryService.ResolveService(info, model, svc.Scope);
// 记录应用消费服务得到的地址
svc.Address = models?.Select(e => new { e.Address }).ToArray().ToJson();
svc.Save();
return models;
}
[HttpPost(nameof(SearchService))]
public IList<AppService> SearchService(String serviceName, String key)
{
var svc = Service.FindByName(serviceName);
if (svc == null) return null;
return AppService.Search(-1, svc.Id, null, true, key, new PageParameter { PageSize = 100 });
}
#endregion
}
|