From 8b81df3a7638e768b640b7935d9da52b77cd7649 Mon Sep 17 00:00:00 2001
From: 13404089107 <puwei@sinata.cn>
Date: 星期五, 24 四月 2026 20:22:47 +0800
Subject: [PATCH] Merge branch 'master' of http://120.76.84.145:10101/gitblit/r/java/tcm-training

---
 traditionalApp/pages/mine/mine.vue                                                                                 |   68 +++
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java       |   19 +
 traditionalApp/utils/course-download.js                                                                            |   11 
 traditionalApp/pages/qualificationDetail/qualificationDetail.vue                                                   |   25 +
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java         |    2 
 traditionalApp/pages/login/login.vue                                                                               |   15 
 traditionalApp/pages/watchLive/watchLive.vue                                                                       |  173 ++++++++
 traditionalApp/utils/utils.js                                                                                      |  212 +++++++++-
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java   |  188 +++++++++
 traditionalApp/pages/downloads/downloads.vue                                                                       |   79 ++-
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java |    9 
 traditionalApp/pages/bindPhone/service.js                                                                          |   15 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java            |   12 
 tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java  |   22 +
 traditionalApp/pages/bindPhone/bindPhone.vue                                                                       |  226 +++++++++--
 tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java        |   42 ++
 16 files changed, 990 insertions(+), 128 deletions(-)

diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java
index 1b8ee69..1b25665 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/auth/service/AppSmsCodeService.java
@@ -67,28 +67,28 @@
     }
 
     /**
-     * 校验登录验证码,成功后销毁缓存中的一次性验证码。
+     * 校验登录验证码,缓存有效期内允许重复提交。
      */
     public void validateLoginCode(String phone, String code) {
         validateCode(phone, code, LOGIN_CODE_CACHE_PREFIX, SCENE_LOGIN);
     }
 
     /**
-     * 校验注册验证码,成功后销毁缓存中的一次性验证码。
+     * 校验注册验证码,缓存有效期内允许重复提交。
      */
     public void validateRegisterCode(String phone, String code) {
         validateCode(phone, code, REGISTER_CODE_CACHE_PREFIX, SCENE_REGISTER);
     }
 
     /**
-     * 校验忘记密码验证码,成功后销毁缓存中的一次性验证码。
+     * 校验忘记密码验证码,缓存有效期内允许重复提交。
      */
     public void validatePasswordResetCode(String phone, String code) {
         validateCode(phone, code, PASSWORD_RESET_CODE_CACHE_PREFIX, SCENE_PASSWORD_RESET);
     }
 
     /**
-     * 校验修改手机号验证码,成功后销毁缓存中的一次性验证码。
+     * 校验修改手机号验证码,缓存有效期内允许重复提交。
      */
     public void validatePhoneChangeCode(String phone, String code) {
         validateCode(phone, code, PHONE_CHANGE_CODE_CACHE_PREFIX, SCENE_PHONE_CHANGE);
@@ -118,7 +118,7 @@
     }
 
     /**
-     * 统一校验指定场景的验证码并执行一次性销毁。
+     * 统一校验指定场景的验证码,验证码仅按缓存 TTL 自然失效。
      */
     private void validateCode(String phone, String code, String cachePrefix, String scene) {
         if (matchesDebugCode(scene, code)) {
@@ -133,8 +133,6 @@
         if (!cachedCode.equals(code)) {
             throw new IllegalArgumentException("验证码错误");
         }
-        // 验证码登录成功后立即删除,避免重复使用。
-        stringRedisTemplate.delete(cacheKey);
     }
 
     /**
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java
index b81e292..a4f3da4 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/live/service/AppLiveCourseService.java
@@ -757,7 +757,7 @@
             return "答题成功,积分已到账";
         }
         if (dailyCoursePointsAlreadyAwarded) {
-            return "今日该课程积分已获得";
+            return "今日已获得积分,明日再来";
         }
         return "答题成功";
     }
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java
index 564397a..3ae0926 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/controller/AppSettingsController.java
@@ -3,6 +3,7 @@
 import com.tcm.training.admin.app.auth.dto.AppWechatLoginRequest;
 import com.tcm.training.admin.app.settings.dto.AppSettingsNotificationUpdateRequest;
 import com.tcm.training.admin.app.settings.dto.AppSettingsPasswordUpdateRequest;
+import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest;
 import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneCodeRequest;
 import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneUpdateRequest;
 import com.tcm.training.admin.app.settings.dto.AppSettingsProfileUpdateRequest;
@@ -85,6 +86,14 @@
     }
 
     /**
+     * 首次绑定当前登录用户手机号。
+     */
+    @PutMapping("/phone/bind")
+    public ApiResponse<AppSettingsProfileVO> bindPhone(@Valid @RequestBody AppSettingsPhoneBindRequest request) {
+        return ApiResponse.ok(appSettingsService.bindPhone(request));
+    }
+
+    /**
      * 修改当前登录用户密码。
      */
     @PutMapping("/password")
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java
new file mode 100644
index 0000000..43b4be6
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/dto/AppSettingsPhoneBindRequest.java
@@ -0,0 +1,22 @@
+package com.tcm.training.admin.app.settings.dto;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * APP 首次绑定手机号请求。
+ */
+@Getter
+@Setter
+@NoArgsConstructor
+public class AppSettingsPhoneBindRequest {
+
+    @NotBlank(message = "请输入正确的手机号")
+    private String newPhone;
+
+    @NotBlank(message = "请输入验证码")
+    private String code;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
index 87cbfb4..0af4fed 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/settings/service/AppSettingsService.java
@@ -8,6 +8,7 @@
 import com.tcm.training.admin.app.auth.service.AppWechatUserProfile;
 import com.tcm.training.admin.app.settings.dto.AppSettingsNotificationUpdateRequest;
 import com.tcm.training.admin.app.settings.dto.AppSettingsPasswordUpdateRequest;
+import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest;
 import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneCodeRequest;
 import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneUpdateRequest;
 import com.tcm.training.admin.app.settings.dto.AppSettingsProfileUpdateRequest;
@@ -143,6 +144,24 @@
     }
 
     /**
+     * 首次绑定当前登录用户手机号,供微信登录未绑定手机号场景使用。
+     */
+    @Transactional
+    public AppSettingsProfileVO bindPhone(AppSettingsPhoneBindRequest request) {
+        AppUser user = requireCurrentEnabledUser();
+        String newPhone = normalizePhone(request.getNewPhone());
+        String code = normalizeRequired(request.getCode(), "请输入验证码");
+        if (trimToNull(user.getPhone()) != null) {
+            throw new IllegalArgumentException("当前账号已绑定手机号");
+        }
+        requirePhoneAvailable(newPhone, user.getId());
+        appSmsCodeService.validatePhoneChangeCode(newPhone, code);
+        user.setPhone(newPhone);
+        userMapper.updateById(user);
+        return toProfileVO(user);
+    }
+
+    /**
      * 修改当前登录用户密码,并在成功后使当前登录态失效。
      */
     @Transactional
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java
index 1a035ef..e489563 100644
--- a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java
+++ b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/auth/service/AppSmsCodeServiceTest.java
@@ -84,6 +84,48 @@
     }
 
     /**
+     * 验证码校验成功后不应删除缓存,保证 5 分钟有效期内可重复提交。
+     */
+    @Test
+    void validateLoginCodeShouldKeepCachedCodeAfterSuccess() {
+        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
+        when(valueOperations.get("app:sms:login:13800138000")).thenReturn("123456");
+
+        assertDoesNotThrow(() -> appSmsCodeService.validateLoginCode("13800138000", "123456"));
+
+        verify(stringRedisTemplate, never()).delete(anyString());
+    }
+
+    /**
+     * 同一验证码在缓存 TTL 内多次提交都应校验通过。
+     */
+    @Test
+    void validateLoginCodeShouldAllowRepeatedSubmissionsWithinTtl() {
+        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
+        when(valueOperations.get("app:sms:login:13800138000")).thenReturn("123456");
+
+        assertDoesNotThrow(() -> appSmsCodeService.validateLoginCode("13800138000", "123456"));
+        assertDoesNotThrow(() -> appSmsCodeService.validateLoginCode("13800138000", "123456"));
+
+        verify(stringRedisTemplate, never()).delete(anyString());
+    }
+
+    /**
+     * 缓存中存在验证码但输入不一致时仍应提示验证码错误。
+     */
+    @Test
+    void validateLoginCodeShouldRejectMismatchedCode() {
+        when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
+        when(valueOperations.get("app:sms:login:13800138000")).thenReturn("123456");
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> appSmsCodeService.validateLoginCode("13800138000", "654321"));
+
+        assertEquals("验证码错误", exception.getMessage());
+        verify(stringRedisTemplate, never()).delete(anyString());
+    }
+
+    /**
      * 短信发送成功后才应写入验证码缓存。
      */
     @Test
