junboV2/docs/plans/2026-02-05-sqlite-migration-design.md

16 KiB
Raw Blame History

SQLite 数据迁移设计方案

概述

将 junboV2 项目的数据存储从 YAML/Excel 文件迁移到 SQLite 数据库,同时提供 Web 管理界面和双向同步能力。

技术选型

组件 技术
后端框架 Spring Boot
数据访问 Spring Data JPA + Hibernate
数据库 SQLite
前端框架 Vue 3
UI 组件库 Element Plus
使用场景 个人使用,无需认证

架构设计

整体架构

┌─────────────────────────────────────────────────────────┐
│                    Vue 3 Web 界面                        │
└─────────────────────┬───────────────────────────────────┘
                      │ REST API
┌─────────────────────▼───────────────────────────────────┐
│                 Spring Boot 服务层                       │
│   AttendanceService / OrganizationService / ...         │
└───┬─────────────────┼─────────────────┬─────────────────┘
    │                 │                 │
┌───▼───┐        ┌────▼────┐       ┌────▼────┐
│ Excel │        │  YAML   │       │ SQLite  │
│  DAO  │        │   DAO   │       │   DAO   │
└───┬───┘        └────┬────┘       └────┬────┘
    │                 │                 │
┌───▼───┐        ┌────▼────┐       ┌────▼────┐
│ .xlsx │        │  .yml   │       │  .db    │
└───────┘        └─────────┘       └─────────┘

设计要点

  • 多 DAO 并存:保留现有 Excel/YAML DAO新增 SQLite DAO
  • 业务层可按需选择数据源
  • 双向同步通过导入/导出功能实现

数据库设计

组织架构模块

-- 员工表
CREATE TABLE employee (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        VARCHAR(50) NOT NULL UNIQUE,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 员工别名表
CREATE TABLE employee_alias (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    employee_id INTEGER NOT NULL,
    alias       VARCHAR(50) NOT NULL,
    FOREIGN KEY (employee_id) REFERENCES employee(id)
);

-- 部门表
CREATE TABLE department (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        VARCHAR(100) NOT NULL UNIQUE
);

-- 部门层级关系表
CREATE TABLE department_relation (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    parent_id       INTEGER NOT NULL,
    child_id        INTEGER NOT NULL,
    effective_date  DATE NOT NULL,
    expiry_date     DATE,
    FOREIGN KEY (parent_id) REFERENCES department(id),
    FOREIGN KEY (child_id) REFERENCES department(id)
);

-- 员工部门关系表
CREATE TABLE employee_department (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    employee_id     INTEGER NOT NULL,
    department_id   INTEGER NOT NULL,
    effective_date  DATE NOT NULL,
    expiry_date     DATE,
    FOREIGN KEY (employee_id) REFERENCES employee(id),
    FOREIGN KEY (department_id) REFERENCES department(id)
);

考勤模块

-- 工作日历表
CREATE TABLE work_calendar (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    date        DATE NOT NULL UNIQUE,
    is_workday  BOOLEAN NOT NULL,
    is_weekend  BOOLEAN NOT NULL,
    is_holiday  BOOLEAN NOT NULL,
    holiday_name VARCHAR(50),
    remark      VARCHAR(200)
);

-- 签到记录表 (每次打卡一条记录)
CREATE TABLE sign_record (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    employee_id INTEGER NOT NULL,
    date        DATE NOT NULL,
    time        TIME NOT NULL,
    is_outside  BOOLEAN DEFAULT FALSE,
    remark      VARCHAR(500),
    FOREIGN KEY (employee_id) REFERENCES employee(id)
);

CREATE INDEX idx_sign_employee_date ON sign_record(employee_id, date);

-- 请假记录表
CREATE TABLE leave_record (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    employee_id     INTEGER NOT NULL,
    date            DATE NOT NULL,
    start_time      TIME NOT NULL,
    end_time        TIME NOT NULL,
    leave_type      VARCHAR(20) NOT NULL,
    reason          VARCHAR(500),
    approval_status VARCHAR(20),
    approval_result VARCHAR(20),
    remark          VARCHAR(500),
    FOREIGN KEY (employee_id) REFERENCES employee(id)
);

业务模块

-- Sprint 迭代表
CREATE TABLE sprint (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        VARCHAR(100) NOT NULL,
    start_date  DATE NOT NULL,
    end_date    DATE NOT NULL,
    remark      VARCHAR(500)
);

-- Story 用户故事表
CREATE TABLE story (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    sprint_id       INTEGER NOT NULL,
    title           VARCHAR(200) NOT NULL,
    story_point     DECIMAL(5,1),
    status          VARCHAR(20),
    remark          VARCHAR(500),
    FOREIGN KEY (sprint_id) REFERENCES sprint(id)
);

-- Story 角色关联表
CREATE TABLE story_member (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    story_id        INTEGER NOT NULL,
    employee_id     INTEGER NOT NULL,
    role            VARCHAR(20) NOT NULL,  -- PO/DEV/TEST/ACCEPT
    FOREIGN KEY (story_id) REFERENCES story(id),
    FOREIGN KEY (employee_id) REFERENCES employee(id)
);

-- KPA 记录主表
CREATE TABLE kpa_record (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    year            INTEGER NOT NULL,
    month           INTEGER NOT NULL,
    title           VARCHAR(200),
    description     TEXT,
    remark          VARCHAR(500),
    UNIQUE(year, month, title)
);

-- KPA 员工得分表
CREATE TABLE kpa_employee_score (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    kpa_record_id   INTEGER NOT NULL,
    employee_id     INTEGER NOT NULL,
    score           DECIMAL(5,2),
    level           VARCHAR(10),
    remark          VARCHAR(500),
    FOREIGN KEY (kpa_record_id) REFERENCES kpa_record(id),
    FOREIGN KEY (employee_id) REFERENCES employee(id)
);

-- 激励记录表
CREATE TABLE incentive_record (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    employee_id     INTEGER NOT NULL,
    date            DATE NOT NULL,
    type            VARCHAR(50),
    amount          DECIMAL(10,2),
    reason          VARCHAR(500),
    FOREIGN KEY (employee_id) REFERENCES employee(id)
);

JPA Entity 设计

组织架构模块

@Entity
@Table(name = "employee")
@Getter @Setter
public class Employee {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;

    @OneToMany(mappedBy = "employee", cascade = CascadeType.ALL)
    private List<EmployeeAlias> aliases = new ArrayList<>();

    @OneToMany(mappedBy = "employee")
    private List<EmployeeDepartment> departmentRelations = new ArrayList<>();

    private LocalDateTime createdAt;
}

@Entity
@Table(name = "employee_alias")
@Getter @Setter
public class EmployeeAlias {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "employee_id", nullable = false)
    private Employee employee;

    @Column(nullable = false)
    private String alias;
}

