feat(iot): 新增云音响设备管理(建表/状态枚举/Controller/i18n)

This commit is contained in:
DaxPay Dev
2026-06-25 10:49:03 +08:00
parent df2e2dceac
commit 1c66bfed35
14 changed files with 562 additions and 0 deletions

View File

@@ -153,3 +153,49 @@ COMMENT ON COLUMN mch_store_info.deleted IS '逻辑删除标志';
CREATE UNIQUE INDEX IF NOT EXISTS uk_mch_store_info_store_no ON mch_store_info (store_no);
-- 商户号查询索引
CREATE INDEX IF NOT EXISTS idx_mch_store_info_mch_no ON mch_store_info (mch_no, deleted);
-- ===================================
-- 云音响设备(商米云音响, 设备台账与商户/门店绑定关系)
-- ===================================
CREATE TABLE IF NOT EXISTS iot_speaker_device (
id bigint NOT NULL,
mch_no varchar(32) NOT NULL,
device_sn varchar(64) NOT NULL,
imei varchar(32),
shop_id varchar(64),
device_name varchar(128),
status varchar(16) NOT NULL DEFAULT 'unbound',
bind_time timestamptz(6),
last_online_time timestamptz(6),
remark varchar(512),
creator bigint,
create_time timestamptz(6),
last_modifier bigint,
last_modified_time timestamptz(6),
version int NOT NULL DEFAULT 0,
deleted boolean NOT NULL DEFAULT false,
CONSTRAINT iot_speaker_device_pkey PRIMARY KEY (id)
);
COMMENT ON TABLE iot_speaker_device IS '云音响设备(商米云音响, 设备台账与商户/门店绑定关系)';
COMMENT ON COLUMN iot_speaker_device.id IS '主键';
COMMENT ON COLUMN iot_speaker_device.mch_no IS '所属商户号';
COMMENT ON COLUMN iot_speaker_device.device_sn IS '商米设备序列号(SN)';
COMMENT ON COLUMN iot_speaker_device.imei IS '设备IMEI';
COMMENT ON COLUMN iot_speaker_device.shop_id IS '商米门店ID';
COMMENT ON COLUMN iot_speaker_device.device_name IS '设备名称';
COMMENT ON COLUMN iot_speaker_device.status IS '设备状态(unbound未绑定/online在线/offline离线/fault故障)';
COMMENT ON COLUMN iot_speaker_device.bind_time IS '绑定时间';
COMMENT ON COLUMN iot_speaker_device.last_online_time IS '最后在线时间';
COMMENT ON COLUMN iot_speaker_device.remark IS '备注';
COMMENT ON COLUMN iot_speaker_device.creator IS '创建人ID';
COMMENT ON COLUMN iot_speaker_device.create_time IS '创建时间';
COMMENT ON COLUMN iot_speaker_device.last_modifier IS '最后修改人ID';
COMMENT ON COLUMN iot_speaker_device.last_modified_time IS '最后修改时间';
COMMENT ON COLUMN iot_speaker_device.version IS '版本号(乐观锁)';
COMMENT ON COLUMN iot_speaker_device.deleted IS '逻辑删除标志';
-- 设备序列号唯一索引(未删除范围内)
CREATE UNIQUE INDEX IF NOT EXISTS uk_iot_speaker_device_sn ON iot_speaker_device (device_sn) WHERE deleted = false;
-- 商户号查询索引
CREATE INDEX IF NOT EXISTS idx_iot_speaker_device_mch_no ON iot_speaker_device (mch_no, deleted);

View File

