fix: refresh uploads and remember login
This commit is contained in:
@@ -154,7 +154,7 @@ function notifyBackendUnavailable(message: string) {
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("live-recorder-token");
|
||||
const token = localStorage.getItem("live-recorder-token") ?? sessionStorage.getItem("live-recorder-token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
@@ -172,6 +172,8 @@ apiClient.interceptors.response.use(
|
||||
markBackendAvailable();
|
||||
localStorage.removeItem("live-recorder-token");
|
||||
localStorage.removeItem("live-recorder-user");
|
||||
sessionStorage.removeItem("live-recorder-token");
|
||||
sessionStorage.removeItem("live-recorder-user");
|
||||
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
|
||||
+24
-10
@@ -6,38 +6,51 @@ import type { AuthenticatedUser, LoginResponse } from "@/types";
|
||||
const USER_STORAGE_KEY = "live-recorder-user";
|
||||
const TOKEN_STORAGE_KEY = "live-recorder-token";
|
||||
|
||||
function readStoredValue(key: string) {
|
||||
return localStorage.getItem(key) ?? sessionStorage.getItem(key);
|
||||
}
|
||||
|
||||
function clearStoredSession() {
|
||||
[localStorage, sessionStorage].forEach((storage) => {
|
||||
storage.removeItem(TOKEN_STORAGE_KEY);
|
||||
storage.removeItem(USER_STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const token = ref(localStorage.getItem(TOKEN_STORAGE_KEY) ?? "");
|
||||
const token = ref(readStoredValue(TOKEN_STORAGE_KEY) ?? "");
|
||||
const user = ref<AuthenticatedUser | null>(
|
||||
(() => {
|
||||
const raw = localStorage.getItem(USER_STORAGE_KEY);
|
||||
const raw = readStoredValue(USER_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as AuthenticatedUser) : null;
|
||||
})()
|
||||
);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value));
|
||||
|
||||
function persistSession(session: LoginResponse) {
|
||||
function persistSession(session: LoginResponse, rememberMe: boolean) {
|
||||
token.value = session.token;
|
||||
user.value = session.user;
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, session.token);
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user));
|
||||
clearStoredSession();
|
||||
const storage = rememberMe ? localStorage : sessionStorage;
|
||||
storage.setItem(TOKEN_STORAGE_KEY, session.token);
|
||||
storage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
token.value = "";
|
||||
user.value = null;
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
localStorage.removeItem(USER_STORAGE_KEY);
|
||||
clearStoredSession();
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
async function login(username: string, password: string, rememberMe: boolean) {
|
||||
const { data } = await apiClient.post<LoginResponse>("/auth/login", {
|
||||
username,
|
||||
password
|
||||
password,
|
||||
rememberMe
|
||||
});
|
||||
|
||||
persistSession(data);
|
||||
persistSession(data, rememberMe);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -54,6 +67,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
currentPassword,
|
||||
newPassword
|
||||
});
|
||||
clearSession();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,7 +16,8 @@ const { themeMode } = useUiPreferences();
|
||||
|
||||
const form = reactive({
|
||||
username: "admin",
|
||||
password: ""
|
||||
password: "",
|
||||
rememberMe: false
|
||||
});
|
||||
|
||||
const currentThemeIcon = computed(() => {
|
||||
@@ -35,7 +36,7 @@ async function handleLogin() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await authStore.login(form.username, form.password);
|
||||
await authStore.login(form.username, form.password, form.rememberMe);
|
||||
ElMessage.success("登录成功");
|
||||
await router.push({ name: "dashboard" });
|
||||
} catch (error) {
|
||||
@@ -94,10 +95,12 @@ async function handleLogin() {
|
||||
:description="backendMessage"
|
||||
/>
|
||||
|
||||
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
|
||||
<el-form label-position="top" class="login-form" autocomplete="on" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
id="username"
|
||||
name="username"
|
||||
:prefix-icon="User"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
@@ -107,6 +110,8 @@ async function handleLogin() {
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
id="password"
|
||||
name="password"
|
||||
:prefix-icon="Lock"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
@@ -115,7 +120,9 @@ async function handleLogin() {
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
|
||||
<el-checkbox v-model="form.rememberMe" name="rememberMe">记住登录状态(30 天)</el-checkbox>
|
||||
|
||||
<el-button class="login-form__submit" native-type="submit" type="primary" :loading="loading">
|
||||
登录控制台 <el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
@@ -691,11 +691,12 @@ async function changePassword() {
|
||||
changingPassword.value = true;
|
||||
try {
|
||||
await authStore.changePassword(pwdForm.currentPassword, pwdForm.newPassword);
|
||||
ElMessage.success("密码已更新");
|
||||
ElMessage.success("密码已更新,请重新登录");
|
||||
pwdForm.currentPassword = "";
|
||||
pwdForm.newPassword = "";
|
||||
pwdForm.confirmPassword = "";
|
||||
pwdFormRef.value?.resetFields();
|
||||
await router.push({ name: "login" });
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "密码修改失败,请稍后重试。"));
|
||||
} finally {
|
||||
|
||||
@@ -197,6 +197,8 @@ public interface IUserSessionRepository
|
||||
|
||||
Task AddAsync(UserSession session, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<UserSession>> ListActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
void Update(UserSession session);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ public sealed class LoginRequest
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthenticatedUser
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class AuthService : IAuthService
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
var expiresAt = now.AddHours(12);
|
||||
var expiresAt = request.RememberMe ? now.AddDays(30) : now.AddHours(12);
|
||||
var session = new UserSession(user.Id, token, expiresAt, now);
|
||||
|
||||
await _userSessionRepository.AddAsync(session, cancellationToken);
|
||||
@@ -137,6 +137,12 @@ public sealed class AuthService : IAuthService
|
||||
|
||||
user.UpdatePassword(PasswordHasher.Hash(request.NewPassword));
|
||||
_userAccountRepository.Update(user);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var session in await _userSessionRepository.ListActiveByUserIdAsync(userId, cancellationToken))
|
||||
{
|
||||
session.Revoke(now);
|
||||
_userSessionRepository.Update(session);
|
||||
}
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,15 @@ public class RecordResult
|
||||
DeletedLocalFilesAfterUpload = false;
|
||||
}
|
||||
|
||||
public void ResetRemoteArtifactsForReplacement(DateTimeOffset updatedAt)
|
||||
{
|
||||
RemoteVideoPath = null;
|
||||
RemoteDanmakuPath = null;
|
||||
LastUploadedAt = updatedAt;
|
||||
UploadErrorMessage = null;
|
||||
DeletedLocalFilesAfterUpload = false;
|
||||
}
|
||||
|
||||
public void MarkUploadWaitingRetry(string provider, string? errorMessage, DateTimeOffset updatedAt)
|
||||
{
|
||||
UploadStatus = RecordArtifactUploadStatus.WaitingRetry;
|
||||
|
||||
@@ -76,6 +76,14 @@ public sealed class RecordUploadJob
|
||||
|
||||
public string? TransferTargetPath { get; private set; }
|
||||
|
||||
public string? PendingCleanupVideoPath { get; private set; }
|
||||
|
||||
public string? PendingCleanupDanmakuPath { get; private set; }
|
||||
|
||||
public string? CleanupErrorMessage { get; private set; }
|
||||
|
||||
public DateTimeOffset? NextCleanupAttemptAt { get; private set; }
|
||||
|
||||
public DateTimeOffset? NextAttemptAt { get; private set; }
|
||||
|
||||
public DateTimeOffset? VerificationStartedAt { get; private set; }
|
||||
@@ -127,6 +135,69 @@ public sealed class RecordUploadJob
|
||||
UpdatedAt = requestedAt;
|
||||
}
|
||||
|
||||
public bool HasArtifactDrift(
|
||||
string providerEndpoint,
|
||||
string sourceVideoPath,
|
||||
string targetVideoPath,
|
||||
long videoSizeBytes,
|
||||
string? sourceDanmakuPath,
|
||||
string? targetDanmakuPath,
|
||||
long? danmakuSizeBytes) =>
|
||||
!string.Equals(ProviderEndpoint, NormalizeRequired(providerEndpoint), StringComparison.Ordinal) ||
|
||||
!string.Equals(SourceVideoPath, NormalizeRequired(sourceVideoPath), StringComparison.Ordinal) ||
|
||||
!string.Equals(TargetVideoPath, NormalizeRequired(targetVideoPath), StringComparison.Ordinal) ||
|
||||
VideoSizeBytes != Math.Max(0, videoSizeBytes) ||
|
||||
!string.Equals(SourceDanmakuPath, NormalizeNullable(sourceDanmakuPath), StringComparison.Ordinal) ||
|
||||
!string.Equals(TargetDanmakuPath, NormalizeNullable(targetDanmakuPath), StringComparison.Ordinal) ||
|
||||
DanmakuSizeBytes != (danmakuSizeBytes.HasValue ? Math.Max(0, danmakuSizeBytes.Value) : null);
|
||||
|
||||
public void RefreshForArtifactDrift(
|
||||
string providerEndpoint,
|
||||
string sourceVideoPath,
|
||||
string targetVideoPath,
|
||||
long videoSizeBytes,
|
||||
string? sourceDanmakuPath,
|
||||
string? targetDanmakuPath,
|
||||
long? danmakuSizeBytes,
|
||||
bool deleteLocalFilesAfterUpload,
|
||||
string? uploadedVideoPath,
|
||||
string? uploadedDanmakuPath,
|
||||
DateTimeOffset requestedAt)
|
||||
{
|
||||
TrackCleanupPath(uploadedVideoPath, isVideo: true, targetVideoPath);
|
||||
TrackCleanupPath(uploadedDanmakuPath, isVideo: false, targetDanmakuPath);
|
||||
RefreshRequest(
|
||||
providerEndpoint,
|
||||
sourceVideoPath,
|
||||
targetVideoPath,
|
||||
videoSizeBytes,
|
||||
sourceDanmakuPath,
|
||||
targetDanmakuPath,
|
||||
danmakuSizeBytes,
|
||||
deleteLocalFilesAfterUpload,
|
||||
requestedAt);
|
||||
}
|
||||
|
||||
public bool HasPendingRemoteCleanup =>
|
||||
!string.IsNullOrWhiteSpace(PendingCleanupVideoPath) ||
|
||||
!string.IsNullOrWhiteSpace(PendingCleanupDanmakuPath);
|
||||
|
||||
public void MarkRemoteCleanupFailed(string errorMessage, DateTimeOffset nextAttemptAt, DateTimeOffset updatedAt)
|
||||
{
|
||||
CleanupErrorMessage = NormalizeNullable(errorMessage);
|
||||
NextCleanupAttemptAt = nextAttemptAt;
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public void MarkRemoteCleanupCompleted(DateTimeOffset updatedAt)
|
||||
{
|
||||
PendingCleanupVideoPath = null;
|
||||
PendingCleanupDanmakuPath = null;
|
||||
CleanupErrorMessage = null;
|
||||
NextCleanupAttemptAt = null;
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public void MarkProcessing(DateTimeOffset updatedAt)
|
||||
{
|
||||
Status = RecordArtifactUploadStatus.Uploading;
|
||||
@@ -354,6 +425,24 @@ public sealed class RecordUploadJob
|
||||
return Math.Clamp(VideoSizeBytes * 100d / totalSize, 0, 100);
|
||||
}
|
||||
|
||||
private void TrackCleanupPath(string? uploadedPath, bool isVideo, string? replacementPath)
|
||||
{
|
||||
var normalized = NormalizeNullable(uploadedPath);
|
||||
if (normalized is null || string.Equals(normalized, replacementPath, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVideo)
|
||||
{
|
||||
PendingCleanupVideoPath = normalized;
|
||||
}
|
||||
else
|
||||
{
|
||||
PendingCleanupDanmakuPath = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRequired(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -142,6 +142,9 @@ public sealed class LiveRecorderDbContext : DbContext, IUnitOfWork
|
||||
builder.Property(static x => x.ExternalTaskId).HasMaxLength(128);
|
||||
builder.Property(static x => x.ExternalTaskType).HasMaxLength(32);
|
||||
builder.Property(static x => x.TransferTargetPath).HasMaxLength(2048);
|
||||
builder.Property(static x => x.PendingCleanupVideoPath).HasMaxLength(2048);
|
||||
builder.Property(static x => x.PendingCleanupDanmakuPath).HasMaxLength(2048);
|
||||
builder.Property(static x => x.CleanupErrorMessage).HasMaxLength(4096);
|
||||
builder.Property(static x => x.ErrorMessage).HasMaxLength(4096);
|
||||
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
|
||||
builder.HasIndex(static x => new { x.Status, x.NextAttemptAt, x.RequestedAt });
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LiveRecorder.Infrastructure.Persistence.Migrations;
|
||||
|
||||
[DbContext(typeof(LiveRecorderDbContext))]
|
||||
[Migration("20260813214000_AddUploadReplacementCleanup")]
|
||||
public sealed class AddUploadReplacementCleanup : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>("CleanupErrorMessage", "RecordUploadJobs", type: "character varying(4096)", maxLength: 4096, nullable: true);
|
||||
migrationBuilder.AddColumn<DateTimeOffset>("NextCleanupAttemptAt", "RecordUploadJobs", type: "timestamp with time zone", nullable: true);
|
||||
migrationBuilder.AddColumn<string>("PendingCleanupDanmakuPath", "RecordUploadJobs", type: "character varying(2048)", maxLength: 2048, nullable: true);
|
||||
migrationBuilder.AddColumn<string>("PendingCleanupVideoPath", "RecordUploadJobs", type: "character varying(2048)", maxLength: 2048, nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn("CleanupErrorMessage", "RecordUploadJobs");
|
||||
migrationBuilder.DropColumn("NextCleanupAttemptAt", "RecordUploadJobs");
|
||||
migrationBuilder.DropColumn("PendingCleanupDanmakuPath", "RecordUploadJobs");
|
||||
migrationBuilder.DropColumn("PendingCleanupVideoPath", "RecordUploadJobs");
|
||||
}
|
||||
}
|
||||
+15
@@ -542,6 +542,10 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CleanupErrorMessage")
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("character varying(4096)");
|
||||
|
||||
b.Property<int>("CurrentArtifact")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -572,6 +576,17 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
||||
b.Property<DateTimeOffset?>("NextAttemptAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("NextCleanupAttemptAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PendingCleanupDanmakuPath")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("PendingCleanupVideoPath")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<double>("ProgressPercent")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
|
||||
@@ -757,5 +757,10 @@ public sealed class UserSessionRepository : IUserSessionRepository
|
||||
public Task AddAsync(UserSession session, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.UserSessions.AddAsync(session, cancellationToken).AsTask();
|
||||
|
||||
public async Task<IReadOnlyList<UserSession>> ListActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) =>
|
||||
await _dbContext.UserSessions
|
||||
.Where(item => item.UserAccountId == userId && item.RevokedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
public void Update(UserSession session) => _dbContext.UserSessions.Update(session);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ public interface IOpenListClient
|
||||
string sourcePath,
|
||||
string targetDirectory,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteFileAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
string path,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record OpenListObjectInfo(
|
||||
@@ -540,6 +545,26 @@ public sealed class OpenListClient : IOpenListClient
|
||||
EnsureSuccess(envelope, $"OpenList move '{sourcePath}' to '{targetDirectory}'");
|
||||
}
|
||||
|
||||
public async Task DeleteFileAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
string path,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
path = NormalizePath(path);
|
||||
var existing = await TryGetObjectAsync(connection, path, cancellationToken);
|
||||
if (existing is null) return;
|
||||
if (existing.IsDirectory) throw new InvalidOperationException($"OpenList cleanup path is a directory: '{path}'.");
|
||||
|
||||
var envelope = await SendAuthorizedAsync(
|
||||
connection,
|
||||
() => CreateJsonRequest(
|
||||
HttpMethod.Post,
|
||||
"/api/fs/remove",
|
||||
new { dir = GetDirectoryName(path), names = new[] { GetFileName(path) } }),
|
||||
cancellationToken);
|
||||
EnsureSuccess(envelope, $"OpenList remove '{path}'");
|
||||
}
|
||||
|
||||
public static string NormalizeBaseUrl(string baseUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri) ||
|
||||
|
||||
@@ -316,6 +316,24 @@ public sealed class OpenListUploadQueueService
|
||||
return false;
|
||||
}
|
||||
|
||||
var cleanupJob = await _dbContext.RecordUploadJobs
|
||||
.Include(static item => item.RecordTask)
|
||||
.Where(item => item.Status == RecordArtifactUploadStatus.Succeeded &&
|
||||
(item.PendingCleanupVideoPath != null || item.PendingCleanupDanmakuPath != null) &&
|
||||
(!item.NextCleanupAttemptAt.HasValue || item.NextCleanupAttemptAt <= now))
|
||||
.OrderBy(static item => item.NextCleanupAttemptAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (cleanupJob is not null)
|
||||
{
|
||||
await TryCleanupReplacedRemoteArtifactsAsync(cleanupJob, new OpenListConnectionRequest
|
||||
{
|
||||
BaseUrl = cleanupJob.ProviderEndpoint,
|
||||
Username = settings.OpenListUpload.Username,
|
||||
Password = settings.OpenListUpload.Password
|
||||
}, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
var jobs = _dbContext.RecordUploadJobs
|
||||
.Include(static item => item.RecordTask)
|
||||
.ThenInclude(static item => item!.Result)
|
||||
@@ -380,6 +398,11 @@ public sealed class OpenListUploadQueueService
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await RefreshJobForCurrentArtifactsAsync(job, result, settings, connection, cancellationToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var validationError = await GetUploadValidationErrorAsync(
|
||||
job.RecordTask,
|
||||
NormalizeAbsolutePath(result.FilePath),
|
||||
@@ -510,14 +533,35 @@ public sealed class OpenListUploadQueueService
|
||||
now);
|
||||
await _dbContext.RecordUploadJobs.AddAsync(job, cancellationToken);
|
||||
}
|
||||
else if (job.Status == RecordArtifactUploadStatus.Succeeded)
|
||||
else if (job.Status == RecordArtifactUploadStatus.Succeeded &&
|
||||
!job.HasArtifactDrift(endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes, sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes))
|
||||
{
|
||||
return SuccessFromExisting(recordTaskId, result);
|
||||
}
|
||||
else if (job.Status is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.Uploading or RecordArtifactUploadStatus.WaitingRetry)
|
||||
else if (job.Status is RecordArtifactUploadStatus.Queued or RecordArtifactUploadStatus.Uploading or RecordArtifactUploadStatus.WaitingRetry &&
|
||||
!job.HasArtifactDrift(endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes, sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes))
|
||||
{
|
||||
return QueuedResult(recordTaskId, result, job, "该分片已在 OpenList 上传队列中。");
|
||||
}
|
||||
else if (job.HasArtifactDrift(endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes, sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(job.ExternalTaskId))
|
||||
{
|
||||
var connection = new OpenListConnectionRequest
|
||||
{
|
||||
BaseUrl = endpoint,
|
||||
Username = settings.OpenListUpload.Username,
|
||||
Password = settings.OpenListUpload.Password
|
||||
};
|
||||
_ = await _openListClient.TryCancelCopyTaskAsync(connection, job.ExternalTaskId, cancellationToken);
|
||||
}
|
||||
job.RefreshForArtifactDrift(
|
||||
endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes,
|
||||
sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes,
|
||||
settings.DeleteLocalFilesAfterUpload,
|
||||
result.RemoteVideoPath, result.RemoteDanmakuPath, now);
|
||||
result.ResetRemoteArtifactsForReplacement(now);
|
||||
}
|
||||
else
|
||||
{
|
||||
job.RefreshRequest(
|
||||
@@ -911,6 +955,14 @@ public sealed class OpenListUploadQueueService
|
||||
now);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var settings = await _settingsService.GetAsync(cancellationToken);
|
||||
await TryCleanupReplacedRemoteArtifactsAsync(job, new OpenListConnectionRequest
|
||||
{
|
||||
BaseUrl = job.ProviderEndpoint,
|
||||
Username = settings.OpenListUpload.Username,
|
||||
Password = settings.OpenListUpload.Password
|
||||
}, cancellationToken);
|
||||
|
||||
await _systemLogService.WriteAsync(
|
||||
cleanupWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning,
|
||||
"Upload",
|
||||
@@ -923,6 +975,81 @@ public sealed class OpenListUploadQueueService
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> RefreshJobForCurrentArtifactsAsync(
|
||||
RecordUploadJob job,
|
||||
RecordResult result,
|
||||
SystemSettingsDto settings,
|
||||
OpenListConnectionRequest connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var localVideoPath = NormalizeAbsolutePath(result.FilePath);
|
||||
if (!File.Exists(localVideoPath)) return false;
|
||||
var outputRoot = Path.GetFullPath(settings.OutputRoot, AppContext.BaseDirectory);
|
||||
var videoRelativePath = GetSafeRelativePath(outputRoot, localVideoPath);
|
||||
var sourceVideoPath = OpenListClient.CombinePath(settings.OpenListUpload.SourcePath, videoRelativePath);
|
||||
var targetVideoPath = OpenListClient.CombinePath(settings.OpenListUpload.DestinationPath, videoRelativePath);
|
||||
var videoSizeBytes = new FileInfo(localVideoPath).Length;
|
||||
var localDanmakuPath = NormalizeNullableAbsolutePath(result.DanmakuFilePath);
|
||||
var hasDanmaku = !string.IsNullOrWhiteSpace(localDanmakuPath) && File.Exists(localDanmakuPath);
|
||||
var sourceDanmakuPath = hasDanmaku
|
||||
? OpenListClient.CombinePath(settings.OpenListUpload.SourcePath, GetSafeRelativePath(outputRoot, localDanmakuPath!))
|
||||
: null;
|
||||
var targetDanmakuPath = hasDanmaku
|
||||
? OpenListClient.CombinePath(settings.OpenListUpload.DestinationPath, GetSafeRelativePath(outputRoot, localDanmakuPath!))
|
||||
: null;
|
||||
var danmakuSizeBytes = hasDanmaku ? new FileInfo(localDanmakuPath!).Length : (long?)null;
|
||||
var endpoint = OpenListClient.NormalizeBaseUrl(settings.OpenListUpload.BaseUrl);
|
||||
if (!job.HasArtifactDrift(endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes, sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes)) return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.ExternalTaskId))
|
||||
{
|
||||
_ = await _openListClient.TryCancelCopyTaskAsync(connection, job.ExternalTaskId, cancellationToken);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
job.RefreshForArtifactDrift(
|
||||
endpoint, sourceVideoPath, targetVideoPath, videoSizeBytes,
|
||||
sourceDanmakuPath, targetDanmakuPath, danmakuSizeBytes,
|
||||
settings.DeleteLocalFilesAfterUpload,
|
||||
result.RemoteVideoPath, result.RemoteDanmakuPath, now);
|
||||
result.ResetRemoteArtifactsForReplacement(now);
|
||||
result.MarkUploadQueued("openlist", now);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task TryCleanupReplacedRemoteArtifactsAsync(
|
||||
RecordUploadJob job,
|
||||
OpenListConnectionRequest connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!job.HasPendingRemoteCleanup) return;
|
||||
var settings = await _settingsService.GetAsync(cancellationToken);
|
||||
var destinationRoot = OpenListClient.NormalizePath(settings.OpenListUpload.DestinationPath).TrimEnd('/') + "/";
|
||||
try
|
||||
{
|
||||
foreach (var path in new[] { job.PendingCleanupVideoPath, job.PendingCleanupDanmakuPath }.Where(static path => !string.IsNullOrWhiteSpace(path)))
|
||||
{
|
||||
var normalized = OpenListClient.NormalizePath(path!);
|
||||
if (!normalized.StartsWith(destinationRoot, StringComparison.Ordinal) ||
|
||||
string.Equals(normalized, job.TargetVideoPath, StringComparison.Ordinal) ||
|
||||
string.Equals(normalized, job.TargetDanmakuPath, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"Refusing unsafe OpenList cleanup path: '{normalized}'.");
|
||||
}
|
||||
await _openListClient.DeleteFileAsync(connection, normalized, cancellationToken);
|
||||
}
|
||||
job.MarkRemoteCleanupCompleted(DateTimeOffset.UtcNow);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
job.MarkRemoteCleanupFailed(ex.Message, DateTimeOffset.UtcNow.Add(CleanupRetryDelay), DateTimeOffset.UtcNow);
|
||||
await _systemLogService.WriteAsync(SystemLogLevel.Warning, "Upload", "OpenList replaced artifact cleanup will retry.", ex.Message,
|
||||
job.RecordTask?.LiveRoomId, job.RecordTask?.RecordSessionId, job.RecordTaskId, cancellationToken);
|
||||
}
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ScheduleRetryOrFailAsync(
|
||||
RecordUploadJob job,
|
||||
RecordResult result,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using LiveRecorder.Application.Common;
|
||||
using LiveRecorder.Application.Models.Auth;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Infrastructure.Persistence;
|
||||
using LiveRecorder.Infrastructure.Persistence.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LiveRecorder.Tests;
|
||||
|
||||
public sealed class AuthServiceTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, 12)]
|
||||
[InlineData(true, 720)]
|
||||
public async Task Login_UsesExpectedSessionLifetime(bool rememberMe, int expectedHours)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LiveRecorderDbContext>()
|
||||
.UseInMemoryDatabase($"auth-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
await using var context = new LiveRecorderDbContext(options);
|
||||
var user = new UserAccount("admin", "Admin", PasswordHasher.Hash("secret123"), DateTimeOffset.UtcNow);
|
||||
context.UserAccounts.Add(user);
|
||||
await context.SaveChangesAsync();
|
||||
var service = new AuthService(new UserAccountRepository(context), new UserSessionRepository(context), context);
|
||||
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
var response = await service.LoginAsync(new LoginRequest
|
||||
{
|
||||
Username = "admin",
|
||||
Password = "secret123",
|
||||
RememberMe = rememberMe
|
||||
});
|
||||
var after = DateTimeOffset.UtcNow;
|
||||
|
||||
Assert.InRange(response.ExpiresAt, before.AddHours(expectedHours), after.AddHours(expectedHours));
|
||||
}
|
||||
}
|
||||
@@ -434,6 +434,28 @@ public sealed class OpenListUploadTests
|
||||
Assert.Equal(1, await fixture.Context.RecordUploadJobs.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queue_RefreshesQueuedJobWhenConsolidationChangesResultPath()
|
||||
{
|
||||
await using var fixture = await QueueFixture.CreateAsync();
|
||||
await fixture.Queue.EnqueueAsync(fixture.RecordTaskId);
|
||||
var originalJob = await fixture.Context.RecordUploadJobs.AsNoTracking().SingleAsync();
|
||||
var result = await fixture.Context.RecordResults.SingleAsync();
|
||||
var mergedPath = Path.Combine(Path.GetDirectoryName(result.FilePath)!, "segment-merged.mp4");
|
||||
await File.WriteAllBytesAsync(mergedPath, Encoding.UTF8.GetBytes("merged-video-content"));
|
||||
result.Update(mergedPath, new FileInfo(mergedPath).Length, 60, null, 0, RecordTaskStatus.Completed, null);
|
||||
await fixture.Context.SaveChangesAsync();
|
||||
|
||||
Assert.True(await fixture.Queue.ProcessNextAsync());
|
||||
|
||||
fixture.Context.ChangeTracker.Clear();
|
||||
var refreshedJob = await fixture.Context.RecordUploadJobs.SingleAsync();
|
||||
Assert.Equal("/source/Douyin/2026/08/01/主播/segment-merged.mp4", refreshedJob.SourceVideoPath);
|
||||
Assert.Equal("/destination/Douyin/2026/08/01/主播/segment-merged.mp4", refreshedJob.TargetVideoPath);
|
||||
Assert.Equal(RecordArtifactUploadStatus.Queued, refreshedJob.Status);
|
||||
Assert.NotEqual(originalJob.SourceVideoPath, refreshedJob.SourceVideoPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queue_RenamesSameNameConflictWithoutOverwriting()
|
||||
{
|
||||
@@ -906,5 +928,15 @@ public sealed class OpenListUploadTests
|
||||
Operations.Add($"move:{sourcePath}->{targetDirectory}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteFileAsync(
|
||||
OpenListConnectionRequest connection,
|
||||
string path,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Operations.Add($"delete:{path}");
|
||||
Objects.Remove(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user