feat: 抖音扫码登录,自动填写 Cookie 和 SecUserId
部署到群晖 / deploy (push) Successful in 32s

新增 DyQrCodeService,调用 sso.douyin.com 二维码接口;
ConfigController 新增 qrcode/qrstatus/qrbind 三个接口;
前端账号列表每行增加"扫码登录"按钮,展示二维码弹窗并自动轮询绑定。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yuming
2026-04-27 08:56:56 +08:00
parent 6803b32b1b
commit 0848ebc272
4 changed files with 360 additions and 3 deletions
+81 -1
View File
@@ -18,12 +18,14 @@ namespace dy.net.Controllers
private readonly CommonService commonService;
private readonly QuartzJobService quartzJobService;
private readonly DyQrCodeService _qrCodeService;
public ConfigController(DyCookieService dyCookieService, CommonService commonService,QuartzJobService quartzJobService)
public ConfigController(DyCookieService dyCookieService, CommonService commonService, QuartzJobService quartzJobService, DyQrCodeService qrCodeService)
{
this.dyCookieService = dyCookieService;
this.commonService = commonService;
this.quartzJobService = quartzJobService;
_qrCodeService = qrCodeService;
}
@@ -198,6 +200,84 @@ namespace dy.net.Controllers
}
/// <summary>
/// 获取抖音扫码登录二维码
/// </summary>
[HttpGet("qrcode")]
[Authorize]
public async Task<IActionResult> GetQrCode()
{
try
{
var (token, qrBase64) = await _qrCodeService.GetQrCodeAsync();
return Ok(new { code = 0, data = new { token, qrBase64 } });
}
catch (Exception ex)
{
Serilog.Log.Error($"获取二维码失败: {ex.Message}");
return Ok(new { code = -1, error = "获取二维码失败:" + ex.Message });
}
}
/// <summary>
/// 轮询扫码状态(前端每2秒调一次)
/// status: 1=待扫码, 2=已扫码待确认, 3=登录成功, -1=已过期
/// </summary>
[HttpGet("qrstatus")]
[Authorize]
public async Task<IActionResult> GetQrStatus([FromQuery] string token)
{
try
{
var (status, cookies, secUserId) = await _qrCodeService.CheckStatusAsync(token);
return Ok(new { code = 0, data = new { status, hasCookies = !string.IsNullOrEmpty(cookies), secUserId } });
}
catch (Exception ex)
{
Serilog.Log.Error($"查询扫码状态失败: {ex.Message}");
return Ok(new { code = -1, error = "查询失败:" + ex.Message });
}
}
/// <summary>
/// 扫码成功后,将 Cookie 绑定到指定账号
/// </summary>
[HttpPost("qrbind")]
[Authorize]
public async Task<IActionResult> 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 account = await dyCookieService.GetByIdAsync(id);
if (account == null)
return Ok(new { code = -1, error = "账号不存在" });
account.Cookies = cookies;
if (!string.IsNullOrEmpty(secUserId))
account.SecUserId = secUserId;
account.Status = 1;
var result = await dyCookieService.UpdateAsync(account);
_qrCodeService.RemoveSession(token);
if (result)
{
await ReStartJob();
return Ok(new { code = 0, data = new { secUserId } });
}
return Ok(new { code = -1, error = "保存失败" });
}
catch (Exception ex)
{
Serilog.Log.Error($"绑定Cookie失败: {ex.Message}");
return Ok(new { code = -1, error = "绑定失败:" + ex.Message });
}
}
private async Task ReStartJob() {
var config= commonService.GetConfig();
+100 -1
View File
@@ -4,7 +4,7 @@ import { FormInstance } from 'ant-design-vue';
import { reactive, ref, onMounted } from 'vue';
import dayjs from 'dayjs';
import { Dayjs } from 'dayjs';
import { EditFilled } from '@ant-design/icons-vue';
import { EditFilled, QrcodeOutlined } from '@ant-design/icons-vue';
import { useApiStore } from '@/store';
import type { UnwrapRef } from 'vue';
@@ -220,6 +220,77 @@ const removeRow = (index) => {
form.upSecUserIdsJson.splice(index, 1);
};
const rowCount = 4;
// 扫码登录
const showQrModal = ref(false);
const qrToken = ref('');
const qrBase64 = ref('');
const qrStatus = ref(0); // 0=加载中, 1=待扫码, 2=已扫码待确认, 3=登录成功, -1=已过期
const qrAccountId = ref('');
const qrAccountName = ref('');
const qrBindSuccess = ref(false);
let qrPollingTimer: ReturnType<typeof setInterval> | null = null;
const openQrModal = async (record: DataItem) => {
qrAccountId.value = record.id;
qrAccountName.value = record.userName;
qrStatus.value = 0;
qrBase64.value = '';
qrToken.value = '';
qrBindSuccess.value = false;
showQrModal.value = true;
try {
const res = await useApiStore().QrCodeGet();
if (res.code === 0) {
qrToken.value = res.data.token;
qrBase64.value = res.data.qrBase64;
qrStatus.value = 1;
startQrPolling();
} else {
qrStatus.value = -1;
}
} catch {
qrStatus.value = -1;
}
};
const startQrPolling = () => {
stopQrPolling();
qrPollingTimer = setInterval(async () => {
if (!qrToken.value) return;
try {
const res = await useApiStore().QrCodeStatus(qrToken.value);
if (res.code === 0) {
qrStatus.value = res.data.status;
if (res.data.status === 3) {
stopQrPolling();
const bindRes = await useApiStore().QrCodeBind(qrToken.value, qrAccountId.value);
if (bindRes.code === 0) {
qrBindSuccess.value = true;
setTimeout(() => {
showQrModal.value = false;
GetRecords();
}, 1500);
}
} else if (res.data.status === -1) {
stopQrPolling();
}
}
} catch { /* 忽略轮询网络错误 */ }
}, 2000);
};
const stopQrPolling = () => {
if (qrPollingTimer) {
clearInterval(qrPollingTimer);
qrPollingTimer = null;
}
};
const closeQrModal = () => {
stopQrPolling();
showQrModal.value = false;
};
</script>
<template>
<a-modal :title="form._isNew ? '新增Cookie' : '编辑Cookie'" v-model:visible="showModal" @ok="submit" @cancel="cancel" width="1000px">
@@ -374,6 +445,12 @@ const rowCount = 4;
</template>
删除
</a-button>
<a-button type="link" @click="openQrModal(record)" size="small">
<template #icon>
<QrcodeOutlined />
</template>
扫码登录
</a-button>
</template>
<div v-else class="text-subtext">
{{ text }}
@@ -381,6 +458,28 @@ const rowCount = 4;
</template>
</a-table>
<a-modal title="抖音扫码登录" :visible="showQrModal" :footer="null" @cancel="closeQrModal" width="360px">
<div style="text-align: center; padding: 16px 0;">
<div style="margin-bottom: 12px; color: #666;">账号:{{ qrAccountName }}</div>
<div v-if="qrStatus === 0" style="padding: 60px 0; color: #999;">加载中...</div>
<div v-else-if="qrStatus === -1" style="padding: 40px 0;">
<div style="color: #ff4d4f; margin-bottom: 12px;">二维码已过期</div>
<a-button @click="openQrModal({ id: qrAccountId, userName: qrAccountName })">刷新二维码</a-button>
</div>
<div v-else-if="qrBindSuccess" style="padding: 40px 0; color: #52c41a; font-size: 16px;">
登录成功,Cookie 已更新!
</div>
<div v-else>
<img v-if="qrBase64" :src="'data:image/png;base64,' + qrBase64" style="width: 200px; height: 200px;" />
<div style="margin-top: 12px; font-size: 13px; color: #666;">
<span v-if="qrStatus === 1">请用抖音 App 扫描二维码</span>
<span v-else-if="qrStatus === 2" style="color: #1890ff;">已扫码,请在手机上确认登录</span>
<span v-else-if="qrStatus === 3" style="color: #52c41a;">登录成功,正在保存...</span>
</div>
</div>
</div>
</a-modal>
<a-modal title="Cookie 详情" :visible="showCookiesModal" style="width:1200px;" @cancel="showCookiesModal = false" @ok="showCookiesModal = false">
<div class="cookie-content">
{{ showCookiesData || '无Cookie数据' }}
+25 -1
View File
@@ -200,6 +200,27 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
// 获取抖音扫码登录二维码
async function QrCodeGet() {
return http.request<any, Response<any>>('/api/config/qrcode', 'get').then(r => {
return r.data;
});
}
// 轮询扫码状态
async function QrCodeStatus(token: string) {
return http.request<any, Response<any>>('/api/config/qrstatus?token=' + token, 'get').then(r => {
return r.data;
});
}
// 扫码成功后绑定到账号
async function QrCodeBind(token: string, id: string) {
return http.request<any, Response<any>>(`/api/config/qrbind?token=${token}&id=${id}`, 'post_json').then(r => {
return r.data;
});
}
return {
deleteCookie,
UpdateConfig,
@@ -216,6 +237,9 @@ export const useApiStore = defineStore('coreapi', () => {
VideoCategories,
VideoCleanup,
CookiePageList,
DeleteAuthorVideos
DeleteAuthorVideos,
QrCodeGet,
QrCodeStatus,
QrCodeBind
};
});
+154
View File
@@ -0,0 +1,154 @@
using System.Collections.Concurrent;
using System.Net;
using Newtonsoft.Json.Linq;
namespace dy.net.service
{
public class DyQrCodeService
{
private static readonly ConcurrentDictionary<string, QrSession> _sessions = new();
private class QrSession
{
public CookieContainer CookieJar { get; set; } = new();
public DateTime CreatedAt { get; set; } = DateTime.Now;
public string? FinalCookies { get; set; }
public string? SecUserId { get; set; }
}
private const string Fp = "kj5j6uhv_tvKYUFA0_qzgZ_4l9c_9Amt_DCywEfwbVFCJ";
private const string NextUrl = "https://creator.douyin.com/content/manage";
private const string Service = "https://creator.douyin.com";
private const string Aid = "2906";
private const string Ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
private const string Referer = "https://creator.douyin.com/";
public async Task<(string token, string qrBase64)> GetQrCodeAsync()
{
var jar = new CookieContainer();
using var client = BuildClient(jar, autoRedirect: true);
var url = $"https://sso.douyin.com/get_qrcode/?next={Uri.EscapeDataString(NextUrl)}&aid={Aid}&service={Uri.EscapeDataString(Service)}&is_vcd=1&fp={Fp}";
var response = await client.GetStringAsync(url);
var json = JObject.Parse(response);
var token = json["data"]?["token"]?.ToString()
?? throw new Exception("获取二维码失败:响应中无 token");
var qrBase64 = json["data"]?["qrcode"]?.ToString()
?? throw new Exception("获取二维码失败:响应中无图片");
_sessions[token] = new QrSession { CookieJar = jar };
CleanExpiredSessions();
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(10))
{
_sessions.TryRemove(token, out _);
return (-1, null, null);
}
if (session.FinalCookies != null)
return (3, session.FinalCookies, session.SecUserId);
using var client = BuildClient(session.CookieJar, autoRedirect: false);
var serviceParam = Uri.EscapeDataString($"{Service}/?logintype=user&loginapp=douyin&jump={NextUrl}");
var url = $"https://sso.douyin.com/check_qrconnect/?next={Uri.EscapeDataString(NextUrl)}&token={token}&service={serviceParam}&loginapp=douyin&jump={Uri.EscapeDataString(NextUrl)}&correct_service={serviceParam}&aid={Aid}&is_vcd=1&fp={Fp}";
var response = await client.GetStringAsync(url);
var json = JObject.Parse(response);
var statusStr = json["data"]?["status"]?.ToString() ?? "1";
int.TryParse(statusStr, out int statusCode);
if (statusCode == 3)
{
var redirectUrl = json["data"]?["redirect_url"]?.ToString();
if (!string.IsNullOrEmpty(redirectUrl))
{
try
{
using var followClient = BuildClient(session.CookieJar, autoRedirect: true);
await followClient.GetAsync(redirectUrl);
session.FinalCookies = ExtractCookieString(session.CookieJar);
session.SecUserId = await GetSecUserIdAsync(session.CookieJar);
}
catch (Exception ex)
{
Serilog.Log.Error($"跟进登录重定向失败: {ex.Message}");
}
}
}
return (statusCode, session.FinalCookies, session.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) => _sessions.TryRemove(token, out _);
private static HttpClient BuildClient(CookieContainer jar, bool autoRedirect)
{
var handler = new HttpClientHandler
{
CookieContainer = jar,
AllowAutoRedirect = autoRedirect,
UseCookies = true
};
var client = new HttpClient(handler);
client.DefaultRequestHeaders.Add("User-Agent", Ua);
client.DefaultRequestHeaders.Add("Referer", Referer);
return client;
}
private static string ExtractCookieString(CookieContainer jar)
{
var domains = new[] { "https://www.douyin.com", "https://creator.douyin.com", "https://sso.douyin.com" };
var all = new Dictionary<string, string>();
foreach (var domain in domains)
{
foreach (Cookie c in jar.GetCookies(new Uri(domain)))
all[c.Name] = c.Value;
}
return string.Join("; ", all.Select(kv => $"{kv.Key}={kv.Value}"));
}
private static async Task<string?> GetSecUserIdAsync(CookieContainer jar)
{
try
{
using var client = BuildClient(jar, autoRedirect: true);
var resp = await client.GetStringAsync("https://creator.douyin.com/web/api/userinfo/");
var json = JObject.Parse(resp);
return json["data"]?["sec_uid"]?.ToString()
?? json["data"]?["sec_user_id"]?.ToString()
?? json["sec_uid"]?.ToString();
}
catch (Exception ex)
{
Serilog.Log.Error($"获取 sec_user_id 失败: {ex.Message}");
return null;
}
}
private static void CleanExpiredSessions()
{
var expired = _sessions
.Where(kv => DateTime.Now - kv.Value.CreatedAt > TimeSpan.FromMinutes(10))
.Select(kv => kv.Key).ToList();
foreach (var key in expired)
_sessions.TryRemove(key, out _);
}
}
}