164
社区成员
发帖
与我相关
我的任务
分享


| 团队成员 | GitHub仓库链接 | 提交次数 | 主要贡献方向 |
|---|---|---|---|
| 贺鸿志 | https://github.com/zczchzhz/Extreme-Programming/tree/main/backend | 53 | 1. Spring Boot后端框架搭建2. RESTful API设计与实现 3. 数据库设计与JPA配置 4. 异常处理体系构建 5. 单元测试编写 6. 云部署配置等 |
| 吴建远 | https://github.com/zczchzhz/Extreme-Programming/tree/main/frontend | 16 | 1. Vue 3前端架构设计 2. Element Plus组件集成 3. 响应式界面开发 4. 导入导出功能实现 5. 数据可视化设计 6. 用户体验优化等 |
在实现联系人收藏功能时,我们采取了状态标记模式这一经典设计模式。选择在Contact实体中直接添加bookmarked布尔字段,而非建立独立的收藏关系表,这一决策基于以下技术考量:
避免JOIN操作带来的性能开销
简化查询逻辑,直接通过条件筛选即可获取收藏列表
减少数据库表关联,降低系统复杂度
删除联系人时自动清理收藏状态,无需额外的级联删除逻辑
事务操作更简单,更新联系人信息时可同时处理收藏状态
可轻松升级为枚举类型支持"普通收藏"、"特别关注"等多级收藏
可添加bookmarked_time字段记录收藏时间
可扩展为集合字段支持用户自定义标签
@Entity
@Table(name = "contacts")
@Data
public class Contact {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "bookmarked", nullable = false)
private Boolean bookmarked = false;
@PrePersist
protected void onCreate() {
if (bookmarked == null) {
bookmarked = false; // 确保默认值
}
}
}
我们实现了三种收藏操作接口——bookmarkContact、unbookmarkContact和toggleBookmark,这种设计满足了不同场景的需求:
在ContactService接口中定义收藏相关操作:
public interface ContactService {
/**
* 收藏联系人
* @param id 联系人ID
* @return 更新后的联系人对象
*/
Contact bookmarkContact(Long id);
/**
* 取消收藏联系人
* @param id 联系人ID
* @return 更新后的联系人对象
*/
Contact unbookmarkContact(Long id);
/**
* 切换收藏状态(智能操作)
* @param id 联系人ID
* @return 更新后的联系人对象
*/
Contact toggleBookmark(Long id);
/**
* 获取收藏的联系人列表
* @return 收藏的联系人列表
*/
List<Contact> getBookmarkedContacts();
}
通过Spring Data JPA的findByBookmarkedTrue()方法,我们利用了数据库索引优化。在实际部署中,我们为bookmarked字段建立了索引,确保即使在数大量记录中也能快速筛选出收藏的联系人。
在ContactRepository中创建专门的查询方法:
@Repository
public interface ContactRepository extends JpaRepository<Contact, Long> {
/**
* 查询收藏的联系人
* @return 收藏的联系人列表
*/
List<Contact> findByBookmarkedTrue();
/**
* 根据收藏状态查询联系人
* @param bookmarked 是否收藏
* @return 匹配的联系人列表
*/
List<Contact> findByBookmarked(Boolean bookmarked);
/**
* 查询收藏状态且包含关键词的联系人
* @param bookmarked 是否收藏
* @param name 姓名关键词
* @return 匹配的联系人列表
*/
@Query("SELECT c FROM Contact c WHERE c.bookmarked = :bookmarked AND c.name LIKE %:name%")
List<Contact> findByBookmarkedAndNameContaining(@Param("bookmarked") Boolean bookmarked,
@Param("name") String name);
}
采用HTTP语义化设计收藏相关接口:
@RestController
@RequestMapping("/api/contacts")
@RequiredArgsConstructor
public class ContactController {
/**
* 收藏联系人 - PUT /api/contacts/{id}/bookmark
* 使用PUT表示设置收藏状态
*/
@PutMapping("/{id}/bookmark")
public ResponseEntity<Contact> bookmarkContact(@PathVariable Long id) {
Contact updatedContact = contactService.bookmarkContact(id);
return ResponseEntity.ok(updatedContact);
}
/**
* 取消收藏联系人 - DELETE /api/contacts/{id}/bookmark
* 使用DELETE表示移除收藏状态
*/
@DeleteMapping("/{id}/bookmark")
public ResponseEntity<Contact> unbookmarkContact(@PathVariable Long id) {
Contact updatedContact = contactService.unbookmarkContact(id);
return ResponseEntity.ok(updatedContact);
}
/**
* 切换收藏状态 - PATCH /api/contacts/{id}/bookmark/toggle
* 使用PATCH表示部分更新
*/
@PatchMapping("/{id}/bookmark/toggle")
public ResponseEntity<Contact> toggleBookmark(@PathVariable Long id) {
Contact updatedContact = contactService.toggleBookmark(id);
return ResponseEntity.ok(updatedContact);
}
/**
* 获取收藏的联系人列表 - GET /api/contacts/bookmarked
*/
@GetMapping("/bookmarked")
public ResponseEntity<List<Contact>> getBookmarkedContacts() {
List<Contact> contacts = contactService.getBookmarkedContacts();
return ResponseEntity.ok(contacts);
}
}
使用Vue 3的Composition API管理收藏状态:
// HomeView.vue中的收藏状态管理
const contacts = ref([])
const showBookmarkedOnly = ref(false)
const togglingBookmarkId = ref(null)
// 计算收藏联系人数量
const bookmarkedCount = computed(() => {
return contacts.value.filter(contact => contact.bookmarked).length
})
// 计算筛选后的联系人(考虑收藏筛选)
const filteredContacts = computed(() => {
let result = contacts.value
// 应用收藏筛选
if (showBookmarkedOnly.value) {
result = result.filter(contact => contact.bookmarked)
}
return result
})
使用Element Plus的图标按钮实现收藏交互:
<template>
<!-- 收藏按钮组件 -->
<el-button
:type="scope.row.bookmarked ? 'warning' : 'default'"
:icon="scope.row.bookmarked ? StarFilled : Star"
circle
size="small"
@click="toggleContactBookmark(scope.row)"
:loading="scope.row.id === togglingBookmarkId"
/>
<!-- 收藏筛选按钮 -->
<el-button
type="warning"
@click="toggleBookmarkFilter"
:icon="StarFilled"
:plain="!showBookmarkedOnly"
>
{{ showBookmarkedOnly ? '显示全部' : '仅显示收藏' }}
<span v-if="bookmarkedCount > 0">({{ bookmarkedCount }})</span>
</el-button>
</template>
使用async/await处理收藏操作的异步性:
// 切换联系人收藏状态
const toggleContactBookmark = async (contact) => {
try {
togglingBookmarkId.value = contact.id
// 调用API切换收藏状态
const updatedContact = await contactsAPI.toggleBookmark(contact.id)
// 更新本地数据
const index = contacts.value.findIndex(c => c.id === contact.id)
if (index !== -1) {
contacts.value[index] = updatedContact
}
// 提供反馈
ElMessage.success(
updatedContact.bookmarked ?
'已收藏联系人' :
'已取消收藏联系人'
)
} catch (error) {
ElMessage.error('操作失败: ' + (error.response?.data?.message || error.message))
} finally {
togglingBookmarkId.value = null
}
}
我们采用了三层乐观更新策略:
// 优化后的收藏切换函数
const toggleContactBookmarkOptimistic = async (contact) => {
// 1. 立即更新本地状态(乐观更新)
const oldBookmarked = contact.bookmarked
contact.bookmarked = !oldBookmarked
// 2. 异步调用API
try {
const updatedContact = await contactsAPI.toggleBookmark(contact.id)
// API调用成功,使用服务器返回的状态
contact.bookmarked = updatedContact.bookmarked
} catch (error) {
// API调用失败,回滚到之前的状态
contact.bookmarked = oldBookmarked
ElMessage.error('操作失败: ' + (error.response?.data?.message || error.message))
}
}
支持批量收藏/取消收藏功能:
// 批量收藏操作
const batchBookmarkContacts = async (contactIds, bookmarked) => {
const promises = contactIds.map(id =>
bookmarked ?
contactsAPI.bookmarkContact(id) :
contactsAPI.unbookmarkContact(id)
)
try {
await Promise.all(promises)
ElMessage.success(`成功${bookmarked ? '收藏' : '取消收藏'} ${contactIds.length} 个联系人`)
loadContacts() // 重新加载数据
} catch (error) {
ElMessage.error('批量操作失败: ' + error.message)
}
}
我们采用了DDD(领域驱动设计)中的值对象概念来处理联系方式。虽然当前实现将联系方式作为Contact实体的属性,但架构已为演进做好准备:
@Entity
@Table(name = "contacts")
@Data
public class Contact {
// 基本联系方式
@Column(name = "phone", nullable = false, length = 20)
private String phone;
@Column(name = "email", length = 100)
private String email;
// 社交媒体联系方式(新增)
@Column(name = "wechat", length = 50)
private String wechat; // 微信账号
@Column(name = "qq", length = 20)
private String qq; // QQ账号
// 其他联系方式(可扩展)
@Column(name = "address", length = 200)
private String address; // 物理地址
@Column(name = "company", length = 100)
private String company; // 公司地址
}
@Service
public class ContactServiceImpl implements ContactService {
private void validateContact(Contact contact) {
// 1. 电话号码验证
validatePhone(contact.getPhone());
// 2. 邮箱验证(如果提供)
if (contact.getEmail() != null && !contact.getEmail().trim().isEmpty()) {
validateEmail(contact.getEmail());
}
// 3. 微信账号验证
if (contact.getWechat() != null && !contact.getWechat().trim().isEmpty()) {
validateWechat(contact.getWechat());
}
// 4. QQ账号验证
if (contact.getQq() != null && !contact.getQq().trim().isEmpty()) {
validateQQ(contact.getQq());
}
}
private void validatePhone(String phone) {
// 手机号:1开头,11位数字
String mobileRegex = "^1[3-9]\\d{9}$";
// 座机号:区号-号码
String landlineRegex = "^\\d{3,4}-\\d{7,8}$";
if (!phone.matches(mobileRegex) && !phone.matches(landlineRegex)) {
throw new ValidationException("电话号码格式不正确");
}
}
private void validateWechat(String wechat) {
// 微信账号:6-20位字符,可包含字母、数字、下划线、减号
String wechatRegex = "^[a-zA-Z0-9_-]{6,20}$";
if (!wechat.matches(wechatRegex)) {
throw new ValidationException("微信账号格式不正确");
}
}
private void validateQQ(String qq) {
// QQ号:5-11位数字,不能以0开头
String qqRegex = "^[1-9][0-9]{4,10}$";
if (!qq.matches(qqRegex)) {
throw new ValidationException("QQ账号格式不正确");
}
}
}
设计支持多联系方式的表单组件:
<template>
<el-form :model="form" :rules="rules" label-width="80px">
<!-- 基本联系方式 -->
<el-form-item label="电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入电话" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<!-- 社交媒体联系方式 -->
<el-form-item label="微信">
<el-input v-model="form.wechat" placeholder="请输入微信账号" :maxlength="50" />
<div class="form-tip">可选填,6-20位字符</div>
</el-form-item>
<el-form-item label="QQ">
<el-input v-model="form.qq" placeholder="请输入QQ号" :maxlength="20" />
<div class="form-tip">可选填,5-11位数字</div>
</el-form-item>
<!-- 其他联系方式 -->
<el-form-item label="地址">
<el-input v-model="form.address" placeholder="请输入地址" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="公司">
<el-input v-model="form.company" placeholder="请输入公司名称" />
</el-form-item>
</el-form>
</template>
在联系人表格中展示多种联系方式:
<template>
<el-table :data="contacts">
<!-- 电话列 -->
<el-table-column prop="phone" label="电话" width="150">
<template #default="scope">
<div class="contact-method">
<el-icon><Phone /></el-icon>
<span>{{ scope.row.phone }}</span>
</div>
</template>
</el-table-column>
<!-- 邮箱列 -->
<el-table-column prop="email" label="邮箱" width="200">
<template #default="scope">
<div v-if="scope.row.email" class="contact-method">
<el-icon><Message /></el-icon>
<span>{{ scope.row.email }}</span>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<!-- 微信列 -->
<el-table-column prop="wechat" label="微信" width="150">
<template #default="scope">
<div v-if="scope.row.wechat" class="contact-method">
<el-icon><ChatDotSquare /></el-icon>
<span>{{ scope.row.wechat }}</span>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<!-- QQ列 -->
<el-table-column prop="qq" label="QQ" width="120">
<template #default="scope">
<div v-if="scope.row.qq" class="contact-method">
<el-icon><ChatLineSquare /></el-icon>
<span>{{ scope.row.qq }}</span>
</div>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
</el-table>
</template>
// 联系方式类型枚举(可扩展)
public enum ContactMethodType {
PHONE("电话"),
EMAIL("邮箱"),
WECHAT("微信"),
QQ("QQ"),
TELEGRAM("Telegram"),
WHATSAPP("WhatsApp"),
ADDRESS("地址"),
COMPANY("公司");
private final String displayName;
ContactMethodType(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}
// 联系方式值对象
@Embeddable
@Data
public class ContactMethod {
@Enumerated(EnumType.STRING)
private ContactMethodType type;
@Column(length = 100)
private String value;
@Column(length = 50)
private String label; // 标签:工作、家庭、其他
}
// 前端验证规则
const rules = {
phone: [
{ required: true, message: '请输入电话', trigger: 'blur' },
{
pattern: /^(1[3-9]\d{9}|\d{3,4}-\d{7,8})$/,
message: '电话格式不正确',
trigger: 'blur'
}
],
email: [
{
pattern: /^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/,
message: '邮箱格式不正确',
trigger: 'blur'
}
],
wechat: [
{
pattern: /^[a-zA-Z0-9_-]{6,20}$/,
message: '微信账号格式不正确',
trigger: 'blur'
}
],
qq: [
{
pattern: /^[1-9][0-9]{4,10}$/,
message: 'QQ号格式不正确',
trigger: 'blur'
}
]
}
1)导入功能设计了多层错误恢复:
语法错误:跳过无法解析的行,继续处理后续数据
语义错误:记录错误行号,导入有效数据
业务错误:违反业务规则的数据放入待审核队列
系统错误:网络中断时可断点续传
2)系统支持并发导入,但有严格的资源控制:
队列管理:使用优先级队列管理导入任务
资源限制:单个导入任务最多使用50% CPU和内存
超时机制:长时间运行的导入任务会被中断
重试策略:失败的导入可自动重试3次
实现Excel文件解析和错误处理:
@Component
public class ExcelUtil {
/**
* 从Excel文件导入联系人
* @param file 上传的Excel文件
* @return 解析出的联系人列表
*/
public static List<Contact> importFromExcel(MultipartFile file) throws IOException {
List<Contact> contacts = new ArrayList<>();
try (InputStream inputStream = file.getInputStream();
Workbook workbook = new XSSFWorkbook(inputStream)) {
// 1. 获取第一个工作表
Sheet sheet = workbook.getSheetAt(0);
Iterator<Row> rowIterator = sheet.iterator();
// 2. 跳过表头行
if (rowIterator.hasNext()) {
rowIterator.next();
}
// 3. 解析数据行
while (rowIterator.hasNext()) {
Row row = rowIterator.next();
// 跳过空行
if (isRowEmpty(row)) {
continue;
}
// 4. 解析联系人数据
Contact contact = parseContactFromRow(row);
// 5. 验证必要字段
if (isValidContact(contact)) {
contacts.add(contact);
}
}
}
return contacts;
}
/**
* 从Excel行解析联系人
*/
private static Contact parseContactFromRow(Row row) {
Contact contact = new Contact();
// 根据列索引解析数据
contact.setName(getCellStringValue(row.getCell(1))); // 姓名
contact.setPhone(getCellStringValue(row.getCell(2))); // 电话
contact.setEmail(getCellStringValue(row.getCell(3))); // 邮箱
contact.setWechat(getCellStringValue(row.getCell(4))); // 微信
contact.setQq(getCellStringValue(row.getCell(5))); // QQ
contact.setAddress(getCellStringValue(row.getCell(6))); // 地址
contact.setCompany(getCellStringValue(row.getCell(7))); // 公司
// 解析收藏状态
String bookmarkedStr = getCellStringValue(row.getCell(8));
contact.setBookmarked("是".equalsIgnoreCase(bookmarkedStr));
return contact;
}
/**
* 验证联系人数据
*/
private static boolean isValidContact(Contact contact) {
return contact.getName() != null && !contact.getName().trim().isEmpty()
&& contact.getPhone() != null && !contact.getPhone().trim().isEmpty();
}
}
实现文件选择和格式检测:
// import.js - 导入工具函数
export const parseCSV = (csvText) => {
const lines = csvText.split('\n').filter(line => line.trim())
if (lines.length === 0) return []
// 1. 解析表头
const headers = lines[0].split(',').map(header =>
header.replace(/^"|"$/g, '').trim()
)
// 2. 解析数据行
const contacts = []
for (let i = 1; i < lines.length; i++) {
const line = lines[i]
if (!line.trim()) continue
// 3. 处理CSV中的引号和逗号
const values = parseCSVLine(line)
// 4. 构建联系人对象
const contact = {}
headers.forEach((header, index) => {
let value = values[index] || ''
value = value.replace(/^"|"$/g, '')
// 映射字段
switch (header) {
case '姓名': contact.name = value; break
case '电话': contact.phone = value; break
case '邮箱': contact.email = value || null; break
case '微信': contact.wechat = value || null; break
case 'QQ': contact.qq = value || null; break
case '地址': contact.address = value || null; break
case '公司': contact.company = value || null; break
case '收藏': contact.bookmarked = value === '是'; break
}
})
// 5. 验证必要字段
if (contact.name && contact.phone) {
contacts.push(contact)
}
}
return contacts
}
// 解析CSV行,处理引号内的逗号
const parseCSVLine = (line) => {
const values = []
let inQuotes = false
let currentValue = ''
for (let j = 0; j < line.length; j++) {
const char = line[j]
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
values.push(currentValue.trim())
currentValue = ''
} else {
currentValue += char
}
}
values.push(currentValue.trim())
return values
}
实现分批次处理和进度反馈:
@RestController
@RequestMapping("/api/contacts")
public class ContactController {
@PostMapping("/import/excel")
public ResponseEntity<String> importFromExcel(@RequestParam("file") MultipartFile file) {
try {
// 1. 验证文件
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("文件不能为空")
}
// 2. 验证文件格式
String filename = file.getOriginalFilename()
if (!isExcelFile(filename)) {
return ResponseEntity.badRequest().body("只支持Excel文件 (.xlsx, .xls)")
}
// 3. 解析Excel
List<Contact> contacts = ExcelUtil.importFromExcel(file)
if (contacts.isEmpty()) {
return ResponseEntity.badRequest().body("Excel文件中没有有效的联系人数据")
}
// 4. 批量导入(分批处理)
int successCount = 0
int errorCount = 0
StringBuilder errors = new StringBuilder()
int batchSize = 50
for (int i = 0; i < contacts.size(); i += batchSize) {
int end = Math.min(i + batchSize, contacts.size())
List<Contact> batch = contacts.subList(i, end)
for (int j = 0; j < batch.size(); j++) {
Contact contact = batch.get(j)
try {
contactService.createContact(contact)
successCount++
} catch (Exception e) {
errorCount++
errors.append("第").append(i + j + 2).append("行: ")
.append(contact.getName()).append(" - ")
.append(e.getMessage()).append("\n")
}
}
}
// 5. 返回导入结果
String message = String.format(
"导入完成!成功导入 %d 个联系人,失败 %d 个",
successCount, errorCount
)
if (errorCount > 0) {
message += "\n\n错误详情:\n" + errors.toString()
}
return ResponseEntity.ok(message)
} catch (IOException e) {
log.error("导入Excel失败", e)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("导入失败: " + e.getMessage())
}
}
}
我们针对POI库的使用进行了多项优化:
// 样式缓存机制
private static final Map<String, CellStyle> styleCache = new ConcurrentHashMap<>();
public CellStyle getOrCreateStyle(Workbook workbook, String styleKey) {
return styleCache.computeIfAbsent(styleKey,
k -> createStyle(workbook, k));
}
使用Apache POI库生成格式规范的Excel文件:
@Component
public class ExcelUtil {
/**
* 导出联系人到Excel
* @param contacts 联系人列表
* @return Excel文件的字节数组
*/
public static byte[] exportToExcel(List<Contact> contacts) throws IOException {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
// 1. 创建工作表
Sheet sheet = workbook.createSheet("联系人");
// 2. 创建表头样式
CellStyle headerStyle = createHeaderStyle(workbook);
// 3. 创建表头行
Row headerRow = sheet.createRow(0);
String[] headers = {
"ID", "姓名", "电话", "邮箱", "微信", "QQ",
"地址", "公司", "收藏", "创建时间", "更新时间"
};
// 4. 设置表头单元格
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, 20 * 256); // 设置列宽
}
// 5. 创建数据行样式
CellStyle dataStyle = createDataStyle(workbook);
// 6. 填充数据行
int rowNum = 1;
for (Contact contact : contacts) {
Row row = sheet.createRow(rowNum++);
fillContactRow(row, contact, dataStyle);
}
// 7. 自动调整列宽
autoSizeColumns(sheet, headers.length);
// 8. 写入输出流
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
/**
* 填充联系人数据行
*/
private static void fillContactRow(Row row, Contact contact, CellStyle style) {
createCell(row, 0, contact.getId().toString(), style); // ID
createCell(row, 1, contact.getName(), style); // 姓名
createCell(row, 2, contact.getPhone(), style); // 电话
createCell(row, 3, contact.getEmail(), style); // 邮箱
createCell(row, 4, contact.getWechat(), style); // 微信
createCell(row, 5, contact.getQq(), style); // QQ
createCell(row, 6, contact.getAddress(), style); // 地址
createCell(row, 7, contact.getCompany(), style); // 公司
createCell(row, 8, contact.getBookmarked() ? "是" : "否", style); // 收藏
createCell(row, 9, formatDateTime(contact.getCreatedTime()), style); // 创建时间
createCell(row, 10, formatDateTime(contact.getUpdatedTime()), style); // 更新时间
}
}
实现多种导出格式支持:
// export.js - 导出工具函数
export const exportToCSV = (contacts, filename = 'contacts.csv') => {
if (!contacts || contacts.length === 0) {
alert('没有数据可导出')
return
}
// 1. 构建CSV内容
const headers = ['姓名', '电话', '邮箱', '微信', 'QQ', '地址', '公司', '收藏']
// 2. 转换数据为CSV格式
const csvData = contacts.map(contact => [
contact.name || '',
contact.phone || '',
contact.email || '',
contact.wechat || '',
contact.qq || '',
contact.address || '',
contact.company || '',
contact.bookmarked ? '是' : '否'
])
// 3. 添加BOM确保Excel正确显示中文
let csvContent = '\uFEFF' + headers.join(',') + '\n'
csvData.forEach(row => {
csvContent += row.map(field => `"${field}"`).join(',') + '\n'
})
// 4. 创建下载链接
downloadFile(csvContent, filename, 'text/csv;charset=utf-8;')
}
export const exportToJSON = (contacts, filename = 'contacts.json') => {
const dataStr = JSON.stringify(contacts, null, 2)
downloadFile(dataStr, filename, 'application/json')
}
// 通用文件下载函数
const downloadFile = (content, filename, mimeType) => {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// 前端导入错误处理
const importContacts = async (contactsToImport) => {
loading.value = true
try {
let successCount = 0
let errorCount = 0
const errors = []
for (let i = 0; i < contactsToImport.length; i++) {
const contact = contactsToImport[i]
try {
await contactsAPI.addContact(contact)
successCount++
// 进度反馈
if (successCount % 10 === 0) {
ElMessage.info(`已导入 ${successCount}/${contactsToImport.length} 个联系人...`)
}
} catch (error) {
errorCount++
errors.push(`第 ${i + 1} 行: ${contact.name} - ${error.response?.data?.message || error.message}`)
// 错误过多时停止
if (errorCount > 10) {
errors.push('... 错误过多,已停止导入')
break
}
}
}
// 显示导入结果
showImportResult(successCount, errorCount, errors)
} catch (error) {
ElMessage.error('导入过程发生错误: ' + error.message)
} finally {
loading.value = false
}
}
// 显示导入结果
const showImportResult = (successCount, errorCount, errors) => {
let message = `导入完成!成功: ${successCount},失败: ${errorCount}`
if (errors.length > 0) {
ElMessageBox.alert(
`${message}\n\n失败详情:\n${errors.slice(0, 10).join('\n')}`,
'导入结果',
{ type: errorCount > 0 ? 'warning' : 'success' }
)
} else {
ElMessage.success(message)
}
}
// 文件格式验证函数
const validateFileFormat = (file) => {
const fileName = file.name.toLowerCase()
const validExtensions = ['.xlsx', '.xls', '.csv', '.json']
const maxSize = 10 * 1024 * 1024 // 10MB
// 检查文件扩展名
const hasValidExtension = validExtensions.some(ext => fileName.endsWith(ext))
if (!hasValidExtension) {
return {
valid: false,
message: '不支持的文件格式。请选择 Excel、CSV 或 JSON 文件'
}
}
// 检查文件大小
if (file.size > maxSize) {
return {
valid: false,
message: '文件大小不能超过 10MB'
}
}
return { valid: true }
}

1)规格配置:2核4GB通用型实例,满足中小型应用需求
2)地域选择:华东1(杭州)节点,确保访问速度
3)镜像选择:CentOS 7.9 64位,稳定且社区支持完善
4)安全组配置:仅开放必要端口(22, 80, 443, 8080, 8081)
MySQL 8.0优势:
1)JSON支持:便于存储动态联系人字段
2)窗口函数:支持复杂数据统计查询
3)性能提升:比MySQL 5.7性能提升2倍
// Contact.java - 完整的联系人实体类
package com.contacts.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "contacts")
public class Contact {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "phone", nullable = false, length = 20)
private String phone;
@Column(name = "email", length = 100)
private String email;
@Column(name = "address", length = 200)
private String address;
@Column(name = "company", length = 100)
private String company;
@Column(name = "avatar", columnDefinition = "TEXT")
private String avatar;
// 多联系方式字段
@Column(name = "wechat", length = 50)
private String wechat;
@Column(name = "qq", length = 20)
private String qq;
// 收藏功能字段
@Column(name = "bookmarked", nullable = false)
private Boolean bookmarked = false;
@Column(name = "created_time")
private LocalDateTime createdTime;
@Column(name = "updated_time")
private LocalDateTime updatedTime;
@PrePersist
protected void onCreate() {
createdTime = LocalDateTime.now();
updatedTime = LocalDateTime.now();
if (bookmarked == null) {
bookmarked = false;
}
}
@PreUpdate
protected void onUpdate() {
updatedTime = LocalDateTime.now();
}
}
// ContactServiceImpl.java - 业务逻辑实现
package com.contacts.service.impl;
@Service
@RequiredArgsConstructor
@Transactional
public class ContactServiceImpl implements ContactService {
private final ContactRepository contactRepository;
@Override
public Contact bookmarkContact(Long id) {
log.info("Service: 开始收藏联系人,ID: {}", id);
// 验证参数
if (id == null || id <= 0) {
throw new ValidationException("联系人ID不能为空且必须大于0");
}
try {
// 查询联系人
Contact contact = contactRepository.findById(id)
.orElseThrow(() -> new ContactNotFoundException(id));
// 如果已经是收藏状态,直接返回
if (Boolean.TRUE.equals(contact.getBookmarked())) {
log.info("Service: 联系人已经是收藏状态,ID: {}", id);
return contact;
}
// 设置为收藏
contact.setBookmarked(true);
Contact updatedContact = contactRepository.save(contact);
log.info("Service: 成功收藏联系人: {} (ID: {})",
updatedContact.getName(), updatedContact.getId());
return updatedContact;
} catch (ContactNotFoundException e) {
throw e;
} catch (Exception e) {
log.error("Service: 收藏联系人失败,ID: {}", id, e);
throw new RuntimeException("收藏联系人失败: " + e.getMessage(), e);
}
}
@Override
public List<Contact> getBookmarkedContacts() {
log.info("Service: 开始获取收藏的联系人列表");
try {
List<Contact> contacts = contactRepository.findByBookmarkedTrue();
log.info("Service: 成功获取 {} 个收藏的联系人", contacts.size());
return contacts;
} catch (Exception e) {
log.error("Service: 获取收藏联系人列表失败", e);
throw new RuntimeException("获取收藏联系人列表失败: " + e.getMessage(), e);
}
}
}
// ExcelUtil.java - Excel处理工具类
package com.contacts.utils;
public class ExcelUtil {
private static final DateTimeFormatter DATE_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static byte[] exportToExcel(List<Contact> contacts) throws IOException {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
// 创建工作表
Sheet sheet = workbook.createSheet("联系人");
// 创建表头
createHeaderRow(sheet, workbook);
// 创建数据样式
CellStyle dataStyle = createDataStyle(workbook);
// 填充数据
int rowNum = 1;
for (Contact contact : contacts) {
Row row = sheet.createRow(rowNum++);
fillContactData(row, contact, dataStyle);
}
// 自动调整列宽
for (int i = 0; i < 11; i++) {
sheet.autoSizeColumn(i);
}
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private static void createHeaderRow(Sheet sheet, Workbook workbook) {
Row headerRow = sheet.createRow(0);
CellStyle headerStyle = createHeaderStyle(workbook);
String[] headers = {
"ID", "姓名", "电话", "邮箱", "微信", "QQ",
"地址", "公司", "收藏", "创建时间", "更新时间"
};
for (int i = 0; i < headers.length; i++) {
Cell cell = headerRow.createCell(i);
cell.setCellValue(headers[i]);
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(i, 20 * 256);
}
}
private static void fillContactData(Row row, Contact contact, CellStyle style) {
createCell(row, 0, contact.getId().toString(), style);
createCell(row, 1, contact.getName(), style);
createCell(row, 2, contact.getPhone(), style);
createCell(row, 3, contact.getEmail(), style);
createCell(row, 4, contact.getWechat(), style);
createCell(row, 5, contact.getQq(), style);
createCell(row, 6, contact.getAddress(), style);
createCell(row, 7, contact.getCompany(), style);
createCell(row, 8, contact.getBookmarked() ? "是" : "否", style);
createCell(row, 9, formatDateTime(contact.getCreatedTime()), style);
createCell(row, 10, formatDateTime(contact.getUpdatedTime()), style);
}
}
<!-- HomeView.vue - 主界面组件 -->
<template>
<div class="home-container">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>通讯录管理系统 - 学号: 832302220/832302126</span>
<div class="action-bar">
<!-- 收藏筛选按钮 -->
<el-button
type="warning"
@click="toggleBookmarkFilter"
:icon="StarFilled"
:plain="!showBookmarkedOnly"
>
{{ showBookmarkedOnly ? '显示全部' : '仅显示收藏' }}
<span v-if="bookmarkedCount > 0">({{ bookmarkedCount }})</span>
</el-button>
<!-- 导入导出按钮 -->
<el-button type="info" @click="handleImport" :icon="Upload">
导入数据
</el-button>
<el-button type="success" @click="handleExport" :icon="Download">
导出数据
</el-button>
</div>
</div>
</template>
<!-- 数据统计面板 -->
<el-row :gutter="20" style="margin-bottom: 20px;">
<el-col :span="4">
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<el-icon size="30" color="#409EFF"><User /></el-icon>
<div class="stat-content">
<div class="stat-label">联系人总数</div>
<div class="stat-value">{{ contacts.length }}</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card shadow="hover" class="stat-card">
<div class="stat-item">
<el-icon size="30" color="#E6A23C"><StarFilled /></el-icon>
<div class="stat-content">
<div class="stat-label">收藏联系人</div>
<div class="stat-value">{{ bookmarkedCount }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 联系人表格 -->
<el-table :data="filteredContacts" v-loading="loading">
<!-- 收藏列 -->
<el-table-column label="收藏" width="80">
<template #default="scope">
<el-button
:type="scope.row.bookmarked ? 'warning' : 'default'"
:icon="scope.row.bookmarked ? StarFilled : Star"
circle
size="small"
@click="toggleContactBookmark(scope.row)"
/>
</template>
</el-table-column>
<!-- 多联系方式列 -->
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="phone" label="电话" width="150" />
<el-table-column prop="email" label="邮箱" width="200">
<template #default="scope">
<span v-if="scope.row.email">{{ scope.row.email }}</span>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<el-table-column prop="wechat" label="微信" width="150">
<template #default="scope">
<span v-if="scope.row.wechat">{{ scope.row.wechat }}</span>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
<el-table-column prop="qq" label="QQ" width="120">
<template #default="scope">
<span v-if="scope.row.qq">{{ scope.row.qq }}</span>
<span v-else class="empty-text">-</span>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
// HomeView.vue中的收藏功能逻辑
<script setup>
import { ref, computed, onMounted } from 'vue'
import { contactsAPI } from '../api/contacts'
// 响应式数据
const contacts = ref([])
const loading = ref(false)
const showBookmarkedOnly = ref(false)
// 计算属性
const bookmarkedCount = computed(() => {
return contacts.value.filter(contact => contact.bookmarked).length
})
const filteredContacts = computed(() => {
let result = contacts.value
// 应用收藏筛选
if (showBookmarkedOnly.value) {
result = result.filter(contact => contact.bookmarked)
}
return result
})
// 切换收藏状态
const toggleContactBookmark = async (contact) => {
try {
const updatedContact = await contactsAPI.toggleBookmark(contact.id)
// 更新本地数据
const index = contacts.value.findIndex(c => c.id === contact.id)
if (index !== -1) {
contacts.value[index] = updatedContact
}
ElMessage.success(
updatedContact.bookmarked ?
'已收藏联系人' :
'已取消收藏联系人'
)
} catch (error) {
ElMessage.error('操作失败: ' + (error.response?.data?.message || error.message))
}
}
// 切换收藏筛选
const toggleBookmarkFilter = () => {
showBookmarkedOnly.value = !showBookmarkedOnly.value
}
// 加载联系人
const loadContacts = async () => {
loading.value = true
try {
contacts.value = await contactsAPI.getAllContacts()
} catch (error) {
ElMessage.error('加载联系人失败: ' + error.message)
} finally {
loading.value = false
}
}
onMounted(() => {
loadContacts()
})
</script>
// 导出功能实现
const handleExport = () => {
if (!contacts.value || contacts.value.length === 0) {
ElMessage.warning('没有数据可导出')
return
}
ElMessageBox.confirm('请选择导出格式', '导出数据', {
confirmButtonText: 'Excel格式',
cancelButtonText: 'CSV/JSON格式',
type: 'info'
}).then(async () => {
// Excel导出
try {
const response = await axios.get('/api/contacts/export/excel', {
responseType: 'blob',
baseURL: import.meta.env.VITE_API_BASE_URL
})
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response.data]))
const link = document.createElement('a')
link.href = url
link.download = `contacts_${new Date().toISOString().split('T')[0]}.xlsx`
document.body.appendChild(link)
link.click()
link.remove()
ElMessage.success('Excel导出成功')
} catch (error) {
ElMessage.error('Excel导出失败: ' + error.message)
}
}).catch((action) => {
if (action !== 'cancel') {
// CSV/JSON导出
ElMessageBox.confirm('请选择格式', '导出数据', {
confirmButtonText: 'CSV格式',
cancelButtonText: 'JSON格式',
type: 'info'
}).then(() => {
exportToCSV(contacts.value)
ElMessage.success('CSV导出成功')
}).catch((action2) => {
if (action2 !== 'cancel') {
exportToJSON(contacts.value)
ElMessage.success('JSON导出成功')
}
})
}
})
}
// 导入功能实现
const handleImport = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json,.csv,.xlsx,.xls,.txt'
input.onchange = async (e) => {
const file = e.target.files[0]
if (!file) return
try {
const fileName = file.name.toLowerCase()
if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
// Excel文件处理
await handleExcelImport(file)
} else {
// CSV/JSON文件处理
const text = await readFileAsText(file)
let importedContacts = []
if (fileName.endsWith('.json')) {
importedContacts = parseJSON(text)
} else {
importedContacts = parseCSV(text)
}
await importContacts(importedContacts)
}
} catch (error) {
ElMessage.error(`导入失败:${error.message}`)
}
}
input.click()
}
// Excel导入处理
const handleExcelImport = async (file) => {
loading.value = true
try {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post('/api/contacts/import/excel', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
baseURL: import.meta.env.VITE_API_BASE_URL
})
if (response.data.includes('成功导入')) {
if (response.data.includes('失败')) {
// 显示详细的导入结果
ElMessageBox.alert(response.data, '导入结果', {
type: 'warning',
showClose: false
})
} else {
ElMessage.success(response.data)
}
loadContacts()
} else {
ElMessage.warning(response.data)
}
} catch (error) {
ElMessage.error('Excel导入失败: ' + (error.response?.data || error.message))
} finally {
loading.value = false
}
}


