feat(open-api): 新增对外开放接口及项目日报同步功能

- 新增项目日报表及其防重唯一索引,支持外部系统同步日报数据
- 添加项目日报实体类及对应 Mapper 和 XML 配置
- 新增对外开放接口控制器 OpenApiController,实现项目列表查询及日报同步接口
- 实现 OpenApiService 服务及其实现类,包含用户项目查询和日报防重同步逻辑
- 扩展 ProjectMapper,支持根据用户名查询用户关联项目列表
- 配置 SaToken 过滤白名单,放行 /api/open/** 路径无登录验证
- 引入 spring-boot-starter-validation 依赖,支持请求参数校验
- 创建数据传输对象 DailyReportSyncDTO,带参数校验注解
- 日志记录和异常处理增强,保证数据同步和查询的健壮性
This commit is contained in:
2026-03-31 15:45:36 +08:00
parent 135e723c64
commit 88c9fe5e06
13 changed files with 541 additions and 1 deletions

View File

@@ -1337,4 +1337,30 @@ INSERT INTO sys_config (config_key, config_value, config_type, description) VALU
('ai.embedding.model', 'text-embedding-v4', 'ai', '向量嵌入模型'), ('ai.embedding.model', 'text-embedding-v4', 'ai', '向量嵌入模型'),
('ai.embedding.dimension', '1024', 'ai', '向量维度'), ('ai.embedding.dimension', '1024', 'ai', '向量维度'),
('ai.rag.top_k', '5', 'ai', 'RAG检索返回数量'), ('ai.rag.top_k', '5', 'ai', 'RAG检索返回数量'),
('ai.rag.similarity_threshold', '0.7', 'ai', 'RAG相似度阈'); ('ai.rag.similarity_threshold', '0.7', 'ai', 'RAG相似度阈');
-- =====================================================
-- 项目日报表(供外部系统同步日报数据)
-- 防重键: (project_id, report_date, submitter_username)
-- =====================================================
CREATE TABLE IF NOT EXISTS project_daily_report (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL,
submitter_username VARCHAR(100) NOT NULL,
submitter_id BIGINT,
report_date DATE NOT NULL,
work_content TEXT,
tomorrow_plan TEXT,
work_intensity INTEGER CHECK (work_intensity BETWEEN 1 AND 5),
need_help BOOLEAN DEFAULT FALSE,
help_content TEXT,
create_time TIMESTAMP DEFAULT NOW(),
update_time TIMESTAMP DEFAULT NOW(),
deleted INTEGER DEFAULT 0,
CONSTRAINT uq_daily_report UNIQUE (project_id, report_date, submitter_username)
);
COMMENT ON TABLE project_daily_report IS '项目日报(外部同步)';
COMMENT ON COLUMN project_daily_report.submitter_username IS '提交人用户名 (对应 sys_user.username)';
COMMENT ON COLUMN project_daily_report.work_intensity IS '工作强度: 1-轻松 2-较轻 3-适中 4-繁忙 5-非常繁忙';

View File

@@ -34,6 +34,12 @@
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Lombok --> <!-- Lombok -->
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>

View File

@@ -31,6 +31,7 @@ public class SaTokenConfig implements WebMvcConfigurer {
"/api/v1/auth/login", "/api/v1/auth/login",
"/api/v1/auth/register", "/api/v1/auth/register",
"/api/v1/auth/feishu/login", "/api/v1/auth/feishu/login",
"/api/open/**",
"/error", "/error",
"/swagger-ui/**", "/swagger-ui/**",
"/v3/api-docs/**" "/v3/api-docs/**"

View File

@@ -0,0 +1,72 @@
package cn.yinlihupo.controller.open;
import cn.yinlihupo.common.core.BaseResponse;
import cn.yinlihupo.common.util.ResultUtils;
import cn.yinlihupo.domain.dto.DailyReportSyncDTO;
import cn.yinlihupo.domain.vo.OpenProjectVO;
import cn.yinlihupo.service.open.OpenApiService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 对外开放接口
* 路径前缀 /api/open/** 已在 SaTokenConfig 中放行,无需登录鉴权
*/
@Slf4j
@RestController
@RequestMapping("/api/open")
@RequiredArgsConstructor
public class OpenApiController {
private final OpenApiService openApiService;
/**
* 根据用户ID查询所在的项目列表
* <p>
* 请求示例GET /api/open/projects/by-user?userId=zhangsan
*
* @param userId 用户标识(对应 sys_user.username
* @return 项目列表
*/
@GetMapping("/projects/by-user")
public BaseResponse<List<OpenProjectVO>> getProjectsByUser(
@RequestParam("userId") String userId) {
log.info("[OpenApi] 查询用户项目列表, userId={}", userId);
List<OpenProjectVO> projects = openApiService.getProjectsByUserId(userId);
return ResultUtils.success("查询成功", projects);
}
/**
* 同步日报数据
* <p>
* 请求示例POST /api/open/daily-report/sync
* <pre>
* {
* "projectId": 123,
* "userId": "zhangsan",
* "reportDate": "2026-03-31",
* "workContent": "完成了XXX功能开发",
* "tomorrowPlan": "继续YYY模块",
* "workIntensity": 3,
* "needHelp": false,
* "helpContent": null
* }
* </pre>
* 防重规则:同一项目+同一日期+同一用户只能提交一次,重复提交返回错误提示
*
* @param dto 日报数据
* @return 操作结果
*/
@PostMapping("/daily-report/sync")
public BaseResponse<String> syncDailyReport(
@RequestBody @Valid DailyReportSyncDTO dto) {
log.info("[OpenApi] 日报同步请求, projectId={}, userId={}, reportDate={}",
dto.getProjectId(), dto.getUserId(), dto.getReportDate());
String result = openApiService.syncDailyReport(dto);
return ResultUtils.success(result, null);
}
}