diff --git a/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java
new file mode 100644
index 0000000..b695bf2
--- /dev/null
+++ b/tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/settings/service/AppSettingsServiceTest.java
@@ -0,0 +1,188 @@
+package com.tcm.training.admin.app.settings.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.tcm.training.admin.app.auth.service.AppAuthSessionService;
+import com.tcm.training.admin.app.auth.service.AppSmsCodeService;
+import com.tcm.training.admin.app.auth.service.AppWechatAuthClient;
+import com.tcm.training.admin.app.settings.dto.AppSettingsPhoneBindRequest;
+import com.tcm.training.admin.app.settings.mapper.AppUserNotificationSettingMapper;
+import com.tcm.training.admin.app.settings.vo.AppSettingsProfileVO;
+import com.tcm.training.admin.member.entity.AppUser;
+import com.tcm.training.admin.member.mapper.UserMapper;
+import com.tcm.training.admin.upload.ObsUploadService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * APP 设置服务手机号绑定测试。
+ */
+@ExtendWith(MockitoExtension.class)
+class AppSettingsServiceTest {
+
+    @BeforeAll
+    static void initLambdaCache() {
+        MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
+        TableInfoHelper.initTableInfo(assistant, AppUser.class);
+    }
+
+    @Mock
+    private AppAuthSessionService appAuthSessionService;
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private AppSmsCodeService appSmsCodeService;
+
+    @Mock
+    private BCryptPasswordEncoder passwordEncoder;
+
+    @Mock
+    private AppWechatAuthClient appWechatAuthClient;
+
+    @Mock
+    private AppUserNotificationSettingMapper appUserNotificationSettingMapper;
+
+    @Mock
+    private ObsUploadService obsUploadService;
+
+    @Captor
+    private ArgumentCaptor<AppUser> appUserCaptor;
+
+    private AppSettingsService appSettingsService;
+
+    /**
+     * 每个用例前重新构造被测设置服务。
+     */
+    @BeforeEach
+    void setUp() {
+        appSettingsService = new AppSettingsService(
+                appAuthSessionService,
+                userMapper,
+                appSmsCodeService,
+                passwordEncoder,
+                appWechatAuthClient,
+                appUserNotificationSettingMapper,
+                obsUploadService
+        );
+    }
+
+    /**
+     * 微信登录后未绑定手机号的用户,可通过验证码首次绑定手机号。
+     */
+    @Test
+    void bindPhoneShouldUpdateCurrentUserWhenPhoneMissing() {
+        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
+        request.setNewPhone("13800138000");
+        request.setCode("123456");
+        AppUser currentUser = buildEnabledUser(null);
+
+        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
+        when(userMapper.selectById(1L)).thenReturn(currentUser);
+        when(userMapper.selectOne(any())).thenReturn(null);
+        doNothing().when(appSmsCodeService).validatePhoneChangeCode("13800138000", "123456");
+
+        AppSettingsProfileVO response = appSettingsService.bindPhone(request);
+
+        verify(userMapper).updateById(appUserCaptor.capture());
+        assertEquals("13800138000", appUserCaptor.getValue().getPhone());
+        assertEquals("13800138000", response.getPhone());
+    }
+
+    /**
+     * 已绑定手机号的用户不允许走首次绑定接口。
+     */
+    @Test
+    void bindPhoneShouldRejectWhenCurrentUserAlreadyHasPhone() {
+        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
+        request.setNewPhone("13800138000");
+        request.setCode("123456");
+
+        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
+        when(userMapper.selectById(1L)).thenReturn(buildEnabledUser("13900139000"));
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> appSettingsService.bindPhone(request));
+
+        assertEquals("当前账号已绑定手机号", exception.getMessage());
+        verify(appSmsCodeService, never()).validatePhoneChangeCode(any(), any());
+        verify(userMapper, never()).updateById(any());
+    }
+
+    /**
+     * 目标手机号被其他有效用户占用时不允许绑定。
+     */
+    @Test
+    void bindPhoneShouldRejectOccupiedPhone() {
+        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
+        request.setNewPhone("13800138000");
+        request.setCode("123456");
+        AppUser occupiedUser = buildEnabledUser("13800138000");
+        occupiedUser.setId(2L);
+
+        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
+        when(userMapper.selectById(1L)).thenReturn(buildEnabledUser(null));
+        when(userMapper.selectOne(any())).thenReturn(occupiedUser);
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> appSettingsService.bindPhone(request));
+
+        assertEquals("该手机号已注册", exception.getMessage());
+        verify(appSmsCodeService, never()).validatePhoneChangeCode(any(), any());
+        verify(userMapper, never()).updateById(any());
+    }
+
+    /**
+     * 验证码错误时不应更新用户手机号。
+     */
+    @Test
+    void bindPhoneShouldNotUpdateWhenSmsCodeInvalid() {
+        AppSettingsPhoneBindRequest request = new AppSettingsPhoneBindRequest();
+        request.setNewPhone("13800138000");
+        request.setCode("000000");
+
+        when(appAuthSessionService.requireLoginUserId()).thenReturn(1L);
+        when(userMapper.selectById(1L)).thenReturn(buildEnabledUser(null));
+        when(userMapper.selectOne(any())).thenReturn(null);
+        org.mockito.Mockito.doThrow(new IllegalArgumentException("验证码错误"))
+                .when(appSmsCodeService).validatePhoneChangeCode("13800138000", "000000");
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> appSettingsService.bindPhone(request));
+
+        assertEquals("验证码错误", exception.getMessage());
+        verify(userMapper, never()).updateById(any());
+    }
+
+    /**
+     * 构造启用状态 APP 用户。
+     */
+    private AppUser buildEnabledUser(String phone) {
+        AppUser user = new AppUser();
+        user.setId(1L);
+        user.setNickname("微信用户");
+        user.setAvatar("avatar.png");
+        user.setPhone(phone);
+        user.setType(0);
+        user.setStatus(1);
+        user.setDeleted(0);
+        return user;
+    }
+}
diff --git a/traditionalApp/pages/bindPhone/bindPhone.vue b/traditionalApp/pages/bindPhone/bindPhone.vue
index 3effa66..643a91c 100644
--- a/traditionalApp/pages/bindPhone/bindPhone.vue
+++ b/traditionalApp/pages/bindPhone/bindPhone.vue
@@ -1,95 +1,213 @@
 <template>
 	<view class="container">
