diff --git a/.dockerignore b/.dockerignore index 12de103..c1b0b47 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,67 +1,15 @@ -# Git -.git -.gitignore -.gitattributes - -# IDE -.vs -.vscode +**/node_modules +**/.git +logs +videos +db +bin +obj +.claude +.playwright-mcp .idea -*.swp -*.swo -*~ - -# Build outputs -bin/ -obj/ -app/dist/ -app/node_modules/ -app/.vite/ - -# Logs and temp -logs/ -*.log -*.tmp - -# Local databases (mounted via volume in prod) -db/*.sqlite -db/*.db - -# Video storage (mounted via volume) -videos/ -collect/ -favorite/ -uper/ - -# Docs (Chinese filenames covered by *.md) -*.md -docs/ -README*.md - -# Tests -test/ -tests/ -*.test.* -*.spec.* - -# Env -.env -.env.local -.env.*.local - -# macOS +.codepilot-uploads +*.tar.gz .DS_Store -._* - -# Windows -Thumbs.db -ehthumbs.db -Desktop.ini - -# Linux -.directory - -# Misc -*.bak -*.backup -*.old +**/.DS_Store +smoke-*.png diff --git a/.gitignore b/.gitignore index 3c1e0f1..e40e3fb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,13 +18,6 @@ videos/ # SQLite 数据库及日志(仅忽略实例数据,不忽略迁移脚本) /db/*.sqlite -/db/*.db-journal -/db/*.db-wal -/db/*.db-shm - -# 飞牛 NAS 相关(当前只部署群晖,不入库) -*feiniu* - -# 手动部署脚本(与 Gitea CI 流程重复,本地保留即可,不入库以免与 CI 冲突) -/deploy.sh -/docker-compose.yml +/appsettings.json +/Properties/PublishProfiles/fn.pubxml.user +/Properties/PublishProfiles/*.user diff --git a/Appsettings.cs b/Appsettings.cs index c61c438..3333ef1 100644 --- a/Appsettings.cs +++ b/Appsettings.cs @@ -1,8 +1,4 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.Json; -using System; -using System.Collections.Generic; -using System.Linq; +using Microsoft.Extensions.Configuration.Json; namespace dy.net { diff --git a/Controllers/AuthController.cs b/Controllers/AuthController.cs index ccd5ae7..c54a07d 100644 --- a/Controllers/AuthController.cs +++ b/Controllers/AuthController.cs @@ -1,13 +1,13 @@ using ClockSnowFlake; +using dy.net.model.dto; +using dy.net.service; +using dy.net.utils; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; -using dy.net.service; -using dy.net.utils; -using dy.net.dto; namespace dy.net.Controllers { @@ -17,10 +17,10 @@ namespace dy.net.Controllers { private readonly IWebHostEnvironment webHostEnvironment; - private readonly UserService _userService; - public AuthController(UserService userService, IWebHostEnvironment webHostEnvironment ) + private readonly AdminUserService _userService; + public AuthController(AdminUserService userService, IWebHostEnvironment webHostEnvironment) { - _userService=userService; + _userService = userService; this.webHostEnvironment = webHostEnvironment; } @@ -35,66 +35,21 @@ namespace dy.net.Controllers public async Task UpdatePwd(UpdatePwdRequest user) { var (code, erro) = await _userService.UpdatePwd(user); - return Ok(new { code, erro }); + return ApiResult.Success("", erro); } - - [Authorize] + /// + /// 获取头像 + /// + /// [HttpGet] public async Task GetUserAvatar() { var user = await _userService.GetUser(); - return Ok(new { code = 0, error = "", data = new { user?.Avatar, user?.Id,user?.UserName } }); + return ApiResult.Success(new { user?.Avatar, user?.Id, user?.UserName }); } - /// - /// 修改用户头像 - /// - /// - /// - [Authorize] - [HttpPost] - public async Task UpdateUserAvatar(IFormFile file) - { - if (file != null && file.Length > 0) - { - long maxFileSize = 5 * 1024 * 1024; // 限制文件大小为5MB - if (file.Length > maxFileSize) - { - return Ok(new { code = -1, erro = "文件最大只能上传5M" }); - } - var fileName = $"{IdGener.GetGuid()}_{file.FileName}"; - var filePath = webHostEnvironment.IsProduction() ? - Path.Combine(Md5Util.UPLOAD_PATH_PRO, fileName) : Path.Combine(Md5Util.UPLOAD_PATH_DEV, fileName); - using (var stream = new FileStream(filePath, FileMode.Create)) - { - await file.CopyToAsync(stream); - - try - { - // 问了节约空间,删除文件夹下的所有文件 - string[] files = Directory.GetFiles(webHostEnvironment.IsProduction() ? Md5Util.UPLOAD_PATH_PRO : Md5Util.UPLOAD_PATH_DEV); - foreach (string mfile in files) - { - if (!mfile.Contains(fileName)) - System.IO.File.Delete(mfile); - } - } - catch (Exception ex) - { - Serilog.Log.Error($"delete file error ,{ex.Message}"); - } - var update = await _userService.UpdateAvatar( fileName); - - return Ok(new { code = update ? 0 : -1, erro = update ? "" : "上传失败", data = update ? fileName : "" }); - } - } - else - { - return Ok(new { code = -1, erro = "空文件" }); - } - } @@ -108,7 +63,7 @@ namespace dy.net.Controllers { if (loginUserInfo == null) { - return Ok(new { code = -1, erro = "参数不能为空" }); + return ApiResult.Fail("参数不能为空"); } else { @@ -116,7 +71,7 @@ namespace dy.net.Controllers if (user == null) { - return Ok(new { code = -1, erro = "用户名或密码不正确" }); + return ApiResult.Fail("用户名或密码不正确"); } else { @@ -124,30 +79,29 @@ namespace dy.net.Controllers { var tokenString = GenerateJwtToken(user.UserName); - - return Ok(new { code = 0, erro = "", token = tokenString, expires = 24 * 60 * 60 * 1000 }); + return Ok(new { code = 0, erro = "", token = tokenString, expires = 24 * 60 * 60 * 1000, data = user.UserName }); } else { - return Ok(new { code = -1, erro = "用户名或密码不正确" }); + return ApiResult.Fail("用户名或密码不正确"); } } } } - private string GenerateJwtToken(string username) + private static string GenerateJwtToken(string username) { var claims = new[] { - new Claim(ClaimTypes.Name, username), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) - }; + new Claim(ClaimTypes.Name, username), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; var k = Md5Util.JWT_TOKEN_KEY; var key = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(k)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - var expires = DateTime.Now.AddDays(1); + var expires = DateTime.Now.AddDays(7); var token = new JwtSecurityToken( issuer: IdGener.GetLong().ToString(), diff --git a/Controllers/CateController.cs b/Controllers/CateController.cs new file mode 100644 index 0000000..56dddd0 --- /dev/null +++ b/Controllers/CateController.cs @@ -0,0 +1,67 @@ +using dy.net.model.dto; +using dy.net.service; +using dy.net.utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace dy.net.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class CateController : ControllerBase + { + private readonly DouyinCollectCateService _douyinCollectCateService; + private readonly DouyinQuartzJobService _douyinQuartzJobService; + + public CateController(DouyinCollectCateService douyinCollectCateService, DouyinQuartzJobService douyinQuartzJobService) + { + this._douyinCollectCateService = douyinCollectCateService; + _douyinQuartzJobService = douyinQuartzJobService; + } + + + + /// + /// 分页查询 + /// + /// 分页结果 + [HttpPost("paged")] + public async Task GetPagedAsync( + DouyinCollectCateRequestDto dto) + { + if (string.IsNullOrWhiteSpace(dto.cookieId)) + { + return ApiResult.Success(new { }); + } + var (list, totalCount) = await _douyinCollectCateService.GetPagedAsync(dto); + + return ApiResult.Success(new + { + data = list, + total = totalCount, + pageIndex = dto.PageIndex, + pageSize = dto.PageSize + }); + } + + /// + /// 修改关注同步状态 + /// + /// + /// + [HttpPost("BatchSave")] + public async Task BatchSave(List dto) + { + + if (dto.Any(x => !DouyinFileNameHelper.IsValidWithoutSpecialChars(x.SaveFolder))) + { + return ApiResult.Fail("有部分文件名不符合要求,请检查"); + } + + var result = await _douyinCollectCateService.BatchSwitchSync(dto); + return ApiResult.SuccOrFail(result, result); + } + + } +} diff --git a/Controllers/ConfigController.cs b/Controllers/ConfigController.cs index 3e0587f..05888f9 100644 --- a/Controllers/ConfigController.cs +++ b/Controllers/ConfigController.cs @@ -1,110 +1,306 @@ using ClockSnowFlake; -using dy.net.dto; -using dy.net.model; +using dy.net.extension; +using dy.net.model.dto; +using dy.net.model.entity; using dy.net.service; +using dy.net.utils; +using jzc.http.lib; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; -using static Dm.net.buffer.ByteArrayBuffer; +using System.Net; +using System.Text.RegularExpressions; namespace dy.net.Controllers { [Route("api/[controller]")] [ApiController] + [Authorize] public class ConfigController : ControllerBase { - private readonly DyCookieService dyCookieService; + private readonly DouyinCookieService dyCookieService; - private readonly CommonService commonService; - private readonly QuartzJobService quartzJobService; - private readonly DyQrCodeService _qrCodeService; + private readonly DouyinCommonService commonService; + private readonly DouyinQuartzJobService quartzJobService; + private readonly DouyinFollowService douyinFollowService; + private readonly DouyinCookieService douyinCookieService; + private readonly DouyinHttpClientService httpClientService; - public ConfigController(DyCookieService dyCookieService, CommonService commonService, QuartzJobService quartzJobService, DyQrCodeService qrCodeService) + public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService, DouyinHttpClientService httpClientService) { this.dyCookieService = dyCookieService; this.commonService = commonService; this.quartzJobService = quartzJobService; - _qrCodeService = qrCodeService; + this.douyinFollowService = douyinFollowService; + this.douyinCookieService = douyinCookieService; + this.httpClientService = httpClientService; } + /// + /// 导出配置 + /// + /// + [HttpGet("exportConf")] + public async Task ExportConf() + { + + // 1. 组装导出数据 + var dto = new AppConfigImportDto() + { + follows = await douyinFollowService.GetHandFollows(), + conf = commonService.GetConfig(), + cookies = await douyinCookieService.GetAllAsync() + }; + + return ApiResult.Success(dto); + + } + [HttpPost("importConf")] + public async Task ImportConf(AppConfigImportDto dto) + { + if (dto == null) + return ApiResult.Fail("json数据为空"); + + await HandleFollowsImport(dto.follows); + await HandleConfigImport(dto.conf); + await HandleCookiesImport(dto.cookies); + + return ApiResult.Success(); + } + + private async Task HandleFollowsImport(List follows) + { + if (follows?.Count > 0) + { + var added = await douyinFollowService.AddHandFollows(follows); + if (added) + Serilog.Log.Debug("关注列表导入成功"); + } + } + + private async Task HandleConfigImport(AppConfig conf) + { + if (conf == null) return; + + if (conf.BatchCount > 30) + { + Serilog.Log.Debug("对不起,为了项目能长久稳定运行,还是最大不要超过30吧。。。"); + conf.BatchCount = 30; + } + + var updated = await commonService.UpdateConfig(conf); + if (updated) + Serilog.Log.Debug("系统配置导入成功"); + } + + private async Task HandleCookiesImport(List cookies) + { + if (cookies?.Count > 0) + { + var imported = await douyinCookieService.ImportCookies(cookies); + if (imported) + Serilog.Log.Debug("抖音Cookie配置导入成功"); + } + } /// /// 分页查询 /// - /// 分页结果(视频列表和总数) + /// 分页结果 [HttpPost("paged")] public async Task GetPagedAsync( PageRequestDto dto) { var (list, totalCount) = await dyCookieService.GetPagedAsync(dto.PageIndex, dto.PageSize); - return Ok(new + + return ApiResult.Success(new { - code = 0, - data = new - { - data = list, - total=totalCount, - pageIndex= dto.PageIndex, - pageSize= dto.PageSize - } - } - ); - } - /// - /// 新增用户Cookie - /// - [HttpPost("add")] - public async Task AddAsync([FromBody] DyUserCookies dyUserCookies) - { - if(dyUserCookies.UpSecUserIdsJson!=null) - { - dyUserCookies.UpSecUserIds = JsonConvert.SerializeObject( dyUserCookies.UpSecUserIdsJson); - } - var result = await dyCookieService.Add(dyUserCookies); - if (result) - { - await ReStartJob(); - return Ok(new { code = 0 }); - } - return BadRequest(new { code=-1, message = "添加失败" }); + data = list, + total = totalCount, + pageIndex = dto.PageIndex, + pageSize = dto.PageSize + }); } + + /// + /// 查询所有用户Cookie + /// + /// + [HttpGet("list")] + public async Task GetAllList() + { + var follows = await douyinFollowService.GetGroupByCookieAsync(); + return ApiResult.Success(follows); + } + + /// + /// 是否已经初始化了 + /// + /// + [HttpGet("isInit")] + [AllowAnonymous] + public async Task IsInit() + { + var init = await dyCookieService.IsInit(); + return ApiResult.Success(init); + } + + + /// + /// 配合工具自动设置cookie + /// + /// + /// + [HttpPost("FastResetCookie")] + [AllowAnonymous] + public async Task FastResetCookie([FromBody] DouyinCookieResetDto dto) + { + var cookieValid = await httpClientService.CheckCookie(new DouyinCookie { Cookies = dto.cookie }); + if (!cookieValid) + return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie"); + var result = await dyCookieService.FastResetCookie(dto.id, dto.cookie); + + return result ? ApiResult.Success() : ApiResult.Fail("cookie设置失败"); + } + + /// + /// 配合工具自动设置cookie,获取当前设置的所有Cookie + /// + /// + [AllowAnonymous] + [HttpGet("Cookies")] + public async Task GetAllCookies() + { + var cookies = await dyCookieService.GetAllAsync(); + if (cookies != null) + return ApiResult.Success(cookies.Select(x => new { id= x.Id,name= x.UserName, status=x.StatusMsg })); + return ApiResult.Success(); + } + + + /// + /// 非docker初始化 + /// + [HttpPost("deskinit")] + [AllowAnonymous] + public async Task DeskInitAsync([FromBody] DouyinCookie dyUserCookies) + { + // 1. 基础赋值 + dyUserCookies.Id = IdGener.GetLong().ToString(); + + // 2. 路径权限校验 + var (Success, Message) = ValidatePaths(dyUserCookies); + if (!Success) + return ApiResult.Fail(Message); + + RemoveCookieLineString(dyUserCookies); + + // 3. Cookie 有效性校验 + var cookieValid = await httpClientService.CheckCookie(dyUserCookies); + if (!cookieValid) + return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie"); + + dyUserCookies.StatusCode = 0; + dyUserCookies.StatusMsg = "正常"; + // 4. 保存到数据库 + var saved = await dyCookieService.Add(dyUserCookies); + return saved ? ApiResult.Success() : ApiResult.Fail("添加失败"); + } + + private static (bool Success, string Message) ValidatePaths(DouyinCookie cookie) + { + var pathsToCheck = new Dictionary + { + { "收藏存储路径", cookie.SavePath }, + { "喜欢视频存储路径", cookie.FavSavePath }, + { "上传视频存储路径", cookie.UpSavePath }, + // { "图片存储路径", cookie.ImgSavePath } // 可随时启用 + }; + + foreach (var (label, path) in pathsToCheck) + { + if (!string.IsNullOrWhiteSpace(path)) + { + if (!DouyinFileUtils.HasDirectoryReadWritePermission(path)) + { + return (false, $"请在飞牛应用设置里面将 {path} 添加读写权限({label})"); + } + } + } + + if (string.IsNullOrWhiteSpace(cookie.SavePath)) + { + return (false, "收藏存储路径不能为空"); + } + + return (true, string.Empty); + } + + + /// + /// 快速开启或停止 + /// + [HttpPost("switch")] + public async Task SwitchAsync([FromBody] DouyinCookieSwitchDto dto) + { + var result = await dyCookieService.Switch(dto); + if (result) + { + ReStartJob(); + return ApiResult.Success(); + } + return ApiResult.Fail("添加失败"); + } /// /// 更新用户Cookie /// [HttpPost("update")] - public async Task UpdateAsync([FromBody] DyUserCookies dyUserCookies) + public async Task AddOrUpdateAsync([FromBody] DouyinCookie dyUserCookies) { + RemoveCookieLineString(dyUserCookies); - if (dyUserCookies.UpSecUserIdsJson != null ) + var checkCk = await httpClientService.CheckCookie(dyUserCookies); + if (!checkCk) { - dyUserCookies.UpSecUserIds = JsonConvert.SerializeObject(dyUserCookies.UpSecUserIdsJson); + return ApiResult.Fail("Cookie无效或已过期,请按照文档提示重新获取有效Cookie,不要使用插件获取cookie"); } - + dyUserCookies.StatusCode = 0; + dyUserCookies.StatusMsg = "正常"; if (dyUserCookies.Id == "0") { - dyUserCookies.Id=IdGener.GetLong().ToString(); + dyUserCookies.Id = IdGener.GetLong().ToString(); var result = await dyCookieService.Add(dyUserCookies); if (result) { - await ReStartJob(); - return Ok(new { code = 0 }); + ReStartJob(); + return ApiResult.Success(); } - return BadRequest(new { code = -1, message = "添加失败" }); + return ApiResult.Fail("添加失败"); } - else { - var result = await dyCookieService.UpdateAsync(dyUserCookies); + else + { + var result = await dyCookieService.UpdateCookieAsync(dyUserCookies); if (result) { - await ReStartJob(); - return Ok(new { code = 0 }); + ReStartJob(); + return ApiResult.Success(); } - return BadRequest(new { code = -1, message = "更新失败" }); + return ApiResult.Fail("更新失败"); + } + + } + + private static void RemoveCookieLineString(DouyinCookie dyUserCookies) + { + if (dyUserCookies != null && !string.IsNullOrWhiteSpace(dyUserCookies.Cookies)) + { + var s = dyUserCookies.Cookies.Replace("\\r\\n", "\r\n"); + var ss = s.Trim(new char[] { '\r', '\n' }); + dyUserCookies.Cookies = ss; } - } /// @@ -113,28 +309,36 @@ namespace dy.net.Controllers [HttpGet("delete")] public async Task DeleteAsync(string id) { - var count = await dyCookieService.DeleteByIdsAsync(new List { id}); - if (count > 0) { - await ReStartJob(); + var count = await dyCookieService.DeleteByIdsAsync(new List { id }); + if (count > 0) + { + ReStartJob(); } - return Ok(new { code = 0, deletedCount = count }); + return ApiResult.Success(count); } [HttpGet("GetConfig")] public IActionResult GetConfig() { - var data = commonService.GetConfig(); - return Ok(new { code = 0, data = data }); + var data = commonService.GetConfig(); + return ApiResult.Success(data); } + [HttpPost("UpdateConfig")] public async Task UpdateConfig(AppConfig config) { - var data = await commonService.UpdateConfig(config); - if (data) { - await ReStartJob(); + var update = await commonService.UpdateConfig(config); + if (update) + { + if (config.OnlySyncNew) + { + Serilog.Log.Debug("仅同步新视频配置已生效,后续所有类型的视频同步将只会读取最近一页约20条数据"); + } + + ReStartJob(); } - return Ok(new { code = 0, data = data }); + return ApiResult.Success(update); } /// @@ -142,12 +346,13 @@ namespace dy.net.Controllers /// /// [HttpGet("ExecuteJobNow")] - [Authorize] + //[Authorize] public async Task ExecuteJobNow() { - var config = commonService.GetConfig(); - await quartzJobService.StartJob(config.Cron); - return Ok(new { code = 0 , error = "" }); + var config = commonService.GetConfig(); + if (config != null) + await quartzJobService.InitOrReStartAllJobs(config.Cron.ToString()); + return ApiResult.Success(); } /// @@ -278,22 +483,132 @@ namespace dy.net.Controllers } } - private async Task ReStartJob() { - var config= commonService.GetConfig(); - - //var cookies = await dyCookieService.GetAllCookies(); - //重置同步状态 - //foreach (var cookie in cookies) - //{ - // cookie.CollHasSyncd = 0; - // cookie.FavHasSyncd = 0; - // cookie.UperSyncd = 0; - // await dyCookieService.UpdateAsync(cookie); - //} - - if (config!=null) - quartzJobService.StartJob(config.Cron); + private void ReStartJob() + { + var config = commonService.GetConfig(); + if (config != null) + quartzJobService.InitOrReStartAllJobs(config.Cron.ToString()); //避免前端等待 } + /// + /// 镜像标签 + /// + /// + [HttpGet("mytag")] + public async Task GetMyTag() + { + var deploy = Appsettings.Get("deploy"); + var tag = Appsettings.Get("tagName"); + tag = deploy == "fn" ? "fn_" + Appsettings.Get("fnVersion") : tag; + deploy = deploy == "fn" ? "fnos" : "docker"; + return ApiResult.Success(new { tag, deploy }); + } + + [AllowAnonymous] + [HttpGet("checktag")] + public async Task CheckTag() + { + + var deploy = Appsettings.Get("deploy"); + if (string.IsNullOrWhiteSpace(deploy)) + { + return await GetDockerTagVersions(); + } + else + { + if (deploy == "fn")//飞牛 + { + return ApiResult.Success(new List { "fn_" + Appsettings.Get("fnVersion") }); + } + else + { + return await GetDockerTagVersions(); + } + } + + + } + + private static async Task GetDockerTagVersions() + { + var data = await JianZhiChuHttpHelper.GetTenImage(Appsettings.Get("tagName")); + if (data.IsSuccessStatusCode) + { + var content = await data.Content.ReadAsStringAsync(); + + var tagData = JsonConvert.DeserializeObject>>(content); + if (tagData != null && tagData.Data != null && tagData.Data.Count > 0) + return ApiResult.Success(tagData.Data); + return ApiResult.Fail(); + } + else + { + return ApiResult.Fail("请求失败"); + } + } + + /// + /// 查询mp3目录下有没有音频文件 + /// + /// + [HttpGet("mp3List")] + public async Task GetExistMps() + { + var path = Path.Combine(AppContext.BaseDirectory, "mp3"); + + if (!string.IsNullOrWhiteSpace(ServiceExtension.FnDataFolder)) + { + path = ServiceExtension.FnDataFolder; + } + + if (Directory.Exists(path)) + { + var allowedExtensions = new HashSet { ".mp3", ".wav" }; + + var customMusics = Directory.GetFiles(path) + .Where(filePath => + allowedExtensions.Contains(Path.GetExtension(filePath).ToLowerInvariant()) && + Path.GetFileNameWithoutExtension(filePath) != "silent_10") + .ToList(); + + var fileNames = customMusics.Select(f => new { filename = Path.GetFileName(f) }).Where(x => x.filename != "silent_10.mp3").ToList(); + return ApiResult.Success(fileNames); + } + else + { + return ApiResult.Fail("没有找到默认音频文件"); + } + } + + /// + /// 播放音频流 + /// + /// + /// + [AllowAnonymous] + [HttpGet("getmp3")] + public async Task GetMp3([FromQuery] string name) + { + var path = Path.Combine(AppContext.BaseDirectory, "mp3", name); + if (!string.IsNullOrWhiteSpace(ServiceExtension.FnDataFolder)) + { + path = Path.Combine(ServiceExtension.FnDataFolder, name); + } + + if (System.IO.File.Exists(path)) + { + //返回mp3文件流 + var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read); + return new FileStreamResult(fileStream, "audio/mpeg") + { + FileDownloadName = name + }; + } + else + { + return ApiResult.Fail("文件不存在"); + } + } + } } diff --git a/Controllers/FollowController.cs b/Controllers/FollowController.cs new file mode 100644 index 0000000..217fdfc --- /dev/null +++ b/Controllers/FollowController.cs @@ -0,0 +1,122 @@ +using dy.net.model.dto; +using dy.net.model.entity; +using dy.net.service; +using dy.net.utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace dy.net.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class FollowController : ControllerBase + { + private readonly DouyinFollowService _douyinFollowService; + private readonly DouyinQuartzJobService _douyinQuartzJobService; + + public FollowController(DouyinFollowService douyinFollowService, DouyinQuartzJobService douyinQuartzJobService) + { + this._douyinFollowService = douyinFollowService; + _douyinQuartzJobService = douyinQuartzJobService; + } + + + + /// + /// 分页查询 + /// + /// 分页结果 + [HttpPost("paged")] + public async Task GetPagedAsync( + FollowRequestDto dto) + { + if (string.IsNullOrWhiteSpace(dto.MySelfId)) + { + return ApiResult.Success(new { }); + } + var (list, totalCount) = await _douyinFollowService.GetPagedAsync(dto); + + return ApiResult.Success(new + { + data = list, + total = totalCount, + pageIndex = dto.PageIndex, + pageSize = dto.PageSize + }); + } + /// + /// 重新同步-单次 + /// + /// + [HttpGet("sync")] + public async Task SyncFollowList() + { + //后台异步 + _douyinQuartzJobService.StartFollowJobOnceAsync(); + await Task.Delay(1000); + return ApiResult.Success(); + } + [HttpPost("add")] + public async Task AddFollow(DouyinFollowed followed) + { + if (string.IsNullOrWhiteSpace(followed.mySelfId)) + { + return ApiResult.Fail("请先配置抖音授权信息,抖音授权配置里面填写你的uid"); + } + var res = await _douyinFollowService.AddAsync(followed); + return ApiResult.SuccOrFail(res, "", res ? "" : "添加失败,或者已存在相同secuid和uid"); + } + + /// + /// 修改关注同步状态 + /// + /// + /// + [HttpPost("openOrCloseSync")] + public async Task OpenOrCloseSync(FollowUpdateDto dto) + { + + if (dto.OpenSync) + { + if (!string.IsNullOrWhiteSpace(dto.SavePath)) + { + if (!DouyinFileNameHelper.IsValidWithoutSpecialChars(dto.SavePath)) + { + return ApiResult.Fail("请输入有效文件夹名称(字母数字中文简体)"); + } + + if (dto.SavePath.Length > 20) + { + return ApiResult.Fail("请输入有效文件夹名称(最长20)"); + } + } + } + var result = await _douyinFollowService.OpenOrCloseSync(dto); + return ApiResult.SuccOrFail(result, result); + } + + /// + /// 修改关注全量同步状态 + /// + /// + /// + [HttpPost("openOrCloseFullSync")] + public async Task OpenOrCloseFullSync(FollowUpdateDto dto) + { + return await OpenOrCloseSync(dto); + } + + /// + /// 删除关注对象 + /// + /// + /// + [HttpPost("delete")] + public async Task DeleteFollow(FollowUpdateDto dto) + { + var result = await _douyinFollowService.DeleteFollow(dto); + return ApiResult.SuccOrFail(result, result); + } + } +} diff --git a/Controllers/LogsController.cs b/Controllers/LogsController.cs index 3bd2145..9330b30 100644 --- a/Controllers/LogsController.cs +++ b/Controllers/LogsController.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Http; +using dy.net.model.dto; +using dy.net.service; using Microsoft.AspNetCore.Mvc; -using System.Net.Http; namespace dy.net.Controllers { @@ -9,19 +9,21 @@ namespace dy.net.Controllers public class LogsController : ControllerBase { private readonly IWebHostEnvironment webHostEnvironment; + private readonly LogInfoService logInfoService; - public LogsController(IWebHostEnvironment webHostEnvironment) + public LogsController(IWebHostEnvironment webHostEnvironment, LogInfoService logInfoService) { this.webHostEnvironment = webHostEnvironment; + this.logInfoService = logInfoService; } - [HttpGet] - public async Task GetLog(string type, string date) + [HttpGet("/api/logs/GetLog/{type}/{date}")] + public async Task GetLog([FromRoute] string type, [FromRoute] string date) { var filePath = Path.Combine(webHostEnvironment.IsDevelopment() ? Directory.GetCurrentDirectory() : AppDomain.CurrentDomain.BaseDirectory, "logs", $"log-{type}-{date}.txt"); if (!System.IO.File.Exists(filePath)) { - var msg = $"Log file log-{type}-{date}.txt not found."; + var msg = $"{date},没有发现{type}的日志"; //Serilog.Log.Error(msg); return Ok(msg); } @@ -35,5 +37,57 @@ namespace dy.net.Controllers } + + + /// + /// 获取最近10天的日志文件列表 + /// + [HttpGet("/api/logs/list")] + public IActionResult GetLogFiles() + { + try + { + var _logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs"); + var files = logInfoService.GetLogFiles(_logDirectory); + return ApiResult.Success(files); + } + catch (Exception ex) + { + return ApiResult.Fail("获取日志列表失败," + ex.Message); + } + } + + + + /// + /// 获取日志文件内容流 + /// + [HttpGet("/api/logs/content")] + public IActionResult GetLogContent([FromQuery] LogContentRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + try + { + var _logDirectory = Path.Combine(Directory.GetCurrentDirectory(), "logs"); + var stream = logInfoService.GetLogFileStream(_logDirectory, request.Type, request.Date); + // 返回文件流,指定MIME类型为文本 + return File(stream, "text/plain", $"log-{request.Type}-{request.Date}.txt"); + } + catch (FileNotFoundException ex) + { + return NotFound(new { message = ex.Message }); + } + catch (ArgumentException ex) + { + return BadRequest(new { message = ex.Message }); + } + catch (Exception ex) + { + return StatusCode(500, new { message = "读取日志文件失败", error = ex.Message }); + } + } } } diff --git a/Controllers/VideoController.cs b/Controllers/VideoController.cs index 71535cc..9df3ede 100644 --- a/Controllers/VideoController.cs +++ b/Controllers/VideoController.cs @@ -1,10 +1,9 @@ -using dy.net.dto; +using dy.net.model.dto; +using dy.net.model.entity; using dy.net.service; using dy.net.utils; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Newtonsoft.Json; -using System.Drawing.Printing; namespace dy.net.Controllers { @@ -12,33 +11,29 @@ namespace dy.net.Controllers [ApiController] public class VideoController : ControllerBase { - private readonly DyCollectVideoService dyCollectVideoService; + private readonly DouyinVideoService douyinVideoService; + private readonly DouyinCommonService douyinCommonService; - public VideoController(DyCollectVideoService dyCollectVideoService) + public VideoController(DouyinVideoService dyCollectVideoService, DouyinCommonService douyinCommonService) { - this.dyCollectVideoService = dyCollectVideoService; + this.douyinVideoService = dyCollectVideoService; + this.douyinCommonService = douyinCommonService; } /// /// 分页查询收藏视频 /// /// + [Authorize] [HttpPost("paged")] - public async Task GetPagedAsync(VideoPageRequestDTO dto) + public async Task GetPagedAsync(DouyinVideoPageRequestDto dto) { - // 调试日志:输出接收到的排序参数 - Console.WriteLine($"排序参数 - SortField: {dto.SortField}, SortOrder: {dto.SortOrder}"); - - var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto.PageIndex, dto.PageSize, dto.Tag, dto.Tags, dto.Author, dto.ViedoType, dto.Dates, dto.IsDeleted, dto.SortField, dto.SortOrder); - return Ok(new + var (list, totalCount) = await douyinVideoService.GetPagedAsync(dto); + return ApiResult.Success(new { - code = 0, - data = new - { - data = list, - total=totalCount, - pageIndex=dto.PageIndex, - pageSize=dto.PageSize - } + data = list, + total = totalCount, + pageIndex = dto.PageIndex, + pageSize = dto.PageSize }); } @@ -61,286 +56,434 @@ namespace dy.net.Controllers /// 查询统计数据 /// /// + [Authorize] [HttpGet("statics")] public async Task GetStaticsAsync() { - var data = await dyCollectVideoService.GetStatics(); - return Ok(new - { - code = 0, - data - }); + return ApiResult.Success(await douyinVideoService.GetStatics()); } /// - /// 清理数据库中文件不存在的视频记录 + /// 播放视频 /// + /// /// - [Authorize] - [HttpPost("cleanup")] - public async Task CleanupMissingFilesAsync() + [AllowAnonymous] + [HttpGet("play/{vid}")] + public async Task StreamVideo([FromRoute] string vid) { try { - Serilog.Log.Information("开始清理文件不存在的视频记录"); - var deletedCount = await dyCollectVideoService.CleanupMissingFilesAsync(); - Serilog.Log.Information($"清理完成,删除了 {deletedCount} 条记录"); - return Ok(new + var viedo = await douyinVideoService.GetById(vid); + + if (viedo == null) { - code = 0, - data = new - { - deletedCount = deletedCount, - message = $"清理完成,删除了 {deletedCount} 条文件不存在的记录" - } - }); + return ApiResult.Fail($"视频不存在:{vid}"); + } + return await PlayVideoAsync(viedo); } catch (Exception ex) { - Serilog.Log.Error(ex, "清理文件不存在的记录失败"); - return StatusCode(500, new { code = -1, error = "清理失败:" + ex.Message }); + return ApiResult.Fail($"视频加载失败:{ex.Message}"); + } + } + + + /// + /// 抖音视频播放接口(优化版) + /// 解决内存泄漏、资源释放不及时、鲁棒性不足等问题 + /// + /// 视频实体(修正拼写错误:viedo -> video) + /// 视频流响应 + public async Task PlayVideoAsync(DouyinVideo video) + { + // 空值校验 + if (video == null) + { + return ApiResult.Fail("视频信息不能为空"); + } + + // 1. 获取完整物理路径并校验 + string videoFullPath = video.VideoSavePath; + if (string.IsNullOrWhiteSpace(videoFullPath)) + { + return ApiResult.Fail("视频保存路径不能为空"); + } + + // 安全校验:防止路径遍历攻击 + try + { + videoFullPath = Path.GetFullPath(videoFullPath); + } + catch + { + return ApiResult.Fail("视频路径格式非法"); + } + + // 2. 验证文件是否存在 + if (!System.IO.File.Exists(videoFullPath)) + { + return ApiResult.Fail($"视频文件不存在:{videoFullPath}"); + } + + try + { + // 3. 获取文件信息(使用using确保FileInfo资源释放) + var fileInfo = new FileInfo(videoFullPath); + long fileSize = fileInfo.Length; + + // 校验空文件 + if (fileSize == 0) + { + return ApiResult.Fail($"视频文件为空:{videoFullPath}"); + } + + string contentType = GetContentType(videoFullPath); + + // 4. 处理分片请求(Range) + if (Request.Headers.ContainsKey("Range")) + { + return await HandleRangeRequestAsync(videoFullPath, fileSize, contentType); + } + else + { + // 完整文件请求(启用分片处理,兼容前端断点续传) + return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true); + } + } + catch (UnauthorizedAccessException) + { + return ApiResult.Fail($"没有权限访问视频文件:{videoFullPath}"); + } + catch (IOException ex) + { + // 记录日志(建议添加日志框架,如Serilog/NLog) + // _logger.LogError(ex, "读取视频文件失败:{Path}", videoFullPath); + return ApiResult.Fail($"读取视频文件失败:{ex.Message}"); + } + catch (Exception ex) + { + // _logger.LogError(ex, "视频播放接口异常:{Path}", videoFullPath); + return ApiResult.Fail($"服务器内部错误:{ex.Message}"); } } /// - /// 删除指定作者的所有视频数据 + /// 处理分片请求(Range),异步安全处理流 /// - /// 作者名称 + /// 文件路径 + /// 文件总大小 + /// 内容类型 + /// 分片响应 + private async Task HandleRangeRequestAsync(string filePath, long fileSize, string contentType) + { + string rangeHeader = Request.Headers["Range"].ToString(); + + // 安全解析Range头 + if (!rangeHeader.StartsWith("bytes=")) + { + // 416 - 请求的范围无法满足 + Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable; + Response.Headers.Add("Content-Range", $"bytes */{fileSize}"); + return new EmptyResult(); + } + + // 解析起始位置 + string[] rangeParts = rangeHeader.Split('=')[1].Split('-'); + if (!long.TryParse(rangeParts[0], out long start) || start < 0 || start >= fileSize) + { + Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable; + Response.Headers.Add("Content-Range", $"bytes */{fileSize}"); + return new EmptyResult(); + } + + // 计算分片结束位置(每片2MB,可配置) + long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1); + long chunkSize = end - start + 1; + + // 设置206分片响应头 + Response.StatusCode = StatusCodes.Status206PartialContent; + Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}"); + Response.Headers.Add("Accept-Ranges", "bytes"); + Response.Headers.Add("Content-Length", chunkSize.ToString()); + Response.ContentType = contentType; + + // 核心改进:使用异步流 + using确保释放(通过管道直接写入响应流) + await using var fileStream = new FileStream( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 8192, // 增大缓冲区提升性能,减少IO次数 + FileOptions.Asynchronous | FileOptions.SequentialScan); // 顺序扫描优化 + + // 定位到分片起始位置 + fileStream.Seek(start, SeekOrigin.Begin); + + // 直接写入响应流,避免FileStreamResult的延迟释放问题 + var buffer = new byte[8192]; + long remainingBytes = chunkSize; + + while (remainingBytes > 0) + { + int bytesRead = await fileStream.ReadAsync(buffer, 0, (int)Math.Min(remainingBytes, buffer.Length)); + if (bytesRead == 0) break; + + await Response.Body.WriteAsync(buffer.AsMemory(0, bytesRead)); + remainingBytes -= bytesRead; + } + + await Response.Body.FlushAsync(); + + return new EmptyResult(); + } + + /// + /// 播放视频 + /// + /// + /// /// - [Authorize] - [HttpPost("delete-author")] - public async Task DeleteAuthorVideosAsync([FromBody] DeleteAuthorRequest request) + [HttpGet("/share/{vid}/{k}")] + [AllowAnonymous] + public async Task Share([FromRoute] string vid, [FromRoute] string k) { try { - if (string.IsNullOrWhiteSpace(request?.AuthorName)) + var viedo = await douyinVideoService.GetById(vid); + + if (viedo == null) { - return BadRequest(new { code = -1, error = "作者名称不能为空" }); + return ApiResult.Fail($"视频不存在:{vid}"); } - Serilog.Log.Information($"开始删除作者 {request.AuthorName} 的视频数据"); - var deletedCount = await dyCollectVideoService.DeleteAuthorVideosAsync(request.AuthorName); - Serilog.Log.Information($"删除完成,删除了 {deletedCount} 条记录"); - - return Ok(new + var expectedKey = (viedo.FileHash + viedo.AuthorId).Md5(); + if (expectedKey != k) { - code = 0, - data = new - { - deletedCount = deletedCount, - message = $"已删除作者 {request.AuthorName} 的 {deletedCount} 条视频记录" - } - }); + return ApiResult.Fail($"视频地址无效"); + } + + return await PlayVideoAsync(viedo); } catch (Exception ex) { - Serilog.Log.Error(ex, $"删除作者视频失败: {request?.AuthorName}"); - return StatusCode(500, new { code = -1, error = "删除失败:" + ex.Message }); + return ApiResult.Fail($"视频加载失败:{ex.Message}"); } } /// - /// 删除作者请求DTO + /// 辅助方法:根据文件名获取 MIME 类型(确保前端正确识别视频格式) /// - public class DeleteAuthorRequest + private static string GetContentType(string filename) { - public string AuthorName { get; set; } - } - - - /// - /// 获取视频流(支持Range请求,用于视频播放) - /// - /// 视频ID - /// - [Authorize] - [HttpGet("stream/{id}")] - public async Task GetVideoStream(string id) - { - try - { - var video = await dyCollectVideoService.GetByIdAsync(id); - if (video == null || string.IsNullOrWhiteSpace(video.VideoSavePath)) - { - return NotFound(new { code = -1, error = "视频不存在" }); - } - - if (!System.IO.File.Exists(video.VideoSavePath)) - { - return NotFound(new { code = -1, error = "视频文件不存在" }); - } - - var fileInfo = new FileInfo(video.VideoSavePath); - var contentType = GetContentType(video.VideoSavePath); - - // 记录日志,便于调试 - var rangeHeader = Request.Headers["Range"].ToString(); - Serilog.Log.Debug($"视频流请求: ID={id}, 路径={video.VideoSavePath}, Content-Type={contentType}, Range={rangeHeader}"); - - var fileStream = new FileStream(video.VideoSavePath, FileMode.Open, FileAccess.Read, FileShare.Read); - - // 使用ASP.NET Core内置的Range支持 - Response.Headers.Add("Accept-Ranges", "bytes"); - - // 添加缓存控制头,确保浏览器可以缓存视频元数据 - // 但允许 Range 请求,这样浏览器可以快速开始播放 - Response.Headers.Add("Cache-Control", "public, max-age=3600"); - - // 添加 CORS 头(如果需要) - Response.Headers.Add("Access-Control-Allow-Origin", "*"); - Response.Headers.Add("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); - Response.Headers.Add("Access-Control-Allow-Headers", "Range"); - Response.Headers.Add("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Length"); - - return File(fileStream, contentType, enableRangeProcessing: true); - } - catch (Exception ex) - { - Serilog.Log.Error(ex, $"获取视频流失败: {id}"); - return StatusCode(500, new { code = -1, error = "获取视频流失败" }); - } - } - - /// - /// 获取视频封面图片 - /// - /// 视频ID - /// - [Authorize] - [HttpGet("cover/{id}")] - public async Task GetVideoCover(string id) - { - try - { - var video = await dyCollectVideoService.GetByIdAsync(id); - if (video == null) - { - return GetDefaultCoverImage(); - } - - // 优先使用本地保存的封面 - if (!string.IsNullOrWhiteSpace(video.VideoCoverSavePath) && System.IO.File.Exists(video.VideoCoverSavePath)) - { - var fileInfo = new FileInfo(video.VideoCoverSavePath); - var contentType = GetImageContentType(video.VideoCoverSavePath); - var fileStream = System.IO.File.OpenRead(video.VideoCoverSavePath); - return File(fileStream, contentType); - } - - // 如果没有本地封面,返回默认图片 - return GetDefaultCoverImage(); - } - catch (Exception ex) - { - Serilog.Log.Error(ex, $"获取视频封面失败: {id}"); - return GetDefaultCoverImage(); - } - } - - /// - /// 返回默认封面图片(SVG 占位图) - /// - private IActionResult GetDefaultCoverImage() - { - var svgContent = @" - - 暂无封面 -"; - var svgBytes = System.Text.Encoding.UTF8.GetBytes(svgContent); - return File(svgBytes, "image/svg+xml"); - } - - /// - /// 根据文件扩展名获取图片 Content-Type - /// - private string GetImageContentType(string fileName) - { - if (string.IsNullOrWhiteSpace(fileName)) - { - return "image/jpeg"; - } - - var extension = Path.GetExtension(fileName).ToLowerInvariant(); + string extension = Path.GetExtension(filename).ToLowerInvariant(); return extension switch { - ".jpg" or ".jpeg" => "image/jpeg", - ".png" => "image/png", - ".gif" => "image/gif", - ".webp" => "image/webp", - ".svg" => "image/svg+xml", - ".bmp" => "image/bmp", - _ => "image/jpeg" + ".mp4" => "video/mp4", + _ => "application/octet-stream" // 默认二进制流 }; } /// - /// 根据文件扩展名获取Content-Type + /// 重新下载 /// - private string GetContentType(string fileName) + /// + /// + [HttpPost("redown")] + public async Task ReDownload(ReDownViedoDto dto) { - if (string.IsNullOrWhiteSpace(fileName)) + if (dto == null) { - Serilog.Log.Warning($"文件名为空,使用默认 video/mp4"); - return "video/mp4"; // 抖音视频默认是 MP4 + return ApiResult.Fail("参数错误"); } - - var extension = Path.GetExtension(fileName).ToLowerInvariant(); - - // 如果没有扩展名,尝试从文件名推断(抖音视频格式通常是 mp4) - if (string.IsNullOrEmpty(extension)) + else { - Serilog.Log.Warning($"文件 {fileName} 没有扩展名,使用默认 video/mp4"); - return "video/mp4"; + var result = await douyinVideoService.ReDownloadViedoAsync(dto); + if (result) + { + return ApiResult.Success(true); + } + else + { + return ApiResult.Fail("错误"); + } } - - var contentType = extension switch - { - ".mp4" => "video/mp4", - ".webm" => "video/webm", - ".ogg" => "video/ogg", - ".mov" => "video/quicktime", - ".avi" => "video/x-msvideo", - ".mkv" => "video/x-matroska", - ".flv" => "video/x-flv", - ".wmv" => "video/x-ms-wmv", - ".3gp" => "video/3gpp", - ".m3u8" => "application/x-mpegURL", // HLS 流 - _ => null - }; - - // 如果无法识别扩展名,默认使用 video/mp4(抖音视频通常是 MP4) - if (contentType == null) - { - Serilog.Log.Warning($"无法识别的视频扩展名: {extension},文件: {fileName},使用默认 video/mp4"); - return "video/mp4"; - } - - return contentType; } - [AllowAnonymous] - [HttpGet] - public IActionResult Index() + /// + /// 批量删除 + /// + /// + /// + [HttpPost("vdelete/batch")] + public async Task BathRealDelete(ReDownViedoDto dto) { - List myClasses = new List(); - foreach (var drive in DriveInfo.GetDrives()) + var result = await douyinVideoService.RealDeleteVideos(dto.Ids); + if (result) { - myClasses.Add(new MyClass { name=drive.Name, x1= drive.TotalSize, x2= drive.TotalFreeSpace }); - Serilog.Log.Debug($"Drive: {drive.Name}, Total Size: {drive.TotalSize}, Free Space: {drive.TotalFreeSpace}"); + return ApiResult.Success(true); + } + else + { + return ApiResult.Fail("错误"); } - //var disk=DiskInfoHelper.GetDockerHostTotalDiskSpaceGB(); - return Ok(JsonConvert.SerializeObject(myClasses)+"\r\n"+JsonConvert.SerializeObject(myClasses.Sum(x=>x.x1)+"\r\n"+ JsonConvert.SerializeObject(myClasses.Sum(x => x.x2)))); } - public class MyClass + /// + /// 删除视频-不再下载 + /// + /// + /// + [HttpGet("vdelete/{vid}")] + public async Task DeleteVideo([FromRoute] string vid) { - public string name { get; set; } + if (string.IsNullOrWhiteSpace(vid)) + { + return ApiResult.Fail("参数错误"); + } + else + { + var res = await douyinVideoService.RealDeleteVideos(new List { vid }); + if (res) + { + return ApiResult.Success("删除成功"); + } + else + { + return ApiResult.Fail("删除失败"); + } + } + } - public long x1 { get; set; } + /// + /// 查询已删除视频列表 + /// + /// + [HttpGet("vdelete/get")] + public async Task GetDeleteVideo() + { + return ApiResult.Success(await douyinCommonService.GetDouyinDeleteVideos()); + } + /// + /// 根据博主id删除博主所有视频 + /// + /// + /// + [HttpGet("vdelete/byauthor/{uperUid}")] + public async Task DeleteByAuthor([FromRoute] string uperUid) + { + var videos = await douyinVideoService.GetByAuthorId(uperUid); + if (videos != null && videos.Any()) + { + var res = await douyinVideoService.RealDeleteVideos(videos.Select(x => x.Id).ToList()); + if (res) + { + return ApiResult.Success("删除成功"); + } + else + { + return ApiResult.Fail("删除失败"); + } + } + return ApiResult.Fail("未找到该博主视频"); + } - public long x2 { get; set; } + //private async Task<(bool flowControl, IActionResult value)> BatchDeleteVideos(List videos) + //{ + + // return (flowControl: true, value: null); + //} + + /// + /// 查询最新N条数据 + /// + /// + /// + [HttpGet("top{top}")] + public async Task GetLastSyncTop([FromRoute] int top = 5) + { + return ApiResult.Success(await douyinVideoService.GetLastSyncTop(top)); + } + + /// + /// 删除无效视频记录 + /// + /// + [HttpGet("removeInvalid")] + public async Task RemoveInvalidVideo() + { + var data = await douyinVideoService.DeleteInvalidVideo(); + return Ok(data); + } + + ///// + ///// 所有视频重新生成nfo文件 + ///// + ///// + //[HttpGet("renfo")] + //public async Task ReCreateNfo() + //{ + // var videos = await douyinVideoService.GetAllAsync(); + // if (videos == null || videos.Count == 0) + // { + // return ApiResult.Success("暂无视频数据需要生成NFO文件"); + // } + // _ = Task.Run(async () => + // { + // try + // { + // var totalCount = videos.Count; + // foreach (var video in videos) + // { + // try + // { + // NfoFileGenerator.GenerateVideoNfoFile(video); + // Serilog.Log.Debug($"刮削视频(Path:{video.VideoSavePath})生成NFO成功!"); + // await Task.Delay(50); + // } + // catch (Exception singleEx) + // { + // Serilog.Log.Error($"刮削视频(Path:{video.VideoSavePath})生成NFO失败:{singleEx.Message}"); + // } + // } + // } + // catch (Exception ex) + // { + // Serilog.Log.Error($"NFO文件生成任务执行异常:{ex.Message}\n{ex.StackTrace}"); + // } + // }); + + // return ApiResult.Success(); + //} + + + + /// + /// 获取7天视频同步趋势数据(曲线图) + /// + /// 7天图表数据列表 + [HttpGet("chart/{day}")] + public async Task Chart([FromRoute] int day = 7) + { + try + { + var chartData = await douyinVideoService.GetChartData(day); + return ApiResult.Success(chartData); + } + catch (Exception ex) + { + return ApiResult.Fail(ex.Message); + } + } + + [HttpGet("/Move")] + public async Task Move() + { + await douyinVideoService.HandOldFolderVideos(); + + return ApiResult.Success(DateTime.Now); } } } diff --git a/Dockerfile.prod b/Dockerfile.prod index 6f955b9..d4a7e08 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -10,7 +10,7 @@ RUN npm config set registry https://registry.npmmirror.com && \ npm install -g pnpm && \ pnpm config set registry https://registry.npmmirror.com && \ if [ -f pnpm-lock.yaml ]; then \ - pnpm install --frozen-lockfile; \ + pnpm install --no-frozen-lockfile; \ else \ npm install; \ fi @@ -18,17 +18,11 @@ RUN npm config set registry https://registry.npmmirror.com && \ COPY app/ ./ RUN pnpm build || npm run build -# 第二阶段:构建后端 + 下载 Playwright Chromium -FROM mcr.azurecr.cn/dotnet/sdk:6.0 AS backend-builder +# 第二阶段:构建后端 +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS backend-builder WORKDIR /src -# 安装 Node.js 18(用于 npx playwright install,最可靠的方式) -RUN apt-get update && apt-get install -y curl && \ - curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ - apt-get install -y nodejs && \ - rm -rf /var/lib/apt/lists/* - COPY *.csproj ./ RUN dotnet restore dy.net.csproj @@ -39,46 +33,18 @@ COPY --from=frontend-builder /app/frontend/dist ./app/dist RUN dotnet publish dy.net.csproj -c Release -o /app/publish -# 用 npx 下载 Playwright Chromium 到固定路径 -ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright -RUN npx -y playwright@1.44.0 install chromium - -# 第三阶段:运行镜像 -FROM mcr.azurecr.cn/dotnet/aspnet:6.0 AS final +# 第三阶段:运行镜像(Alpine 版:apk 包更小、代理下更易通过) +FROM mcr.microsoft.com/dotnet/aspnet:6.0-alpine AS final WORKDIR /app ENV TZ=Asia/Shanghai -RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone - -# 切换阿里云镜像源,解决国内网络无法访问 deb.debian.org 的问题 -RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list && \ - sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list - -# 安装 Chromium 运行时依赖(Debian Bullseye) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libnss3 \ - libatk1.0-0 \ - libatk-bridge2.0-0 \ - libcups2 \ - libdrm2 \ - libxkbcommon0 \ - libxcomposite1 \ - libxdamage1 \ - libxfixes3 \ - libxrandr2 \ - libgbm1 \ - libasound2 \ - libpango-1.0-0 \ - libpangocairo-1.0-0 \ - libgtk-3-0 \ - fonts-liberation \ - && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache tzdata ffmpeg && \ + cp /usr/share/zoneinfo/$TZ /etc/localtime && \ + echo $TZ > /etc/timezone COPY --from=backend-builder /app/publish . -COPY --from=backend-builder /ms-playwright /ms-playwright -ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright EXPOSE 10101 ENV ASPNETCORE_URLS=http://*:10101 ENV ASPNETCORE_ENVIRONMENT=Production diff --git a/Program.cs b/Program.cs index 6a843f4..68be463 100644 --- a/Program.cs +++ b/Program.cs @@ -1,14 +1,7 @@ -using ClockSnowFlake; -using dy.net.dto; -using dy.net.extension; -using dy.net.model; +using dy.net.extension; using dy.net.service; using dy.net.utils; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.FileProviders; -using Microsoft.IdentityModel.Tokens; using Serilog; -using SqlSugar; using System.Reflection; using System.Text; @@ -16,136 +9,190 @@ namespace dy.net { public class Program { - // ³£Á¿¶¨Òå - private const string DefaultListenUrl = "http://*:10101"; + // 常量定义 + private static readonly string DefaultListenUrl = "http://*:10101"; private const string SpaRootPath = "app/dist"; private const string SpaSourcePath = "app/"; - private const string SwaggerDocTitle = "dy.net WebApi Docs"; + private const string SwaggerDocTitle = "dysync.net WebApi Docs"; - public static void Main(string[] args) + /// + /// + /// + public static async Task Main(string[] args) { - // ³õʼ»¯±àÂëÌṩÆ÷ + // 初始化编码提供器 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); - - // ¹¹½¨WebӦÓà + // 构建Web应用 var builder = WebApplication.CreateBuilder(args); var isDevelopment = builder.Environment.IsDevelopment(); - - // ÅäÖÃÖ÷»ú + // 配置主机 ConfigureHost(builder, isDevelopment); - - // ÅäÖ÷þÎñ - var services = builder.Services; - ConfigureServices(services, builder.Configuration, isDevelopment); - - // ¹¹½¨ӦÓà + // 配置服务--数据库保存路径从命令行参数传入的第一个参数 + string dbPath = args.Length > 0 ? args[0] : ""; + ConfigureServices(builder.Services, builder.Configuration, builder.Environment, dbPath); + // 构建应用 var app = builder.Build(); + // 配置中间件 + ConfigureMiddleware(app, builder.Environment); + Log.Debug($"dy.sync app is started successfully on {DefaultListenUrl}"); + // 初始化应用服务 + await InitApplicationServices(app, isDevelopment, dbPath); + Console.WriteLine(); - // ÅäÖÃÖмä¼þ - ConfigureMiddleware(app, isDevelopment); - - // ³õʼ»¯ӦÓ÷þÎñ - InitApplicationServices(app); - - // Æô¶¯ӦÓà - Serilog.Log.Debug("dy.net service started successfully"); - app.Run(); + await app.RunAsync(); } /// - /// ÅäÖÃÖ÷»úÉèÖà + /// 配置主机设置 /// private static void ConfigureHost(WebApplicationBuilder builder, bool isDevelopment) { - // ÉèÖüàÌýµØַ + InitAppsettings(builder); + //from docker yaml file 环境变量 或者 dockerfile 或appsettings.json + // 设置监听地址 builder.WebHost.UseUrls(DefaultListenUrl); - - // ÅäÖÃÅäÖÃÎļþ - builder.Host.ConfigureAppConfiguration((context, config) => - { - config.SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) - .AddEnvironmentVariables(); - }); - - // ÅäÖÃÈÕ־ - builder.Host.ConfigureLogging(logging => logging.ClearProviders()) - .UseSerilog(); - + // 配置日志 builder.ConfigureLogging(); } - /// - /// ÅäÖÃÒÀÀµעÈë·þÎñ - /// - private static void ConfigureServices(IServiceCollection services, IConfiguration config, bool isDevelopment) + private static void InitAppsettings(WebApplicationBuilder builder) { - // ѩ»¨IDÉú³ÉÆ÷ - services.AddSnowFlakeId(options => options.WorkId = new Random().Next(1, 127)); + // 配置配置文件 + builder.Host.ConfigureAppConfiguration((context, config) => + { + // 定义配置文件名(统一小写,适配Linux大小写敏感特性) + const string configFileName = "appsettings.json"; - // MVC¿ØÖÆÆ÷ - services.AddControllers(); + // 步骤1:获取多维度的候选基础路径(覆盖不同部署场景) + var candidateBasePaths = new List + { + // 候选1:程序集所在目录(优先,解决工作目录不一致问题) + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), + // 候选2:当前工作目录(兜底) + Directory.GetCurrentDirectory(), + // 候选3:应用根目录(针对单文件发布/容器部署场景) + AppContext.BaseDirectory, + // 候选4:自定义环境变量指定的配置目录(灵活性扩展) + Environment.GetEnvironmentVariable("APP_CONFIG_DIR") + } + // 过滤空值和无效路径 + .Where(path => !string.IsNullOrEmpty(path) && Directory.Exists(path)) + .Distinct() // 去重 + .ToList(); - // HTTP¿ͻ§¶Ë + // 步骤2:遍历候选路径,查找存在的配置文件 + string configFilePath = null; + foreach (var basePath in candidateBasePaths) + { + var tempPath = Path.Combine(basePath, configFileName); + if (File.Exists(tempPath)) + { + configFilePath = tempPath; + break; // 找到第一个存在的配置文件即可 + } + } + + // 步骤3:配置加载(增加容错和日志提示) + if (!string.IsNullOrEmpty(configFilePath)) + { + // 找到配置文件,正常加载 + var basePath = Path.GetDirectoryName(configFilePath); + config.SetBasePath(basePath) + .AddJsonFile(configFileName, optional: false, reloadOnChange: true) + .AddEnvironmentVariables(); + + // 可选:输出日志,确认配置文件加载路径(方便排查) + //Console.WriteLine($"成功加载配置文件:{configFilePath}"); + } + else + { + // 未找到配置文件,抛出明确异常(或根据需求调整为兜底逻辑) + throw new FileNotFoundException( + $"未找到配置文件 {configFileName},已检查以下路径:{string.Join("; ", candidateBasePaths)}", + configFileName); + } + }); + } + + /// + /// 配置依赖注入服务 + /// + private static void ConfigureServices(IServiceCollection services, IConfiguration config, IWebHostEnvironment environment, string dbPath) + { + + //打印logo + //PrintApp(); + + services.AddSingleton(new Appsettings(config)); + // 雪花ID生成器 + services.AddSnowFlakeId(options => options.WorkId = new Random().Next(1, 100)); + + // MVC控制器+异常拦截器 + services.AddControllers().AddGlobalExceptionFilter(); + + // HTTP客户端 services.AddHttpClients(); - // Êý¾ݿâ - services.AddSqlsugar(config); + // 数据库 + services.AddSqlsugar(dbPath); - // ¶¨ʱÈÎÎñ - services.AddQuartzService(config); + // 定时任务 + services.AddQuartzService(dbPath); - // ²ִ¢ºͷþÎñע²á + // 仓储和服务注册 services.AddServicesFromNamespace("dy.net.repository") .AddServicesFromNamespace("dy.net.service"); - // SPA¾²̬Îļþ֧³Ö + services.AddScoped(); + + // SPA静态文件支持 services.AddSpaStaticFiles(options => options.RootPath = SpaRootPath); - // ¿ª·¢»·¾³ÆôÓÃSwagger - if (isDevelopment) - { - services.AddSwagger(); - } + // 开发环境启用Swagger + //if (environment.IsDevelopment()) + //{ + // services.AddSwagger(); + //} - // ÏìӦѹËõ + // 响应压缩 services.AddResponseCompression(); - // JWTÈÏ֤ + // JWT认证 services.ConfigureJwtAuthentication(); } - + /// - /// ÅäÖÃÖмä¼þ + /// 配置中间件 /// - private static void ConfigureMiddleware(WebApplication app, bool isDevelopment) + private static void ConfigureMiddleware(WebApplication app, IWebHostEnvironment environment) { - // ÏìӦѹËõ + // 响应压缩 app.UseResponseCompression(); - // ¿ª·¢»·¾³ÆôÓÃSwaggerUI - if (isDevelopment) - { - app.UseCustomSwaggerUI(options => options.Title = SwaggerDocTitle); - } + // 开发环境启用SwaggerUI + //if (environment.IsDevelopment()) + //{ + // app.UseCustomSwaggerUI(options => options.Title = SwaggerDocTitle); + //} - // ·ÓÉ + // 路由 app.UseRouting(); - // ÈÏ֤ÊÚȨ + // 认证授权 app.UseAuthentication(); app.UseAuthorization(); - // ÅäÖÃÎļþÉϴ«·¾¶ + // 配置文件上传路径 //ConfigureUploadPath(app, isDevelopment); - // API·ÓÉӳÉä + // API路由映射 app.MapControllers(); - // Éú²ú»·¾³ÆôÓÃSPA - if (!isDevelopment) + app.UseStaticFiles(); + // 生产环境启用SPA + if (!environment.IsDevelopment()) { app.UseSpaStaticFiles(); app.UseSpa(spa => spa.Options.SourcePath = SpaSourcePath); @@ -153,47 +200,71 @@ namespace dy.net } /// - /// ³õʼ»¯ӦÓ÷þÎñÊý¾Ý + /// 初始化应用服务数据 /// - private static void InitApplicationServices(WebApplication app) + private static async Task InitApplicationServices(WebApplication app, bool isDevelopment, string dbPath) { using var scope = app.Services.CreateScope(); var services = scope.ServiceProvider; try { - // ³õʼ»¯Óû§ - var userService = services.GetRequiredService(); - userService.InitUser(); + // 初始化用户 + var userService = services.GetRequiredService(); + userService.InitUser("douyin", "douyin2026"); - // ³õʼ»¯Cookie - var cookieService = services.GetRequiredService(); - cookieService.Init(); + var pwdTxt = Path.Combine(AppContext.BaseDirectory, "db", "pwd.txt"); + if (!string.IsNullOrWhiteSpace(dbPath)) + { + pwdTxt = Path.Combine(dbPath, "pwd.txt"); + } - // ³õʼ»¯ÅäÖà - var commonService = services.GetRequiredService(); + if (File.Exists(pwdTxt)) + { + var pwd = File.ReadAllText(pwdTxt); + var reset = userService.ResetPwd(pwd); + File.Delete(pwdTxt); + } + + var commonService = services.GetRequiredService(); + + // 更新视频类型--兼容老版本--不再需要 + //commonService.UpdateCollectViedoType(); + // 重置博主作品同步状态为未同步 + //commonService.UpdateAllCookieSyncedToZero(); + + //await commonService.UpdateFollowedSavePathAsync(); + //await commonService.UpdateImageVideoSavePath(); + + // 初始化Cookie + var cookieService = services.GetRequiredService(); + var deploy = Appsettings.Get("deploy"); + if (deploy != null && deploy == "docker")//docker环境直接初始化一个默认的配置 + { + cookieService.InitCookie(); + } + // 初始化配置 var config = commonService.InitConfig(); - // ¸üÐÂÊղØÊÓƵÀàÐÍ--¼æÈÝÀϰ汾-ԭÀ´µľÉÊý¾ÝûÓÐÕâ¸öÀàÐÍ×ֶÎ - commonService.UpdateCollectViedoType(); - // ÖØÖò©Ö÷×÷Ʒͬ²½״̬Ϊδͬ²½ - commonService.UpdateAllCookieSyncedToZero(); - // Æô¶¯¶¨ʱÈÎÎñ - var quartzJobService = services.GetRequiredService(); - quartzJobService.StartJob(config?.Cron ?? "30"); + //Serilog.Log.Debug("isRestart1=" + config.IsFirstRunning); + //await cookieService.UpdateCookieToSupportOldVersionAsync(); - Serilog.Log.Debug("ϵͳ³õʼ»¯Íê³ɣ¬»áĬÈϽ«-²©Ö÷×÷Ʒͬ²½¹¦ÄÜ-ͬ²½ȫ²¿×÷ƷÖØÖÃΪ¹رգ¨ÈôҪ¿ªÆô£¬¿ÉÒԵ½¶¶ÒôÊÚȨҳÃæÖÐÐ޸ģ©"); - Serilog.Log.Debug($"ĬÈÏÉèÖõÄÿ´ζÁȡÐÐÊýΪ:{config.BatchCount}£¬¿ÉǰÍùϵͳÅäÖÃҳÐ޸Ä"); + if (!isDevelopment) + { + var cookie = await cookieService.GetOpendCookies(); + if (cookie != null && cookie.Any()) + { + // 启动定时任务 + var quartzJobService = services.GetRequiredService(); + await quartzJobService.InitOrReStartAllJobs(config?.Cron <= 0 ? "30" : config.Cron.ToString()); + } + } } catch (Exception ex) { - var logger = services.GetRequiredService>(); - logger.LogError(ex, "Failed to initialize services on startup"); + Serilog.Log.Error(ex, "Failed to initialize services on startup"); } } - - - } } \ No newline at end of file diff --git a/Properties/PublishProfiles/FolderProfile.pubxml.user b/Properties/PublishProfiles/FolderProfile.pubxml.user deleted file mode 100644 index 529fcde..0000000 --- a/Properties/PublishProfiles/FolderProfile.pubxml.user +++ /dev/null @@ -1,11 +0,0 @@ - - - - - <_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\ - True|2025-11-12T15:06:38.7834109Z||;False|2025-11-12T23:01:02.2269478+08:00||;True|2025-10-22T22:08:11.6423148+08:00||;True|2025-10-22T21:54:24.7180547+08:00||;True|2025-10-22T21:40:03.5194315+08:00||;True|2025-10-22T21:28:28.8962766+08:00||;True|2025-10-22T21:22:47.9631689+08:00||;True|2025-10-22T21:18:24.5274318+08:00||;True|2025-10-22T21:14:51.6386326+08:00||;False|2025-10-22T21:14:07.6282769+08:00||;True|2025-10-22T21:03:48.9892860+08:00||;True|2025-10-22T21:00:56.3617243+08:00||;False|2025-10-22T21:00:30.5472941+08:00||;True|2025-10-22T20:51:29.6155916+08:00||;False|2025-10-22T20:50:47.1882956+08:00||;True|2025-10-22T15:01:04.1668366+08:00||;True|2025-10-22T14:49:43.6340569+08:00||;True|2025-10-22T14:38:39.4685603+08:00||;True|2025-10-21T18:35:28.8392541+08:00||;True|2025-10-20T10:31:05.5865212+08:00||;True|2025-10-20T10:20:41.5717101+08:00||;True|2025-10-19T10:08:33.6669332+08:00||;False|2025-10-19T10:07:22.3337545+08:00||;False|2025-10-19T10:05:22.9484805+08:00||;True|2025-10-10T16:54:27.1472888+08:00||;False|2025-10-10T16:53:44.1700030+08:00||;False|2025-10-10T16:52:48.1740453+08:00||;False|2025-10-10T16:51:21.5067253+08:00||;False|2025-10-10T16:50:19.2140597+08:00||;False|2025-10-10T16:49:21.2213290+08:00||;False|2025-10-10T16:48:47.7229948+08:00||;False|2025-10-10T16:48:15.1258700+08:00||;True|2025-09-30T13:29:42.2354235+08:00||;True|2025-09-25T11:44:52.6858389+08:00||;True|2025-09-25T11:08:27.8810109+08:00||;True|2025-09-25T09:28:50.2563374+08:00||;True|2025-09-24T16:14:02.3072184+08:00||;True|2025-09-23T22:38:52.4414757+08:00||;True|2025-09-23T22:19:07.1006356+08:00||;True|2025-09-23T22:18:01.5945225+08:00||;True|2025-09-23T22:06:15.6293613+08:00||;True|2025-09-23T21:53:14.2977444+08:00||;True|2025-09-23T21:46:55.2322411+08:00||;False|2025-09-23T21:45:23.7858854+08:00||;False|2025-09-23T21:03:51.8325689+08:00||;True|2025-09-23T10:03:32.2251920+08:00||;False|2025-09-23T09:38:08.2372201+08:00||;False|2025-09-23T09:37:49.9390545+08:00||;True|2024-03-11T21:31:45.8102398+08:00||;True|2024-03-11T07:26:24.7660541+08:00||;True|2024-03-08T22:08:40.0154831+08:00||;True|2024-03-03T10:14:36.8109114+08:00||;True|2024-03-02T18:44:57.3288537+08:00||;True|2024-01-24T17:51:37.9164415+08:00||;True|2024-01-24T16:36:50.5612157+08:00||;True|2024-01-24T15:51:35.7556653+08:00||;True|2024-01-17T23:40:40.7526618+08:00||;True|2024-01-17T23:36:10.3692844+08:00||;True|2024-01-17T23:22:03.2378834+08:00||;True|2024-01-03T11:35:44.7118292+08:00||;True|2024-01-03T11:11:23.4270453+08:00||;True|2024-01-03T11:04:35.2081526+08:00||;True|2024-01-03T10:57:03.7053107+08:00||;True|2024-01-03T10:51:50.7463989+08:00||;False|2024-01-03T10:50:24.9775312+08:00||;True|2024-01-03T10:47:30.1128183+08:00||;True|2024-01-03T10:42:55.8640657+08:00||;True|2024-01-03T09:24:24.3436056+08:00||;True|2024-01-02T23:40:38.2001198+08:00||;True|2024-01-02T23:08:36.7230444+08:00||;True|2024-01-02T22:53:43.9658255+08:00||;True|2024-01-02T22:25:38.5545279+08:00||;False|2024-01-02T22:24:42.6577609+08:00||;True|2024-01-02T22:04:33.4708889+08:00||;True|2024-01-02T21:49:57.9249898+08:00||;True|2023-12-28T22:42:58.5976180+08:00||;True|2023-12-28T22:24:09.2051205+08:00||;True|2023-12-28T22:05:48.4865497+08:00||;True|2023-12-28T21:50:57.4549336+08:00||;True|2023-12-28T21:45:30.4557939+08:00||;True|2023-12-28T21:19:59.3614594+08:00||;False|2023-12-28T21:17:04.3069097+08:00||;False|2023-12-28T21:15:56.7788810+08:00||;False|2023-12-28T21:14:57.3617402+08:00||;False|2023-12-28T21:10:45.4382469+08:00||;True|2023-12-28T19:45:33.1431914+08:00||;True|2023-12-26T11:30:36.6418716+08:00||;True|2023-12-26T11:17:40.6433651+08:00||;True|2023-12-26T09:13:56.0741559+08:00||;True|2023-12-26T09:10:55.5358737+08:00||;True|2023-12-25T23:15:34.6534359+08:00||;True|2023-12-25T23:09:47.1566176+08:00||;False|2023-12-25T23:07:32.1118803+08:00||;True|2023-12-25T22:59:50.4643459+08:00||;True|2023-12-25T22:50:14.3584798+08:00||;False|2023-12-25T22:49:20.0279513+08:00||;True|2023-12-25T22:44:07.4183973+08:00||;True|2023-12-25T22:35:27.3847085+08:00||;True|2023-12-25T22:23:21.3542658+08:00||;True|2023-12-25T22:12:00.5219522+08:00||; - - - \ No newline at end of file diff --git a/Properties/PublishProfiles/FolderProfile.pubxml b/Properties/PublishProfiles/docker.pubxml similarity index 80% rename from Properties/PublishProfiles/FolderProfile.pubxml rename to Properties/PublishProfiles/docker.pubxml index 3ed83c2..eb42734 100644 --- a/Properties/PublishProfiles/FolderProfile.pubxml +++ b/Properties/PublishProfiles/docker.pubxml @@ -1,7 +1,5 @@  - + true @@ -17,5 +15,8 @@ https://go.microsoft.com/fwlink/?LinkID=208121. net6.0 680660ef-acae-43a9-ab6c-b75532e758ad false + + + \ No newline at end of file diff --git a/Properties/PublishProfiles/fn.pubxml b/Properties/PublishProfiles/fn.pubxml new file mode 100644 index 0000000..4c80046 --- /dev/null +++ b/Properties/PublishProfiles/fn.pubxml @@ -0,0 +1,32 @@ + + + + + true + false + true + Release + Any CPU + FileSystem + bin\Release\net6.0\publish\ + FileSystem + <_TargetId>Folder + + net6.0 + 680660ef-acae-43a9-ab6c-b75532e758ad + true + linux-x64 + true + false + none + false + true + + true + + true + + + \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json index a492985..9befb46 100644 --- a/Properties/launchSettings.json +++ b/Properties/launchSettings.json @@ -2,13 +2,13 @@ "profiles": { "dy.net": { "commandName": "Project", - "launchBrowser": true, + //"launchBrowser": true, "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "dotnetRunMessages": true, - "applicationUrl": "http://localhost:5193" + "applicationUrl": "http://localhost:10101" }, "IIS Express": { "commandName": "IISExpress", diff --git a/README.md b/README.md index a23bba9..9e57f62 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,191 @@ -# dysync.net - 抖音同步工具(收藏/喜欢/指定博主,无缝对接Emby/Jellyfin) +# dysync.net - 抖音同步工具(抖小云) -`dysync.net` 是一款基于 **.NET Core 6.0** 和 **Vue** 开发的工具,用于同步抖音收藏夹、「我喜欢」的视频及指定博主作品,解决个人收藏视频易失效的问题。支持多账号同步,内置视频信息刮削功能,同步后的视频可直接在 Emby 或 Jellyfin 中播放。 +`dysync.net` 是一款基于 **.NET Core ** 和 **Vue** 开发的工具,用于同步抖音收藏夹、「我喜欢」的视频及指定博主作品,支持多账号同步,内置视频信息刮削功能,同步后的视频可直接在 Emby 或 Jellyfin 中播放。 -> 🔧 问题反馈:使用中遇到任何问题,欢迎添加 QQ:279225040 咨询 +> 🔧 **问题反馈**:如使用中遇到任何问题,欢迎进Q群反馈:759876963,不一定及时回复,见谅。 --- -### 📺 Emby 播放效果预览 +## 📺 Emby 效果预览 ![Emby播放效果](docs/emby.png) --- -## 📌 1. 获取抖音关键信息(必做!同步核心凭证) -Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,避免遗漏或错误。 - -### 1.1 提取抖音 Cookie -1. 打开 **抖音网页版** (https://www.douyin.com/) 并登录目标账号; -2. 进入「我的收藏」页面,确保页面加载完成; -3. 按 `F12` 打开浏览器「开发者工具」,切换到「Network (网络)」标签; -4. 刷新页面,在搜索框中输入 `v1/web/aweme/listcollection` 筛选请求; -5. 点击任意一条筛选结果,在右侧「Headers (标头)」中找到 `Cookie` 字段,**完整复制整段内容**(不可删减字符)。 - -![获取Cookie步骤](docs/getcookies.png) -![提取个人sec_user_id](docs/secUserId.png) - -### 1.2 提取 `sec_user_id`(个人/指定博主) -- **个人 sec_user_id**(同步自己的收藏/喜欢用): - 进入自己的抖音主页,浏览器地址栏中 `sec_user_id=` 后的字符串即为个人 ID(如 `https://www.douyin.com/user/sec_user_id=xxx`)。 -- **博主 sec_user_id**(同步指定博主作品用): - 1. 进入目标博主主页; - 2. 方式1:直接复制地址栏中 `user/` 到 `?from_tab_name`中间部分内容即是博主的 `sec_user_id`; - 3. 方式2:按 `F12` → 「Network」→ 任意请求 → 「Headers」→ 提取 `sec_user_id` 字段值。 - -> ⚠️ 风控提示:同步博主作品时,**慎用开启全量同步**(一次性下载过多易被抖音限制访问)。 - -![提取博主sec_user_id](docs/upers-uid.png) +## 📋 目录 +- [dysync.net - 抖音同步工具(抖小云)](#dysyncnet---抖音同步工具抖小云) + - [📺 Emby 播放效果预览](#-emby-播放效果预览) + - [📋 目录](#-目录) + - [1. 获取抖音关键信息(必做!同步核心凭证)](#1-获取抖音关键信息必做同步核心凭证) + - [1.1 提取抖音 Cookie](#11-提取抖音-cookie) + - [1.2 提取 `sec_user_id`(个人/指定博主)](#12-提取-sec_user_id个人指定博主) + - [2. 路径映射规则(核心!错配会导致无法访问/数据丢失)](#2-路径映射规则核心错配会导致无法访问数据丢失) + - [3. 默认账号密码(首次登录用)](#3-默认账号密码首次登录用) + - [4. 运行方式(推荐 Docker Compose)](#4-运行方式推荐-docker-compose) + - [镜像版本](#镜像版本) + - [构建命令示例](#构建命令示例) + - [方式一:Docker 命令行](#方式一docker-命令行) + - [方式二:Docker Compose 运行(推荐)](#方式二docker-compose-运行推荐) + - [🚀 5. 软件截图](#-5-软件截图) + - [🚀 5. 已有功能与计划](#-5-已有功能与计划) --- -## 📁 2. 路径映射规则(核心!错配会导致无法访问/数据丢失) +## 1. 获取抖音关键信息(必做!同步核心凭证) + +Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,避免遗漏或错误。 + + +### 1.1 提取`Cookie`以及 `sec_uer_id` +1. 打开 **抖音网页版** (https://www.douyin.com/) 并登录目标账号; +2. 进入个人主页,按下F12进入开发者模式、并切换到 `网络`(也可能叫`network`). +3. 在筛选框中输入`/follow`. +4. 点击自己头像边上的 `关注` 按钮、会弹出你的关注列表,然后在右侧网络请求里面会出现多个请求,随便选一个. +4. 在请求的标签里面切换到`负载`(也可能叫`payload`) +5. 找到sec_user_id,复制值即可 +6. 在请求的标签里面切换到`标头`(也可能叫`Headers`) +7. 往下拉,直到出现`Cookie` ,复制完整的值,注意前后不要带换行符,很多人会多复制个换行符出来. + +![cookie](docs/findcookie_1_2_3.png) +![cookie](docs/findcookie_4.png) +![cookie](docs/findcookie_5.png) +![cookie](docs/findcookie_6_7.png) + +### 1.2 提取 博主的`sec_user_id`以及博主的uid +- **对于想下载博主视频,但是又不想关注博主,需要用到** +- 1.进入博主主页,按`F12` 点击`Network` 或`网络` 筛选器里面填`/web/aweme/post` 然后切到`预览`或`preview` 展开 json数据结果 找到`aweme_list` 然后随便点开其中一个子项 即可找到`aweme_list[0].author.sec_uid` 这便是博主的uid 。后续在关注列表中,需要手动添加非关注博主同步视频时将会要用到。 +![获取Cookie步骤](docs/finduser_id.png) +--- + +## 2. 路径映射规则(核心!错配会导致无法访问/数据丢失) 为实现视频在 Emby/Jellyfin 中正常播放及数据持久化,需正确配置本地路径与容器路径的映射: -| 存储类型 | 容器内路径 | 本地路径配置要求 | 用途说明 | -|------------------|------------------|----------------------------------------------------------------------------------|------------------------------| -| 个人收藏视频 | `/app/collect` | 映射到本地目录(如 NAS:`/volume1/抖音/收藏`、电脑:`D:/抖音/收藏`) | 存储同步后的收藏视频 | -| 个人喜欢视频 | `/app/favorite` | 映射到本地目录(建议与收藏视频路径区分,如 `/volume1/抖音/喜欢`) | 存储同步后的「我喜欢」视频 | -| 指定博主视频 | `/app/uper` | 映射到本地目录(如 `/volume1/抖音/博主作品`) | 存储同步后的博主视频 | -| 数据库文件 | `/app/db` | 映射到本地稳定目录(如 `/volume1/抖音/工具数据库`) | 持久化配置、同步记录(防止容器删除后数据丢失) | -| 多账号视频(可选)| `/app/user1`/`/app/user2` 等 | 为每个账号分配独立本地路径(如 `/volume1/抖音/账号A`、`/volume1/抖音/账号B`) | 实现多账号隔离同步 | +| 存储类型 | 容器内路径 | 本地路径配置要求 | 用途说明 | +| ------------ | --------------- | --------------------------------------------------------------------- | -------------------------- | +| 个人收藏视频 | `/app/collect` | 映射到本地目录(如 NAS:`/volume1/抖音/收藏`、电脑:`D:/抖音/收藏`) | 存储同步后的收藏视频 | +| 个人喜欢视频 | `/app/favorite` | 映射到本地目录(建议与收藏视频路径区分,如 `/volume1/抖音/喜欢`) | 存储同步后的「我喜欢」视频 | +| 图文视频 | `/app/images` | 映射到本地目录(建议与收藏视频路径区分,如 `/volume1/抖音/图文视频`) | 存储同步后的「图文视频」 | +| 指定博主视频 | `/app/uper` | 映射到本地目录(如 `/volume1/抖音/博主作品`) | 存储同步后的博主视频 | +| 指定博主视频 | `/app/mix` | 映射到本地目录(如 `/volume1/抖音/合集`) | 存储同步后的合集视频 | +| 指定博主视频 | `/app/series` | 映射到本地目录(如 `/volume1/抖音/短剧`) | 存储同步后的短剧视频 | +| 数据库文件 | `/app/db` | 映射到本地稳定目录(如 `/volume1/抖音/db`) | 持久化配置、同步记录 | +| 数据库文件 | `/app/mp3` | 映射到本地稳定目录(如 `/volume1/抖音/mp3`) | 图文视频自定义音频(用于原视频音频带版权的情况) | -> ✅ 关键注意: -> 1. 本地路径需与后续后台「抖音授权」页面配置的「文件存储路径」**完全一致**; -> 2. 未配置路径映射时,文件仅存于容器内部,Emby/Jellyfin 无法访问,且容器删除后数据丢失。 +> ✅ **关键注意**: +> 1. 抖音授权时配置的路径为上面 表格里面 容器内路径 如 `/app/collect` 且注意 必须是`/`开头,路径可以自己随便命名,只要保证 授权里面配的路径有在docker-compose里面添加映射即可 +> 2. 未配置路径映射时,文件仅存于容器内部,且容器删除后数据丢失。 ![路径配置示例](docs/auth2.png) +![路径配置示例](docs/auth.png) + --- -## 🔑 3. 默认账号密码(首次登录用) +## 3. 默认账号密码(首次登录用) 首次访问后台管理页面时,使用以下默认账号密码: - **用户名**:`douyin` -- **密码**:`douyin2025` +- **密码**:`douyin2026` -> ⚠️ 安全建议:登录后修改密码。 +> ⚠️ **安全建议**:登录后修改密码。 --- -## 🚀 4. 运行方式(推荐 Docker Compose,更易维护) +## 4. 运行方式(推荐 Docker Compose) +### 镜像版本 + +| 镜像标签 | 架构 | +| ----------------- | -------------- | +| `beta_2.2.0` | x86_64 (amd64) | +| `arm_2.2.0` | ARM64 | +| `latest` | x86_64 (amd64) | + + +### 构建命令示例 将下方命令中的「本地路径」替换为你的实际路径,终端执行即可: -```bash - -### Arm设备请使用 registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0 版本镜像 -### 方式一:Docker 命令行 -docker run -d --restart=always \ - -v /你的/本地/收藏视频路径:/app/collect \ - -v /你的/本地/喜欢视频路径:/app/favorite \ - -v /你的/本地/数据库路径:/app/db \ - -v /你的/本地/博主视频路径:/app/uper \ - -p 10101:10101 \ - --name dysync2025 \ - registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net -# 注意:-p 后面的容器端口必须为 10101(源码固定) -### 方式二:Docker Compose 运行(推荐) -### Arm设备请使用 registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net:arm_1.0 版本镜像 +### 方式一:Docker Compose 运行(推荐) 创建 docker-compose.yml 文件,复制以下内容,替换「本地路径」后执行 docker-compose up -d: -version: '3.8' +需要注意:unraid玩家需要增加 user配置 --user 1000:100 + +```yaml services: dysync: - image: registry.cn-hangzhou.aliyuncs.com/jianzhichu/dysync.net - container_name: dysync2025 - restart: always + image: ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_2.2.0 + container_name: dysync2026 # 容器名称 + restart: unless-stopped # 始终重启容器,除非容器被手动停止或Docker服务停止 ports: - - "10101:10101" + - "10101:10101" volumes: - - /volume1/docker/dysync/db:/app/db + # 基础路径映射 + - /vol2/1000/media/dysync/db:/app/db # 数据库目录(持久化配置和同步记录) + # 默认音频目录(用于图文/动态视频合成,版权受限音频的替代) + - /vol2/1000/media/dysync/mp3:/app/mp3 + # 账号1 - 视频存储目录 + - /vol2/1000/media/dysync/dy1/coll:/app/collect # 个人收藏视频 + - /vol2/1000/media/dysync/dy1/fav:/app/favorite # 个人喜欢视频 + - /vol2/1000/media/dysync/dy1/up:/app/uper # 指定博主视频 + - /vol2/1000/media/dysync/dy1/mix:/app/mix # 合集(不需要可删除) + - /vol2/1000/media/dysync/dy1/series:/app/series # 短剧(不需要可删除) + + # 账号2 - 多账号路径映射示例(可选:授权时可复用账号1路径) + - /vol2/1000/media/dysync/dy2/collect:/app/collect2 + - /vol2/1000/media/dysync/dy2/fav:/app/favorite2 + - /vol2/1000/media/dysync/dy2/up:/app/uper2 - - /volume2/mediay/dysync:/app/collect - - /volume2/mediay/dysync_fav:/app/favorite - - /volume2/mediay/dysync_up:/app/uper - # 下面是多账号路径映射示例(可选) - - /volume2/mediay/dysync2:/app/yeyeye - - /volume2/mediay/dysync2_fav:/app/yeyeye_fav - - /volume2/mediay/dysync2_up:/app/yeyeye_up - # 配置DNS服务器(解决域名解析失败导致的无法访问外网) network_mode: bridge dns: - - 8.8.8.8 # Google DNS - - 114.114.114.114 # 国内DNS - - 223.5.5.5 - + - 223.5.5.5 # 阿里云DNS(备用) + - 114.114.114.114 # 国内114 DNS(提升国内访问稳定性) + deploy: + resources: + limits: + memory: 250m # 限制内存占用≤250MB(图文视频下载场景不建议设置) ``` + + 另一个版本去掉了所有视频存储路径设置项,docker只需要映射3个路径就可以,但是!!! + 不兼容老的版本,如果觉得这种方式好一些可以重新部署。其他没什么变化。 + 所有路径都变成 根目录/cookie名字/视频类型/(如果是合集或者短剧这里还有一层)/合集或短剧/名称/视频文件 + +``` +services: + dysync: + image: ccr.ccs.tencentyun.com/jianzhichu/dysync:latest + container_name: dysync_latest # 容器自定义名称 + restart: unless-stopped # 容器非手动停止则自动重启 + ports: + - "10110:10101" # 端口映射 + environment: + # 限制GC堆最大内存为100MB(100*1024*1024=104857600,可根据需求调整) + - COMPlus_GCHeapHardLimit=154857600 + # 禁用服务器GC,改用工作站GC(轻量应用更友好) + - COMPlus_GCServer=0 + # 可选:强制GC回收后释放内存给操作系统(Linux专属) + - COMPlus_GCConserveMemory=1 + # 可选:显示GC日志(排查内存问题时用) + # - COMPlus_GCLogFile=/app/gc.log + + volumes: + # 基础路径映射 - 主账号(账号1) + - /vol2/1000/media/dy001/db:/app/db # 数据库目录(持久化配置和同步记录) + - /vol2/1000/media/dy001/mp3:/app/mp3 # 备用音频目录(版权音频无法下载时使用) + - /vol2/1000/media/dy001/:/app/data # 视频存储目录 + + + network_mode: bridge + dns: + - 223.5.5.5 # 阿里云DNS(备用) + - 114.114.114.114 # 国内114 DNS(提升国内访问稳定性) + deploy: + resources: + limits: + memory: 211m # 限制内存占用≤250MB(图文视频下载场景不建议设置) +``` + ## 🚀 5. 软件截图 ![输入图片说明](docs/homepage.png) @@ -125,8 +194,111 @@ services: ![输入图片说明](docs/datalist.png) -![输入图片说明](docs/auth.png) +![输入图片说明](docs/appconf.png) + +![输入图片说明](docs/player.png) + +![输入图片说明](docs/follows.png) + +![路径配置示例](docs/followset.png) ![输入图片说明](docs/logs.png) -![输入图片说明](docs/set.png) \ No newline at end of file + +
+ + + +
+ +## 🚀 5. 已有功能与计划 + + +1. ✅ 支持多账号同步(每个账号可单独配置存储路径) + +2. ✅ 收藏的视频 + +3. ✅ 喜欢的视频(点赞的视频) + +4. ✅ 图文视频(需要将图片 + mp3 合成视频) + +5. ✅ 指定博主的视频,可配置是否单独存放一个总文件夹,是否直接用视频标题做文件名 + +6. ✅ 清除日志(防止容器被日志占用太多空间,可配置保留天数) + +7. ✅ 将项目名称改成 "抖小云" 灵感来源于哪吒电影那坨云 + +8. ✅ Cookie 过期提醒,在DY授权页可查看 + +9. ✅ 自动根据去重规则进行去重(可设置去重优先级,同一个视频出现再多个分类时适用) + +10. ✅ 在同步记录页面中直接播放视频 + +11. ✅ 关注列表同步。 + +11. ✅ 选中记录,批量重新同步。以及分享(比较简陋) + +12. ✅ 关注列表支持新增,非关注的博主 + +13. ✅ 增加永久删除功能,删除后,以后不会再同步该视频 + +14. ✅ 增加配置项及关注列表(手动添加部分)导出导入功能 + +15. ✅ 支持移动端 + +16. ✅ 增加开关配置是否仅同步最近视频 + +17. ✅ 完成飞牛fpk打包:https://github.com/jianzhichu/FnDepot + +18. ✅ 合集、短剧、自定义收藏夹下载。 + +19. ✅ 增加一个工具dy.cookie.exe在源码根目录,可以快速重置失效cookie,操作说明(一步一步操作即可)。 + + +## 💖 支持项目 + +如果这个项目对你有帮助,欢迎通过以下方式支持: + +### ⭐ Star 项目 +点击右上角的 ⭐ Star 按钮,让更多人发现这个项目 + +### 💰 赞赏支持 + +- 扫码请作者喝杯奶茶 ☕ + +| 微信支付 | 支付宝 | +| :-------------------------------------: | :--------------------------------------: | +| | | + +### 🎁 其他支持方式 +- 分享给更多需要的朋友 +- 提交 Bug 报告和功能建议 +- 贡献代码和文档 + +--- + +