View File

@@ -0,0 +1,62 @@
package cn.yinlihupo.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Data;
import java.time.LocalDate;
/**
* 日报数据同步 DTO供外部系统调用
*/
@Data
public class DailyReportSyncDTO {
/**
* 项目ID必填
*/
@NotNull(message = "项目ID不能为空")
private Long projectId;
/**
* 用户标识(对应 sys_user.username必填
*/
@NotBlank(message = "用户ID不能为空")
private String userId;
/**
* 日报日期(必填)
*/
@NotNull(message = "日报日期不能为空")
private LocalDate reportDate;
/**
* 工作内容(必填)
*/
@NotBlank(message = "工作内容不能为空")
private String workContent;
/**
* 明日计划
*/
private String tomorrowPlan;
/**
* 工作强度 1-5 (1-轻松, 2-较轻, 3-适中, 4-繁忙, 5-非常繁忙)
*/
@Min(value = 1, message = "工作强度最小为1")
@Max(value = 5, message = "工作强度最大为5")
private Integer workIntensity;
/**
* 是否需要协助
*/
private Boolean needHelp;
/**
* 协助内容needHelp 为 true 时填写)
*/
private String helpContent;
}

View File

@@ -0,0 +1,83 @@
package cn.yinlihupo.domain.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 项目日报实体类
* 对应数据库表: project_daily_report
* 防重键: (project_id, report_date, submitter_username)
*/
@Data
@TableName("project_daily_report")
public class ProjectDailyReport {
@TableId(type = IdType.AUTO)
private Long id;
/**
* 项目ID
*/
private Long projectId;
/**
* 提交人用户名 (对应 sys_user.username)
*/
private String submitterUsername;
/**
* 提交人ID (冗余,方便查询)
*/
private Long submitterId;
/**
* 日报日期
*/
private LocalDate reportDate;
/**
* 工作内容
*/
private String workContent;
/**
* 明日计划
*/
private String tomorrowPlan;
/**
* 工作强度 1-5 (1-轻松, 2-较轻, 3-适中, 4-繁忙, 5-非常繁忙)
*/
private Integer workIntensity;
/**
* 是否需要协助
*/
private Boolean needHelp;
/**
* 协助内容
*/
private String helpContent;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 删除标记
*/
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,62 @@
package cn.yinlihupo.domain.vo;
import lombok.Data;
import java.time.LocalDate;
/**
* 开放接口-项目列表 VO对外简化字段供外部系统使用
*/
@Data
public class OpenProjectVO {
/**
* 项目ID
*/
private Long id;
/**
* 项目编号
*/
private String projectCode;
/**
* 项目名称
*/
private String projectName;
/**
* 项目类型
*/
private String projectType;
/**
* 项目状态: draft-草稿, planning-规划中, ongoing-进行中, paused-暂停, completed-已完成, cancelled-已取消
*/
private String status;
/**
* 优先级: critical-关键, high-高, medium-中, low-低
*/
private String priority;
/**
* 计划开始日期
*/
private LocalDate planStartDate;
/**
* 计划结束日期
*/
private LocalDate planEndDate;
/**
* 项目进度百分比
*/
private Integer progress;
/**
* 该用户在项目中的角色: manager-项目经理, leader-负责人, member-成员, observer-观察者
*/
private String myRole;
}

View File

@@ -0,0 +1,27 @@
package cn.yinlihupo.mapper;
import cn.yinlihupo.domain.entity.ProjectDailyReport;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
/**
* 项目日报 Mapper 接口
*/
@Mapper
public interface ProjectDailyReportMapper extends BaseMapper<ProjectDailyReport> {
/**
* 防重检查:查询同项目+同日期+同用户的日报数量
*
* @param projectId 项目ID
* @param reportDate 日报日期
* @param submitterUsername 提交人用户名
* @return 记录数量大于0表示已存在
*/
int countByUniqueKey(@Param("projectId") Long projectId,
@Param("reportDate") LocalDate reportDate,
@Param("submitterUsername") String submitterUsername);
}

View File

@@ -45,4 +45,13 @@ public interface ProjectMapper extends BaseMapper<Project> {
* 查询即将超期的项目N天内 * 查询即将超期的项目N天内
*/ */
List<Project> selectAboutToExpire(@Param("days") int days); List<Project> selectAboutToExpire(@Param("days") int days);
/**
* 根据 sys_user.username 联查用户所在的项目列表
* (包含作为项目经理或项目成员的项目)
*
* @param username 用户名 (对应 sys_user.username)
* @return 项目列表
*/
List<Project> selectProjectsByUsername(@Param("username") String username);
} }

