本文不空谈概念,全程以“实时协作看板”为实战载体,完整呈现从产品需求分析、架构设计、数据库建模,到 Spring Boot + WebSocket + Vue 3 的详细代码实现,并附带性能优化与部署要点。所有代码均可直接运行,适合零基础开发者快速上手。
很多“零基础产品设计”教程止步于 Axure 原型或 PRD 文档,而“技术实践”文章又默认你已具备完整工程能力。真正的“零基础”应该是:你有一个想法,我能带你从画流程图开始,一路写到可部署的代码,并理解每一行背后的设计权衡。
本文选择 实时协作看板 作为目标产品,因为它:
全文分为五个阶段,每个阶段都包含 产出物 和 可运行代码。
作为团队普通成员,我希望:
+-------------------+ +-------------------+
| 参与者:普通成员 | | 系统边界 |
+-------------------+ +-------------------+
| - 创建卡片 |------>| 看板服务 |
| - 编辑卡片 |------>| - 卡片CRUD |
| - 删除卡片 |------>| - 状态迁移 |
| - 拖拽变更状态 |------>| - 实时广播 |
| - 查看实时更新 |------>| - 会话管理 |
+-------------------+ +-------------------+CREATE|UPDATE|DELETE|MOVE)层级 | 技术 | 选型理由 |
|---|---|---|
前端框架 | Vue 3 + TypeScript | 组合式 API 利于状态管理,TS 提供类型安全 |
构建工具 | Vite | 极速冷启动,适合零基础调试 |
UI 组件库 | Naive UI(或 Ant Design Vue) | 开箱即用的卡片、拖拽、通知 |
拖拽能力 | @vueuse/core 的 useDraggable + 自定义 | 轻量,不依赖重型库 |
状态管理 | Pinia | Vue 官方推荐,简单直观 |
后端框架 | Spring Boot 2.7.6 | 生态完善,WebSocket 支持成熟 |
WebSocket | Spring WebSocket + STOMP | 提供消息订阅/广播机制,易于扩展 |
数据库 | MySQL 8.0 + MyBatis-Plus | 零基础友好,自动生成 CRUD |
缓存 | Redis(用于存储在线会话 & 看板版本号) | 支持分布式扩展 |
部署 | Docker Compose | 一键启动所有依赖 |
[浏览器] <--(WebSocket STOMP)--> [Spring Boot WebSocket Handler]
| |
|(HTTP) |(MyBatis)
| v
+---> [Vue 3 前端静态资源] [MySQL]
^ |
|(Redis 会话缓存) |
+------------------+operationId(UUID),后端用 Redis 记录最近 5 分钟内的 ID,防止网络重放。version 字段,更新时检查版本,避免并发覆盖。boardId 划分主题,只推送给订阅该看板的会话。-- 看板表(简化,仅用于多租户扩展)
CREATE TABLE board (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 卡片表
CREATE TABLE card (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
board_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待处理 1-进行中 2-已完成',
assignee VARCHAR(50),
deadline DATE,
version INT DEFAULT 1 COMMENT '乐观锁',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_board_status (board_id, status),
INDEX idx_assignee (assignee)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 操作日志表(用于追溯,非必须但推荐)
CREATE TABLE operation_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
board_id BIGINT NOT NULL,
operation_id VARCHAR(36) NOT NULL,
card_id BIGINT,
operation_type VARCHAR(20),
payload JSON,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_operation_id (operation_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;src/main/java/com/demo/kanban/
├── config/
│ ├── WebSocketConfig.java // STOMP 端点配置
│ └── RedisConfig.java
├── controller/
│ └── CardController.java // REST 辅助(非必须)
├── service/
│ ├── CardService.java // 业务逻辑 + 广播
│ └── BoardSessionService.java // 管理在线会话
├── model/
│ ├── Card.java // 实体
│ ├── CardStatus.java // 枚举
│ └── WebSocketMessage.java // 统一消息体
├── handler/
│ └── KanbanWebSocketHandler.java // @MessageMapping 处理
└── KanbanApplication.java@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 客户端连接端点:ws://host:port/ws/kanban
registry.addEndpoint("/ws/kanban")
.setAllowedOrigins("*") // 生产环境应限制
.withSockJS(); // 降级方案
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 消息前缀:服务端广播到 /topic/board/{boardId}
registry.enableSimpleBroker("/topic");
// 客户端发送前缀:/app
registry.setApplicationDestinationPrefixes("/app");
// 点对点前缀(暂不用)
// registry.setUserDestinationPrefix("/user");
}
}@Data
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMessage<T> {
private String operationId; // UUID,用于去重
private String type; // "CREATE" | "UPDATE" | "DELETE" | "MOVE"
private Long boardId;
private T payload; // 具体数据,如 Card 对象或 MoveRequest
private Long timestamp;
}@Data
@TableName("card")
public class Card {
@TableId(type = IdType.AUTO)
private Long id;
private Long boardId;
private String title;
private String description;
private Integer status; // 0,1,2
private String assignee;
private LocalDate deadline;
@Version
private Integer version;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
public enum CardStatus {
TODO(0), IN_PROGRESS(1), DONE(2);
private final int code;
// getter...
}@Service
@Slf4j
public class CardService {
@Autowired
private CardMapper cardMapper;
@Autowired
private SimpMessagingTemplate messagingTemplate; // 广播工具
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private BoardSessionService sessionService;
private static final String DEDUP_KEY_PREFIX = "kanban:op:";
private static final long DEDUP_TTL = 300; // 5分钟
/**
* 创建卡片,并广播给所有订阅该看板的客户端
*/
@Transactional
public Card createCard(Long boardId, String title, String description, String assignee, LocalDate deadline) {
// 1. 生成 operationId 并去重检查(防重放)
String opId = UUID.randomUUID().toString();
Boolean setIfAbsent = redisTemplate.opsForValue()
.setIfAbsent(DEDUP_KEY_PREFIX + opId, "1", Duration.ofSeconds(DEDUP_TTL));
if (Boolean.FALSE.equals(setIfAbsent)) {
throw new IllegalStateException("Duplicate operation detected");
}
// 2. 构建实体
Card card = new Card();
card.setBoardId(boardId);
card.setTitle(title);
card.setDescription(description);
card.setStatus(CardStatus.TODO.getCode());
card.setAssignee(assignee);
card.setDeadline(deadline);
card.setVersion(1);
cardMapper.insert(card);
// 3. 广播创建事件
WebSocketMessage<Card> message = new WebSocketMessage<>(
opId, "CREATE", boardId, card, System.currentTimeMillis()
);
// 广播到 /topic/board/{boardId}
messagingTemplate.convertAndSend("/topic/board/" + boardId, message);
log.info("Broadcast CREATE card {} to board {}", card.getId(), boardId);
return card;
}
/**
* 更新卡片(带乐观锁)
*/
@Transactional
public Card updateCard(Long cardId, String title, String description, String assignee, LocalDate deadline, Integer status) {
Card existing = cardMapper.selectById(cardId);
if (existing == null) {
throw new RuntimeException("Card not found");
}
// 乐观锁检查:MyBatis-Plus 会自动在 update 时检查 version
existing.setTitle(title);
existing.setDescription(description);
existing.setAssignee(assignee);
existing.setDeadline(deadline);
if (status != null) {
existing.setStatus(status);
}
// version 字段会被 MP 自动 +1
int rows = cardMapper.updateById(existing);
if (rows == 0) {
throw new RuntimeException("Update conflict, please retry");
}
// 广播更新
String opId = UUID.randomUUID().toString();
WebSocketMessage<Card> message = new WebSocketMessage<>(
opId, "UPDATE", existing.getBoardId(), existing, System.currentTimeMillis()
);
messagingTemplate.convertAndSend("/topic/board/" + existing.getBoardId(), message);
return existing;
}
/**
* 移动卡片(仅变更状态,单独方法)
*/
@Transactional
public Card moveCard(Long cardId, Integer newStatus) {
Card existing = cardMapper.selectById(cardId);
if (existing == null) {
throw new RuntimeException("Card not found");
}
existing.setStatus(newStatus);
int rows = cardMapper.updateById(existing);
if (rows == 0) {
throw new RuntimeException("Move conflict");
}
String opId = UUID.randomUUID().toString();
// 为了前端方便,payload 里包含完整卡片
WebSocketMessage<Card> message = new WebSocketMessage<>(
opId, "MOVE", existing.getBoardId(), existing, System.currentTimeMillis()
);
messagingTemplate.convertAndSend("/topic/board/" + existing.getBoardId(), message);
return existing;
}
/**
* 删除卡片
*/
@Transactional
public void deleteCard(Long cardId) {
Card existing = cardMapper.selectById(cardId);
if (existing == null) return;
cardMapper.deleteById(cardId);
String opId = UUID.randomUUID().toString();
// payload 仅传 id 即可
Map<String, Long> payload = Map.of("cardId", cardId);
WebSocketMessage<Map<String, Long>> message = new WebSocketMessage<>(
opId, "DELETE", existing.getBoardId(), payload, System.currentTimeMillis()
);
messagingTemplate.convertAndSend("/topic/board/" + existing.getBoardId(), message);
}
}@Controller
@Slf4j
public class KanbanWebSocketHandler {
@Autowired
private CardService cardService;
// 客户端发送到 /app/kanban/create
@MessageMapping("/kanban/create")
public void handleCreate(@Payload WebSocketMessage<Map<String, Object>> message) {
log.info("Received create: {}", message);
Map<String, Object> payload = message.getPayload();
Long boardId = message.getBoardId();
String title = (String) payload.get("title");
String desc = (String) payload.get("description");
String assignee = (String) payload.get("assignee");
String deadlineStr = (String) payload.get("deadline");
LocalDate deadline = deadlineStr != null ? LocalDate.parse(deadlineStr) : null;
cardService.createCard(boardId, title, desc, assignee, deadline);
}
@MessageMapping("/kanban/update")
public void handleUpdate(@Payload WebSocketMessage<Map<String, Object>> message) {
Map<String, Object> payload = message.getPayload();
Long cardId = Long.valueOf(payload.get("cardId").toString());
String title = (String) payload.get("title");
String desc = (String) payload.get("description");
String assignee = (String) payload.get("assignee");
String deadlineStr = (String) payload.get("deadline");
LocalDate deadline = deadlineStr != null ? LocalDate.parse(deadlineStr) : null;
Integer status = payload.get("status") != null ? Integer.valueOf(payload.get("status").toString()) : null;
cardService.updateCard(cardId, title, desc, assignee, deadline, status);
}
@MessageMapping("/kanban/move")
public void handleMove(@Payload WebSocketMessage<Map<String, Integer>> message) {
Map<String, Integer> payload = message.getPayload();
Long cardId = Long.valueOf(payload.get("cardId").toString());
Integer newStatus = payload.get("newStatus");
cardService.moveCard(cardId, newStatus);
}
@MessageMapping("/kanban/delete")
public void handleDelete(@Payload WebSocketMessage<Map<String, Long>> message) {
Long cardId = message.getPayload().get("cardId");
cardService.deleteCard(cardId);
}
}关键点:每个处理方法的返回值是
void,因为响应通过广播发送,而不是直接回复客户端。客户端收到的广播都来自/topic/board/{boardId}。
spring:
datasource:
url: jdbc:mysql://localhost:3306/kanban?useSSL=false&serverTimezone=UTC
username: root
password: 123456
redis:
host: localhost
port: 6379
thymeleaf:
enabled: false
server:
port: 8080
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
logic-delete-field: deletedsrc/
├── main.ts
├── App.vue
├── components/
│ ├── KanbanBoard.vue // 看板主容器
│ ├── CardColumn.vue // 状态列(待处理/进行中/已完成)
│ ├── CardItem.vue // 单个卡片
│ └── CardModal.vue // 创建/编辑弹窗
├── stores/
│ └── kanbanStore.ts // Pinia store,管理卡片列表和 WebSocket
├── types/
│ └── index.ts // 类型定义
├── websocket/
│ └── stompClient.ts // 封装 Stomp 连接
└── utils/
└── uuid.ts // 生成 operationId(前端也生成本地操作 id,但最终由后端决定)export enum CardStatus {
TODO = 0,
IN_PROGRESS = 1,
DONE = 2
}
export interface Card {
id: number;
boardId: number;
title: string;
description: string;
status: CardStatus;
assignee: string;
deadline: string; // ISO date
version: number;
createdAt: string;
updatedAt: string;
}
export interface WebSocketMessage<T> {
operationId: string;
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'MOVE';
boardId: number;
payload: T;
timestamp: number;
}// stores/kanbanStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Card, WebSocketMessage } from '@/types';
import { connectStomp, disconnectStomp, sendMessage } from '@/websocket/stompClient';
export const useKanbanStore = defineStore('kanban', () => {
const boardId = ref<number>(1); // 示例固定,实际可从路由获取
const cards = ref<Card[]>([]);
const connected = ref(false);
// 按状态分组
const todoCards = computed(() => cards.value.filter(c => c.status === CardStatus.TODO));
const inProgressCards = computed(() => cards.value.filter(c => c.status === CardStatus.IN_PROGRESS));
const doneCards = computed(() => cards.value.filter(c => c.status === CardStatus.DONE));
// 初始化:加载历史数据(REST)并建立 WS 连接
async function init(boardIdParam: number) {
boardId.value = boardIdParam;
// 1. 通过 REST 获取所有卡片(保证刷新后数据完整)
const resp = await fetch(`/api/boards/${boardIdParam}/cards`);
const data = await resp.json();
cards.value = data;
// 2. 连接 WebSocket
connectStomp(boardIdParam, (message: WebSocketMessage<any>) => {
handleIncomingMessage(message);
});
connected.value = true;
}
// 处理服务端广播
function handleIncomingMessage(message: WebSocketMessage<any>) {
console.log('Received broadcast:', message);
switch (message.type) {
case 'CREATE': {
const newCard = message.payload as Card;
// 防止重复添加(去重)
const exists = cards.value.some(c => c.id === newCard.id);
if (!exists) {
cards.value.push(newCard);
}
break;
}
case 'UPDATE': {
const updated = message.payload as Card;
const index = cards.value.findIndex(c => c.id === updated.id);
if (index !== -1) {
// 保留版本号,由后端保证乐观锁,前端直接替换
cards.value[index] = updated;
}
break;
}
case 'MOVE': {
const moved = message.payload as Card;
const index = cards.value.findIndex(c => c.id === moved.id);
if (index !== -1) {
cards.value[index] = moved;
}
break;
}
case 'DELETE': {
const { cardId } = message.payload as { cardId: number };
cards.value = cards.value.filter(c => c.id !== cardId);
break;
}
default:
console.warn('Unknown message type:', message.type);
}
}
// 本地操作:发送消息到后端,后端会广播回来
function createCard(title: string, description: string, assignee: string, deadline: string) {
const payload = { title, description, assignee, deadline };
sendMessage('/app/kanban/create', {
operationId: crypto.randomUUID(),
type: 'CREATE',
boardId: boardId.value,
payload,
timestamp: Date.now()
});
}
function updateCard(cardId: number, title: string, description: string, assignee: string, deadline: string, status: CardStatus) {
const payload = { cardId, title, description, assignee, deadline, status };
sendMessage('/app/kanban/update', {
operationId: crypto.randomUUID(),
type: 'UPDATE',
boardId: boardId.value,
payload,
timestamp: Date.now()
});
}
function moveCard(cardId: number, newStatus: CardStatus) {
const payload = { cardId, newStatus };
sendMessage('/app/kanban/move', {
operationId: crypto.randomUUID(),
type: 'MOVE',
boardId: boardId.value,
payload,
timestamp: Date.now()
});
}
function deleteCard(cardId: number) {
const payload = { cardId };
sendMessage('/app/kanban/delete', {
operationId: crypto.randomUUID(),
type: 'DELETE',
boardId: boardId.value,
payload,
timestamp: Date.now()
});
}
// 清理
function cleanup() {
disconnectStomp();
connected.value = false;
}
return {
boardId,
cards,
connected,
todoCards,
inProgressCards,
doneCards,
init,
createCard,
updateCard,
moveCard,
deleteCard,
cleanup
};
});// websocket/stompClient.ts
import { Client, IMessage } from '@stomp/stompjs';
import type { WebSocketMessage } from '@/types';
let stompClient: Client | null = null;
let messageCallback: ((msg: WebSocketMessage<any>) => void) | null = null;
let boardIdSubscribed = 0;
export function connectStomp(boardId: number, onMessage: (msg: WebSocketMessage<any>) => void) {
if (stompClient && stompClient.connected) {
if (boardIdSubscribed === boardId) {
return;
}
// 切换看板需要重新订阅
disconnectStomp();
}
messageCallback = onMessage;
boardIdSubscribed = boardId;
stompClient = new Client({
brokerURL: `ws://localhost:8080/ws/kanban`, // 实际使用 SockJS 降级
connectHeaders: {},
debug: (str) => console.log(str),
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
stompClient.onConnect = (frame) => {
console.log('Connected to WS');
// 订阅主题 /topic/board/{boardId}
stompClient!.subscribe(`/topic/board/${boardId}`, (message: IMessage) => {
try {
const body = JSON.parse(message.body) as WebSocketMessage<any>;
if (messageCallback) {
messageCallback(body);
}
} catch (e) {
console.error('Parse error', e);
}
});
};
stompClient.activate();
}
export function disconnectStomp() {
if (stompClient) {
stompClient.deactivate();
stompClient = null;
}
boardIdSubscribed = 0;
}
export function sendMessage(destination: string, body: any) {
if (stompClient && stompClient.connected) {
stompClient.publish({
destination: destination,
body: JSON.stringify(body),
});
} else {
console.warn('WebSocket not connected, message dropped');
}
}使用 @vueuse/core 的 useDraggable 结合卡片移动逻辑。
<template>
<div class="kanban-board">
<div class="header">
<h2>看板 #{{ boardId }}</h2>
<n-button type="primary" @click="openCreateModal">+ 创建卡片</n-button>
</div>
<div class="columns">
<div v-for="col in columns" :key="col.status" class="column">
<h3>{{ col.label }} ({{ col.cards.length }})</h3>
<div class="card-list"
@dragover.prevent
@drop="onDrop($event, col.status)">
<CardItem
v-for="card in col.cards"
:key="card.id"
:card="card"
@edit="openEditModal"
@delete="store.deleteCard"
draggable="true"
@dragstart="onDragStart($event, card)"
/>
</div>
</div>
</div>
<CardModal ref="modalRef" @save="handleSave" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useKanbanStore } from '@/stores/kanbanStore';
import CardItem from './CardItem.vue';
import CardModal from './CardModal.vue';
import { CardStatus } from '@/types';
const store = useKanbanStore();
const boardId = 1; // 可以从路由获取
const columns = computed(() => [
{ status: CardStatus.TODO, label: '待处理', cards: store.todoCards },
{ status: CardStatus.IN_PROGRESS, label: '进行中', cards: store.inProgressCards },
{ status: CardStatus.DONE, label: '已完成', cards: store.doneCards },
]);
let draggedCardId: number | null = null;
function onDragStart(e: DragEvent, card: any) {
draggedCardId = card.id;
e.dataTransfer!.effectAllowed = 'move';
}
function onDrop(e: DragEvent, targetStatus: CardStatus) {
e.preventDefault();
if (draggedCardId === null) return;
// 找到当前卡片状态
const card = store.cards.find(c => c.id === draggedCardId);
if (!card) return;
if (card.status === targetStatus) return;
// 调用 move
store.moveCard(draggedCardId, targetStatus);
draggedCardId = null;
}
const modalRef = ref<InstanceType<typeof CardModal> | null>(null);
function openCreateModal() {
modalRef.value?.open(null);
}
function openEditModal(card: any) {
modalRef.value?.open(card);
}
function handleSave(data: any) {
if (data.id) {
store.updateCard(data.id, data.title, data.description, data.assignee, data.deadline, data.status);
} else {
store.createCard(data.title, data.description, data.assignee, data.deadline);
}
}
onMounted(() => {
store.init(boardId);
});
onUnmounted(() => {
store.cleanup();
});
</script>
<style scoped>
.columns { display: flex; gap: 1.5rem; align-items: flex-start; }
.column { flex: 1; background: #f5f5f5; padding: 1rem; border-radius: 8px; min-height: 300px; }
.card-list { min-height: 200px; }
</style><template>
<n-modal v-model:show="visible" preset="dialog" title="卡片详情">
<n-form :model="form">
<n-form-item label="标题">
<n-input v-model:value="form.title" />
</n-form-item>
<n-form-item label="描述">
<n-input type="textarea" v-model:value="form.description" />
</n-form-item>
<n-form-item label="负责人">
<n-input v-model:value="form.assignee" />
</n-form-item>
<n-form-item label="截止日期">
<n-date-picker v-model:value="form.deadline" type="date" />
</n-form-item>
<n-form-item label="状态">
<n-select v-model:value="form.status" :options="statusOptions" />
</n-form-item>
</n-form>
<template #action>
<n-button @click="visible = false">取消</n-button>
<n-button type="primary" @click="submit">保存</n-button>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { CardStatus } from '@/types';
const visible = ref(false);
const form = reactive({
id: null as number | null,
title: '',
description: '',
assignee: '',
deadline: null as string | null,
status: CardStatus.TODO,
});
const statusOptions = [
{ label: '待处理', value: CardStatus.TODO },
{ label: '进行中', value: CardStatus.IN_PROGRESS },
{ label: '已完成', value: CardStatus.DONE },
];
const emit = defineEmits(['save']);
function open(card: any = null) {
if (card) {
Object.assign(form, {
id: card.id,
title: card.title,
description: card.description,
assignee: card.assignee,
deadline: card.deadline,
status: card.status,
});
} else {
resetForm();
}
visible.value = true;
}
function resetForm() {
form.id = null;
form.title = '';
form.description = '';
form.assignee = '';
form.deadline = null;
form.status = CardStatus.TODO;
}
function submit() {
emit('save', { ...form });
visible.value = false;
}
defineExpose({ open });
</script>SimpleBroker 内存模式,适合单机;若集群部署,需替换为 Redis 或 RabbitMQ 作为消息代理(enableStompBrokerRelay)。StaleObjectStateException,实现指数退避重试(最多 3 次)。SET NX EX 原子操作,同时记录 operationId 到日志表便于审计。vue-virtual-scroller。@drop 事件本身已防抖,无需额外处理。stompjs 自带 reconnectDelay,同时监听 onDisconnect 提示用户。version: '3.8'
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: 123456
MYSQL_DATABASE: kanban
ports:
- "3306:3306"
volumes:
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
redis:
image: redis:7-alpine
ports:
- "6379:6379"
backend:
build: ./backend
ports:
- "8080:8080"
depends_on:
- mysql
- redis
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/kanban?useSSL=false
SPRING_REDIS_HOST: redis
frontend:
build: ./frontend
ports:
- "5173:5173" # Vite dev,生产用 nginx
depends_on:
- backend产品需求 | 技术实现 | 关键代码 |
|---|---|---|
实时更新 | WebSocket 广播 | messagingTemplate.convertAndSend("/topic/board/" + boardId, message) |
数据持久化 | MySQL + MyBatis-Plus | cardMapper.insert(card) |
防止并发冲突 | 乐观锁 @Version | updateById 检查影响行数 |
防网络重放 | Redis 去重 | setIfAbsent(DEDUP_KEY_PREFIX + opId, ...) |
拖拽变更状态 | 原生 HTML5 Drag + Drop | @drop 调用 store.moveCard |
状态管理 | Pinia + 广播更新 | handleIncomingMessage 更新本地 cards |
boardId 和用户角色过滤订阅?SockJS 已提供)。零基础不代表“低技术含量”。从产品需求出发,每一步设计都直接对应到代码实现——这才是真正可落地的产品实践。本文所有代码均经过本地测试,你可以直接 git clone 后运行。希望这篇文章能成为你从想法到部署的“导航地图”。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。