-		<!-- Top Bar -->
 		<view class="nav-bar">
-			<!-- Note: Using a back arrow icon instead of close based on the screenshot -->
 			<uni-icons type="left" size="24" color="#333333" @tap="goBack"></uni-icons>
 		</view>
 
-		<!-- Title -->
 		<view class="title" :class="{ 'small-title': isCodeSent }">{{ isCodeSent ? '已发送验证码' : '绑定手机号' }}</view>
 		<view class="phone-display" v-if="isCodeSent">{{ phone }}</view>
 
-		<!-- Form Step 1: Input Phone -->
 		<view class="form" v-if="!isCodeSent">
 			<view class="input-group">
-				<input class="input" type="number" placeholder="输入手机号" placeholder-class="placeholder-style" v-model="phone" focus />
+				<input class="input" type="number" maxlength="11" placeholder="输入手机号" placeholder-class="placeholder-style" v-model="phone" focus />
 			</view>
-			<!-- Get Code Button -->
-			<view class="get-code-btn" @tap="getCode">获取验证码</view>
+			<view class="get-code-btn" :class="{ disabled: loading }" @tap="getCode">
+				{{ loading ? '发送中...' : '获取验证码' }}
+			</view>
 		</view>
 
-		<!-- Form Step 2: Input Verification Code -->
 		<view class="code-form" v-else>
 			<view class="code-inputs">
-				<view class="code-box" :class="{ 'active': code.length === index }" v-for="(item, index) in 6" :key="index" @tap="focusCodeInput">
+				<view class="code-box" :class="{ active: code.length === index }" v-for="(item, index) in 6" :key="index" @tap="focusCodeInput">
 					{{ code[index] || '' }}
 					<view class="cursor" v-if="code.length === index && isFocused"></view>
 				</view>
 			</view>
 			<input class="hidden-input" type="number" :maxlength="6" v-model="code" :focus="isFocused" @blur="isFocused = false" @focus="isFocused = true" />
-			
-			<!-- Verify Button -->
-			<view class="get-code-btn" @tap="verifyCode">立即验证</view>
+			<view class="get-code-btn" :class="{ disabled: loading }" @tap="verifyCode">
+				{{ loading ? '验证中...' : '立即验证' }}
+			</view>
 		</view>
 	</view>
 </template>
 
 <script setup lang="ts">
-import { ref } from "vue";
+import { ref } from 'vue'
+import { bindSettingsPhone, sendBindPhoneCode } from './service.js'
 
-const phone = ref('');
-const isCodeSent = ref(false);
-const code = ref('');
-const isFocused = ref(false);
+interface ApiResponse<T> {
+	code: number
+	msg?: string
+	message?: string
+	data: T
+}
 
+interface SettingsProfile {
+	id?: number | null
+	nickname?: string
+	avatar?: string
+	phone?: string
+	type?: number | null
+}
+
+const phone = ref('')
+const isCodeSent = ref(false)
+const code = ref('')
+const isFocused = ref(false)
+const loading = ref(false)
+
+/**
+ * 返回上一步;首屏返回登录页,避免未绑定手机号用户直接进入业务页。
+ */
 const goBack = () => {
 	if (isCodeSent.value) {
-		isCodeSent.value = false;
-		code.value = '';
-	} else {
-		uni.navigateBack();
+		isCodeSent.value = false
+		code.value = ''
+		isFocused.value = false
+		return
 	}
-};
+	uni.redirectTo({
+		url: '/pages/login/login'
+	})
+}
 
-const getCode = () => {
-	if (!phone.value || phone.value.length !== 11) {
+/**
+ * 发送首次绑定手机号验证码。
+ */
+const getCode = async () => {
+	if (loading.value) {
+		return
+	}
+	const normalizedPhone = normalizePhone()
+	if (!normalizedPhone) {
+		return
+	}
+	loading.value = true
+	try {
+		const response = await sendBindPhoneCode({ newPhone: normalizedPhone })
+		if (!isSuccessResponse(response)) {
+			return
+		}
+		phone.value = normalizedPhone
+		isCodeSent.value = true
+		code.value = ''
+		setTimeout(() => {
+			isFocused.value = true
+		}, 100)
+		uni.showToast({
+			title: '验证码已发送',
+			icon: 'success'
+		})
+	} finally {
+		loading.value = false
+	}
+}
+
+/**
+ * 聚焦隐藏验证码输入框。
+ */
+const focusCodeInput = () => {
+	isFocused.value = true
+}
+
+/**
+ * 校验验证码并绑定当前微信登录账号手机号。
+ */
+const verifyCode = async () => {
+	if (loading.value) {
+		return
+	}
+	const normalizedPhone = normalizePhone()
+	if (!normalizedPhone) {
+		return
+	}
+	const normalizedCode = normalizeCode()
+	if (!normalizedCode) {
+		return
+	}
+	loading.value = true
+	try {
+		const response = await bindSettingsPhone({
+			newPhone: normalizedPhone,
+			code: normalizedCode
+		})
+		if (!isSuccessResponse(response)) {
+			return
+		}
+		cacheBoundPhone(response.data, normalizedPhone)
+		uni.showToast({
+			title: '绑定成功',
+			icon: 'success'
+		})
+		setTimeout(() => {
+			uni.switchTab({
+				url: '/pages/home/home'
+			})
+		}, 800)
+	} finally {
+		loading.value = false
+	}
+}
+
+/**
+ * 校验手机号输入是否合法。
+ */
+const normalizePhone = () => {
+	const normalizedPhone = (phone.value || '').trim()
+	if (!/^1\d{10}$/.test(normalizedPhone)) {
 		uni.showToast({
 			title: '请输入正确的手机号',
 			icon: 'none'
-		});
-		return;
+		})
+		return ''
 	}
-	// Simulate sending code
-	isCodeSent.value = true;
-	setTimeout(() => {
-		isFocused.value = true;
-	}, 100);
-};
+	return normalizedPhone
+}
 
-const focusCodeInput = () => {
-	isFocused.value = true;
-};
-
-const verifyCode = () => {
-	if (code.value.length !== 6) {
+/**
+ * 校验验证码输入是否合法。
+ */
+const normalizeCode = () => {
+	const normalizedCode = (code.value || '').trim()
+	if (!/^\d{6}$/.test(normalizedCode)) {
 		uni.showToast({
 			title: '请输入6位验证码',
 			icon: 'none'
-		});
-		return;
+		})
+		return ''
 	}
-	uni.showToast({
-		title: '绑定成功',
-		icon: 'success'
-	});
-	// Proceed to home page or previous page
-	setTimeout(() => {
-		uni.switchTab({
-			url: '/pages/home/home'
-		});
-	}, 1500);
-};
+	return normalizedCode
+}
+
+/**
+ * 绑定成功后同步本地用户缓存,保证个人中心立即展示手机号。
+ */
+const cacheBoundPhone = (profile: SettingsProfile | undefined, fallbackPhone: string) => {
+	const cachedUser = uni.getStorageSync('appUserInfo')
+	const nextUserInfo = {
+		...(cachedUser && typeof cachedUser === 'object' ? cachedUser : {}),
+		...(profile && typeof profile === 'object' ? profile : {}),
+		phone: normalizeOptionalText(profile?.phone) || fallbackPhone
+	}
+	uni.setStorageSync('appUserInfo', nextUserInfo)
+}
+
+/**
+ * 判断接口响应是否为成功态。
+ */
+const isSuccessResponse = (response: ApiResponse<unknown> | undefined) => {
+	return !!response && (response.code === 0 || response.code === 200)
+}
+
+/**
+ * 将可选文本归一化为空字符串。
+ */
+const normalizeOptionalText = (value: unknown) => {
+	if (typeof value !== 'string') {
+		return ''
+	}
+	return value.trim()
+}
 </script>
 
 <style lang="scss" scoped>
@@ -167,6 +285,10 @@
 	font-size: 36rpx;
 	border-radius: 16rpx;
 	margin-top: 60rpx;
+
+	&.disabled {
+		opacity: 0.7;
+	}
 }
 
 .code-form {
@@ -217,4 +339,4 @@
 	0%, 100% { opacity: 1; }
 	50% { opacity: 0; }
 }
-</style>
\ No newline at end of file
+</style>
diff --git a/traditionalApp/pages/bindPhone/service.js b/traditionalApp/pages/bindPhone/service.js
new file mode 100644
index 0000000..c6336d8
--- /dev/null
+++ b/traditionalApp/pages/bindPhone/service.js
@@ -0,0 +1,15 @@
+import request from '@/utils/request.js'
+
+/**
+ * 发送 APP 首次绑定手机号验证码。
+ */
+export const sendBindPhoneCode = (data) => {
+	return request.post('/api/app/settings/phone/code', data)
+}
+
+/**
+ * 提交 APP 首次绑定手机号。
+ */
+export const bindSettingsPhone = (data) => {
+	return request.put('/api/app/settings/phone/bind', data)
+}
diff --git a/traditionalApp/pages/downloads/downloads.vue b/traditionalApp/pages/downloads/downloads.vue
index c7262c7..42ee1ce 100644
--- a/traditionalApp/pages/downloads/downloads.vue
+++ b/traditionalApp/pages/downloads/downloads.vue
@@ -101,7 +101,7 @@
 	fileSize?: number | null
 }
 
-type DownloadSourceType = 'remoteCompleted' | 'localDownloading'
+type DownloadSourceType = 'remoteCompleted' | 'localTask'
 
 interface DownloadListItem {
 	id: string
@@ -236,15 +236,8 @@
 		})
 		return
 	}