| 团队成员 | 负责模块 | 具体工作内容 |
|---|---|---|
| 贺鸿志 | 后端开发与架构设计 | 1. Spring Boot后端框架搭建 2. RESTful API设计与实现 3. 数据库设计与JPA配置 4.异常处理体系构建 5.单元测试编写 6.云部署配置 |
| 吴建远 | 前端开发与UI设计 | 1. Vue 3前端架构设计 2. Element Plus组件集成 3. 响应式界面开发 4.导入导出功能实现 5.数据可视化设计 6.用户体验优化 |
协作流程:
1.需求分析阶段:共同讨论功能需求和实现方案
2.设计阶段:后端设计API接口,前端设计组件结构
3.开发阶段:并行开发,代码审查
4.测试阶段:交叉测试,确保功能完整性
5.部署阶段:协同配置部署环境
| 团队成员 | 贡献比例 | 评估依据 |
|---|---|---|
| 贺鸿志(832302220) | 50% | 1. 完成了核心后端架构和所有API接口 2. 实现了完整的异常处理体系 3. 编写了详细的单元测试 4.配置了多环境部署方案 5. 解决了数据库连接和性能优化问题 |
| 吴建远(832302126) | 50% | 1. 设计了美观实用的前端界面 2. 实现了复杂的前端交互逻辑 3. 完成了多格式导入导出功能 4.优化了移动端响应式体验 5. 解决了跨域访问和状态管理问题 |
困难:阿里云RDS连接稳定性问题
在配置Spring Boot连接阿里云RDS MySQL时,频繁出现连接超时和断开问题。特别是在高并发测试时,连接池迅速耗尽,错误日志显示:
com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure
The last packet sent successfully to the server was 0 milliseconds ago.
根本原因分析:
1.网络延迟:ECS与RDS通过内网连接,但存在间歇性延迟
2.连接池配置不当:HikariCP默认配置不适合生产环境
3.RDS白名单限制:安全组未正确配置ECS实例IP
4.MySQL参数限制:wait_timeout设置过短
解决方案:
1.优化连接池配置
# application-prod.yml
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
connection-test-query: SELECT 1
validation-timeout: 5000
2.增加连接保活机制:
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.hikari")
public DataSource dataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.addDataSourceProperty("socketTimeout", "30000");
dataSource.addDataSourceProperty("connectTimeout", "10000");
return dataSource;
}
}
3.配置RDS参数组:
-- 修改RDS参数
SET GLOBAL wait_timeout = 28800; -- 8小时
SET GLOBAL interactive_timeout = 28800;
SET GLOBAL max_connections = 100;
4.实现连接重试机制:
@Component
public class DatabaseHealthCheck {
@Scheduled(fixedDelay = 300000) // 每5分钟检查一次
public void checkConnection() {
try {
dataSource.getConnection().close();
} catch (SQLException e) {
// 记录告警并尝试重新初始化连接池
logger.error("数据库连接异常,尝试重连", e);
reinitializeDataSource();
}
}
}
经过优化后,连接稳定性明显提高
困难:前端与后端跨域通信问题
在阿里云部署环境中,前端(Vue)运行端口与后端(Spring Boot)运行端口不同,浏览器因同源策略阻止跨域请求。特别是在HTTPS环境下,CORS配置更为复杂。
现象:
1.预检请求失败:OPTIONS请求返回403
2.证书问题:前端HTTPS,后端HTTP导致混合内容错误
3.Cookie跨域:Session无法共享
4.WebSocket连接失败:实时功能不可用
解决方案:
采用Nginx反向代理 + 前后端同域 + 详细CORS配置的多层方案:通过 Nginx 作为统一的 HTTPS 入口实现前后端同域访问是解决跨域问题的核心:首先利用 Nginx 配置域名的 HTTPS 证书,将前端静态资源请求和后端 API、WebSocket 请求都收敛到同一个域名和端口下,从根源上消除浏览器同源策略的限制,既解决了前端 HTTPS 与后端 HTTP 并存导致的混合内容错误,也让 Cookie/Session 能够基于同域正常共享。同时,在 Nginx 层面对预检 OPTIONS 请求做缓存处理,减少频繁的预检交互,再配合 Spring Boot 后端的精细化 CORS 配置作为兜底防护,明确允许的请求源、请求方法、请求头和凭证携带规则,解决预检请求 403 的问题;针对 WebSocket 场景,通过 Nginx 开启协议升级支持,配置长连接超时参数,保障实时通信的连接稳定性,最终形成从网络层到应用层的多层跨域解决方案,覆盖协议兼容、凭证共享、实时通信等全场景的跨域问题。
| PSP阶段 | 预估时间(小时) | 实际时间(小时) | 备注 | 完成情况 |
|---|---|---|---|---|
| 计划 | 1 | 1.5 | 细化项目功能需求,评估后端技术难点 | 完成 |
| 需求分析 | 4 | 7 | 分析项目功能点 | 完成 |
| 设计 | 2 | 3.5 | 进行数据库设计与调整等 | 完成 |
| 编码 | 6 | 9.5 | 实现后端接口和前端交互(,处理异常 | 完成 |
| 测试 | 2 | 2 | 测试基础功能的正常流程和异常场景,进行集成测试等 | 完成 |
| 文档 | 2 | 4 | 整理后端功能的实现思路,提交至GitHub与CSDN | 完成 |
| 部署 | 1 | 2.5 | 使用阿里云服务器进行云部署 | 完成 |
| 总计 | 18 | 29 | 功能完善,任务全部完成 | 优秀 |
| PSP阶段 | 预估时间(小时) | 实际时间(小时) | 备注 | 完成情况 |
|---|---|---|---|---|
| 计划 | 1 | 1.5 | 细化项目功能需求,评估前端技术难点 | 完成 |
| 原型设计 | 3 | 5 | 进行UI设计并反复调整 | 完成 |
| 编码 | 6 | 7 | 实现多种联系方式、导入导出功能等,完善代码 | 完成 |
| 测试 | 2 | 2 | 测试不同格式Excel文件的导入导出,测试跨设备访问 | 完成 |
| 优化 | 2 | 3 | 响应式布局优化,配合后端优化功能实现 | 完成 |
| 文档 | 2 | 4 | 编写部署文档和功能实现说明等 | 完成 |
| 部署 | 1 | 2.5 | 使用阿里云服务器进行云部署 | 完成 |
| 总计 | 17 | 25 | 功能完善,任务全部完成 | 优秀 |
通过本次极限编程实践,我们团队成功构建了一个功能完整、技术先进的通讯录管理系统。项目采用Vue 3 + Spring Boot全栈架构,实现了联系人收藏、多联系方式管理、Excel导入导出等核心功能,并成功部署至阿里云生产环境。
在开发过程中,我们深刻践行了极限编程方法论:通过结对编程和持续集成确保代码质量,采用测试驱动开发提升系统稳定性。团队成员在跨域通信、大文件处理、移动端适配等技术挑战面前,展现了卓越的问题解决能力和创新思维。
项目不仅满足了所有功能需求,更在代码质量、用户体验和部署运维方面达到了高的标准。我们构建了一套完整的开发运维体系与保障措施。
此次实践让我们将课堂理论知识转化为实际工程能力,为未来的软件开发工作积累了宝贵经验。项目完全达到了课程作业的所有要求,并在技术创新和团队协作方面取得了显著成果。