7. 话题与消息管理

3 min

接下来实现话题和消息的数据库设计、后端 CRUD 接口,以及前端的话题管理界面。完成后,用户可以创建、切换、删除话题,为接下来的 AI 对话做好数据基础。

1. 数据库设计

1.1 表结构

schema.sql 中添加 topicsmessages 两张表:

src/main/resources/schema.sql

sql
CREATE TABLE IF NOT EXISTS USERS (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS TOPICS (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    title VARCHAR(100) NOT NULL DEFAULT '新对话',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES USERS(id)
);

CREATE TABLE IF NOT EXISTS MESSAGES (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    topic_id BIGINT NOT NULL,
    role VARCHAR(20) NOT NULL,
    content TEXT NOT NULL,
    thinking TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (topic_id) REFERENCES TOPICS(id)
);

设计要点:

  • topics.user_id:关联用户,实现数据隔离,用户只能看到自己创建的话题
  • messages.topic_id:关联话题,每个话题下有独立的消息列表
  • messages.role:消息角色(userassistant),对应 Spring AI 的 Message 类型
  • messages.thinking:AI 的思考过程(仅思考模型返回,可为空),用于存储深度推理内容

1.2 实体定义

src/main/java/com/albertstack/aichat/entity/Topic.java

java
package com.albertstack.aichat.entity;

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

import java.time.LocalDateTime;

@Table("TOPICS")
public record Topic(
        @Id Long id,
        Long userId,
        String title,
        LocalDateTime createdAt
) {
    public static Topic create(Long userId, String title) {
        return new Topic(null, userId, title, LocalDateTime.now());
    }
}

src/main/java/com/albertstack/aichat/entity/Message.java

java
package com.albertstack.aichat.entity;

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

import java.time.LocalDateTime;

@Table("MESSAGES")
public record Message(
        @Id Long id,
        Long topicId,
        String role,
        String content,
        String thinking,
        LocalDateTime createdAt
) {
    public static Message create(Long topicId, String role, String content) {
        return new Message(null, topicId, role, content, null, LocalDateTime.now());
    }

    public static Message create(Long topicId, String role, String content, String thinking) {
        return new Message(null, topicId, role, content, thinking, LocalDateTime.now());
    }
}

1.3 Repository

src/main/java/com/albertstack/aichat/repository/TopicRepository.java

java
package com.albertstack.aichat.repository;

import com.albertstack.aichat.entity.Topic;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public interface TopicRepository extends ReactiveCrudRepository<Topic, Long> {
    // 查询某个用户的所有话题,按创建时间倒序
    Flux<Topic> findByUserIdOrderByCreatedAtDesc(Long userId);

    // 查询某个用户的某个话题(数据隔离:确保话题属于该用户)
    Mono<Topic> findByIdAndUserId(Long id, Long userId);

    // 删除某个用户的某个话题
    Mono<Void> deleteByIdAndUserId(Long id, Long userId);
}

src/main/java/com/albertstack/aichat/repository/MessageRepository.java

java
package com.albertstack.aichat.repository;

import com.albertstack.aichat.entity.Message;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public interface MessageRepository extends ReactiveCrudRepository<Message, Long> {
    // 查询某个话题的所有消息,按时间正序
    Flux<Message> findByTopicIdOrderByCreatedAtAsc(Long topicId);

    // 删除某个话题的所有消息(级联删除时使用)
    Mono<Void> deleteByTopicId(Long topicId);
}

1.4 Repository 测试

src/test/java/com/albertstack/aichat/repository/TopicRepositoryTest.java

java
package com.albertstack.aichat.repository;

import com.albertstack.aichat.entity.Topic;
import com.albertstack.aichat.entity.User;
import lombok.extern.slf4j.Slf4j;
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.r2dbc.core.DatabaseClient;
import reactor.test.StepVerifier;

@Slf4j
@SpringBootTest
class TopicRepositoryTest {

    @Autowired
    private TopicRepository topicRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private DatabaseClient databaseClient;

    private Long userId1;
    private Long userId2;

    @BeforeEach
    void setUp() {
        // 截断所有关联表(先关闭外键检查,截断后恢复)
        databaseClient.sql("SET REFERENTIAL_INTEGRITY FALSE").then().block();
        databaseClient.sql("TRUNCATE TABLE MESSAGES RESTART IDENTITY").then().block();
        databaseClient.sql("TRUNCATE TABLE TOPICS RESTART IDENTITY").then().block();
        databaseClient.sql("TRUNCATE TABLE USERS RESTART IDENTITY").then().block();
        databaseClient.sql("SET REFERENTIAL_INTEGRITY TRUE").then().block();

        // 创建测试用户(满足外键约束)
        userId1 = userRepository.save(User.create("Albert", "ps123456")).block().id();
        userId2 = userRepository.save(User.create("Macy", "ps123456")).block().id();
    }

    @Test
    void save_shouldPersistTopic() {
        Topic topic = Topic.create(userId1, "测试话题");

        StepVerifier.create(topicRepository.save(topic))
                .expectNextMatches(saved -> {
                    log.info("save_shouldPersistTopic => id={}, title={}, userId={}",
                            saved.id(), saved.title(), saved.userId());
                    return saved.id() != null && saved.title().equals("测试话题");
                })
                .verifyComplete();
    }

    @Test
    void findByUserId_shouldReturnOnlyUserTopics() {
        topicRepository.save(Topic.create(userId1, "用户1的话题")).block();
        topicRepository.save(Topic.create(userId2, "用户2的话题")).block();

        StepVerifier.create(topicRepository.findByUserIdOrderByCreatedAtDesc(userId1))
                .expectNextMatches(topic -> {
                    log.info("findByUserId_shouldReturnOnlyUserTopics => id={}, title={}", topic.id(), topic.title());
                    return topic.title().equals("用户1的话题");
                })
                .verifyComplete();
        log.info(" ✅ 用户 1 只能查到自己的话题");
    }

    @Test
    void findByIdAndUserId_shouldEnforceOwnership() {
        Topic saved = topicRepository.save(Topic.create(userId1, "用户1的话题")).block();
        log.info("创建话题 => id={}", saved.id());

        // 用户 1 查询自己的话题,能查到
        StepVerifier.create(topicRepository.findByIdAndUserId(saved.id(), userId1))
                .expectNextMatches(t -> {
                    log.info(" ✅ 用户 1 查询自己的话题 => 找到: {}", t.title());
                    return true;
                })
                .verifyComplete();

        // 用户 2 查询同一个话题,查不到(数据隔离)
        StepVerifier.create(
                        topicRepository.findByIdAndUserId(saved.id(), userId2)
                                .doOnSuccess(t -> {
                                    if (t == null) {
                                        log.info(" ✅ 用户 2 查询用户 1 的话题 => 未找到(数据隔离生效)");
                                    }
                                })
                )
                .verifyComplete();
    }
}

src/test/java/com/albertstack/aichat/repository/MessageRepositoryTest.java

java
package com.albertstack.aichat.repository;

import com.albertstack.aichat.entity.Message;
import com.albertstack.aichat.entity.Topic;
import com.albertstack.aichat.entity.User;
import lombok.extern.slf4j.Slf4j;
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.r2dbc.core.DatabaseClient;
import reactor.test.StepVerifier;

@Slf4j
@SpringBootTest
class MessageRepositoryTest {

    @Autowired
    private MessageRepository messageRepository;

    @Autowired
    private TopicRepository topicRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private DatabaseClient databaseClient;

    private Long topicId;

    @BeforeEach
    void setUp() {
        // 截断所有关联表(先关闭外键检查,截断后恢复)
        databaseClient.sql("SET REFERENTIAL_INTEGRITY FALSE").then().block();
        databaseClient.sql("TRUNCATE TABLE MESSAGES RESTART IDENTITY").then().block();
        databaseClient.sql("TRUNCATE TABLE TOPICS RESTART IDENTITY").then().block();
        databaseClient.sql("TRUNCATE TABLE USERS RESTART IDENTITY").then().block();
        databaseClient.sql("SET REFERENTIAL_INTEGRITY TRUE").then().block();

        // 创建测试用户和话题(满足外键约束)
        Long userId = userRepository.save(User.create("Albert", "ps123456")).block().id();
        topicId = topicRepository.save(Topic.create(userId, "测试话题")).block().id();
    }

    @Test
    void save_shouldPersistMessage() {
        Message message = Message.create(topicId, "user", "你好");

        StepVerifier.create(messageRepository.save(message))
                .expectNextMatches(saved -> {
                    log.info("save_shouldPersistMessage => id={}, role={}, content={}",
                            saved.id(), saved.role(), saved.content());
                    return saved.id() != null && saved.content().equals("你好");
                })
                .verifyComplete();
    }

    @Test
    void findByTopicId_shouldReturnMessagesInOrder() {
        messageRepository.save(Message.create(topicId, "user", "第一条")).block();
        messageRepository.save(Message.create(topicId, "assistant", "回复")).block();
        messageRepository.save(Message.create(topicId, "user", "第二条")).block();

        StepVerifier.create(messageRepository.findByTopicIdOrderByCreatedAtAsc(topicId))
                .expectNextMatches(m -> {
                    log.info("findByTopicId_shouldReturnMessagesInOrder => role={}, content={}", m.role(), m.content());
                    return m.content().equals("第一条");
                })
                .expectNextMatches(m -> {
                    log.info("findByTopicId_shouldReturnMessagesInOrder => role={}, content={}", m.role(), m.content());
                    return m.content().equals("回复");
                })
                .expectNextMatches(m -> {
                    log.info("findByTopicId_shouldReturnMessagesInOrder => role={}, content={}", m.role(), m.content());
                    return m.content().equals("第二条");
                })
                .verifyComplete();
        log.info(" ✅ 消息按时间正序返回");
    }

    @Test
    void deleteByTopicId_shouldRemoveAllMessages() {
        messageRepository.save(Message.create(topicId, "user", "消息1")).block();
        messageRepository.save(Message.create(topicId, "assistant", "消息2")).block();
        log.info("已插入 2 条消息");

        StepVerifier.create(messageRepository.deleteByTopicId(topicId))
                .verifyComplete();

        StepVerifier.create(messageRepository.findByTopicIdOrderByCreatedAtAsc(topicId))
                .verifyComplete();
        log.info(" ✅ 删除后消息为空");
    }
}

2. 后端 CRUD 接口

2.1 DTO

src/main/java/com/albertstack/aichat/dto/TopicRequest.java

java
package com.albertstack.aichat.dto;

public record TopicRequest(String title) {}

2.2 TopicService

src/main/java/com/albertstack/aichat/service/TopicService.java

java
package com.albertstack.aichat.service;

import com.albertstack.aichat.dto.TopicRequest;
import com.albertstack.aichat.entity.Message;
import com.albertstack.aichat.entity.Topic;
import com.albertstack.aichat.repository.MessageRepository;
import com.albertstack.aichat.repository.TopicRepository;
import com.albertstack.aichat.util.SecurityUtils;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
public class TopicService {

    private final TopicRepository topicRepository;
    private final MessageRepository messageRepository;

    public TopicService(TopicRepository topicRepository, MessageRepository messageRepository) {
        this.topicRepository = topicRepository;
        this.messageRepository = messageRepository;
    }

    // 查询当前用户的所有话题
    public Flux<Topic> listTopics() {
        return SecurityUtils.getCurrentUserId()
                .flatMapMany(topicRepository::findByUserIdOrderByCreatedAtDesc);
    }

    // 创建话题
    public Mono<Topic> createTopic(TopicRequest request) {
        return SecurityUtils.getCurrentUserId()
                .flatMap(userId -> {
                    String title = (request.title() != null && !request.title().isBlank())
                            ? request.title() : "新对话";
                    return topicRepository.save(Topic.create(userId, title));
                });
    }

    // 更新话题标题
    public Mono<Topic> updateTopic(Long topicId, TopicRequest request) {
        return SecurityUtils.getCurrentUserId()
                .flatMap(userId -> topicRepository.findByIdAndUserId(topicId, userId))
                .switchIfEmpty(Mono.error(new RuntimeException("话题不存在")))
                .flatMap(topic -> {
                    Topic updated = new Topic(topic.id(), topic.userId(), request.title(), topic.createdAt());
                    return topicRepository.save(updated);
                });
    }

    // 删除话题(级联删除消息)
    public Mono<Void> deleteTopic(Long topicId) {
        return SecurityUtils.getCurrentUserId()
                .flatMap(userId -> topicRepository.findByIdAndUserId(topicId, userId))
                .switchIfEmpty(Mono.error(new RuntimeException("话题不存在")))
                .flatMap(topic -> messageRepository.deleteByTopicId(topic.id())
                        .then(topicRepository.deleteById(topic.id()))
                );
    }

    // 查询话题下的消息列表
    public Flux<Message> listMessages(Long topicId) {
        return SecurityUtils.getCurrentUserId()
                .flatMap(userId -> topicRepository.findByIdAndUserId(topicId, userId))
                .switchIfEmpty(Mono.error(new RuntimeException("话题不存在")))
                .flatMapMany(topic -> messageRepository.findByTopicIdOrderByCreatedAtAsc(topic.id()));
    }
}

2.3 TopicController

src/main/java/com/albertstack/aichat/controller/TopicController.java

java
package com.albertstack.aichat.controller;

import com.albertstack.aichat.common.ApiResponse;
import com.albertstack.aichat.dto.TopicRequest;
import com.albertstack.aichat.entity.Message;
import com.albertstack.aichat.entity.Topic;
import com.albertstack.aichat.service.TopicService;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;

import java.util.List;

@RestController
@RequestMapping("/api/topics")
public class TopicController {

    private final TopicService topicService;

    public TopicController(TopicService topicService) {
        this.topicService = topicService;
    }

    @GetMapping
    public Mono<ApiResponse<List<Topic>>> list() {
        return topicService.listTopics()
                .collectList()
                .map(ApiResponse::success);
    }

    @PostMapping
    public Mono<ApiResponse<Topic>> create(@RequestBody TopicRequest request) {
        return topicService.createTopic(request).map(ApiResponse::success);
    }

    @PutMapping("/{topicId}")
    public Mono<ApiResponse<Topic>> update(@PathVariable Long topicId, @RequestBody TopicRequest request) {
        return topicService.updateTopic(topicId, request).map(ApiResponse::success);
    }

    @DeleteMapping("/{topicId}")
    public Mono<ApiResponse<Void>> delete(@PathVariable Long topicId) {
        return topicService.deleteTopic(topicId).then(Mono.just(ApiResponse.success()));
    }

    @GetMapping("/{topicId}/messages")
    public Mono<ApiResponse<List<Message>>> messages(@PathVariable Long topicId) {
        return topicService.listMessages(topicId)
                .collectList()
                .map(ApiResponse::success);
    }
}

2.4 API 总览

方法 路径 说明 认证
GET /api/topics 获取当前用户的话题列表 需要
POST /api/topics 创建新话题 需要
PUT /api/topics/{id} 更新话题标题 需要
DELETE /api/topics/{id} 删除话题(级联删除消息) 需要
GET /api/topics/{id}/messages 获取话题下的消息列表 需要

2.5 Controller 测试

src/test/java/com/albertstack/aichat/controller/TopicControllerTest.java

java
package com.albertstack.aichat.controller;

import com.albertstack.aichat.common.ResultCode;
import com.albertstack.aichat.dto.RegisterRequest;
import com.albertstack.aichat.dto.TopicRequest;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.test.web.reactive.server.WebTestClient;

import static com.albertstack.aichat.util.ApiTest.*;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class TopicControllerTest {

    @Autowired
    private WebTestClient webTestClient;

    @Autowired
    private DatabaseClient databaseClient;

    private final ObjectMapper objectMapper = new ObjectMapper();
    private String token;

    @BeforeEach
    void setUp() throws Exception {
        // 截断所有关联表
        databaseClient.sql("SET REFERENTIAL_INTEGRITY FALSE").then().block();
        databaseClient.sql("TRUNCATE TABLE MESSAGES RESTART IDENTITY").then().block();
        databaseClient.sql("TRUNCATE TABLE TOPICS RESTART IDENTITY").then().block();
        databaseClient.sql("TRUNCATE TABLE USERS RESTART IDENTITY").then().block();
        databaseClient.sql("SET REFERENTIAL_INTEGRITY TRUE").then().block();

        // 注册测试用户,通过原始请求提取 token(verify 用于校验,不返回值)
        byte[] body = webTestClient.post()
                .uri("/api/auth/register")
                .bodyValue(new RegisterRequest("Albert", "ps123456"))
                .exchange()
                .expectStatus().isEqualTo(200)
                .expectBody(byte[].class)
                .returnResult().getResponseBody();
        token = objectMapper.readTree(body).get("data").get("token").asString();
    }

    @Test
    void createTopic_shouldReturnNewTopic() {
        of(webTestClient).post("/api/topics", new TopicRequest("我的第一个话题"))
                .withToken(token)
                .verify(
                        eq("code", ResultCode.SUCCESS.getCode()),
                        eq("data.title", "我的第一个话题"),
                        notNull("data.id")
                );
    }

    @Test
    void listTopics_shouldReturnUserTopics() {
        of(webTestClient).post("/api/topics", new TopicRequest("话题一"))
                .withToken(token).verify(eq("code", ResultCode.SUCCESS.getCode()));
        of(webTestClient).post("/api/topics", new TopicRequest("话题二"))
                .withToken(token).verify(eq("code", ResultCode.SUCCESS.getCode()));

        of(webTestClient).get("/api/topics")
                .withToken(token)
                .verify(
                        eq("code", ResultCode.SUCCESS.getCode()),
                        size("data", 2)
                );
    }

    @Test
    void deleteTopic_shouldRemoveTopic() throws Exception {
        // 创建话题,通过原始请求提取 topicId
        byte[] createBody = webTestClient.post()
                .uri("/api/topics")
                .header("Authorization", "Bearer " + token)
                .bodyValue(new TopicRequest("待删除话题"))
                .exchange()
                .expectBody(byte[].class)
                .returnResult().getResponseBody();
        long topicId = objectMapper.readTree(createBody).get("data").get("id").asLong();

        // 删除话题
        of(webTestClient).delete("/api/topics/" + topicId)
                .withToken(token)
                .verify(eq("code", ResultCode.SUCCESS.getCode()));

        // 验证已删除
        of(webTestClient).get("/api/topics")
                .withToken(token)
                .verify(size("data", 0));
    }

    @Test
    void topicAccess_withoutToken_shouldReturn401() {
        of(webTestClient).get("/api/topics")
                .expectStatus(401)
                .verify();
    }

    @Test
    void topicAccess_differentUser_shouldNotSeeOtherTopics() throws Exception {
        of(webTestClient).post("/api/topics", new TopicRequest("用户A的话题"))
                .withToken(token).verify(eq("code", ResultCode.SUCCESS.getCode()));

        // 注册另一个用户,提取 token
        byte[] otherBody = webTestClient.post()
                .uri("/api/auth/register")
                .bodyValue(new RegisterRequest("Macy", "ps123456"))
                .exchange()
                .expectBody(byte[].class)
                .returnResult().getResponseBody();
        String otherToken = objectMapper.readTree(otherBody).get("data").get("token").asString();

        of(webTestClient).get("/api/topics")
                .withToken(otherToken)
                .verify(
                        eq("code", ResultCode.SUCCESS.getCode()),
                        size("data", 0)
                );
    }
}

最后一个测试 topicAccess_differentUser_shouldNotSeeOtherTopics 是数据隔离的端到端验证:用户 A 创建的话题,用户 B 查不到。

3. 前端话题管理

3.1 Topic API 模块

src/api/topic.ts

typescript
import api from './index'

export interface Topic {
  id: number
  userId: number
  title: string
  createdAt: string
}

export interface Message {
  id: number
  topicId: number
  role: 'user' | 'assistant'
  content: string
  thinking: string | null
  createdAt: string
}

export function listTopicsApi() {
  return api.get<Topic[]>('/topics')
}

export function createTopicApi(title: string) {
  return api.post<Topic>('/topics', { title })
}

export function updateTopicApi(topicId: number, title: string) {
  return api.put<Topic>(`/topics/${topicId}`, { title })
}

export function deleteTopicApi(topicId: number) {
  return api.delete(`/topics/${topicId}`)
}

export function listMessagesApi(topicId: number) {
  return api.get<Message[]>(`/topics/${topicId}/messages`)
}

3.2 更新 ChatView

现在我们开始构建聊天界面的基本布局,左侧是话题列表,右侧是聊天区域。我们先实现话题管理功能,聊天功能将在接下来实现。

src/views/ChatView.vue

vue
<template>
  <div class="h-screen flex bg-base-200">
    <!-- 左侧:话题列表 -->
    <aside class="w-64 bg-base-100 flex flex-col border-r border-base-300">
      <!-- 新建话题按钮 -->
      <div class="p-3">
        <button class="btn btn-primary btn-sm w-full" @click="createTopic">
          + 新对话
        </button>
      </div>

      <!-- 话题列表 -->
      <div class="flex-1 overflow-y-auto">
        <ul class="menu p-2">
          <li v-for="topic in topics" :key="topic.id">
            <a
              :class="{ 'active': currentTopicId === topic.id }"
              class="flex justify-between items-center"
              @click="selectTopic(topic)"
            >
              <!-- 编辑模式:显示输入框 -->
              <input
                v-if="editingTopicId === topic.id"
                v-model="editingTitle"
                class="input input-xs input-bordered flex-1 mr-1"
                @click.stop
                @keyup.enter="confirmEdit(topic)"
                @keyup.escape="cancelEdit"
                @blur="confirmEdit(topic)"
              />
              <!-- 显示模式:双击进入编辑 -->
              <span v-else class="truncate flex-1" @dblclick.stop="startEdit(topic)">
                {{ topic.title }}
              </span>
              <button
                class="btn btn-ghost btn-xs"
                @click.stop="deleteTopic(topic.id)"
              >

              </button>
            </a>
          </li>
        </ul>

        <div v-if="topics.length === 0" class="text-center text-base-content/50 p-4 text-sm">
          暂无对话,点击上方按钮创建
        </div>
      </div>

      <!-- 底部:用户信息 -->
      <div class="p-3 border-t border-base-300 flex justify-between items-center">
        <span class="text-sm">{{ authStore.username }}</span>
        <button class="btn btn-ghost btn-xs" @click="handleLogout">退出</button>
      </div>
    </aside>

    <!-- 右侧:聊天区域 -->
    <main class="flex-1 flex flex-col">
      <!-- 顶部栏 -->
      <header class="h-14 flex items-center px-4 border-b border-base-300 bg-base-100">
        <h2 class="text-lg font-medium">
          {{ currentTopic?.title || '选择或创建一个对话' }}
        </h2>
      </header>

      <!-- 消息区域(接下来实现) -->
      <div class="flex-1 flex items-center justify-center text-base-content/50">
        <span v-if="!currentTopicId">← 选择一个话题开始对话</span>
        <span v-else>聊天功能将在接下来实现</span>
      </div>

      <!-- 输入框(接下来实现) -->
      <div class="p-4 border-t border-base-300 bg-base-100">
        <div class="flex gap-2">
          <input
            type="text"
            placeholder="输入消息..."
            class="input input-bordered flex-1"
            disabled
          />
          <button class="btn btn-primary" disabled>发送</button>
        </div>
      </div>
    </main>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import {
  listTopicsApi,
  createTopicApi,
  updateTopicApi,
  deleteTopicApi,
  type Topic,
} from '../api/topic'

const router = useRouter()
const authStore = useAuthStore()

const topics = ref<Topic[]>([])
const currentTopicId = ref<number | null>(null)
const currentTopic = ref<Topic | null>(null)
const editingTopicId = ref<number | null>(null)
const editingTitle = ref('')

// 加载话题列表
async function loadTopics() {
  const { data } = await listTopicsApi()
  topics.value = data
}

// 创建新话题
async function createTopic() {
  const { data } = await createTopicApi('新对话')
  topics.value.unshift(data)
  selectTopic(data)
}

// 选择话题
function selectTopic(topic: Topic) {
  currentTopicId.value = topic.id
  currentTopic.value = topic
}

// 双击进入编辑模式
function startEdit(topic: Topic) {
  editingTopicId.value = topic.id
  editingTitle.value = topic.title
}

// 确认编辑
async function confirmEdit(topic: Topic) {
  if (editingTopicId.value === null) return
  editingTopicId.value = null

  const newTitle = editingTitle.value.trim()
  if (!newTitle || newTitle === topic.title) return

  const { data } = await updateTopicApi(topic.id, newTitle)
  // 更新本地列表
  const index = topics.value.findIndex((t) => t.id === topic.id)
  if (index !== -1) topics.value[index] = data
  // 如果正在查看该话题,同步更新标题
  if (currentTopicId.value === topic.id) currentTopic.value = data
}

// 取消编辑
function cancelEdit() {
  editingTopicId.value = null
}

// 删除话题
async function deleteTopic(topicId: number) {
  await deleteTopicApi(topicId)
  topics.value = topics.value.filter((t) => t.id !== topicId)

  if (currentTopicId.value === topicId) {
    currentTopicId.value = null
    currentTopic.value = null
  }
}

// 退出登录
function handleLogout() {
  authStore.logout()
  router.push('/login')
}

onMounted(() => {
  loadTopics()
})
</script>

3.3 验证

启动前后端项目后,可以先执行以下 SQL 截断表,确保数据干净:

sql
SET REFERENTIAL_INTEGRITY FALSE;
TRUNCATE TABLE MESSAGES RESTART IDENTITY;
TRUNCATE TABLE TOPICS RESTART IDENTITY;
TRUNCATE TABLE USERS RESTART IDENTITY;
SET REFERENTIAL_INTEGRITY TRUE;

然后,打开浏览器 http://localhost:5173

  1. 登录(或注册新账号)
  2. 点击「+ 新对话」创建话题
  3. 双击话题标题进入编辑,修改标题后按回车或点击空白处确认
  4. 创建多个话题,点击切换
  5. 点击话题右侧的 ✕ 删除话题
  6. 退出登录,用另一个账号登录,验证看不到之前用户的话题
话题管理界面

4. 小结

完成了话题和消息的完整数据层以及话题管理的后端接口和前端页面,实现了用户数据隔离,确保每个用户只能访问自己的话题和消息。

新增内容
数据库 topics 表、messages 表
实体 Topic、Message(Java Record)
Repository TopicRepository(带 userId 隔离)、MessageRepository
Service TopicService(CRUD + 数据隔离)
Controller TopicController(5 个 RESTful 端点)
测试 TopicRepositoryTest、MessageRepositoryTest、TopicControllerTest
前端 API topic.ts(5 个请求函数)
前端页面 ChatView(话题列表 + 聊天区域布局)

接下来介绍 AI 对话与流式响应,实现消息发送、SSE 流式输出和深度思考模式的完整链路。