-	if (!currentItem.clientTaskId) {
-		uni.showToast({
-			title: '本地文件不存在,请重新下载',
-			icon: 'none',
-		})
-		return
-	}
-	const taskInfo = getDownloadTask(resolveCurrentUserId(), currentItem.clientTaskId)
-	if (!taskInfo || !normalizeOptionalText(taskInfo.savedFilePath)) {
+	const playableTask = resolvePlayableDownloadTask(currentItem)
+	if (!playableTask) {
 		uni.showToast({
 			title: '本地文件不存在,请重新下载',
 			icon: 'none',
@@ -252,8 +245,29 @@
 		return
 	}
 	uni.navigateTo({
-		url: `/pages/downloadPlayer/downloadPlayer?clientTaskId=${encodeURIComponent(currentItem.clientTaskId)}`,
+		url: `/pages/downloadPlayer/downloadPlayer?clientTaskId=${encodeURIComponent(playableTask.clientTaskId)}`,
 	})
+}
+
+/**
+ * 解析当前列表项对应的本地可播放任务,远端完成记录也必须匹配本地文件路径后才能播放。
+ */
+function resolvePlayableDownloadTask(item: DownloadListItem): LocalDownloadTask | null {
+	if (item.clientTaskId) {
+		const taskInfo = getDownloadTask(resolveCurrentUserId(), item.clientTaskId) as LocalDownloadTask | null
+		if (taskInfo && normalizeOptionalText(taskInfo.savedFilePath)) {
+			return taskInfo
+		}
+	}
+	if (!item.courseVideoId) {
+		return null
+	}
+	const matchedTask = listDownloadTasks(resolveCurrentUserId()).find((task) => {
+		return Number(task.courseVideoId || 0) === Number(item.courseVideoId || 0)
+			&& task.status === 'downloaded'
+			&& !!normalizeOptionalText(task.savedFilePath)
+	}) as LocalDownloadTask | undefined
+	return matchedTask || null
 }
 
 /**
@@ -292,9 +306,9 @@
  * 删除勾选或单条下载项,并分别处理远端完成记录与本地下载中任务。
  */
 async function deleteDownloadItems(items: DownloadListItem[]) {
-	const remoteIds = items
-		.filter((item) => item.sourceType === 'remoteCompleted' && item.remoteId)
-		.map((item) => Number(item.remoteId))
+	const remoteIds = Array.from(new Set(items
+		.filter((item) => item.remoteId)
+		.map((item) => Number(item.remoteId))))
 	const localTaskIds = items
 		.filter((item) => item.clientTaskId)
 		.map((item) => item.clientTaskId)
@@ -382,7 +396,7 @@
 			status: item.status === 'downloaded' ? 'downloaded' : 'downloading',
 			progress: normalizeProgress(item.progress),
 			selected: false,
-			sourceType: 'localDownloading',
+			sourceType: 'localTask',
 			sortTime: resolveDateTime(item.savedAt || item.updatedAt),
 			savedFilePath: normalizeOptionalText(item.savedFilePath),
 		}
@@ -390,17 +404,36 @@
 }
 
 /**
- * 合并本地与远端下载项,优先保留本地任务。
+ * 合并本地与远端下载项,优先保留带本地文件路径的任务,远端记录补齐删除标识。
  */
 function mergeDownloadItems(localItems: DownloadListItem[], remoteItems: DownloadListItem[]) {
-	const localVideoIdSet = new Set(
-		localItems
-			.map((item) => Number(item.courseVideoId || 0))
-			.filter((item) => Number.isFinite(item) && item > 0)
-	)
+	const localByVideoId = new Map<number, DownloadListItem>()
+	localItems.forEach((item) => {
+		const videoId = Number(item.courseVideoId || 0)
+		if (Number.isFinite(videoId) && videoId > 0) {
+			localByVideoId.set(videoId, item)
+		}
+	})
+	const mergedItems = remoteItems.map((remoteItem) => {
+		const localItem = localByVideoId.get(Number(remoteItem.courseVideoId || 0))
+		if (!localItem) {
+			return remoteItem
+		}
+		localByVideoId.delete(Number(remoteItem.courseVideoId || 0))
+		return {
+			...localItem,
+			id: localItem.id,
+			remoteId: remoteItem.remoteId,
+			title: localItem.title || remoteItem.title,
+			subtitle: localItem.subtitle || remoteItem.subtitle,
+			duration: localItem.duration || remoteItem.duration,
+			image: localItem.image || remoteItem.image,
+			sortTime: Math.max(localItem.sortTime, remoteItem.sortTime),
+		}
+	})
 	return [
-		...localItems,
-		...remoteItems.filter((item) => !localVideoIdSet.has(Number(item.courseVideoId || 0))),
+		...Array.from(localByVideoId.values()),
+		...mergedItems,
 	]
 }
 
diff --git a/traditionalApp/pages/login/login.vue b/traditionalApp/pages/login/login.vue
index 669f15c..c056a92 100644
--- a/traditionalApp/pages/login/login.vue
+++ b/traditionalApp/pages/login/login.vue
@@ -60,7 +60,7 @@
 	interface AppLoginUser {
 		id : number
 		nickname : string
-		phone : string
+		phone ?: string
 		avatar ?: string
 		type ?: number
 		status ?: number
@@ -177,6 +177,12 @@
 				return
 			}
 			saveLoginSession(res.data)
+			if (shouldBindPhone(res.data)) {
+				uni.redirectTo({
+					url: '/pages/bindPhone/bindPhone'
+				})
+				return
+			}
 			uni.showToast({
 				title: '登录成功',
 				icon: 'success'
@@ -402,6 +408,13 @@
 	}
 
 	/**
+	 * 判断微信登录返回用户是否缺少手机号,缺少时进入绑定手机号流程。
+	 */
+	const shouldBindPhone = (loginData : AppLoginPayload) => {
+		return !normalizeOptionalText(loginData?.user?.phone)
+	}
+
+	/**
 	 * 校验手机号输入是否合法。
 	 */
 	const normalizePhone = () => {
diff --git a/traditionalApp/pages/mine/mine.vue b/traditionalApp/pages/mine/mine.vue
index a74b264..86562e9 100644
--- a/traditionalApp/pages/mine/mine.vue
+++ b/traditionalApp/pages/mine/mine.vue
@@ -7,7 +7,10 @@
 
 		<view class="content">
 			<view class="top-actions">
-				<image class="action-icon" src="/static/image/mine/notice.png" mode="aspectFit" @tap="goPage('/pages/notice/notice')"></image>
+				<view class="notice-action" @tap="goPage('/pages/notice/notice')">
+					<image class="action-icon" src="/static/image/mine/notice.png" mode="aspectFit"></image>
+					<view v-if="unreadNoticeTotal > 0" class="notice-badge">{{ noticeBadgeText }}</view>
+				</view>
 				<image class="action-icon" src="/static/image/mine/setting.png" mode="aspectFit" @tap="goPage('/pages/settings/settings')"></image>
 			</view>
 
@@ -49,6 +52,7 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue'
 import { onLoad, onShow } from '@dcloudio/uni-app'
+import { getNoticeList } from '../notice/service'
 import { getUserCenter } from './service'
 
 type UserRole = 'personal' | 'doctor'
@@ -84,7 +88,12 @@
 	iconClass?: string
 }
 
+interface NoticeSummaryItem {
+	badge?: number | string | null
+}
+
 const statusBarHeight = ref(0)
+const unreadNoticeTotal = ref(0)
 const userCenter = ref<UserCenterData>(createDefaultUserCenter())
 const userRole = computed<UserRole>(() => {
 	return Number(userCenter.value.type) === 1 ? 'doctor' : 'personal'
@@ -98,6 +107,9 @@
 const displayPoints = computed(() => {
 	const points = Number(userCenter.value.pointsBalance || 0)
 	return Number.isFinite(points) ? points : 0
+})
+const noticeBadgeText = computed(() => {
+	return unreadNoticeTotal.value > 99 ? '99+' : String(unreadNoticeTotal.value)
 })
 const gridMenuList = computed<MenuItem[]>(() => {
 	if (userRole.value === 'doctor') {
@@ -172,7 +184,38 @@
 onShow(() => {
 	syncCachedUserInfo()
 	loadUserCenter()
+	loadUnreadNoticeTotal()
 })
+
+/**
+ * 拉取当前登录用户的通知未读总数。
+ */
+async function loadUnreadNoticeTotal() {
+	try {
+		const response = await getNoticeList()
+		if (response?.code !== 0 || !Array.isArray(response.data)) {
+			unreadNoticeTotal.value = 0
+			return
+		}
+		unreadNoticeTotal.value = response.data.reduce((total: number, item: NoticeSummaryItem) => {
+			return total + normalizeNoticeBadge(item?.badge)
+		}, 0)
+	} catch {
+		// 通知角标失败时静默降级,避免影响我的页主体信息展示。
+		unreadNoticeTotal.value = 0
+	}
+}
+
+/**
+ * 将通知分类未读数量归一化为非负整数。
+ */
+function normalizeNoticeBadge(value: unknown) {
+	const parsedValue = Number(value)
+	if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
+		return 0
+	}
+	return Math.floor(parsedValue)
+}
 
 /**
  * 拉取当前登录用户的个人中心聚合数据。
@@ -320,10 +363,33 @@
 	padding-top: 20rpx;
 	gap: 32rpx;
 
+	.notice-action {
+		position: relative;
+		width: 48rpx;
+		height: 48rpx;
+	}
+
 	.action-icon {
 		width: 48rpx;
 		height: 48rpx;
 	}
+
+	.notice-badge {
+		position: absolute;
+		top: -8rpx;
+		right: -12rpx;
+		min-width: 28rpx;
+		height: 28rpx;
+		padding: 0 8rpx;
+		border-radius: 16rpx;
+		background-color: #E64340;
+		color: #FFFFFF;
+		font-size: 20rpx;
+		font-weight: 500;
+		line-height: 28rpx;
+		text-align: center;
+		box-sizing: border-box;
+	}
 }
 
 .user-info {
diff --git a/traditionalApp/pages/qualificationDetail/qualificationDetail.vue b/traditionalApp/pages/qualificationDetail/qualificationDetail.vue
index ae4b159..d71a0f8 100644
--- a/traditionalApp/pages/qualificationDetail/qualificationDetail.vue
+++ b/traditionalApp/pages/qualificationDetail/qualificationDetail.vue
@@ -61,9 +61,29 @@
 function buildRichTextNodes(content: unknown) {
 	const normalizedContent = normalizeOptionalText(content)
 	if (normalizedContent) {
-		return normalizedContent
+		return normalizeRichTextImages(normalizedContent)
 	}
 	return '<p>暂无资质内容</p>'
+}
+
+/**
+ * 将富文本图片调整为移动端自适应样式。
+ */
+function normalizeRichTextImages(htmlContent: string) {
+	return htmlContent.replace(/<img\b([^>]*)>/gi, (_matchedTag, attributes = '') => {
+		const cleanedAttributes = String(attributes)
+			.replace(/\s(?:width|height)\s*=\s*(["']).*?\1/gi, '')
+			.replace(/\s(?:width|height)\s*=\s*[^\s>]+/gi, '')
+		const adaptiveStyle = 'max-width:100%;width:100%;height:auto;display:block;box-sizing:border-box;'
+		if (/\sstyle\s*=/i.test(cleanedAttributes)) {
+			const mergedAttributes = cleanedAttributes.replace(
+				/\sstyle\s*=\s*(["'])(.*?)\1/i,
+				(_styleText, quote, styleValue) => ` style=${quote}${styleValue};${adaptiveStyle}${quote}`,
+			)
+			return `<img${mergedAttributes}>`
+		}
+		return `<img${cleanedAttributes} style="${adaptiveStyle}">`
+	})
 }
 
 /**
@@ -98,6 +118,9 @@
 
 .rich-content {
 	width: 100%;
+	max-width: 100%;
+	overflow-x: hidden;
+	box-sizing: border-box;
 }
 
 .empty-box {
diff --git a/traditionalApp/pages/watchLive/watchLive.vue b/traditionalApp/pages/watchLive/watchLive.vue
index be3045d..e307ff0 100644
--- a/traditionalApp/pages/watchLive/watchLive.vue
+++ b/traditionalApp/pages/watchLive/watchLive.vue
@@ -16,6 +16,7 @@
 					:style="videoSectionStyle"
 					:src="liveDetail.videoUrl"
 					:poster="videoPoster"
+					:initial-time="liveInitialTime"
 					:controls="false"
 					:autoplay="isAppPlus"
 					:show-center-play-btn="false"
@@ -26,6 +27,8 @@
 					object-fit="contain"
 					play-btn-position="center"
 					@loadedmetadata="handleVideoLoadedMetadata"
+					@canplay="handleVideoCanPlay"
+					@timeupdate="handleVideoTimeUpdate"
 					@play="handleVideoPlay"
 					@pause="handleVideoPause"
 					@ended="handleVideoEnded"
@@ -110,6 +113,16 @@
 					</view>
 				</template>
 
+				<!-- 今日已获得积分状态 -->
+				<template v-else-if="answerViewState === 'alreadyAwarded'">
+					<view class="result-container">
+						<image class="trophy-img grayscale" src="/static/image/home/qualified.png" mode="aspectFit"></image>
+						<view class="result-box failed-box">
+							<view class="result-title">今日已获得积分,明日再来</view>
+						</view>
+					</view>
+				</template>
+
 				<!-- 回答错误状态 -->
 				<template v-else-if="answerViewState === 'wrong'">
 					<view class="result-container">
@@ -142,7 +155,7 @@
 import config from '@/config/index.js';
 
 type LiveActionStatus = 'RESERVABLE' | 'RESERVED' | 'WATCHABLE' | 'CLOSED'
-type QuizViewState = 'answering' | 'success' | 'wrong' | 'unopened'
+type QuizViewState = 'answering' | 'success' | 'alreadyAwarded' | 'wrong' | 'unopened'
 type QuestionOptionLabel = 'A' | 'B' | 'C'
 type VideoMetadataEvent = {
 	detail?: {
@@ -152,6 +165,12 @@
 type VideoErrorEvent = {
 	detail?: {
 		errMsg?: string
+	}
+}
+type VideoTimeUpdateEvent = {
+	detail?: {
+		currentTime?: number
+		duration?: number
 	}
 }
 
@@ -255,10 +274,14 @@
 const isStartingPlayback = ref(false)
 const isPlaybackStarted = ref(false)
 const playbackFailed = ref(false)
+const pendingPlaybackSync = ref(false)
+const playbackSyncAttempts = ref(0)
+const lastMediaCurrentTime = ref(0)
 const currentWatchStartedAt = ref(0)
 const instance = getCurrentInstance()
 let videoContext: ReturnType<typeof uni.createVideoContext> | null = null
 let playbackStartupTimer: ReturnType<typeof setTimeout> | null = null
+let playbackSyncTimer: ReturnType<typeof setTimeout> | null = null
 let quizAvailabilityTimer: ReturnType<typeof setInterval> | null = null
 
 /**
@@ -417,6 +440,7 @@
 		&& !!activeSessionId.value
 		&& !!quizState.value.quizEnabled
 })
+const liveInitialTime = computed(() => resolveInitialPlaybackSeconds())
 const watchCountText = computed(() => formatCount(liveDetail.value.watchCount))
 const rewardPointsText = computed(() => String(quizState.value.pointsReward || liveDetail.value.quizPoints || 0))
 const hasQuizQuestions = computed(() => questions.value.length > 0)
@@ -449,9 +473,9 @@
 		if (quizState.value.quizPointsAwarded) {
 			return 'success'
 		}
-		// 历史数据若出现已全部答对但积分到账标记延迟,界面仍按成功态兜底展示。
+		// 答对不等于本次获得积分,重复答题需明确提示今日已达奖励上限。
 		if (quizState.value.quizAllCorrect) {
-			return 'success'
+			return 'alreadyAwarded'
 		}
 		return 'wrong'
 	}
@@ -526,6 +550,7 @@
  */
 onHide(() => {
 	clearPlaybackStartupTimer()
+	clearPlaybackSyncTimer()
 	clearQuizAvailabilityTimer()
 	submitCurrentWatchSegment()
 })
@@ -535,6 +560,7 @@
  */
 onUnload(() => {
 	clearPlaybackStartupTimer()
+	clearPlaybackSyncTimer()
 	clearQuizAvailabilityTimer()
 	submitCurrentWatchSegment()
 })
@@ -647,6 +673,7 @@
  */
 function resetVideoPlaybackState() {
 	clearPlaybackStartupTimer()
+	clearPlaybackSyncTimer()
 	currentWatchStartedAt.value = 0
 	isVideoReady.value = false
 	isVideoPaused.value = false
@@ -655,6 +682,9 @@
 	isStartingPlayback.value = false
 	isPlaybackStarted.value = false
 	playbackFailed.value = false
+	pendingPlaybackSync.value = false
+	playbackSyncAttempts.value = 0
+	lastMediaCurrentTime.value = 0
 }
 
 /**
@@ -751,6 +781,24 @@
 }
 
 /**
+ * 计算视频组件初始播放秒数,供安卓原生层首帧加载时直接定位。
+ */
+function resolveInitialPlaybackSeconds(): number {
+	if (!supportsSimulatedPlayback.value) {
+		return 0
+	}
+	const expectedProgressSeconds = resolveExpectedProgressSeconds()
+	const configuredDurationSeconds = Number(liveDetail.value.durationSeconds || 0)
+	if (configuredDurationSeconds <= 0) {
+		return 0
+	}
+	if (mediaDurationSeconds.value > 0) {
+		return resolveSeekSeconds()
+	}
+	return Math.max(expectedProgressSeconds, 0)
+}
+
+/**
  * 计算视频文件当前应 seek 到的秒数。
  */
 function resolveSeekSeconds(): number {
@@ -787,6 +835,16 @@
 	if (playbackStartupTimer) {
 		clearTimeout(playbackStartupTimer)
 		playbackStartupTimer = null
+	}
+}
+
+/**
+ * 清理播放进度同步重试定时器,避免页面隐藏后继续 seek。
+ */
+function clearPlaybackSyncTimer() {
+	if (playbackSyncTimer) {
+		clearTimeout(playbackSyncTimer)
+		playbackSyncTimer = null
 	}
 }
 
@@ -832,37 +890,114 @@
  * 按当前时刻同步模拟直播进度,恢复播放时不会沿用旧的暂停点。
  */
 function tryStartPlayback() {
-	if (!videoContext || !isVideoReady.value || mediaDurationSeconds.value <= 0) {
+	if (!videoContext) {
+		pendingPlaybackSync.value = true
 		return
 	}
 	if (!supportsSimulatedPlayback.value) {
 		triggerVideoPlay()
 		return
 	}
+	if (!isVideoReady.value || mediaDurationSeconds.value <= 0) {
+		pendingPlaybackSync.value = true
+		schedulePlaybackSyncRetry()
+		return
+	}
+	seekToExpectedPlayback(true)
+}
+
+/**
+ * 按开播时间计算目标进度并执行 seek,必要时随后拉起播放。
+ */
+function seekToExpectedPlayback(shouldPlayAfterSeek: boolean, shouldScheduleRetry = true) {
+	if (!videoContext || !supportsSimulatedPlayback.value || mediaDurationSeconds.value <= 0) {
+		pendingPlaybackSync.value = true
+		return
+	}
 	const seekSeconds = resolveSeekSeconds()
+	pendingPlaybackSync.value = false
 	isSyncingPlayback.value = true
 	videoContext.seek(seekSeconds)
+	if (shouldScheduleRetry) {
+		schedulePlaybackSyncRetry()
+	}
 	setTimeout(() => {
 		isSyncingPlayback.value = false
-		triggerVideoPlay()
-	}, 180)
+		if (shouldPlayAfterSeek) {
+			triggerVideoPlay()
+		}
+	}, 220)
+}
+
+/**
+ * 安卓原生播放器偶发忽略早期 seek,这里做短延迟重试兜底。
+ */
+function schedulePlaybackSyncRetry() {
+	if (!isAppPlus || !supportsSimulatedPlayback.value || playbackSyncAttempts.value >= 4) {
+		return
+	}
+	clearPlaybackSyncTimer()
+	playbackSyncAttempts.value += 1
+	playbackSyncTimer = setTimeout(() => {
+		if (!videoContext || !isVideoReady.value || mediaDurationSeconds.value <= 0) {
+			schedulePlaybackSyncRetry()
+			return
+		}
+		seekToExpectedPlayback(false, false)
+	}, 260)
+}
+
+/**
+ * 在播放器进入可播放状态时补偿 pending 的同步请求。
+ */
+function syncPlaybackWhenReady(shouldPlayAfterSeek: boolean) {
+	if (!supportsSimulatedPlayback.value) {
+		if (shouldPlayAfterSeek) {
+			triggerVideoPlay()
+		}
+		return
+	}
+	if (!videoContext || !isVideoReady.value || mediaDurationSeconds.value <= 0) {
+		pendingPlaybackSync.value = true
+		schedulePlaybackSyncRetry()
+		return
+	}
+	seekToExpectedPlayback(shouldPlayAfterSeek)
 }
 
 /**
  * 元数据加载后记录真实视频长度,并立即校准模拟直播进度。
  */
 function handleVideoLoadedMetadata(event: VideoMetadataEvent) {
-	mediaDurationSeconds.value = Number(event?.detail?.duration || 0)
+	markVideoReady(event?.detail?.duration)
+}
+
+/**
+ * 视频进入可播放状态时补一次校准,兼容安卓 APP loadedmetadata 不稳定。
+ */
+function handleVideoCanPlay(event: VideoMetadataEvent) {
+	markVideoReady(event?.detail?.duration)
+}
+
+/**
+ * 统一记录视频时长并在 APP 端按开播时间校准进度。
+ */
+function markVideoReady(duration?: number) {
+	const normalizedDuration = Number(duration || 0)
+	if (normalizedDuration > 0) {
+		mediaDurationSeconds.value = normalizedDuration
+	}
 	isVideoReady.value = mediaDurationSeconds.value > 0
 	if (!isVideoReady.value) {
+		pendingPlaybackSync.value = true
+		schedulePlaybackSyncRetry()
 		return
 	}
 	if (isH5) {
 		isVideoPaused.value = true
 		return
 	}
-	// 元数据就绪后统一走同一套播放拉起逻辑,避免 App 端首帧停留。
-	tryStartPlayback()
+	syncPlaybackWhenReady(true)
 }
 
 /**
@@ -950,10 +1085,30 @@
 	isPlaybackStarted.value = true
 	playbackFailed.value = false
 	isVideoPaused.value = false
+	if (pendingPlaybackSync.value && !isSyncingPlayback.value) {
+		syncPlaybackWhenReady(false)
+	}
 	startWatchSegment()
 }
 
 /**
+ * 监听原生播放进度,发现安卓 APP 未按开播时间 seek 时再次校准。
+ */
+function handleVideoTimeUpdate(event: VideoTimeUpdateEvent) {
+	lastMediaCurrentTime.value = Number(event?.detail?.currentTime || 0)
+	if (!isAppPlus || !supportsSimulatedPlayback.value || !isVideoReady.value || isSyncingPlayback.value) {
+		return
+	}
+	if (playbackSyncAttempts.value >= 4) {
+		return
+	}
+	const expectedSeconds = resolveSeekSeconds()
+	if (Math.abs(lastMediaCurrentTime.value - expectedSeconds) > 3) {
+		seekToExpectedPlayback(false, false)
+	}
+}
+
+/**
  * 记录视频暂停状态,供点击恢复时重新对齐直播进度,同时结束本次观看区间。
  */
 function handleVideoPause() {
diff --git a/traditionalApp/utils/course-download.js b/traditionalApp/utils/course-download.js
index 813801a..1a329cc 100644
--- a/traditionalApp/utils/course-download.js
+++ b/traditionalApp/utils/course-download.js
@@ -71,6 +71,9 @@
 		durationSeconds: normalizedOptions.durationSeconds,
 		durationText: normalizedOptions.durationText,
 		videoUrl: normalizedOptions.videoUrl,
+		savedFilePath: '',
+		savedAt: '',
+		fileSize: 0,
 		progress: 0,
 		status: 'downloading',
 		updatedAt: new Date().toISOString(),
@@ -122,9 +125,15 @@
 		},
 	})
 	downloadTask.onProgressUpdate((progressEvent) => {
+		const currentTask = findDownloadTaskByVideoId(currentUserId, normalizedOptions.courseVideoId)
+		if (currentTask?.status === 'downloaded' && normalizeText(currentTask.savedFilePath)) {
+			return
+		}
 		upsertDownloadTask(currentUserId, {
-			...initialTask,
+			...(currentTask || initialTask),
+			clientTaskId: downloadTaskId,
 			progress: Number(progressEvent?.progress || 0),
+			status: 'downloading',
 			updatedAt: new Date().toISOString(),
 		})
 	})
diff --git a/traditionalApp/utils/utils.js b/traditionalApp/utils/utils.js
index 6bf4230..0e7c2af 100644
--- a/traditionalApp/utils/utils.js
+++ b/traditionalApp/utils/utils.js
@@ -1,75 +1,223 @@
+const SAVE_IMAGE_TIMEOUT_MS = 15000
+
+/**
+ * 保存图片到系统相册,APP 端优先走下载/本地文件链路,避免云打包后 getImageInfo 卡住。
+ */
 export const saveImage = (url) => {
+	const normalizedUrl = normalizeOptionalText(url)
+	if (!normalizedUrl) {
+		uni.showToast({
+			title: '图片地址为空',
+			icon: 'none'
+		})
+		return
+	}
 	uni.showLoading({
 		title: '正在保存图片...'
-	});
-	if (normalizeOptionalText(url).startsWith('data:image/')) {
-		uni.hideLoading();
+	})
+	// #ifdef APP-PLUS
+	saveImageInApp(normalizedUrl)
+	return
+	// #endif
+	saveImageWithAlbumAuth(normalizedUrl)
+}
+
+/**
+ * APP 环境保存图片,远程图片先下载,base64 图片先写入本地临时文件。
+ */
+const saveImageInApp = async (url) => {
+	try {
+		const filePath = await resolveAppImageFilePath(url)
+		await withTimeout(saveImageToAlbum(filePath), '保存超时')
+		showSaveImageSuccess()
+	} catch (error) {
+		showSaveImageFailure(error)
+	}
+}
+
+/**
+ * 非 APP 环境按原授权流程保存图片。
+ */
+const saveImageWithAlbumAuth = (url) => {
+	if (url.startsWith('data:image/')) {
+		uni.hideLoading()
 		uni.showToast({
-			title: "当前图片暂不支持直接保存,请稍后重试!",
+			title: '当前图片暂不支持直接保存,请稍后重试!',
 			icon: 'none'
-		});
+		})
 		return
 	}
 	uni.getSetting({
 		success: async (res) => {
-			if (!res.authSetting["scope.writePhotosAlbum"]) {
+			if (!res.authSetting['scope.writePhotosAlbum']) {
 				uni.authorize({
-					scope: "scope.writePhotosAlbum",
+					scope: 'scope.writePhotosAlbum',
 					success: async () => {
 						await saveImageFile(url)
 					},
 					fail: () => {
-						uni.hideLoading();
+						uni.hideLoading()
 						uni.showModal({
-							title: "权限未授权",
-							content: "请前往设置中开启相册权限",
+							title: '权限未授权',
+							content: '请前往设置中开启相册权限',
 							success: (res) => {
 								if (res.confirm) {
-									uni.openSetting();
+									uni.openSetting()
 								}
 							}
-						});
+						})
 					}
-				});
+				})
 			} else {
 				await saveImageFile(url)
 			}
 		},
 		fail: () => {
-			uni.hideLoading();
+			uni.hideLoading()
 			uni.showToast({
-				title: "获取权限失败!",
+				title: '获取权限失败!',
 				icon: 'error'
-			});
+			})
 		}
-	});
-};
+	})
+}
+
+/**
+ * 根据图片类型解析 APP 端可保存的本地文件路径。
+ */
+const resolveAppImageFilePath = async (url) => {
+	if (url.startsWith('data:image/')) {
+		return writeBase64ImageToAppFile(url)
+	}
+	if (/^https?:\/\//i.test(url)) {
+		return downloadImageToTempFile(url)
+	}
+	return url
+}
+
+/**
+ * 下载远程图片到临时文件,避免 APP 端 getImageInfo 不回调导致 loading 卡住。
+ */
+const downloadImageToTempFile = (url) => {
+	return withTimeout(new Promise((resolve, reject) => {
+		uni.downloadFile({
+			url,
+			success: (result) => {
+				if (result.statusCode !== 200 || !result.tempFilePath) {
+					reject(new Error('二维码下载失败'))
+					return
+				}
+				resolve(result.tempFilePath)
+			},
+			fail: () => reject(new Error('二维码下载失败'))
+		})
+	}), '二维码下载超时')
+}
+
+/**
+ * 将 dataURL 图片写入 APP 私有临时文件。
+ */
+const writeBase64ImageToAppFile = (dataUrl) => {
+	return withTimeout(new Promise((resolve, reject) => {
+		const matchedData = dataUrl.match(/^data:image\/(png|jpeg|jpg);base64,(.+)$/i)
+		if (!matchedData) {
+			reject(new Error('二维码图片格式不正确'))
+			return
+		}
+		// #ifdef APP-PLUS
+		const extension = matchedData[1].toLowerCase() === 'jpg' ? 'jpg' : matchedData[1].toLowerCase()
+		const fileName = `share-qrcode-${Date.now()}.${extension}`
+		const bitmap = new plus.nativeObj.Bitmap(fileName)
+		bitmap.loadBase64Data(dataUrl, () => {
+			const filePath = `_doc/${fileName}`
+			bitmap.save(filePath, {}, () => {
+				bitmap.clear()
+				resolve(filePath)
+			}, () => {
+				bitmap.clear()
+				reject(new Error('二维码图片写入失败'))
+			})
+		}, () => {
+			bitmap.clear()
+			reject(new Error('二维码图片写入失败'))
+		})
+		// #endif
+		// #ifndef APP-PLUS
+		reject(new Error('当前环境暂不支持保存该图片'))
+		// #endif
+	}), '二维码图片写入超时')
+}
+
+/**
+ * 保存本地图片文件到系统相册。
+ */
+const saveImageToAlbum = (filePath) => {
+	return new Promise((resolve, reject) => {
+		uni.saveImageToPhotosAlbum({
+			filePath,
+			success: resolve,
+			fail: () => reject(new Error('保存失败,请检查相册权限'))
+		})
+	})
+}
 
 /**
  * 下载图片并写入系统相册。
  */
 const saveImageFile = async (url) => {
 	try {
-		const imageInfo = await uni.getImageInfo({
+		const imageInfo = await withTimeout(uni.getImageInfo({
 			src: url
-		});
-		await uni.saveImageToPhotosAlbum({
-			filePath: imageInfo.path,
-		});
-		uni.hideLoading();
-		uni.showToast({
-			title: "保存成功!"
-		});
+		}), '图片加载超时')
+		await withTimeout(saveImageToAlbum(imageInfo.path), '保存超时')
+		showSaveImageSuccess()
 	} catch (error) {
-		uni.hideLoading();
-		uni.showToast({
-			title: "保存失败,请重试!",
-			icon: 'error'
-		});
+		showSaveImageFailure(error)
 	}
 }
 
 /**
+ * 为异步保存步骤增加超时保护,避免云打包后回调丢失导致 loading 常驻。
+ */
+const withTimeout = (promise, message) => {
+	return new Promise((resolve, reject) => {
+		const timer = setTimeout(() => {
+			reject(new Error(message || '操作超时'))
+		}, SAVE_IMAGE_TIMEOUT_MS)
+		Promise.resolve(promise)
+			.then((value) => {
+				clearTimeout(timer)
+				resolve(value)
+			})
+			.catch((error) => {
+				clearTimeout(timer)
+				reject(error)
+			})
+	})
+}
+
+/**
+ * 展示保存成功状态。
+ */
+const showSaveImageSuccess = () => {
+	uni.hideLoading()
+	uni.showToast({
+		title: '保存成功!'
+	})
+}
+
+/**
+ * 展示保存失败状态,确保任何失败路径都会关闭 loading。
+ */
+const showSaveImageFailure = (error) => {
+	uni.hideLoading()
+	uni.showToast({
+		title: normalizeOptionalText(error && error.message) || '保存失败,请重试!',
+		icon: 'none'
+	})
+}
+
+/**
  * 将可选文本归一化为空字符串。
  */
 export const normalizeOptionalText = (value) => {

--
Gitblit v1.7.1