@Entity
@Table(name = "department")
@Getter @Setter
public class Department {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;
}

@Entity
@Table(name = "employee_department")
@Getter @Setter
public class EmployeeDepartment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "employee_id", nullable = false)
    private Employee employee;

    @ManyToOne
    @JoinColumn(name = "department_id", nullable = false)
    private Department department;

    @Column(nullable = false)
    private LocalDate effectiveDate;

    private LocalDate expiryDate;
}

考勤模块

@Entity
@Table(name = "work_calendar")
@Getter @Setter
public class WorkCalendar {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private LocalDate date;

    private boolean workday;
    private boolean weekend;
    private boolean holiday;
    private String holidayName;
    private String remark;
}

@Entity
@Table(name = "sign_record")
@Getter @Setter
public class SignRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "employee_id", nullable = false)
    private Employee employee;

    @Column(nullable = false)
    private LocalDate date;

    @Column(nullable = false)
    private LocalTime time;

    private boolean outside;
    private String remark;
}

@Entity
@Table(name = "leave_record")
@Getter @Setter
public class LeaveRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "employee_id", nullable = false)
    private Employee employee;

    @Column(nullable = false)
    private LocalDate date;

    @Column(nullable = false)
    private LocalTime startTime;

    @Column(nullable = false)
    private LocalTime endTime;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private LeaveType leaveType;

    private String reason;
    private String approvalStatus;
    private String approvalResult;
    private String remark;
}

public enum LeaveType {
    SICK, PERSONAL, VACATION, CHANGE_REST, OUT, MATERNITY, FUNERAL, MARRIAGE
}

业务模块

@Entity
@Table(name = "sprint")
@Getter @Setter
public class Sprint {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private LocalDate startDate;

    @Column(nullable = false)
    private LocalDate endDate;

    private String remark;

    @OneToMany(mappedBy = "sprint")
    private List<Story> stories = new ArrayList<>();
}

@Entity
@Table(name = "story")
@Getter @Setter
public class Story {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "sprint_id", nullable = false)
    private Sprint sprint;

    @Column(nullable = false)
    private String title;

    private BigDecimal storyPoint;
    private String status;
    private String remark;

    @OneToMany(mappedBy = "story", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<StoryMember> members = new ArrayList<>();
}

