From 6390bb4897208689261e54c2a3da7e3dd3c8c905 Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 31 Jul 2026 14:41:05 +0800 Subject: [PATCH 1/8] =?UTF-8?q?build(backend):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=87=E4=B8=8E=E9=9B=86=E6=88=90=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=9E=84=E5=BB=BA=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配置 JaCoCo、Surefire、Failsafe 和 CI 集成测试 Profile。 保证单元测试与集成测试可并行执行并汇总覆盖率。 --- backend/unispeaking-server/pom.xml | 174 ++++++++++++++++++++++++++++- 1 file changed, 173 insertions(+), 1 deletion(-) diff --git a/backend/unispeaking-server/pom.xml b/backend/unispeaking-server/pom.xml index efeac05..352f4d1 100644 --- a/backend/unispeaking-server/pom.xml +++ b/backend/unispeaking-server/pom.xml @@ -29,6 +29,12 @@ 21 3.5.17 + 0.8.13 + 2.0.5 + false + true + + @@ -102,6 +108,23 @@ h2 test + + org.testcontainers + testcontainers-junit-jupiter + ${testcontainers.version} + test + + + org.testcontainers + testcontainers-postgresql + ${testcontainers.version} + test + + + io.lettuce + lettuce-core + test + @@ -158,10 +181,159 @@ org.apache.maven.plugins maven-surefire-plugin - -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar + ${skipUnitTests} + @{jacoco.unit.argLine} -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar + + org.apache.maven.plugins + maven-failsafe-plugin + + ${skipIntegrationTests} + @{jacoco.integration.argLine} -javaagent:${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar + + + + + integration-test + verify + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + com/unispeaking/UniSpeakingApplication* + com/unispeaking/domain/dto/** + com/unispeaking/infrastructure/config/JwtProperties* + com/unispeaking/infrastructure/ai/qwen/RealtimeProperties* + + + + + prepare-unit-tests + + prepare-agent + + + jacoco.unit.argLine + ${project.build.directory}/jacoco-unit.exec + + + + report-unit-tests + verify + + report + + + ${project.build.directory}/jacoco-unit.exec + ${project.reporting.outputDirectory}/jacoco-unit + + + + prepare-integration-tests + pre-integration-test + + prepare-agent-integration + + + jacoco.integration.argLine + ${project.build.directory}/jacoco-integration.exec + + + + report-integration-tests + verify + + report-integration + + + ${project.build.directory}/jacoco-integration.exec + ${project.reporting.outputDirectory}/jacoco-integration + + + + + + + ci-integration + + false + + + + coverage-aggregate + + + + org.jacoco + jacoco-maven-plugin + + + merge-coverage-data + verify + + merge + + + + + ${project.build.directory} + + jacoco-unit.exec + jacoco-integration.exec + + + + ${project.build.directory}/jacoco-merged.exec + + + + report-aggregate-coverage + verify + + report + + + ${project.build.directory}/jacoco-merged.exec + ${project.reporting.outputDirectory}/jacoco-aggregate + + + + check-aggregate-coverage + verify + + check + + + ${project.build.directory}/jacoco-merged.exec + + + BUNDLE + + + LINE + COVEREDRATIO + 0.70 + + + + + + + + + + + + + From 6ebcff14d98cfac10adfd9fe93817c7ad7ed0391 Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 31 Jul 2026 14:47:54 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(database):=20=E4=BD=BF=E7=94=A8=20Flyw?= =?UTF-8?q?ay=20=E6=8E=A5=E7=AE=A1=E6=95=B0=E6=8D=AE=E5=BA=93=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将现有 Schema 转为 V1 基线迁移,并以版本 0 接管已有数据库。 停用 Spring SQL 初始化和重复手工脚本,保留幂等升级与数据兼容能力。 --- backend/unispeaking-server/pom.xml | 9 + .../src/main/resources/application.yaml | 9 +- .../V1__baseline.sql} | 1 + .../src/test/resources/application.yaml | 2 + deploy/postgres/phrase.sql | 82 --------- deploy/postgres/scene.sql | 58 ------ deploy/postgres/sentence.sql | 171 ------------------ deploy/postgres/sentence_evaluation.sql | 59 ------ deploy/postgres/session_evaluation.sql | 97 ---------- deploy/postgres/session_message.sql | 73 -------- deploy/postgres/turn_evaluation.sql | 122 ------------- deploy/postgres/user_preference.sql | 163 ----------------- deploy/postgres/word.sql | 61 ------- 13 files changed, 19 insertions(+), 888 deletions(-) rename backend/unispeaking-server/src/main/resources/db/{schema.sql => migration/V1__baseline.sql} (99%) delete mode 100644 deploy/postgres/phrase.sql delete mode 100644 deploy/postgres/scene.sql delete mode 100644 deploy/postgres/sentence.sql delete mode 100644 deploy/postgres/sentence_evaluation.sql delete mode 100644 deploy/postgres/session_evaluation.sql delete mode 100644 deploy/postgres/session_message.sql delete mode 100644 deploy/postgres/turn_evaluation.sql delete mode 100644 deploy/postgres/user_preference.sql delete mode 100644 deploy/postgres/word.sql diff --git a/backend/unispeaking-server/pom.xml b/backend/unispeaking-server/pom.xml index 352f4d1..7ed67e8 100644 --- a/backend/unispeaking-server/pom.xml +++ b/backend/unispeaking-server/pom.xml @@ -71,6 +71,15 @@ postgresql runtime + + org.springframework.boot + spring-boot-starter-flyway + + + org.flywaydb + flyway-database-postgresql + runtime + de.sciss jump3r diff --git a/backend/unispeaking-server/src/main/resources/application.yaml b/backend/unispeaking-server/src/main/resources/application.yaml index 201dd98..477fbea 100644 --- a/backend/unispeaking-server/src/main/resources/application.yaml +++ b/backend/unispeaking-server/src/main/resources/application.yaml @@ -8,10 +8,15 @@ spring: username: ${DATABASE_USERNAME:postgres} password: ${DATABASE_PASSWORD:} driver-class-name: org.postgresql.Driver + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 0 + validate-on-migrate: true sql: init: - mode: always - schema-locations: classpath:db/schema.sql + mode: never servlet: multipart: max-file-size: 10MB diff --git a/backend/unispeaking-server/src/main/resources/db/schema.sql b/backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql similarity index 99% rename from backend/unispeaking-server/src/main/resources/db/schema.sql rename to backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql index 0b325a0..0fde13a 100644 --- a/backend/unispeaking-server/src/main/resources/db/schema.sql +++ b/backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql @@ -1,3 +1,4 @@ +-- Flyway V1: current production schema plus idempotent legacy upgrades. CREATE TABLE IF NOT EXISTS "user" ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(254) NOT NULL UNIQUE, diff --git a/backend/unispeaking-server/src/test/resources/application.yaml b/backend/unispeaking-server/src/test/resources/application.yaml index 234e833..5be1113 100644 --- a/backend/unispeaking-server/src/test/resources/application.yaml +++ b/backend/unispeaking-server/src/test/resources/application.yaml @@ -6,6 +6,8 @@ spring: username: sa password: driver-class-name: org.h2.Driver + flyway: + enabled: false sql: init: mode: never diff --git a/deploy/postgres/phrase.sql b/deploy/postgres/phrase.sql deleted file mode 100644 index 7ad396d..0000000 --- a/deploy/postgres/phrase.sql +++ /dev/null @@ -1,82 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS phrase ( - scene_id VARCHAR(64) NOT NULL, - phrase_id VARCHAR(64) NOT NULL, - phrase VARCHAR(255) NOT NULL, - phonetic VARCHAR(255), - translation TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT phrase_pk PRIMARY KEY (scene_id, phrase_id), - CONSTRAINT phrase_id_check - CHECK (phrase_id LIKE 'phrase\_%' ESCAPE '\'), - CONSTRAINT phrase_scene_id_check CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'), - CONSTRAINT phrase_text_check CHECK (BTRIM(phrase) <> ''), - CONSTRAINT phrase_translation_check CHECK (BTRIM(translation) <> '') -); - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'phrase' - AND column_name = 'id' - ) AND NOT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'phrase' - AND column_name = 'phrase_id' - ) THEN - ALTER TABLE phrase RENAME COLUMN id TO phrase_id; - END IF; -END; -$$; - -ALTER TABLE phrase -DROP CONSTRAINT IF EXISTS phrase_pkey; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conrelid = 'phrase'::REGCLASS - AND contype = 'p' - ) THEN - ALTER TABLE phrase - ADD CONSTRAINT phrase_pk PRIMARY KEY (scene_id, phrase_id); - END IF; -END; -$$; - -COMMENT ON TABLE phrase IS -'自定义场景的词组学习内容'; - -COMMENT ON COLUMN phrase.scene_id IS -'逻辑关联 scene.id,不设置数据库外键'; - -COMMENT ON COLUMN phrase.phrase_id IS -'词组业务标识,与 scene_id 共同构成主键'; - -CREATE OR REPLACE FUNCTION set_phrase_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS phrase_set_updated_at ON phrase; - -CREATE TRIGGER phrase_set_updated_at -BEFORE UPDATE ON phrase -FOR EACH ROW -EXECUTE FUNCTION set_phrase_updated_at(); - -COMMIT; diff --git a/deploy/postgres/scene.sql b/deploy/postgres/scene.sql deleted file mode 100644 index 46f318c..0000000 --- a/deploy/postgres/scene.sql +++ /dev/null @@ -1,58 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS scene ( - id VARCHAR(64) PRIMARY KEY, - user_id UUID NOT NULL, - title VARCHAR(128) NOT NULL, - background TEXT NOT NULL, - ai_role TEXT NOT NULL, - user_role TEXT NOT NULL, - learning_goal TEXT NOT NULL, - custom_instruction TEXT, - success_factor JSONB NOT NULL DEFAULT '{}'::JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMPTZ, - CONSTRAINT scene_id_check CHECK (id LIKE 'custom\_%' ESCAPE '\'), - CONSTRAINT scene_title_check CHECK (BTRIM(title) <> ''), - CONSTRAINT scene_success_factor_check - CHECK (JSONB_TYPEOF(success_factor) = 'object') -); - -CREATE INDEX IF NOT EXISTS idx_scene_user_id -ON scene (user_id); - -CREATE INDEX IF NOT EXISTS idx_scene_active_user_updated_at -ON scene (user_id, updated_at DESC) -WHERE deleted_at IS NULL; - -COMMENT ON TABLE scene IS -'用户创建的自定义场景定义;学习内容和练习结果由其他业务实体保存'; - -COMMENT ON COLUMN scene.user_id IS -'逻辑关联 user.id,不设置数据库外键'; - -COMMENT ON COLUMN scene.success_factor IS -'状态机用于判断场景是否成功完成的 JSON 条件对象'; - -COMMENT ON COLUMN scene.deleted_at IS -'软删除时间;为空表示场景有效'; - -CREATE OR REPLACE FUNCTION set_scene_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS scene_set_updated_at ON scene; - -CREATE TRIGGER scene_set_updated_at -BEFORE UPDATE ON scene -FOR EACH ROW -EXECUTE FUNCTION set_scene_updated_at(); - -COMMIT; diff --git a/deploy/postgres/sentence.sql b/deploy/postgres/sentence.sql deleted file mode 100644 index 1bc7b7d..0000000 --- a/deploy/postgres/sentence.sql +++ /dev/null @@ -1,171 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS sentence ( - scene_id VARCHAR(64) NOT NULL, - sentence_id VARCHAR(64) NOT NULL, - sentence TEXT NOT NULL, - translation TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT sentence_pk PRIMARY KEY (scene_id, sentence_id), - CONSTRAINT sentence_content_id_check - CHECK (sentence_id LIKE 'sentence\_%' ESCAPE '\'), - CONSTRAINT sentence_scene_id_check - CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'), - CONSTRAINT sentence_text_check CHECK (BTRIM(sentence) <> ''), - CONSTRAINT sentence_translation_check CHECK (BTRIM(translation) <> '') -); - -CREATE TABLE IF NOT EXISTS sentence_evaluation ( - id VARCHAR(64) PRIMARY KEY, - scene_id VARCHAR(64) NOT NULL, - sentence_id VARCHAR(64) NOT NULL, - overall_score NUMERIC(5, 2) NOT NULL, - score_detail JSONB NOT NULL DEFAULT '{}'::JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT sentence_evaluation_id_check - CHECK (id LIKE 'sentence\_reading\_%' ESCAPE '\'), - CONSTRAINT sentence_evaluation_scene_id_check - CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'), - CONSTRAINT sentence_evaluation_sentence_id_check - CHECK (sentence_id LIKE 'sentence\_%' ESCAPE '\'), - CONSTRAINT sentence_evaluation_overall_score_check - CHECK (overall_score BETWEEN 0 AND 100), - CONSTRAINT sentence_evaluation_score_detail_check - CHECK (JSONB_TYPEOF(score_detail) = 'object') -); - -DO $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'sentence' - AND column_name = 'id' - ) THEN - EXECUTE ' - INSERT INTO sentence_evaluation ( - id, - scene_id, - sentence_id, - overall_score, - score_detail, - created_at, - updated_at - ) - SELECT - id, - scene_id, - sentence_id, - overall_score, - score_detail, - created_at, - updated_at - FROM sentence - WHERE overall_score IS NOT NULL - ON CONFLICT (id) DO NOTHING - '; - EXECUTE ' - DELETE FROM sentence AS duplicate - USING sentence AS retained - WHERE duplicate.scene_id = retained.scene_id - AND duplicate.sentence_id = retained.sentence_id - AND (duplicate.created_at, duplicate.id) - > (retained.created_at, retained.id) - '; - ALTER TABLE sentence - DROP CONSTRAINT IF EXISTS sentence_pkey; - ALTER TABLE sentence - DROP CONSTRAINT IF EXISTS sentence_record_id_check; - ALTER TABLE sentence - DROP CONSTRAINT IF EXISTS sentence_overall_score_check; - ALTER TABLE sentence - DROP CONSTRAINT IF EXISTS sentence_score_detail_check; - ALTER TABLE sentence - DROP COLUMN id; - ALTER TABLE sentence - DROP COLUMN overall_score; - ALTER TABLE sentence - DROP COLUMN score_detail; - END IF; -END; -$$; - -ALTER TABLE sentence -DROP CONSTRAINT IF EXISTS sentence_pk; - -ALTER TABLE sentence -ADD CONSTRAINT sentence_pk PRIMARY KEY (scene_id, sentence_id); - -DROP INDEX IF EXISTS idx_sentence_scene_id; - -CREATE INDEX IF NOT EXISTS idx_sentence_content_id -ON sentence (sentence_id); - -DROP INDEX IF EXISTS idx_sentence_scene_content_created_at; - -CREATE INDEX IF NOT EXISTS idx_sentence_evaluation_scene_sentence -ON sentence_evaluation (scene_id, sentence_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_sentence_evaluation_created_at -ON sentence_evaluation (created_at DESC); - -COMMENT ON TABLE sentence IS -'自定义场景的参考句子资产,不保存朗读评分'; - -COMMENT ON COLUMN sentence.scene_id IS -'场景业务标识,与 sentence_id 共同构成主键'; - -COMMENT ON COLUMN sentence.sentence_id IS -'参考句子的业务标识,与 scene_id 共同构成主键'; - -COMMENT ON TABLE sentence_evaluation IS -'用户每次朗读场景句子的评分记录'; - -COMMENT ON COLUMN sentence_evaluation.scene_id IS -'场景业务标识,不设置数据库外键'; - -COMMENT ON COLUMN sentence_evaluation.sentence_id IS -'句子业务标识,与 scene_id 一起定位 sentence 资产,不设置数据库外键'; - -COMMENT ON COLUMN sentence_evaluation.score_detail IS -'JSON 格式的逐词及逐音素评分细则'; - -CREATE OR REPLACE FUNCTION set_sentence_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS sentence_set_updated_at ON sentence; - -CREATE TRIGGER sentence_set_updated_at -BEFORE UPDATE ON sentence -FOR EACH ROW -EXECUTE FUNCTION set_sentence_updated_at(); - -CREATE OR REPLACE FUNCTION set_sentence_evaluation_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS sentence_evaluation_set_updated_at -ON sentence_evaluation; - -CREATE TRIGGER sentence_evaluation_set_updated_at -BEFORE UPDATE ON sentence_evaluation -FOR EACH ROW -EXECUTE FUNCTION set_sentence_evaluation_updated_at(); - -COMMIT; diff --git a/deploy/postgres/sentence_evaluation.sql b/deploy/postgres/sentence_evaluation.sql deleted file mode 100644 index af3625b..0000000 --- a/deploy/postgres/sentence_evaluation.sql +++ /dev/null @@ -1,59 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS sentence_evaluation ( - id VARCHAR(64) PRIMARY KEY, - scene_id VARCHAR(64) NOT NULL, - sentence_id VARCHAR(64) NOT NULL, - overall_score NUMERIC(5, 2) NOT NULL, - score_detail JSONB NOT NULL DEFAULT '{}'::JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT sentence_evaluation_id_check - CHECK (id LIKE 'sentence\_reading\_%' ESCAPE '\'), - CONSTRAINT sentence_evaluation_scene_id_check - CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'), - CONSTRAINT sentence_evaluation_sentence_id_check - CHECK (sentence_id LIKE 'sentence\_%' ESCAPE '\'), - CONSTRAINT sentence_evaluation_overall_score_check - CHECK (overall_score BETWEEN 0 AND 100), - CONSTRAINT sentence_evaluation_score_detail_check - CHECK (JSONB_TYPEOF(score_detail) = 'object') -); - -CREATE INDEX IF NOT EXISTS idx_sentence_evaluation_scene_sentence -ON sentence_evaluation (scene_id, sentence_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_sentence_evaluation_created_at -ON sentence_evaluation (created_at DESC); - -COMMENT ON TABLE sentence_evaluation IS -'用户每次朗读场景句子的评分记录'; - -COMMENT ON COLUMN sentence_evaluation.scene_id IS -'场景业务标识,不设置数据库外键'; - -COMMENT ON COLUMN sentence_evaluation.sentence_id IS -'句子业务标识,与 scene_id 一起定位 sentence 资产,不设置数据库外键'; - -COMMENT ON COLUMN sentence_evaluation.score_detail IS -'JSON 格式的逐词及逐音素评分细则'; - -CREATE OR REPLACE FUNCTION set_sentence_evaluation_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS sentence_evaluation_set_updated_at -ON sentence_evaluation; - -CREATE TRIGGER sentence_evaluation_set_updated_at -BEFORE UPDATE ON sentence_evaluation -FOR EACH ROW -EXECUTE FUNCTION set_sentence_evaluation_updated_at(); - -COMMIT; diff --git a/deploy/postgres/session_evaluation.sql b/deploy/postgres/session_evaluation.sql deleted file mode 100644 index e7957e9..0000000 --- a/deploy/postgres/session_evaluation.sql +++ /dev/null @@ -1,97 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS session_evaluation ( - session_id VARCHAR(64) PRIMARY KEY, - scene_id VARCHAR(64), - accuracy_score NUMERIC(5, 2) NOT NULL, - fluency_score NUMERIC(5, 2) NOT NULL, - grammar_score NUMERIC(5, 2) NOT NULL, - vocabulary_score NUMERIC(5, 2) NOT NULL, - naturalness_score NUMERIC(5, 2) NOT NULL, - final_score NUMERIC(5, 2) NOT NULL, - summary TEXT NOT NULL, - strengths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], - improvements TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT session_evaluation_session_id_check - CHECK (BTRIM(session_id) <> ''), - CONSTRAINT session_evaluation_scene_id_check - CHECK (scene_id IS NULL OR BTRIM(scene_id) <> ''), - CONSTRAINT session_evaluation_accuracy_score_check - CHECK (accuracy_score BETWEEN 0 AND 100), - CONSTRAINT session_evaluation_fluency_score_check - CHECK (fluency_score BETWEEN 0 AND 100), - CONSTRAINT session_evaluation_grammar_score_check - CHECK (grammar_score BETWEEN 0 AND 100), - CONSTRAINT session_evaluation_vocabulary_score_check - CHECK (vocabulary_score BETWEEN 0 AND 100), - CONSTRAINT session_evaluation_naturalness_score_check - CHECK (naturalness_score BETWEEN 0 AND 100), - CONSTRAINT session_evaluation_final_score_check - CHECK (final_score BETWEEN 0 AND 100), - CONSTRAINT session_evaluation_summary_check - CHECK (BTRIM(summary) <> '') -); - -ALTER TABLE session_evaluation -ADD COLUMN IF NOT EXISTS scene_id VARCHAR(64); - -DO $$ -BEGIN - IF TO_REGCLASS('public.session_message') IS NOT NULL THEN - EXECUTE ' - UPDATE session_evaluation AS evaluation - SET scene_id = message.scene_id - FROM ( - SELECT DISTINCT ON (session_id) session_id, scene_id - FROM session_message - ORDER BY session_id, message_no - ) AS message - WHERE evaluation.session_id = message.session_id - AND evaluation.scene_id IS NULL - '; - END IF; -END; -$$; - -CREATE INDEX IF NOT EXISTS idx_session_evaluation_scene_id -ON session_evaluation (scene_id, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_session_evaluation_created_at -ON session_evaluation (created_at DESC); - -COMMENT ON TABLE session_evaluation IS -'一场会话结束后的综合练习评分;每个 session_id 只保存一份结果'; - -COMMENT ON COLUMN session_evaluation.session_id IS -'会话业务标识,与其他会话表保持 VARCHAR(64),不设置数据库外键'; - -COMMENT ON COLUMN session_evaluation.scene_id IS -'场景业务标识,用于保留同一场景的历次五维评分,不设置数据库外键'; - -COMMENT ON COLUMN session_evaluation.strengths IS -'本场会话表现较好的方面'; - -COMMENT ON COLUMN session_evaluation.improvements IS -'本场会话需要改进的方面'; - -CREATE OR REPLACE FUNCTION set_session_evaluation_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS session_evaluation_set_updated_at -ON session_evaluation; - -CREATE TRIGGER session_evaluation_set_updated_at -BEFORE UPDATE ON session_evaluation -FOR EACH ROW -EXECUTE FUNCTION set_session_evaluation_updated_at(); - -COMMIT; diff --git a/deploy/postgres/session_message.sql b/deploy/postgres/session_message.sql deleted file mode 100644 index 475bd93..0000000 --- a/deploy/postgres/session_message.sql +++ /dev/null @@ -1,73 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS session_message ( - scene_id VARCHAR(64) NOT NULL, - session_id VARCHAR(64) NOT NULL, - message_no INTEGER NOT NULL, - owner SMALLINT NOT NULL, - content TEXT NOT NULL, - audio_url TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - update_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT session_message_pk - PRIMARY KEY (session_id, message_no), - CONSTRAINT session_message_scene_id_check - CHECK (BTRIM(scene_id) <> ''), - CONSTRAINT session_message_session_id_check - CHECK (BTRIM(session_id) <> ''), - CONSTRAINT session_message_no_check - CHECK (message_no > 0), - CONSTRAINT session_message_owner_check - CHECK (owner IN (0, 1)), - CONSTRAINT session_message_content_check - CHECK (BTRIM(content) <> ''), - CONSTRAINT session_message_audio_url_check - CHECK (audio_url IS NULL OR BTRIM(audio_url) <> '') -); - -CREATE INDEX IF NOT EXISTS idx_session_message_scene_id -ON session_message (scene_id); - -CREATE INDEX IF NOT EXISTS idx_session_message_created_at -ON session_message (created_at DESC); - -COMMENT ON TABLE session_message IS -'按消息保存自定义场景会话的最终完整转写,不保存流式 delta'; - -COMMENT ON COLUMN session_message.scene_id IS -'场景业务标识,不设置数据库外键'; - -COMMENT ON COLUMN session_message.session_id IS -'会话业务标识,不设置数据库外键'; - -COMMENT ON COLUMN session_message.message_no IS -'消息在当前会话中的顺序号,从 1 开始'; - -COMMENT ON COLUMN session_message.owner IS -'消息归属:0 表示 AI,1 表示用户'; - -COMMENT ON COLUMN session_message.content IS -'用户或 AI 的最终完整对话转写,不保存流式 delta'; - -COMMENT ON COLUMN session_message.audio_url IS -'可选的音频对象存储地址'; - -CREATE OR REPLACE FUNCTION set_session_message_update_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.update_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS session_message_set_update_at -ON session_message; - -CREATE TRIGGER session_message_set_update_at -BEFORE UPDATE ON session_message -FOR EACH ROW -EXECUTE FUNCTION set_session_message_update_at(); - -COMMIT; diff --git a/deploy/postgres/turn_evaluation.sql b/deploy/postgres/turn_evaluation.sql deleted file mode 100644 index 95aa843..0000000 --- a/deploy/postgres/turn_evaluation.sql +++ /dev/null @@ -1,122 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS turn_evaluation ( - session_id VARCHAR(64) NOT NULL, - turn_no INTEGER NOT NULL, - scene_id VARCHAR(64) NOT NULL, - transcript TEXT NOT NULL, - overall_score NUMERIC(5, 2) NOT NULL, - rhythm_score NUMERIC(5, 2) NOT NULL, - tone_score NUMERIC(5, 2), - integrity_score NUMERIC(5, 2) NOT NULL, - pronunciation_score NUMERIC(5, 2) NOT NULL, - fluency_score NUMERIC(5, 2) NOT NULL, - feedback_summary TEXT NOT NULL, - suggested_expression TEXT NOT NULL, - pronunciation_details JSONB NOT NULL DEFAULT '{}'::JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT turn_evaluation_pk PRIMARY KEY (session_id, turn_no), - CONSTRAINT turn_evaluation_scene_id_check - CHECK (BTRIM(scene_id) <> ''), - CONSTRAINT turn_evaluation_session_id_check - CHECK (BTRIM(session_id) <> ''), - CONSTRAINT turn_evaluation_turn_no_check - CHECK (turn_no > 0), - CONSTRAINT turn_evaluation_transcript_check - CHECK (BTRIM(transcript) <> ''), - CONSTRAINT turn_evaluation_overall_score_check - CHECK (overall_score BETWEEN 0 AND 100), - CONSTRAINT turn_evaluation_rhythm_score_check - CHECK (rhythm_score BETWEEN 0 AND 100), - CONSTRAINT turn_evaluation_tone_score_check - CHECK (tone_score IS NULL OR tone_score BETWEEN 0 AND 100), - CONSTRAINT turn_evaluation_integrity_score_check - CHECK (integrity_score BETWEEN 0 AND 100), - CONSTRAINT turn_evaluation_pronunciation_score_check - CHECK (pronunciation_score BETWEEN 0 AND 100), - CONSTRAINT turn_evaluation_fluency_score_check - CHECK (fluency_score BETWEEN 0 AND 100), - CONSTRAINT turn_evaluation_feedback_summary_check - CHECK (BTRIM(feedback_summary) <> ''), - CONSTRAINT turn_evaluation_pronunciation_details_check - CHECK (JSONB_TYPEOF(pronunciation_details) = 'object') -); - -DO $$ -BEGIN - ALTER TABLE turn_evaluation - DROP CONSTRAINT IF EXISTS turn_evaluation_session_turn_uk; - IF EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'turn_evaluation' - AND column_name = 'id' - ) THEN - ALTER TABLE turn_evaluation - DROP CONSTRAINT IF EXISTS turn_evaluation_pkey; - ALTER TABLE turn_evaluation - DROP COLUMN id; - END IF; -END; -$$; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conrelid = 'turn_evaluation'::REGCLASS - AND contype = 'p' - ) THEN - ALTER TABLE turn_evaluation - ADD CONSTRAINT turn_evaluation_pk - PRIMARY KEY (session_id, turn_no); - END IF; -END; -$$; - -CREATE INDEX IF NOT EXISTS idx_turn_evaluation_scene_id -ON turn_evaluation (scene_id); - -CREATE INDEX IF NOT EXISTS idx_turn_evaluation_created_at -ON turn_evaluation (created_at DESC); - -COMMENT ON TABLE turn_evaluation IS -'会话中每一轮用户发言的单句评分;session_id 与 turn_no 唯一确定一轮'; - -COMMENT ON COLUMN turn_evaluation.scene_id IS -'场景业务标识,不设置数据库外键'; - -COMMENT ON COLUMN turn_evaluation.session_id IS -'会话业务标识,不设置数据库外键'; - -COMMENT ON COLUMN turn_evaluation.turn_no IS -'用户发言在当前会话中的轮次,从 1 开始'; - -COMMENT ON COLUMN turn_evaluation.tone_score IS -'语调评分;供应商未返回该维度时允许为空'; - -COMMENT ON COLUMN turn_evaluation.pronunciation_details IS -'逐词和逐音素发音评分明细 JSON 对象'; - -CREATE OR REPLACE FUNCTION set_turn_evaluation_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS turn_evaluation_set_updated_at -ON turn_evaluation; - -CREATE TRIGGER turn_evaluation_set_updated_at -BEFORE UPDATE ON turn_evaluation -FOR EACH ROW -EXECUTE FUNCTION set_turn_evaluation_updated_at(); - -COMMIT; diff --git a/deploy/postgres/user_preference.sql b/deploy/postgres/user_preference.sql deleted file mode 100644 index 9cfb3f0..0000000 --- a/deploy/postgres/user_preference.sql +++ /dev/null @@ -1,163 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS "user" ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username VARCHAR(254) NOT NULL UNIQUE, - password_hash VARCHAR(255) NOT NULL, - nickname VARCHAR(32), - role VARCHAR(16) NOT NULL DEFAULT 'USER', - status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', - auth_version BIGINT NOT NULL DEFAULT 0, - last_login_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT user_role_check CHECK (role IN ('USER', 'ADMIN')), - CONSTRAINT user_status_check CHECK (status IN ('ACTIVE', 'DISABLED', 'LOCKED')), - CONSTRAINT user_auth_version_check CHECK (auth_version >= 0) -); - -ALTER TABLE "user" -ALTER COLUMN username TYPE VARCHAR(254); - -CREATE TABLE IF NOT EXISTS user_preference ( - user_id UUID PRIMARY KEY, - preferred_voice VARCHAR(64), - preferred_ai_speech_speed VARCHAR(16) NOT NULL DEFAULT 'NATURAL', - preferences JSONB NOT NULL DEFAULT '{}'::JSONB, - memory_text TEXT, - cefr_level VARCHAR(4), - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT user_preference_cefr_level_check - CHECK (cefr_level IS NULL OR cefr_level IN ('A', 'B', 'C', 'D')), - CONSTRAINT user_preference_speech_speed_check - CHECK (preferred_ai_speech_speed IN ('SLOWER', 'MODERATE', 'NATURAL', 'FASTER')) -); - -COMMENT ON COLUMN user_preference.memory_text IS -'用户主动维护的长期档案摘要,仅包含兴趣与熟悉背景、昵称或称谓、年龄段、代词和敏感话题边界,不存储逐轮对话或会话历史摘要'; - -UPDATE user_preference -SET memory_text = NULL -WHERE memory_text LIKE '本次会话摘要(本地):%'; - -INSERT INTO "user" ( - id, - username, - password_hash, - nickname, - role, - status -) -VALUES ( - '11111111-1111-4111-8111-111111111111', - 'demo@example.com', - '$2y$12$Oxho43Xny2DnCJ1BT2BtvOb5b6CeH6xNI/fa5wBT7jLLkvbHsPK9W', - 'Demo User', - 'USER', - 'ACTIVE' -) -ON CONFLICT (id) DO UPDATE -SET username = EXCLUDED.username, - password_hash = EXCLUDED.password_hash, - nickname = EXCLUDED.nickname, - role = EXCLUDED.role, - status = EXCLUDED.status; - -DO $$ -DECLARE - current_user_id_type TEXT; -BEGIN - SELECT data_type - INTO current_user_id_type - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'user_preference' - AND column_name = 'user_id'; - - IF current_user_id_type <> 'uuid' THEN - UPDATE user_preference - SET user_id = '11111111-1111-4111-8111-111111111111' - WHERE user_id = 'demo-user-001'; - - ALTER TABLE user_preference - ALTER COLUMN user_id TYPE UUID - USING user_id::UUID; - END IF; -END; -$$; - -ALTER TABLE user_preference -DROP CONSTRAINT IF EXISTS user_preference_cefr_level_check; - -UPDATE user_preference -SET cefr_level = CASE cefr_level - WHEN 'A1' THEN 'A' - WHEN 'A2' THEN 'B' - WHEN 'B1' THEN 'C' - WHEN 'B2' THEN 'D' - WHEN 'C1' THEN 'D' - WHEN 'C2' THEN 'D' - ELSE cefr_level -END -WHERE cefr_level IS NOT NULL; - -ALTER TABLE user_preference -ADD CONSTRAINT user_preference_cefr_level_check -CHECK (cefr_level IS NULL OR cefr_level IN ('A', 'B', 'C', 'D')); - -ALTER TABLE user_preference -DROP CONSTRAINT IF EXISTS user_preference_speech_speed_check; - -ALTER TABLE user_preference -ADD CONSTRAINT user_preference_speech_speed_check -CHECK (preferred_ai_speech_speed IN ('SLOWER', 'MODERATE', 'NATURAL', 'FASTER')); - -CREATE OR REPLACE FUNCTION set_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS user_set_updated_at ON "user"; -CREATE TRIGGER user_set_updated_at -BEFORE UPDATE ON "user" -FOR EACH ROW -EXECUTE FUNCTION set_updated_at(); - -DROP TRIGGER IF EXISTS user_preference_set_updated_at ON user_preference; -CREATE TRIGGER user_preference_set_updated_at -BEFORE UPDATE ON user_preference -FOR EACH ROW -EXECUTE FUNCTION set_updated_at(); - -DROP FUNCTION IF EXISTS set_user_preference_updated_at(); - -INSERT INTO user_preference ( - user_id, - preferred_voice, - preferred_ai_speech_speed, - preferences, - memory_text, - cefr_level -) -VALUES ( - '11111111-1111-4111-8111-111111111111', - 'Katerina', - 'NATURAL', - '{"translation_enabled": true, "learning_goal": "daily_conversation"}'::JSONB, - '兴趣与背景:喜欢科技、电影和旅行,从事软件产品相关工作,熟悉会议和演示场景。个人信息:昵称 Demo;未提供年龄段、代词和敏感话题边界。', - 'C' -) -ON CONFLICT (user_id) DO UPDATE -SET preferred_voice = EXCLUDED.preferred_voice, - preferred_ai_speech_speed = EXCLUDED.preferred_ai_speech_speed, - preferences = EXCLUDED.preferences, - memory_text = EXCLUDED.memory_text, - cefr_level = EXCLUDED.cefr_level; - -COMMIT; diff --git a/deploy/postgres/word.sql b/deploy/postgres/word.sql deleted file mode 100644 index 1dd590a..0000000 --- a/deploy/postgres/word.sql +++ /dev/null @@ -1,61 +0,0 @@ -BEGIN; - -CREATE TABLE IF NOT EXISTS "word" ( - scene_id VARCHAR(64) NOT NULL, - word_id VARCHAR(64) NOT NULL, - word VARCHAR(128) NOT NULL, - phonetic VARCHAR(128), - translation TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT word_pk PRIMARY KEY (scene_id, word_id), - CONSTRAINT word_id_check CHECK (word_id LIKE 'word\_%' ESCAPE '\'), - CONSTRAINT word_scene_id_check CHECK (scene_id LIKE 'custom\_%' ESCAPE '\'), - CONSTRAINT word_text_check CHECK (BTRIM(word) <> ''), - CONSTRAINT word_translation_check CHECK (BTRIM(translation) <> '') -); - -ALTER TABLE "word" -DROP CONSTRAINT IF EXISTS word_pkey; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conrelid = '"word"'::REGCLASS - AND contype = 'p' - ) THEN - ALTER TABLE "word" - ADD CONSTRAINT word_pk PRIMARY KEY (scene_id, word_id); - END IF; -END; -$$; - -COMMENT ON TABLE "word" IS -'自定义场景的单词学习内容'; - -COMMENT ON COLUMN "word".scene_id IS -'逻辑关联 scene.id,不设置数据库外键'; - -COMMENT ON COLUMN "word".word_id IS -'单词业务标识,与 scene_id 共同构成主键'; - -CREATE OR REPLACE FUNCTION set_word_updated_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS word_set_updated_at ON "word"; - -CREATE TRIGGER word_set_updated_at -BEFORE UPDATE ON "word" -FOR EACH ROW -EXECUTE FUNCTION set_word_updated_at(); - -COMMIT; From a2a31cf1234a4a586909c14ed13962153fdd9dce Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 31 Jul 2026 15:02:09 +0800 Subject: [PATCH 3/8] =?UTF-8?q?test(integration):=20=E5=A2=9E=E5=8A=A0=20P?= =?UTF-8?q?ostgreSQL=20=E4=B8=8E=20Redis=20=E5=AE=B9=E5=99=A8=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用 Testcontainers 验证 Flyway、真实持久化、JSONB 和复合主键。 通过测试作用域 Redis 客户端检查读写及 TTL 到期行为。 --- .../integration/PostgresPersistenceIT.java | 493 ++++++++++++++++++ .../integration/RedisContainerIT.java | 64 +++ 2 files changed, 557 insertions(+) create mode 100644 backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java create mode 100644 backend/unispeaking-server/src/test/java/com/unispeaking/integration/RedisContainerIT.java diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java new file mode 100644 index 0000000..fd5d6d6 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/PostgresPersistenceIT.java @@ -0,0 +1,493 @@ +package com.unispeaking.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.unispeaking.common.evaluation.model.EndingTone; +import com.unispeaking.common.evaluation.model.PronunciationAssessmentResult; +import com.unispeaking.common.evaluation.model.PronunciationPhonemeResult; +import com.unispeaking.common.evaluation.model.PronunciationWordResult; +import com.unispeaking.common.evaluation.model.WordReadStatus; +import com.unispeaking.domain.dto.evaluation.DialogueReportResult; +import com.unispeaking.domain.dto.scene.LearningContentItem; +import com.unispeaking.domain.dto.scene.SceneGenerationResponse; +import com.unispeaking.domain.dto.session.Message; +import com.unispeaking.domain.po.auth.UserAccount; +import com.unispeaking.domain.po.auth.UserRole; +import com.unispeaking.domain.po.auth.UserStatus; +import com.unispeaking.domain.po.profile.UserProfile; +import com.unispeaking.domain.po.scene.CustomSceneDefinition; +import com.unispeaking.infrastructure.persistence.entity.evaluation.CustomTurnEvaluation; +import com.unispeaking.infrastructure.persistence.entity.evaluation.PronunciationWordDetail; +import com.unispeaking.infrastructure.persistence.repository.evaluation.SceneSentenceReadingRepository; +import com.unispeaking.infrastructure.persistence.repository.evaluation.SessionEvaluationRepository; +import com.unispeaking.infrastructure.persistence.repository.evaluation.TurnEvaluationRepository; +import com.unispeaking.infrastructure.persistence.repository.scene.MybatisSceneRepository; +import com.unispeaking.infrastructure.persistence.repository.session.SessionMessageRepository; +import com.unispeaking.infrastructure.persistence.repository.user.MybatisUserAccountRepository; +import com.unispeaking.infrastructure.persistence.repository.user.MybatisUserProfileRepository; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@SpringBootTest +@Testcontainers +class PostgresPersistenceIT { + + @Container + static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:17-alpine") + .withDatabaseName("unispeaking_it") + .withUsername("unispeaking") + .withPassword("unispeaking"); + + @DynamicPropertySource + static void postgresProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.flyway.baseline-on-migrate", () -> true); + registry.add("spring.flyway.baseline-version", () -> "0"); + registry.add("spring.flyway.locations", () -> "classpath:db/migration"); + registry.add("spring.sql.init.mode", () -> "never"); + registry.add( + "mybatis-plus.type-handlers-package", + () -> "com.unispeaking.infrastructure.persistence.typehandler"); + } + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private MybatisUserAccountRepository userAccountRepository; + + @Autowired + private MybatisUserProfileRepository userProfileRepository; + + @Autowired + private MybatisSceneRepository sceneRepository; + + @Autowired + private SessionMessageRepository sessionMessageRepository; + + @Autowired + private TurnEvaluationRepository turnEvaluationRepository; + + @Autowired + private SessionEvaluationRepository sessionEvaluationRepository; + + @Autowired + private SceneSentenceReadingRepository sentenceReadingRepository; + + @BeforeEach + void clearBusinessTables() { + jdbcTemplate.execute(""" + TRUNCATE TABLE + sentence_evaluation, + session_evaluation, + turn_evaluation, + session_message, + sentence, + phrase, + "word", + scene, + user_preference, + "user" + """); + } + + @Test + void migratesEmptyDatabaseAndRegistersFlywayHistory() { + Integer migrationCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM flyway_schema_history WHERE success", + Integer.class); + String successFactorType = jdbcTemplate.queryForObject( + """ + SELECT data_type + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'scene' + AND column_name = 'success_factor' + """, + String.class); + + assertEquals(1, migrationCount); + assertEquals("jsonb", successFactorType); + } + + @Test + void persistsUserProfileAndLastLoginAgainstPostgres() { + UUID userId = UUID.fromString("11111111-1111-4111-8111-111111111111"); + Instant createdAt = Instant.parse("2026-07-31T06:00:00Z"); + UserAccount account = new UserAccount( + userId, + "ci@example.com", + "encoded-password", + "CI User", + UserRole.USER, + UserStatus.ACTIVE, + 0, + null, + createdAt, + createdAt); + + userAccountRepository.create(account); + userAccountRepository.updateLastLoginAt( + userId, + Instant.parse("2026-07-31T07:00:00Z")); + UserProfile profile = new UserProfile( + userId.toString(), + "B", + "Clara", + "MODERATE", + "zh-CN", + "喜欢旅行", + "{\"translation_enabled\":true}"); + userProfileRepository.save(profile); + userProfileRepository.save(profile.withPreferences( + "James", + "NATURAL", + "C", + "喜欢旅行和咖啡")); + + assertEquals( + "ci@example.com", + userAccountRepository.findById(userId).orElseThrow().username()); + assertEquals( + Instant.parse("2026-07-31T07:00:00Z"), + userAccountRepository.findByUsername("ci@example.com") + .orElseThrow() + .lastLoginAt()); + UserProfile saved = userProfileRepository + .findByUserId(userId.toString()) + .orElseThrow(); + assertEquals("James", saved.voiceId()); + assertEquals("C", saved.level()); + assertEquals("喜欢旅行和咖啡", saved.memoryText()); + } + + @Test + void persistsSceneContentReadsAssetsAndHonorsSoftDelete() throws Exception { + CustomSceneDefinition definition = sceneDefinition(); + SceneGenerationResponse response = new SceneGenerationResponse( + definition.sceneId(), + definition.wordList(), + definition.phraseList(), + definition.sentenceList(), + "prompt"); + + sceneRepository.saveCustomScene(definition, response); + + SceneGenerationResponse generated = sceneRepository + .findGeneratedById(definition.sceneId()) + .orElseThrow(); + assertEquals("word_it1", generated.wordList().getFirst().contentId()); + assertEquals("phrase_it1", generated.phraseList().getFirst().contentId()); + assertEquals("sentence_it1", generated.sentenceList().getFirst().contentId()); + assertEquals( + objectMapper.readTree("{\"minimum_user_turns\":2}"), + objectMapper.readTree(sceneRepository + .findCustomDefinitionById(definition.sceneId()) + .orElseThrow() + .successFactorJson())); + assertEquals(1, sceneRepository.findAssetsByUserId(definition.userId()).size()); + assertTrue(sceneRepository.findAssetsByUserId("not-a-uuid").isEmpty()); + + jdbcTemplate.update( + "UPDATE scene SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?", + definition.sceneId()); + + assertTrue(sceneRepository.findGeneratedById(definition.sceneId()).isEmpty()); + assertTrue(sceneRepository.findAssetsByUserId(definition.userId()).isEmpty()); + } + + @Test + void storesMessagesAndEvaluationJsonbWithCompositeKeys() { + sessionMessageRepository.append( + "custom_it1", + "session_it1", + 2, + new Message(0, " Welcome. ", null)); + sessionMessageRepository.append( + "custom_it1", + "session_it1", + 1, + new Message(1, " Hello. ", null)); + sessionMessageRepository.append( + "custom_it1", + "session_old", + 1, + new Message(1, "Old message", null)); + + assertEquals( + List.of("Hello.", "Welcome."), + sessionMessageRepository.findMessages("session_it1").stream() + .map(Message::content) + .toList()); + assertEquals( + "custom_it1", + sessionMessageRepository.findSceneId("session_it1").orElseThrow()); + assertEquals( + 1, + sessionMessageRepository.deleteObsoleteForScene( + "custom_it1", + "session_it1")); + + CustomTurnEvaluation first = turnEvaluation(1, new BigDecimal("82")); + CustomTurnEvaluation second = turnEvaluation(2, new BigDecimal("86")); + turnEvaluationRepository.upsert(first); + turnEvaluationRepository.upsert(second); + turnEvaluationRepository.upsert(turnEvaluation( + 1, + new BigDecimal("91"))); + + List saved = + turnEvaluationRepository.findAll("session_it1"); + assertEquals(2, saved.size()); + assertEquals(new BigDecimal("91.00"), saved.getFirst().overallScore()); + assertEquals("coffee", saved.getFirst().words().getFirst().text()); + assertEquals( + 1, + turnEvaluationRepository.findBefore("session_it1", 2).size()); + assertEquals( + "object", + jdbcTemplate.queryForObject( + """ + SELECT jsonb_typeof(pronunciation_details) + FROM turn_evaluation + WHERE session_id = 'session_it1' AND turn_no = 1 + """, + String.class)); + } + + @Test + void storesSessionArraysAndSentenceReadingDetails() { + CustomSceneDefinition definition = sceneDefinition(); + sceneRepository.saveCustomScene( + definition, + new SceneGenerationResponse( + definition.sceneId(), + definition.wordList(), + definition.phraseList(), + definition.sentenceList(), + "prompt")); + DialogueReportResult report = new DialogueReportResult( + new BigDecimal("88"), + new BigDecimal("87"), + new BigDecimal("86"), + new BigDecimal("85"), + new BigDecimal("84"), + new BigDecimal("86"), + "表达稳定", + List.of("内容清楚", "语速自然"), + List.of("增加连接词")); + + sessionEvaluationRepository.save( + definition.sceneId(), + "session_it1", + report); + sessionEvaluationRepository.save( + definition.sceneId(), + "session_it1", + report); + String readingId = sentenceReadingRepository.saveAttempt( + definition.sceneId(), + definition.sentenceList().getFirst(), + pronunciationAssessment()); + + assertEquals( + List.of("内容清楚", "语速自然"), + sessionEvaluationRepository.find("session_it1") + .orElseThrow() + .strengths()); + assertEquals( + definition.sceneId(), + sessionEvaluationRepository.findRecord("session_it1") + .orElseThrow() + .sceneId()); + assertEquals( + 1, + sessionEvaluationRepository.findBySceneId(definition.sceneId()).size()); + assertEquals( + definition.sceneId(), + sentenceReadingRepository + .findSceneIdBySentenceId("sentence_it1") + .orElseThrow()); + assertTrue(readingId.startsWith("sentence_reading_")); + assertEquals( + "object", + jdbcTemplate.queryForObject( + "SELECT jsonb_typeof(score_detail) FROM sentence_evaluation WHERE id = ?", + String.class, + readingId)); + } + + @Test + void baselinesLegacySchemaAtZeroAndKeepsExistingData() { + String schema = "legacy_ci"; + jdbcTemplate.execute("DROP SCHEMA IF EXISTS " + schema + " CASCADE"); + jdbcTemplate.execute("CREATE SCHEMA " + schema); + jdbcTemplate.execute(""" + CREATE TABLE legacy_ci."user" ( + id UUID PRIMARY KEY, + username VARCHAR(128) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + nickname VARCHAR(32), + role VARCHAR(16) NOT NULL DEFAULT 'USER', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + auth_version BIGINT NOT NULL DEFAULT 0, + last_login_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """); + jdbcTemplate.update( + """ + INSERT INTO legacy_ci."user" + (id, username, password_hash) + VALUES (?::uuid, ?, ?) + """, + "22222222-2222-4222-8222-222222222222", + "legacy@example.com", + "legacy-password"); + + Flyway.configure() + .dataSource( + POSTGRES.getJdbcUrl(), + POSTGRES.getUsername(), + POSTGRES.getPassword()) + .schemas(schema) + .defaultSchema(schema) + .locations("classpath:db/migration") + .baselineOnMigrate(true) + .baselineVersion("0") + .load() + .migrate(); + + assertEquals( + 1, + jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM legacy_ci.\"user\" WHERE username = 'legacy@example.com'", + Integer.class)); + assertEquals( + List.of("0", "1"), + jdbcTemplate.queryForList( + """ + SELECT version + FROM legacy_ci.flyway_schema_history + WHERE version IS NOT NULL + ORDER BY installed_rank + """, + String.class)); + assertFalse(jdbcTemplate.queryForList( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'legacy_ci' + AND table_name = 'scene' + """, + String.class).isEmpty()); + + jdbcTemplate.execute("DROP SCHEMA " + schema + " CASCADE"); + } + + private CustomSceneDefinition sceneDefinition() { + return new CustomSceneDefinition( + "custom_it1", + "11111111-1111-4111-8111-111111111111", + "酒店入住", + "酒店前台", + "前台接待员", + "住客", + "完成入住", + "保持礼貌", + "{\"minimum_user_turns\":2}", + List.of(new LearningContentItem( + "word_it1", + "reservation", + "预订", + "/ˌrezərˈveɪʃn/")), + List.of(new LearningContentItem( + "phrase_it1", + "check in", + "办理入住", + "/tʃek ɪn/")), + List.of(new LearningContentItem( + "sentence_it1", + "I have a reservation.", + "我有预订。", + ""))); + } + + private CustomTurnEvaluation turnEvaluation( + int turnNo, + BigDecimal overallScore) { + return new CustomTurnEvaluation( + "custom_it1", + "session_it1", + turnNo, + "I would like some coffee.", + overallScore, + new BigDecimal("82"), + new BigDecimal("80"), + new BigDecimal("100"), + new BigDecimal("86"), + new BigDecimal("83"), + "表达清楚。", + "I'd like some coffee, please.", + List.of(new PronunciationWordDetail( + 0, + "coffee", + new BigDecimal("88"), + List.of(new PronunciationWordDetail.Phoneme( + 0, + "k", + "k", + new BigDecimal("90"), + 0, + 1))))); + } + + private PronunciationAssessmentResult pronunciationAssessment() { + return new PronunciationAssessmentResult( + new BigDecimal("88"), + new BigDecimal("87"), + new BigDecimal("86"), + new BigDecimal("100"), + new BigDecimal("89"), + new BigDecimal("90"), + EndingTone.FALL, + List.of(new PronunciationWordResult( + 0, + "reservation", + WordReadStatus.NORMAL, + new BigDecimal("88"), + new BigDecimal("89"), + true, + List.of(new PronunciationPhonemeResult( + 0, + "r", + "r", + new BigDecimal("90"), + 0, + 1))))); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/integration/RedisContainerIT.java b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/RedisContainerIT.java new file mode 100644 index 0000000..c0e1481 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/integration/RedisContainerIT.java @@ -0,0 +1,64 @@ +package com.unispeaking.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.api.sync.RedisCommands; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +class RedisContainerIT { + + private static final int REDIS_PORT = 6379; + + @Container + static final GenericContainer REDIS = + new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")) + .withExposedPorts(REDIS_PORT) + .waitingFor(Wait.forListeningPort()); + + @Test + void writesReadsAndExpiresValue() throws InterruptedException { + RedisURI uri = RedisURI.builder() + .withHost(REDIS.getHost()) + .withPort(REDIS.getMappedPort(REDIS_PORT)) + .withTimeout(Duration.ofSeconds(5)) + .build(); + RedisClient client = RedisClient.create(uri); + try (StatefulRedisConnection connection = + client.connect()) { + RedisCommands commands = connection.sync(); + String key = "ci:ttl"; + + commands.psetex(key, 800, "ready"); + + assertEquals("ready", commands.get(key)); + long remainingTtl = commands.pttl(key); + assertTrue(remainingTtl > 0 && remainingTtl <= 800); + awaitExpiration(commands, key); + assertNull(commands.get(key)); + } + finally { + client.shutdown(); + } + } + + private void awaitExpiration( + RedisCommands commands, + String key) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (commands.get(key) != null && System.nanoTime() < deadline) { + Thread.sleep(50); + } + } +} From 4592ae5fe3d6dbb3f016bca4d3bf9a08337f8537 Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 31 Jul 2026 15:09:03 +0800 Subject: [PATCH 4/8] =?UTF-8?q?test(backend):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E4=B8=9A=E5=8A=A1=E6=B5=8B=E8=AF=95=E5=B9=B6?= =?UTF-8?q?=E8=BE=BE=E5=88=B0=E8=A6=86=E7=9B=96=E7=8E=87=E9=97=A8=E6=A7=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加场景状态机和过短评分策略测试,覆盖异常、确认与关闭分支。 单元测试增至 186 个,合并行覆盖率达到 73.80%。 --- .../policy/TooShortEvaluationPolicyTest.java | 50 ++++++ .../ScenarioDialogueStateMachineTest.java | 157 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 backend/unispeaking-server/src/test/java/com/unispeaking/common/evaluation/policy/TooShortEvaluationPolicyTest.java create mode 100644 backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/ScenarioDialogueStateMachineTest.java diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/common/evaluation/policy/TooShortEvaluationPolicyTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/common/evaluation/policy/TooShortEvaluationPolicyTest.java new file mode 100644 index 0000000..bbcb0c7 --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/common/evaluation/policy/TooShortEvaluationPolicyTest.java @@ -0,0 +1,50 @@ +package com.unispeaking.common.evaluation.policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import org.junit.jupiter.api.Test; + +class TooShortEvaluationPolicyTest { + + @Test + void createsRecognizableZeroScoreResult() { + var result = TooShortEvaluationPolicy.createResult(3, "Hi"); + + assertEquals(3, result.turnNo()); + assertEquals("Hi", result.transcript()); + assertTrue(result.words().isEmpty()); + assertTrue(TooShortEvaluationPolicy.isTooShort(result)); + } + + @Test + void requiresEveryZeroScoreAndExactFeedback() { + assertFalse(TooShortEvaluationPolicy.isTooShort(null)); + assertFalse(TooShortEvaluationPolicy.isTooShort( + BigDecimal.ONE, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + TooShortEvaluationPolicy.FEEDBACK_SUMMARY)); + assertFalse(TooShortEvaluationPolicy.isTooShort( + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + " 过短,不予评分 ")); + assertFalse(TooShortEvaluationPolicy.isTooShort( + null, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + TooShortEvaluationPolicy.FEEDBACK_SUMMARY)); + } +} diff --git a/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/ScenarioDialogueStateMachineTest.java b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/ScenarioDialogueStateMachineTest.java new file mode 100644 index 0000000..3db3eca --- /dev/null +++ b/backend/unispeaking-server/src/test/java/com/unispeaking/service/scene/ScenarioDialogueStateMachineTest.java @@ -0,0 +1,157 @@ +package com.unispeaking.service.scene; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.unispeaking.common.exception.BusinessException; +import com.unispeaking.domain.dto.session.Message; +import com.unispeaking.domain.po.scene.CustomSceneDefinition; +import com.unispeaking.domain.po.scene.ScenarioDialogueEvent; +import com.unispeaking.domain.po.scene.ScenarioSuccessFactor; +import com.unispeaking.domain.vo.scene.ScenarioDialogueCompletionReason; +import com.unispeaking.domain.vo.scene.ScenarioDialogueEventType; +import com.unispeaking.domain.vo.scene.ScenarioDialogueStage; +import com.unispeaking.infrastructure.persistence.repository.session.SessionMessageRepository; +import com.unispeaking.service.scene.impl.ScenarioDialogueEventExtractor; +import com.unispeaking.service.scene.impl.ScenarioDialogueStateMachine; +import com.unispeaking.service.scene.impl.ScenarioSuccessFactorParser; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ScenarioDialogueStateMachineTest { + + private ScenarioDialogueEventExtractor eventExtractor; + private SessionMessageRepository messageRepository; + private ScenarioDialogueStateMachine stateMachine; + + @BeforeEach + void setUp() { + ScenarioSuccessFactorParser successFactorParser = + mock(ScenarioSuccessFactorParser.class); + eventExtractor = mock(ScenarioDialogueEventExtractor.class); + messageRepository = mock(SessionMessageRepository.class); + when(successFactorParser.parse(any())) + .thenReturn(new ScenarioSuccessFactor( + 1, + 4, + Map.of( + "drink", "choose a drink", + "payment", "choose payment"), + "complete the order", + "Thank the learner.")); + when(messageRepository.findMessages(anyString())) + .thenReturn(List.of(new Message(0, "What would you like?", null))); + stateMachine = new ScenarioDialogueStateMachine( + successFactorParser, + eventExtractor, + messageRepository); + } + + @Test + void advancesFromGreetingThroughConfirmationAndClosing() { + var started = stateMachine.start("session_1", scene()); + assertEquals(ScenarioDialogueStage.GREETING, started.stage()); + assertTrue(started.controlInstruction().contains("choose a drink")); + assertFalse(started.completed()); + + when(eventExtractor.extract(any(), anyString(), anyList())) + .thenReturn( + outcome("drink", "latte"), + outcome("payment", "card"), + new ScenarioDialogueEvent( + ScenarioDialogueEventType.USER_CONFIRMED, + Map.of(), + 0.99)); + + var collecting = stateMachine.advance("session_1", 1, "A latte."); + assertEquals(ScenarioDialogueStage.COLLECTING_INFORMATION, collecting.stage()); + assertTrue(collecting.outcomes().stream() + .anyMatch(outcome -> outcome.outcomeId().equals("drink") + && outcome.satisfied())); + + var duplicate = stateMachine.advance("session_1", 1, "Use cash."); + assertEquals(1, duplicate.effectiveUserTurns()); + verify(eventExtractor, times(1)).extract(any(), anyString(), anyList()); + + var confirmation = stateMachine.advance("session_1", 2, "By card."); + assertEquals(ScenarioDialogueStage.CONFIRMATION, confirmation.stage()); + assertTrue(confirmation.controlInstruction().contains("final confirmation")); + + var completed = stateMachine.advance("session_1", 3, "That is correct."); + assertTrue(completed.completed()); + assertEquals(ScenarioDialogueCompletionReason.GOAL_ACHIEVED, + completed.completionReason()); + assertTrue(completed.controlInstruction().contains("Thank the learner.")); + + var closing = stateMachine.beginClosing("session_1"); + assertEquals(ScenarioDialogueStage.CLOSING, closing.stage()); + assertTrue(closing.controlInstruction().isEmpty()); + assertEquals(3, + stateMachine.advance("session_1", 4, "ignored") + .effectiveUserTurns()); + } + + @Test + void recordsExtractorFailureAndValidatesInput() { + stateMachine.start("session_2", scene()); + when(eventExtractor.extract(any(), anyString(), anyList())) + .thenThrow(new IllegalStateException("provider unavailable")); + + var response = stateMachine.advance("session_2", 1, " Continue "); + + assertEquals(1, response.effectiveUserTurns()); + assertTrue(response.warning().contains("语义目标提取失败")); + assertThrows(BusinessException.class, + () -> stateMachine.advance("session_2", 0, "invalid")); + assertThrows(BusinessException.class, + () -> stateMachine.advance("session_2", 2, " ")); + } + + @Test + void removesStateAndRejectsUnknownSession() { + stateMachine.start("session_3", scene()); + assertTrue(stateMachine.findState("session_3").isPresent()); + + stateMachine.remove("session_3"); + + assertTrue(stateMachine.findState("session_3").isEmpty()); + BusinessException exception = assertThrows( + BusinessException.class, + () -> stateMachine.getState("session_3")); + assertEquals("SCENARIO_STATE_NOT_FOUND", exception.code()); + } + + private CustomSceneDefinition scene() { + return new CustomSceneDefinition( + "custom_1", + "11111111-1111-4111-8111-111111111111", + "Coffee", + "Cafe", + "Barista", + "Customer", + "Order coffee", + "", + "{}", + List.of(), + List.of(), + List.of()); + } + + private ScenarioDialogueEvent outcome(String key, String value) { + return new ScenarioDialogueEvent( + ScenarioDialogueEventType.OUTCOME_UPDATE, + Map.of(key, value), + 0.95); + } +} From e40c2349696c072040ecda44e3c3890510e0445b Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 31 Jul 2026 15:27:15 +0800 Subject: [PATCH 5/8] =?UTF-8?q?ci(core):=20=E5=BB=BA=E7=AB=8B=E6=8C=89?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E6=89=A7=E8=A1=8C=E7=9A=84=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按路径组织后端、前端、Docker、Compose 与依赖审查任务。 保存测试和覆盖率报告,并由 always 汇总任务统一判定门禁。 --- .github/workflows/ci-core.yml | 480 +++++++++++++++++++++ .github/workflows/ci-pr.yml | 26 ++ backend/unispeaking-server/.dockerignore | 5 + backend/unispeaking-server/Dockerfile | 5 +- frontend/Unispeaking_fronted/.dockerignore | 6 +- frontend/Unispeaking_fronted/Dockerfile | 4 +- 6 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci-core.yml create mode 100644 .github/workflows/ci-pr.yml create mode 100644 backend/unispeaking-server/.dockerignore diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml new file mode 100644 index 0000000..967e18e --- /dev/null +++ b/.github/workflows/ci-core.yml @@ -0,0 +1,480 @@ +name: CI Core + +on: + workflow_call: + inputs: + pr_number: + description: Pull request number used for concurrency and artifacts + required: true + type: string + base_sha: + description: Base commit used to calculate changed paths + required: true + type: string + head_sha: + description: Pull request head commit + required: true + type: string + merge_sha: + description: GitHub-generated merge commit to test + required: true + type: string + +concurrency: + group: ci-pr-${{ inputs.pr_number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + name: 识别变更范围 + runs-on: ubuntu-latest + outputs: + backend: ${{ steps.paths.outputs.backend }} + frontend: ${{ steps.paths.outputs.frontend }} + docker_backend: ${{ steps.paths.outputs.docker_backend }} + docker_frontend: ${{ steps.paths.outputs.docker_frontend }} + compose: ${{ steps.paths.outputs.compose }} + nginx: ${{ steps.paths.outputs.nginx }} + dependencies: ${{ steps.paths.outputs.dependencies }} + steps: + - name: 检出待测合并提交 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.merge_sha }} + fetch-depth: 0 + + - name: 计算条件任务 + id: paths + env: + BASE_SHA: ${{ inputs.base_sha }} + HEAD_SHA: ${{ inputs.head_sha }} + shell: bash + run: | + set -euo pipefail + git diff --name-only "$BASE_SHA" "$HEAD_SHA" > "$RUNNER_TEMP/changed-files" + + backend=false + frontend=false + docker_backend=false + docker_frontend=false + compose=false + nginx=false + dependencies=false + force_all=false + + while IFS= read -r file; do + case "$file" in + .github/workflows/*) + force_all=true + ;; + backend/*) + backend=true + docker_backend=true + ;; + frontend/Unispeaking_fronted/*) + frontend=true + docker_frontend=true + ;; + esac + case "$file" in + backend/unispeaking-server/pom.xml|backend/unispeaking-server/.mvn/*|backend/unispeaking-server/mvnw) + dependencies=true + ;; + frontend/Unispeaking_fronted/package.json|frontend/Unispeaking_fronted/package-lock.json) + dependencies=true + ;; + deploy/docker-compose.yml|deploy/docker-compose.yaml|deploy/compose.yml|deploy/compose.yaml|deploy/env/.env.example) + compose=true + ;; + deploy/nginx/*) + nginx=true + ;; + esac + done < "$RUNNER_TEMP/changed-files" + + if [[ "$force_all" == true ]]; then + backend=true + frontend=true + docker_backend=true + docker_frontend=true + compose=true + nginx=true + dependencies=true + fi + + { + echo "backend=$backend" + echo "frontend=$frontend" + echo "docker_backend=$docker_backend" + echo "docker_frontend=$docker_frontend" + echo "compose=$compose" + echo "nginx=$nginx" + echo "dependencies=$dependencies" + } >> "$GITHUB_OUTPUT" + + backend: + name: 后端编译、单测与打包 + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: 检出待测合并提交 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.merge_sha }} + + - name: 配置 Java 21 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: "21" + cache: maven + cache-dependency-path: backend/unispeaking-server/pom.xml + + - name: 编译、测试并打包 + working-directory: backend/unispeaking-server + shell: bash + run: | + set -euo pipefail + mkdir -p ci-logs + ./mvnw --batch-mode --no-transfer-progress clean verify 2>&1 \ + | tee ci-logs/backend-unit.log + + - name: 保存单元测试与覆盖率报告 + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: backend-unit-reports-${{ inputs.pr_number }} + path: | + backend/unispeaking-server/target/surefire-reports + backend/unispeaking-server/target/site/jacoco-unit + backend/unispeaking-server/ci-logs/backend-unit.log + if-no-files-found: error + retention-days: 30 + + - name: 传递单元测试覆盖率数据 + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: backend-unit-coverage-${{ inputs.pr_number }} + path: | + backend/unispeaking-server/target/jacoco-unit.exec + backend/unispeaking-server/target/classes + if-no-files-found: error + retention-days: 1 + + integration: + name: PostgreSQL 与 Redis 集成测试 + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: 检出待测合并提交 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.merge_sha }} + + - name: 配置 Java 21 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: "21" + cache: maven + cache-dependency-path: backend/unispeaking-server/pom.xml + + - name: 运行容器集成测试 + working-directory: backend/unispeaking-server + shell: bash + run: | + set -euo pipefail + mkdir -p ci-logs + ./mvnw --batch-mode --no-transfer-progress \ + -Pci-integration -DskipUnitTests verify 2>&1 \ + | tee ci-logs/backend-integration.log + + - name: 保存集成测试报告 + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: backend-integration-reports-${{ inputs.pr_number }} + path: | + backend/unispeaking-server/target/failsafe-reports + backend/unispeaking-server/target/site/jacoco-integration + backend/unispeaking-server/ci-logs/backend-integration.log + if-no-files-found: error + retention-days: 30 + + - name: 传递集成测试覆盖率数据 + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: backend-integration-coverage-${{ inputs.pr_number }} + path: backend/unispeaking-server/target/jacoco-integration.exec + if-no-files-found: error + retention-days: 1 + + coverage: + name: 合并覆盖率并检查 70% 门槛 + needs: + - changes + - backend + - integration + if: >- + always() && + needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: 确认前置任务成功 + if: needs.backend.result != 'success' || needs.integration.result != 'success' + run: exit 1 + + - name: 检出待测合并提交 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.merge_sha }} + + - name: 配置 Java 21 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: "21" + cache: maven + cache-dependency-path: backend/unispeaking-server/pom.xml + + - name: 下载单元测试覆盖率数据 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: backend-unit-coverage-${{ inputs.pr_number }} + path: backend/unispeaking-server/target + + - name: 下载集成测试覆盖率数据 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: backend-integration-coverage-${{ inputs.pr_number }} + path: backend/unispeaking-server/target + + - name: 合并报告并执行覆盖率门禁 + working-directory: backend/unispeaking-server + shell: bash + run: | + set -euo pipefail + mkdir -p ci-logs + ./mvnw --batch-mode --no-transfer-progress \ + -Pcoverage-aggregate \ + -DskipUnitTests \ + -DskipIntegrationTests \ + verify 2>&1 | tee ci-logs/backend-coverage.log + + - name: 保存合并覆盖率报告 + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: backend-coverage-${{ inputs.pr_number }} + path: | + backend/unispeaking-server/target/site/jacoco-aggregate + backend/unispeaking-server/ci-logs/backend-coverage.log + if-no-files-found: error + retention-days: 30 + + frontend: + name: 前端依赖与生产构建 + needs: changes + if: needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: frontend/Unispeaking_fronted + steps: + - name: 检出待测合并提交 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.merge_sha }} + + - name: 配置 Node.js 22 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/Unispeaking_fronted/package-lock.json + + - name: 安装锁定依赖 + run: npm ci + + - name: 检查路由 + run: npm run check:routes + + - name: 检查 Realtime 事件 + run: npm run check:realtime-events + + - name: 生产构建 + run: npm run build + + docker_backend: + name: 构建后端镜像 + needs: changes + if: needs.changes.outputs.docker_backend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: 检出待测合并提交 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.merge_sha }} + + - name: 验证后端镜像构建 + run: >- + docker build + --file backend/unispeaking-server/Dockerfile + --tag unispeaking-backend:ci + backend/unispeaking-server + + docker_frontend: + name: 构建前端镜像 + needs: changes + if: needs.changes.outputs.docker_frontend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: 检出待测合并提交 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.merge_sha }} + + - name: 验证前端镜像构建 + run: >- + docker build + --file frontend/Unispeaking_fronted/Dockerfile + --tag unispeaking-frontend:ci + frontend/Unispeaking_fronted + + compose: + name: 校验 Compose 配置 + needs: changes + if: needs.changes.outputs.compose == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: 检出待测合并提交 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.merge_sha }} + + - name: 准备 CI 环境模板 + run: cp deploy/env/.env.example deploy/env/.env + + - name: 校验配置 + run: docker compose --env-file deploy/env/.env.example -f deploy/docker-compose.yml config --quiet + + nginx: + name: 校验 Nginx 配置 + needs: changes + if: needs.changes.outputs.nginx == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: 检出待测合并提交 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ inputs.merge_sha }} + + - name: 执行 nginx -t + run: >- + docker run --rm + --add-host backend:127.0.0.1 + --add-host frontend:127.0.0.1 + --volume "$PWD/deploy/nginx/nginx.conf:/etc/nginx/nginx.conf:ro" + nginx:1.27-alpine + nginx -t + + dependency_review: + name: 审查新增依赖漏洞 + needs: changes + if: needs.changes.outputs.dependencies == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: 审查依赖差异 + uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + base-ref: ${{ inputs.base_sha }} + head-ref: ${{ inputs.head_sha }} + fail-on-severity: high + comment-summary-in-pr: never + + required: + name: 核心检查汇总 + if: always() + needs: + - changes + - backend + - integration + - coverage + - frontend + - docker_backend + - docker_frontend + - compose + - nginx + - dependency_review + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: 汇总所有必要任务 + env: + CHANGES_RESULT: ${{ needs.changes.result }} + BACKEND_RESULT: ${{ needs.backend.result }} + INTEGRATION_RESULT: ${{ needs.integration.result }} + COVERAGE_RESULT: ${{ needs.coverage.result }} + FRONTEND_RESULT: ${{ needs.frontend.result }} + DOCKER_BACKEND_RESULT: ${{ needs.docker_backend.result }} + DOCKER_FRONTEND_RESULT: ${{ needs.docker_frontend.result }} + COMPOSE_RESULT: ${{ needs.compose.result }} + NGINX_RESULT: ${{ needs.nginx.result }} + DEPENDENCY_RESULT: ${{ needs.dependency_review.result }} + BACKEND_REQUIRED: ${{ needs.changes.outputs.backend }} + FRONTEND_REQUIRED: ${{ needs.changes.outputs.frontend }} + DOCKER_BACKEND_REQUIRED: ${{ needs.changes.outputs.docker_backend }} + DOCKER_FRONTEND_REQUIRED: ${{ needs.changes.outputs.docker_frontend }} + COMPOSE_REQUIRED: ${{ needs.changes.outputs.compose }} + NGINX_REQUIRED: ${{ needs.changes.outputs.nginx }} + DEPENDENCY_REQUIRED: ${{ needs.changes.outputs.dependencies }} + shell: bash + run: | + set -euo pipefail + + if [[ "$CHANGES_RESULT" != success ]]; then + echo "变更识别任务未成功:$CHANGES_RESULT" + exit 1 + fi + + require_job() { + local name="$1" + local result="$2" + local required="$3" + if [[ "$required" == true && "$result" != success ]]; then + echo "$name 为必要任务,但结果为 $result" + return 1 + fi + if [[ "$required" != true && "$result" != skipped && "$result" != success ]]; then + echo "$name 非必要任务,但出现异常结果 $result" + return 1 + fi + } + + require_job backend "$BACKEND_RESULT" "$BACKEND_REQUIRED" + require_job integration "$INTEGRATION_RESULT" "$BACKEND_REQUIRED" + require_job coverage "$COVERAGE_RESULT" "$BACKEND_REQUIRED" + require_job frontend "$FRONTEND_RESULT" "$FRONTEND_REQUIRED" + require_job docker_backend "$DOCKER_BACKEND_RESULT" "$DOCKER_BACKEND_REQUIRED" + require_job docker_frontend "$DOCKER_FRONTEND_RESULT" "$DOCKER_FRONTEND_REQUIRED" + require_job compose "$COMPOSE_RESULT" "$COMPOSE_REQUIRED" + require_job nginx "$NGINX_RESULT" "$NGINX_REQUIRED" + require_job dependency_review "$DEPENDENCY_RESULT" "$DEPENDENCY_REQUIRED" diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 0000000..bc0852a --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + +permissions: + contents: read + pull-requests: read + +jobs: + core: + name: PR 核心检查 + if: github.event.pull_request.draft == false + uses: ./.github/workflows/ci-core.yml + with: + pr_number: ${{ format('{0}', github.event.pull_request.number) }} + base_sha: ${{ github.event.pull_request.base.sha }} + head_sha: ${{ github.event.pull_request.head.sha }} + merge_sha: ${{ github.sha }} diff --git a/backend/unispeaking-server/.dockerignore b/backend/unispeaking-server/.dockerignore new file mode 100644 index 0000000..666bfbc --- /dev/null +++ b/backend/unispeaking-server/.dockerignore @@ -0,0 +1,5 @@ +target +.git +.idea +*.iml +logs diff --git a/backend/unispeaking-server/Dockerfile b/backend/unispeaking-server/Dockerfile index 36d5197..873268a 100644 --- a/backend/unispeaking-server/Dockerfile +++ b/backend/unispeaking-server/Dockerfile @@ -1,7 +1,10 @@ FROM eclipse-temurin:21-jdk AS build WORKDIR /workspace COPY . . -RUN ./mvnw package -DskipTests +RUN ./mvnw --batch-mode --no-transfer-progress \ + -DskipUnitTests \ + -DskipIntegrationTests \ + package FROM eclipse-temurin:21-jre WORKDIR /app diff --git a/frontend/Unispeaking_fronted/.dockerignore b/frontend/Unispeaking_fronted/.dockerignore index 16006f4..26fd83e 100644 --- a/frontend/Unispeaking_fronted/.dockerignore +++ b/frontend/Unispeaking_fronted/.dockerignore @@ -1,3 +1,5 @@ -dist node_modules -npm-debug.log* +dist +.git +.idea +*.log diff --git a/frontend/Unispeaking_fronted/Dockerfile b/frontend/Unispeaking_fronted/Dockerfile index 21429d6..0ac90ec 100644 --- a/frontend/Unispeaking_fronted/Dockerfile +++ b/frontend/Unispeaking_fronted/Dockerfile @@ -9,7 +9,9 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . -RUN npm run build +RUN npm run check:routes \ + && npm run check:realtime-events \ + && npm run build FROM nginx:1.27-alpine From f1c99baf25e51977353ecbeb9398893ef2216350 Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 31 Jul 2026 15:32:12 +0800 Subject: [PATCH 6/8] =?UTF-8?q?ci(refresh):=20=E5=AE=9E=E7=8E=B0=20main=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=90=8E=E7=9A=84=E5=BC=80=E6=94=BE=20PR=20?= =?UTF-8?q?=E9=87=8D=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 main 更新后轮询开放 PR 的最新 merge SHA,并跳过冲突分支。 复用 PR 并发组取消过期运行,以可信任务发布 CI / required 状态。 --- .github/workflows/ci-core.yml | 80 ++++++++++++++++++ .github/workflows/ci-refresh-pr.yml | 125 ++++++++++++++++++++++++++++ .github/workflows/ci-refresh.yml | 69 +++++++++++++++ .github/workflows/ci-status.yml | 109 ++++++++++++++++++++++++ 4 files changed, 383 insertions(+) create mode 100644 .github/workflows/ci-refresh-pr.yml create mode 100644 .github/workflows/ci-refresh.yml create mode 100644 .github/workflows/ci-status.yml diff --git a/.github/workflows/ci-core.yml b/.github/workflows/ci-core.yml index 967e18e..15c9bf6 100644 --- a/.github/workflows/ci-core.yml +++ b/.github/workflows/ci-core.yml @@ -19,6 +19,11 @@ on: description: GitHub-generated merge commit to test required: true type: string + publish_status: + description: Publish CI / required from the trusted main refresh path + required: false + type: boolean + default: false concurrency: group: ci-pr-${{ inputs.pr_number }} @@ -115,6 +120,36 @@ jobs: echo "dependencies=$dependencies" } >> "$GITHUB_OUTPUT" + - name: 记录本次待测对象 + env: + PR_NUMBER: ${{ inputs.pr_number }} + BASE_SHA: ${{ inputs.base_sha }} + HEAD_SHA: ${{ inputs.head_sha }} + MERGE_SHA: ${{ inputs.merge_sha }} + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/ci-metadata" + jq -n \ + --arg pr_number "$PR_NUMBER" \ + --arg base_sha "$BASE_SHA" \ + --arg head_sha "$HEAD_SHA" \ + --arg merge_sha "$MERGE_SHA" \ + '{ + pr_number: $pr_number, + base_sha: $base_sha, + head_sha: $head_sha, + merge_sha: $merge_sha + }' > "$RUNNER_TEMP/ci-metadata/metadata.json" + + - name: 保存待测对象元数据 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ci-metadata-${{ inputs.pr_number }}-${{ github.run_id }} + path: ${{ runner.temp }}/ci-metadata/metadata.json + if-no-files-found: error + retention-days: 1 + backend: name: 后端编译、单测与打包 needs: changes @@ -426,8 +461,13 @@ jobs: - dependency_review runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + statuses: write steps: - name: 汇总所有必要任务 + id: summary + continue-on-error: true env: CHANGES_RESULT: ${{ needs.changes.result }} BACKEND_RESULT: ${{ needs.backend.result }} @@ -478,3 +518,43 @@ jobs: require_job compose "$COMPOSE_RESULT" "$COMPOSE_REQUIRED" require_job nginx "$NGINX_RESULT" "$NGINX_REQUIRED" require_job dependency_review "$DEPENDENCY_RESULT" "$DEPENDENCY_REQUIRED" + + - name: 发布 main 更新重检状态 + if: always() && inputs.publish_status + env: + GH_TOKEN: ${{ github.token }} + MERGE_SHA: ${{ inputs.merge_sha }} + SUMMARY_OUTCOME: ${{ steps.summary.outcome }} + shell: bash + run: | + set -euo pipefail + state=failure + description="UniSpeaking 必要 CI 检查未通过" + if [[ "$SUMMARY_OUTCOME" == success ]]; then + state=success + description="UniSpeaking 必要 CI 检查已通过" + fi + + jq -n \ + --arg state "$state" \ + --arg context "CI / required" \ + --arg description "$description" \ + --arg target_url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{ + state: $state, + context: $context, + description: $description, + target_url: $target_url + }' > "$RUNNER_TEMP/status.json" + + curl --fail-with-body --silent --show-error \ + --request POST \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --data-binary "@$RUNNER_TEMP/status.json" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$MERGE_SHA" + + - name: 执行最终门禁 + if: always() && steps.summary.outcome != 'success' + run: exit 1 diff --git a/.github/workflows/ci-refresh-pr.yml b/.github/workflows/ci-refresh-pr.yml new file mode 100644 index 0000000..83f3060 --- /dev/null +++ b/.github/workflows/ci-refresh-pr.yml @@ -0,0 +1,125 @@ +name: CI Refresh PR + +on: + workflow_call: + inputs: + pr_number: + required: true + type: string + initial_head_sha: + required: true + type: string + main_sha: + required: true + type: string + +permissions: + contents: read + pull-requests: read + statuses: write + +jobs: + resolve: + name: 等待最新 merge SHA + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + runnable: ${{ steps.merge.outputs.runnable }} + base_sha: ${{ steps.merge.outputs.base_sha }} + head_sha: ${{ steps.merge.outputs.head_sha }} + merge_sha: ${{ steps.merge.outputs.merge_sha }} + steps: + - name: 等待 GitHub 生成最新合并提交 + id: merge + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + INITIAL_HEAD_SHA: ${{ inputs.initial_head_sha }} + MAIN_SHA: ${{ inputs.main_sha }} + shell: bash + run: | + set -euo pipefail + + api() { + curl --fail-with-body --silent --show-error \ + --location \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + echo "runnable=false" >> "$GITHUB_OUTPUT" + for attempt in {1..24}; do + echo "等待最新 merge SHA($attempt/24)" + pr_json=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + state=$(jq -r '.state' <<< "$pr_json") + draft=$(jq -r '.draft' <<< "$pr_json") + base_sha=$(jq -r '.base.sha' <<< "$pr_json") + head_sha=$(jq -r '.head.sha' <<< "$pr_json") + mergeable=$(jq -r '.mergeable // "unknown"' <<< "$pr_json") + + if [[ "$state" != open || "$draft" == true || "$head_sha" != "$INITIAL_HEAD_SHA" ]]; then + echo "PR 状态或 head 已变化,由对应事件启动的新 CI 接管。" + exit 0 + fi + if [[ "$base_sha" != "$MAIN_SHA" || "$mergeable" == unknown ]]; then + sleep 10 + continue + fi + if [[ "$mergeable" == false ]]; then + echo "PR 与最新 main 冲突,跳过测试。" + exit 0 + fi + + merge_ref=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/pull/$PR_NUMBER/merge") + merge_sha=$(jq -r '.object.sha' <<< "$merge_ref") + merge_commit=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/commits/$merge_sha") + first_parent=$(jq -r '.parents[0].sha' <<< "$merge_commit") + second_parent=$(jq -r '.parents[1].sha' <<< "$merge_commit") + + if [[ "$first_parent" != "$MAIN_SHA" || "$second_parent" != "$head_sha" ]]; then + sleep 10 + continue + fi + + { + echo "runnable=true" + echo "base_sha=$base_sha" + echo "head_sha=$head_sha" + echo "merge_sha=$merge_sha" + } >> "$GITHUB_OUTPUT" + + jq -n \ + --arg state pending \ + --arg context "CI / required" \ + --arg description "正在针对最新 main 重新检查" \ + --arg target_url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + '{ + state: $state, + context: $context, + description: $description, + target_url: $target_url + }' > "$RUNNER_TEMP/status.json" + + api \ + --request POST \ + --data-binary "@$RUNNER_TEMP/status.json" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$merge_sha" + exit 0 + done + + echo "GitHub 未在等待窗口内生成包含最新 main 的 merge SHA。" + exit 1 + + core: + name: 对最新 main 重检 + needs: resolve + if: needs.resolve.outputs.runnable == 'true' + uses: ./.github/workflows/ci-core.yml + with: + pr_number: ${{ inputs.pr_number }} + base_sha: ${{ needs.resolve.outputs.base_sha }} + head_sha: ${{ needs.resolve.outputs.head_sha }} + merge_sha: ${{ needs.resolve.outputs.merge_sha }} + publish_status: true diff --git a/.github/workflows/ci-refresh.yml b/.github/workflows/ci-refresh.yml new file mode 100644 index 0000000..112102e --- /dev/null +++ b/.github/workflows/ci-refresh.yml @@ -0,0 +1,69 @@ +name: CI Refresh + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + statuses: write + +jobs: + discover: + name: 查找开放 PR + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + prs: ${{ steps.prs.outputs.prs }} + steps: + - name: 查询以 main 为目标的开放 PR + id: prs + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + all='[]' + page=1 + while true; do + response=$(curl --fail-with-body --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls?state=open&base=main&per_page=100&page=$page") + all=$(jq -cn \ + --argjson all "$all" \ + --argjson batch "$response" \ + '$all + $batch') + count=$(jq 'length' <<< "$response") + if (( count < 100 )); then + break + fi + ((page += 1)) + done + + prs=$(jq -c \ + '[.[] | select(.draft == false) | { + number: (.number | tostring), + initial_head_sha: .head.sha + }]' <<< "$all") + echo "prs=$prs" >> "$GITHUB_OUTPUT" + + refresh: + name: 重检 PR + needs: discover + if: needs.discover.outputs.prs != '[]' + strategy: + fail-fast: false + max-parallel: 10 + matrix: + pr: ${{ fromJSON(needs.discover.outputs.prs) }} + uses: ./.github/workflows/ci-refresh-pr.yml + with: + pr_number: ${{ matrix.pr.number }} + initial_head_sha: ${{ matrix.pr.initial_head_sha }} + main_sha: ${{ github.sha }} diff --git a/.github/workflows/ci-status.yml b/.github/workflows/ci-status.yml new file mode 100644 index 0000000..fee4a2c --- /dev/null +++ b/.github/workflows/ci-status.yml @@ -0,0 +1,109 @@ +name: CI Status + +on: + workflow_run: + workflows: + - CI + types: + - completed + +permissions: + actions: read + contents: read + pull-requests: read + statuses: write + +jobs: + publish: + name: 发布 CI / required + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: 校验待测对象并发布状态 + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + shell: bash + run: | + set -euo pipefail + + api() { + curl --fail-with-body --silent --show-error \ + --location \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$@" + } + + run_json=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID") + pr_number=$(jq -r '.pull_requests[0].number // empty' <<< "$run_json") + if [[ -z "$pr_number" ]]; then + echo "运行未关联 PR,不发布状态。" + exit 0 + fi + + artifact_name="ci-metadata-$pr_number-$RUN_ID" + artifacts=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts?per_page=100") + artifact_url=$(jq -r \ + --arg name "$artifact_name" \ + '.artifacts[] | select(.name == $name and .expired == false) | .archive_download_url' \ + <<< "$artifacts" | head -n 1) + if [[ -z "$artifact_url" ]]; then + echo "未找到待测对象元数据;草稿、取消或初始化失败的运行不发布状态。" + exit 0 + fi + + mkdir -p "$RUNNER_TEMP/ci-metadata" + api "$artifact_url" > "$RUNNER_TEMP/ci-metadata.zip" + unzip -q "$RUNNER_TEMP/ci-metadata.zip" -d "$RUNNER_TEMP/ci-metadata" + metadata="$RUNNER_TEMP/ci-metadata/metadata.json" + + recorded_pr=$(jq -r '.pr_number' "$metadata") + recorded_base=$(jq -r '.base_sha' "$metadata") + recorded_head=$(jq -r '.head_sha' "$metadata") + recorded_merge=$(jq -r '.merge_sha' "$metadata") + if [[ "$recorded_pr" != "$pr_number" ]]; then + echo "元数据中的 PR 编号不匹配,拒绝发布。" + exit 1 + fi + + pr_json=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls/$pr_number") + current_base=$(jq -r '.base.sha' <<< "$pr_json") + current_head=$(jq -r '.head.sha' <<< "$pr_json") + current_merge=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/pull/$pr_number/merge" \ + | jq -r '.object.sha') + + if [[ "$recorded_base" != "$current_base" || + "$recorded_head" != "$current_head" || + "$recorded_merge" != "$current_merge" ]]; then + echo "检测到过期 CI 结果,不覆盖当前 PR 状态。" + exit 0 + fi + + state=failure + description="UniSpeaking 必要 CI 检查未通过" + if [[ "$RUN_CONCLUSION" == success ]]; then + state=success + description="UniSpeaking 必要 CI 检查已通过" + fi + + jq -n \ + --arg state "$state" \ + --arg context "CI / required" \ + --arg description "$description" \ + --arg target_url "$RUN_URL" \ + '{ + state: $state, + context: $context, + description: $description, + target_url: $target_url + }' > "$RUNNER_TEMP/status.json" + + api \ + --request POST \ + --data-binary "@$RUNNER_TEMP/status.json" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$recorded_merge" From aa4690bf2bb392ebc89cdfa5af5e6f9efd3c3f52 Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 31 Jul 2026 15:34:19 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(ci):=20=E5=AE=8C=E5=96=84=20CI=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E4=B8=8E=E5=88=86=E6=94=AF=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录 RFC 对应关系、运行方式、报告位置和首次启用步骤。 说明 main 分支保护、提交与 PR 规范,以及不发布和不部署的范围边界。 --- README.md | 20 ++++++----- docs/ci.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 docs/ci.md diff --git a/README.md b/README.md index dda0d8b..80174bb 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,7 @@ IELTS、英文面试、个人主页统计和会员页面目前主要完成了前 ├── deploy │ ├── docker-compose.yml │ ├── env -│ ├── nginx -│ └── postgres 独立建表脚本 +│ └── nginx ├── docs 架构、接口和部署文档 └── CLAUDE.md 后端架构与开发规范 ``` @@ -153,9 +152,10 @@ createdb -U postgres unispeaking CREATE DATABASE unispeaking; ``` -后端启动时会执行 -[`src/main/resources/db/schema.sql`](backend/unispeaking-server/src/main/resources/db/schema.sql) -创建或补齐当前表结构。运行账号必须具有建表和建索引权限。 +后端启动时由 Flyway 执行 +[`V1__baseline.sql`](backend/unispeaking-server/src/main/resources/db/migration/V1__baseline.sql) +创建或补齐当前表结构。新库直接执行 V1;已有数据库会先以版本 0 纳入管理,再执行 +幂等迁移并保留现有数据。运行账号必须具有建表、建索引和管理 Flyway 历史表的权限。 当前主要数据表: @@ -322,10 +322,13 @@ Browser <========= authenticated WebSocket ==========> Spring Boot ```bash cd backend/unispeaking-server -./mvnw test +./mvnw --batch-mode --no-transfer-progress clean verify +./mvnw --batch-mode --no-transfer-progress \ + -Pci-integration -DskipUnitTests verify ``` -当前测试基线:181 项测试通过。 +当前测试基线:186 项单元测试、7 项 PostgreSQL/Redis 容器集成测试通过,合并后的 +JaCoCo 全局行覆盖率为 73.80%。 前端: @@ -342,12 +345,13 @@ npm run check:realtime-events - [完整业务架构](docs/UniSpeaking架构设计(完整版).md) - [前后端接口文档](docs/frontend-backend-interface-contract.md) - [部署与配置](docs/deployment.md) +- [持续集成与分支保护](docs/ci.md) - [用户、场景与会话标识](docs/用户会话标识与用量归属流程.md) ## 当前边界 - PostgreSQL 是持久化真相来源;运行时代码只允许使用 MyBatis-Plus。 -- Redis 和消息队列尚未启用。 +- Redis 仅用于 CI 容器烟测,生产运行时和消息队列尚未启用。 - 自由聊天内容不进入长期存储。 - 自定义场景、学习内容、逐轮评分和会话报告写入 PostgreSQL。 - IELTS、面试、个人统计和会员功能尚需继续开发后端接口。 diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..9ecff5a --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,103 @@ +# UniSpeaking 持续集成与分支保护 + +本文档对应 RFC《UniSpeaking 最小可用 CI 方案》,说明第一阶段 CI 的运行方式、 +报告位置和仓库设置。CI 只证明代码可以安全合并,不发布制品、不部署环境,也不把 +Redis 引入生产运行时。 + +## 工作流 + +| 文件 | 职责 | +| --- | --- | +| `ci-pr.yml` | 在 PR 创建、重新打开、新提交和转为 Ready 时测试 GitHub merge commit | +| `ci-core.yml` | 按变更范围运行后端、前端、Docker、Compose、Nginx 和依赖检查 | +| `ci-refresh.yml` | `main` 更新后查找全部开放 PR,并行发起重检 | +| `ci-refresh-pr.yml` | 等待包含最新 `main` 的 merge SHA,跳过冲突或已变化的 PR | +| `ci-status.yml` | 在可信上下文校验当前 base、head、merge SHA 后发布 `CI / required` | + +同一 PR 的核心检查使用 `ci-pr-` 并发组。出现新提交或 `main` 更新时,旧检查 +会自动取消;不同 PR 可以并行执行。PR 工作流只使用只读权限,不接收仓库 Secrets。 +状态写入任务不检出或执行 PR 代码,过期结果不会覆盖当前 merge SHA。 + +工作流文件本身发生变化时会强制运行全部检查。其他变更按路径执行: + +- 后端:编译、186 项单元测试、打包、PostgreSQL/Redis 集成测试、70% 行覆盖率和镜像构建; +- 前端:Node.js 22、`npm ci`、路由与 Realtime 事件检查、生产构建和镜像构建; +- Compose、环境模板或 Nginx:配置解析或 `nginx -t`; +- Maven/npm 依赖文件:Dependency Review,High 和 Critical 阻止合并。 + +## 本地复现 + +后端单元测试、打包和覆盖率数据: + +```bash +cd backend/unispeaking-server +./mvnw --batch-mode --no-transfer-progress clean verify +``` + +PostgreSQL 与 Redis 集成测试: + +```bash +./mvnw --batch-mode --no-transfer-progress \ + -Pci-integration -DskipUnitTests verify +``` + +合并两类测试的 JaCoCo 数据并执行 70% 门禁: + +```bash +./mvnw --batch-mode --no-transfer-progress \ + -Pcoverage-aggregate \ + -DskipUnitTests \ + -DskipIntegrationTests \ + verify +``` + +集成测试使用 Testcontainers,需要本机 Docker 可用;测试结束后容器会自动清理。 + +前端: + +```bash +cd frontend/Unispeaking_fronted +npm ci +npm run check:routes +npm run check:realtime-events +npm run build +``` + +## 报告 + +GitHub Actions 在对应运行的 Artifacts 中保留以下内容 30 天: + +- Surefire 单元测试报告和必要日志; +- Failsafe 集成测试报告和必要日志; +- JaCoCo 单元、集成及合并覆盖率报告。 + +PR 不上传后端 JAR、前端 `dist` 或 Docker 镜像。用于在任务间合并覆盖率和校验 merge SHA +的临时数据只保留 1 天。 + +## 首次启用与分支保护 + +新增工作流的首个 PR 合并前,默认分支尚不存在可供 Ruleset 选择的 +`CI / required` 状态。首次启用按以下顺序完成: + +1. 审核并合入工作流; +2. 创建一个验证 PR,确认 `CI / required` 首次成功; +3. 在仓库 Settings → Rules → Rulesets 中为 `main` 创建规则; +4. 要求通过 PR、至少一人批准,并把 `CI / required` 设为必需状态; +5. 禁止直接 Push、禁止绕过失败门禁,并要求分支无冲突后才能合并; +6. 分别验证新提交取消旧运行、`main` 更新重检、冲突跳过和条件任务。 + +Ruleset 只绑定稳定状态 `CI / required`,不绑定可能调整名称的内部 Job。配置规则需要 +仓库管理员权限,且必须在默认分支存在工作流并产生首个成功状态后完成。 + +## 提交与 PR + +CI 实现提交采用 Conventional Commit 结构,标题与正文均使用中文说明: + +```text +(): <中文摘要> + +<说明做了什么、原因以及必要的验证或兼容性影响> +``` + +PR 正文应关联《UniSpeaking 最小可用 CI 方案》,简述“做了什么、怎么做、验证结果和 +范围说明”,总长度不超过 1500 字符;不粘贴完整日志或重复 RFC 全文。 From 763da089614bd70debd38f02dc9f5d973db8f52b Mon Sep 17 00:00:00 2001 From: pionxe Date: Fri, 31 Jul 2026 16:10:59 +0800 Subject: [PATCH 8/8] =?UTF-8?q?docs(backend):=20=E6=8F=90=E9=AB=98?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=94=B9=E5=8A=A8=E7=9A=84=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E8=A6=81=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 要求每次修改生产代码时同步补齐测试,并将开发质量标准提高到 80% 行覆盖率。 保留 CI 70% 过渡门槛,待测试基线稳定后再单独调整。 --- CLAUDE.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b46b0ce..62230e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -527,12 +527,26 @@ src/test/java/com/unispeaking/{same-package} - 真实供应商手工测试不放在默认 `src/test`,避免依赖密钥、网络和本机文件。 - 未引用夹具、旧包路径测试和已删除功能测试必须同步删除。 - Bug 修复必须添加能复现问题的回归测试。 +- 每次修改生产代码时,必须同步新增或更新对应测试文件,覆盖新增行为、受影响分支和 + 兼容性边界;不得以“改动较小”或“后续补测”为由只提交实现代码。 +- 后端自动化测试的全局行覆盖率不得低于 80%。启动类、纯 DTO、简单属性绑定类和明确 + 生成代码可以按 CI 约定排除,Service、Controller、Repository、Provider、鉴权、 + 会话、评分和状态机等核心业务代码不得通过排除规则规避覆盖率要求。 +- 当前 CI 的强制门槛暂时保持为 70%,用于降低建设初期的协作阻力。这是过渡性自动 + 门禁,不代表开发质量标准降低;测试基线稳定达到 80% 后,再单独调整 CI 阈值。 提交前至少执行: ```bash cd backend/unispeaking-server -./mvnw test +./mvnw --batch-mode --no-transfer-progress clean verify +./mvnw --batch-mode --no-transfer-progress \ + -Pci-integration -DskipUnitTests verify +./mvnw --batch-mode --no-transfer-progress \ + -Pcoverage-aggregate \ + -DskipUnitTests \ + -DskipIntegrationTests \ + verify ``` 修改前端接口契约时,还须执行前端构建和路由/实时协议检查。 @@ -559,5 +573,6 @@ cd backend/unispeaking-server - 持久化仅使用 MyBatis-Plus,复合主键使用完整条件。 - 认证、授权、输入校验、超时和错误转换完整。 - 配置无真实密钥,日志无敏感数据。 -- 新增行为有测试,默认测试不依赖外网或真实账号。 -- `./mvnw test` 全部通过。 +- 所有生产代码改动均同步补齐测试,默认测试不依赖外网或真实账号。 +- 全局行覆盖率达到 80% 的开发质量标准;CI 在过渡期仍按 70% 自动门禁执行。 +- 单元测试、容器集成测试和覆盖率检查全部通过。