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
+81 -9
View File
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using System.Net;
using System.Text.RegularExpressions;
using dy.net.storage;
namespace dy.net.Controllers
{
@@ -25,10 +26,12 @@ namespace dy.net.Controllers
private readonly DouyinFollowService douyinFollowService;
private readonly DouyinCookieService douyinCookieService;
private readonly DouyinHttpClientService httpClientService;
private readonly WebDavSettingsService _webDavSettingsService;
private readonly VideoTaskService _videoTaskService;
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService)
public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService, WebDavSettingsService webDavSettingsService, VideoTaskService videoTaskService)
{
this.dyCookieService = dyCookieService;
this.commonService = commonService;
@@ -36,6 +39,8 @@ namespace dy.net.Controllers
this.douyinFollowService = douyinFollowService;
this.douyinCookieService = douyinCookieService;
this.httpClientService = httpClientService;
_webDavSettingsService = webDavSettingsService;
_videoTaskService = videoTaskService;
}
@@ -102,6 +107,11 @@ namespace dy.net.Controllers
{
if (cookies?.Count > 0)
{
foreach (var cookie in cookies) ClearImportedSourceHealth(cookie);
if (commonService.GetConfig()?.StorageType.IsRemote() == true)
{
foreach (var cookie in cookies) _webDavSettingsService.ApplyDefaultCookiePaths(cookie);
}
var imported = await douyinCookieService.ImportCookies(cookies);
if (imported)
Serilog.Log.Debug("抖音Cookie配置导入成功");
@@ -164,6 +174,12 @@ namespace dy.net.Controllers
if (!cookieValid)
return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie");
var result = await dyCookieService.FastResetCookie(dto.id, dto.cookie);
if (result)
{
if (!string.IsNullOrWhiteSpace(dto.id)) await _videoTaskService.ResetSourceHealthAsync(dto.id);
else
foreach (var item in await dyCookieService.GetAllAsync()) await _videoTaskService.ResetSourceHealthAsync(item.Id);
}
return result ? ApiResult.Success() : ApiResult.Fail("cookie设置失败");
}
@@ -193,8 +209,12 @@ namespace dy.net.Controllers
// 1. 基础赋值
dyUserCookies.Id = IdGener.GetLong().ToString();
var storageType = commonService.GetConfig()?.StorageType ?? StorageType.Local;
if (storageType.IsRemote())
_webDavSettingsService.ApplyDefaultCookiePaths(dyUserCookies);
// 2. 路径权限校验
var (Success, Message) = ValidatePaths(dyUserCookies);
var (Success, Message) = ValidatePaths(dyUserCookies, storageType);
if (!Success)
return ApiResult.Fail(Message);
@@ -212,8 +232,28 @@ namespace dy.net.Controllers
return saved ? ApiResult.Success() : ApiResult.Fail("添加失败");
}
private static (bool Success, string Message) ValidatePaths(DouyinCookie cookie)
private static (bool Success, string Message) ValidatePaths(DouyinCookie cookie, StorageType storageType)
{
if (storageType.IsRemote())
{
var remotePaths = new Dictionary<string, string>
{
{ "收藏 OpenList 相对路径", cookie.WebDavCollectPath },
{ "喜欢 OpenList 相对路径", cookie.WebDavFavoritePath },
{ "关注 OpenList 相对路径", cookie.WebDavFollowPath },
{ "合集 OpenList 相对路径", cookie.WebDavMixPath },
{ "短剧 OpenList 相对路径", cookie.WebDavSeriesPath }
};
foreach (var (label, path) in remotePaths)
{
if (string.IsNullOrWhiteSpace(path)) return (false, $"{label}不能为空");
try { StoragePath.NormalizeRemote(path); }
catch (ArgumentException ex) { return (false, $"{label}无效:{ex.Message}"); }
}
return (true, string.Empty);
}
var pathsToCheck = new Dictionary<string, string>
{
{ "收藏存储路径", cookie.SavePath },
@@ -262,6 +302,19 @@ namespace dy.net.Controllers
[HttpPost("update")]
public async Task<IActionResult> AddOrUpdateAsync([FromBody] DouyinCookie dyUserCookies)
{
if (dyUserCookies == null) return ApiResult.Fail("配置不能为空");
var isNewCookie = string.IsNullOrWhiteSpace(dyUserCookies.Id) || dyUserCookies.Id == "0";
var storageType = commonService.GetConfig()?.StorageType ?? StorageType.Local;
if (storageType.IsRemote())
{
if (isNewCookie)
dyUserCookies.Id = IdGener.GetLong().ToString();
_webDavSettingsService.ApplyDefaultCookiePaths(dyUserCookies);
var (success, message) = ValidatePaths(dyUserCookies, storageType);
if (!success) return ApiResult.Fail(message);
}
RemoveCookieLineString(dyUserCookies);
var checkCk = await httpClientService.CheckCookie(dyUserCookies);
@@ -271,9 +324,10 @@ namespace dy.net.Controllers
}
dyUserCookies.StatusCode = 0;
dyUserCookies.StatusMsg = "正常";
if (dyUserCookies.Id == "0")
if (isNewCookie)
{
dyUserCookies.Id = IdGener.GetLong().ToString();
if (string.IsNullOrWhiteSpace(dyUserCookies.Id) || dyUserCookies.Id == "0")
dyUserCookies.Id = IdGener.GetLong().ToString();
var result = await dyCookieService.Add(dyUserCookies);
if (result)
{
@@ -287,6 +341,7 @@ namespace dy.net.Controllers
var result = await dyCookieService.UpdateCookieAsync(dyUserCookies);
if (result)
{
await _videoTaskService.ResetSourceHealthAsync(dyUserCookies.Id);
ReStartJob();
return ApiResult.Success();
}
@@ -305,6 +360,18 @@ namespace dy.net.Controllers
}
}
private static void ClearImportedSourceHealth(DouyinCookie cookie)
{
cookie.ConsecutiveSourceForbidden = 0;
cookie.SourceCooldownUntil = null;
cookie.SourceRequiresAuthorization = false;
cookie.SourceProbePending = false;
cookie.SourceProbeInProgress = false;
cookie.LastSourceStatusCode = null;
cookie.LastSourceError = null;
cookie.SourceHealthUpdatedAt = null;
}
/// <summary>
/// 批量删除用户Cookie
/// </summary>
@@ -351,10 +418,15 @@ namespace dy.net.Controllers
//[Authorize]
public async Task<IActionResult> ExecuteJobNow()
{
var config = commonService.GetConfig();
if (config != null)
await quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
return ApiResult.Success();
try
{
var tasks = await quartzJobService.TriggerVideoJobsNowAsync();
return ApiResult.Success(new { taskIds = tasks.Select(x => x.Id).ToList(), count = tasks.Count });
}
catch (InvalidOperationException ex)
{
return ApiResult.Fail(ex.Message);
}
}
+47
View File
@@ -0,0 +1,47 @@
using dy.net.model.dto;
using dy.net.service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/email")]
[ApiController]
[Authorize]
public sealed class EmailController : ControllerBase
{
private readonly EmailNotificationSettingsService _settings;
public EmailController(EmailNotificationSettingsService settings) => _settings = settings;
[HttpGet("config")]
public async Task<IActionResult> GetConfig()
{
var settings = await _settings.GetAsync();
return ApiResult.Success(_settings.ToDto(settings));
}
[HttpPut("config")]
public async Task<IActionResult> UpdateConfig(EmailNotificationSettingsDto dto)
{
try { return ApiResult.Success(await _settings.SaveAsync(dto)); }
catch (InvalidOperationException ex) { return ApiResult.Fail(ex.Message); }
}
[HttpPost("test")]
public async Task<IActionResult> Test(EmailNotificationSettingsDto dto, CancellationToken cancellationToken)
{
try
{
var message = await _settings.TestAsync(dto, cancellationToken);
return ApiResult.Success(new { message });
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
var message = ex.GetBaseException().Message;
Serilog.Log.Warning(ex, "SMTP 测试邮件发送失败");
return ApiResult.Fail(string.IsNullOrWhiteSpace(message) ? "测试邮件发送失败" : message);
}
}
}
}
+156 -7
View File
@@ -4,6 +4,7 @@ using dy.net.service;
using dy.net.utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Serilog;
namespace dy.net.Controllers
{
@@ -14,11 +15,19 @@ namespace dy.net.Controllers
{
private readonly DouyinFollowService _douyinFollowService;
private readonly DouyinQuartzJobService _douyinQuartzJobService;
private readonly DouyinUserLookupService _douyinUserLookupService;
private readonly DouyinLiveStatusService _douyinLiveStatusService;
public FollowController(DouyinFollowService douyinFollowService, DouyinQuartzJobService douyinQuartzJobService)
public FollowController(
DouyinFollowService douyinFollowService,
DouyinQuartzJobService douyinQuartzJobService,
DouyinUserLookupService douyinUserLookupService,
DouyinLiveStatusService douyinLiveStatusService)
{
this._douyinFollowService = douyinFollowService;
_douyinQuartzJobService = douyinQuartzJobService;
_douyinUserLookupService = douyinUserLookupService;
_douyinLiveStatusService = douyinLiveStatusService;
}
@@ -52,20 +61,107 @@ namespace dy.net.Controllers
[HttpGet("sync")]
public async Task<IActionResult> SyncFollowList()
{
//后台异步
_douyinQuartzJobService.StartFollowJobOnceAsync();
await Task.Delay(1000);
return ApiResult.Success();
try
{
var started = await _douyinQuartzJobService.StartFollowJobOnceAsync();
return started
? ApiResult.Success(new { message = "关注列表更新任务已启动" })
: ApiResult.Fail("关注列表更新任务启动失败,请查看错误日志。");
}
catch (Exception ex)
{
Log.Error(ex, "手动启动关注列表更新任务失败");
return ApiResult.Fail("关注列表更新任务启动失败:" + ex.GetBaseException().Message);
}
}
[HttpPost("add")]
public async Task<IActionResult> AddFollow(DouyinFollowed followed)
{
if (followed == null)
{
return ApiResult.Fail("新增信息不能为空");
}
if (string.IsNullOrWhiteSpace(followed.mySelfId))
{
return ApiResult.Fail("请先配置抖音授权信息,抖音授权配置里面填写你的uid");
}
var res = await _douyinFollowService.AddAsync(followed);
return ApiResult.SuccOrFail(res, "", res ? "" : "添加失败,或者已存在相同secuid和uid");
if (string.IsNullOrWhiteSpace(followed.SecUid))
{
return ApiResult.Fail("博主 SecUid 不能为空");
}
if (string.IsNullOrWhiteSpace(followed.UperId))
{
return ApiResult.Fail("博主 UID 不能为空");
}
if (string.IsNullOrWhiteSpace(followed.UperName))
{
return ApiResult.Fail("博主姓名不能为空");
}
followed.mySelfId = followed.mySelfId.Trim();
followed.SecUid = followed.SecUid.Trim();
followed.UperId = followed.UperId.Trim();
followed.UperName = followed.UperName.Trim();
followed.DouyinNo = followed.DouyinNo?.Trim();
followed.SavePath = followed.SavePath?.Trim();
followed.Signature = Limit(followed.Signature, 500);
followed.UperAvatar = Limit(followed.UperAvatar, 1000);
followed.Enterprise = Limit(followed.Enterprise, 200);
followed.FullSync = followed.OpenSync && followed.FullSync;
if (followed.mySelfId.Length > 200 || followed.SecUid.Length > 500 ||
followed.UperId.Length > 100 || followed.UperName.Length > 200)
{
return ApiResult.Fail("新增信息长度超出限制,请检查 UID、SecUid 和博主姓名");
}
if (!string.IsNullOrWhiteSpace(followed.DouyinNo) && followed.DouyinNo.Length > 100)
{
return ApiResult.Fail("抖音号长度不能超过 100 个字符");
}
if (!string.IsNullOrWhiteSpace(followed.SavePath))
{
if (followed.SavePath.Length > 20)
{
return ApiResult.Fail("保存文件夹名称最长 20 个字符");
}
if (!DouyinFileNameHelper.IsValidWithoutSpecialChars(followed.SavePath))
{
return ApiResult.Fail("保存文件夹名称只能包含字母、数字或中文");
}
}
var result = await _douyinFollowService.TryAddAsync(followed);
return result switch
{
AddFollowResult.Added => ApiResult.Success("新增非关注博主成功"),
AddFollowResult.AlreadyExists => ApiResult.Fail("该博主已存在,无需重复添加"),
_ => ApiResult.Fail("添加失败,请稍后重试")
};
}
/// <summary>
/// 使用展示在抖音资料页上的抖音号查找博主。结果需要用户确认后再调用 add。
/// </summary>
[HttpPost("resolve-by-douyin-no")]
public async Task<IActionResult> ResolveByDouyinNo(
DouyinFollowLookupRequest request,
CancellationToken cancellationToken)
{
try
{
var result = await _douyinUserLookupService.ResolveAsync(request, cancellationToken);
return ApiResult.Success(result);
}
catch (InvalidOperationException ex)
{
return ApiResult.Fail(ex.Message);
}
}
private static string Limit(string value, int maxLength)
{
value = value?.Trim();
return value != null && value.Length > maxLength ? value[..maxLength] : value;
}
/// <summary>
@@ -107,6 +203,59 @@ namespace dy.net.Controllers
return await OpenOrCloseSync(dto);
}
[HttpPost("live-monitor")]
public async Task<IActionResult> UpdateLiveMonitor(
FollowLiveMonitorUpdateDto dto,
CancellationToken cancellationToken)
{
try
{
return ApiResult.Success(await _douyinLiveStatusService.SetMonitorAsync(dto, cancellationToken));
}
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
[HttpPost("live-status/refresh")]
public async Task<IActionResult> RefreshLiveStatus(
FollowLiveStatusRefreshDto dto,
CancellationToken cancellationToken)
{
try
{
if (dto == null || string.IsNullOrWhiteSpace(dto.Id))
return ApiResult.Fail("博主记录不能为空");
return ApiResult.Success(await _douyinLiveStatusService.RefreshAsync(dto.Id, cancellationToken));
}
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
[HttpPost("live-status/query")]
public async Task<IActionResult> QueryLiveStatus(FollowLiveStatusQueryDto dto)
{
if (dto?.Ids == null || dto.Ids.Count == 0)
return ApiResult.Success(Array.Empty<FollowLiveStatusDto>());
return ApiResult.Success(await _douyinLiveStatusService.QueryAsync(dto.Ids));
}
[HttpPost("live-email")]
public async Task<IActionResult> UpdateLiveEmail(FollowLiveEmailUpdateDto dto)
{
try
{
return ApiResult.Success(await _douyinLiveStatusService.SetEmailNotificationAsync(dto));
}
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
/// <summary>
/// 删除关注对象
/// </summary>
@@ -0,0 +1,75 @@
using dy.net.model.dto;
using dy.net.service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/storage/openlist/directory-repairs")]
[ApiController]
[Authorize]
public sealed class OpenListDirectoryRepairsController : ControllerBase
{
private readonly OpenListDirectoryRepairService _service;
public OpenListDirectoryRepairsController(OpenListDirectoryRepairService service) => _service = service;
[HttpPost("preflight")]
public Task<IActionResult> Preflight(
[FromBody] OpenListDirectoryRepairPreflightRequest request,
CancellationToken cancellationToken) => ExecuteAsync(async () =>
ApiResult.Success(await _service.PreflightAsync(request, cancellationToken)));
[HttpPost]
public Task<IActionResult> Create(
[FromBody] CreateOpenListDirectoryRepairRequest request,
CancellationToken cancellationToken) => ExecuteAsync(async () =>
ApiResult.Success(await _service.CreateAsync(request, cancellationToken)));
[HttpGet("latest")]
public Task<IActionResult> Latest() => ExecuteAsync(async () =>
ApiResult.Success(await _service.GetLatestAsync()));
[HttpGet("{id}")]
public Task<IActionResult> Get(string id) => ExecuteAsync(async () =>
ApiResult.Success(await _service.GetAsync(id)));
[HttpGet("{id}/items")]
public Task<IActionResult> Items(string id, [FromQuery] OpenListDirectoryRepairItemPageRequest request) =>
ExecuteAsync(async () => ApiResult.Success(await _service.GetItemsAsync(id, request)));
[HttpPost("{id}/confirm-cleanup")]
public Task<IActionResult> Confirm(
string id,
[FromBody] ConfirmOpenListDirectoryRepairRequest request) => ExecuteAsync(async () =>
{
await _service.ConfirmCleanupAsync(id, request);
return ApiResult.Success("清理任务已提交");
});
[HttpPost("{id}/cancel")]
public Task<IActionResult> Cancel(string id) => ExecuteActionAsync(() => _service.CancelAsync(id));
[HttpPost("{id}/resume")]
public Task<IActionResult> Resume(string id) => ExecuteActionAsync(() => _service.ResumeAsync(id));
[HttpPost("{id}/retry-failed")]
public Task<IActionResult> RetryFailed(string id) => ExecuteActionAsync(() => _service.RetryFailedAsync(id));
private Task<IActionResult> ExecuteActionAsync(Func<Task> action) => ExecuteAsync(async () =>
{
await action();
return ApiResult.Success("操作已提交");
});
private static async Task<IActionResult> ExecuteAsync(Func<Task<IActionResult>> action)
{
try { return await action(); }
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException
or HttpRequestException or TimeoutException)
{
return ApiResult.Fail(ex.GetBaseException().Message);
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
using dy.net.model.dto;
using dy.net.service;
using dy.net.storage;
using jzc.http.lib;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class StorageController : ControllerBase
{
private readonly DouyinCommonService _commonService;
private readonly OpenListSettingsService _openListSettingsService;
private readonly OpenListMediaStorage _openListStorage;
private readonly DouyinQuartzJobService _quartzJobService;
private readonly DouyinVideoService _videoService;
public StorageController(
DouyinCommonService commonService,
OpenListSettingsService openListSettingsService,
OpenListMediaStorage openListStorage,
DouyinQuartzJobService quartzJobService,
DouyinVideoService videoService)
{
_commonService = commonService;
_openListSettingsService = openListSettingsService;
_openListStorage = openListStorage;
_quartzJobService = quartzJobService;
_videoService = videoService;
}
[HttpGet("inventory")]
public async Task<IActionResult> GetInventory()
{
var type = _commonService.GetConfig()?.StorageType ?? StorageType.Local;
return ApiResult.Success(await _videoService.GetStorageInventoryAsync(type));
}
[HttpGet("config")]
public async Task<IActionResult> GetConfig()
{
var type = _commonService.GetConfig()?.StorageType ?? StorageType.Local;
var inventory = await _videoService.GetStorageInventoryAsync(type);
var saved = await _openListSettingsService.HasSavedSettingsAsync();
var dto = await _openListSettingsService.ToDtoAsync(type);
dto.RequiresCutover = type == StorageType.WebDav || inventory.WebDavRecordCount > 0;
dto.LegacyWebDavRecordCount = inventory.WebDavRecordCount;
dto.SuggestedFromLegacy = !saved;
return ApiResult.Success(dto);
}
[HttpPost("test")]
public async Task<IActionResult> Test(
[FromBody] StorageTestRequest request, CancellationToken cancellationToken)
{
if (request == null) return ApiResult.Fail("测试请求不能为空");
if (request.StorageType != StorageType.OpenList)
return ApiResult.Fail("新远端存储只支持 OpenList 原生 API。");
var candidate = await _openListSettingsService.BuildCandidateAsync(
new OpenListTestRequest
{
Endpoint = request.Endpoint,
BasePath = request.BasePath,
LocalStagingPath = request.LocalStagingPath,
SourcePath = request.SourcePath,
UserName = request.UserName,
Password = request.Password
});
var result = await _openListStorage.ProbeAsync(candidate, cancellationToken);
return result.Success
? ApiResult.Success(new { message = result.Message })
: ApiResult.Fail(result.Message);
}
[HttpPost("openlist/directories")]
public async Task<IActionResult> ListOpenListDirectories(
[FromBody] OpenListDirectoryRequest request,
CancellationToken cancellationToken)
{
if (request == null) return ApiResult.Fail("目录请求不能为空");
try
{
var candidate = await _openListSettingsService.BuildCandidateAsync(request);
return ApiResult.Success(await _openListStorage.ListDirectoriesAsync(
candidate, request.Path, cancellationToken));
}
catch (Exception ex) when (ex is InvalidOperationException or HttpRequestException)
{ return ApiResult.Fail(ex.GetBaseException().Message); }
}
[HttpPut("config")]
public async Task<IActionResult> Update(
[FromBody] StorageSettingsDto dto, CancellationToken cancellationToken)
{
if (dto == null) return ApiResult.Fail("配置不能为空");
if (dto.StorageType == StorageType.WebDav)
return ApiResult.Fail("WebDAV 已停止作为新存储目标,请改用 OpenList 原生 API。");
if (dto.StorageType == StorageType.OpenList)
{
var candidate = await _openListSettingsService.BuildCandidateAsync(
new OpenListTestRequest
{
Endpoint = dto.Endpoint,
BasePath = dto.BasePath,
LocalStagingPath = dto.LocalStagingPath,
SourcePath = dto.SourcePath,
UserName = dto.UserName,
Password = dto.Password
});
var result = await _openListStorage.ProbeAsync(candidate, cancellationToken);
if (!result.Success) return ApiResult.Fail("无法启用 OpenList" + result.Message);
await _openListSettingsService.SaveAsync(candidate, true, result.Message);
await _openListSettingsService.InitializeCookiePathsAsync();
var config = _commonService.GetConfig();
if (config == null) return ApiResult.Fail("系统配置不存在");
config.StorageType = dto.StorageType;
if (!await _commonService.UpdateConfig(config))
return ApiResult.Fail("存储模式保存失败");
await _quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
var response = await _openListSettingsService.ToDtoAsync(config.StorageType);
var inventory = await _videoService.GetStorageInventoryAsync(config.StorageType);
response.RequiresCutover = inventory.WebDavRecordCount > 0;
response.LegacyWebDavRecordCount = inventory.WebDavRecordCount;
return ApiResult.Success(response);
}
if (dto.StorageType == StorageType.Local)
{
var config = _commonService.GetConfig();
if (config == null) return ApiResult.Fail("系统配置不存在");
config.StorageType = StorageType.Local;
if (!await _commonService.UpdateConfig(config))
return ApiResult.Fail("存储模式保存失败");
await _quartzJobService.InitOrReStartAllJobs(config.Cron.ToString());
var response = await _openListSettingsService.ToDtoAsync(config.StorageType);
var inventory = await _videoService.GetStorageInventoryAsync(config.StorageType);
response.RequiresCutover = inventory.WebDavRecordCount > 0;
response.LegacyWebDavRecordCount = inventory.WebDavRecordCount;
return ApiResult.Success(response);
}
return ApiResult.Fail("不支持的存储类型");
}
}
}
@@ -0,0 +1,88 @@
using dy.net.model.dto;
using dy.net.service;
using jzc.http.lib;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/storage/migrations")]
[ApiController]
[Authorize]
public class StorageMigrationsController : ControllerBase
{
private readonly StorageMigrationService _service;
public StorageMigrationsController(StorageMigrationService service) => _service = service;
[HttpPost("preflight")]
public async Task<IActionResult> Preflight([FromBody] StorageMigrationPreflightRequest request, CancellationToken cancellationToken) =>
ApiResult.Success(await _service.PreflightAsync(request?.VerifyCapabilities ?? true, cancellationToken));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateStorageMigrationRequest request, CancellationToken cancellationToken) =>
await ExecuteAsync(async () => ApiResult.Success(await _service.CreateAsync(request, cancellationToken)));
[HttpGet("latest")]
public async Task<IActionResult> Latest() => ApiResult.Success(await _service.GetLatestAsync());
[HttpGet("failed-records/preview")]
public async Task<IActionResult> FailedRecordsPreview() =>
ApiResult.Success(await _service.PreviewFailedRecordRemovalAsync());
[HttpPost("failed-records/remove")]
public async Task<IActionResult> RemoveFailedRecords([FromBody] RemoveFailedMigrationRecordsRequest request) =>
await ExecuteAsync(async () => ApiResult.Success(await _service.RemoveFailedRecordsAsync(request)));
[HttpPost("history/archive")]
public async Task<IActionResult> ArchiveHistory() =>
await ExecuteAsync(async () => ApiResult.Success(await _service.ArchiveAllSettledAsync()));
[HttpGet("{id}")]
public async Task<IActionResult> Get(string id) =>
await ExecuteAsync(async () => ApiResult.Success(await _service.GetAsync(id)));
[HttpGet("{id}/items")]
public async Task<IActionResult> Items(string id, [FromQuery] StorageMigrationItemPageRequest request) =>
await ExecuteAsync(async () => ApiResult.Success(await _service.GetItemsAsync(id, request)));
[HttpPost("{id}/pause")]
public Task<IActionResult> Pause(string id) => ExecuteActionAsync(() => _service.PauseAsync(id));
[HttpPost("{id}/resume")]
public Task<IActionResult> Resume(string id) => ExecuteActionAsync(() => _service.ResumeAsync(id));
[HttpPost("{id}/cancel")]
public Task<IActionResult> Cancel(string id) => ExecuteActionAsync(() => _service.CancelAsync(id));
[HttpPost("{id}/retry-failed")]
public Task<IActionResult> RetryFailed(string id) => ExecuteActionAsync(() => _service.RetryFailedAsync(id));
[HttpPost("{id}/cleanup")]
public Task<IActionResult> Cleanup(string id, CancellationToken cancellationToken) =>
ExecuteActionAsync(() => _service.CleanupAsync(id, cancellationToken));
[HttpPost("{id}/rollback")]
public Task<IActionResult> Rollback(string id, CancellationToken cancellationToken) =>
ExecuteActionAsync(() => _service.RollbackAsync(id, cancellationToken));
[HttpPost("{id}/archive")]
public Task<IActionResult> Archive(string id) => ExecuteActionAsync(() => _service.ArchiveAsync(id));
private async Task<IActionResult> ExecuteActionAsync(Func<Task> action) =>
await ExecuteAsync(async () =>
{
await action();
return ApiResult.Success("操作已提交");
});
private static async Task<IActionResult> ExecuteAsync(Func<Task<IActionResult>> action)
{
try { return await action(); }
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
using dy.net.model.dto;
using dy.net.service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/tasks")]
[ApiController]
[Authorize]
public class TasksController : ControllerBase
{
private readonly VideoTaskService _tasks;
private readonly DouyinQuartzJobService _quartz;
private readonly DouyinVideoService _videos;
public TasksController(VideoTaskService tasks, DouyinQuartzJobService quartz, DouyinVideoService videos)
{
_tasks = tasks;
_quartz = quartz;
_videos = videos;
}
[HttpPost("sync")]
public async Task<IActionResult> StartSync([FromQuery] VideoTypeEnum? videoType = null) => await ExecuteAsync(async () =>
{
var tasks = await _quartz.TriggerVideoJobsNowAsync(videoType);
return ApiResult.Success(new { taskIds = tasks.Select(x => x.Id).ToList(), count = tasks.Count });
});
[HttpGet]
public async Task<IActionResult> List([FromQuery] VideoTaskPageRequest request) =>
ApiResult.Success(await _tasks.GetTasksAsync(request));
[HttpGet("summary")]
public async Task<IActionResult> Summary() => ApiResult.Success(await _tasks.GetSummaryAsync());
[HttpGet("{type}/{id}")]
public async Task<IActionResult> Detail(VideoTaskType type, string id) =>
await ExecuteAsync(async () => ApiResult.Success(await _tasks.GetTaskAsync(type, id)));
[HttpGet("{type}/{id}/items")]
public async Task<IActionResult> Items(VideoTaskType type, string id, [FromQuery] VideoTaskItemPageRequest request) =>
await ExecuteAsync(async () => ApiResult.Success(await _tasks.GetItemsAsync(type, id, request)));
[HttpPost("{type}/{id}/retry-failed")]
public async Task<IActionResult> RetryFailed(VideoTaskType type, string id) =>
await ExecuteAsync(async () =>
{
await _tasks.RetryFailedAsync(type, id);
return ApiResult.Success("重试任务已提交");
});
[HttpPost("{type}/{id}/items/{itemId}/retry")]
public async Task<IActionResult> RetryItem(VideoTaskType type, string id, string itemId) =>
await ExecuteAsync(async () =>
{
await _tasks.RetryItemAsync(type, id, itemId);
return ApiResult.Success("重试任务已提交");
});
[HttpPost("{type}/{id}/items/{itemId}/retry-cleanup")]
public async Task<IActionResult> RetryCleanup(VideoTaskType type, string id, string itemId) =>
await ExecuteAsync(async () =>
{
if (type != VideoTaskType.Sync)
throw new InvalidOperationException("仅普通同步任务支持重试旧本地文件清理。");
var deleted = await _videos.RetryTaskItemLocalCleanupAsync(id, itemId);
return ApiResult.Success(new
{
deletedCount = deleted,
message = deleted > 0 ? $"已清理 {deleted} 个旧本地文件。" : "旧本地文件已不存在或仍被共享引用,清理状态已确认。"
});
});
[HttpPost("{type}/{id}/actions/{action}")]
public async Task<IActionResult> Action(VideoTaskType type, string id, string action, CancellationToken cancellationToken) =>
await ExecuteAsync(async () =>
{
await _tasks.ExecuteTaskActionAsync(type, id, action, cancellationToken);
return ApiResult.Success("操作已提交");
});
[HttpPost("storage-health/probe")]
public async Task<IActionResult> Probe(CancellationToken cancellationToken)
{
var result = await _tasks.ProbeStorageAsync(cancellationToken);
return result.Success ? ApiResult.Success(new { message = result.Message }) : ApiResult.Fail(result.Message);
}
private static async Task<IActionResult> ExecuteAsync(Func<Task<IActionResult>> action)
{
try { return await action(); }
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
}
}
+42 -1
View File
@@ -4,6 +4,7 @@ using dy.net.service;
using dy.net.utils;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using dy.net.storage;
namespace dy.net.Controllers
{
@@ -13,11 +14,13 @@ namespace dy.net.Controllers
{
private readonly DouyinVideoService douyinVideoService;
private readonly DouyinCommonService douyinCommonService;
private readonly MediaStorageRouter _storageRouter;
public VideoController(DouyinVideoService dyCollectVideoService, DouyinCommonService douyinCommonService)
public VideoController(DouyinVideoService dyCollectVideoService, DouyinCommonService douyinCommonService, MediaStorageRouter storageRouter)
{
this.douyinVideoService = dyCollectVideoService;
this.douyinCommonService = douyinCommonService;
_storageRouter = storageRouter;
}
/// <summary>
/// 分页查询收藏视频
@@ -88,6 +91,9 @@ namespace dy.net.Controllers
return ApiResult.Fail("视频信息不能为空");
}
if (video.StorageType.IsRemote())
return await PlayRemoteVideoAsync(video);
// 1. 获取完整物理路径并校验
string videoFullPath = video.VideoSavePath;
if (string.IsNullOrWhiteSpace(videoFullPath))
@@ -153,6 +159,34 @@ namespace dy.net.Controllers
}
}
private async Task<IActionResult> PlayRemoteVideoAsync(DouyinVideo video)
{
if (string.IsNullOrWhiteSpace(video.VideoSavePath)) return ApiResult.Fail("远端视频路径不能为空");
long? from = null;
long? to = null;
var range = Request.Headers.Range.ToString();
if (!string.IsNullOrWhiteSpace(range) && range.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
{
var parts = range[6..].Split('-', 2);
if (long.TryParse(parts[0], out var parsedFrom)) from = parsedFrom;
if (parts.Length > 1 && long.TryParse(parts[1], out var parsedTo)) to = parsedTo;
}
await using var remote = await _storageRouter.Resolve(video.StorageType)
.OpenReadAsync(video.VideoSavePath, from, to, HttpContext.RequestAborted);
Response.StatusCode = remote.StatusCode;
Response.ContentType = remote.ContentType ?? GetContentType(video.VideoSavePath);
Response.Headers.AcceptRanges = "bytes";
if (remote.ContentLength.HasValue) Response.ContentLength = remote.ContentLength.Value;
if (!string.IsNullOrWhiteSpace(remote.ContentRange)) Response.Headers.ContentRange = remote.ContentRange;
await remote.Stream.CopyToAsync(Response.Body, 81920, HttpContext.RequestAborted);
await Response.Body.FlushAsync(HttpContext.RequestAborted);
return new EmptyResult();
}
/// <summary>
/// 处理分片请求(Range),异步安全处理流
/// </summary>
@@ -275,6 +309,7 @@ namespace dy.net.Controllers
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize]
[HttpPost("redown")]
public async Task<IActionResult> ReDownload(ReDownViedoDto dto)
{
@@ -301,6 +336,7 @@ namespace dy.net.Controllers
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize]
[HttpPost("vdelete/batch")]
public async Task<IActionResult> BathRealDelete(ReDownViedoDto dto)
{
@@ -321,6 +357,7 @@ namespace dy.net.Controllers
/// </summary>
/// <param name="vid"></param>
/// <returns></returns>
[Authorize]
[HttpGet("vdelete/{vid}")]
public async Task<IActionResult> DeleteVideo([FromRoute] string vid)
{
@@ -346,6 +383,7 @@ namespace dy.net.Controllers
/// 查询已删除视频列表
/// </summary>
/// <returns></returns>
[Authorize]
[HttpGet("vdelete/get")]
public async Task<IActionResult> GetDeleteVideo()
{
@@ -356,6 +394,7 @@ namespace dy.net.Controllers
/// </summary>
/// <param name="uperUid"></param>
/// <returns></returns>
[Authorize]
[HttpGet("vdelete/byauthor/{uperUid}")]
public async Task<IActionResult> DeleteByAuthor([FromRoute] string uperUid)
{
@@ -396,6 +435,7 @@ namespace dy.net.Controllers
/// 删除无效视频记录
/// </summary>
/// <returns></returns>
[Authorize]
[HttpGet("removeInvalid")]
public async Task<IActionResult> RemoveInvalidVideo()
{
@@ -463,6 +503,7 @@ namespace dy.net.Controllers
}
}
[Authorize]
[HttpGet("/Move")]
public async Task<IActionResult> Move()
{
+31
View File
@@ -0,0 +1,31 @@
using dy.net.model.dto;
using dy.net.service;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace dy.net.Controllers
{
[Route("api/video/exclusions")]
[ApiController]
[Authorize]
public class VideoExclusionsController : ControllerBase
{
private readonly VideoExclusionService _service;
public VideoExclusionsController(VideoExclusionService service) => _service = service;
[HttpGet]
public async Task<IActionResult> List([FromQuery] VideoExclusionPageRequest request) =>
ApiResult.Success(await _service.GetPageAsync(request));
[HttpPost("unexclude")]
public async Task<IActionResult> Unexclude([FromBody] UnexcludeVideosRequest request)
{
try { return ApiResult.Success(await _service.UnexcludeAsync(request)); }
catch (Exception ex) when (ex is InvalidOperationException or KeyNotFoundException)
{
return ApiResult.Fail(ex.Message);
}
}
}
}