From 20cd74edd3165c935135358ad7eb6c9535137c97 Mon Sep 17 00:00:00 2001 From: dsp Date: Fri, 11 Sep 2026 17:25:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=B8=89=E7=AB=AF=E7=BC=BA=E9=99=B7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D(=E5=90=8E=E7=AB=AF=E5=A5=91=E7=BA=A6/Web=20?= =?UTF-8?q?=E5=88=86=E9=A1=B5/auth=20=E9=97=AD=E7=8E=AF/Android=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=80=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 经三端只读审计 + 真实端到端验证后修复,每项都带回归用例。 后端 - 游标分页 off-by-one:next_cursor 指向被丢弃的哨兵行,严格不等式又排除它, 13 个端点每页静默丢 1 条;统一收敛到 core.api.paginate_cursor - Feed 排序键(-hot_score/-published_at)与游标键(id)不同序,翻页重复+跳过; hot_score 恒为 0,统一按 -id - 无关/非法 page_size 曾 500 或一次拉全表;统一 parse_page_size 容错限幅 - 匿名写评论 500(AnonymousUser 写非空 FK)→ 读公开写登录;下架视频评论区可读写 → 404 - 取消点赞不回退作者获赞、收藏删除按固定 -1 → 按实际行数回退(可刷计数) - view_count 去重失效(+1 在缓存判断之外)→ 计数与写历史同入去重分支 - WatchHistory 无唯一约束 + update_or_create → 并发重复行后永久 500;补约束 - is_active 未校验:封禁账号可继续用 access/refresh → 三处补校验 - WS user_ 组无订阅者,实时通知整条链是死的 → 补 UserConsumer + 广播 - 广播系统消息已读状态跨用户共享、可改他人定向消息 → 加 per-user 已读表 - 评论回复路由双前缀(comments/comments/…)导致 Web 与 Android 均 404 - 通知 type="all" 落到 filter(type="all") 匹配 0 行 → 全部已读 - 播放上报无鉴权可刷量、无视频存在性校验 → IsAuthenticated + 404 - 生产默认密钥是公开串,可离线伪造任意用户 JWT(含管理员)→ DEBUG=0 时拒启 - 搜索热词读-改-写丢更新 → F() 原子自增 Web - 3 处把分页 envelope 当数组读(历史/访客/系统消息)→ 页面不可用/抛 TypeError - CommentPanel 复用不跟随视频切换,显示并写入别的视频的评论 - 上传受全局 15s 超时,500MB 文件必然失败 → 单请求放宽到 15min - auth 闭环:新 refresh 从不落盘、刷新失败不登出、restore 是死代码、深链接被 App.vue 的 replace("/") 拽回首页 - 私信被前后端双重反转,时序颠倒;点赞/收藏/评论点赞只有 POST 取消不掉 - 通知全读、消息中心两处 filter 崩溃、WS 4401 后永久不重连 Android - 播放统计返回类型 Map 解不了 {"status":"recorded"} → 每次上报 都抛异常;且每 2 秒插一行播放记录,完播率被灌水 10 倍以上 → 改为离开时结算一次 - 上传走通用客户端(20s readTimeout)对同步转码必然超时 → 专用长超时客户端 - BODY 级 HTTP 日志(含密码/令牌/私信)打到 release 包 → 仅 debug BASIC - allowBackup=true 可导出含 30 天 refresh 的 prefs → 关闭并加 dataExtractionRules - 全局明文放行 → networkSecurityConfig 白名单 - 服务器地址只改内存、重启即丢,且 remember 旧 service 导致改地址不生效 - 登录态只算一次:登录后不轮询、登出后仍轮询、401 不跳登录 → SessionState 可观察 - 昵称含 "/" 会崩导航(路由未编码);观看历史/访客页无入口不可达 测试与 CI - backend 30 passed(新增 11 条回归:分页/计数/鉴权/越权/契约) - web 5 passed(vitest:401 单飞刷新、滑动续期回写、失败登出)+ typecheck 绿 - android 4 passed(JVM 契约测试,无需模拟器) - e2e smoke 补取消点赞与 4 个分页页断言;CI 增加 web/android job 验证:API 端到端 19/19、浏览器 E2E 全绿、三端构建通过 --- .github/workflows/ci.yml | 19 + .gitignore | 1 + dsp/android/app/build.gradle.kts | 6 + dsp/android/app/src/main/AndroidManifest.xml | 5 +- .../src/main/java/com/dsp/app/MainActivity.kt | 18 +- .../java/com/dsp/app/data/api/ApiClient.kt | 85 +- .../com/dsp/app/data/api/DspApiService.kt | 8 +- .../com/dsp/app/data/local/SessionState.kt | 31 + .../com/dsp/app/data/local/TokenManager.kt | 8 + .../java/com/dsp/app/data/model/Models.kt | 7 + .../java/com/dsp/app/ui/auth/LoginScreen.kt | 7 +- .../dsp/app/ui/components/VideoPlayerView.kt | 46 +- .../java/com/dsp/app/ui/feed/FeedScreen.kt | 8 +- .../com/dsp/app/ui/navigation/NavRoutes.kt | 4 +- .../com/dsp/app/ui/profile/ProfileScreen.kt | 29 + .../dsp/app/ui/upload/VideoUploadScreen.kt | 4 +- .../main/res/xml/data_extraction_rules.xml | 13 + .../main/res/xml/network_security_config.xml | 15 + .../java/com/dsp/app/data/ApiContractTest.kt | 99 ++ dsp/backend/apps/accounts/auth.py | 2 + dsp/backend/apps/accounts/jwt_utils.py | 3 +- dsp/backend/apps/accounts/views.py | 4 + dsp/backend/apps/comments/urls.py | 18 +- dsp/backend/apps/comments/views.py | 69 +- dsp/backend/apps/creator/views.py | 67 +- ...chhistory_uniq_watch_history_user_video.py | 20 + dsp/backend/apps/history/models.py | 6 + dsp/backend/apps/history/views.py | 42 +- dsp/backend/apps/logs/views.py | 36 +- dsp/backend/apps/messages_app/consumers.py | 41 +- .../migrations/0002_systemmessageread.py | 29 + dsp/backend/apps/messages_app/models.py | 19 + dsp/backend/apps/messages_app/routing.py | 4 +- dsp/backend/apps/messages_app/views.py | 137 +- dsp/backend/apps/music/views.py | 22 +- dsp/backend/apps/notifications/services.py | 46 +- dsp/backend/apps/notifications/views.py | 22 +- dsp/backend/apps/search/views.py | 14 +- dsp/backend/apps/videos/views.py | 105 +- dsp/backend/apps/visitors/views.py | 18 +- dsp/backend/config/settings.py | 27 +- dsp/backend/conftest.py | 4 + dsp/backend/core/api.py | 54 + dsp/backend/tests/test_new_modules.py | 23 +- dsp/backend/tests/test_regressions.py | 343 ++++ dsp/backend/tests/test_smoke.py | 2 +- dsp/web/e2e/smoke.mjs | 35 +- dsp/web/package-lock.json | 1373 +++++++++++++++++ dsp/web/package.json | 6 +- dsp/web/src/App.vue | 21 +- dsp/web/src/api/client.spec.ts | 151 ++ dsp/web/src/api/client.ts | 34 +- dsp/web/src/api/index.ts | 64 +- dsp/web/src/router/index.ts | 15 + dsp/web/src/stores/auth.ts | 65 +- dsp/web/src/utils/ws.ts | 92 +- dsp/web/src/views/feed/CommentPanel.vue | 49 +- dsp/web/src/views/feed/FeedView.vue | 92 +- dsp/web/src/views/history/HistoryView.vue | 84 +- dsp/web/src/views/message/MessagesView.vue | 23 +- .../views/notifications/NotificationsView.vue | 49 +- dsp/web/src/views/visitor/VisitorsView.vue | 40 +- dsp/web/tsconfig.tsbuildinfo | 1 - dsp/web/vitest.config.ts | 15 + 64 files changed, 3393 insertions(+), 406 deletions(-) create mode 100644 dsp/android/app/src/main/java/com/dsp/app/data/local/SessionState.kt create mode 100644 dsp/android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 dsp/android/app/src/main/res/xml/network_security_config.xml create mode 100644 dsp/android/app/src/test/java/com/dsp/app/data/ApiContractTest.kt create mode 100644 dsp/backend/apps/history/migrations/0002_watchhistory_uniq_watch_history_user_video.py create mode 100644 dsp/backend/apps/messages_app/migrations/0002_systemmessageread.py create mode 100644 dsp/backend/tests/test_regressions.py create mode 100644 dsp/web/src/api/client.spec.ts delete mode 100644 dsp/web/tsconfig.tsbuildinfo create mode 100644 dsp/web/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0b1331..d81e8cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,4 +37,23 @@ jobs: node-version: 20 - run: npm ci - run: npx vue-tsc -b + - name: Unit tests (401 单飞刷新 / 契约回归) + run: npm test - run: npm run build + + android: + runs-on: ubuntu-latest + defaults: + run: + working-directory: dsp/android + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - uses: android-actions/setup-android@v3 + - name: Install SDK platform (compileSdk=34) + run: sdkmanager "platforms;android-34" "build-tools;34.0.0" + - name: Unit tests (JSON 契约回归,无需模拟器) + run: ./gradlew testDebugUnitTest --no-daemon diff --git a/.gitignore b/.gitignore index 28f4470..a1dde50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # ---- 依赖与构建产物 ---- +*.tsbuildinfo node_modules/ dist/ .vite/ diff --git a/dsp/android/app/build.gradle.kts b/dsp/android/app/build.gradle.kts index 39a3aa1..0d1c5b2 100644 --- a/dsp/android/app/build.gradle.kts +++ b/dsp/android/app/build.gradle.kts @@ -44,6 +44,8 @@ android { } buildFeatures { compose = true + // ApiClient 依赖 BuildConfig.DEBUG 决定是否输出 HTTP 日志 + buildConfig = true } packaging { resources { @@ -88,4 +90,8 @@ dependencies { // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + // 纯 JVM 单测(不需要模拟器):锁住前后端 JSON 契约 + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") } diff --git a/dsp/android/app/src/main/AndroidManifest.xml b/dsp/android/app/src/main/AndroidManifest.xml index cdba9f8..73c0aa7 100644 --- a/dsp/android/app/src/main/AndroidManifest.xml +++ b/dsp/android/app/src/main/AndroidManifest.xml @@ -9,12 +9,15 @@ 0) { + unreadCount = 0 + navController.navigate(NavRoutes.Login.route) { + popUpTo(navController.graph.id) { inclusive = true } + } + } + } + // Poll unread message count LaunchedEffect(isLoggedIn) { if (isLoggedIn) { @@ -80,6 +94,8 @@ fun MainAppContent() { } delay(10000) } + } else { + unreadCount = 0 } } diff --git a/dsp/android/app/src/main/java/com/dsp/app/data/api/ApiClient.kt b/dsp/android/app/src/main/java/com/dsp/app/data/api/ApiClient.kt index fcc7e03..d127591 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/data/api/ApiClient.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/data/api/ApiClient.kt @@ -1,6 +1,7 @@ package com.dsp.app.data.api import android.content.Context +import com.dsp.app.BuildConfig import com.dsp.app.data.local.TokenManager import com.dsp.app.data.model.AuthResponse import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory @@ -47,7 +48,12 @@ class RefreshAuthenticator(private val tokenManager: TokenManager) : Authenticat private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } override fun authenticate(route: Route?, response: Response): Request? { - if (responseCount(response) >= 2) return null + if (responseCount(response) >= 2) { + // 刷新也没救回来(refresh 过期/账号被停用):清凭证并通知 UI 回登录页, + // 否则界面停在“假登录态”里,用户只看到空数据、不知道要重新登录。 + tokenManager.clearAndForceLogout() + return null + } val refresh = tokenManager.getRefreshToken() ?: return null val body = """{"refresh":"$refresh"}""" @@ -61,10 +67,15 @@ class RefreshAuthenticator(private val tokenManager: TokenManager) : Authenticat val parsed = try { refreshClient.newCall(request).execute().use { resp -> - if (!resp.isSuccessful) return null + if (!resp.isSuccessful) { + // 401/403: refresh 已失效,继续重试只会空转 + tokenManager.clearAndForceLogout() + return null + } json.decodeFromString(resp.body!!.string()) } } catch (e: Exception) { + // 网络异常不代表凭证失效,保留登录态等下个请求重试 return null } tokenManager.saveTokens(parsed.access, parsed.refresh) @@ -97,18 +108,27 @@ object ApiClient { private var retrofit: Retrofit? = null private var service: DspApiService? = null + private var uploadService: DspApiService? = null fun getService(context: Context): DspApiService { + val ctx = context.applicationContext return service ?: synchronized(this) { - val tokenManager = TokenManager(context.applicationContext) + // 服务器地址持久化:改过之后重启 app 仍然生效(此前只改内存,重启即丢) + loadBaseUrl(ctx)?.let { BASE_URL = it } + val tokenManager = TokenManager(ctx) val logging = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BODY + // BODY 会把登录密码、Bearer token、私信正文全打进 logcat,只在 debug 保留 + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BASIC + } else { + HttpLoggingInterceptor.Level.NONE + } } val okHttpClient = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(20, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(DEFAULT_READ_TIMEOUT_SEC, TimeUnit.SECONDS) + .writeTimeout(DEFAULT_WRITE_TIMEOUT_SEC, TimeUnit.SECONDS) .addInterceptor(AuthInterceptor(tokenManager)) .authenticator(RefreshAuthenticator(tokenManager)) .addInterceptor(logging) @@ -127,9 +147,54 @@ object ApiClient { } } - fun updateBaseUrl(newUrl: String) { - BASE_URL = if (newUrl.endsWith("/")) newUrl else "$newUrl/" - service = null - retrofit = null + /** + * 上传/转码专用客户端:后端在请求内同步跑 ffmpeg(最长 600s), + * 通用客户端 20s readTimeout 会让发布必然超时,用户看到失败去重试 → 重复视频。 + */ + fun getUploadService(context: Context): DspApiService { + val ctx = context.applicationContext + return uploadService ?: synchronized(this) { + loadBaseUrl(ctx)?.let { BASE_URL = it } + val tokenManager = TokenManager(ctx) + val okHttpClient = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(UPLOAD_READ_TIMEOUT_SEC, TimeUnit.SECONDS) + .writeTimeout(UPLOAD_WRITE_TIMEOUT_SEC, TimeUnit.SECONDS) + .addInterceptor(AuthInterceptor(tokenManager)) + .authenticator(RefreshAuthenticator(tokenManager)) + .build() + val newService = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(DspApiService::class.java) + uploadService = newService + newService + } } + + fun updateBaseUrl(context: Context, newUrl: String) { + BASE_URL = if (newUrl.endsWith("/")) newUrl else "$newUrl/" + context.applicationContext + .getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(KEY_BASE_URL, BASE_URL) + .apply() + synchronized(this) { + service = null + retrofit = null + uploadService = null + } + } + + private fun loadBaseUrl(context: Context): String? = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_BASE_URL, null) + + private const val PREFS = "dsp_prefs" + private const val KEY_BASE_URL = "base_url" + private const val DEFAULT_READ_TIMEOUT_SEC = 20L + private const val DEFAULT_WRITE_TIMEOUT_SEC = 30L + private const val UPLOAD_READ_TIMEOUT_SEC = 900L + private const val UPLOAD_WRITE_TIMEOUT_SEC = 900L } diff --git a/dsp/android/app/src/main/java/com/dsp/app/data/api/DspApiService.kt b/dsp/android/app/src/main/java/com/dsp/app/data/api/DspApiService.kt index 01b9bee..fa5ba68 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/data/api/DspApiService.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/data/api/DspApiService.kt @@ -104,16 +104,16 @@ interface DspApiService { @Body request: CommentCreateRequest ): Response - @GET("api/v1/comments/comments/{comment_id}/replies") + @GET("api/v1/comments/{comment_id}/replies") suspend fun getCommentReplies( @Path("comment_id") commentId: Long, @Query("cursor") cursor: String? = null ): Response - @POST("api/v1/comments/comments/{comment_id}/like") + @POST("api/v1/comments/{comment_id}/like") suspend fun likeComment(@Path("comment_id") commentId: Long): Response - @DELETE("api/v1/comments/comments/{comment_id}/like") + @DELETE("api/v1/comments/{comment_id}/like") suspend fun unlikeComment(@Path("comment_id") commentId: Long): Response // ==================== 模块 2: 互动通知系统 ==================== @@ -141,7 +141,7 @@ interface DspApiService { // ==================== 模块 5: 创作者中心与播放完播率 ==================== @POST("api/v1/creator/play-stat") - suspend fun reportPlayStat(@Body request: PlayStatReportRequest): Response> + suspend fun reportPlayStat(@Body request: PlayStatReportRequest): Response @GET("api/v1/creator/dashboard") suspend fun getCreatorDashboard(): Response diff --git a/dsp/android/app/src/main/java/com/dsp/app/data/local/SessionState.kt b/dsp/android/app/src/main/java/com/dsp/app/data/local/SessionState.kt new file mode 100644 index 0000000..f5f5f33 --- /dev/null +++ b/dsp/android/app/src/main/java/com/dsp/app/data/local/SessionState.kt @@ -0,0 +1,31 @@ +package com.dsp.app.data.local + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * 全局登录态。 + * + * 之前 MainActivity 用 `remember { tokenManager.isLoggedIn() }` 只在首帧取一次: + * 冷启动未登录时它的键永远是 false,登录成功后未读轮询不会启动; + * 登出后键也没变,轮询继续每 10s 打一次无 token 请求。 + * 这里让登录/登出/强制登出都能被 Compose 观察到。 + */ +object SessionState { + private val _loggedIn = MutableStateFlow(false) + val loggedIn: StateFlow = _loggedIn.asStateFlow() + + /** 强制登出信号:token 过期且刷新失败时自增,UI 据此跳登录页 */ + private val _forceLogoutTick = MutableStateFlow(0) + val forceLogoutTick: StateFlow = _forceLogoutTick.asStateFlow() + + fun sync(isLoggedIn: Boolean) { + _loggedIn.value = isLoggedIn + } + + fun requestForceLogout() { + _loggedIn.value = false + _forceLogoutTick.value += 1 + } +} diff --git a/dsp/android/app/src/main/java/com/dsp/app/data/local/TokenManager.kt b/dsp/android/app/src/main/java/com/dsp/app/data/local/TokenManager.kt index 78493b9..6912bc7 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/data/local/TokenManager.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/data/local/TokenManager.kt @@ -16,6 +16,7 @@ class TokenManager(context: Context) { .putString("access_token", accessToken) .putString("refresh_token", refreshToken) .apply() + SessionState.sync(true) } fun getAccessToken(): String? = prefs.getString("access_token", null) @@ -40,5 +41,12 @@ class TokenManager(context: Context) { fun clear() { prefs.edit().clear().apply() + SessionState.sync(false) + } + + /** 刷新失败(token 过期/账号停用)时的强制登出:清凭证并通知 UI 回登录页 */ + fun clearAndForceLogout() { + prefs.edit().clear().apply() + SessionState.requestForceLogout() } } diff --git a/dsp/android/app/src/main/java/com/dsp/app/data/model/Models.kt b/dsp/android/app/src/main/java/com/dsp/app/data/model/Models.kt index 8ba862e..3bd4491 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/data/model/Models.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/data/model/Models.kt @@ -211,6 +211,13 @@ data class PlayStatReportRequest( val duration: Double ) +/** 后端返回 {"status":"recorded","is_completed":bool};用 Map 每次都解析失败 */ +@Serializable +data class PlayStatReportResponse( + val status: String = "", + @SerialName("is_completed") val isCompleted: Boolean = false +) + @Serializable data class CreatorOverview( @SerialName("total_videos") val totalVideos: Int = 0, diff --git a/dsp/android/app/src/main/java/com/dsp/app/ui/auth/LoginScreen.kt b/dsp/android/app/src/main/java/com/dsp/app/ui/auth/LoginScreen.kt index 2c1c978..bb6f163 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/ui/auth/LoginScreen.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/ui/auth/LoginScreen.kt @@ -33,7 +33,9 @@ fun LoginScreen( onNavigateToRegister: () -> Unit ) { val context = LocalContext.current - val api = remember { ApiClient.getService(context) } + // 服务器地址可改:改完必须重新取 service,否则登录仍发往旧地址。 + var apiGeneration by remember { mutableIntStateOf(0) } + val api = remember(apiGeneration) { ApiClient.getService(context) } val tokenManager = remember { TokenManager(context) } val scope = rememberCoroutineScope() @@ -210,7 +212,8 @@ fun LoginScreen( confirmButton = { Button( onClick = { - ApiClient.updateBaseUrl(serverUrlInput.trim()) + ApiClient.updateBaseUrl(context, serverUrlInput.trim()) + apiGeneration++ // 触发 remember 重新创建 service,指向新地址 showServerDialog = false } ) { diff --git a/dsp/android/app/src/main/java/com/dsp/app/ui/components/VideoPlayerView.kt b/dsp/android/app/src/main/java/com/dsp/app/ui/components/VideoPlayerView.kt index d434796..0216962 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/ui/components/VideoPlayerView.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/ui/components/VideoPlayerView.kt @@ -18,16 +18,27 @@ import com.dsp.app.data.media.DspVideoCache import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +/** + * 播放完成上报:每个视频只回调一次,带上「看到的最远位置」与总时长。 + * + * 之前是每 2 秒回调一次、调用方每次插一行播放记录,看一个 30 秒视频会产生 + * 十几行,创作者看板的播放量和完播率被整体灌水 10 倍以上。改为在离开页面 + * (或暂停)时结算一次。 + */ @OptIn(UnstableApi::class) @Composable fun VideoPlayerView( videoUrl: String, isPlaying: Boolean, - onProgressUpdate: ((Double, Double) -> Unit)? = null, + onWatchSettled: ((watchedSeconds: Double, durationSeconds: Double) -> Unit)? = null, modifier: Modifier = Modifier ) { val context = LocalContext.current var exoPlayer by remember { mutableStateOf(null) } + // 最远播放位置:MutableState 对象本身被 effect 闭包捕获,onDispose 时读到的仍是最新值 + val maxWatched = remember(videoUrl) { mutableStateOf(0.0) } + // 回调可能随重组变化,用 rememberUpdatedState 保证 onDispose 里调用的是最新那个 + val settle = rememberUpdatedState(onWatchSettled) DisposableEffect(videoUrl) { // 使用带有 512MB 磁盘 LRU 缓存的 MediaSourceFactory,支持边播边存与二次秒开 @@ -46,28 +57,37 @@ fun VideoPlayerView( exoPlayer = player onDispose { + // 离开这个视频时结算一次播放数据 + val watched = maxWatched.value + val total = (player.duration / 1000.0).takeIf { it > 0 } ?: 0.0 + if (watched > 1.0) settle.value?.invoke(watched, total) player.release() exoPlayer = null } } LaunchedEffect(isPlaying) { - exoPlayer?.playWhenReady = isPlaying + val player = exoPlayer + player?.playWhenReady = isPlaying + if (isPlaying != true && player != null) { + // 划走时先结算一次(实例保留,划回来还能继续播) + val watched = maxWatched.value + val total = player.duration / 1000.0 + if (watched > 1.0) settle.value?.invoke(watched, total) + } } - // 完播率与播放时长统计上报轮询 - LaunchedEffect(isPlaying) { - if (isPlaying && onProgressUpdate != null) { - while (isActive) { - exoPlayer?.let { p -> - if (p.isPlaying) { - val currentSec = (p.currentPosition / 1000.0).coerceAtLeast(0.0) - val totalSec = (p.duration / 1000.0).coerceAtLeast(0.0) - onProgressUpdate(currentSec, totalSec) - } + // 只累积进度,不再按秒回调上报 + LaunchedEffect(isPlaying, videoUrl) { + if (onWatchSettled == null) return@LaunchedEffect + while (isActive) { + exoPlayer?.let { p -> + if (p.isPlaying) { + val currentSec = (p.currentPosition / 1000.0).coerceAtLeast(0.0) + if (currentSec > maxWatched.value) maxWatched.value = currentSec } - delay(2000) } + delay(1000) } } diff --git a/dsp/android/app/src/main/java/com/dsp/app/ui/feed/FeedScreen.kt b/dsp/android/app/src/main/java/com/dsp/app/ui/feed/FeedScreen.kt index d0b7fa1..5923525 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/ui/feed/FeedScreen.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/ui/feed/FeedScreen.kt @@ -141,15 +141,15 @@ fun FeedScreen( VideoPlayerView( videoUrl = video.videoUrl, isPlaying = isCurrentPage, - onProgressUpdate = { currentSec, totalSec -> - // 模块 5: 播放进度与完播率数据上报 - if (isCurrentPage && currentSec > 1.0) { + onWatchSettled = { watchedSec, totalSec -> + // 模块 5: 一次播放只结算一次(此前每 2 秒插一行,把完播率灌水 10 倍) + if (watchedSec > 1.0 && totalSec > 0) { scope.launch { try { api.reportPlayStat( PlayStatReportRequest( videoId = video.id, - watchSeconds = currentSec, + watchSeconds = watchedSec, duration = totalSec ) ) diff --git a/dsp/android/app/src/main/java/com/dsp/app/ui/navigation/NavRoutes.kt b/dsp/android/app/src/main/java/com/dsp/app/ui/navigation/NavRoutes.kt index d9d022f..92d52da 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/ui/navigation/NavRoutes.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/ui/navigation/NavRoutes.kt @@ -30,8 +30,10 @@ sealed class NavRoutes(val route: String) { } object ChatDetail : NavRoutes("chat_detail/{convId}/{peerId}/{peerName}") { + // 昵称是任意 64 字符文本,含 "/"、"?"、"#" 时未编码会让 NavController + // 匹配不到路由并抛 IllegalArgumentException(与某类昵称的用户私信必崩)。 fun createRoute(convId: Long, peerId: Long, peerName: String) = - "chat_detail/$convId/$peerId/$peerName" + "chat_detail/$convId/$peerId/${android.net.Uri.encode(peerName)}" } object UserProfile : NavRoutes("user_profile/{userId}") { diff --git a/dsp/android/app/src/main/java/com/dsp/app/ui/profile/ProfileScreen.kt b/dsp/android/app/src/main/java/com/dsp/app/ui/profile/ProfileScreen.kt index 047e1d8..ae93de2 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/ui/profile/ProfileScreen.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/ui/profile/ProfileScreen.kt @@ -194,6 +194,35 @@ fun ProfileScreen( Icon(imageVector = Icons.Default.ExitToApp, contentDescription = "退出登录", tint = PrimaryRed) } } + + Spacer(modifier = Modifier.height(8.dp)) + + // 观看历史 / 谁看过我:接口早已对接,但一直没有入口 → 页面实际不可达 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onNavigateToWatchHistory, + colors = ButtonDefaults.buttonColors(containerColor = DarkSurface), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f) + ) { + Icon(imageVector = Icons.Default.History, contentDescription = null, tint = White, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("观看历史", color = White, fontSize = 13.sp) + } + Button( + onClick = onNavigateToVisitors, + colors = ButtonDefaults.buttonColors(containerColor = DarkSurface), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f) + ) { + Icon(imageVector = Icons.Default.Visibility, contentDescription = null, tint = White, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("谁看过我", color = White, fontSize = 13.sp) + } + } } else { Row( modifier = Modifier.fillMaxWidth(), diff --git a/dsp/android/app/src/main/java/com/dsp/app/ui/upload/VideoUploadScreen.kt b/dsp/android/app/src/main/java/com/dsp/app/ui/upload/VideoUploadScreen.kt index 8ee3edc..a495cb4 100644 --- a/dsp/android/app/src/main/java/com/dsp/app/ui/upload/VideoUploadScreen.kt +++ b/dsp/android/app/src/main/java/com/dsp/app/ui/upload/VideoUploadScreen.kt @@ -39,7 +39,9 @@ fun VideoUploadScreen( onUploadSuccess: () -> Unit ) { val context = LocalContext.current - val api = remember { ApiClient.getService(context) } + // 上传走专用客户端:后端在请求内同步转码(ffmpeg 最长 600s), + // 通用客户端的 20s readTimeout 会让发布必然报“上传异常”,用户重试造成重复视频。 + val api = remember { ApiClient.getUploadService(context) } val scope = rememberCoroutineScope() var selectedVideoUri by remember { mutableStateOf(null) } diff --git a/dsp/android/app/src/main/res/xml/data_extraction_rules.xml b/dsp/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..92ced40 --- /dev/null +++ b/dsp/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/dsp/android/app/src/main/res/xml/network_security_config.xml b/dsp/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..965aec7 --- /dev/null +++ b/dsp/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,15 @@ + + + + + + 192.168.5.7 + 10.0.2.2 + localhost + + diff --git a/dsp/android/app/src/test/java/com/dsp/app/data/ApiContractTest.kt b/dsp/android/app/src/test/java/com/dsp/app/data/ApiContractTest.kt new file mode 100644 index 0000000..4f4d24e --- /dev/null +++ b/dsp/android/app/src/test/java/com/dsp/app/data/ApiContractTest.kt @@ -0,0 +1,99 @@ +package com.dsp.app.data + +import com.dsp.app.data.model.PlayStatReportResponse +import com.dsp.app.data.model.SystemMessageListResponse +import com.dsp.app.data.model.VideoFeedResponse +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 后端 JSON 契约回归。 + * + * 这些字段名/类型是与后端 serializer 逐字对齐的,改错不会编译失败, + * 只会在真机上静默解析失败(历史问题:播放统计用 Map + * 解 {"status":"recorded"},每次上报都抛 SerializationException 被吞掉)。 + */ +class ApiContractTest { + + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + encodeDefaults = true + } + + @Test + fun `play stat response decodes status string and is_completed`() { + val raw = """{"status":"recorded","is_completed":true}""" + val parsed = json.decodeFromString(raw) + assertEquals("recorded", parsed.status) + assertTrue(parsed.isCompleted) + + val notCompleted = json.decodeFromString( + """{"status":"recorded","is_completed":false}""" + ) + assertFalse(notCompleted.isCompleted) + } + + @Test + fun `feed response uses next_cursor and results envelope`() { + val raw = """ + { + "next_cursor": "42", + "results": [ + { + "id": 7, + "title": "demo", + "video_url": "http://host/media/videos/a.mp4", + "cover_url": "http://host/media/covers/a.jpg", + "like_count": 3, + "comment_count": 1, + "favorite_count": 0, + "view_count": 9, + "is_liked": true, + "is_favorited": false, + "author": {"id": 2, "username": "u", "nickname": "N"}, + "tags": ["旅行", "美食"] + } + ] + } + """.trimIndent() + val parsed = json.decodeFromString(raw) + assertEquals("42", parsed.nextCursor) + assertEquals(1, parsed.results.size) + val v = parsed.results.first() + assertEquals(7L, v.id) + assertEquals("http://host/media/videos/a.mp4", v.videoUrl) + assertTrue(v.isLiked) + assertFalse(v.isFavorited) + assertEquals(listOf("旅行", "美食"), v.tags) + } + + @Test + fun `null next_cursor on last page decodes as null`() { + val raw = """{"next_cursor": null, "results": []}""" + val parsed = json.decodeFromString(raw) + assertNull(parsed.nextCursor) + assertTrue(parsed.results.isEmpty()) + } + + @Test + fun `system message list is a paged envelope not a bare array`() { + val raw = """ + { + "next_cursor": null, + "results": [ + {"id": 1, "type": "announcement", "title": "t", "content": "c", "is_read": false, + "created_at": "2026-09-11T10:00:00+08:00"} + ] + } + """.trimIndent() + val parsed = json.decodeFromString(raw) + assertEquals(1, parsed.results.size) + assertEquals("announcement", parsed.results.first().type) + assertFalse(parsed.results.first().isRead) + } +} diff --git a/dsp/backend/apps/accounts/auth.py b/dsp/backend/apps/accounts/auth.py index ad59191..76e9b24 100644 --- a/dsp/backend/apps/accounts/auth.py +++ b/dsp/backend/apps/accounts/auth.py @@ -27,6 +27,8 @@ class JWTAuthentication(authentication.BaseAuthentication): user = User.objects.get(pk=int(payload["sub"])) except (User.DoesNotExist, ValueError): raise exceptions.AuthenticationFailed("User not found") + if not user.is_active: + raise exceptions.AuthenticationFailed("Account disabled") return (user, token) def authenticate_header(self, request): diff --git a/dsp/backend/apps/accounts/jwt_utils.py b/dsp/backend/apps/accounts/jwt_utils.py index dfe80eb..3b09303 100644 --- a/dsp/backend/apps/accounts/jwt_utils.py +++ b/dsp/backend/apps/accounts/jwt_utils.py @@ -61,7 +61,8 @@ async def authenticate_scope(scope: dict) -> dict: try: user_id = int(payload["sub"]) user = await database_sync_to_async(User.objects.get)(pk=user_id) - scope["user"] = user + if user.is_active: + scope["user"] = user except (User.DoesNotExist, ValueError): pass return scope \ No newline at end of file diff --git a/dsp/backend/apps/accounts/views.py b/dsp/backend/apps/accounts/views.py index a035aae..5d7b025 100644 --- a/dsp/backend/apps/accounts/views.py +++ b/dsp/backend/apps/accounts/views.py @@ -65,6 +65,8 @@ class LoginView(APIView): return Response({"detail": "invalid credentials"}, status=status.HTTP_401_UNAUTHORIZED) if not await sync_to_async(check_password)(d["password"], user.password): return Response({"detail": "invalid credentials"}, status=status.HTTP_401_UNAUTHORIZED) + if not user.is_active: + return Response({"detail": "account disabled"}, status=status.HTTP_403_FORBIDDEN) return Response(await _tokens(user, request)) @@ -82,6 +84,8 @@ class RefreshView(APIView): user = await User.objects.aget(pk=int(payload["sub"])) except (User.DoesNotExist, ValueError): return Response({"detail": "user not found"}, status=status.HTTP_401_UNAUTHORIZED) + if not user.is_active: + return Response({"detail": "account disabled"}, status=status.HTTP_403_FORBIDDEN) return Response(await _tokens(user, request)) diff --git a/dsp/backend/apps/comments/urls.py b/dsp/backend/apps/comments/urls.py index 7717a80..a536445 100644 --- a/dsp/backend/apps/comments/urls.py +++ b/dsp/backend/apps/comments/urls.py @@ -1,10 +1,16 @@ -"""Comment urls.""" +"""Comment urls. + +挂载于 ``/api/v1/comments/``,因此这里只写资源自身的路径段。 +列表/创建走 ``/api/v1/videos//comments``(见 apps/videos/urls.py): +此前这里重复挂了一份同视图并复用了同名 "video-comments",既产生 +``/api/v1/comments/comments//replies`` 这样的双前缀(Web 端按标准 +路径请求直接 404),也让 reverse() 名称冲突。 +""" from django.urls import path -from .views import CommentLikeView, CommentRepliesView, VideoCommentsView +from .views import CommentLikeView, CommentRepliesView urlpatterns = [ - path("videos//comments", VideoCommentsView.as_view(), name="video-comments"), - path("comments//replies", CommentRepliesView.as_view(), name="comment-replies"), - path("comments//like", CommentLikeView.as_view(), name="comment-like"), -] \ No newline at end of file + path("/replies", CommentRepliesView.as_view(), name="comment-replies"), + path("/like", CommentLikeView.as_view(), name="comment-like"), +] diff --git a/dsp/backend/apps/comments/views.py b/dsp/backend/apps/comments/views.py index 656302e..52a3e1a 100644 --- a/dsp/backend/apps/comments/views.py +++ b/dsp/backend/apps/comments/views.py @@ -12,39 +12,51 @@ from rest_framework.response import Response from apps.history.services import record_browse from apps.notifications.services import create_notification -from core.api import render_data +from core.api import paginate_cursor, parse_page_size, render_data from .models import Comment, CommentLike from .serializers import CommentCreateSerializer, CommentSerializer +async def _published_video_or_none(video_id: int): + """Return the video only when it is publicly visible. + + Comments are readable and writable only for published videos; a hidden or + deleted video must not keep leaking its comment thread. + """ + from apps.videos.models import Video + + try: + return await Video.objects.aget(pk=video_id, status="published") + except Video.DoesNotExist: + return None + + class VideoCommentsView(APIView): permission_classes = [AllowAny] + def get_permissions(self): + # 读公开、写必须登录:匿名 POST 会把 AnonymousUser 写进非空 FK 而 500。 + if self.request.method == "POST": + return [IsAuthenticated()] + return [AllowAny()] + async def get(self, request, video_id: int): - cursor = request.query_params.get("cursor") - try: - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) - except ValueError: - page_size = 20 + if await _published_video_or_none(video_id) is None: + return Response({"detail": "not found"}, status=404) + page_size = parse_page_size(request.query_params.get("page_size")) qs = ( Comment.objects.filter(video_id=video_id, parent__isnull=True) .select_related("user") .order_by("-created_at", "-id") ) - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [c async for c in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor(qs, request.query_params.get("cursor"), page_size) data = await render_data(CommentSerializer(rows, many=True, context={"request": request})) - return Response({"next_cursor": str(next_cursor) if next_cursor else None, "results": data}) + return Response({"next_cursor": next_cursor, "results": data}) async def post(self, request, video_id: int): + if await _published_video_or_none(video_id) is None: + return Response({"detail": "not found"}, status=404) s = CommentCreateSerializer(data=request.data) s.is_valid(raise_exception=True) d = s.validated_data @@ -86,6 +98,9 @@ class VideoCommentsView(APIView): new_id, video_author_id, parent_user_id = await _create() except Comment.DoesNotExist: return Response({"detail": "parent comment not found"}, status=400) + except Video.DoesNotExist: + # 视频在鉴权与建评论之间被删除/下架,回滚整条写入。 + return Response({"detail": "not found"}, status=404) await record_browse(request.user.id, "video_comment", video_id) @@ -137,27 +152,17 @@ class CommentRepliesView(APIView): permission_classes = [AllowAny] async def get(self, request, comment_id: int): - cursor = request.query_params.get("cursor") - try: - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) - except ValueError: - page_size = 20 + page_size = parse_page_size(request.query_params.get("page_size")) qs = ( Comment.objects.filter(parent_id=comment_id) - .select_related("user") + .select_related("user", "parent__user") .order_by("created_at", "id") ) - if cursor: - try: - qs = qs.filter(id__gt=int(cursor)) - except ValueError: - pass - rows = [c async for c in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size, ascending=True + ) data = await render_data(CommentSerializer(rows, many=True, context={"request": request})) - return Response({"next_cursor": str(next_cursor) if next_cursor else None, "results": data}) + return Response({"next_cursor": next_cursor, "results": data}) class CommentLikeView(APIView): diff --git a/dsp/backend/apps/creator/views.py b/dsp/backend/apps/creator/views.py index 0bb41c1..40f4f1b 100644 --- a/dsp/backend/apps/creator/views.py +++ b/dsp/backend/apps/creator/views.py @@ -17,27 +17,33 @@ from .models import VideoPlayStat class PlayStatReportView(APIView): - """Client reports playback duration and completion.""" + """Client reports playback duration and completion. - permission_classes = [AllowAny] + Authenticated-only: anonymous reports let anyone inflate a video's play + and completion numbers, and the creator dashboard reads straight off them. + """ + + permission_classes = [IsAuthenticated] async def post(self, request): - video_id = int(request.data.get("video_id") or 0) - watch_seconds = float(request.data.get("watch_seconds") or 0.0) - duration = float(request.data.get("duration") or 0.0) + try: + video_id = int(request.data.get("video_id") or 0) + watch_seconds = float(request.data.get("watch_seconds") or 0.0) + duration = float(request.data.get("duration") or 0.0) + except (TypeError, ValueError): + return Response({"detail": "invalid numeric field"}, status=400) if not video_id: return Response({"detail": "video_id required"}, status=400) + if not await Video.objects.filter(pk=video_id, status="published").aexists(): + return Response({"detail": "not found"}, status=404) # 观看进度 >= 85% 即判定为“有效完播” - is_completed = False - if duration > 0 and (watch_seconds >= duration * 0.85): - is_completed = True + is_completed = bool(duration > 0 and watch_seconds >= duration * 0.85) - user = request.user if request.user.is_authenticated else None await VideoPlayStat.objects.acreate( video_id=video_id, - user=user, + user=request.user, watch_seconds=watch_seconds, video_duration=duration, is_completed=is_completed, @@ -57,32 +63,43 @@ class CreatorDashboardView(APIView): video_qs = Video.objects.filter(author=user, status="published") total_videos = await video_qs.acount() - # 汇总基础数据 - total_views = 0 - total_likes = 0 - total_comments = 0 - total_favorites = 0 + # 汇总基础数据:全量聚合,不再只累加最近 20 条(此前视频多于 20 个时看板偏小)。 + totals = await video_qs.aaggregate( + views=Sum("view_count"), + likes=Sum("like_count"), + comments=Sum("comment_count"), + favorites=Sum("favorite_count"), + ) + total_views = totals["views"] or 0 + total_likes = totals["likes"] or 0 + total_comments = totals["comments"] or 0 + total_favorites = totals["favorites"] or 0 video_items = [] now = timezone.now() - async for v in video_qs.order_by("-created_at")[:20]: - total_views += v.view_count - total_likes += v.like_count - total_comments += v.comment_count - total_favorites += v.favorite_count + # 完播统计一次性按 video 聚合,避免每行两次 count()。 + stat_rows = {} + async for row in ( + VideoPlayStat.objects.filter(video__author=user) + .values("video_id") + .annotate(total=Count("id"), completed=Count("id", filter=Q(is_completed=True))) + ): + stat_rows[row["video_id"]] = row - # 查询该视频的历史完播数据 - stats_qs = VideoPlayStat.objects.filter(video_id=v.id) - stat_total = await stats_qs.acount() - stat_completed = await stats_qs.filter(is_completed=True).acount() + async for v in video_qs.order_by("-created_at")[:20]: + row = stat_rows.get(v.id) or {"total": 0, "completed": 0} + stat_total = row["total"] + stat_completed = row["completed"] completion_rate = round((stat_completed / stat_total * 100), 1) if stat_total > 0 else 0.0 + # 绝对 URL:客户端(Android Coil / Web )拿到相对路径无法拼接主机。 + cover = v.cover_image.url if v.cover_image else "" video_items.append({ "id": v.id, "title": v.title, - "cover_url": v.cover_image.url if v.cover_image else "", + "cover_url": request.build_absolute_uri(cover) if cover else "", "view_count": v.view_count, "like_count": v.like_count, "comment_count": v.comment_count, diff --git a/dsp/backend/apps/history/migrations/0002_watchhistory_uniq_watch_history_user_video.py b/dsp/backend/apps/history/migrations/0002_watchhistory_uniq_watch_history_user_video.py new file mode 100644 index 0000000..3817af5 --- /dev/null +++ b/dsp/backend/apps/history/migrations/0002_watchhistory_uniq_watch_history_user_video.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.12 on 2026-09-11 06:47 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('history', '0001_initial'), + ('videos', '0002_video_music'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddConstraint( + model_name='watchhistory', + constraint=models.UniqueConstraint(fields=('user', 'video'), name='uniq_watch_history_user_video'), + ), + ] diff --git a/dsp/backend/apps/history/models.py b/dsp/backend/apps/history/models.py index 97b4274..b36ac1e 100644 --- a/dsp/backend/apps/history/models.py +++ b/dsp/backend/apps/history/models.py @@ -17,6 +17,12 @@ class WatchHistory(models.Model): class Meta: db_table = "history_watch_history" indexes = [models.Index(fields=["user", "-watched_at"])] + # record_watch 用 update_or_create 定位「用户+视频」这一行;没有唯一约束时 + # 两个并发请求可能各插一条,此后 get() 命中多行会抛 MultipleObjectsReturned, + # 该用户该视频的观看记录就永久 500。 + constraints = [ + models.UniqueConstraint(fields=["user", "video"], name="uniq_watch_history_user_video") + ] class SearchHistory(models.Model): diff --git a/dsp/backend/apps/history/views.py b/dsp/backend/apps/history/views.py index 8e183c5..44fcba0 100644 --- a/dsp/backend/apps/history/views.py +++ b/dsp/backend/apps/history/views.py @@ -5,7 +5,7 @@ from adrf.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from core.api import render_data +from core.api import paginate_cursor, parse_page_size, render_data from .models import BrowseHistory, SearchHistory, WatchHistory from .serializers import BrowseHistorySerializer, SearchHistorySerializer, WatchHistorySerializer @@ -15,28 +15,18 @@ class WatchHistoryView(APIView): permission_classes = [IsAuthenticated] async def get(self, request): - cursor = request.query_params.get("cursor") - try: - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) - except ValueError: - page_size = 20 + page_size = parse_page_size(request.query_params.get("page_size")) qs = ( WatchHistory.objects.filter(user=request.user) .select_related("video", "video__author") .order_by("-watched_at", "-id") ) - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [h async for h in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) return Response( { - "next_cursor": str(next_cursor) if next_cursor else None, + "next_cursor": next_cursor, "results": await render_data( WatchHistorySerializer(rows, many=True, context={"request": request}) ), @@ -64,24 +54,14 @@ class BrowseHistoryView(APIView): permission_classes = [IsAuthenticated] async def get(self, request): - cursor = request.query_params.get("cursor") - try: - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) - except ValueError: - page_size = 20 + page_size = parse_page_size(request.query_params.get("page_size")) qs = BrowseHistory.objects.filter(user=request.user).order_by("-browsed_at", "-id") - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [h async for h in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) return Response( { - "next_cursor": str(next_cursor) if next_cursor else None, + "next_cursor": next_cursor, "results": await render_data(BrowseHistorySerializer(rows, many=True)), } ) \ No newline at end of file diff --git a/dsp/backend/apps/logs/views.py b/dsp/backend/apps/logs/views.py index 8b394f9..f76db3a 100644 --- a/dsp/backend/apps/logs/views.py +++ b/dsp/backend/apps/logs/views.py @@ -8,6 +8,8 @@ from django.utils import timezone from rest_framework.permissions import AllowAny, IsAuthenticated, IsAdminUser from rest_framework.response import Response +from core.api import paginate_cursor, parse_page_size, render_data + from .models import OperationLog, RequestLog from .serializers import ClientReportSerializer, OperationLogSerializer, RequestLogSerializer @@ -50,22 +52,15 @@ class OperationLogListView(APIView): permission_classes = [IsAdminUser] async def get(self, request): - cursor = request.query_params.get("cursor") - page_size = int(request.query_params.get("page_size") or 50) + page_size = parse_page_size(request.query_params.get("page_size"), default=50) qs = OperationLog.objects.select_related("user").order_by("-id") - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [r async for r in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) return Response( { "next_cursor": next_cursor, - "results": OperationLogSerializer(rows, many=True).data, + "results": await render_data(OperationLogSerializer(rows, many=True)), } ) @@ -74,21 +69,14 @@ class RequestLogListView(APIView): permission_classes = [IsAdminUser] async def get(self, request): - cursor = request.query_params.get("cursor") - page_size = int(request.query_params.get("page_size") or 50) + page_size = parse_page_size(request.query_params.get("page_size"), default=50) qs = RequestLog.objects.select_related("user").order_by("-id") - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [r async for r in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) return Response( { "next_cursor": next_cursor, - "results": RequestLogSerializer(rows, many=True).data, + "results": await render_data(RequestLogSerializer(rows, many=True)), } ) diff --git a/dsp/backend/apps/messages_app/consumers.py b/dsp/backend/apps/messages_app/consumers.py index 6623979..f737b0b 100644 --- a/dsp/backend/apps/messages_app/consumers.py +++ b/dsp/backend/apps/messages_app/consumers.py @@ -1,13 +1,45 @@ -"""Chat WebSocket consumer using Channels + JWT-in-query-string auth.""" +"""Chat WebSocket consumers using Channels + JWT-in-query-string auth.""" from __future__ import annotations -import json - from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.db import models +class UserConsumer(AsyncJsonWebsocketConsumer): + """Per-user channel for notifications that are not tied to one conversation. + + Views broadcast to the ``user_`` group when a DM or notification lands, + but nothing ever subscribed to it, so those events were dropped. This + consumer is that subscriber: connect once per logged-in client and all + user-scoped pushes arrive here. + """ + + async def connect(self): + user = self.scope.get("user") + if not user or not user.is_authenticated: + await self.close(code=4401) + return + self.user = user + self.group_name = f"user_{user.id}" + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + + async def disconnect(self, code): + if hasattr(self, "group_name"): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive_json(self, content, **kwargs): + # Read-only channel: clients keep the socket open for pushes only. + return + + async def user_notify(self, event): + await self.send_json(event["payload"]) + + async def notify(self, event): + await self.send_json(event["payload"]) + + class ChatConsumer(AsyncJsonWebsocketConsumer): async def connect(self): user = self.scope.get("user") @@ -48,6 +80,9 @@ class ChatConsumer(AsyncJsonWebsocketConsumer): async def notify(self, event): await self.send_json(event["payload"]) + async def user_notify(self, event): + await self.send_json(event["payload"]) + @database_sync_to_async def _check_membership(self, conv_id: int, user_id: int) -> bool: from .models import Conversation diff --git a/dsp/backend/apps/messages_app/migrations/0002_systemmessageread.py b/dsp/backend/apps/messages_app/migrations/0002_systemmessageread.py new file mode 100644 index 0000000..f8239a0 --- /dev/null +++ b/dsp/backend/apps/messages_app/migrations/0002_systemmessageread.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.12 on 2026-09-11 06:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('messages_app', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SystemMessageRead', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('read_at', models.DateTimeField(auto_now_add=True)), + ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reads', to='messages_app.systemmessage')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='system_message_reads', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'messages_system_message_read', + 'unique_together': {('user', 'message')}, + }, + ), + ] diff --git a/dsp/backend/apps/messages_app/models.py b/dsp/backend/apps/messages_app/models.py index 6087780..8ef26f0 100644 --- a/dsp/backend/apps/messages_app/models.py +++ b/dsp/backend/apps/messages_app/models.py @@ -33,6 +33,25 @@ class SystemMessage(models.Model): indexes = [models.Index(fields=["user", "-created_at"])] +class SystemMessageRead(models.Model): + """Per-user read state for broadcast system messages. + + ``SystemMessage.user`` is null on a broadcast row and the row is shared by + every account, so its ``is_read`` flag cannot express "read by me" without + clearing the badge for all users. Targeted rows keep using ``is_read``. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="system_message_reads", db_index=True + ) + message = models.ForeignKey(SystemMessage, on_delete=models.CASCADE, related_name="reads") + read_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "messages_system_message_read" + unique_together = [("user", "message")] + + class Conversation(models.Model): user_a = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="conv_a", on_delete=models.CASCADE diff --git a/dsp/backend/apps/messages_app/routing.py b/dsp/backend/apps/messages_app/routing.py index fce82d3..571472a 100644 --- a/dsp/backend/apps/messages_app/routing.py +++ b/dsp/backend/apps/messages_app/routing.py @@ -5,4 +5,6 @@ from . import consumers websocket_urlpatterns = [ re_path(r"^ws/chat/(?P\d+)/$", consumers.ChatConsumer.as_asgi()), -] \ No newline at end of file + # 用户级推送通道(私信提醒/系统通知),与具体会话无关。 + re_path(r"^ws/user/$", consumers.UserConsumer.as_asgi()), +] diff --git a/dsp/backend/apps/messages_app/views.py b/dsp/backend/apps/messages_app/views.py index 1f143ca..d13e255 100644 --- a/dsp/backend/apps/messages_app/views.py +++ b/dsp/backend/apps/messages_app/views.py @@ -1,7 +1,7 @@ """Messages views: system, conversations, messages REST.""" from __future__ import annotations -from asgiref.sync import async_to_sync +from asgiref.sync import async_to_sync, sync_to_async from channels.layers import get_channel_layer from django.db.models import F from rest_framework import status @@ -9,14 +9,14 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from adrf.views import APIView -from .models import Conversation, Message, SystemMessage +from .models import Conversation, Message, SystemMessage, SystemMessageRead from .serializers import ( ConversationSerializer, MessageCreateSerializer, MessageSerializer, SystemMessageSerializer, ) -from core.api import render_data +from core.api import paginate_cursor, parse_page_size, render_data def _channel_layer(): @@ -37,7 +37,7 @@ async def _broadcast(conversation_id: int, sender_id: int, receiver_id: int, pay ) await layer.group_send( f"user_{receiver_id}", - {"type": "notify", "payload": {"event": "new_message", **payload}}, + {"type": "user.notify", "payload": {"event": "new_message", **payload}}, ) @@ -47,37 +47,49 @@ class SystemMessageListView(APIView): async def get(self, request): from django.db.models import Q - cursor = request.query_params.get("cursor") - try: - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) - except ValueError: - page_size = 20 + page_size = parse_page_size(request.query_params.get("page_size")) qs = SystemMessage.objects.filter( Q(user__isnull=True) | Q(user=request.user) ).order_by("-id") - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [r async for r in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) return Response( { - "next_cursor": str(next_cursor) if next_cursor else None, + "next_cursor": next_cursor, "results": await render_data(SystemMessageSerializer(rows, many=True)), } ) async def post(self, request): - """Mark messages as read.""" + """Mark messages as read. + + Only the caller's own targeted messages may be touched; a broadcast row + (user=null) is shared by every account, so marking it read here would + clear it for everyone. Those are marked per-user in the read table. + """ + from django.db.models import Q + ids = request.data.get("ids") or [] if not isinstance(ids, list): return Response({"detail": "ids must be list"}, status=400) - await SystemMessage.objects.filter(pk__in=ids).aupdate(is_read=True) - return Response({"status": "ok"}) + if not ids: + return Response({"status": "ok", "updated_count": 0}) + + own = SystemMessage.objects.filter(pk__in=ids, user=request.user) + updated = await own.aupdate(is_read=True) + + broadcast_ids = [ + sid + async for sid in SystemMessage.objects.filter( + Q(pk__in=ids) & Q(user__isnull=True) + ).values_list("id", flat=True) + ] + for sid in broadcast_ids: + await SystemMessageRead.objects.aget_or_create( + user=request.user, message_id=sid + ) + return Response({"status": "ok", "updated_count": updated}) class UnreadCountView(APIView): @@ -86,9 +98,17 @@ class UnreadCountView(APIView): async def get(self, request): from django.db.models import Q - sys_unread = await SystemMessage.objects.filter( - Q(user__isnull=True) | Q(user=request.user), is_read=False - ).acount() + read_ids = [ + mid + async for mid in SystemMessageRead.objects.filter(user=request.user).values_list( + "message_id", flat=True + ) + ] + sys_unread = ( + await SystemMessage.objects.filter(Q(user__isnull=True) | Q(user=request.user), is_read=False) + .exclude(pk__in=read_ids) + .acount() + ) dm_unread = await Message.objects.filter(receiver=request.user, is_read=False).acount() return Response({"system_unread": sys_unread, "dm_unread": dm_unread}) @@ -104,13 +124,24 @@ class ConversationListCreateView(APIView): .select_related("user_a", "user_b") .order_by(F("last_message_at").desc(nulls_last=True), "-id") ) - rows = [c async for c in qs[:200]] + page_size = parse_page_size(request.query_params.get("page_size")) + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) return Response( - await render_data(ConversationSerializer(rows, many=True, context={"request": request})) + { + "next_cursor": next_cursor, + "results": await render_data( + ConversationSerializer(rows, many=True, context={"request": request}) + ), + } ) async def post(self, request): - peer_id = int(request.data.get("peer_id") or 0) + try: + peer_id = int(request.data.get("peer_id") or 0) + except (TypeError, ValueError): + return Response({"detail": "invalid peer"}, status=400) if not peer_id or peer_id == request.user.id: return Response({"detail": "invalid peer"}, status=400) a, b = sorted([request.user.id, peer_id]) @@ -125,18 +156,34 @@ class ConversationListCreateView(APIView): return Response( await render_data(ConversationSerializer(me, context={"request": request})) ) - peer = await User.objects.aget(pk=peer_id) - conv = Conversation() - if a == request.user.id: - conv.user_a = request.user - conv.user_b = peer - else: - conv.user_a = peer - conv.user_b = request.user - await conv.asave() + try: + peer = await User.objects.aget(pk=peer_id) + except User.DoesNotExist: + return Response({"detail": "peer not found"}, status=404) + + @sync_to_async(thread_sensitive=True) + def _open(): + # 并发下同一对用户可能同时创建;唯一约束冲突时取已存在那条。 + from django.db import IntegrityError, transaction + + conv = Conversation() + if a == request.user.id: + conv.user_a = request.user + conv.user_b = peer + else: + conv.user_a = peer + conv.user_b = request.user + try: + with transaction.atomic(): + conv.save() + return conv, True + except IntegrityError: + return Conversation.objects.get(user_a_id=a, user_b_id=b), False + + conv, created = await _open() return Response( await render_data(ConversationSerializer(conv, context={"request": request})), - status=status.HTTP_201_CREATED, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, ) @@ -154,17 +201,11 @@ class ConversationMessagesView(APIView): return Response({"detail": "forbidden"}, status=403) cursor = request.query_params.get("cursor") - page_size = int(request.query_params.get("page_size") or 30) + page_size = parse_page_size(request.query_params.get("page_size"), default=30) qs = Message.objects.filter(conversation_id=conv_id).order_by("-id") - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [m async for m in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) # mark received as read await Message.objects.filter( conversation_id=conv_id, receiver=request.user, is_read=False diff --git a/dsp/backend/apps/music/views.py b/dsp/backend/apps/music/views.py index 0c68d22..81798c0 100644 --- a/dsp/backend/apps/music/views.py +++ b/dsp/backend/apps/music/views.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from apps.videos.models import Video from apps.videos.serializers import VideoListSerializer -from core.api import render_data +from core.api import paginate_cursor, parse_page_size, render_data from .models import Music from .serializers import MusicSerializer @@ -51,26 +51,20 @@ class MusicVideosListView(APIView): except Music.DoesNotExist: return Response({"detail": "music not found"}, status=404) - cursor = request.query_params.get("cursor") - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) + page_size = parse_page_size(request.query_params.get("page_size")) qs = ( Video.objects.filter(music=m, status="published") .select_related("author", "music") .prefetch_related("tags") - .order_by("-hot_score", "-id") + # 排序键与游标键保持同序(hot_score 恒为 0,真正生效的是 id) + .order_by("-id") + ) + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size ) - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [v async for v in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] data = await render_data(VideoListSerializer(rows, many=True, context={"request": request})) return Response({ "music": await render_data(MusicSerializer(m, context={"request": request})), - "next_cursor": str(next_cursor) if next_cursor else None, + "next_cursor": next_cursor, "results": data, }) diff --git a/dsp/backend/apps/notifications/services.py b/dsp/backend/apps/notifications/services.py index 4c87b50..b60bded 100644 --- a/dsp/backend/apps/notifications/services.py +++ b/dsp/backend/apps/notifications/services.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from asgiref.sync import sync_to_async +from asgiref.sync import async_to_sync, sync_to_async from channels.layers import get_channel_layer from .models import Notification @@ -11,6 +11,24 @@ from .models import Notification logger = logging.getLogger("dsp.notifications") +def _push_to_user(user_id: int, payload: dict) -> None: + """Best-effort group_send of a user-scoped event. + + Runs inside the sync body of ``create_notification`` (i.e. already on a + worker thread), so ``async_to_sync`` bridges back onto the channel layer. + A failed push must never break the caller's write. + """ + try: + layer = get_channel_layer() + if layer is None: + return + async_to_sync(layer.group_send)( + f"user_{user_id}", {"type": "user.notify", "payload": payload} + ) + except Exception: + logger.warning("ws push to user %s failed", user_id, exc_info=True) + + @sync_to_async(thread_sensitive=False) def create_notification( recipient_id: int, @@ -21,7 +39,13 @@ def create_notification( title: str = "", content: str = "", ) -> Notification | None: - """Create a notification in DB and push real-time WebSocket event.""" + """Create a notification in DB and push it to the recipient's WS channel. + + The push targets the ``user_`` group served by + ``messages_app.consumers.UserConsumer``. Before that consumer existed the + "real-time" half of this function was silently dropped — only the DB row + was written and no client ever saw an event. + """ if recipient_id == sender_id: return None # Do not notify self-actions try: @@ -34,7 +58,23 @@ def create_notification( title=title[:128], content=content[:500], ) - return notif except Exception: logger.exception("create_notification failed for recipient %s", recipient_id) return None + + _push_to_user( + recipient_id, + { + "event": "new_notification", + "id": notif.id, + "notification_type": notification_type, + "target_type": target_type, + "target_id": target_id, + "title": notif.title, + "content": notif.content, + "sender_id": sender_id, + "is_read": False, + "created_at": notif.created_at.isoformat(), + }, + ) + return notif diff --git a/dsp/backend/apps/notifications/views.py b/dsp/backend/apps/notifications/views.py index 020846e..34f19ce 100644 --- a/dsp/backend/apps/notifications/views.py +++ b/dsp/backend/apps/notifications/views.py @@ -7,7 +7,7 @@ from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from core.api import render_data +from core.api import paginate_cursor, parse_page_size, render_data from .models import Notification from .serializers import NotificationSerializer @@ -19,7 +19,7 @@ class NotificationListView(APIView): async def get(self, request): cursor = request.query_params.get("cursor") notif_type = request.query_params.get("type") - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) + page_size = parse_page_size(request.query_params.get("page_size")) qs = Notification.objects.filter(recipient=request.user).select_related("sender") if notif_type: @@ -35,20 +35,14 @@ class NotificationListView(APIView): qs = qs.filter(notification_type=notif_type) qs = qs.order_by("-created_at", "-id") - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [n async for n in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) data = await render_data(NotificationSerializer(rows, many=True, context={"request": request})) return Response({ - "next_cursor": str(next_cursor) if next_cursor else None, + "next_cursor": next_cursor, "results": data, }) @@ -83,7 +77,9 @@ class NotificationMarkReadView(APIView): qs = Notification.objects.filter(recipient=request.user, is_read=False) if ids and isinstance(ids, list): qs = qs.filter(id__in=ids) - elif notif_type: + elif notif_type and notif_type != "all": + # "all" / 省略 type 都表示全部已读;此前 "all" 会落到 + # filter(notification_type="all") 匹配 0 行,接口返回 200 却什么都没做。 if notif_type == "likes": qs = qs.filter(notification_type__in=["like_video", "like_comment"]) elif notif_type == "comments": diff --git a/dsp/backend/apps/search/views.py b/dsp/backend/apps/search/views.py index bfa4ab3..1e02757 100644 --- a/dsp/backend/apps/search/views.py +++ b/dsp/backend/apps/search/views.py @@ -2,7 +2,7 @@ from __future__ import annotations from adrf.views import APIView -from django.db.models import Q +from django.db.models import F, Q from rest_framework.permissions import AllowAny from rest_framework.response import Response @@ -11,7 +11,7 @@ from apps.accounts.serializers import UserPublicSerializer from apps.history.services import record_search from apps.videos.models import Tag, Video from apps.videos.serializers import VideoListSerializer -from core.api import render_data +from core.api import parse_page_size, render_data from .models import HotKeyword from .serializers import HotKeywordSerializer @@ -24,14 +24,15 @@ class SearchView(APIView): q = (request.query_params.get("q") or "").strip() if not q: return Response({"videos": [], "users": [], "tags": []}) - page_size = int(request.query_params.get("page_size") or 20) + page_size = parse_page_size(request.query_params.get("page_size")) video_qs = ( Video.objects.filter(status="published") .filter(Q(title__icontains=q) | Q(description__icontains=q)) .select_related("author") .prefetch_related("tags") - .order_by("-hot_score", "-id")[:page_size] + # hot_score 目前恒为 0,排序实际落在 id 上;保持一致避免误解 + .order_by("-id")[:page_size] ) user_qs = ( User.objects.filter(Q(username__icontains=q) | Q(nickname__icontains=q)) @@ -46,9 +47,10 @@ class SearchView(APIView): if request.user.is_authenticated: await record_search(request.user.id, q, hit_count=len(videos) + len(users) + len(tags)) - # bump hot keyword + # bump hot keyword:用 F() 原子自增,读-改-写会在并发搜索时丢计数。 kw, created = await HotKeyword.objects.aget_or_create(keyword=q[:128]) - await HotKeyword.objects.filter(pk=kw.pk).aupdate(search_count=kw.search_count + 1) + if not created: + await HotKeyword.objects.filter(pk=kw.pk).aupdate(search_count=F("search_count") + 1) videos_data = await render_data(VideoListSerializer(videos, many=True, context={"request": request})) users_data = await render_data(UserPublicSerializer(users, many=True, context={"request": request})) diff --git a/dsp/backend/apps/videos/views.py b/dsp/backend/apps/videos/views.py index 2049965..4c2f758 100644 --- a/dsp/backend/apps/videos/views.py +++ b/dsp/backend/apps/videos/views.py @@ -10,7 +10,8 @@ from adrf.views import APIView from asgiref.sync import sync_to_async from django.conf import settings from django.core.cache import cache -from django.db.models import F +from django.db.models import F, Value +from django.db.models.functions import Greatest from django.utils import timezone from rest_framework import status from rest_framework.parsers import FormParser, MultiPartParser @@ -19,7 +20,7 @@ from rest_framework.response import Response from apps.history.services import record_browse, record_watch from apps.music.models import Music -from core.api import render_data +from core.api import paginate_cursor, parse_page_size, render_data from .models import Tag, Video, VideoFavorite, VideoLike from .serializers import VideoListSerializer, VideoUploadSerializer @@ -137,28 +138,21 @@ class FeedView(APIView): permission_classes = [AllowAny] async def get(self, request): - cursor = request.query_params.get("cursor") - try: - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) - except ValueError: - page_size = 20 + page_size = parse_page_size(request.query_params.get("page_size")) qs = ( Video.objects.filter(status="published") .select_related("author", "music") .prefetch_related("tags") - .order_by("-hot_score", "-published_at", "-id") + # 排序键必须与游标键同序。hot_score 目前恒为 0(没有热度重算任务), + # 真实生效的排序是 published_at,而游标过滤的是 id —— 两者不同序时 + # 翻页会重复返回同一条并跳过另一些。这里统一按 -id 排。 + .order_by("-id") + ) + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size ) - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [v async for v in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] data = await render_data(VideoListSerializer(rows, many=True, context={"request": request})) - return Response({"next_cursor": str(next_cursor) if next_cursor else None, "results": data}) + return Response({"next_cursor": next_cursor, "results": data}) class VideoUploadView(APIView): @@ -218,11 +212,27 @@ class VideoDetailView(APIView): return Response({"detail": "not found"}, status=404) async def _count_view(): + # 去重键必须同时挡掉「计数」和「写历史」:之前的 +1 在判断之外, + # 30 秒内刷新多少次就加多少次,播放量可被单账号无限刷。 if request.user.is_authenticated: key = f"view:{request.user.id}:{v.id}" - if not await sync_to_async(cache.get)(key): - await sync_to_async(cache.set)(key, 1, timeout=30) - await record_watch(request.user.id, v.id, progress=0.0) + if await sync_to_async(cache.get)(key): + return + await sync_to_async(cache.set)(key, 1, timeout=30) + await Video.objects.filter(pk=v.id).aupdate(view_count=F("view_count") + 1) + await record_watch(request.user.id, v.id, progress=0.0) + return + # 匿名访问按 IP 去重,同样 30 秒窗口。 + ip = (request.META.get("HTTP_X_FORWARDED_FOR") or "").split(",")[0].strip() or request.META.get( + "REMOTE_ADDR", "" + ) + if not ip: + await Video.objects.filter(pk=v.id).aupdate(view_count=F("view_count") + 1) + return + key = f"view:anon:{ip}:{v.id}" + if await sync_to_async(cache.get)(key): + return + await sync_to_async(cache.set)(key, 1, timeout=30) await Video.objects.filter(pk=v.id).aupdate(view_count=F("view_count") + 1) await _count_view() @@ -273,9 +283,18 @@ class VideoLikeView(APIView): async def delete(self, request, video_id: int): @sync_to_async(thread_sensitive=True) def _unlike(): - deleted, _ = VideoLike.objects.filter(user=request.user, video_id=video_id).delete() - if deleted: - Video.objects.filter(pk=video_id).update(like_count=F("like_count") - 1) + from django.db import transaction + + with transaction.atomic(): + v = Video.objects.filter(pk=video_id).values("author_id").first() + deleted, _ = VideoLike.objects.filter(user=request.user, video_id=video_id).delete() + if deleted and v: + # 点赞时同时加了视频与作者两个计数,取消必须一起回退, + # 否则反复点赞/取消能把作者的获赞数单向刷高。 + Video.objects.filter(pk=video_id).update(like_count=F("like_count") - 1) + from apps.accounts.models import User + + User.objects.filter(pk=v["author_id"]).update(like_count=F("like_count") - 1) return deleted deleted = await _unlike() @@ -306,11 +325,23 @@ class VideoFavoriteView(APIView): return Response({"status": "favorited", "folder": folder, "created": created}) async def delete(self, request, video_id: int): + folder = request.data.get("folder") or request.query_params.get("folder") + @sync_to_async(thread_sensitive=True) def _unfav(): - deleted, _ = VideoFavorite.objects.filter(user=request.user, video_id=video_id).delete() - if deleted: - Video.objects.filter(pk=video_id).update(favorite_count=F("favorite_count") - 1) + from django.db import transaction + + with transaction.atomic(): + qs = VideoFavorite.objects.filter(user=request.user, video_id=video_id) + if folder: + qs = qs.filter(folder=folder) + deleted, _ = qs.delete() + if deleted: + # 同一视频可能被收进多个文件夹(每个 folder 各加过一次计数), + # 因此按实际删除的行数回退,而不是固定减 1。 + Video.objects.filter(pk=video_id).update( + favorite_count=Greatest(F("favorite_count") - deleted, Value(0)) + ) return deleted deleted = await _unfav() @@ -336,28 +367,18 @@ class UserVideosListView(APIView): author_id = int(author_id) except (TypeError, ValueError): return Response({"detail": "bad author"}, status=400) - try: - page_size = min(max(int(request.query_params.get("page_size") or 20), 1), 50) - except ValueError: - page_size = 20 + page_size = parse_page_size(request.query_params.get("page_size")) qs = ( Video.objects.filter(author_id=author_id, status="published") .select_related("author", "music") .prefetch_related("tags") .order_by("-created_at", "-id") ) - cursor = request.query_params.get("cursor") - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [v async for v in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) data = await render_data(VideoListSerializer(rows, many=True, context={"request": request})) - return Response({"next_cursor": str(next_cursor) if next_cursor else None, "results": data}) + return Response({"next_cursor": next_cursor, "results": data}) class MyVideosView(UserVideosListView): diff --git a/dsp/backend/apps/visitors/views.py b/dsp/backend/apps/visitors/views.py index 7ade917..a4ccaa5 100644 --- a/dsp/backend/apps/visitors/views.py +++ b/dsp/backend/apps/visitors/views.py @@ -5,7 +5,7 @@ from adrf.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from core.api import render_data +from core.api import paginate_cursor, parse_page_size, render_data from .models import Visit from .serializers import VisitSerializer @@ -32,24 +32,18 @@ class VisitView(APIView): async def get(self, request): cursor = request.query_params.get("cursor") - page_size = int(request.query_params.get("page_size") or 20) + page_size = parse_page_size(request.query_params.get("page_size"), default=20) qs = ( Visit.objects.filter(owner=request.user) .select_related("visitor", "video") .order_by("-visited_at", "-id") ) - if cursor: - try: - qs = qs.filter(id__lt=int(cursor)) - except ValueError: - pass - rows = [v async for v in qs[: page_size + 1]] - next_cursor = rows[-1].id if len(rows) > page_size else None - if next_cursor: - rows = rows[:-1] + rows, next_cursor = await paginate_cursor( + qs, request.query_params.get("cursor"), page_size + ) return Response( { - "next_cursor": str(next_cursor) if next_cursor else None, + "next_cursor": next_cursor, "results": await render_data(VisitSerializer(rows, many=True)), } ) diff --git a/dsp/backend/config/settings.py b/dsp/backend/config/settings.py index ecc24f4..ac1beda 100644 --- a/dsp/backend/config/settings.py +++ b/dsp/backend/config/settings.py @@ -21,6 +21,8 @@ ALLOWED_HOSTS = config( default="127.0.0.1,localhost,testserver", cast=Csv(), ) +# 生产域名/端口白名单,供 CSRF 与安全重定向使用;逗号分隔。 +CSRF_TRUSTED_ORIGINS = config("DJANGO_CSRF_TRUSTED_ORIGINS", default="", cast=Csv()) INSTALLED_APPS = [ "daphne", @@ -147,8 +149,9 @@ REST_FRAMEWORK = { } # JWT +# 签名密钥独立可轮换:默认沿用 SECRET_KEY,生产可用 DJANGO_JWT_SECRET 单独指定。 JWT = { - "SECRET": SECRET_KEY, + "SECRET": config("DJANGO_JWT_SECRET", default="") or SECRET_KEY, "ALGORITHM": "HS256", "ACCESS_TTL_MIN": config("JWT_ACCESS_TTL_MIN", default=60, cast=int), "REFRESH_TTL_DAY": config("JWT_REFRESH_TTL_DAY", default=30, cast=int), @@ -167,7 +170,7 @@ CORS_ALLOWED_ORIGINS = config( default="http://localhost:5173,http://127.0.0.1:5173,http://192.168.5.7:19000", cast=Csv(), ) -CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_CREDENTIALS = False CORS_ALLOW_HEADERS = list( { *[ @@ -228,3 +231,23 @@ if DSP_TEST: } MEDIA_ROOT = Path(tempfile.mkdtemp(prefix="dsp-test-media-")) PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +# ---- Production guard ---- +# 默认密钥是公开字符串,任何人都能用它自签 JWT 冒充任意账号(含管理员)。 +# 生产环境(DEBUG=0)若仍在使用默认值,直接拒绝启动而不是静默降级。 +if not DEBUG and not DSP_TEST and SECRET_KEY == "dev-secret-key-change-me-dsp-platform-32chars-min!": + from django.core.exceptions import ImproperlyConfigured + + raise ImproperlyConfigured( + "DJANGO_SECRET_KEY 未设置:默认密钥是公开值,会让 JWT 可被任意伪造。" + "请在环境变量中提供随机密钥(python -c \"import secrets;print(secrets.token_urlsafe(64))\")。" + ) + +if not DEBUG: + # 明文 HTTP 时不能开 SSL 重定向,否则回环地址也会被 301;用环境变量显式开启。 + # 反向代理已终止 TLS 时通过 DJANGO_BEHIND_TLS_PROXY=1 打开下面几项。 + SESSION_COOKIE_SECURE = config("DJANGO_SECURE_COOKIES", default=False, cast=bool) + CSRF_COOKIE_SECURE = SESSION_COOKIE_SECURE + SECURE_CONTENT_TYPE_NOSNIFF = True + SECURE_REFERRER_POLICY = "same-origin" + X_FRAME_OPTIONS = "DENY" diff --git a/dsp/backend/conftest.py b/dsp/backend/conftest.py index 688c472..fb45c17 100644 --- a/dsp/backend/conftest.py +++ b/dsp/backend/conftest.py @@ -14,6 +14,7 @@ from httpx import ASGITransport, AsyncClient async def app(django_db_setup, django_db_blocker): from asgiref.sync import sync_to_async from django.core.asgi import get_asgi_application + from django.core.cache import cache from django.core.management import call_command with django_db_blocker.unblock(): @@ -25,6 +26,9 @@ async def app(django_db_setup, django_db_blocker): await sync_to_async(call_command, thread_sensitive=False)( "flush", verbosity=0, interactive=False ) + # LocMemCache 跨测试存活(flush 只清 DB)。观看去重等 key 以 id 为后缀, + # 而每个测试的视频 id 都从 1 开始,不清缓存会互相串。 + await sync_to_async(cache.clear)() http_only = get_asgi_application() transport = ASGITransport(app=http_only) async with AsyncClient(transport=transport, base_url="http://testserver") as client: diff --git a/dsp/backend/core/api.py b/dsp/backend/core/api.py index dc94ff7..29b3a72 100644 --- a/dsp/backend/core/api.py +++ b/dsp/backend/core/api.py @@ -1,6 +1,13 @@ """API helpers.""" +from __future__ import annotations + +from typing import Any + from asgiref.sync import sync_to_async +DEFAULT_PAGE_SIZE = 20 +MAX_PAGE_SIZE = 50 + async def render_data(serializer): """Evaluate serializer.data on a worker thread. @@ -10,3 +17,50 @@ async def render_data(serializer): thread sidesteps that. """ return await sync_to_async(lambda: serializer.data, thread_sensitive=True)() + + +def parse_page_size(raw: Any, default: int = DEFAULT_PAGE_SIZE, maximum: int = MAX_PAGE_SIZE) -> int: + """Clamp a ?page_size= value into [1, maximum], falling back to default on junk. + + A bare ``int(...)`` turned ``?page_size=abc`` into a 500 and accepted + unbounded values, so both the crash and the unbounded fetch land here. + """ + try: + return min(max(int(raw or default), 1), maximum) + except (TypeError, ValueError): + return default + + +async def paginate_cursor( + qs, + cursor: Any, + page_size: int, + *, + ascending: bool = False, + cursor_field: str = "id", +): + """Cursor-slice a queryset and return ``(rows, next_cursor)``. + + Fetches ``page_size + 1`` rows to detect a following page, drops the extra + row, and points the cursor at the last *kept* row. Pointing it at the + dropped sentinel (the previous behaviour at every call site) made the + strictly-inequality resume filter skip that record, silently losing one + row per page. + + ``ascending`` picks the resume operator: ``cursor_field__gt`` for + oldest-first lists, ``cursor_field__lt`` for newest-first lists. + """ + if cursor not in (None, ""): + try: + value = int(cursor) + except (TypeError, ValueError): + value = None + if value is not None: + lookup = f"{cursor_field}__gt" if ascending else f"{cursor_field}__lt" + qs = qs.filter(**{lookup: value}) + + rows = [row async for row in qs[: page_size + 1]] + has_more = len(rows) > page_size + rows = rows[:page_size] + next_cursor = str(rows[-1].id) if (has_more and rows) else None + return rows, next_cursor diff --git a/dsp/backend/tests/test_new_modules.py b/dsp/backend/tests/test_new_modules.py index efbc46f..099201b 100644 --- a/dsp/backend/tests/test_new_modules.py +++ b/dsp/backend/tests/test_new_modules.py @@ -199,23 +199,40 @@ async def test_creator_dashboard_and_completion_rate(app, user_factory): ) vid = v_resp.json()["id"] - # 2. 模拟播放数据上报:一次有效完播 (观看 19 秒 / 20 秒 >= 85%) + # 2. 未登录上报必须被拒:匿名可上报等于任何人都能刷播放量/完播率 + anon = await app.post( + "/api/v1/creator/play-stat", + json={"video_id": vid, "watch_seconds": 19.0, "duration": 20.0}, + ) + assert anon.status_code == 401 + + # 3. 模拟播放数据上报:一次有效完播 (观看 19 秒 / 20 秒 >= 85%) r1 = await app.post( "/api/v1/creator/play-stat", + headers=c_headers, json={"video_id": vid, "watch_seconds": 19.0, "duration": 20.0}, ) assert r1.status_code == 200 assert r1.json()["is_completed"] is True - # 3. 模拟播放数据上报:一次中途划走 (观看 3 秒 / 20 秒) + # 4. 模拟播放数据上报:一次中途划走 (观看 3 秒 / 20 秒) r2 = await app.post( "/api/v1/creator/play-stat", + headers=c_headers, json={"video_id": vid, "watch_seconds": 3.0, "duration": 20.0}, ) assert r2.status_code == 200 assert r2.json()["is_completed"] is False - # 4. 创作者查看数据看板 + # 5. 不存在的视频必须 404,不能写坏外键 + missing = await app.post( + "/api/v1/creator/play-stat", + headers=c_headers, + json={"video_id": 999999, "watch_seconds": 1.0, "duration": 2.0}, + ) + assert missing.status_code == 404 + + # 6. 创作者查看数据看板 dash = await app.get("/api/v1/creator/dashboard", headers=c_headers) assert dash.status_code == 200 data = dash.json() diff --git a/dsp/backend/tests/test_regressions.py b/dsp/backend/tests/test_regressions.py new file mode 100644 index 0000000..f432445 --- /dev/null +++ b/dsp/backend/tests/test_regressions.py @@ -0,0 +1,343 @@ +"""回归用例:覆盖本轮修复的缺陷,防止再次退化。 + +分组: + 1. 游标分页 off-by-one(每页曾静默丢 1 条) + 2. 计数一致性(取消点赞/收藏后的计数漂移) + 3. 鉴权与状态隔离(匿名写、下架视频、封禁账号、越权已读) + 4. 契约稳定性(分页 envelope、page_size 容错) +""" +from __future__ import annotations + +import io +from unittest.mock import patch + +import pytest + + +async def _login(app, username, password="secret123"): + r = await app.post("/api/v1/accounts/login", json={"username": username, "password": password}) + assert r.status_code == 200, r.text + return {"Authorization": f"Bearer {r.json()['access']}"} + + +async def _upload(app, headers, title="demo"): + fake = io.BytesIO(b"FAKE_VIDEO_BYTES") + with patch( + "apps.videos.views._probe_media", + return_value={"duration": 12.5, "width": 720, "height": 1280}, + ): + r = await app.post( + "/api/v1/videos/", + headers=headers, + files={"video_file": ("demo.mp4", fake, "video/mp4")}, + data={"title": title}, + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + +# ---------------------------------------------------------------- 1. 分页 + + +@pytest.mark.asyncio +async def test_feed_cursor_pagination_loses_no_row(app, user_factory): + """游标翻页必须逐条覆盖全集,不能跳过哨兵行。 + + 修复前 next_cursor 指向被丢弃的第 page_size+1 条,下一页用严格不等式 + 过滤时把它排除,每页固定丢 1 条。 + """ + author = await user_factory("pager_author", "secret123") + headers = await _login(app, "pager_author") + + total = 7 + ids = [await _upload(app, headers, f"vid-{i}") for i in range(total)] + + seen = [] + cursor = None + for _ in range(total + 2): # 多给两轮,若没走完说明游标卡住 + url = "/api/v1/videos/feed?page_size=3" + (f"&cursor={cursor}" if cursor else "") + r = await app.get(url) + assert r.status_code == 200, r.text + body = r.json() + assert set(body) == {"next_cursor", "results"}, "分页 envelope 字段必须稳定" + seen.extend(v["id"] for v in body["results"]) + cursor = body["next_cursor"] + if not cursor: + break + + assert sorted(seen) == sorted(ids), f"游标翻页丢数据: 期望 {sorted(ids)},实际 {sorted(seen)}" + + +@pytest.mark.asyncio +async def test_comment_replies_pagination_ascending(app, user_factory): + """回复列表是升序游标(id__gt),同样不能丢行。""" + author = await user_factory("reply_author", "secret123") + commenter = await user_factory("reply_commenter", "secret123") + a_headers = await _login(app, "reply_author") + c_headers = await _login(app, "reply_commenter") + + vid = await _upload(app, a_headers) + root = await app.post( + f"/api/v1/videos/{vid}/comments", headers=c_headers, json={"content": "根评论"} + ) + root_id = root.json()["id"] + + for i in range(5): + r = await app.post( + f"/api/v1/videos/{vid}/comments", + headers=c_headers, + json={"content": f"回复-{i}", "parent_id": root_id}, + ) + assert r.status_code == 201, r.text + + seen = [] + cursor = None + for _ in range(5): + url = f"/api/v1/comments/{root_id}/replies?page_size=2" + if cursor: + url += f"&cursor={cursor}" + body = (await app.get(url)).json() + seen.extend(c["id"] for c in body["results"]) + cursor = body["next_cursor"] + if not cursor: + break + + assert len(seen) == 5, f"回复翻页丢数据: 只拿到 {len(seen)} 条" + assert len(set(seen)) == 5, "回复翻页出现重复" + + +@pytest.mark.asyncio +async def test_page_size_junk_does_not_500(app, user_factory): + """?page_size=abc / 超大值 必须被容错收敛,而不是 500 或全表拉取。""" + await user_factory("ps_user", "secret123") + headers = await _login(app, "ps_user") + + for value in ("abc", "-5", "99999999", ""): + r = await app.get(f"/api/v1/videos/feed?page_size={value}") + assert r.status_code == 200, f"page_size={value!r} -> {r.status_code}" + assert r.json()["results"] is not None + + for value in ("abc", "10000000"): + r = await app.get(f"/api/v1/visitors/me?page_size={value}", headers=headers) + assert r.status_code == 200, f"visitors page_size={value!r} -> {r.status_code}" + + r = await app.get("/api/v1/messages/system?page_size=abc", headers=headers) + assert r.status_code == 200 + + +# ------------------------------------------------------- 2. 计数一致性 + + +@pytest.mark.asyncio +async def test_unlike_rolls_back_author_like_count(app, user_factory): + """取消点赞必须同时回退作者获赞数,否则可被反复点击刷高。""" + author = await user_factory("cnt_author", "secret123") + fan = await user_factory("cnt_fan", "secret123") + a_headers = await _login(app, "cnt_author") + f_headers = await _login(app, "cnt_fan") + + vid = await _upload(app, a_headers) + + for _ in range(3): + like = await app.post(f"/api/v1/videos/{vid}/like", headers=f_headers) + assert like.status_code == 200 + unlike = await app.delete(f"/api/v1/videos/{vid}/like", headers=f_headers) + assert unlike.status_code == 200 + + author_public = (await app.get(f"/api/v1/accounts/{author.id}")).json() + assert author_public["like_count"] == 0, "反复点赞/取消把作者获赞数刷高了" + + video = (await app.get(f"/api/v1/videos/{vid}")).json() + assert video["like_count"] == 0 + + +@pytest.mark.asyncio +async def test_favorite_multi_folder_count_tracks_rows(app, user_factory): + """同一视频收进多个文件夹后,删除要按实际删除行数回退计数。""" + author = await user_factory("fav_author", "secret123") + fan = await user_factory("fav_fan", "secret123") + a_headers = await _login(app, "fav_author") + f_headers = await _login(app, "fav_fan") + + vid = await _upload(app, a_headers) + + for folder in ("默认收藏", "稍后再看", "学习"): + r = await app.post(f"/api/v1/videos/{vid}/favorite", headers=f_headers, json={"folder": folder}) + assert r.status_code == 200 + + assert (await app.get(f"/api/v1/videos/{vid}")).json()["favorite_count"] == 3 + + r = await app.delete(f"/api/v1/videos/{vid}/favorite", headers=f_headers) + assert r.status_code == 200 + assert (await app.get(f"/api/v1/videos/{vid}")).json()["favorite_count"] == 0 + + # 再次删除不应把计数压成负数 + await app.delete(f"/api/v1/videos/{vid}/favorite", headers=f_headers) + assert (await app.get(f"/api/v1/videos/{vid}")).json()["favorite_count"] == 0 + + +# ------------------------------------------------------ 3. 鉴权/状态隔离 + + +@pytest.mark.asyncio +async def test_comment_requires_auth_and_published_video(app, user_factory): + """匿名写评论应 401(曾 500);下架视频的评论区应 404。""" + author = await user_factory("cmt_author", "secret123") + a_headers = await _login(app, "cmt_author") + vid = await _upload(app, a_headers) + + anon = await app.post(f"/api/v1/videos/{vid}/comments", json={"content": "匿名评论"}) + assert anon.status_code == 401, f"匿名写评论返回 {anon.status_code}" + + missing = await app.post("/api/v1/videos/999999/comments", headers=a_headers, json={"content": "x"}) + assert missing.status_code == 404 + + # 下架后读写都应 404 + from apps.videos.models import Video + + from asgiref.sync import sync_to_async + + await sync_to_async(Video.objects.filter(pk=vid).update)(status="hidden") + + assert (await app.get(f"/api/v1/videos/{vid}/comments")).status_code == 404 + hidden_post = await app.post( + f"/api/v1/videos/{vid}/comments", headers=a_headers, json={"content": "下架后评论"} + ) + assert hidden_post.status_code == 404 + + +@pytest.mark.asyncio +async def test_disabled_account_cannot_use_existing_token(app, user_factory): + """封禁账号后,已签发的 access 与 refresh 都必须失效。""" + user = await user_factory("banned_user", "secret123") + tokens = (await app.post( + "/api/v1/accounts/login", json={"username": "banned_user", "password": "secret123"} + )).json() + headers = {"Authorization": f"Bearer {tokens['access']}"} + + assert (await app.get("/api/v1/accounts/me", headers=headers)).status_code == 200 + + from asgiref.sync import sync_to_async + + await sync_to_async(type(user).objects.filter(pk=user.pk).update)(is_active=False) + + assert (await app.get("/api/v1/accounts/me", headers=headers)).status_code == 401 + refreshed = await app.post("/api/v1/accounts/refresh", json={"refresh": tokens["refresh"]}) + assert refreshed.status_code == 403 + relogin = await app.post( + "/api/v1/accounts/login", json={"username": "banned_user", "password": "secret123"} + ) + assert relogin.status_code == 403 + + +@pytest.mark.asyncio +async def test_system_message_read_is_per_user(app, user_factory): + """广播系统消息的已读状态不能跨用户共享,也不能改到别人的定向消息。""" + from asgiref.sync import sync_to_async + + from apps.messages_app.models import SystemMessage + + u1 = await user_factory("sys_user1", "secret123") + u2 = await user_factory("sys_user2", "secret123") + h1 = await _login(app, "sys_user1") + _ = await _login(app, "sys_user2") + + broadcast = await sync_to_async(SystemMessage.objects.create)( + user=None, type="announcement", title="全站公告", content="你好" + ) + private_to_u2 = await sync_to_async(SystemMessage.objects.create)( + user=u2, type="notice", title="仅 u2 可见", content="私密" + ) + + before = (await app.get("/api/v1/messages/unread", headers=h1)).json()["system_unread"] + assert before >= 1 + + # u1 尝试标记「广播 + u2 私有」为已读:私有那条必须不受影响 + r = await app.post("/api/v1/messages/system", headers=h1, json={"ids": [broadcast.id, private_to_u2.id]}) + assert r.status_code == 200 + + u2_msg = await sync_to_async(SystemMessage.objects.get)(pk=private_to_u2.id) + assert u2_msg.is_read is False, "u1 把 u2 的定向系统消息标记成已读了" + + # u1 的未读数应因广播已读而下降 + after = (await app.get("/api/v1/messages/unread", headers=h1)).json()["system_unread"] + assert after == before - 1 + + # u2 未受 u1 的操作影响 + h2 = await _login(app, "sys_user2") + u2_unread = (await app.get("/api/v1/messages/unread", headers=h2)).json()["system_unread"] + assert u2_unread >= 2, "u1 标记已读影响了 u2 的广播未读状态" + + +@pytest.mark.asyncio +async def test_notification_mark_all_read(app, user_factory): + """不带 type/ids 的已读请求应清空全部未读,而不是过滤 type='all' 匹配 0 行。""" + from asgiref.sync import sync_to_async + + from apps.notifications.models import Notification + + user = await user_factory("notif_user", "secret123") + other = await user_factory("notif_other", "secret123") + headers = await _login(app, "notif_user") + + for i in range(3): + await sync_to_async(Notification.objects.create)( + recipient=user, sender=other, notification_type="follow", title=f"n{i}", content="x" + ) + + r = await app.post("/api/v1/notifications/read", headers=headers, json={}) + assert r.status_code == 200 + assert r.json()["updated_count"] == 3 + + unread = await sync_to_async( + Notification.objects.filter(recipient=user, is_read=False).count + )() + assert unread == 0 + + +# ------------------------------------------------------------ 4. 契约 + + +@pytest.mark.asyncio +async def test_watch_history_unique_per_video(app, user_factory): + """同一用户同一视频只允许一条观看记录(并发下曾产生重复行)。""" + from asgiref.sync import sync_to_async + + from apps.history.models import WatchHistory + from apps.history.services import record_watch + + user = await user_factory("hist_user", "secret123") + headers = await _login(app, "hist_user") + vid = await _upload(app, headers) + + for _ in range(3): + await record_watch(user.id, vid, progress=1.0, duration=10.0) + + count = await sync_to_async( + WatchHistory.objects.filter(user_id=user.id, video_id=vid).count + )() + assert count == 1, f"同一 user+video 出现 {count} 条观看记录" + + # 重复写入后接口仍可用(曾因多行抛 MultipleObjectsReturned 而 500) + r = await app.get(f"/api/v1/videos/{vid}", headers=headers) + assert r.status_code == 200 + + +@pytest.mark.asyncio +async def test_conversation_list_is_paginated_envelope(app, user_factory): + """会话列表统一为 {next_cursor, results},与其它列表一致。""" + await user_factory("conv_a", "secret123") + peer = await user_factory("conv_b", "secret123") + headers = await _login(app, "conv_a") + + r = await app.post("/api/v1/messages/conversations", headers=headers, json={"peer_id": peer.id}) + assert r.status_code in (200, 201) + + body = (await app.get("/api/v1/messages/conversations", headers=headers)).json() + assert isinstance(body, dict), "会话列表应返回分页 envelope" + assert "results" in body and "next_cursor" in body + assert len(body["results"]) == 1 + + # 非法 peer_id 必须 400/404,而不是 500 + assert (await app.post("/api/v1/messages/conversations", headers=headers, json={"peer_id": "x"})).status_code == 400 + assert (await app.post("/api/v1/messages/conversations", headers=headers, json={"peer_id": 999999})).status_code == 404 diff --git a/dsp/backend/tests/test_smoke.py b/dsp/backend/tests/test_smoke.py index f780507..bfd1235 100644 --- a/dsp/backend/tests/test_smoke.py +++ b/dsp/backend/tests/test_smoke.py @@ -93,7 +93,7 @@ async def test_video_feed_like_comment(app, user_factory): assert r.status_code == 201 # comments list - r = await app.get(f"/api/v1/comments/videos/{vid}/comments") + r = await app.get(f"/api/v1/videos/{vid}/comments") assert r.status_code == 200 assert r.json()["results"][0]["reply_count"] == 1 diff --git a/dsp/web/e2e/smoke.mjs b/dsp/web/e2e/smoke.mjs index 43d03c1..f3e408f 100644 --- a/dsp/web/e2e/smoke.mjs +++ b/dsp/web/e2e/smoke.mjs @@ -45,7 +45,8 @@ async function main() { try { // 1. 注册即登录 await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); - await page.evaluate(() => document.querySelector(".switch a")?.click()); + // 原生 无 href,用文案定位更稳 + await page.getByText("去注册").click(); await page.getByPlaceholder("用户名").fill(USER); await page.getByPlaceholder("密码").fill(PASS); await page.getByRole("button", { name: "注册" }).click(); @@ -95,6 +96,38 @@ async function main() { fail(`评论流程失败: ${e.message.split("\n")[0]}`); } + // 5. 取消点赞(此前只有 POST,永远取消不掉) + await like.click(); + await page + .waitForFunction( + (prev) => { + const el = document.querySelector(".feed-item .act span:last-child"); + return el && parseInt(el.textContent || "0", 10) === prev; + }, + before, + { timeout: 6000 } + ) + .then(() => ok("取消点赞生效(计数回到原值)")) + .catch(() => fail("取消点赞后计数未回退")); + + // 6. 分页 envelope 页面:这三页此前直接读 .data 而不是 .data.results, + // 拿到对象后渲染期就会抛 TypeError(页面空白)。 + for (const [path, label, selector] of [ + ["/history", "观看历史", ".watch-item, .tip"], + ["/visitors", "谁看过我", ".visit, .tip"], + ["/notifications", "通知中心", ".notif, .tip"], + ]) { + const errsBefore = errors.length; + try { + await page.goto(`${BASE}${path}`, { waitUntil: "domcontentloaded" }); + await page.locator(selector).first().waitFor({ timeout: 8000 }); + if (errors.length > errsBefore) throw new Error(errors[errsBefore]); + ok(`${label}页渲染正常`); + } catch (e) { + fail(`${label}页异常: ${String(e.message).split("\n")[0]}`); + } + } + if (errors.length) fail(`页面 JS 错误 ${errors.length} 条: ${errors[0]}`); await browser.close(); console.log(process.exitCode ? "冒烟未全部通过" : "冒烟全部通过 ✓"); diff --git a/dsp/web/package-lock.json b/dsp/web/package-lock.json index 81d0522..822a7dd 100644 --- a/dsp/web/package-lock.json +++ b/dsp/web/package-lock.json @@ -20,6 +20,7 @@ "playwright": "^1.63.0", "typescript": "~5.6.3", "vite": "^6.0.7", + "vitest": "^2.1.9", "vue-tsc": "^2.2.0" } }, @@ -1032,6 +1033,92 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -1258,6 +1345,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-validator": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", @@ -1299,6 +1396,16 @@ "balanced-match": "^1.0.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1312,6 +1419,33 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1360,6 +1494,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1439,6 +1583,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1514,6 +1665,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1726,6 +1887,13 @@ "lodash-es": "*" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1831,6 +1999,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1984,6 +2169,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1993,6 +2185,34 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2010,6 +2230,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -2106,6 +2356,1112 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, "node_modules/vscode-uri": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", @@ -2197,6 +3553,23 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/dsp/web/package.json b/dsp/web/package.json index baf7eca..cabb8e8 100644 --- a/dsp/web/package.json +++ b/dsp/web/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "e2e": "node e2e/smoke.mjs" }, "dependencies": { "axios": "^1.7.9", @@ -21,6 +24,7 @@ "playwright": "^1.63.0", "typescript": "~5.6.3", "vite": "^6.0.7", + "vitest": "^2.1.9", "vue-tsc": "^2.2.0" } } diff --git a/dsp/web/src/App.vue b/dsp/web/src/App.vue index 2baa04b..bbbf362 100644 --- a/dsp/web/src/App.vue +++ b/dsp/web/src/App.vue @@ -1,18 +1,11 @@ diff --git a/dsp/web/src/views/notifications/NotificationsView.vue b/dsp/web/src/views/notifications/NotificationsView.vue index 69aaafc..6332873 100644 --- a/dsp/web/src/views/notifications/NotificationsView.vue +++ b/dsp/web/src/views/notifications/NotificationsView.vue @@ -18,6 +18,9 @@ const TYPE_LABEL: Record = { mention: "@了我", }; +const cursor = ref(null); +const loadingMore = ref(false); + onMounted(async () => { try { const [{ data: unreadData }, { data: listData }] = await Promise.all([ @@ -25,15 +28,38 @@ onMounted(async () => { api.notifications(), ]); unread.value = unreadData; - list.value = listData.results; - if (unreadData.total > 0) await api.markNotificationsRead({ type: "all" }).catch(() => {}); + list.value = listData.results ?? []; + cursor.value = listData.next_cursor ?? null; + if (unreadData.total > 0) { + // 不传 type 才是“全部已读”;此前传 type:"all" 会被后端当作具体类型过滤,更新 0 行。 + const { data } = await api.markNotificationsRead({}); + if ((data as { updated_count?: number })?.updated_count) { + list.value = list.value.map((n) => ({ ...n, is_read: true })); + unread.value = { likes: 0, comments: 0, follows: 0, mentions: 0, total: 0 }; + } + } + } catch { + list.value = []; } finally { loading.value = false; } }); +async function loadMore() { + if (!cursor.value || loadingMore.value) return; + loadingMore.value = true; + try { + const { data } = await api.notifications(cursor.value); + list.value = [...list.value, ...(data.results ?? [])]; + cursor.value = data.next_cursor ?? null; + } finally { + loadingMore.value = false; + } +} + function goTarget(n: Notification) { - if (n.target_type === "video" && n.target_id) router.push("/"); + if (n.target_type === "video" && n.target_id) router.push({ name: "feed", query: { video: String(n.target_id) } }); + else if (n.target_type === "comment" && n.target_id) router.push({ name: "feed", query: { video: String(n.target_id) } }); else if (n.sender) router.push(`/user/${n.sender.id}`); } @@ -63,6 +89,9 @@ function goTarget(n: Notification) {
{{ (n.created_at || "").slice(0, 16).replace("T", " ") }}
暂无通知
+ @@ -154,4 +183,18 @@ h2 { padding: 32px 0; text-align: center; } +.more { + display: block; + margin: 16px auto 0; + background: var(--panel); + border: none; + color: var(--text-dim); + border-radius: 8px; + padding: 8px 28px; + cursor: pointer; +} +.more:disabled { + opacity: 0.6; + cursor: default; +} diff --git a/dsp/web/src/views/visitor/VisitorsView.vue b/dsp/web/src/views/visitor/VisitorsView.vue index 24df21e..2b039b6 100644 --- a/dsp/web/src/views/visitor/VisitorsView.vue +++ b/dsp/web/src/views/visitor/VisitorsView.vue @@ -5,14 +5,35 @@ import { api, type VisitItem } from "@/api"; const visits = ref([]); const loading = ref(true); +const cursor = ref(null); +const loadingMore = ref(false); + +/** /visitors/me 返回 {next_cursor, results};直接当数组用会让 v-for 遍历对象属性。 */ +async function load(cursorArg?: string | null) { + const { data } = await api.visitors(cursorArg); + visits.value = cursorArg ? [...visits.value, ...(data.results ?? [])] : (data.results ?? []); + cursor.value = data.next_cursor ?? null; +} onMounted(async () => { try { - visits.value = (await api.visitors()).data; + await load(); + } catch { + visits.value = []; } finally { loading.value = false; } }); + +async function loadMore() { + if (!cursor.value || loadingMore.value) return; + loadingMore.value = true; + try { + await load(cursor.value); + } finally { + loadingMore.value = false; + } +} @@ -78,4 +102,18 @@ h2 { padding: 32px 0; text-align: center; } +.more { + display: block; + margin: 16px auto 0; + background: var(--panel); + border: none; + color: var(--text-dim); + border-radius: 8px; + padding: 8px 28px; + cursor: pointer; +} +.more:disabled { + opacity: 0.6; + cursor: default; +} diff --git a/dsp/web/tsconfig.tsbuildinfo b/dsp/web/tsconfig.tsbuildinfo deleted file mode 100644 index 169fbfb..0000000 --- a/dsp/web/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/constants.ts","./src/main.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/index.ts","./src/router/index.ts","./src/stores/auth.ts","./src/utils/ws.ts","./src/app.vue","./src/views/auth/loginview.vue","./src/views/feed/commentpanel.vue","./src/views/feed/feedview.vue","./src/views/history/historyview.vue","./src/views/message/messagesview.vue","./src/views/notifications/notificationsview.vue","./src/views/profile/profileview.vue","./src/views/search/searchview.vue","./src/views/upload/uploadview.vue","./src/views/user/userview.vue","./src/views/visitor/visitorsview.vue","./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/dsp/web/vitest.config.ts b/dsp/web/vitest.config.ts new file mode 100644 index 0000000..9f5b8d0 --- /dev/null +++ b/dsp/web/vitest.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vitest/config"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, + }, + define: { __APP_VERSION__: JSON.stringify("0.1.0") }, + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + }, +});