From 30060fe29c6e113694ca1bbf8c67003f4dcf0988 Mon Sep 17 00:00:00 2001 From: yuming Date: Thu, 2 Jul 2026 20:58:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=86=E6=9C=AC=E5=9C=B0=E7=8B=AC?= =?UTF-8?q?=E6=9C=89=E5=8A=9F=E8=83=BD=E7=A7=BB=E6=A4=8D=E5=88=B0=20upstre?= =?UTF-8?q?am=20=E6=9C=80=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 upstream/master (c5922f9) 新建分支,移植以下功能: 1. 扫码登录(DyQrCodeService + ConfigController 3个接口) 2. 视频预览组件(VideoPlayer.vue) 3. 下载配置服务(DownloadConfigService + AppConfig 6个扩展字段) 4. Gitea CI/CD(.gitea/workflows/deploy.yml) 5. 生产 Dockerfile(Dockerfile.prod,含中国镜像源配置) Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/deploy.yml | 47 ++ Controllers/ConfigController.cs | 64 ++- Dockerfile.prod | 86 ++++ app/src/components/video/VideoPlayer.vue | 580 +++++++++++++++++++++++ dy.net.csproj | 1 + extension/ServiceExtension.cs | 3 + model/entity/AppConfig.cs | 32 ++ service/DownloadConfigService.cs | 121 +++++ service/DyQrCodeService.cs | 153 ++++++ 9 files changed, 1086 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 Dockerfile.prod create mode 100644 app/src/components/video/VideoPlayer.vue create mode 100644 service/DownloadConfigService.cs create mode 100644 service/DyQrCodeService.cs diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..f585b47 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,47 @@ +name: 部署到群晖 + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: 安装 Docker CLI + run: | + # 替换 Debian 源为清华镜像,避免国内连 deb.debian.org 超时 + sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true + sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true + apt-get update -qq + apt-get install -y -qq docker.io + + - name: 拉取代码 + uses: actions/checkout@v4 + + - name: 构建镜像 + run: docker build -f Dockerfile.prod -t dysync:latest . + + - name: 停止旧容器 + run: | + docker stop dysync 2>/dev/null || true + docker rm dysync 2>/dev/null || true + + - name: 启动新容器 + run: | + docker run -d \ + --name dysync \ + --restart unless-stopped \ + -p 10101:10101 \ + -e TZ=Asia/Shanghai \ + -e ASPNETCORE_ENVIRONMENT=Production \ + -v /volume1/docker/apps/dysync/db:/app/db \ + -v /volume1/docker/apps/dysync/logs:/app/logs \ + -v /volume2/save/dysync/collect:/app/collect \ + -v /volume2/save/dysync/favorite:/app/favorite \ + -v /volume2/save/dysync/uper:/app/uper \ + dysync:latest + + - name: 部署完成提示 + run: | + echo "✅ 部署完成,浏览器访问 http://192.168.1.66:10101 查看" diff --git a/Controllers/ConfigController.cs b/Controllers/ConfigController.cs index a2c688c..0d8ec41 100644 --- a/Controllers/ConfigController.cs +++ b/Controllers/ConfigController.cs @@ -25,10 +25,11 @@ namespace dy.net.Controllers private readonly DouyinFollowService douyinFollowService; private readonly DouyinCookieService douyinCookieService; private readonly DouyinHttpClientService httpClientService; + private readonly DyQrCodeService _qrCodeService; - 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, DyQrCodeService qrCodeService) { this.dyCookieService = dyCookieService; this.commonService = commonService; @@ -36,6 +37,7 @@ namespace dy.net.Controllers this.douyinFollowService = douyinFollowService; this.douyinCookieService = douyinCookieService; this.httpClientService = httpClientService; + _qrCodeService = qrCodeService; } @@ -484,5 +486,65 @@ namespace dy.net.Controllers return ApiResult.Fail("文件不存在"); } } + + /// + /// 获取抖音扫码登录二维码 + /// + [AllowAnonymous] + [HttpGet("qrcode")] + public async Task GetQrCode() + { + try + { + var (token, qrBase64) = await _qrCodeService.GetQrCodeAsync(); + return Ok(new { code = 0, data = new { token, qrBase64 } }); + } + catch (Exception ex) + { + return Ok(new { code = -1, error = ex.Message }); + } + } + + /// + /// 轮询扫码状态(前端每2秒调一次) + /// status: 1=待扫码, 2=已扫码待确认, 3=登录成功, -1=已过期 + /// + [AllowAnonymous] + [HttpGet("qrstatus")] + public async Task GetQrStatus([FromQuery] string token) + { + try + { + var (status, cookies, secUserId) = await _qrCodeService.CheckStatusAsync(token); + return Ok(new { code = 0, data = new { status, cookies, secUserId } }); + } + catch (Exception ex) + { + Serilog.Log.Error($"查询扫码状态失败: {ex.Message}"); + return Ok(new { code = -1, error = ex.Message }); + } + } + + /// + /// 扫码成功后,将 Cookie 绑定到指定账号 + /// + [HttpPost("qrbind")] + public async Task BindQrCode([FromQuery] string token, [FromQuery] string id) + { + try + { + var (cookies, secUserId) = _qrCodeService.GetSessionResult(token); + if (string.IsNullOrEmpty(cookies)) + return Ok(new { code = -1, error = "扫码尚未完成或已过期" }); + + var result = await douyinCookieService.FastResetCookie(id, cookies); + _qrCodeService.RemoveSession(token); + return Ok(new { code = result ? 0 : -1, secUserId }); + } + catch (Exception ex) + { + return Ok(new { code = -1, error = ex.Message }); + } + } } } diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..6f955b9 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,86 @@ +# 多阶段构建 Dockerfile +# 第一阶段:构建前端 +FROM node:18-alpine AS frontend-builder + +WORKDIR /app/frontend + +COPY app/package.json app/pnpm-lock.yaml* ./ + +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; \ + else \ + npm install; \ + fi + +COPY app/ ./ +RUN pnpm build || npm run build + +# 第二阶段:构建后端 + 下载 Playwright Chromium +FROM mcr.azurecr.cn/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 + +COPY . . +RUN find . -name "._*" -type f -delete + +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 + +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/* + +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 + +ENTRYPOINT ["dotnet", "dy.net.dll"] diff --git a/app/src/components/video/VideoPlayer.vue b/app/src/components/video/VideoPlayer.vue new file mode 100644 index 0000000..f5f6bed --- /dev/null +++ b/app/src/components/video/VideoPlayer.vue @@ -0,0 +1,580 @@ + + + + + + diff --git a/dy.net.csproj b/dy.net.csproj index 537dc8a..ab4ff19 100644 --- a/dy.net.csproj +++ b/dy.net.csproj @@ -76,6 +76,7 @@ + diff --git a/extension/ServiceExtension.cs b/extension/ServiceExtension.cs index d69e75b..bdc2eb3 100644 --- a/extension/ServiceExtension.cs +++ b/extension/ServiceExtension.cs @@ -309,6 +309,9 @@ namespace dy.net.extension }); services.AddScoped(); + // 扫码登录服务(Playwright,全局单例避免重复初始化) + services.AddSingleton(); + services.AddScoped(); } /// diff --git a/model/entity/AppConfig.cs b/model/entity/AppConfig.cs index da08a53..8f7edc1 100644 --- a/model/entity/AppConfig.cs +++ b/model/entity/AppConfig.cs @@ -120,6 +120,38 @@ namespace dy.net.model.entity /// [SugarColumn(IsNullable = true)] public bool CloseNfo { get; set; } + + // -------- 下载配置扩展字段(本地新增)-------- + /// + /// 是否启用自动下载 + /// + [SugarColumn(IsNullable = true)] + public bool? EnableAutoDownload { get; set; } + /// + /// 是否下载喜欢视频(videoType=1) + /// + [SugarColumn(IsNullable = true)] + public bool? DownloadFavorite { get; set; } + /// + /// 是否下载收藏视频(videoType=2) + /// + [SugarColumn(IsNullable = true)] + public bool? DownloadCollect { get; set; } + /// + /// 下载时间范围(天),0 表示不限 + /// + [SugarColumn(IsNullable = true)] + public int? DownloadDaysRange { get; set; } + /// + /// 作者白名单(JSON 数组字符串) + /// + [SugarColumn(Length = 2000, IsNullable = true)] + public string AuthorWhitelist { get; set; } + /// + /// 作者黑名单(JSON 数组字符串) + /// + [SugarColumn(Length = 2000, IsNullable = true)] + public string AuthorBlacklist { get; set; } } } diff --git a/service/DownloadConfigService.cs b/service/DownloadConfigService.cs new file mode 100644 index 0000000..e199766 --- /dev/null +++ b/service/DownloadConfigService.cs @@ -0,0 +1,121 @@ +using dy.net.model.entity; +using Newtonsoft.Json; + +namespace dy.net.service +{ + /// + /// 下载配置服务,用于判断是否应该下载视频 + /// + public class DownloadConfigService + { + private readonly DouyinCommonService _commonService; + + public DownloadConfigService(DouyinCommonService commonService) + { + _commonService = commonService; + } + + private AppConfig GetConfig() + { + return _commonService.GetConfig(); + } + + /// + /// 判断是否应该下载视频 + /// + /// 视频类型:"1"=喜欢,"2"=收藏 + /// 作者ID + /// 作者名称 + /// 视频创建时间 + /// true=应该下载,false=不应该下载 + public bool ShouldDownload(string videoType, string authorId, string authorName, DateTime createTime) + { + var config = GetConfig(); + if (config == null) + { + return false; + } + + // 如果未启用自动下载,直接返回false + if (config.EnableAutoDownload != true) + { + return false; + } + + // 检查视频类型是否允许下载 + if (videoType == "1" && config.DownloadFavorite != true) + { + return false; // 喜欢类型但未启用下载 + } + + if (videoType == "2" && config.DownloadCollect != true) + { + return false; // 收藏类型但未启用下载 + } + + // 检查时间范围 + if (config.DownloadDaysRange.HasValue && config.DownloadDaysRange.Value > 0) + { + var daysDiff = (DateTime.Now - createTime).TotalDays; + if (daysDiff > config.DownloadDaysRange.Value) + { + return false; // 超出时间范围 + } + } + + // 检查作者白名单 + if (!string.IsNullOrWhiteSpace(config.AuthorWhitelist)) + { + try + { + var whitelist = JsonConvert.DeserializeObject>(config.AuthorWhitelist); + if (whitelist != null && whitelist.Any()) + { + // 如果配置了白名单,只有白名单中的作者才下载 + var isInWhitelist = whitelist.Any(a => + (!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase)) + ); + if (!isInWhitelist) + { + return false; // 不在白名单中 + } + } + } + catch (Exception ex) + { + Serilog.Log.Warning($"解析作者白名单失败:{ex.Message}"); + } + } + + // 检查作者黑名单 + if (!string.IsNullOrWhiteSpace(config.AuthorBlacklist)) + { + try + { + var blacklist = JsonConvert.DeserializeObject>(config.AuthorBlacklist); + if (blacklist != null && blacklist.Any()) + { + // 如果配置了黑名单,黑名单中的作者不下载 + var isInBlacklist = blacklist.Any(a => + (!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase)) + ); + if (isInBlacklist) + { + return false; // 在黑名单中 + } + } + } + catch (Exception ex) + { + Serilog.Log.Warning($"解析作者黑名单失败:{ex.Message}"); + } + } + + // 所有检查都通过,可以下载 + return true; + } + } +} + diff --git a/service/DyQrCodeService.cs b/service/DyQrCodeService.cs new file mode 100644 index 0000000..57480e8 --- /dev/null +++ b/service/DyQrCodeService.cs @@ -0,0 +1,153 @@ +using System.Collections.Concurrent; +using Microsoft.Playwright; + +namespace dy.net.service +{ + public class DyQrCodeService + { + private static readonly ConcurrentDictionary _sessions = new(); + + private class QrSession + { + public IPlaywright Playwright { get; set; } + public IBrowser Browser { get; set; } + public IBrowserContext Context { get; set; } + public IPage Page { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.Now; + public string FinalCookies { get; set; } + public string SecUserId { get; set; } + } + + private const string LoginUrl = "https://creator.douyin.com/creator-micro/login"; + // QR 码图片的 class 名含 qrcode_img(后缀随版本变化,用属性选择器匹配) + private const string QrSelector = "img[class*='qrcode_img']"; + + public async Task<(string token, string qrBase64)> GetQrCodeAsync() + { + CleanExpiredSessions(); + + var playwright = await Playwright.CreateAsync(); + var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + Args = new[] { "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage" } + }); + var context = await browser.NewContextAsync(new BrowserNewContextOptions + { + UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36" + }); + var page = await context.NewPageAsync(); + + await page.GotoAsync(LoginUrl, new PageGotoOptions { Timeout = 15000 }); + await page.WaitForSelectorAsync(QrSelector, new PageWaitForSelectorOptions { Timeout = 10000 }); + + // img.src 已经是 data:image/png;base64,... 直接取用 + var src = await page.EvaluateAsync($"() => document.querySelector(\"{QrSelector}\").src"); + var qrBase64 = src.Replace("data:image/png;base64,", ""); + + var token = Guid.NewGuid().ToString("N"); + _sessions[token] = new QrSession + { + Playwright = playwright, + Browser = browser, + Context = context, + Page = page + }; + + return (token, qrBase64); + } + + public async Task<(int status, string cookies, string secUserId)> CheckStatusAsync(string token) + { + if (!_sessions.TryGetValue(token, out var session)) + return (-1, null, null); + + if (DateTime.Now - session.CreatedAt > TimeSpan.FromMinutes(5)) + { + await CloseSessionAsync(token, session); + return (-1, null, null); + } + + // 已经登录过,直接返回缓存 + if (session.FinalCookies != null) + return (3, session.FinalCookies, session.SecUserId); + + var url = session.Page.Url; + + // 还在登录页 + if (url.Contains("/creator-micro/login") || url.Contains("sso.douyin.com")) + { + // 判断是否已扫码(页面出现"确认"或"已扫码"文字) + var scanned = await session.Page.EvaluateAsync( + "() => { const t = document.body.innerText; return t.includes('已扫码') || t.includes('确认登录'); }" + ); + return (scanned ? 2 : 1, null, null); + } + + // URL 已离开登录页,说明登录成功 + var browserCookies = await session.Context.CookiesAsync(new[] + { + "https://creator.douyin.com", + "https://www.douyin.com", + "https://sso.douyin.com" + }); + var cookieStr = string.Join("; ", browserCookies.Select(c => $"{c.Name}={c.Value}")); + + // 尝试从页面获取 sec_user_id + var secUserId = await GetSecUserIdAsync(session.Page); + + session.FinalCookies = cookieStr; + session.SecUserId = secUserId; + + // 登录完成后关闭浏览器释放资源 + _ = Task.Run(async () => await CloseSessionAsync(token, session)); + + return (3, cookieStr, secUserId); + } + + public (string cookies, string secUserId) GetSessionResult(string token) + { + if (_sessions.TryGetValue(token, out var session)) + return (session.FinalCookies ?? "", session.SecUserId ?? ""); + return ("", ""); + } + + public void RemoveSession(string token) + { + if (_sessions.TryRemove(token, out var session)) + _ = Task.Run(async () => await CloseSessionAsync(token, session)); + } + + private static async Task GetSecUserIdAsync(IPage page) + { + try + { + // 从页面 URL 或 API 提取 sec_user_id + var resp = await page.EvaluateAsync( + "async () => { const r = await fetch('/web/api/userinfo/'); const j = await r.json(); return j?.data?.sec_uid || j?.sec_uid || ''; }" + ); + return resp ?? ""; + } + catch + { + return ""; + } + } + + private static async Task CloseSessionAsync(string token, QrSession session) + { + _sessions.TryRemove(token, out _); + try { await session.Browser?.CloseAsync(); } catch { } + try { session.Playwright?.Dispose(); } catch { } + } + + private static void CleanExpiredSessions() + { + var expired = _sessions + .Where(kv => DateTime.Now - kv.Value.CreatedAt > TimeSpan.FromMinutes(6)) + .ToList(); + foreach (var kv in expired) + _ = Task.Run(async () => await CloseSessionAsync(kv.Key, kv.Value)); + } + } +}