fix: refresh uploads and remember login
This commit is contained in:
@@ -154,7 +154,7 @@ function notifyBackendUnavailable(message: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiClient.interceptors.request.use((config) => {
|
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) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
@@ -172,6 +172,8 @@ apiClient.interceptors.response.use(
|
|||||||
markBackendAvailable();
|
markBackendAvailable();
|
||||||
localStorage.removeItem("live-recorder-token");
|
localStorage.removeItem("live-recorder-token");
|
||||||
localStorage.removeItem("live-recorder-user");
|
localStorage.removeItem("live-recorder-user");
|
||||||
|
sessionStorage.removeItem("live-recorder-token");
|
||||||
|
sessionStorage.removeItem("live-recorder-user");
|
||||||
|
|
||||||
if (window.location.pathname !== "/login") {
|
if (window.location.pathname !== "/login") {
|
||||||
window.location.href = "/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 USER_STORAGE_KEY = "live-recorder-user";
|
||||||
const TOKEN_STORAGE_KEY = "live-recorder-token";
|
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", () => {
|
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 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;
|
return raw ? (JSON.parse(raw) as AuthenticatedUser) : null;
|
||||||
})()
|
})()
|
||||||
);
|
);
|
||||||
|
|
||||||
const isAuthenticated = computed(() => Boolean(token.value));
|
const isAuthenticated = computed(() => Boolean(token.value));
|
||||||
|
|
||||||
function persistSession(session: LoginResponse) {
|
function persistSession(session: LoginResponse, rememberMe: boolean) {
|
||||||
token.value = session.token;
|
token.value = session.token;
|
||||||
user.value = session.user;
|
user.value = session.user;
|
||||||
localStorage.setItem(TOKEN_STORAGE_KEY, session.token);
|
clearStoredSession();
|
||||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user));
|
const storage = rememberMe ? localStorage : sessionStorage;
|
||||||
|
storage.setItem(TOKEN_STORAGE_KEY, session.token);
|
||||||
|
storage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
token.value = "";
|
token.value = "";
|
||||||
user.value = null;
|
user.value = null;
|
||||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
clearStoredSession();
|
||||||
localStorage.removeItem(USER_STORAGE_KEY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(username: string, password: string) {
|
async function login(username: string, password: string, rememberMe: boolean) {
|
||||||
const { data } = await apiClient.post<LoginResponse>("/auth/login", {
|
const { data } = await apiClient.post<LoginResponse>("/auth/login", {
|
||||||
username,
|
username,
|
||||||
password
|
password,
|
||||||
|
rememberMe
|
||||||
});
|
});
|
||||||
|
|
||||||
persistSession(data);
|
persistSession(data, rememberMe);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +67,7 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
currentPassword,
|
currentPassword,
|
||||||
newPassword
|
newPassword
|
||||||
});
|
});
|
||||||
|
clearSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const { themeMode } = useUiPreferences();
|
|||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: "admin",
|
username: "admin",
|
||||||
password: ""
|
password: "",
|
||||||
|
rememberMe: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentThemeIcon = computed(() => {
|
const currentThemeIcon = computed(() => {
|
||||||
@@ -35,7 +36,7 @@ async function handleLogin() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authStore.login(form.username, form.password);
|
await authStore.login(form.username, form.password, form.rememberMe);
|
||||||
ElMessage.success("登录成功");
|
ElMessage.success("登录成功");
|
||||||
await router.push({ name: "dashboard" });
|
await router.push({ name: "dashboard" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -94,10 +95,12 @@ async function handleLogin() {
|
|||||||
:description="backendMessage"
|
: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-form-item label="用户名">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
:prefix-icon="User"
|
:prefix-icon="User"
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
@@ -107,6 +110,8 @@ async function handleLogin() {
|
|||||||
<el-form-item label="密码">
|
<el-form-item label="密码">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
:prefix-icon="Lock"
|
:prefix-icon="Lock"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
@@ -115,7 +120,9 @@ async function handleLogin() {
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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-icon class="el-icon--right"><ArrowRight /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
@@ -691,11 +691,12 @@ async function changePassword() {
|
|||||||
changingPassword.value = true;
|
changingPassword.value = true;
|
||||||
try {
|
try {
|
||||||
await authStore.changePassword(pwdForm.currentPassword, pwdForm.newPassword);
|
await authStore.changePassword(pwdForm.currentPassword, pwdForm.newPassword);
|
||||||
ElMessage.success("密码已更新");
|
ElMessage.success("密码已更新,请重新登录");
|
||||||
pwdForm.currentPassword = "";
|
pwdForm.currentPassword = "";
|
||||||
pwdForm.newPassword = "";
|
pwdForm.newPassword = "";
|
||||||
pwdForm.confirmPassword = "";
|
pwdForm.confirmPassword = "";
|
||||||
pwdFormRef.value?.resetFields();
|
pwdFormRef.value?.resetFields();
|
||||||
|
await router.push({ name: "login" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(getApiErrorMessage(error, "密码修改失败,请稍后重试。"));
|
ElMessage.error(getApiErrorMessage(error, "密码修改失败,请稍后重试。"));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ public interface IUserSessionRepository
|
|||||||
|
|
||||||
Task AddAsync(UserSession session, CancellationToken cancellationToken = default);
|
Task AddAsync(UserSession session, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<UserSession>> ListActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
void Update(UserSession session);
|
void Update(UserSession session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ public sealed class LoginRequest
|
|||||||
public string Username { get; set; } = string.Empty;
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string Password { get; set; } = string.Empty;
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool RememberMe { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AuthenticatedUser
|
public sealed class AuthenticatedUser
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed class AuthService : IAuthService
|
|||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
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);
|
var session = new UserSession(user.Id, token, expiresAt, now);
|
||||||
|
|
||||||
await _userSessionRepository.AddAsync(session, cancellationToken);
|
await _userSessionRepository.AddAsync(session, cancellationToken);
|
||||||
@@ -137,6 +137,12 @@ public sealed class AuthService : IAuthService
|
|||||||
|
|
||||||
user.UpdatePassword(PasswordHasher.Hash(request.NewPassword));
|
user.UpdatePassword(PasswordHasher.Hash(request.NewPassword));
|
||||||
_userAccountRepository.Update(user);
|
_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);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ public class RecordResult
|
|||||||
DeletedLocalFilesAfterUpload = false;
|
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)
|
public void MarkUploadWaitingRetry(string provider, string? errorMessage, DateTimeOffset updatedAt)
|
||||||
{
|
{
|
||||||
UploadStatus = RecordArtifactUploadStatus.WaitingRetry;
|
UploadStatus = RecordArtifactUploadStatus.WaitingRetry;
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ public sealed class RecordUploadJob
|
|||||||
|
|
||||||
public string? TransferTargetPath { get; private set; }
|
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? NextAttemptAt { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset? VerificationStartedAt { get; private set; }
|
public DateTimeOffset? VerificationStartedAt { get; private set; }
|
||||||
@@ -127,6 +135,69 @@ public sealed class RecordUploadJob
|
|||||||
UpdatedAt = requestedAt;
|
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)
|
public void MarkProcessing(DateTimeOffset updatedAt)
|
||||||
{
|
{
|
||||||
Status = RecordArtifactUploadStatus.Uploading;
|
Status = RecordArtifactUploadStatus.Uploading;
|
||||||
@@ -354,6 +425,24 @@ public sealed class RecordUploadJob
|
|||||||
return Math.Clamp(VideoSizeBytes * 100d / totalSize, 0, 100);
|
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)
|
private static string NormalizeRequired(string value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(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.ExternalTaskId).HasMaxLength(128);
|
||||||
builder.Property(static x => x.ExternalTaskType).HasMaxLength(32);
|
builder.Property(static x => x.ExternalTaskType).HasMaxLength(32);
|
||||||
builder.Property(static x => x.TransferTargetPath).HasMaxLength(2048);
|
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.Property(static x => x.ErrorMessage).HasMaxLength(4096);
|
||||||
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
|
builder.HasIndex(static x => x.RecordTaskId).IsUnique();
|
||||||
builder.HasIndex(static x => new { x.Status, x.NextAttemptAt, x.RequestedAt });
|
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")
|
b.Property<DateTimeOffset?>("CompletedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CleanupErrorMessage")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)");
|
||||||
|
|
||||||
b.Property<int>("CurrentArtifact")
|
b.Property<int>("CurrentArtifact")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -572,6 +576,17 @@ namespace LiveRecorder.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<DateTimeOffset?>("NextAttemptAt")
|
b.Property<DateTimeOffset?>("NextAttemptAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.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")
|
b.Property<double>("ProgressPercent")
|
||||||
.HasColumnType("double precision");
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
|||||||
@@ -757,5 +757,10 @@ public sealed class UserSessionRepository : IUserSessionRepository
|
|||||||
public Task AddAsync(UserSession session, CancellationToken cancellationToken = default) =>
|
public Task AddAsync(UserSession session, CancellationToken cancellationToken = default) =>
|
||||||
_dbContext.UserSessions.AddAsync(session, cancellationToken).AsTask();
|
_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);
|
public void Update(UserSession session) => _dbContext.UserSessions.Update(session);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ public interface IOpenListClient
|
|||||||
string sourcePath,
|
string sourcePath,
|
||||||
string targetDirectory,
|
string targetDirectory,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task DeleteFileAsync(
|
||||||
|
OpenListConnectionRequest connection,
|
||||||
|
string path,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record OpenListObjectInfo(
|
public sealed record OpenListObjectInfo(
|
||||||
@@ -540,6 +545,26 @@ public sealed class OpenListClient : IOpenListClient
|
|||||||
EnsureSuccess(envelope, $"OpenList move '{sourcePath}' to '{targetDirectory}'");
|
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)
|
public static string NormalizeBaseUrl(string baseUrl)
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri) ||
|
if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri) ||
|
||||||
|
|||||||
@@ -316,6 +316,24 @@ public sealed class OpenListUploadQueueService
|
|||||||
return false;
|
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
|
var jobs = _dbContext.RecordUploadJobs
|
||||||
.Include(static item => item.RecordTask)
|
.Include(static item => item.RecordTask)
|
||||||
.ThenInclude(static item => item!.Result)
|
.ThenInclude(static item => item!.Result)
|
||||||
@@ -380,6 +398,11 @@ public sealed class OpenListUploadQueueService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await RefreshJobForCurrentArtifactsAsync(job, result, settings, connection, cancellationToken))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
var validationError = await GetUploadValidationErrorAsync(
|
var validationError = await GetUploadValidationErrorAsync(
|
||||||
job.RecordTask,
|
job.RecordTask,
|
||||||
NormalizeAbsolutePath(result.FilePath),
|
NormalizeAbsolutePath(result.FilePath),
|
||||||
@@ -510,14 +533,35 @@ public sealed class OpenListUploadQueueService
|
|||||||
now);
|
now);
|
||||||
await _dbContext.RecordUploadJobs.AddAsync(job, cancellationToken);
|
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);
|
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 上传队列中。");
|
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
|
else
|
||||||
{
|
{
|
||||||
job.RefreshRequest(
|
job.RefreshRequest(
|
||||||
@@ -911,6 +955,14 @@ public sealed class OpenListUploadQueueService
|
|||||||
now);
|
now);
|
||||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
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(
|
await _systemLogService.WriteAsync(
|
||||||
cleanupWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning,
|
cleanupWarning is null ? SystemLogLevel.Info : SystemLogLevel.Warning,
|
||||||
"Upload",
|
"Upload",
|
||||||
@@ -923,6 +975,81 @@ public sealed class OpenListUploadQueueService
|
|||||||
cancellationToken: cancellationToken);
|
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(
|
private async Task ScheduleRetryOrFailAsync(
|
||||||
RecordUploadJob job,
|
RecordUploadJob job,
|
||||||
RecordResult result,
|
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());
|
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]
|
[Fact]
|
||||||
public async Task Queue_RenamesSameNameConflictWithoutOverwriting()
|
public async Task Queue_RenamesSameNameConflictWithoutOverwriting()
|
||||||
{
|
{
|
||||||
@@ -906,5 +928,15 @@ public sealed class OpenListUploadTests
|
|||||||
Operations.Add($"move:{sourcePath}->{targetDirectory}");
|
Operations.Add($"move:{sourcePath}->{targetDirectory}");
|
||||||
return Task.CompletedTask;
|
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