+ 感谢你的支持!❤️ +

+ +# 免责声明 + +本项目(dysync.net)仅作为技术研究与学习交流的开源项目,其所有代码、功能及相关内容均不代表任何第三方平台的官方服务或立场。使用本项目前,请仔细阅读以下条款: + +## 1. 授权与使用限制 +- 本项目未获得抖音(Douyin)等任何第三方平台的官方授权、合作或认可,与该等平台无任何关联。 +- 项目功能仅允许用于个人技术研究和学习目的,严禁用于商业用途、非法活动或违反任何第三方平台规则的行为。 + +## 2. 知识产权说明 +- 项目中涉及的第三方平台(如抖音)的名称、商标、接口、内容等均为其各自所有者的知识产权。 +- 本项目仅对技术实现方式进行研究,不涉及对第三方知识产权的侵占或滥用。如因使用本项目引发知识产权争议,相关责任由使用者自行承担。 + +## 3. 风险提示与责任豁免 +- 使用本项目可能导致违反第三方平台的用户协议或相关规定,进而导致账号限制、功能封禁等风险,此类风险由使用者自行承担。 +- 项目开发者不对使用本项目产生的任何直接或间接损失(包括但不限于数据丢失、设备损坏、账号问题等)承担责任。 +- 使用者应确保自身行为符合《网络安全法》《著作权法》等相关法律法规,不得利用本项目从事侵权、违法或违背公序良俗的活动。 + +## 4. 声明的修改与生效 +- 本免责声明的最终解释权归项目开发者所有,开发者保留随时修改本声明的权利。 +- 修改后的声明将在项目仓库中更新,建议使用者定期查阅。 +- 一旦使用本项目的代码、功能或相关资源,即视为您已充分理解并同意本免责声明的全部条款。如不同意,请立即停止使用。 \ No newline at end of file diff --git a/app/.env b/app/.env index adce5e5..8d1c6ac 100644 --- a/app/.env +++ b/app/.env @@ -1,2 +1,2 @@ VITE_BASE_URL=/ -VITE_API_URL=http://localhost +VITE_API_URL=/ diff --git a/app/.env.development b/app/.env.development index 1a1c4ba..9d6a59d 100644 --- a/app/.env.development +++ b/app/.env.development @@ -1 +1,2 @@ -VITE_API_URL=http://localhost:10101 +VITE_BASE_URL=/ +VITE_API_URL=http://localhost:10101/ \ No newline at end of file diff --git a/app/index.html b/app/index.html index 5a5af33..302ff6d 100644 --- a/app/index.html +++ b/app/index.html @@ -4,112 +4,140 @@ - - 抖音同步小帮手 + + 抖小云 +
- - -
- -
loading ...
- -
-
首次加载可能较慢,请耐心等待...
+ + +
+
+ - - - \ No newline at end of file diff --git a/app/package.json b/app/package.json index e7db7b2..d1be31d 100644 --- a/app/package.json +++ b/app/package.json @@ -32,11 +32,13 @@ "nprogress": "^0.2.0", "pinia": "^2.0.33", "qs": "^6.10.1", + "sortablejs": "^1.15.6", "stepin": "2.1.42-beta", "vue": "^3.3.4", "vue-json-editor": "^1.4.3", "vue-request": "^2.0.4", - "vue-router": "^4.1.6" + "vue-router": "^4.1.6", + "vue-sortablejs": "^1.0.4" }, "devDependencies": { "@tailwindcss/container-queries": "^0.1.0", @@ -50,8 +52,9 @@ "autoprefixer": "^10.4.7", "babel-preset-es2015": "^6.24.1", "babel-register": "^6.26.0", + "baseline-browser-mapping": "^2.9.15", "body-parser": "^1.20.1", - "crypto-js": "^4.1.1", + "crypto-js": "^4.2.0", "gh-pages": "^3.1.0", "gulp": "^4.0.2", "gulp-clean": "^0.4.0", diff --git a/app/public/logo.png b/app/public/logo.png index c3435d4..af2ad7b 100644 Binary files a/app/public/logo.png and b/app/public/logo.png differ diff --git a/app/src/App.vue b/app/src/App.vue index 51686fe..200fbf1 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -1,6 +1,6 @@ + + \ No newline at end of file diff --git a/app/src/components/layout/HeaderActions.vue b/app/src/components/layout/HeaderActions.vue index 88aeefe..6b8f179 100644 --- a/app/src/components/layout/HeaderActions.vue +++ b/app/src/components/layout/HeaderActions.vue @@ -1,85 +1,360 @@ + + + + + + + + + - --> + - - + +
+ + + + + + + +
+
+ + - --> + + + +.action-icon { + font-size: 20px; +} + +/* 版本 Popover 样式(纯模板适配) */ +:deep(.version-popover-overlay) { + .ant-popover-inner { + padding: 16px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + width: 520px; + } + + .ant-popover-arrow-content { + background-color: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + } +} + +.version-popover-content { + width: 100%; + box-sizing: border-box; +} + +/* 版本列表(模板对应样式) */ +.custom-version-list { + width: 100%; + padding: 8px 0; + box-sizing: border-box; +} + +.custom-version-item { + width: 100%; + padding: 8px 12px; + border-bottom: 1px solid #f0f0f0; + border-radius: 4px; + transition: all 0.2s ease; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + white-space: nowrap; + background-color: transparent; + + &:hover { + background-color: #f5fafe; + } + + &:last-child { + border-bottom: none; + } +} + +/* 当前版本/最新版本高亮 */ +.custom-current-version { + background-color: rgba(24, 144, 255, 0.05) !important; + + &:hover { + background-color: rgba(24, 144, 255, 0.1) !important; + } +} + +.custom-latest-version { + background-color: rgba(46, 125, 50, 0.05) !important; + + &:hover { + background-color: rgba(46, 125, 50, 0.1) !important; + } +} + +/* 版本文本容器 */ +.version-text { + display: flex; + align-items: center; + max-width: calc(100% - 40px); /* 修正:原 maxwidth 拼写错误 */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 版本复制按钮 */ +.version-copy-btn { + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background-color: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + margin: 0; + flex-shrink: 0; + transition: all 0.2s ease; + + &:hover { + background-color: rgba(24, 144, 255, 0.1); + color: #1890ff !important; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +/* 暂无版本数据 */ +.no-version-data { + text-align: center; + color: #999; + padding: 20px 0; + font-size: 14px; + background-color: #f9fafb; + border-radius: 8px; + margin: 0 12px; +} + +/* Git Popover 样式 */ +:deep(.git-popover-overlay) { + .ant-popover-inner { + padding: 16px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + width: 360px; + } + + .ant-popover-arrow-content { + background-color: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + } +} + +.git-popover-content { + width: 100%; + box-sizing: border-box; +} + +.git-repo-item { + margin-bottom: 12px; + + &:last-child { + margin-bottom: 0; + } +} + +.git-repo-name { + display: flex; + align-items: center; + font-size: 14px; + font-weight: 500; + margin-bottom: 6px; +} + +.git-repo-tag { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; +} + +.git-repo-url-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: #f9fafb; + border-radius: 4px; + font-size: 13px; +} + +.git-repo-url { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; + + &:hover { + text-decoration: underline; + opacity: 0.8; + } +} + +.git-copy-btn { + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + background-color: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + margin-left: 8px; + flex-shrink: 0; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +/* 根容器样式 */ +.header-actions-root { + display: flex; + align-items: center; + gap: 8px; +} + \ No newline at end of file diff --git a/app/src/components/layout/PageFooter.vue b/app/src/components/layout/PageFooter.vue index 6b6be30..16c8262 100644 --- a/app/src/components/layout/PageFooter.vue +++ b/app/src/components/layout/PageFooter.vue @@ -1,41 +1,137 @@ - + + + \ No newline at end of file diff --git a/app/src/components/layout/VersionDrawer.vue b/app/src/components/layout/VersionDrawer.vue new file mode 100644 index 0000000..cd434bf --- /dev/null +++ b/app/src/components/layout/VersionDrawer.vue @@ -0,0 +1,188 @@ + + + + + + \ No newline at end of file diff --git a/app/src/components/switch/DayNightSwitch.vue b/app/src/components/switch/DayNightSwitch.vue index 9f496d1..b8200ba 100644 --- a/app/src/components/switch/DayNightSwitch.vue +++ b/app/src/components/switch/DayNightSwitch.vue @@ -9,7 +9,6 @@ export type Type = 'day' | 'night'; const props = defineProps({ value: { type: String as PropType }, - // nightColor: { type: String, default: '#1D1D1D' }, nightColor: { type: String, default: '#1a1a2e' }, }); @@ -17,37 +16,34 @@ const emit = defineEmits<{ (e: 'update:value', value: Type): void; }>(); +const STORAGE_KEY = 'theme-mode'; + +const getInitialValue = () => { + if (typeof window !== 'undefined') { + const cachedValue = localStorage.getItem(STORAGE_KEY); + if (cachedValue === 'day' || cachedValue === 'night') { + return cachedValue; + } + } + return props.value || 'day'; +}; + const { value: _value } = useModelValue( () => props.value, (val) => emit('update:value', val), - 'day' + getInitialValue() ); + const switcher: { [key in Type]: Type } = { day: 'night', night: 'day', }; const { theme } = storeToRefs(useThemeStore()); +let cachedMiddleColors = {}; -// 监听主题色变换,更新缓存 -let cachedMiddleColors = cloneDeep(theme.value.color.middle); -watch( - theme, - (val) => { - if (val.color.middle['bg-base'] !== props.nightColor) { - cachedMiddleColors = cloneDeep(val.color.middle); - _value.value = 'day'; - } else { - _value.value = 'night'; - } - console.log('theme', _value.value); - updateHtmlClass(_value.value); - }, - { deep: true } -); - -// 在你的主题store中添加切换逻辑 -const updateHtmlClass = (mode) => { +// --- 修正点 1: 函数定义移到 watch 之前 --- +const updateHtmlClass = (mode: Type) => { const html = document.documentElement; if (mode === 'night') { html.classList.add('dark-mode'); @@ -55,14 +51,46 @@ const updateHtmlClass = (mode) => { html.classList.remove('dark-mode'); } }; -// 主题颜色配置 + +// 监听 theme bg-base 变化,用于同步外部修改 +watch( + () => theme.value?.color?.middle?.['bg-base'], + (newBgColor) => { + if (newBgColor) { + if (newBgColor === props.nightColor) { + _value.value = 'night'; + } else { + _value.value = 'day'; + } + } + } +); + +// --- 修正点 2: watch 现在可以安全地调用 updateHtmlClass --- +watch( + _value, + (newMode) => { + // console.log('mode switched to:', newMode); + if (typeof window !== 'undefined') { + localStorage.setItem(STORAGE_KEY, newMode); + } + updateHtmlClass(newMode); + }, + { immediate: true } +); + +// --- 修正点 3: 惰性缓存白天模式颜色 --- const colorCfg = computed(() => { if (_value.value === 'day') { + if (Object.keys(cachedMiddleColors).length === 0) { + cachedMiddleColors = cloneDeep(theme.value?.color?.middle || {}); + } return { middle: cachedMiddleColors }; } return { middle: { 'bg-base': props.nightColor } }; }); + + + \ No newline at end of file diff --git a/app/src/components/video/VideoPlayer.vue b/app/src/components/video/VideoPlayer.vue index f5f6bed..41a70fe 100644 --- a/app/src/components/video/VideoPlayer.vue +++ b/app/src/components/video/VideoPlayer.vue @@ -125,7 +125,7 @@ const loadVideoStream = async (videoId: string) => { // 构建视频流 URL,浏览器会自动发送 Range 请求 // 添加时间戳避免缓存问题 - const url = `/api/video/stream/${videoId}?t=${Date.now()}`; + const url = `/api/Video/play/${videoId}?t=${Date.now()}`; console.log('[VideoPlayer] 设置视频流 URL:', url); // 直接使用 URL,让浏览器处理 Range 请求 @@ -555,11 +555,12 @@ defineExpose({ .video-element { width: 100%; height: auto; - max-height: 70vh; + max-height: 45vh; display: block; } } + .loading-overlay { position: absolute; top: 0; @@ -578,3 +579,15 @@ defineExpose({ } + + diff --git a/app/src/pages/cok/CookieTable.vue b/app/src/pages/cok/CookieTable.vue index 986543d..722ecde 100644 --- a/app/src/pages/cok/CookieTable.vue +++ b/app/src/pages/cok/CookieTable.vue @@ -1,30 +1,36 @@ + + + + + \ No newline at end of file diff --git a/app/src/pages/login/LoginBox.vue b/app/src/pages/login/LoginBox.vue index ae14600..a665bb5 100644 --- a/app/src/pages/login/LoginBox.vue +++ b/app/src/pages/login/LoginBox.vue @@ -1,78 +1,103 @@ \ No newline at end of file diff --git a/app/src/pages/mobile/MobileDashboard.vue b/app/src/pages/mobile/MobileDashboard.vue new file mode 100644 index 0000000..3e1547c --- /dev/null +++ b/app/src/pages/mobile/MobileDashboard.vue @@ -0,0 +1,2288 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/pages/mylogs/MyLogs.vue b/app/src/pages/mylogs/MyLogs.vue index 052014c..38734bb 100644 --- a/app/src/pages/mylogs/MyLogs.vue +++ b/app/src/pages/mylogs/MyLogs.vue @@ -1,79 +1,293 @@ + - + +// 调整form布局,让复制按钮靠右 +:deep(.ant-form-inline) { + display: flex; + align-items: center; + width: 100%; +} + \ No newline at end of file diff --git a/app/src/pages/personal/EmailSet.vue b/app/src/pages/personal/EmailSet.vue deleted file mode 100644 index 863711c..0000000 --- a/app/src/pages/personal/EmailSet.vue +++ /dev/null @@ -1,173 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/pages/personal/MyPersonal.vue b/app/src/pages/personal/MyPersonal.vue index 6544a65..7030a97 100644 --- a/app/src/pages/personal/MyPersonal.vue +++ b/app/src/pages/personal/MyPersonal.vue @@ -1,26 +1,6 @@