新增产品QC

This commit is contained in:
2026-02-27 17:41:08 +08:00
parent 7d5a62f8f2
commit fa16fec0a0
24 changed files with 1111 additions and 8 deletions

View File

@@ -30,6 +30,8 @@ public class SaTokenConfig implements WebMvcConfigurer {
.excludePathPatterns("/user/login", "/user/register", "/user/sendCode", "/user/forgetPassword", "/user/codeLogin")
// 排除后台管理登录接口
.excludePathPatterns("/sys/user/login")
//排除QC接口
.excludePathPatterns("/api/qc/**")
// 排除反馈接口(支持匿名提交)
.excludePathPatterns("/feedback", "/feedback/**")
// 排除教程接口(支持匿名查询)

View File

@@ -0,0 +1,19 @@
package com.corewing.app.dto.qc;
import lombok.Data;
@Data
public class StepResultData {
private Integer stepIndex;
private String stepName;
private String result;
private String dataJson;
private Long duration;
private String completedAt;
}

View File

@@ -0,0 +1,35 @@
package com.corewing.app.dto.qc;
import lombok.Data;
import java.util.List;
@Data
public class UploadTestRecordRequest {
private String testNo;
private String productType;
private String sn;
private String operatorId;
private String operatorName;
private String phoneModel;
private String appVersion;
private String status;
private String createTime;
private String updatedAt;
private String uploadTime;
private List<StepResultData> steps;
private List<String> photoUrls;
}

View File

@@ -0,0 +1,9 @@
package com.corewing.app.dto.qc;
import lombok.Data;
@Data
public class ValidateSnRequest {
private String sn;
}

View File

@@ -0,0 +1,8 @@
package com.corewing.app.dto.qc;
import lombok.Data;
@Data
public class ValidateWirelessBoardRequest {
private String mac;
}

View File

@@ -0,0 +1,28 @@
package com.corewing.app.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("flight_controller")
public class FlightController {
@TableId(type = IdType.AUTO)
private Long id;
private String sn;
private String model;
private String batchNo;
private String status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,24 @@
package com.corewing.app.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("qc_photo")
public class QcPhoto {
@TableId(type = IdType.AUTO)
private Long id;
private Long testRecordId;
private String testNo;
private String url;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,30 @@
package com.corewing.app.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("qc_step_result")
public class QcStepResult {
@TableId(type = IdType.AUTO)
private Long id;
private Long testRecordId;
private Integer stepIndex;
private String stepName;
private String result;
private String dataJson;
private Long duration;
private LocalDateTime completedAt;
}

View File

@@ -0,0 +1,42 @@
package com.corewing.app.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("qc_test_record")
public class QcTestRecord {
@TableId(type = IdType.AUTO)
private Long id;
private String testNo;
private String productType;
private String sn;
private String operatorId;
private String operatorName;
private String phoneModel;
private String appVersion;
private String status;
private LocalDateTime createTime;
private LocalDateTime updatedAt;
private LocalDateTime uploadTime;
private String reportUrl;
private LocalDateTime serverCreateTime;
}

View File

@@ -0,0 +1,9 @@
package com.corewing.app.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.corewing.app.entity.FlightController;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FlightControllerMapper extends BaseMapper<FlightController> {
}

View File

@@ -0,0 +1,9 @@
package com.corewing.app.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.corewing.app.entity.QcPhoto;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface QcPhotoMapper extends BaseMapper<QcPhoto> {
}

View File

@@ -0,0 +1,9 @@
package com.corewing.app.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.corewing.app.entity.QcStepResult;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface QcStepResultMapper extends BaseMapper<QcStepResult> {
}

View File

@@ -0,0 +1,9 @@
package com.corewing.app.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.corewing.app.entity.QcTestRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface QcTestRecordMapper extends BaseMapper<QcTestRecord> {
}

View File

@@ -0,0 +1,27 @@
package com.corewing.app.modules.qc;
import com.corewing.app.common.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@Api(tags = "QC配置")
@RestController
@RequestMapping("/api/qc/config")
public class QcConfigController {
@ApiOperation("获取测试配置")
@GetMapping("/test-params")
public Result<Map<String, Object>> getTestParams(@RequestParam("productType") String productType) {
Map<String, Object> params = new HashMap<>();
// 后续可根据 productType 返回不同的测试参数/阈值
params.put("productType", productType);
return Result.success(params);
}
}

View File

@@ -0,0 +1,57 @@
package com.corewing.app.modules.qc;
import com.corewing.app.common.Result;
import com.corewing.app.dto.qc.UploadTestRecordRequest;
import com.corewing.app.dto.qc.ValidateSnRequest;
import com.corewing.app.entity.QcTestRecord;
import com.corewing.app.service.QcTestService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
@Api(tags = "QC测试")
@RestController
@RequestMapping("/api/qc/test")
public class QcTestController {
private final QcTestService qcTestService;
public QcTestController(QcTestService qcTestService) {
this.qcTestService = qcTestService;
}
@ApiOperation("上传测试记录")
@PostMapping("/upload")
public Result<String> uploadTestRecord(@RequestBody UploadTestRecordRequest request) {
return qcTestService.uploadTestRecord(request);
}
@ApiOperation("上传照片")
@PostMapping("/upload-photo")
public Result<Map<String, String>> uploadPhoto(@RequestParam("testNo") String testNo,
@RequestParam("photo") MultipartFile photo) {
return qcTestService.uploadPhoto(testNo, photo);
}
@ApiOperation("验证SN")
@PostMapping("/validate-sn")
public Result<Boolean> validateSn(@RequestBody ValidateSnRequest request) {
return qcTestService.validateSn(request);
}
@ApiOperation("查询SN历史记录")
@GetMapping("/history")
public Result<List<QcTestRecord>> getHistory(@RequestParam("sn") String sn) {
return qcTestService.getHistory(sn);
}
@ApiOperation("获取测试报告")
@GetMapping("/report")
public Result<String> getReport(@RequestParam("testNo") String testNo) {
return qcTestService.getReport(testNo);
}
}

View File

@@ -0,0 +1,29 @@
package com.corewing.app.modules.qc;
import com.corewing.app.common.Result;
import com.corewing.app.dto.qc.ValidateWirelessBoardRequest;
import com.corewing.app.service.BizDeviceService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Api(tags = "无线版QC")
@RestController
@RequestMapping("/api/qc/wireless")
public class WirelessBoardController {
private final BizDeviceService bizDeviceService;
public WirelessBoardController(BizDeviceService bizDeviceService) {
this.bizDeviceService = bizDeviceService;
}
@ApiOperation("验证已经激活的无线板")
@PostMapping("/validate-wireless")
public Result<Boolean> validateWirelessBoard(@RequestBody ValidateWirelessBoardRequest validateWirelessBoardRequest) {
return bizDeviceService.validateWirelessBoard(validateWirelessBoardRequest);
}
}

View File

@@ -1,7 +1,10 @@
package com.corewing.app.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.corewing.app.common.Result;
import com.corewing.app.dto.qc.ValidateWirelessBoardRequest;
import com.corewing.app.entity.BizDevice;
public interface BizDeviceService extends IService<BizDevice> {
Result<Boolean> validateWirelessBoard(ValidateWirelessBoardRequest validateWirelessBoardRequest);
}

View File

@@ -0,0 +1,24 @@
package com.corewing.app.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.corewing.app.common.Result;
import com.corewing.app.dto.qc.UploadTestRecordRequest;
import com.corewing.app.dto.qc.ValidateSnRequest;
import com.corewing.app.entity.QcTestRecord;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
public interface QcTestService extends IService<QcTestRecord> {
Result<String> uploadTestRecord(UploadTestRecordRequest request);
Result<Map<String, String>> uploadPhoto(String testNo, MultipartFile photo);
Result<Boolean> validateSn(ValidateSnRequest request);
Result<List<QcTestRecord>> getHistory(String sn);
Result<String> getReport(String testNo);
}

View File

@@ -2,7 +2,6 @@ package com.corewing.app.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.corewing.app.dto.DeviceActivationRequest;
import com.corewing.app.entity.BizDevice;
@@ -36,9 +35,9 @@ public class BizDeviceActivationServiceImpl extends ServiceImpl<BizDeviceActivat
LambdaQueryWrapper<BizDeviceActivation> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizDeviceActivation::getDeviceMac, deviceActivationRequest.getMac());
BizDeviceActivation checkDeviceActivation = getOne(wrapper);
if(checkDeviceActivation != null) {
if (checkDeviceActivation != null) {
// 该用户已经激活过该设备
if(checkDeviceActivation.getUserId().equals(loginId)) {
if (checkDeviceActivation.getUserId().equals(loginId)) {
return true;
}
return updateById(checkDeviceActivation);
@@ -48,7 +47,7 @@ public class BizDeviceActivationServiceImpl extends ServiceImpl<BizDeviceActivat
LambdaQueryWrapper<BizDevice> checkDeviceWrapper = new LambdaQueryWrapper<>();
checkDeviceWrapper.eq(BizDevice::getDeviceMac, deviceActivationRequest.getMac());
List<BizDevice> list = deviceService.list(checkDeviceWrapper);
if(list.isEmpty()) {
if (list.isEmpty()) {
throw new RuntimeException("该设备不是酷翼官方产品");
}
@@ -67,10 +66,8 @@ public class BizDeviceActivationServiceImpl extends ServiceImpl<BizDeviceActivat
@Transactional(rollbackFor = Exception.class)
@Override
public Boolean factoryActivation(DeviceActivationRequest deviceActivationRequest) {
LambdaQueryWrapper<BizDeviceActivation> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizDeviceActivation::getDeviceMac, deviceActivationRequest.getMac());
BizDeviceActivation checkDeviceActivation = getOne(wrapper);
if(checkDeviceActivation != null) {
BizDeviceActivation checkDeviceActivation = getBizDeviceActivationByMac(deviceActivationRequest.getMac());
if (checkDeviceActivation != null) {
return false;
}
@@ -85,4 +82,16 @@ public class BizDeviceActivationServiceImpl extends ServiceImpl<BizDeviceActivat
device.setCategoryId(deviceCategory.getId());
return deviceService.save(device);
}
/**
* 根据mac地址获取激活设备
*
* @param mac mac地址
* @return 激活设备
*/
private BizDeviceActivation getBizDeviceActivationByMac(String mac) {
LambdaQueryWrapper<BizDeviceActivation> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizDeviceActivation::getDeviceMac, mac);
return getOne(wrapper);
}
}

View File

@@ -1,6 +1,9 @@
package com.corewing.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.corewing.app.common.Result;
import com.corewing.app.dto.qc.ValidateWirelessBoardRequest;
import com.corewing.app.entity.BizDevice;
import com.corewing.app.mapper.BizDeviceMapper;
import com.corewing.app.service.BizDeviceService;
@@ -8,4 +11,16 @@ import org.springframework.stereotype.Service;
@Service
public class BizDeviceServiceImpl extends ServiceImpl<BizDeviceMapper, BizDevice> implements BizDeviceService {
@Override
public Result<Boolean> validateWirelessBoard(ValidateWirelessBoardRequest validateWirelessBoardRequest) {
LambdaQueryWrapper<BizDevice> bizDeviceLambdaQueryWrapper = new LambdaQueryWrapper<>();
bizDeviceLambdaQueryWrapper.eq(BizDevice::getDeviceMac, validateWirelessBoardRequest.getMac());
long deviceCount = count(bizDeviceLambdaQueryWrapper);
if (deviceCount > 0) {
return Result.success(true);
}
return Result.error("设备不存在或未激活");
}
}

View File

@@ -0,0 +1,241 @@
package com.corewing.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.corewing.app.common.Result;
import com.corewing.app.dto.qc.StepResultData;
import com.corewing.app.dto.qc.UploadTestRecordRequest;
import com.corewing.app.dto.qc.ValidateSnRequest;
import com.corewing.app.entity.FlightController;
import com.corewing.app.entity.QcPhoto;
import com.corewing.app.entity.QcStepResult;
import com.corewing.app.entity.QcTestRecord;
import com.corewing.app.mapper.FlightControllerMapper;
import com.corewing.app.mapper.QcPhotoMapper;
import com.corewing.app.mapper.QcStepResultMapper;
import com.corewing.app.mapper.QcTestRecordMapper;
import com.corewing.app.service.QcTestService;
import com.corewing.app.util.OSSUploadUtil;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class QcTestServiceImpl extends ServiceImpl<QcTestRecordMapper, QcTestRecord> implements QcTestService {
@Resource
private QcStepResultMapper qcStepResultMapper;
@Resource
private QcPhotoMapper qcPhotoMapper;
@Resource
private FlightControllerMapper flightControllerMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public Result<String> uploadTestRecord(UploadTestRecordRequest request) {
if (request.getTestNo() == null || request.getTestNo().isEmpty()) {
return Result.error("测试单号不能为空");
}
// 查找是否已存在相同 testNo 的记录
LambdaQueryWrapper<QcTestRecord> testNoQuery = new LambdaQueryWrapper<>();
testNoQuery.eq(QcTestRecord::getTestNo, request.getTestNo());
QcTestRecord existByTestNo = baseMapper.selectOne(testNoQuery);
QcTestRecord record;
if (existByTestNo != null) {
// 相同 testNo 已存在 → 更新APP 重传场景)
record = existByTestNo;
fillRecord(record, request);
baseMapper.updateById(record);
// 清除旧的步骤和照片,后面重新插入
deleteStepsAndPhotos(record.getId());
} else {
// 查找同一 SN 是否已有记录(返修重测场景)
LambdaQueryWrapper<QcTestRecord> snQuery = new LambdaQueryWrapper<>();
snQuery.eq(QcTestRecord::getSn, request.getSn())
.eq(QcTestRecord::getProductType, request.getProductType())
.orderByDesc(QcTestRecord::getCreateTime)
.last("LIMIT 1");
QcTestRecord existBySn = baseMapper.selectOne(snQuery);
if (existBySn != null) {
// 同一 SN 已有记录 → 更新最近一条(返修后重新 QC
record = existBySn;
fillRecord(record, request);
baseMapper.updateById(record);
deleteStepsAndPhotos(record.getId());
} else {
// 全新记录
record = new QcTestRecord();
fillRecord(record, request);
baseMapper.insert(record);
}
}
// 保存步骤结果
if (!CollectionUtils.isEmpty(request.getSteps())) {
for (StepResultData stepData : request.getSteps()) {
QcStepResult step = new QcStepResult();
BeanUtils.copyProperties(stepData, step, "completedAt");
step.setTestRecordId(record.getId());
step.setCompletedAt(parseTimestamp(stepData.getCompletedAt()));
qcStepResultMapper.insert(step);
}
}
// 保存照片 URL
if (!CollectionUtils.isEmpty(request.getPhotoUrls())) {
for (String photoUrl : request.getPhotoUrls()) {
QcPhoto photo = new QcPhoto();
photo.setTestRecordId(record.getId());
photo.setTestNo(request.getTestNo());
photo.setUrl(photoUrl);
qcPhotoMapper.insert(photo);
}
}
// 返回报告 URL可为 null
return Result.success("上传成功", record.getReportUrl());
}
private void fillRecord(QcTestRecord record, UploadTestRecordRequest request) {
// 拷贝同名同类型字段testNo, productType, sn, operatorId, operatorName, phoneModel, appVersion, status
BeanUtils.copyProperties(request, record, "createTime", "updatedAt", "uploadTime");
// 时间戳字符串 → LocalDateTime 需要手动转换
record.setCreateTime(parseTimestamp(request.getCreateTime()));
record.setUpdatedAt(parseTimestamp(request.getUpdatedAt()));
record.setUploadTime(parseTimestamp(request.getUploadTime()));
}
private void deleteStepsAndPhotos(Long recordId) {
LambdaQueryWrapper<QcStepResult> stepDelete = new LambdaQueryWrapper<>();
stepDelete.eq(QcStepResult::getTestRecordId, recordId);
qcStepResultMapper.delete(stepDelete);
LambdaQueryWrapper<QcPhoto> photoDelete = new LambdaQueryWrapper<>();
photoDelete.eq(QcPhoto::getTestRecordId, recordId);
qcPhotoMapper.delete(photoDelete);
}
@Override
public Result<Map<String, String>> uploadPhoto(String testNo, MultipartFile photo) {
if (photo == null || photo.isEmpty()) {
return Result.error("照片文件不能为空");
}
if (testNo == null || testNo.isEmpty()) {
return Result.error("测试单号不能为空");
}
// 查找对应的测试记录
LambdaQueryWrapper<QcTestRecord> query = new LambdaQueryWrapper<>();
query.eq(QcTestRecord::getTestNo, testNo);
QcTestRecord record = baseMapper.selectOne(query);
if (record == null) {
return Result.error("测试记录不存在: " + testNo);
}
try {
// 生成 OSS 存储路径
String originalFilename = photo.getOriginalFilename();
String suffix = "";
if (originalFilename != null && originalFilename.contains(".")) {
suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
}
String objectName = "qc/photos/" + testNo + "/" + UUID.randomUUID().toString().replace("-", "") + suffix;
// 上传到 OSS
String url = OSSUploadUtil.uploadFile(photo.getInputStream(), objectName);
// 保存照片记录
QcPhoto qcPhoto = new QcPhoto();
qcPhoto.setTestRecordId(record.getId());
qcPhoto.setTestNo(testNo);
qcPhoto.setUrl(url);
qcPhotoMapper.insert(qcPhoto);
Map<String, String> result = new HashMap<>();
result.put("url", url);
return Result.success("上传成功", result);
} catch (Exception e) {
log.error("照片上传失败", e);
return Result.error("照片上传失败: " + e.getMessage());
}
}
@Override
public Result<Boolean> validateSn(ValidateSnRequest request) {
if (request.getSn() == null || request.getSn().isEmpty()) {
return Result.error("SN 不能为空");
}
LambdaQueryWrapper<FlightController> query = new LambdaQueryWrapper<>();
query.eq(FlightController::getSn, request.getSn());
Long count = flightControllerMapper.selectCount(query);
if (count > 0) {
return Result.success("SN 有效", true);
}
return Result.error("SN 无效或未注册");
}
@Override
public Result<List<QcTestRecord>> getHistory(String sn) {
if (sn == null || sn.isEmpty()) {
return Result.error("SN 不能为空");
}
LambdaQueryWrapper<QcTestRecord> query = new LambdaQueryWrapper<>();
query.eq(QcTestRecord::getSn, sn)
.orderByDesc(QcTestRecord::getCreateTime);
List<QcTestRecord> records = baseMapper.selectList(query);
return Result.success(records);
}
@Override
public Result<String> getReport(String testNo) {
if (testNo == null || testNo.isEmpty()) {
return Result.error("测试单号不能为空");
}
LambdaQueryWrapper<QcTestRecord> query = new LambdaQueryWrapper<>();
query.eq(QcTestRecord::getTestNo, testNo);
QcTestRecord record = baseMapper.selectOne(query);
if (record == null) {
return Result.error("测试记录不存在");
}
return Result.success(record.getReportUrl());
}
private LocalDateTime parseTimestamp(String timestamp) {
if (timestamp == null || timestamp.isEmpty()) {
return null;
}
try {
long millis = Long.parseLong(timestamp);
if (millis <= 0) {
return null;
}
return LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault());
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@@ -0,0 +1,81 @@
-- ============================================================
-- Corewing QC 后端数据库建表 SQLMySQL
-- ============================================================
CREATE TABLE `flight_controller` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`sn` VARCHAR(64) NOT NULL COMMENT '序列号',
`model` VARCHAR(64) DEFAULT NULL COMMENT '型号',
`batch_no` VARCHAR(64) DEFAULT NULL COMMENT '批次号',
`status` VARCHAR(32) NOT NULL DEFAULT 'REGISTERED' COMMENT '状态: REGISTERED / QC_PASS / QC_FAIL / ACTIVATED',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sn` (`sn`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='飞控板设备注册表';
CREATE TABLE `qc_test_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`test_no` VARCHAR(32) NOT NULL COMMENT '测试单号,格式: WL-yyyyMMdd-NNN / FC-yyyyMMdd-NNN',
`product_type` VARCHAR(32) NOT NULL COMMENT '产品类型: WIRELESS_BOARD / FLIGHT_CONTROLLER',
`sn` VARCHAR(64) NOT NULL COMMENT '产品序列号(无线板为 BLE MAC',
`operator_id` VARCHAR(32) NOT NULL COMMENT '测试员 ID',
`operator_name` VARCHAR(64) NOT NULL COMMENT '测试员姓名',
`phone_model` VARCHAR(64) DEFAULT NULL COMMENT '测试手机型号',
`app_version` VARCHAR(16) DEFAULT NULL COMMENT 'APP 版本',
`status` VARCHAR(16) NOT NULL COMMENT '测试结果: PASS / FAIL',
`create_time` DATETIME NOT NULL COMMENT 'APP 端创建时间',
`updated_at` DATETIME DEFAULT NULL COMMENT 'APP 端最后更新时间',
`upload_time` DATETIME DEFAULT NULL COMMENT 'APP 端上传时间',
`report_url` VARCHAR(512) DEFAULT NULL COMMENT '测试报告 URL',
`server_create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '服务端入库时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_test_no` (`test_no`),
KEY `idx_sn` (`sn`),
KEY `idx_product_type` (`product_type`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='QC 测试记录主表';
CREATE TABLE `qc_step_result` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`test_record_id` BIGINT NOT NULL COMMENT '关联 qc_test_record.id',
`step_index` INT NOT NULL COMMENT '步骤序号(从 1 开始)',
`step_name` VARCHAR(64) NOT NULL COMMENT '步骤名称',
`result` VARCHAR(8) DEFAULT NULL COMMENT '步骤结果: PASS / FAIL / NULL',
`data_json` TEXT DEFAULT NULL COMMENT '步骤详细数据 JSON',
`duration` BIGINT DEFAULT 0 COMMENT '步骤耗时(毫秒)',
`completed_at` DATETIME DEFAULT NULL COMMENT '步骤完成时间',
PRIMARY KEY (`id`),
KEY `idx_test_record_id` (`test_record_id`),
UNIQUE KEY `uk_record_step` (`test_record_id`, `step_index`),
CONSTRAINT `fk_step_record` FOREIGN KEY (`test_record_id`)
REFERENCES `qc_test_record` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='QC 步骤结果表';
CREATE TABLE `qc_photo` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`test_record_id` BIGINT NOT NULL COMMENT '关联 qc_test_record.id',
`test_no` VARCHAR(32) NOT NULL COMMENT '测试单号',
`url` VARCHAR(512) NOT NULL COMMENT '照片远程 URL',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_test_no` (`test_no`),
CONSTRAINT `fk_photo_record` FOREIGN KEY (`test_record_id`)
REFERENCES `qc_test_record` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='QC 测试照片表';
-- ============================================================
-- 示例数据(可选)
-- ============================================================
-- 注册一批无线板
-- INSERT INTO `wireless_board` (`mac`) VALUES
-- ('AA:BB:CC:DD:EE:01'),
-- ('AA:BB:CC:DD:EE:02'),
-- ('AA:BB:CC:DD:EE:03');