@@ -0,0 +1,89 @@
package cn.daxpay.open.payment.admin.iot.speaker.controller;
import cn.daxpay.open.payment.admin.iot.speaker.service.IotSpeakerDeviceAdminService;
import cn.daxpay.open.payment.iot.speaker.param.IotSpeakerDeviceParam;
import cn.daxpay.open.payment.iot.speaker.param.IotSpeakerDeviceQuery;
import cn.daxpay.open.payment.iot.speaker.result.IotSpeakerDeviceResult;
import cn.daxpay.open.platform.core.annotation.PermCode;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.param.PageParam;
import cn.daxpay.open.platform.core.rest.result.PageResult;
import cn.daxpay.open.platform.core.rest.result.Result;
import cn.daxpay.open.platform.core.validation.ValidationGroup;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
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;
/// # 云音响设备管理(运营端)
///
@PermCode(menuCode = "payment:iot:speaker")
@Validated
@Tag(name = "云音响设备管理")
@RestController
@RequestMapping("/admin/iot/speaker-device")
@RequiredArgsConstructor
public class IotSpeakerDeviceAdminController {
private final IotSpeakerDeviceAdminService iotSpeakerDeviceAdminService;
@PermCode(code = "add", nameCn = "云音响新增", nameEn = "Speaker Add")
@Operation(summary = "新增云音响设备")
@PostMapping("/add")
public Result<Void> add(@RequestBody @Validated(ValidationGroup.add.class) IotSpeakerDeviceParam param) {
iotSpeakerDeviceAdminService.add(param);
return Res.ok();
}
@PermCode(code = "edit", nameCn = "云音响编辑", nameEn = "Speaker Edit")
@Operation(summary = "修改云音响设备")
@PostMapping("/update")
public Result<Void> update(@RequestBody @Validated(ValidationGroup.edit.class) IotSpeakerDeviceParam param) {
iotSpeakerDeviceAdminService.update(param);
return Res.ok();
}
@PermCode(code = "view", nameCn = "云音响查看", nameEn = "Speaker View")
@Operation(summary = "云音响设备分页")
@GetMapping("/page")
public Result<PageResult<IotSpeakerDeviceResult>> page(PageParam pageParam, IotSpeakerDeviceQuery query) {
return Res.ok(iotSpeakerDeviceAdminService.page(pageParam, query));
}
@PermCode(code = "view", nameCn = "云音响查看", nameEn = "Speaker View")
@Operation(summary = "根据id查询云音响设备")
@GetMapping("/get")
public Result<IotSpeakerDeviceResult> findById(@NotNull(message = "{validation.field.id.notNull}") Long id) {
return Res.ok(iotSpeakerDeviceAdminService.findById(id));
}
@PermCode(code = "delete", nameCn = "云音响删除", nameEn = "Speaker Delete")
@Operation(summary = "删除云音响设备")
@PostMapping("/delete")
public Result<Void> delete(@NotNull(message = "{validation.field.id.notNull}") Long id) {
iotSpeakerDeviceAdminService.delete(id);
return Res.ok();
}
@PermCode(code = "edit", nameCn = "云音响编辑", nameEn = "Speaker Edit")
@Operation(summary = "绑定云音响设备")
@PostMapping("/bind")
public Result<Void> bind(@NotNull(message = "{validation.field.id.notNull}") Long id) {
iotSpeakerDeviceAdminService.bind(id);
return Res.ok();
}
@PermCode(code = "edit", nameCn = "云音响编辑", nameEn = "Speaker Edit")
@Operation(summary = "解绑云音响设备")
@PostMapping("/unbind")
public Result<Void> unbind(@NotNull(message = "{validation.field.id.notNull}") Long id) {
iotSpeakerDeviceAdminService.unbind(id);
return Res.ok();
}
}

View File

@@ -0,0 +1,110 @@
package cn.daxpay.open.payment.admin.iot.speaker.service;
import cn.daxpay.open.payment.iot.speaker.dao.IotSpeakerDeviceManager;
import cn.daxpay.open.payment.iot.speaker.entity.IotSpeakerDevice;
import cn.daxpay.open.payment.iot.speaker.enums.IotDeviceStatusEnum;
import cn.daxpay.open.payment.iot.speaker.param.IotSpeakerDeviceParam;
import cn.daxpay.open.payment.iot.speaker.param.IotSpeakerDeviceQuery;
import cn.daxpay.open.payment.iot.speaker.result.IotSpeakerDeviceResult;
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
import cn.daxpay.open.platform.core.code.CommonCode;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.exception.operation.OperationFailException;
import cn.daxpay.open.platform.core.rest.param.PageParam;
import cn.daxpay.open.platform.core.rest.result.PageResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
/// # 云音响设备管理(运营端)
///
/// 首期仅维护本地设备台账, 绑定/解绑只更新本地状态; 真实商米对接由独立服务 dax-pay-iot 完成。
@Slf4j
@Service
@RequiredArgsConstructor
public class IotSpeakerDeviceAdminService {
private final IotSpeakerDeviceManager iotSpeakerDeviceManager;
/// 新增设备(默认未绑定状态)
@Transactional(rollbackFor = Exception.class)
public void add(IotSpeakerDeviceParam param) {
// 校验设备SN唯一
if (iotSpeakerDeviceManager.existsByDeviceSn(param.getDeviceSn(), null)) {
// 云音响: 设备序列号已存在
throw new OperationFailException(CommonCode.FAIL_CODE, "error.iot.speaker.deviceSnExists");
}
IotSpeakerDevice entity = new IotSpeakerDevice()
.setMchNo(param.getMchNo())
.setDeviceSn(param.getDeviceSn())
.setImei(param.getImei())
.setShopId(param.getShopId())
.setDeviceName(param.getDeviceName())
.setRemark(param.getRemark())
// 新增默认未绑定
.setStatus(IotDeviceStatusEnum.UNBOUND.getCode());
iotSpeakerDeviceManager.save(entity);
}
/// 修改设备
@Transactional(rollbackFor = Exception.class)
public void update(IotSpeakerDeviceParam param) {
IotSpeakerDevice entity = iotSpeakerDeviceManager.findById(param.getId())
// 云音响: 设备不存在
.orElseThrow(() -> new DataNotExistException("error.iot.speaker.deviceNotFound"));
// SN 变更时校验唯一
if (!entity.getDeviceSn().equals(param.getDeviceSn())
&& iotSpeakerDeviceManager.existsByDeviceSn(param.getDeviceSn(), param.getId())) {
throw new OperationFailException(CommonCode.FAIL_CODE, "error.iot.speaker.deviceSnExists");
}
entity.setMchNo(param.getMchNo())
.setDeviceSn(param.getDeviceSn())
.setImei(param.getImei())
.setShopId(param.getShopId())
.setDeviceName(param.getDeviceName())
.setRemark(param.getRemark());
iotSpeakerDeviceManager.updateById(entity);
}
/// 分页
public PageResult<IotSpeakerDeviceResult> page(PageParam pageParam, IotSpeakerDeviceQuery query) {
return MpUtil.toPageResult(iotSpeakerDeviceManager.page(pageParam, query));
}
/// 根据id查询
public IotSpeakerDeviceResult findById(Long id) {
return iotSpeakerDeviceManager.findById(id)
// 云音响: 设备不存在
.orElseThrow(() -> new DataNotExistException("error.iot.speaker.deviceNotFound"))
.toResult();
}
/// 删除
public void delete(Long id) {
iotSpeakerDeviceManager.findById(id)
.orElseThrow(() -> new DataNotExistException("error.iot.speaker.deviceNotFound"));
iotSpeakerDeviceManager.deleteById(id);
}
/// 绑定设备(首期仅更新本地状态为在线, 真实商米对接由独立服务完成)
@Transactional(rollbackFor = Exception.class)
public void bind(Long id) {
IotSpeakerDevice entity = iotSpeakerDeviceManager.findById(id)
.orElseThrow(() -> new DataNotExistException("error.iot.speaker.deviceNotFound"));
entity.setStatus(IotDeviceStatusEnum.ONLINE.getCode())
.setBindTime(OffsetDateTime.now());
iotSpeakerDeviceManager.updateById(entity);
}
/// 解绑设备(首期仅更新本地状态为未绑定)
@Transactional(rollbackFor = Exception.class)
public void unbind(Long id) {
IotSpeakerDevice entity = iotSpeakerDeviceManager.findById(id)
.orElseThrow(() -> new DataNotExistException("error.iot.speaker.deviceNotFound"));
entity.setStatus(IotDeviceStatusEnum.UNBOUND.getCode());
iotSpeakerDeviceManager.updateById(entity);
}
}

View File

