feat: 完成后端基础功能开发,包括用户注册、问卷调查等功能

This commit is contained in:
胡海星 2025-02-23 11:49:13 +08:00
parent 20c57a13d2
commit d9c64cb28f
48 changed files with 4672 additions and 105 deletions

View File

@ -59,4 +59,17 @@
## 10. 版本控制 ## 10. 版本控制
- 使用Git进行版本控制 - 使用Git进行版本控制
- 遵循语义化版本规范 - 遵循语义化版本规范
- 重要配置文件加入版本控制 - 重要配置文件加入版本控制
## 11. 文件操作
- 当需要复制文件时,使用 `command cp` 命令
- 如果发现目录不存在,首先确认自己当前目录是否正确
- 如果需要创建目录,使用 `command mkdir -p` 命令
- 不要尝试重新安装开发依赖工具比如jdk, nodepython等
## 前端开发
- 前端使用vue3开发
- 代码必须严格遵守 eslint 规则
- 前端项目使用yarn打包用最新的4.x版
- 前端项目根目录下需要有`.yarn.yml`配置文件
- 前端启动开发服务器,需要把切换到前端目录以及启动开发服务器两个命令合并执行

View File

@ -83,6 +83,11 @@
<artifactId>jackson-databind</artifactId> <artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version> <version>${jackson.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- PageHelper --> <!-- PageHelper -->
<dependency> <dependency>

View File

@ -1,51 +0,0 @@
package com.fasterxml.jackson.databind;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
/**
* 自定义的 JSON 类型处理器
*/
public class JsonTypeHandler extends BaseTypeHandler<Object> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType)
throws SQLException {
try {
ps.setString(i, MAPPER.writeValueAsString(parameter));
} catch (Exception e) {
throw new SQLException("Error converting JSON to String", e);
}
}
@Override
public Object getNullableResult(ResultSet rs, String columnName) throws SQLException {
return parse(rs.getString(columnName));
}
@Override
public Object getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return parse(rs.getString(columnIndex));
}
@Override
public Object getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return parse(cs.getString(columnIndex));
}
private Object parse(String json) throws SQLException {
try {
if (json == null || json.isEmpty()) {
return null;
}
return MAPPER.readValue(json, Object.class);
} catch (Exception e) {
throw new SQLException("Error converting String to JSON", e);
}
}
}

View File

@ -0,0 +1,39 @@
package ltd.qubit.survey.common.mybatis;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
/**
* Instant类型处理器
*/
public class InstantTypeHandler extends BaseTypeHandler<Instant> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Instant parameter, JdbcType jdbcType)
throws SQLException {
ps.setTimestamp(i, Timestamp.from(parameter));
}
@Override
public Instant getNullableResult(ResultSet rs, String columnName) throws SQLException {
Timestamp timestamp = rs.getTimestamp(columnName);
return timestamp != null ? timestamp.toInstant() : null;
}
@Override
public Instant getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
Timestamp timestamp = rs.getTimestamp(columnIndex);
return timestamp != null ? timestamp.toInstant() : null;
}
@Override
public Instant getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
Timestamp timestamp = cs.getTimestamp(columnIndex);
return timestamp != null ? timestamp.toInstant() : null;
}
}

View File

@ -1,6 +1,6 @@
package ltd.qubit.survey.model; package ltd.qubit.survey.model;
import java.time.LocalDateTime; import java.time.Instant;
import lombok.Data; import lombok.Data;
/** /**
@ -36,5 +36,5 @@ public class Option {
/** /**
* 创建时间 * 创建时间
*/ */
private LocalDateTime createdAt; private Instant createdAt;
} }

View File

@ -1,6 +1,6 @@
package ltd.qubit.survey.model; package ltd.qubit.survey.model;
import java.time.LocalDateTime; import java.time.Instant;
import lombok.Data; import lombok.Data;
/** /**
@ -46,5 +46,5 @@ public class Question {
/** /**
* 创建时间 * 创建时间
*/ */
private LocalDateTime createdAt; private Instant createdAt;
} }

View File

@ -1,6 +1,6 @@
package ltd.qubit.survey.model; package ltd.qubit.survey.model;
import java.time.LocalDateTime; import java.time.Instant;
import java.util.List; import java.util.List;
import lombok.Data; import lombok.Data;
@ -37,5 +37,5 @@ public class SurveyResponse {
/** /**
* 创建时间 * 创建时间
*/ */
private LocalDateTime createdAt; private Instant createdAt;
} }

View File

@ -1,6 +1,6 @@
package ltd.qubit.survey.model; package ltd.qubit.survey.model;
import java.time.LocalDateTime; import java.time.Instant;
import lombok.Data; import lombok.Data;
/** /**
@ -36,5 +36,5 @@ public class User {
/** /**
* 创建时间 * 创建时间
*/ */
private LocalDateTime createdAt; private Instant createdAt;
} }