View File

@@ -0,0 +1,28 @@
package cn.yinlihupo.service.open;
import cn.yinlihupo.domain.dto.DailyReportSyncDTO;
import cn.yinlihupo.domain.vo.OpenProjectVO;
import java.util.List;
/**
* 开放接口服务(供外部系统调用,无需登录鉴权)
*/
public interface OpenApiService {
/**
* 根据用户标识 (sys_user.username) 查询用户所在的项目列表
*
* @param userId 用户标识(对应 sys_user.username
* @return 项目列表
*/
List<OpenProjectVO> getProjectsByUserId(String userId);
/**
* 同步日报数据到库(带防重设计)
*
* @param dto 日报数据
* @return 操作结果描述
*/
String syncDailyReport(DailyReportSyncDTO dto);
}

View File

@@ -0,0 +1,134 @@
package cn.yinlihupo.service.open.impl;
import cn.yinlihupo.common.exception.BusinessException;
import cn.yinlihupo.domain.dto.DailyReportSyncDTO;
import cn.yinlihupo.domain.entity.Project;
import cn.yinlihupo.domain.entity.ProjectDailyReport;
import cn.yinlihupo.domain.entity.SysUser;
import cn.yinlihupo.domain.vo.OpenProjectVO;
import cn.yinlihupo.mapper.ProjectDailyReportMapper;
import cn.yinlihupo.mapper.ProjectMapper;
import cn.yinlihupo.mapper.ProjectMemberMapper;
import cn.yinlihupo.mapper.SysUserMapper;
import cn.yinlihupo.service.open.OpenApiService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
/**
* 开放接口服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OpenApiServiceImpl implements OpenApiService {
private final SysUserMapper sysUserMapper;
private final ProjectMapper projectMapper;
private final ProjectMemberMapper projectMemberMapper;
private final ProjectDailyReportMapper projectDailyReportMapper;
/**
* 根据用户标识 (sys_user.username) 查询用户所在的项目列表
*/
@Override
public List<OpenProjectVO> getProjectsByUserId(String userId) {
if (!StringUtils.hasText(userId)) {
return new ArrayList<>();
}
// 1. 先验证用户是否存在
SysUser user = sysUserMapper.selectByUsername(userId);
if (user == null) {
log.warn("[OpenApi] 用户不存在, username={}", userId);
return new ArrayList<>();
}
// 2. 联查用户所在的项目列表(通过 username 关联 sys_user -> project/project_member
List<Project> projects = projectMapper.selectProjectsByUsername(userId);
if (projects == null || projects.isEmpty()) {
return new ArrayList<>();
}
// 3. 转换为 OpenProjectVO 并填充用户角色
List<OpenProjectVO> result = new ArrayList<>();
for (Project project : projects) {
OpenProjectVO vo = new OpenProjectVO();
vo.setId(project.getId());
vo.setProjectCode(project.getProjectCode());
vo.setProjectName(project.getProjectName());
vo.setProjectType(project.getProjectType());
vo.setStatus(project.getStatus());
vo.setPriority(project.getPriority());
vo.setPlanStartDate(project.getPlanStartDate());
vo.setPlanEndDate(project.getPlanEndDate());
vo.setProgress(project.getProgress());
// 4. 填充用户在该项目的角色
if (project.getManagerId() != null && project.getManagerId().equals(user.getId())) {
vo.setMyRole("manager");
} else {
String role = projectMemberMapper.selectRoleByUserAndProject(project.getId(), user.getId());
vo.setMyRole(role);
}
result.add(vo);
}
log.info("[OpenApi] 查询用户项目列表成功, username={}, 项目数={}", userId, result.size());
return result;
}
/**
* 同步日报数据到库(带防重设计)
*/
@Override
public String syncDailyReport(DailyReportSyncDTO dto) {
String username = dto.getUserId();
// 1. 校验用户是否存在
SysUser user = sysUserMapper.selectByUsername(username);
if (user == null) {
log.warn("[OpenApi] 日报同步失败,用户不存在, username={}", username);
throw new BusinessException("用户不存在: " + username);
}
// 2. 校验项目是否存在
Project project = projectMapper.selectById(dto.getProjectId());
if (project == null || project.getDeleted() == 1) {
log.warn("[OpenApi] 日报同步失败,项目不存在, projectId={}", dto.getProjectId());
throw new BusinessException("项目不存在: " + dto.getProjectId());
}
// 3. 防重检查:同一用户同一天同一项目只能提交一条日报
int count = projectDailyReportMapper.countByUniqueKey(
dto.getProjectId(), dto.getReportDate(), username);
if (count > 0) {
log.warn("[OpenApi] 日报重复提交, projectId={}, reportDate={}, username={}",
dto.getProjectId(), dto.getReportDate(), username);
throw new BusinessException("日报已提交请勿重复提交项目ID: "
+ dto.getProjectId() + ",日期: " + dto.getReportDate() + "");
}
// 4. 入库保存
ProjectDailyReport report = new ProjectDailyReport();
report.setProjectId(dto.getProjectId());
report.setSubmitterUsername(username);
report.setSubmitterId(user.getId());
report.setReportDate(dto.getReportDate());
report.setWorkContent(dto.getWorkContent());
report.setTomorrowPlan(dto.getTomorrowPlan());
report.setWorkIntensity(dto.getWorkIntensity());
report.setNeedHelp(dto.getNeedHelp());
report.setHelpContent(dto.getHelpContent());
projectDailyReportMapper.insert(report);
log.info("[OpenApi] 日报同步成功, projectId={}, reportDate={}, username={}",
dto.getProjectId(), dto.getReportDate(), username);
return "日报同步成功";
}
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.yinlihupo.mapper.ProjectDailyReportMapper">
<!-- 防重检查:查询同项目+同日期+同用户的日报数量(不含已逻辑删除的记录) -->
<select id="countByUniqueKey" resultType="int">
SELECT COUNT(*)
FROM project_daily_report
WHERE project_id = #{projectId}
AND report_date = #{reportDate}
AND submitter_username = #{submitterUsername}
AND deleted = 0
</select>
</mapper>

View File

@@ -107,4 +107,18 @@
ORDER BY plan_end_date ASC ORDER BY plan_end_date ASC
</select> </select>
<!-- 根据 sys_user.username 联查用户所在的项目列表 -->
<select id="selectProjectsByUsername" resultMap="BaseResultMap">
SELECT DISTINCT p.*
FROM project p
LEFT JOIN project_member pm ON p.id = pm.project_id AND pm.deleted = 0 AND pm.status = 1
INNER JOIN sys_user u ON u.username = #{username} AND u.deleted = 0 AND u.status = 1
WHERE p.deleted = 0
AND (
p.manager_id = u.id
OR pm.user_id = u.id
)
ORDER BY p.create_time DESC
</select>
</mapper> </mapper>