@@ -0,0 +1,43 @@
package cn.daxpay.open.payment.iot.speaker.dao;
import cn.daxpay.open.payment.iot.speaker.entity.IotSpeakerDevice;
import cn.daxpay.open.payment.iot.speaker.param.IotSpeakerDeviceQuery;
import cn.daxpay.open.platform.common.mybatisplus.impl.BaseManager;
import cn.daxpay.open.platform.common.mybatisplus.query.generator.QueryGenerator;
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
import cn.daxpay.open.platform.core.rest.param.PageParam;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/// # 云音响设备管理
///
@Slf4j
@Repository
@RequiredArgsConstructor
public class IotSpeakerDeviceManager extends BaseManager<IotSpeakerDeviceMapper, IotSpeakerDevice> {
/// 根据设备SN查询
public Optional<IotSpeakerDevice> findByDeviceSn(String deviceSn) {
return findByField(IotSpeakerDevice::getDeviceSn, deviceSn);
}
/// 判断设备SN是否存在(排除指定id, excludeId 为 null 时不排除)
public boolean existsByDeviceSn(String deviceSn, Long excludeId) {
if (excludeId == null) {
return existedByField(IotSpeakerDevice::getDeviceSn, deviceSn);
}
return existedByField(IotSpeakerDevice::getDeviceSn, deviceSn, excludeId);
}
/// 分页
public Page<IotSpeakerDevice> page(PageParam pageParam, IotSpeakerDeviceQuery query) {
Page<IotSpeakerDevice> mpPage = MpUtil.getMpPage(pageParam);
QueryWrapper<IotSpeakerDevice> wrapper = QueryGenerator.generator(query);
return this.page(mpPage, wrapper);
}
}

View File

@@ -0,0 +1,10 @@
package cn.daxpay.open.payment.iot.speaker.dao;
import cn.daxpay.open.payment.iot.speaker.entity.IotSpeakerDevice;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/// # 云音响设备
@Mapper
public interface IotSpeakerDeviceMapper extends MPJBaseMapper<IotSpeakerDevice> {
}

View File

@@ -0,0 +1,68 @@
package cn.daxpay.open.payment.iot.speaker.entity;
import cn.daxpay.open.payment.iot.speaker.enums.IotDeviceStatusEnum;
import cn.daxpay.open.payment.iot.speaker.result.IotSpeakerDeviceResult;
import cn.daxpay.open.platform.common.mybatisplus.base.MpBaseEntity;
import cn.daxpay.open.platform.common.mybatisplus.function.ToResult;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
/// # 云音响设备
///
/// 记录商米云音响设备与商户/门店的绑定关系, 真实播报对接由独立服务 dax-pay-iot 完成。
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@TableName("iot_speaker_device")
public class IotSpeakerDevice extends MpBaseEntity implements ToResult<IotSpeakerDeviceResult> {
/// 商户号
private String mchNo;
/// 商米设备序列号(SN)
private String deviceSn;
/// 设备IMEI
private String imei;
/// 商米门店ID
private String shopId;
/// 设备名称
private String deviceName;
/// 设备状态
/// @see IotDeviceStatusEnum
private String status;
/// 绑定时间
private OffsetDateTime bindTime;
/// 最后在线时间
private OffsetDateTime lastOnlineTime;
/// 备注
private String remark;
/// 转换为返回对象
@Override
public IotSpeakerDeviceResult toResult() {
IotSpeakerDeviceResult result = new IotSpeakerDeviceResult()
.setMchNo(mchNo)
.setDeviceSn(deviceSn)
.setImei(imei)
.setShopId(shopId)
.setDeviceName(deviceName)
.setStatus(status)
.setBindTime(bindTime)
.setLastOnlineTime(lastOnlineTime)
.setRemark(remark);
result.setId(getId());
result.setCreateTime(getCreateTime());
return result;
}
}

View File

@@ -0,0 +1,43 @@
package cn.daxpay.open.payment.iot.speaker.enums;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.i18n.I18nSupport;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
/// # 云音响设备状态
///
/// 字典: iot_speaker_device_status
@Getter
@RequiredArgsConstructor
public enum IotDeviceStatusEnum implements I18nSupport {
/// 未绑定
UNBOUND("unbound"),
/// 在线
ONLINE("online"),
/// 离线
OFFLINE("offline"),
/// 故障
FAULT("fault");
/// 编码
private final String code;
/// 翻译 key 前缀
@Override
public String getI18nPrefix() {
return "enum.iot_speaker_device_status";
}
/// 根据编码查找
public static IotDeviceStatusEnum findByCode(String code) {
return Arrays.stream(values())
.filter(e -> e.getCode().equals(code))
.findFirst()
// 通用: 未找到对应的云音响设备状态: {0}
.orElseThrow(() -> new DataNotExistException("error.iot.speaker.deviceStatusNotFound", code));
}
}

View File

@@ -0,0 +1,46 @@
package cn.daxpay.open.payment.iot.speaker.param;
import cn.daxpay.open.platform.core.validation.ValidationGroup;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 云音响设备
@Data
@Accessors(chain = true)
@Schema(title = "云音响设备")
public class IotSpeakerDeviceParam {
/// 主键
@Schema(description = "主键")
@NotNull(message = "{validation.field.id.notNull}", groups = ValidationGroup.edit.class)
private Long id;
/// 商户号
@Schema(description = "商户号")
@NotBlank(message = "{validation.field.mchNo.notBlank}")
private String mchNo;
/// 设备序列号
@Schema(description = "设备序列号")
@NotBlank(message = "{validation.field.deviceSn.notBlank}")
private String deviceSn;
/// 设备IMEI
@Schema(description = "设备IMEI")
private String imei;
/// 商米门店ID
@Schema(description = "商米门店ID")
private String shopId;
/// 设备名称
@Schema(description = "设备名称")
private String deviceName;
/// 备注
@Schema(description = "备注")
private String remark;
}

View File

@@ -0,0 +1,38 @@
package cn.daxpay.open.payment.iot.speaker.param;
import cn.daxpay.open.platform.core.annotation.QueryParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 云音响设备查询参数
@Data
@QueryParam(type = QueryParam.CompareTypeEnum.LIKE)
@Accessors(chain = true)
@Schema(title = "云音响设备查询参数")
public class IotSpeakerDeviceQuery {
/// 商户号
@Schema(description = "商户号")
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
private String mchNo;
/// 设备序列号
@Schema(description = "设备序列号")
private String deviceSn;
/// 设备名称
@Schema(description = "设备名称")
private String deviceName;
/// 商米门店ID
@Schema(description = "商米门店ID")
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
private String shopId;
/// 设备状态
/// @see cn.daxpay.open.payment.iot.speaker.enums.IotDeviceStatusEnum
@Schema(description = "设备状态")
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
private String status;
}

View File

@@ -0,0 +1,47 @@
package cn.daxpay.open.payment.iot.speaker.result;
import cn.daxpay.open.payment.iot.speaker.enums.IotDeviceStatusEnum;
import cn.daxpay.open.platform.core.result.BaseResult;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import java.time.OffsetDateTime;
/// # 云音响设备
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@Schema(title = "云音响设备")
public class IotSpeakerDeviceResult extends BaseResult {
@Schema(description = "商户号")
private String mchNo;
@Schema(description = "商米设备序列号(SN)")
private String deviceSn;
@Schema(description = "设备IMEI")
private String imei;
@Schema(description = "商米门店ID")
private String shopId;
@Schema(description = "设备名称")
private String deviceName;
/// 设备状态
/// @see IotDeviceStatusEnum
@Schema(description = "设备状态(unbound未绑定/online在线/offline离线/fault故障)")
private String status;
@Schema(description = "绑定时间")
private OffsetDateTime bindTime;
@Schema(description = "最后在线时间")
private OffsetDateTime lastOnlineTime;
@Schema(description = "备注")
private String remark;
}

View File

@@ -0,0 +1,6 @@
{
"unbound": "Unbound",
"online": "Online",
"offline": "Offline",
"fault": "Fault"
}

View File

@@ -0,0 +1,5 @@
{
"deviceNotFound": "Cloud speaker device not found",
"deviceSnExists": "Device serial number already exists",
"deviceStatusNotFound": "Cloud speaker device status not found: {0}"
}

View File

@@ -0,0 +1,6 @@
{
"unbound": "未绑定",
"online": "在线",
"offline": "离线",
"fault": "故障"
}

View File

@@ -0,0 +1,5 @@
{
"deviceNotFound": "云音响设备不存在",
"deviceSnExists": "设备序列号已存在",
"deviceStatusNotFound": "未找到对应的云音响设备状态: {0}"
}