feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
+293
View File
@@ -0,0 +1,293 @@
using System.Net;
using System.Text;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service;
using dy.net.Tests.TestInfrastructure;
using dy.net.utils;
using Microsoft.AspNetCore.DataProtection;
using Newtonsoft.Json.Linq;
using SqlSugar;
namespace dy.net.Tests;
public class DouyinLiveStatusTests
{
[Fact]
public void Parser_ExtractsLiveRoomFromStringRoomData()
{
var roomData = JToken.FromObject("{\"status\":2,\"id_str\":\"room-1\",\"web_rid\":\"web-1\",\"title\":\"测试直播\",\"start_time\":1700000000}");
var result = DouyinLiveStatusParser.Parse(1, null, null, roomData);
Assert.Equal(DouyinLiveStatusState.Live, result.Status);
Assert.Equal("room-1", result.RoomId);
Assert.Equal("web-1", result.WebRid);
Assert.Equal("测试直播", result.Title);
Assert.NotNull(result.StartedAt);
}
[Fact]
public void Parser_DoesNotTreatMissingStatusAsOffline()
{
var result = DouyinLiveStatusParser.Parse(null, null, null, null);
Assert.Equal(DouyinLiveStatusState.Unknown, result.Status);
}
[Fact]
public async Task Client_SignsProfileRequestAndParsesLiveState()
{
var handler = new LiveProfileResponseHandler();
using var factory = new StubHttpClientFactory(handler, "https://www.douyin.com");
var client = new DouyinLiveStatusClient(factory);
var result = await client.ProbeAsync("sec-user-1", "msToken=token-value; UIFID=ui-value");
Assert.Equal(DouyinLiveStatusState.Live, result.Status);
Assert.Equal("web-live-1", result.WebRid);
Assert.NotNull(handler.Request);
Assert.Equal("/aweme/v1/web/user/profile/other/", handler.Request.RequestUri.AbsolutePath);
Assert.Contains("sec_user_id=sec-user-1", handler.Request.RequestUri.Query);
Assert.Contains("a_bogus=", handler.Request.RequestUri.Query);
Assert.Equal("msToken=token-value; UIFID=ui-value", handler.Cookie);
Assert.Equal("https://www.douyin.com/user/sec-user-1", handler.Request.Headers.Referrer?.ToString());
}
[Fact]
public async Task Monitor_SendsOnlyOncePerLiveRoomAndRearmsAfterOffline()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "live.sqlite"));
db.CodeFirst.InitTables<DouyinCookie, DouyinFollowed, EmailNotificationSettings>();
db.Insertable(new DouyinCookie
{
Id = "cookie-1", MyUserId = "my-user-1", UserName = "账号",
Cookies = "msToken=value", Status = 1, StatusCode = 0
}).ExecuteCommand();
db.Insertable(new DouyinFollowed
{
Id = "follow-1", mySelfId = "my-user-1", SecUid = "sec-1", UperName = "测试博主",
UperId = "uid-1", LastSyncTime = DateTime.UtcNow
}).ExecuteCommand();
db.Insertable(new EmailNotificationSettings
{
Id = "default", Enabled = true, Host = "smtp.test", Port = 465,
FromAddress = "from@example.test", Recipients = "to@example.test"
}).ExecuteCommand();
var liveClient = new FakeLiveStatusClient
{
Result = new DouyinLiveStatusProbe
{
Status = DouyinLiveStatusState.Live, RoomId = "room-1", WebRid = "web-1", Title = "第一场"
}
};
var emailSender = new FakeEmailSender();
var settings = new EmailNotificationSettingsService(db, new EphemeralDataProtectionProvider(), emailSender);
var service = new DouyinLiveStatusService(
db,
liveClient,
new LiveEmailNotificationService(settings, emailSender));
var enabled = await service.SetMonitorAsync(new FollowLiveMonitorUpdateDto { Id = "follow-1", Enabled = true });
Assert.Equal(DouyinLiveStatusState.Live, enabled.LiveStatus);
await service.SetEmailNotificationAsync(new FollowLiveEmailUpdateDto { Id = "follow-1", Enabled = true });
Assert.Equal(1, emailSender.SendCount);
await MakeDueAsync(db, "follow-1");
await service.RefreshDueAsync();
Assert.Equal(1, emailSender.SendCount);
liveClient.Result = new DouyinLiveStatusProbe { Status = DouyinLiveStatusState.Offline };
await MakeDueAsync(db, "follow-1");
await service.RefreshDueAsync();
liveClient.Result = new DouyinLiveStatusProbe
{
Status = DouyinLiveStatusState.Live, RoomId = "room-2", WebRid = "web-2", Title = "第二场"
};
await MakeDueAsync(db, "follow-1");
await service.RefreshDueAsync();
Assert.Equal(2, emailSender.SendCount);
var follow = await db.Queryable<DouyinFollowed>().InSingleAsync("follow-1");
Assert.Equal("web:web-2", follow.LastLiveNotificationKey);
Assert.Null(follow.LastLiveNotificationError);
}
[Fact]
public async Task EmailFailure_DoesNotChangeSuccessfulLiveState()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "mail-failure.sqlite"));
db.CodeFirst.InitTables<DouyinCookie, DouyinFollowed, EmailNotificationSettings>();
db.Insertable(new DouyinCookie
{
Id = "cookie-1", MyUserId = "my-user-1", UserName = "账号",
Cookies = "msToken=value", Status = 1, StatusCode = 0
}).ExecuteCommand();
db.Insertable(new DouyinFollowed
{
Id = "follow-1", mySelfId = "my-user-1", SecUid = "sec-1", UperName = "测试博主",
UperId = "uid-1", LastSyncTime = DateTime.UtcNow, LiveMonitorEnabled = true,
LiveEmailNotificationEnabled = true
}).ExecuteCommand();
db.Insertable(new EmailNotificationSettings
{
Id = "default", Enabled = true, Host = "smtp.test", Port = 465,
FromAddress = "from@example.test", Recipients = "to@example.test"
}).ExecuteCommand();
var sender = new FakeEmailSender { Error = new IOException("smtp unavailable") };
var settings = new EmailNotificationSettingsService(db, new EphemeralDataProtectionProvider(), sender);
var service = new DouyinLiveStatusService(
db,
new FakeLiveStatusClient
{
Result = new DouyinLiveStatusProbe { Status = DouyinLiveStatusState.Live, WebRid = "web-1" }
},
new LiveEmailNotificationService(settings, sender));
await service.RefreshDueAsync();
var follow = await db.Queryable<DouyinFollowed>().InSingleAsync("follow-1");
Assert.Equal(DouyinLiveStatusState.Live, follow.LiveStatus);
Assert.Null(follow.LiveCheckError);
Assert.Contains("smtp unavailable", follow.LastLiveNotificationError);
}
[Fact]
public async Task EmailSettings_EncryptsStoredPassword()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "email-settings.sqlite"));
db.CodeFirst.InitTables<EmailNotificationSettings>();
var service = new EmailNotificationSettingsService(
db,
new EphemeralDataProtectionProvider(),
new FakeEmailSender());
var dto = await service.SaveAsync(new EmailNotificationSettingsDto
{
Enabled = true,
Host = "smtp.example.test",
Port = 465,
SecurityMode = EmailSecurityMode.SslOnConnect,
UserName = "sender@example.test",
Password = "secret-auth-code",
FromAddress = "sender@example.test",
FromName = "dysync",
Recipients = "one@example.test;two@example.test"
});
var stored = await db.Queryable<EmailNotificationSettings>().InSingleAsync("default");
Assert.True(dto.HasPassword);
Assert.NotEqual("secret-auth-code", stored.ProtectedPassword);
Assert.Equal("secret-auth-code", await service.GetPasswordAsync(stored));
Assert.Equal("one@example.test;two@example.test", stored.Recipients);
}
[Fact]
public async Task ForbiddenResponse_KeepsLastStateAndStartsAccountCooldown()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "cooldown.sqlite"));
db.CodeFirst.InitTables<DouyinCookie, DouyinFollowed, EmailNotificationSettings>();
db.Insertable(new DouyinCookie
{
Id = "cookie-1", MyUserId = "my-user-1", UserName = "账号",
Cookies = "msToken=value", Status = 1, StatusCode = 0
}).ExecuteCommand();
db.Insertable(new DouyinFollowed
{
Id = "follow-1", mySelfId = "my-user-1", SecUid = "sec-1", UperName = "测试博主",
UperId = "uid-1", LastSyncTime = DateTime.UtcNow, LiveMonitorEnabled = true,
LiveStatus = DouyinLiveStatusState.Live, LiveWebRid = "existing-room",
LiveStatusUpdatedAt = DateTime.UtcNow.AddMinutes(-6)
}).ExecuteCommand();
var liveClient = new FakeLiveStatusClient
{
Error = new DouyinLiveStatusRequestException(
"抖音直播状态请求失败(HTTP 403",
HttpStatusCode.Forbidden,
requiresAccountCooldown: true)
};
var sender = new FakeEmailSender();
var settings = new EmailNotificationSettingsService(db, new EphemeralDataProtectionProvider(), sender);
var service = new DouyinLiveStatusService(db, liveClient, new LiveEmailNotificationService(settings, sender));
await service.RefreshDueAsync();
var follow = await db.Queryable<DouyinFollowed>().InSingleAsync("follow-1");
var cookie = await db.Queryable<DouyinCookie>().InSingleAsync("cookie-1");
Assert.Equal(DouyinLiveStatusState.Live, follow.LiveStatus);
Assert.Equal("existing-room", follow.LiveWebRid);
Assert.Contains("HTTP 403", follow.LiveCheckError);
Assert.True(cookie.LiveCheckCooldownUntil > DateTime.UtcNow);
Assert.Equal(1, cookie.ConsecutiveLiveCheckFailures);
}
private static async Task MakeDueAsync(ISqlSugarClient db, string id)
{
var follow = await db.Queryable<DouyinFollowed>().InSingleAsync(id);
follow.LiveCheckedAt = DateTime.UtcNow.AddMinutes(-6);
await db.Updateable(follow).UpdateColumns(x => x.LiveCheckedAt).ExecuteCommandAsync();
}
private static SqlSugarClient CreateDatabase(string path) => new(new ConnectionConfig
{
ConnectionString = $"Data Source={path}",
DbType = DbType.Sqlite,
IsAutoCloseConnection = true,
InitKeyType = InitKeyType.Attribute
});
private sealed class FakeLiveStatusClient : IDouyinLiveStatusClient
{
public DouyinLiveStatusProbe Result { get; set; }
public Exception Error { get; set; }
public int Calls { get; private set; }
public Task<DouyinLiveStatusProbe> ProbeAsync(string secUid, string cookie, CancellationToken cancellationToken = default)
{
Calls++;
if (Error != null) throw Error;
return Task.FromResult(Result);
}
}
private sealed class FakeEmailSender : IEmailNotificationSender
{
public int SendCount { get; private set; }
public Exception Error { get; set; }
public Task SendAsync(
EmailNotificationSettings settings,
string password,
string subject,
string htmlBody,
CancellationToken cancellationToken = default)
{
SendCount++;
if (Error != null) throw Error;
return Task.CompletedTask;
}
}
private sealed class LiveProfileResponseHandler : HttpMessageHandler
{
public HttpRequestMessage Request { get; private set; }
public string Cookie { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;
Cookie = request.Headers.GetValues("Cookie").Single();
const string json = "{\"status_code\":0,\"user\":{\"live_status\":1,\"room_id_str\":\"room-live-1\",\"room_data\":\"{\\\"status\\\":2,\\\"web_rid\\\":\\\"web-live-1\\\",\\\"title\\\":\\\"正在直播\\\"}\"}}";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}
}
}