@Entity
@Table(name = "story_member")
@Getter @Setter
public class StoryMember {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "story_id", nullable = false)
    private Story story;

    @ManyToOne
    @JoinColumn(name = "employee_id", nullable = false)
    private Employee employee;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private StoryRole role;
}

public enum StoryRole {
    PO, DEV, TEST, ACCEPT
}

@Entity
@Table(name = "kpa_record")
@Getter @Setter
public class KpaRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Integer year;

    @Column(nullable = false)
    private Integer month;

    private String title;

    @Column(columnDefinition = "TEXT")
    private String description;

    private String remark;

    @OneToMany(mappedBy = "kpaRecord", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<KpaEmployeeScore> employeeScores = new ArrayList<>();
}

@Entity
@Table(name = "kpa_employee_score")
@Getter @Setter
public class KpaEmployeeScore {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "kpa_record_id", nullable = false)
    private KpaRecord kpaRecord;

    @ManyToOne
    @JoinColumn(name = "employee_id", nullable = false)
    private Employee employee;

    private BigDecimal score;
    private String level;
    private String remark;
}

@Entity
@Table(name = "incentive_record")
@Getter @Setter
public class IncentiveRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "employee_id", nullable = false)
    private Employee employee;

    @Column(nullable = false)
    private LocalDate date;

    private String type;
    private BigDecimal amount;
    private String reason;
}

Service 层设计

多数据源架构

public enum DataSourceType {
    SQLITE, EXCEL, YAML
}

@Service
@RequiredArgsConstructor
public class SignServiceImpl implements SignService {

    private final SignRecordRepository signRecordRepository;  // SQLite
    private final SignExcelDao signExcelDao;                  // Excel
    private final EmployeeRepository employeeRepository;

    @Value("${app.datasource.sign:SQLITE}")
    private DataSourceType defaultSource;

    public List<SignBo> getSignRecords(Employee employee, LocalDate start, LocalDate end,
                                        DataSourceType source) {
        return switch (source) {
            case SQLITE -> loadFromSqlite(employee, start, end);
            case EXCEL -> loadFromExcel(employee, start, end);
            default -> throw new IllegalArgumentException("Unsupported source: " + source);
        };
    }

    @Transactional
    public void importFromExcel(String filePath) {
        List<SignBo> excelData = signExcelDao.read2Bo();
        excelData.forEach(this::saveSignRecord);
    }

    public void exportToExcel(String filePath, LocalDate start, LocalDate end) {
        List<SignRecord> records = signRecordRepository
                .findByDateBetweenOrderByEmployeeAscDateAscTimeAsc(start, end);
        // 调用 Excel 写入工具
    }
}

REST API 设计

方法 路径 说明
GET /api/employees 员工列表
POST /api/employees 新增员工
PUT /api/employees/{id} 更新员工
DELETE /api/employees/{id} 删除员工
GET /api/departments 部门列表
GET /api/sign 签到记录列表
POST /api/sign 新增签到记录
GET /api/leaves 请假记录列表
POST /api/leaves 新增请假记录
GET /api/calendar 工作日历
GET /api/sprints Sprint 列表
GET /api/stories Story 列表
GET /api/kpa KPA 记录列表
GET /api/incentives 激励记录列表
POST /api/sync/import/yaml 从 YAML 导入
POST /api/sync/import/excel 从 Excel 导入
POST /api/sync/export/yaml 导出到 YAML
POST /api/sync/export/excel 导出到 Excel

前端设计

项目结构

junbo-web/
├── src/
│   ├── api/                    # API 调用
│   │   ├── employee.js
│   │   ├── sign.js
│   │   ├── leave.js
│   │   └── sync.js
│   ├── views/                  # 页面
│   │   ├── employee/
│   │   ├── attendance/
│   │   ├── scrum/
│   │   ├── kpa/
│   │   └── sync/
│   ├── components/             # 公共组件
│   ├── router/
│   └── App.vue
└── package.json

导航菜单

菜单 页面
组织架构 员工管理、部门管理
考勤管理 签到记录、请假记录、工作日历
Scrum Sprint、Story
绩效 KPA 记录、激励记录
数据同步 导入导出面板

实现阶段

阶段 内容 优先级
1 基础设施Spring Boot 项目、SQLite 配置、JPA Entity
2 组织架构模块:员工、部门 CRUD + YAML 导入
3 考勤模块:签到、请假 CRUD + Excel 导入
4 Vue 前端:基础框架 + 组织架构页面
5 其他业务模块Sprint、Story、KPA、Incentive
6 数据同步:完整的导入导出功能