View File

@ -1,6 +1,6 @@
package ltd.qubit.survey.service.impl; package ltd.qubit.survey.service.impl;
import java.time.LocalDateTime; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -19,7 +19,7 @@ public class OptionServiceImpl implements OptionService {
@Override @Override
public Option create(Option option) { public Option create(Option option) {
option.setCreatedAt(LocalDateTime.now()); option.setCreatedAt(Instant.now());
optionDao.insert(option); optionDao.insert(option);
return option; return option;
} }
@ -58,7 +58,7 @@ public class OptionServiceImpl implements OptionService {
@Override @Override
public List<Option> batchCreate(List<Option> options) { public List<Option> batchCreate(List<Option> options) {
// 设置创建时间 // 设置创建时间
LocalDateTime now = LocalDateTime.now(); Instant now = Instant.now();
options.forEach(option -> option.setCreatedAt(now)); options.forEach(option -> option.setCreatedAt(now));
// 批量插入 // 批量插入

View File

@ -2,7 +2,7 @@ package ltd.qubit.survey.service.impl;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -32,7 +32,7 @@ public class QuestionServiceImpl implements QuestionService {
if (question.getQuestionNumber() == null) { if (question.getQuestionNumber() == null) {
question.setQuestionNumber(questionDao.getNextQuestionNumber()); question.setQuestionNumber(questionDao.getNextQuestionNumber());
} }
question.setCreatedAt(LocalDateTime.now()); question.setCreatedAt(Instant.now());
questionDao.insert(question); questionDao.insert(question);
return question; return question;
} }

View File

@ -1,6 +1,6 @@
package ltd.qubit.survey.service.impl; package ltd.qubit.survey.service.impl;
import java.time.LocalDateTime; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -22,7 +22,7 @@ public class SurveyResponseServiceImpl implements SurveyResponseService {
@Override @Override
public SurveyResponse create(SurveyResponse response) { public SurveyResponse create(SurveyResponse response) {
response.setCreatedAt(LocalDateTime.now()); response.setCreatedAt(Instant.now());
surveyResponseDao.insert(response); surveyResponseDao.insert(response);
return response; return response;
} }
@ -66,7 +66,7 @@ public class SurveyResponseServiceImpl implements SurveyResponseService {
@Override @Override
public List<SurveyResponse> batchSave(List<SurveyResponse> responses) { public List<SurveyResponse> batchSave(List<SurveyResponse> responses) {
// 设置创建时间 // 设置创建时间
LocalDateTime now = LocalDateTime.now(); Instant now = Instant.now();
responses.forEach(response -> response.setCreatedAt(now)); responses.forEach(response -> response.setCreatedAt(now));
// 批量插入 // 批量插入
@ -98,7 +98,7 @@ public class SurveyResponseServiceImpl implements SurveyResponseService {
deleteByUserId(userId); deleteByUserId(userId);
// 设置用户ID和创建时间 // 设置用户ID和创建时间
LocalDateTime now = LocalDateTime.now(); Instant now = Instant.now();
responses.forEach(response -> { responses.forEach(response -> {
response.setUserId(userId); response.setUserId(userId);
response.setCreatedAt(now); response.setCreatedAt(now);

View File

@ -1,6 +1,6 @@
package ltd.qubit.survey.service.impl; package ltd.qubit.survey.service.impl;
import java.time.LocalDateTime; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -20,7 +20,7 @@ public class UserServiceImpl implements UserService {
@Override @Override
public User create(User user) { public User create(User user) {
user.setCreatedAt(LocalDateTime.now()); user.setCreatedAt(Instant.now());
userDao.insert(user); userDao.insert(user);
return user; return user;
} }

View File

@ -0,0 +1,29 @@
package ltd.qubit.survey.utils;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.ZoneOffset;
/**
* 自定义的ObjectMapper支持Java 8日期时间类型
*/
public class CustomObjectMapper extends ObjectMapper {
public CustomObjectMapper() {
super();
// 注册Java 8日期时间模块
JavaTimeModule javaTimeModule = new JavaTimeModule();
registerModule(javaTimeModule);
// 配置序列化特性
setSerializationInclusion(JsonInclude.Include.NON_NULL);
// 将日期序列化为时间戳毫秒
configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true);
configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// 设置默认时区为UTC
setTimeZone(java.util.TimeZone.getTimeZone(ZoneOffset.UTC));
}
}

View File

@ -6,7 +6,7 @@
<id property="id" column="id"/> <id property="id" column="id"/>
<result property="userId" column="user_id"/> <result property="userId" column="user_id"/>
<result property="questionId" column="question_id"/> <result property="questionId" column="question_id"/>
<result property="selectedOptions" column="selected_options" typeHandler="com.fasterxml.jackson.databind.JsonTypeHandler"/> <result property="selectedOptions" column="selected_options" typeHandler="ltd.qubit.survey.common.mybatis.JsonTypeHandler"/>
<result property="textAnswer" column="text_answer"/> <result property="textAnswer" column="text_answer"/>
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
</resultMap> </resultMap>
@ -19,7 +19,7 @@
<!-- 插入 --> <!-- 插入 -->
<insert id="insert" parameterType="ltd.qubit.survey.model.SurveyResponse" useGeneratedKeys="true" keyProperty="id"> <insert id="insert" parameterType="ltd.qubit.survey.model.SurveyResponse" useGeneratedKeys="true" keyProperty="id">
INSERT INTO survey_responses (user_id, question_id, selected_options, text_answer) INSERT INTO survey_responses (user_id, question_id, selected_options, text_answer)
VALUES (#{userId}, #{questionId}, #{selectedOptions,typeHandler=com.fasterxml.jackson.databind.JsonTypeHandler}, #{textAnswer}) VALUES (#{userId}, #{questionId}, #{selectedOptions,typeHandler=ltd.qubit.survey.common.mybatis.JsonTypeHandler}, #{textAnswer})
</insert> </insert>
<!-- 批量插入 --> <!-- 批量插入 -->
@ -28,7 +28,7 @@
VALUES VALUES
<foreach collection="list" item="item" separator=","> <foreach collection="list" item="item" separator=",">
(#{item.userId}, #{item.questionId}, (#{item.userId}, #{item.questionId},
#{item.selectedOptions,typeHandler=com.fasterxml.jackson.databind.JsonTypeHandler}, #{item.selectedOptions,typeHandler=ltd.qubit.survey.common.mybatis.JsonTypeHandler},
#{item.textAnswer}) #{item.textAnswer})
</foreach> </foreach>
</insert> </insert>
@ -38,7 +38,7 @@
UPDATE survey_responses UPDATE survey_responses
SET user_id = #{userId}, SET user_id = #{userId},
question_id = #{questionId}, question_id = #{questionId},
selected_options = #{selectedOptions,typeHandler=com.fasterxml.jackson.databind.JsonTypeHandler}, selected_options = #{selectedOptions,typeHandler=ltd.qubit.survey.common.mybatis.JsonTypeHandler},
text_answer = #{textAnswer} text_answer = #{textAnswer}
WHERE id = #{id} WHERE id = #{id}
</update> </update>

View File

@ -29,6 +29,9 @@
<!-- JSON类型处理器 --> <!-- JSON类型处理器 -->
<typeHandler handler="ltd.qubit.survey.common.mybatis.JsonTypeHandler" <typeHandler handler="ltd.qubit.survey.common.mybatis.JsonTypeHandler"
javaType="java.util.List"/> javaType="java.util.List"/>
<!-- Instant类型处理器 -->
<typeHandler handler="ltd.qubit.survey.common.mybatis.InstantTypeHandler"
javaType="java.time.Instant"/>
</typeHandlers> </typeHandlers>
<!-- 映射器配置 --> <!-- 映射器配置 -->

View File

@ -16,18 +16,8 @@
<!-- 加载属性文件 --> <!-- 加载属性文件 -->
<context:property-placeholder location="classpath:application.properties"/> <context:property-placeholder location="classpath:application.properties"/>
<!-- 配置ObjectMapper --> <!-- 配置自定义ObjectMapper -->
<bean id="objectMapper" class="com.fasterxml.jackson.databind.ObjectMapper" primary="true"> <bean id="objectMapper" class="ltd.qubit.survey.utils.CustomObjectMapper" primary="true"/>
<property name="dateFormat">
<bean class="java.text.SimpleDateFormat">
<constructor-arg value="yyyy-MM-dd HH:mm:ss"/>
</bean>
</property>
<!-- 配置JSON序列化特性 -->
<property name="serializationInclusion">
<value type="com.fasterxml.jackson.annotation.JsonInclude.Include">NON_NULL</value>
</property>
</bean>
<!-- 开启注解扫描排除Controller --> <!-- 开启注解扫描排除Controller -->
<context:component-scan base-package="ltd.qubit.survey"> <context:component-scan base-package="ltd.qubit.survey">

View File

@ -6,7 +6,7 @@
<id property="id" column="id"/> <id property="id" column="id"/>
<result property="userId" column="user_id"/> <result property="userId" column="user_id"/>
<result property="questionId" column="question_id"/> <result property="questionId" column="question_id"/>
<result property="selectedOptions" column="selected_options" typeHandler="com.fasterxml.jackson.databind.JsonTypeHandler"/> <result property="selectedOptions" column="selected_options" typeHandler="ltd.qubit.survey.common.mybatis.JsonTypeHandler"/>
<result property="textAnswer" column="text_answer"/> <result property="textAnswer" column="text_answer"/>
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
</resultMap> </resultMap>
@ -19,7 +19,7 @@
<!-- 插入 --> <!-- 插入 -->
<insert id="insert" parameterType="ltd.qubit.survey.model.SurveyResponse" useGeneratedKeys="true" keyProperty="id"> <insert id="insert" parameterType="ltd.qubit.survey.model.SurveyResponse" useGeneratedKeys="true" keyProperty="id">
INSERT INTO survey_responses (user_id, question_id, selected_options, text_answer) INSERT INTO survey_responses (user_id, question_id, selected_options, text_answer)
VALUES (#{userId}, #{questionId}, #{selectedOptions,typeHandler=com.fasterxml.jackson.databind.JsonTypeHandler}, #{textAnswer}) VALUES (#{userId}, #{questionId}, #{selectedOptions,typeHandler=ltd.qubit.survey.common.mybatis.JsonTypeHandler}, #{textAnswer})
</insert> </insert>
<!-- 批量插入 --> <!-- 批量插入 -->
@ -28,7 +28,7 @@
VALUES VALUES
<foreach collection="list" item="item" separator=","> <foreach collection="list" item="item" separator=",">
(#{item.userId}, #{item.questionId}, (#{item.userId}, #{item.questionId},
#{item.selectedOptions,typeHandler=com.fasterxml.jackson.databind.JsonTypeHandler}, #{item.selectedOptions,typeHandler=ltd.qubit.survey.common.mybatis.JsonTypeHandler},
#{item.textAnswer}) #{item.textAnswer})
</foreach> </foreach>
</insert> </insert>
@ -38,7 +38,7 @@
UPDATE survey_responses UPDATE survey_responses
SET user_id = #{userId}, SET user_id = #{userId},
question_id = #{questionId}, question_id = #{questionId},
selected_options = #{selectedOptions,typeHandler=com.fasterxml.jackson.databind.JsonTypeHandler}, selected_options = #{selectedOptions,typeHandler=ltd.qubit.survey.common.mybatis.JsonTypeHandler},
text_answer = #{textAnswer} text_answer = #{textAnswer}
WHERE id = #{id} WHERE id = #{id}
</update> </update>

View File

@ -29,6 +29,9 @@
<!-- JSON类型处理器 --> <!-- JSON类型处理器 -->
<typeHandler handler="ltd.qubit.survey.common.mybatis.JsonTypeHandler" <typeHandler handler="ltd.qubit.survey.common.mybatis.JsonTypeHandler"
javaType="java.util.List"/> javaType="java.util.List"/>
<!-- Instant类型处理器 -->
<typeHandler handler="ltd.qubit.survey.common.mybatis.InstantTypeHandler"
javaType="java.time.Instant"/>
</typeHandlers> </typeHandlers>
<!-- 映射器配置 --> <!-- 映射器配置 -->

View File

@ -16,18 +16,8 @@
<!-- 加载属性文件 --> <!-- 加载属性文件 -->
<context:property-placeholder location="classpath:application.properties"/> <context:property-placeholder location="classpath:application.properties"/>
<!-- 配置ObjectMapper --> <!-- 配置自定义ObjectMapper -->
<bean id="objectMapper" class="com.fasterxml.jackson.databind.ObjectMapper" primary="true"> <bean id="objectMapper" class="ltd.qubit.survey.utils.CustomObjectMapper" primary="true"/>
<property name="dateFormat">
<bean class="java.text.SimpleDateFormat">
<constructor-arg value="yyyy-MM-dd HH:mm:ss"/>
</bean>
</property>
<!-- 配置JSON序列化特性 -->
<property name="serializationInclusion">
<value type="com.fasterxml.jackson.annotation.JsonInclude.Include">NON_NULL</value>
</property>
</bean>
<!-- 开启注解扫描排除Controller --> <!-- 开启注解扫描排除Controller -->
<context:component-scan base-package="ltd.qubit.survey"> <context:component-scan base-package="ltd.qubit.survey">

View File

@ -1,3 +1,4 @@
ltd/qubit/survey/common/mybatis/InstantTypeHandler.class
ltd/qubit/survey/controller/GlobalExceptionHandler.class ltd/qubit/survey/controller/GlobalExceptionHandler.class
ltd/qubit/survey/dao/QuestionDao.class ltd/qubit/survey/dao/QuestionDao.class
ltd/qubit/survey/model/PositionType.class ltd/qubit/survey/model/PositionType.class
@ -10,6 +11,7 @@ ltd/qubit/survey/controller/QuestionController.class
ltd/qubit/survey/service/impl/UserServiceImpl.class ltd/qubit/survey/service/impl/UserServiceImpl.class
ltd/qubit/survey/service/impl/QuestionServiceImpl.class ltd/qubit/survey/service/impl/QuestionServiceImpl.class
ltd/qubit/survey/model/SurveyResponse.class ltd/qubit/survey/model/SurveyResponse.class
ltd/qubit/survey/utils/CustomObjectMapper.class
ltd/qubit/survey/model/User.class ltd/qubit/survey/model/User.class
ltd/qubit/survey/service/QuestionService.class ltd/qubit/survey/service/QuestionService.class
ltd/qubit/survey/common/mybatis/JsonTypeHandler.class ltd/qubit/survey/common/mybatis/JsonTypeHandler.class
@ -26,4 +28,3 @@ ltd/qubit/survey/service/SurveyResponseService.class
ltd/qubit/survey/model/Option.class ltd/qubit/survey/model/Option.class
ltd/qubit/survey/model/QuestionType.class ltd/qubit/survey/model/QuestionType.class
ltd/qubit/survey/service/BaseService.class ltd/qubit/survey/service/BaseService.class
com/fasterxml/jackson/databind/JsonTypeHandler.class

View File

@ -8,8 +8,8 @@
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/User.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/User.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/service/impl/OptionServiceImpl.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/service/impl/OptionServiceImpl.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/service/impl/QuestionServiceImpl.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/service/impl/QuestionServiceImpl.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/common/mybatis/InstantTypeHandler.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/service/SurveyResponseService.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/service/SurveyResponseService.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/com/fasterxml/jackson/databind/JsonTypeHandler.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/Option.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/Option.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/controller/GlobalExceptionHandler.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/controller/GlobalExceptionHandler.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/QuestionType.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/QuestionType.java
@ -22,6 +22,7 @@
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/dao/SurveyResponseDao.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/dao/SurveyResponseDao.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/controller/QuestionController.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/controller/QuestionController.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/WorkArea.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/WorkArea.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/utils/CustomObjectMapper.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/PositionType.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/model/PositionType.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/dao/BaseDao.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/dao/BaseDao.java
/Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/controller/SurveyController.java /Volumes/working/qubit/project/llm-survey/backend/src/main/java/ltd/qubit/survey/controller/SurveyController.java

18
frontend/.eslintrc.js Normal file
View File

@ -0,0 +1,18 @@
/* eslint-env node */
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-prettier',
],
parserOptions: {
ecmaVersion: 'latest',
},
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-model-argument': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
};

7
frontend/.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"printWidth": 100,
"trailingComma": "es5"
}

Binary file not shown.

2
frontend/.yarnrc.yml Normal file
View File

@ -0,0 +1,2 @@
nodeLinker: node-modules
enableGlobalCache: true

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover">
<title>LLM 问卷调查</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

38
frontend/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "llm-survey",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.6.7",
"pinia": "^2.1.7",
"vant": "^4.8.3",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^16.0.0",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-prettier": "^9.0.0",
"esbuild": "^0.25.0",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.21.1",
"less": "^4.2.0",
"postcss-px-to-viewport": "^1.1.1",
"prettier": "^3.2.5",
"rollup": "^4.34.8",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.0.12"
},
"packageManager": "yarn@4.6.0"
}

27
frontend/src/App.vue Normal file
View File

@ -0,0 +1,27 @@
<template>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</template>
<script setup>
//
</script>
<style>
/* 全局样式 */
html,
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100%;
}
#app {
height: 100%;
}
</style>

View File

@ -0,0 +1,47 @@
import request from '@/utils/request';
// 获取用户的问题列表
export function getUserQuestions(userId) {
return request({
url: `/question/user/${userId}`,
method: 'get',
});
}
// 获取问题的选项列表
export function getQuestionOptions(questionId) {
return request({
url: `/question/${questionId}/option`,
method: 'get',
});
}
// 获取下一个问题
export function getNextQuestion(userId, currentQuestionNumber, selectedOptions) {
return request({
url: '/question/next',
method: 'get',
params: {
userId,
currentQuestionNumber,
selectedOptions,
},
});
}
// 提交问卷答案
export function submitSurvey(userId, responses) {
return request({
url: `/survey/submit/${userId}`,
method: 'post',
data: responses,
});
}
// 获取用户的问卷答案
export function getUserResponses(userId) {
return request({
url: `/survey/user/${userId}`,
method: 'get',
});
}

26
frontend/src/api/user.js Normal file
View File

@ -0,0 +1,26 @@
import request from '@/utils/request';
// 用户注册
export function register(data) {
return request({
url: '/user/register',
method: 'post',
data,
});
}
// 检查手机号是否已注册
export function checkPhone(phone) {
return request({
url: `/user/check/${phone}`,
method: 'get',
});
}
// 根据ID获取用户信息
export function getUserInfo(id) {
return request({
url: `/user/${id}`,
method: 'get',
});
}

12
frontend/src/main.js Normal file
View File

@ -0,0 +1,12 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import 'vant/lib/index.css';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');

View File

@ -0,0 +1,43 @@
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/register',
name: 'register',
component: () => import('@/views/RegisterView.vue'),
},
{
path: '/survey',
name: 'survey',
component: () => import('@/views/SurveyView.vue'),
meta: { requiresAuth: true },
},
],
});
// 路由守卫
router.beforeEach(async (to, from, next) => {
const userId = localStorage.getItem('userId');
// 如果是从注册页面来的,允许直接进入需要认证的页面
if (from.name === 'register' && to.meta.requiresAuth) {
next();
return;
}
// 其他情况下检查认证
if (to.meta.requiresAuth && !userId) {
next({ name: 'register' });
} else {
next();
}
});
export default router;

View File

@ -0,0 +1,105 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import {
getUserQuestions,
getQuestionOptions,
getNextQuestion,
submitSurvey,
getUserResponses
} from '@/api/survey';
import { showToast } from 'vant';
export const useSurveyStore = defineStore('survey', () => {
const currentQuestion = ref(null);
const questionOptions = ref([]);
const userResponses = ref([]);
const currentQuestionNumber = ref(0);
const isCompleted = ref(false);
// 获取用户的问题列表
async function fetchUserQuestions(userId) {
try {
const response = await getUserQuestions(userId);
return response.data;
} catch (error) {
showToast('获取问题列表失败:' + error.message);
throw error;
}
}
// 获取问题的选项列表
async function fetchQuestionOptions(questionId) {
try {
const response = await getQuestionOptions(questionId);
questionOptions.value = response.data;
return response.data;
} catch (error) {
showToast('获取问题选项失败:' + error.message);
throw error;
}
}
// 获取下一个问题
async function fetchNextQuestion(userId, selectedOptions) {
try {
const response = await getNextQuestion(userId, currentQuestionNumber.value, selectedOptions);
if (response.data) {
currentQuestion.value = response.data;
currentQuestionNumber.value++;
await fetchQuestionOptions(response.data.id);
} else {
isCompleted.value = true;
}
return response.data;
} catch (error) {
showToast('获取下一个问题失败:' + error.message);
throw error;
}
}
// 提交问卷答案
async function submitSurveyResponses(userId, responses) {
try {
const response = await submitSurvey(userId, responses);
userResponses.value = responses;
return response.data;
} catch (error) {
showToast('提交问卷失败:' + error.message);
throw error;
}
}
// 获取用户的问卷答案
async function fetchUserResponses(userId) {
try {
const response = await getUserResponses(userId);
userResponses.value = response.data;
return response.data;
} catch (error) {
showToast('获取问卷答案失败:' + error.message);
throw error;
}
}
// 重置问卷状态
function resetSurvey() {
currentQuestion.value = null;
questionOptions.value = [];
currentQuestionNumber.value = 0;
isCompleted.value = false;
}
return {
currentQuestion,
questionOptions,
userResponses,
currentQuestionNumber,
isCompleted,
fetchUserQuestions,
fetchQuestionOptions,
fetchNextQuestion,
submitSurveyResponses,
fetchUserResponses,
resetSurvey,
};
});

View File

@ -0,0 +1,61 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { register, checkPhone, getUserInfo } from '@/api/user';
import { showToast } from 'vant';
export const useUserStore = defineStore('user', () => {
const userId = ref(localStorage.getItem('userId') || '');
const userInfo = ref(null);
// 注册用户
async function registerUser(data) {
try {
const response = await register(data);
userId.value = response.data.id;
localStorage.setItem('userId', userId.value);
return response;
} catch (error) {
showToast('注册失败:' + error.message);
throw error;
}
}
// 检查手机号是否已注册
async function checkPhoneNumber(phone) {
try {
const response = await checkPhone(phone);
return response.data;
} catch (error) {
showToast('检查手机号失败:' + error.message);
throw error;
}
}
// 获取用户信息
async function fetchUserInfo(id) {
try {
const response = await getUserInfo(id);
userInfo.value = response.data;
return response.data;
} catch (error) {
showToast('获取用户信息失败:' + error.message);
throw error;
}
}
// 退出登录
function logout() {
userId.value = '';
userInfo.value = null;
localStorage.removeItem('userId');
}
return {
userId,
userInfo,
registerUser,
checkPhoneNumber,
fetchUserInfo,
logout,
};
});

View File

@ -0,0 +1,32 @@
import axios from 'axios';
import { showToast } from 'vant';
const request = axios.create({
baseURL: '/api',
timeout: 10000,
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 可以在这里添加 token 等认证信息
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
request.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
const message = error.response?.data?.message || '请求失败,请稍后重试';
showToast(message);
return Promise.reject(error);
}
);
export default request;

View File

@ -0,0 +1,106 @@
<template>
<div class="home-view">
<van-nav-bar title="LLM 问卷调查" />
<div class="content">
<van-image
class="logo"
width="200"
height="200"
src="/logo.png"
fit="contain"
/>
<h1 class="title">欢迎参与 LLM 问卷调查</h1>
<p class="description">
本问卷旨在了解您对大语言模型LLM的使用体验和看法您的反馈对我们非常重要
</p>
<div class="action-buttons">
<van-button
v-if="!userStore.userId"
round
block
type="primary"
@click="router.push('/register')"
>
开始参与
</van-button>
<template v-else>
<van-button
round
block
type="primary"
@click="router.push('/survey')"
>
继续答题
</van-button>
<van-button
round
block
type="default"
@click="onLogout"
class="logout-btn"
>
退出登录
</van-button>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { showToast } from 'vant';
const router = useRouter();
const userStore = useUserStore();
function onLogout() {
userStore.logout();
showToast('已退出登录');
}
</script>
<style scoped>
.home-view {
min-height: 100vh;
background-color: #f7f8fa;
}
.content {
padding: 40px 20px;
text-align: center;
}
.logo {
margin-bottom: 20px;
}
.title {
font-size: 24px;
font-weight: bold;
color: #323233;
margin-bottom: 16px;
}
.description {
font-size: 16px;
color: #666;
line-height: 1.5;
margin-bottom: 40px;
}
.action-buttons {
max-width: 300px;
margin: 0 auto;
}
.logout-btn {
margin-top: 16px;
}
</style>

View File

@ -0,0 +1,181 @@
<template>
<div class="register-view">
<van-nav-bar title="用户注册" />
<van-form @submit="onSubmit" class="register-form">
<van-cell-group inset>
<van-field
v-model="formData.phone"
name="phone"
label="手机号"
placeholder="请输入手机号"
:rules="[
{ required: true, message: '请填写手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }
]"
@blur="onPhoneBlur"
/>
<van-field
v-model="formData.name"
name="name"
label="姓名"
placeholder="请输入姓名"
:rules="[{ required: true, message: '请填写姓名' }]"
/>
<van-field
v-model="displayWorkArea"
name="workArea"
label="工作内容"
readonly
clickable
placeholder="请选择工作内容"
:rules="[{ required: true, message: '请选择工作内容' }]"
@click="showWorkAreaPicker = true"
/>
<van-popup v-model:show="showWorkAreaPicker" position="bottom">
<van-picker
:columns="workAreaOptions"
@confirm="onWorkAreaConfirm"
@cancel="showWorkAreaPicker = false"
show-toolbar
title="选择工作内容"
/>
</van-popup>
<van-field
v-model="displayPositionType"
name="positionType"
label="岗位性质"
readonly
clickable
placeholder="请选择岗位性质"
:rules="[{ required: true, message: '请选择岗位性质' }]"
@click="showPositionTypePicker = true"
/>
<van-popup v-model:show="showPositionTypePicker" position="bottom">
<van-picker
:columns="positionTypeOptions"
@confirm="onPositionTypeConfirm"
@cancel="showPositionTypePicker = false"
show-toolbar
title="选择岗位性质"
/>
</van-popup>
</van-cell-group>
<div class="submit-btn">
<van-button round block type="primary" native-type="submit">
注册
</van-button>
</div>
</van-form>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { showToast, showNotify } from 'vant';
const router = useRouter();
const userStore = useUserStore();
const formData = reactive({
phone: '',
name: '',
workArea: '',
positionType: '',
});
const showWorkAreaPicker = ref(false);
const showPositionTypePicker = ref(false);
const displayWorkArea = ref('');
const displayPositionType = ref('');
const workAreaOptions = [
{ text: '研发', value: 'RD' },
{ text: '项目', value: 'PROJECT' },
{ text: '保险', value: 'INSURANCE' },
{ text: '财务', value: 'FINANCE' },
{ text: '运营', value: 'OPERATION' },
{ text: '客服', value: 'CUSTOMER_SERVICE' },
{ text: '综合管理', value: 'ADMIN' }
];
const positionTypeOptions = [
{ text: '管理岗', value: 'MANAGEMENT' },
{ text: '技术岗', value: 'TECHNICAL' },
{ text: '业务岗', value: 'BUSINESS' },
{ text: '职能支持岗', value: 'SUPPORT' }
];
//
async function onPhoneBlur() {
if (formData.phone && /^1[3-9]\d{9}$/.test(formData.phone)) {
try {
const isRegistered = await userStore.checkPhoneNumber(formData.phone);
if (isRegistered) {
showToast('该手机号已注册');
formData.phone = '';
}
} catch (error) {
console.error('检查手机号失败:', error);
}
}
}
//
function onWorkAreaConfirm({ selectedOptions }) {
console.log(selectedOptions);
formData.workArea = selectedOptions[0].value;
displayWorkArea.value = selectedOptions[0].text;
showWorkAreaPicker.value = false;
console.log(formData.workArea);
console.log(displayWorkArea.value);
}
//
function onPositionTypeConfirm({ selectedOptions }) {
formData.positionType = selectedOptions[0].value;
displayPositionType.value = selectedOptions[0].text;
showPositionTypePicker.value = false;
}
//
async function onSubmit() {
try {
await userStore.registerUser(formData);
// 使 showNotify
showNotify({
type: 'success',
message: '注册成功',
duration: 2000,
position: 'top'
});
//
setTimeout(() => {
router.replace('/survey');
}, 500);
} catch (error) {
console.error('注册失败:', error);
}
}
</script>
<style scoped>
.register-view {
min-height: 100vh;
background-color: #f7f8fa;
}
.register-form {
padding: 20px;
}
.submit-btn {
margin: 16px;
}
</style>

View File

@ -0,0 +1,183 @@
<template>
<div class="survey-view">
<van-nav-bar
title="问卷调查"
left-text="返回"
left-arrow
@click-left="onBack"
/>
<div v-if="!surveyStore.isCompleted" class="survey-content">
<div v-if="surveyStore.currentQuestion" class="question-card">
<van-cell-group inset>
<van-cell>
<template #title>
<div class="question-title">
{{ surveyStore.currentQuestionNumber }}. {{ surveyStore.currentQuestion.content }}
</div>
</template>
</van-cell>
<van-radio-group v-model="selectedOption">
<van-cell-group>
<van-cell
v-for="option in surveyStore.questionOptions"
:key="option.id"
clickable
@click="selectedOption = option.id"
>
<template #title>
<van-radio :name="option.id">{{ option.content }}</van-radio>
</template>
</van-cell>
</van-cell-group>
</van-radio-group>
</van-cell-group>
<div class="action-buttons">
<van-button
round
block
type="primary"
:disabled="!selectedOption"
@click="onNextQuestion"
>
下一题
</van-button>
</div>
</div>
<van-empty v-else description="加载中..." />
</div>
<div v-else class="survey-completed">
<van-empty description="问卷已完成">
<template #image>
<van-icon name="success" size="64" color="#07c160" />
</template>
</van-empty>
<van-button
round
type="primary"
class="restart-button"
@click="onRestartSurvey"
>
重新开始
</van-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';
import { useSurveyStore } from '@/stores/survey';
import { showToast } from 'vant';
const router = useRouter();
const userStore = useUserStore();
const surveyStore = useSurveyStore();
const selectedOption = ref('');
const responses = ref([]);
//
function onBack() {
router.back();
}
//
async function onNextQuestion() {
if (!selectedOption.value) {
showToast('请选择一个选项');
return;
}
try {
//
responses.value.push({
questionId: surveyStore.currentQuestion.id,
optionId: selectedOption.value,
});
//
const nextQuestion = await surveyStore.fetchNextQuestion(
userStore.userId,
selectedOption.value
);
//
if (!nextQuestion) {
await surveyStore.submitSurveyResponses(userStore.userId, responses.value);
showToast('问卷提交成功');
}
//
selectedOption.value = '';
} catch (error) {
console.error('获取下一个问题失败:', error);
}
}
//
function onRestartSurvey() {
surveyStore.resetSurvey();
responses.value = [];
initSurvey();
}
//
async function initSurvey() {
try {
await surveyStore.fetchNextQuestion(userStore.userId);
} catch (error) {
console.error('初始化问卷失败:', error);
showToast('初始化问卷失败,请重试');
}
}
//
onMounted(() => {
if (!userStore.userId) {
router.replace('/register');
return;
}
initSurvey();
});
</script>
<style scoped>
.survey-view {
min-height: 100vh;
background-color: #f7f8fa;
}
.survey-content {
padding: 20px;
}
.question-card {
margin-bottom: 20px;
}
.question-title {
font-size: 16px;
font-weight: bold;
line-height: 1.5;
}
.action-buttons {
margin-top: 20px;
}
.survey-completed {
padding: 40px 20px;
text-align: center;
}
.restart-button {
margin-top: 20px;
width: 80%;
}
</style>

48
frontend/vite.config.js Normal file
View File

@ -0,0 +1,48 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import { VantResolver } from 'unplugin-vue-components/resolvers';
import { fileURLToPath, URL } from 'node:url';
import postcssPxToViewport from 'postcss-px-to-viewport';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
// 自动导入 Vant 组件
Components({
resolvers: [VantResolver()],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
css: {
postcss: {
plugins: [
// 移动端适配
postcssPxToViewport({
viewportWidth: 375, // 设计稿宽度
viewportUnit: 'vw',
fontViewportUnit: 'vw',
selectorBlackList: ['.ignore-'], // 不转换的类名
minPixelValue: 1, // 最小转换数值
mediaQuery: false, // 是否转换媒体查询中的像素值
}),
],
},
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:18080/llm-survey-api',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});

3595
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff