新增 DyQrCodeService,调用 sso.douyin.com 二维码接口; ConfigController 新增 qrcode/qrstatus/qrbind 三个接口; 前端账号列表每行增加"扫码登录"按钮,展示二维码弹窗并自动轮询绑定。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,12 +18,14 @@ namespace dy.net.Controllers
|
|||||||
|
|
||||||
private readonly CommonService commonService;
|
private readonly CommonService commonService;
|
||||||
private readonly QuartzJobService quartzJobService;
|
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.dyCookieService = dyCookieService;
|
||||||
this.commonService = commonService;
|
this.commonService = commonService;
|
||||||
this.quartzJobService = quartzJobService;
|
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() {
|
private async Task ReStartJob() {
|
||||||
var config= commonService.GetConfig();
|
var config= commonService.GetConfig();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FormInstance } from 'ant-design-vue';
|
|||||||
import { reactive, ref, onMounted } from 'vue';
|
import { reactive, ref, onMounted } from 'vue';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
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 { useApiStore } from '@/store';
|
||||||
import type { UnwrapRef } from 'vue';
|
import type { UnwrapRef } from 'vue';
|
||||||
|
|
||||||
@@ -220,6 +220,77 @@ const removeRow = (index) => {
|
|||||||
form.upSecUserIdsJson.splice(index, 1);
|
form.upSecUserIdsJson.splice(index, 1);
|
||||||
};
|
};
|
||||||
const rowCount = 4;
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<a-modal :title="form._isNew ? '新增Cookie' : '编辑Cookie'" v-model:visible="showModal" @ok="submit" @cancel="cancel" width="1000px">
|
<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>
|
</template>
|
||||||
删除
|
删除
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-button type="link" @click="openQrModal(record)" size="small">
|
||||||
|
<template #icon>
|
||||||
|
<QrcodeOutlined />
|
||||||
|
</template>
|
||||||
|
扫码登录
|
||||||
|
</a-button>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="text-subtext">
|
<div v-else class="text-subtext">
|
||||||
{{ text }}
|
{{ text }}
|
||||||
@@ -381,6 +458,28 @@ const rowCount = 4;
|
|||||||
</template>
|
</template>
|
||||||
</a-table>
|
</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">
|
<a-modal title="Cookie 详情" :visible="showCookiesModal" style="width:1200px;" @cancel="showCookiesModal = false" @ok="showCookiesModal = false">
|
||||||
<div class="cookie-content">
|
<div class="cookie-content">
|
||||||
{{ showCookiesData || '无Cookie数据' }}
|
{{ showCookiesData || '无Cookie数据' }}
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
deleteCookie,
|
deleteCookie,
|
||||||
UpdateConfig,
|
UpdateConfig,
|
||||||
@@ -216,6 +237,9 @@ export const useApiStore = defineStore('coreapi', () => {
|
|||||||
VideoCategories,
|
VideoCategories,
|
||||||
VideoCleanup,
|
VideoCleanup,
|
||||||
CookiePageList,
|
CookiePageList,
|
||||||
DeleteAuthorVideos
|
DeleteAuthorVideos,
|
||||||
|
QrCodeGet,
|
||||||
|
QrCodeStatus,
|
||||||
|
QrCodeBind
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user