Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions backend/unispeaking-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ WORKDIR /app
ARG DEBIAN_MIRROR=https://mirrors.aliyun.com/debian
ARG DEBIAN_SECURITY_MIRROR=https://mirrors.aliyun.com/debian-security
ARG PYPI_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
ARG PYPI_FALLBACK_INDEX_URL=https://pypi.org/simple
ARG PADDLE_PDX_MODEL_SOURCE=bos

RUN sed -i \
Expand Down Expand Up @@ -53,9 +54,15 @@ ENV PIP_DEFAULT_TIMEOUT=120

COPY docker/ocr/requirements.txt /tmp/ocr-requirements.txt
RUN python -m pip install \
--no-cache-dir \
--retries 5 \
-r /tmp/ocr-requirements.txt
--no-cache-dir \
--retries 5 \
-r /tmp/ocr-requirements.txt \
|| python -m pip install \
--no-cache-dir \
--index-url "${PYPI_FALLBACK_INDEX_URL}" \
--retries 5 \
--timeout 300 \
-r /tmp/ocr-requirements.txt

RUN mkdir -p /app/ocr/models
COPY src/main/resources/ocr/paddle_ocr_runner.py /app/ocr/paddle_ocr_runner.py
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
public class WeeklyGoalProgressCalculator {

private static final Set<SceneType> INCLUDED_TYPES =
EnumSet.of(SceneType.FREE_CHAT, SceneType.CUSTOM_SCENE);
EnumSet.of(
SceneType.FREE_CHAT,
SceneType.CUSTOM_SCENE,
SceneType.IELTS_SCENE);

public WeeklyGoalProgress calculate(
List<PracticeSessionRecord> records,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.unispeaking.controller;

import com.unispeaking.common.response.ApiResponse;
import com.unispeaking.domain.dto.help.HelpCenterResponse;
import com.unispeaking.domain.dto.help.HelpArticleResponse;
import com.unispeaking.domain.dto.help.HelpCategoryDetailResponse;
import com.unispeaking.service.help.HelpCenterService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/api/help-center")
public class HelpCenterController {

private final HelpCenterService helpCenterService;

public HelpCenterController(HelpCenterService helpCenterService) {
this.helpCenterService = helpCenterService;
}

@GetMapping
public ApiResponse<HelpCenterResponse> getHelpCenter() {
return ApiResponse.success(helpCenterService.getHelpCenter());
}

@GetMapping("/categories/{categoryId}")
public ApiResponse<HelpCategoryDetailResponse> getCategory(
@PathVariable String categoryId) {
return ApiResponse.success(helpCenterService.getCategory(categoryId)
.orElseThrow(() -> notFound("帮助分类不存在")));
}

@GetMapping("/articles/{articleId}")
public ApiResponse<HelpArticleResponse> getArticle(
@PathVariable String articleId) {
return ApiResponse.success(helpCenterService.getArticle(articleId)
.orElseThrow(() -> notFound("帮助文章不存在")));
}

private ResponseStatusException notFound(String message) {
return new ResponseStatusException(HttpStatus.NOT_FOUND, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.unispeaking.domain.dto.help;

public record HelpArticleResponse(
String id,
String categoryId,
String title,
String summary,
String updatedAt) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.unispeaking.domain.dto.help;

public record HelpArticleSummaryResponse(
String id,
String title,
String summary) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.unispeaking.domain.dto.help;

import java.util.List;

public record HelpCategoryDetailResponse(
String id,
String title,
String description,
List<HelpArticleSummaryResponse> articles) {

public HelpCategoryDetailResponse {
articles = List.copyOf(articles);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.unispeaking.domain.dto.help;

import java.util.List;

public record HelpCenterResponse(List<HelpCategoryResponse> categories) {

public HelpCenterResponse {
categories = List.copyOf(categories);
}

public record HelpCategoryResponse(
String id,
String title,
String description,
int articleCount) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ SecurityFilterChain securityFilterChain(
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/error").permitAll()
.requestMatchers(HttpMethod.GET, "/api/help-center", "/api/help-center/**").permitAll()
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/email/**",
"/api/auth/mobile/email/**", "/api/auth/logout",
"/api/admin/auth/login", "/api/admin/auth/logout", "/actuator/health").permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.unispeaking.service.help;

import com.unispeaking.domain.dto.help.HelpCenterResponse;
import com.unispeaking.domain.dto.help.HelpArticleResponse;
import com.unispeaking.domain.dto.help.HelpArticleSummaryResponse;
import com.unispeaking.domain.dto.help.HelpCategoryDetailResponse;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Service;

@Service
public class HelpCenterService {
private static final String UPDATED_AT = "2026-08-04";

private final HelpCenterResponse helpCenter = new HelpCenterResponse(List.of(
category("quick-start", "快速开始", "完成首次设置,选择适合自己的口语练习方式。", 3),
category("account-login", "账号与登录", "了解注册、登录、个人资料和密码安全。", 4),
category("ai-training", "AI 对话与训练", "使用自由对话和情景口语完成一次完整练习。", 5),
category("audio", "麦克风和音频", "排查麦克风权限、播放声音和实时连接问题。", 4),
category("learning-records", "学习记录", "查看学习资产、评分、打卡和练习统计。", 4),
category("membership", "会员与额度", "了解当前会员页面、额度提示和功能开放状态。", 3),
category("privacy-security", "隐私与安全", "保护账号信息,安全使用麦克风并提交问题反馈。", 3),
category("feedback", "问题反馈", "整理问题信息,帮助我们更快定位使用异常。", 3)));

private final List<Article> articles = List.of(
article("complete-first-time-setup", "quick-start", "如何完成首次设置并开始练习?", "设置英语水平和 AI 老师后,即可进入自由对话或情景训练。"),
article("choose-practice-mode", "quick-start", "自由对话和情景训练有什么区别?", "自由对话适合即时开口,情景训练适合围绕具体任务循序练习。"),
article("adjust-assistant-settings", "quick-start", "如何更换英语水平、AI 老师和语速?", "在个人中心的助手设置中调整对话体验。"),
article("register-and-login", "account-login", "如何注册和登录 UniSpeaking?", "使用有效邮箱和密码创建账号,之后可从登录页再次进入。"),
article("change-account-password", "account-login", "如何修改账号密码?", "在账号与安全中验证当前密码并设置新密码。"),
article("why-login-expired", "account-login", "为什么系统要求我重新登录?", "登录凭证失效、密码变更或账号状态变化时,系统会要求重新认证。"),
article("update-profile-details", "account-login", "如何修改昵称和头像?", "通过个人概览顶部的编辑按钮更新展示昵称和个人头像。"),
article("start-free-conversation", "ai-training", "如何开始一次 AI 自由对话?", "进入自由对话,确认麦克风可用后开始实时语音交流。"),
article("create-custom-scene", "ai-training", "如何创建自己的情景口语训练?", "描述想练习的真实情景,由系统生成对应学习内容。"),
article("learn-read-speak-flow", "ai-training", "“学、读、说”三个阶段分别做什么?", "先理解表达,再练习朗读,最后在完整情景中开口。"),
article("use-subtitles-and-translation", "ai-training", "如何使用字幕和翻译?", "在实时对话中按需要显示完整字幕并翻译对话内容。"),
article("finish-training-correctly", "ai-training", "怎样正确结束一次训练?", "使用页面中的结束操作,让系统完成会话收尾和结果保存。"),
article("grant-microphone-permission", "audio", "如何允许应用使用麦克风?", "在系统权限提示中允许麦克风,并确认使用正确的输入设备。"),
article("microphone-not-detected", "audio", "应用检测不到麦克风怎么办?", "检查设备连接、系统输入设置和应用权限。"),
article("cannot-hear-ai-audio", "audio", "听不到 AI 老师的声音怎么办?", "检查输出设备、页面播放状态和系统音量。"),
article("realtime-connection-interrupted", "audio", "实时对话连接中断后怎么办?", "结束异常会话,检查网络和权限后重新开始。"),
article("what-learning-assets-save", "learning-records", "学习资产会保存哪些内容?", "集中查看已完成场景中的语言材料、对话记录和可用评分。"),
article("view-conversation-feedback", "learning-records", "如何查看对话记录和评分?", "从学习资产打开最近完成的场景对话详情。"),
article("practice-from-assets", "learning-records", "如何从学习资产再次练习?", "复用已有场景直接练口语,或从头重新学习内容。"),
article("understand-checkin-statistics", "learning-records", "自动打卡和学习统计是怎样计算的?", "打卡来自已生成的训练报告,学习时长来自正常完成的有效会话。"),
article("open-membership-page", "membership", "在哪里查看会员与额度页面?", "从个人中心进入会员权益,查看当前方案和额度信息。"),
article("membership-payment-status", "membership", "当前会员升级会产生真实扣费吗?", "不会。当前版本的升级和支付流程是界面演示。"),
article("understand-quota-reminders", "membership", "页面中的额度提示代表什么?", "当前额度和特训限制用于展示预期体验,不构成正式计费承诺。"),
article("protect-account-security", "privacy-security", "如何保护我的 UniSpeaking 账号?", "使用独立密码,妥善保管登录状态,并在异常时及时修改密码。"),
article("avoid-sensitive-feedback", "privacy-security", "提交问题反馈时不应包含哪些信息?", "不要提交密码、令牌、密钥、完整身份证明或其他敏感数据。"),
article("use-microphone-safely", "privacy-security", "使用麦克风练习时需要注意什么?", "只在开始口语练习时授权麦克风,结束后及时停止会话。"),
article("check-before-feedback", "feedback", "反馈问题前应该先做哪些检查?", "先确认网络、权限和页面状态,避免重复提交可以自行恢复的问题。"),
article("prepare-feedback-details", "feedback", "一条有效的问题反馈应包含什么?", "提供发生位置、复现步骤、实际结果、期望结果和环境信息。"),
article("report-security-concern", "feedback", "发现账号或隐私安全问题时怎么办?", "先保护账号并停止继续暴露数据,再整理最小必要问题信息。"));

public HelpCenterResponse getHelpCenter() {
return helpCenter;
}

public Optional<HelpCategoryDetailResponse> getCategory(String categoryId) {
return helpCenter.categories().stream()
.filter(category -> category.id().equals(categoryId))
.findFirst()
.map(category -> new HelpCategoryDetailResponse(
category.id(),
category.title(),
category.description(),
articles.stream()
.filter(article -> article.categoryId().equals(categoryId))
.map(article -> new HelpArticleSummaryResponse(
article.id(), article.title(), article.summary()))
.toList()));
}

public Optional<HelpArticleResponse> getArticle(String articleId) {
return articles.stream()
.filter(article -> article.id().equals(articleId))
.findFirst()
.map(article -> new HelpArticleResponse(
article.id(),
article.categoryId(),
article.title(),
article.summary(),
UPDATED_AT));
}

private static HelpCenterResponse.HelpCategoryResponse category(
String id,
String title,
String description,
int articleCount) {
return new HelpCenterResponse.HelpCategoryResponse(
id,
title,
description,
articleCount);
}

private static Article article(
String id,
String categoryId,
String title,
String summary) {
return new Article(id, categoryId, title, summary);
}

private record Article(String id, String categoryId, String title, String summary) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.unispeaking.controller;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.unispeaking.service.help.HelpCenterService;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

class HelpCenterControllerTest {

@Test
void returnsHelpCategoriesFromTheBackendContract() throws Exception {
var mvc = MockMvcBuilders
.standaloneSetup(new HelpCenterController(new HelpCenterService()))
.build();

mvc.perform(get("/api/help-center"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.categories.length()").value(8))
.andExpect(jsonPath("$.data.categories[0].id").value("quick-start"))
.andExpect(jsonPath("$.data.categories[0].title").value("快速开始"))
.andExpect(jsonPath("$.data.categories[0].articleCount").value(3));
}

@Test
void returnsCategoryArticlesAndArticleDetails() throws Exception {
var mvc = MockMvcBuilders
.standaloneSetup(new HelpCenterController(new HelpCenterService()))
.build();

mvc.perform(get("/api/help-center/categories/quick-start"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.title").value("快速开始"))
.andExpect(jsonPath("$.data.articles.length()").value(3))
.andExpect(jsonPath("$.data.articles[0].id")
.value("complete-first-time-setup"));

mvc.perform(get("/api/help-center/articles/complete-first-time-setup"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.categoryId").value("quick-start"))
.andExpect(jsonPath("$.data.title")
.value("如何完成首次设置并开始练习?"));
}

@Test
void returnsNotFoundForUnknownHelpResources() throws Exception {
var mvc = MockMvcBuilders
.standaloneSetup(new HelpCenterController(new HelpCenterService()))
.build();

mvc.perform(get("/api/help-center/categories/missing"))
.andExpect(status().isNotFound());
mvc.perform(get("/api/help-center/articles/missing"))
.andExpect(status().isNotFound());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,27 +56,33 @@ void calculatesOverlappingDurationAndCompletionWeekCount() {
NOW,
ZONE_ID);

assertEquals(330, progress.completedDurationSeconds());
assertEquals(930, progress.completedDurationSeconds());
assertEquals(0, progress.remainingDurationSeconds());
assertEquals(100.0, progress.durationProgress());
assertTrue(progress.durationAchieved());
assertEquals(2, progress.completedTrainingCount());
assertEquals(2, progress.remainingTrainingCount());
assertEquals(50.0, progress.countProgress());
assertEquals(3, progress.completedTrainingCount());
assertEquals(1, progress.remainingTrainingCount());
assertEquals(75.0, progress.countProgress());
assertFalse(progress.countAchieved());
assertEquals(2, progress.trainingTypeDurations().size());
assertEquals(3, progress.trainingTypeDurations().size());
assertEquals(SceneType.FREE_CHAT,
progress.trainingTypeDurations().get(0).type());
assertEquals(30,
progress.trainingTypeDurations().get(0).durationSeconds());
assertEquals(9.1,
assertEquals(3.2,
progress.trainingTypeDurations().get(0).percentage());
assertEquals(SceneType.CUSTOM_SCENE,
progress.trainingTypeDurations().get(1).type());
assertEquals(300,
progress.trainingTypeDurations().get(1).durationSeconds());
assertEquals(90.9,
assertEquals(32.3,
progress.trainingTypeDurations().get(1).percentage());
assertEquals(SceneType.IELTS_SCENE,
progress.trainingTypeDurations().get(2).type());
assertEquals(600,
progress.trainingTypeDurations().get(2).durationSeconds());
assertEquals(64.5,
progress.trainingTypeDurations().get(2).percentage());
assertEquals(Instant.parse("2026-08-02T16:00:00Z"),
progress.weekStartsAt());
assertEquals(Instant.parse("2026-08-09T16:00:00Z"),
Expand Down Expand Up @@ -107,7 +113,7 @@ void roundsProgressToOneDecimalAndReturnsRemainingValues() {
}

@Test
void returnsEmptyDistributionWhenNoSessionContributesDuration() {
void includesIeltsSessionsInLearningDuration() {
var progress = calculator.calculate(
List.of(record(
SceneType.IELTS_SCENE,
Expand All @@ -118,7 +124,10 @@ void returnsEmptyDistributionWhenNoSessionContributesDuration() {
NOW,
ZONE_ID);

assertTrue(progress.trainingTypeDurations().isEmpty());
assertEquals(300, progress.completedDurationSeconds());
assertEquals(1, progress.completedTrainingCount());
assertEquals(SceneType.IELTS_SCENE,
progress.trainingTypeDurations().getFirst().type());
}

private PracticeSessionRecord record(
Expand Down
6 changes: 6 additions & 0 deletions frontend/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@
}
],
"expo-secure-store",
[
"expo-image-picker",
{
"photosPermission": "允许 UniSpeaking 访问照片,以便选择并上传个人头像。"
}
],
"expo-audio",
"@siteed/audio-studio"
],
Expand Down
Loading
Loading