feat(device): 运营端支付码牌管理(批量生成/绑定商户/商户名翻译)

支持空白码库存与划拨绑定,分页翻译商户名称;绑定默认走商户默认应用。
补齐码牌菜单落地、batch_no 升级 SQL 与中英校验/错误文案。
This commit is contained in:
DaxPay Dev
2026-07-10 16:50:47 +08:00
parent 835e062ffe
commit 6f0622fca8
26 changed files with 993 additions and 1 deletions

View File

@@ -134,7 +134,7 @@ INSERT INTO "public"."iam_perm_menu" VALUES (90201, 9, 'device:vendor_config', '
INSERT INTO "public"."iam_perm_menu" VALUES (40503, 40105, 'payment:wechat:isv', 'admin', 'WechatIsvAppManage', '微信服务商应用', 'WeChat ISV Application', 'menu.payment.config.wechatApp', NULL, 't', 'f', '/payment/channel/wechat/manage/app/WechatIsvAppManage', '/payment/config/product/wechat-app-manage', NULL, 4, 'f', 't', 'f', 1, 1, 0, 'f', 'subpage', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-14 00:00:00+00', '2026-06-14 00:00:00+00');
INSERT INTO "public"."iam_perm_menu" VALUES (90202, 9, 'device:speaker', 'admin', 'DeviceSpeakerList', '云音箱管理', 'Speaker Management', 'menu.device.speaker', 'lucide:speaker', 'f', 'f', '/payment/device/speaker/DeviceSpeakerList', '/device/speaker', NULL, 3, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-25 01:21:34.647632+00', '2026-06-25 12:51:25.245363+00');
INSERT INTO "public"."iam_perm_menu" VALUES (8, NULL, 'develop', 'admin', 'Develop', '开发调试', 'Develop', 'menu.develop', 'lucide:wrench', 'f', 'f', NULL, '/develop', NULL, 5, 'f', 't', 'f', 1, 1, 0, 'f', 'catalog', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-23 16:00:00+00', '2026-06-23 16:00:00+00');
INSERT INTO "public"."iam_perm_menu" VALUES (901, 9, 'device:qrcode', 'admin', 'DeviceQrCode', '码牌管理', 'QR Code Management', 'menu.device.qrcode', 'lucide:qr-code', 't', 'f', '/_core/fallback/coming-soon', '/device/qrcode', NULL, 2, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-25 01:21:34.634639+00', '2026-06-25 12:51:25.24304+00');
INSERT INTO "public"."iam_perm_menu" VALUES (901, 9, 'device:qrcode', 'admin', 'DeviceQrCode', '码牌管理', 'QR Code Management', 'menu.device.qrcode', 'lucide:qr-code', 'f', 'f', '/payment/device/qrcode/DeviceQrCode', '/device/qrcode', NULL, 2, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-25 01:21:34.634639+00', '2026-06-25 12:51:25.24304+00');
INSERT INTO "public"."iam_perm_menu" VALUES (204, 2, 'demos:artemis', 'admin', 'ArtemisDemo', '消息队列演示', 'Message Queue Demo', 'menu.demos.artemis', 'lucide:send', 'f', 'f', '/demos/artemis/ArtemisDemo', '/demos/artemis', NULL, 4, 'f', 't', 'f', 0, NULL, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-18 00:00:00+00', '2026-06-18 00:00:00+00');
INSERT INTO "public"."iam_perm_menu" VALUES (900001, 304, 'iam:social:login-config', 'admin', 'ThirdPlatform', '三方平台管理', 'Third-party Platform Management', 'menu.system.config.thirdPlatform', 'lucide:share-2', 'f', 'f', 'views/system/config/third-platform/ThirdPlatform', '/system/config/third-platform', NULL, 5, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO "public"."iam_perm_menu" VALUES (30403, 304, 'system:protocol', 'admin', 'UserProtocol', '用户协议', 'User Protocol', 'menu.system.config.protocol', 'lucide:file-text', 'f', 'f', '/system/protocol/UserProtocolList', '/system/config/protocol', NULL, 10, 'f', 't', 'f', 1, 1, 0, 'f', 'menu', NULL, NULL, NULL, NULL, NULL, NULL, '2026-06-24 10:32:13.183371+00', '2026-06-24 10:32:13.183371+00');

View File

@@ -201,3 +201,13 @@ COMMENT ON COLUMN pay_platform_mobile_app.deleted IS '逻辑删除标记';
CREATE UNIQUE INDEX IF NOT EXISTS uk_pay_platform_mobile_app_type_platform
ON pay_platform_mobile_app (app_type, platform) WHERE deleted = false;
-- ------------------------------------------------------------
-- 码牌: 支持空白库存与划拨(批次号 + 商户号可空)
-- ------------------------------------------------------------
ALTER TABLE device_qr_code ADD COLUMN IF NOT EXISTS batch_no varchar(64);
ALTER TABLE device_qr_code ALTER COLUMN mch_no DROP NOT NULL;
COMMENT ON COLUMN device_qr_code.batch_no IS '批次号(批量创建空白码时写入)';
COMMENT ON COLUMN device_qr_code.mch_no IS '所属商户号(空白码为空, 划拨后写入)';

View File

@@ -0,0 +1,39 @@
package cn.daxpay.open.payment.device.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;
/// # 码牌金额类型
///
/// 字典: qrcode_amount_type
@Getter
@RequiredArgsConstructor
public enum QrCodeAmountTypeEnum implements I18nSupport {
/// 自定义金额(扫码后由用户输入)
RANDOM("random"),
/// 固定金额(创建时设定, 扫码后直接支付)
FIXED("fixed");
/// 编码
private final String code;
/// 翻译 key 前缀
@Override
public String getI18nPrefix() {
return "enum.qrcode_amount_type";
}
/// 根据编码查找
public static QrCodeAmountTypeEnum findByCode(String code) {
return Arrays.stream(values())
.filter(e -> e.getCode().equals(code))
.findFirst()
// 通用: 未找到对应的码牌金额类型: {0}
.orElseThrow(() -> new DataNotExistException("error.device.qrcode.amountTypeNotFound", code));
}
}

View File

@@ -0,0 +1,39 @@
package cn.daxpay.open.payment.device.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;
/// # 码牌状态
///
/// 字典: qrcode_status
@Getter
@RequiredArgsConstructor
public enum QrCodeStatusEnum implements I18nSupport {
/// 启用
ENABLED("enabled"),
/// 停用
DISABLED("disabled");
/// 编码
private final String code;
/// 翻译 key 前缀
@Override
public String getI18nPrefix() {
return "enum.qrcode_status";
}
/// 根据编码查找
public static QrCodeStatusEnum findByCode(String code) {
return Arrays.stream(values())
.filter(e -> e.getCode().equals(code))
.findFirst()
// 通用: 未找到对应的码牌状态: {0}
.orElseThrow(() -> new DataNotExistException("error.device.qrcode.statusNotFound", code));
}
}

View File

@@ -0,0 +1,119 @@
package cn.daxpay.open.payment.device.qrcode.controller.admin;
import cn.daxpay.open.payment.device.qrcode.param.DeviceQrCodeBatchParam;
import cn.daxpay.open.payment.device.qrcode.param.DeviceQrCodeBindMerchantParam;
import cn.daxpay.open.payment.device.qrcode.param.DeviceQrCodeParam;
import cn.daxpay.open.payment.device.qrcode.param.DeviceQrCodeQuery;
import cn.daxpay.open.payment.device.qrcode.result.DeviceQrCodeResult;
import cn.daxpay.open.payment.device.qrcode.service.DeviceQrCodeAdminService;
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.NotBlank;
import jakarta.validation.constraints.NotEmpty;
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;
import java.util.List;
/// # 支付码牌管理(运营端)
///
@PermCode(menuCode = "device:qrcode")
@Validated
@Tag(name = "支付码牌管理")
@RestController
@RequestMapping("/admin/device/qrcode")
@RequiredArgsConstructor
public class DeviceQrCodeAdminController {
private final DeviceQrCodeAdminService deviceQrCodeAdminService;
@PermCode(code = "manage", nameCn = "码牌管理", nameEn = "QrCode Manage")
@Operation(summary = "新增码牌(快捷绑定商户)")
@PostMapping("/add")
public Result<Void> add(@RequestBody @Validated(ValidationGroup.add.class) DeviceQrCodeParam param) {
deviceQrCodeAdminService.add(param);
return Res.ok();
}
@PermCode(code = "manage", nameCn = "码牌管理", nameEn = "QrCode Manage")
@Operation(summary = "批量创建空白码牌")
@PostMapping("/create-batch")
public Result<Void> createBatch(@RequestBody @Validated DeviceQrCodeBatchParam param) {
deviceQrCodeAdminService.createBatch(param);
return Res.ok();
}
@PermCode(code = "view", nameCn = "码牌查看", nameEn = "QrCode View")
@Operation(summary = "判断批次号是否已存在")
@GetMapping("/exists-by-batch-no")
public Result<Boolean> existsByBatchNo(@NotBlank(message = "{validation.field.batchNo.notBlank}") String batchNo) {
return Res.ok(deviceQrCodeAdminService.existsByBatchNo(batchNo));
}
@PermCode(code = "manage", nameCn = "码牌管理", nameEn = "QrCode Manage")
@Operation(summary = "批量绑定商户")
@PostMapping("/bind-merchant")
public Result<Void> bindMerchant(@RequestBody @Validated DeviceQrCodeBindMerchantParam param) {
deviceQrCodeAdminService.bindMerchant(param);
return Res.ok();
}
@PermCode(code = "manage", nameCn = "码牌管理", nameEn = "QrCode Manage")
@Operation(summary = "批量解绑商户")
@PostMapping("/unbind-merchant")
public Result<Void> unbindMerchant(@RequestBody @NotEmpty(message = "{validation.field.ids.notEmpty}") List<Long> ids) {
deviceQrCodeAdminService.unbindMerchant(ids);
return Res.ok();
}
@PermCode(code = "manage", nameCn = "码牌管理", nameEn = "QrCode Manage")
@Operation(summary = "修改码牌")
@PostMapping("/update")
public Result<Void> update(@RequestBody @Validated(ValidationGroup.edit.class) DeviceQrCodeParam param) {
deviceQrCodeAdminService.update(param);
return Res.ok();
}
@PermCode(code = "view", nameCn = "码牌查看", nameEn = "QrCode View")
@Operation(summary = "码牌分页")
@GetMapping("/page")
public Result<PageResult<DeviceQrCodeResult>> page(PageParam pageParam, DeviceQrCodeQuery query) {
return Res.ok(deviceQrCodeAdminService.page(pageParam, query));
}
@PermCode(code = "view", nameCn = "码牌查看", nameEn = "QrCode View")
@Operation(summary = "根据id查询码牌")
@GetMapping("/get")
public Result<DeviceQrCodeResult> findById(@NotNull(message = "{validation.field.id.notNull}") Long id) {
return Res.ok(deviceQrCodeAdminService.findById(id));
}
@PermCode(code = "manage", nameCn = "码牌管理", nameEn = "QrCode Manage")
@Operation(summary = "删除码牌")
@PostMapping("/delete")
public Result<Void> delete(@NotNull(message = "{validation.field.id.notNull}") Long id) {
deviceQrCodeAdminService.delete(id);
return Res.ok();
}
@PermCode(code = "manage", nameCn = "码牌管理", nameEn = "QrCode Manage")
@Operation(summary = "修改码牌状态")
@PostMapping("/change-status")
public Result<Void> changeStatus(@NotNull(message = "{validation.field.id.notNull}") Long id,
@NotBlank(message = "{validation.field.status.notBlank}") String status) {
deviceQrCodeAdminService.changeStatus(id, status);
return Res.ok();
}
}

View File

@@ -0,0 +1,35 @@
package cn.daxpay.open.payment.device.qrcode.controller.client;
import cn.daxpay.open.payment.device.qrcode.result.CodePayInfoResult;
import cn.daxpay.open.payment.device.qrcode.service.CodePayAssistService;
import cn.daxpay.open.platform.core.annotation.IgnoreAuth;
import cn.daxpay.open.platform.core.rest.Res;
import cn.daxpay.open.platform.core.rest.result.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/// # 码牌支付(公开/H5 侧)
///
/// 供 H5 码牌支付页(/code-pay/:code)扫码后调用, 无需登录态。
@IgnoreAuth
@Validated
@Tag(name = "码牌支付")
@RestController
@RequestMapping("/client/device/qrcode")
@RequiredArgsConstructor
public class DeviceQrCodeClientController {
private final CodePayAssistService codePayAssistService;
@Operation(summary = "根据码牌编码查询支付信息")
@GetMapping("/get-by-code")
public Result<CodePayInfoResult> getByCode(@NotBlank(message = "{validation.field.code.notBlank}") String code) {
return Res.ok(codePayAssistService.getByCode(code));
}
}

View File

@@ -0,0 +1,15 @@
package cn.daxpay.open.payment.device.qrcode.convert;
import cn.daxpay.open.payment.device.qrcode.entity.DeviceQrCode;
import cn.daxpay.open.payment.device.qrcode.result.DeviceQrCodeResult;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
/// # 支付码牌转换
///
@Mapper
public interface DeviceQrCodeConvert {
DeviceQrCodeConvert CONVERT = Mappers.getMapper(DeviceQrCodeConvert.class);
DeviceQrCodeResult toResult(DeviceQrCode in);
}

View File

@@ -0,0 +1,67 @@
package cn.daxpay.open.payment.device.qrcode.dao;
import cn.daxpay.open.payment.device.qrcode.entity.DeviceQrCode;
import cn.daxpay.open.payment.device.qrcode.param.DeviceQrCodeQuery;
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.Collection;
import java.util.Optional;
/// # 支付码牌管理
///
@Slf4j
@Repository
@RequiredArgsConstructor
public class DeviceQrCodeManager extends BaseManager<DeviceQrCodeMapper, DeviceQrCode> {
/// 根据码牌编码查询
public Optional<DeviceQrCode> findByCode(String code) {
return findByField(DeviceQrCode::getCode, code);
}
/// 判断码牌编码是否存在(排除指定id, excludeId 为 null 时不排除)
public boolean existsByCode(String code, Long excludeId) {
if (excludeId == null) {
return existedByField(DeviceQrCode::getCode, code);
}
return existedByField(DeviceQrCode::getCode, code, excludeId);
}
/// 判断批次号是否已存在
public boolean existedByBatchNo(String batchNo) {
return existedByField(DeviceQrCode::getBatchNo, batchNo);
}
/// 批量绑定商户与应用
public void bindMerchant(Collection<Long> ids, String mchNo, String appId) {
lambdaUpdate()
.set(DeviceQrCode::getMchNo, mchNo)
.set(DeviceQrCode::getAppId, appId)
.in(DeviceQrCode::getId, ids)
.update();
}
/// 批量解绑商户与应用(回空白库存)
public void unbindMerchant(Collection<Long> ids) {
lambdaUpdate()
.set(DeviceQrCode::getMchNo, null)
.set(DeviceQrCode::getAppId, null)
.in(DeviceQrCode::getId, ids)
.update();
}
/// 分页
public Page<DeviceQrCode> page(PageParam pageParam, DeviceQrCodeQuery query) {
Page<DeviceQrCode> mpPage = MpUtil.getMpPage(pageParam);
QueryWrapper<DeviceQrCode> wrapper = QueryGenerator.generator(query);
return this.page(mpPage, wrapper);
}
}

View File

@@ -0,0 +1,10 @@
package cn.daxpay.open.payment.device.qrcode.dao;
import cn.daxpay.open.payment.device.qrcode.entity.DeviceQrCode;
import com.github.yulichang.base.MPJBaseMapper;
import org.apache.ibatis.annotations.Mapper;
/// # 支付码牌
@Mapper
public interface DeviceQrCodeMapper extends MPJBaseMapper<DeviceQrCode> {
}

View File

@@ -0,0 +1,56 @@
package cn.daxpay.open.payment.device.qrcode.entity;
import cn.daxpay.open.payment.device.qrcode.convert.DeviceQrCodeConvert;
import cn.daxpay.open.payment.device.qrcode.result.DeviceQrCodeResult;
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;
/// # 支付码牌
///
/// 记录码牌与商户/应用的绑定关系, 扫码后进入 H5 码牌支付页(/code-pay/:code)。
/// 支持运营端批量创建空白码牌后划拨给商户: mchNo 初始为空, 绑定后写入。
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@TableName("device_qr_code")
public class DeviceQrCode extends MpBaseEntity implements ToResult<DeviceQrCodeResult> {
/// 码牌编码(系统自动生成, 唯一, 二维码 :code 参数)
private String code;
/// 码牌名称
private String name;
/// 批次号(批量创建空白码时写入, 单条新增可空)
private String batchNo;
/// 所属商户号(划拨后设置, 初始为空表示空白码)
private String mchNo;
/// 关联应用号(可空, 空白码为空; 绑定后空则使用商户默认应用)
private String appId;
/// 金额类型: random-自定义金额 / fixed-固定金额
/// @see cn.daxpay.open.payment.device.enums.QrCodeAmountTypeEnum
private String amountType;
/// 固定金额(分, amount_type=fixed 时必填)
private Long fixedAmount;
/// 状态: enabled-启用 / disabled-停用
/// @see cn.daxpay.open.payment.device.enums.QrCodeStatusEnum
private String status;
/// 备注
private String remark;
/// 转换为返回对象
@Override
public DeviceQrCodeResult toResult() {
return DeviceQrCodeConvert.CONVERT.toResult(this);
}
}

View File

@@ -0,0 +1,55 @@
package cn.daxpay.open.payment.device.qrcode.param;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 批量创建空白码牌参数
@Data
@Accessors(chain = true)
@Schema(title = "批量创建空白码牌参数")
public class DeviceQrCodeBatchParam {
/// 批次号(全局唯一, 同时作为码牌编码前缀)
@Schema(description = "批次号")
@NotBlank(message = "{validation.field.batchNo.notBlank}")
@Size(max = 64, message = "{validation.field.batchNo.size}")
private String batchNo;
/// 创建数量(1-999)
@Schema(description = "创建数量")
@NotNull(message = "{validation.field.count.notNull}")
@Min(value = 1, message = "{validation.field.count.min}")
@Max(value = 999, message = "{validation.field.count.max}")
private Integer count;
/// 码牌名称(可选, 整批统一名称)
@Schema(description = "码牌名称")
@Size(max = 100, message = "{validation.field.qrCodeName.size}")
private String name;
/// 金额类型: random-自定义金额 / fixed-固定金额
/// @see cn.daxpay.open.payment.device.enums.QrCodeAmountTypeEnum
@Schema(description = "金额类型(random-自定义/fixed-固定)")
@NotBlank(message = "{validation.field.amountType.notBlank}")
private String amountType;
/// 固定金额(分, amount_type=fixed 时必填, 由 Service 层校验)
@Schema(description = "固定金额(分)")
private Long fixedAmount;
/// 状态(enabled/disabled), 空则默认启用
/// @see cn.daxpay.open.payment.device.enums.QrCodeStatusEnum
@Schema(description = "状态(enabled-启用/disabled-停用), 空则默认启用")
private String status;
/// 备注
@Schema(description = "备注")
@Size(max = 500, message = "{validation.field.remark.size}")
private String remark;
}

View File

@@ -0,0 +1,32 @@
package cn.daxpay.open.payment.device.qrcode.param;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.experimental.Accessors;
import java.util.List;
/// # 码牌绑定商户参数
@Data
@Accessors(chain = true)
@Schema(title = "码牌绑定商户参数")
public class DeviceQrCodeBindMerchantParam {
/// 码牌主键列表
@Schema(description = "码牌主键列表")
@NotEmpty(message = "{validation.field.ids.notEmpty}")
private List<Long> ids;
/// 商户号
@Schema(description = "商户号")
@NotBlank(message = "{validation.field.mchNo.notBlank}")
private String mchNo;
/// 关联应用号(可空, 空则使用商户默认应用)
@Schema(description = "关联应用号")
@Size(max = 50, message = "{validation.field.appId.size}")
private String appId;
}

View File

@@ -0,0 +1,57 @@
package cn.daxpay.open.payment.device.qrcode.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 jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 支付码牌
@Data
@Accessors(chain = true)
@Schema(title = "支付码牌")
public class DeviceQrCodeParam {
/// 主键
@Schema(description = "主键")
@NotNull(message = "{validation.field.id.notNull}", groups = ValidationGroup.edit.class)
private Long id;
/// 码牌名称
@Schema(description = "码牌名称")
@NotBlank(message = "{validation.field.qrCodeName.notBlank}")
@Size(max = 100, message = "{validation.field.qrCodeName.size}")
private String name;
/// 商户号(新增快捷绑定时必填; 编辑不改归属, 仅 add 校验)
@Schema(description = "商户号")
@NotBlank(message = "{validation.field.mchNo.notBlank}", groups = ValidationGroup.add.class)
private String mchNo;
/// 关联应用号(可空, 空则使用商户默认应用)
@Schema(description = "关联应用号")
@Size(max = 50, message = "{validation.field.appId.size}")
private String appId;
/// 金额类型: random-自定义金额 / fixed-固定金额
/// @see cn.daxpay.open.payment.device.enums.QrCodeAmountTypeEnum
@Schema(description = "金额类型(random-自定义/fixed-固定)")
@NotBlank(message = "{validation.field.amountType.notBlank}")
private String amountType;
/// 固定金额(分, amount_type=fixed 时必填, 由 Service 层校验)
@Schema(description = "固定金额(分)")
private Long fixedAmount;
/// 状态(enabled/disabled)
/// @see cn.daxpay.open.payment.device.enums.QrCodeStatusEnum
@Schema(description = "状态")
private String status;
/// 备注
@Schema(description = "备注")
@Size(max = 500, message = "{validation.field.remark.size}")
private String remark;
}

View File

@@ -0,0 +1,45 @@
package cn.daxpay.open.payment.device.qrcode.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 DeviceQrCodeQuery {
/// 码牌编码
@Schema(description = "码牌编码")
@QueryParam(type = QueryParam.CompareTypeEnum.LIKE)
private String code;
/// 码牌名称
@Schema(description = "码牌名称")
private String name;
/// 批次号
@Schema(description = "批次号")
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
private String batchNo;
/// 商户号
@Schema(description = "商户号")
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
private String mchNo;
/// 金额类型
/// @see cn.daxpay.open.payment.device.enums.QrCodeAmountTypeEnum
@Schema(description = "金额类型")
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
private String amountType;
/// 状态
/// @see cn.daxpay.open.payment.device.enums.QrCodeStatusEnum
@Schema(description = "状态")
@QueryParam(type = QueryParam.CompareTypeEnum.EQ)
private String status;
}

View File

@@ -0,0 +1,28 @@
package cn.daxpay.open.payment.device.qrcode.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;
/// # 码牌支付信息(公开接口返回, 脱敏)
///
/// 供 H5 码牌支付页(/code-pay/:code)扫码后查询展示, 不含 appId/mchNo 等内部字段。
@Data
@Accessors(chain = true)
@Schema(title = "码牌支付信息")
public class CodePayInfoResult {
@Schema(description = "码牌编码")
private String code;
@Schema(description = "码牌名称")
private String name;
/// 金额类型
/// @see cn.daxpay.open.payment.device.enums.QrCodeAmountTypeEnum
@Schema(description = "金额类型(random-自定义/fixed-固定)")
private String amountType;
@Schema(description = "固定金额(分)")
private Long fixedAmount;
}

View File

@@ -0,0 +1,53 @@
package cn.daxpay.open.payment.device.qrcode.result;
import cn.daxpay.open.payment.merchant.entity.info.MerchantInfo;
import cn.daxpay.open.platform.core.annotation.Trans;
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;
/// # 支付码牌
@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
@Schema(title = "支付码牌")
public class DeviceQrCodeResult extends BaseResult {
@Schema(description = "码牌编码")
private String code;
@Schema(description = "码牌名称")
private String name;
@Schema(description = "批次号")
private String batchNo;
@Schema(description = "所属商户号(空表示未绑定)")
private String mchNo;
/// 商户名称(由 mchNo 翻译, 未绑定为空)
@Trans(entity = MerchantInfo.class, source = "mchNo", result = "mchName")
@Schema(description = "商户名称")
private String mchName;
@Schema(description = "关联应用号(空表示使用商户默认应用)")
private String appId;
/// 金额类型
/// @see cn.daxpay.open.payment.device.enums.QrCodeAmountTypeEnum
@Schema(description = "金额类型(random-自定义/fixed-固定)")
private String amountType;
@Schema(description = "固定金额(分)")
private Long fixedAmount;
/// 状态
/// @see cn.daxpay.open.payment.device.enums.QrCodeStatusEnum
@Schema(description = "状态(enabled-启用/disabled-停用)")
private String status;
@Schema(description = "备注")
private String remark;
}

View File

@@ -0,0 +1,56 @@
package cn.daxpay.open.payment.device.qrcode.service;
import cn.daxpay.open.payment.device.enums.QrCodeStatusEnum;
import cn.daxpay.open.payment.device.qrcode.dao.DeviceQrCodeManager;
import cn.daxpay.open.payment.device.qrcode.entity.DeviceQrCode;
import cn.daxpay.open.payment.device.qrcode.result.CodePayInfoResult;
import cn.daxpay.open.payment.merchant.dto.MerchantAccessInfo;
import cn.daxpay.open.payment.merchant.service.query.MerchantAccessQueryService;
import cn.daxpay.open.platform.core.exception.DataNotExistException;
import cn.daxpay.open.platform.core.exception.operation.OperationFailException;
import cn.daxpay.open.platform.core.code.CommonCode;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/// # 码牌支付编排(公开/H5 侧)
///
/// 一期: 仅提供按编码查询码牌信息(供 H5 扫码展示)。
/// 二期: 扩展 OAuth 重定向获取 openId 与发起 JSAPI 支付(复用 ChannelAuthService/NormalPayService)。
@Slf4j
@Service
@RequiredArgsConstructor
public class CodePayAssistService {
private final DeviceQrCodeManager deviceQrCodeManager;
private final MerchantAccessQueryService merchantAccessQueryService;
/// 根据码牌编码查询支付信息(公开接口, 脱敏返回)
///
/// 校验码牌存在、启用、已分配商户, 且商户存在。
public CodePayInfoResult getByCode(String code) {
DeviceQrCode entity = deviceQrCodeManager.findByCode(code)
// 码牌: 码牌不存在
.orElseThrow(() -> new DataNotExistException("error.device.qrcode.notFound"));
// 码牌停用
if (!QrCodeStatusEnum.ENABLED.getCode().equals(entity.getStatus())) {
throw new OperationFailException(CommonCode.FAIL_CODE, "error.device.qrcode.disabled");
}
// 空白码未划拨商户
if (StrUtil.isBlank(entity.getMchNo())) {
throw new OperationFailException(CommonCode.FAIL_CODE, "error.device.qrcode.notAssigned");
}
// 查商户并校验存在(启用校验留待二期支付链路 NormalPayService 统一处理)
MerchantAccessInfo merchant = merchantAccessQueryService.getMerchantByMchNo(entity.getMchNo());
if (merchant == null) {
// 码牌: 商户不存在
throw new DataNotExistException("error.device.qrcode.mchNotFound");
}
return new CodePayInfoResult()
.setCode(entity.getCode())
.setName(entity.getName())
.setAmountType(entity.getAmountType())
.setFixedAmount(entity.getFixedAmount());
}
}

View File

@@ -0,0 +1,194 @@
package cn.daxpay.open.payment.device.qrcode.service;
import cn.daxpay.open.payment.common.service.MerchantContextLoader;
import cn.daxpay.open.payment.device.enums.QrCodeAmountTypeEnum;
import cn.daxpay.open.payment.device.enums.QrCodeStatusEnum;
import cn.daxpay.open.payment.device.qrcode.dao.DeviceQrCodeManager;
import cn.daxpay.open.payment.device.qrcode.entity.DeviceQrCode;
import cn.daxpay.open.payment.device.qrcode.param.DeviceQrCodeBatchParam;
import cn.daxpay.open.payment.device.qrcode.param.DeviceQrCodeBindMerchantParam;
import cn.daxpay.open.payment.device.qrcode.param.DeviceQrCodeParam;
import cn.daxpay.open.payment.device.qrcode.param.DeviceQrCodeQuery;
import cn.daxpay.open.payment.device.qrcode.result.DeviceQrCodeResult;
import cn.daxpay.open.payment.merchant.dto.MchAppInfoAccessInfo;
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
import cn.daxpay.open.platform.common.translate.service.TransService;
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 cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.IntStream;
/// # 支付码牌管理(运营端)
///
/// 维护码牌台账, 支持批量创建空白码与划拨绑定商户。
/// 扫码后的支付编排由 [CodePayAssistService] 承担。
@Slf4j
@Service
@RequiredArgsConstructor
public class DeviceQrCodeAdminService {
/// 码牌编码前缀(单条快捷新增)
private static final String CODE_PREFIX = "QR";
private final DeviceQrCodeManager deviceQrCodeManager;
private final MerchantContextLoader merchantContextLoader;
private final TransService transService;
/// 新增码牌(快捷路径: 直接绑定商户, 自动生成编码)
@Transactional(rollbackFor = Exception.class)
public void add(DeviceQrCodeParam param) {
// 金额类型校验
QrCodeAmountTypeEnum amountType = QrCodeAmountTypeEnum.findByCode(param.getAmountType());
// 固定金额: fixed 类型必填且大于 0
validateFixedAmount(amountType, param.getFixedAmount());
// 应用解析: appId 空则取商户默认应用, 否则校验启用与归属, 回填 appId
String appId = resolveAppId(param.getMchNo(), param.getAppId());
DeviceQrCode entity = new DeviceQrCode()
.setCode(generateCode())
.setName(param.getName())
.setMchNo(param.getMchNo())
.setAppId(appId)
.setAmountType(amountType.getCode())
.setFixedAmount(amountType == QrCodeAmountTypeEnum.FIXED ? param.getFixedAmount() : null)
.setStatus(QrCodeStatusEnum.ENABLED.getCode())
.setRemark(param.getRemark());
deviceQrCodeManager.save(entity);
}
/// 批量创建空白码牌(不绑商户, 进入平台库存)
@Transactional(rollbackFor = Exception.class)
public void createBatch(DeviceQrCodeBatchParam param) {
// 批次号唯一
if (deviceQrCodeManager.existedByBatchNo(param.getBatchNo())) {
// 码牌: 批次号已存在
throw new OperationFailException(CommonCode.FAIL_CODE, "error.device.qrcode.batchNoExists");
}
// 金额类型校验
QrCodeAmountTypeEnum amountType = QrCodeAmountTypeEnum.findByCode(param.getAmountType());
validateFixedAmount(amountType, param.getFixedAmount());
Long fixedAmount = amountType == QrCodeAmountTypeEnum.FIXED ? param.getFixedAmount() : null;
// 状态: 空则默认启用
String status = StrUtil.isBlank(param.getStatus())
? QrCodeStatusEnum.ENABLED.getCode()
: QrCodeStatusEnum.findByCode(param.getStatus()).getCode();
// 编码: 批次号 + 三位序号(001-999)
List<DeviceQrCode> list = IntStream.rangeClosed(1, param.getCount())
.mapToObj(i -> new DeviceQrCode()
.setCode(param.getBatchNo() + String.format("%03d", i))
.setName(param.getName())
.setBatchNo(param.getBatchNo())
.setAmountType(amountType.getCode())
.setFixedAmount(fixedAmount)
.setStatus(status)
.setRemark(param.getRemark()))
.toList();
deviceQrCodeManager.saveAll(list);
}
/// 判断批次号是否已存在
public boolean existsByBatchNo(String batchNo) {
return deviceQrCodeManager.existedByBatchNo(batchNo);
}
/// 批量绑定商户(可覆盖已绑定归属)
@Transactional(rollbackFor = Exception.class)
public void bindMerchant(DeviceQrCodeBindMerchantParam param) {
// 应用解析: appId 空则取商户默认应用
String appId = resolveAppId(param.getMchNo(), param.getAppId());
deviceQrCodeManager.bindMerchant(param.getIds(), param.getMchNo(), appId);
}
/// 批量解绑商户(回空白库存, 保留编码/批次/金额配置)
@Transactional(rollbackFor = Exception.class)
public void unbindMerchant(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
// 码牌: 请选择码牌
throw new OperationFailException(CommonCode.FAIL_CODE, "error.device.qrcode.idsEmpty");
}
deviceQrCodeManager.unbindMerchant(ids);
}
/// 修改码牌(编码/归属不可改; 归属走 bind/unbind)
@Transactional(rollbackFor = Exception.class)
public void update(DeviceQrCodeParam param) {
DeviceQrCode entity = deviceQrCodeManager.findById(param.getId())
// 码牌: 码牌不存在
.orElseThrow(() -> new DataNotExistException("error.device.qrcode.notFound"));
// 金额类型校验
QrCodeAmountTypeEnum amountType = QrCodeAmountTypeEnum.findByCode(param.getAmountType());
validateFixedAmount(amountType, param.getFixedAmount());
// 仅更新业务配置, 不改 mchNo/appId
entity.setName(param.getName())
.setAmountType(amountType.getCode())
.setFixedAmount(amountType == QrCodeAmountTypeEnum.FIXED ? param.getFixedAmount() : null)
.setRemark(param.getRemark());
deviceQrCodeManager.updateById(entity);
}
/// 分页
public PageResult<DeviceQrCodeResult> page(PageParam pageParam, DeviceQrCodeQuery query) {
PageResult<DeviceQrCodeResult> pageResult = MpUtil.toPageResult(deviceQrCodeManager.page(pageParam, query));
// 翻译商户名称(mchNo -> mchName)
transService.translate(pageResult);
return pageResult;
}
/// 根据id查询
public DeviceQrCodeResult findById(Long id) {
DeviceQrCodeResult result = deviceQrCodeManager.findById(id)
// 码牌: 码牌不存在
.orElseThrow(() -> new DataNotExistException("error.device.qrcode.notFound"))
.toResult();
// 翻译商户名称
transService.translate(result);
return result;
}
/// 删除
public void delete(Long id) {
deviceQrCodeManager.findById(id)
.orElseThrow(() -> new DataNotExistException("error.device.qrcode.notFound"));
deviceQrCodeManager.deleteById(id);
}
/// 修改状态(启用/停用)
@Transactional(rollbackFor = Exception.class)
public void changeStatus(Long id, String status) {
QrCodeStatusEnum statusEnum = QrCodeStatusEnum.findByCode(status);
DeviceQrCode entity = deviceQrCodeManager.findById(id)
.orElseThrow(() -> new DataNotExistException("error.device.qrcode.notFound"));
entity.setStatus(statusEnum.getCode());
deviceQrCodeManager.updateById(entity);
}
/// 生成码牌编码: 前缀 + 雪花ID
private String generateCode() {
return CODE_PREFIX + IdUtil.getSnowflakeNextId();
}
/// 应用解析: 复用 [MerchantContextLoader.resolveApp], appId 空取商户默认应用, 返回解析后的 appId
private String resolveAppId(String mchNo, String appId) {
MchAppInfoAccessInfo mchApp = merchantContextLoader.resolveApp(mchNo, appId);
return mchApp.getAppId();
}
/// 固定金额校验: fixed 类型必填且大于 0
private void validateFixedAmount(QrCodeAmountTypeEnum amountType, Long fixedAmount) {
if (amountType == QrCodeAmountTypeEnum.FIXED) {
if (fixedAmount == null || fixedAmount <= 0) {
// 码牌: 固定金额必须大于 0
throw new OperationFailException(CommonCode.FAIL_CODE, "error.device.qrcode.fixedAmountInvalid");
}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"random": "Custom Amount",
"fixed": "Fixed Amount"
}

View File

@@ -0,0 +1,4 @@
{
"enabled": "Enabled",
"disabled": "Disabled"
}

View File

@@ -0,0 +1,11 @@
{
"notFound": "QR code board does not exist",
"disabled": "QR code board is disabled",
"mchNotFound": "The merchant of the QR code board does not exist",
"amountTypeNotFound": "No matching QR code amount type found: {0}",
"statusNotFound": "No matching QR code status found: {0}",
"fixedAmountInvalid": "Fixed amount must be greater than 0",
"batchNoExists": "Batch number already exists",
"notAssigned": "QR code board has not been assigned to a merchant",
"idsEmpty": "Please select QR code boards"
}

View File

@@ -15,6 +15,9 @@
"clientSecret": {
"notBlank": "Client secret cannot be blank"
},
"clientKey": {
"notBlank": "Client Key cannot be blank"
},
"clientCode": {
"notBlank": "Client code cannot be blank"
},
@@ -552,6 +555,25 @@
"vendorConfigId": {
"notNull": "Vendor config cannot be null"
},
"qrCodeName": {
"notBlank": "QR code name cannot be blank",
"size": "QR code name cannot exceed 100 characters"
},
"amountType": {
"notBlank": "Amount type cannot be blank"
},
"batchNo": {
"notBlank": "Batch number cannot be blank",
"size": "Batch number cannot exceed 64 characters"
},
"count": {
"notNull": "Count cannot be null",
"min": "Count cannot be less than 1",
"max": "Count cannot be greater than 999"
},
"ids": {
"notEmpty": "Please select QR code boards"
},
"configName": {
"notBlank": "Config name cannot be blank"
},

View File

@@ -0,0 +1,4 @@
{
"random": "自定义金额",
"fixed": "固定金额"
}

View File

@@ -0,0 +1,4 @@
{
"enabled": "启用",
"disabled": "停用"
}

View File

@@ -0,0 +1,11 @@
{
"notFound": "码牌不存在",
"disabled": "码牌已停用",
"mchNotFound": "码牌所属商户不存在",
"amountTypeNotFound": "未找到对应的码牌金额类型: {0}",
"statusNotFound": "未找到对应的码牌状态: {0}",
"fixedAmountInvalid": "固定金额必须大于0",
"batchNoExists": "批次号已存在",
"notAssigned": "码牌未分配商户",
"idsEmpty": "请选择码牌"
}

View File

@@ -15,6 +15,9 @@
"clientSecret": {
"notBlank": "客户端密钥不能为空"
},
"clientKey": {
"notBlank": "Client Key不能为空"
},
"clientCode": {
"notBlank": "终端不能为空"
},
@@ -555,6 +558,25 @@
"vendorConfigId": {
"notNull": "厂商配置不能为空"
},
"qrCodeName": {
"notBlank": "码牌名称不能为空",
"size": "码牌名称不可超过100位"
},
"amountType": {
"notBlank": "金额类型不能为空"
},
"batchNo": {
"notBlank": "批次号不能为空",
"size": "批次号不可超过64位"
},
"count": {
"notNull": "创建数量不能为空",
"min": "创建数量不能小于1",
"max": "创建数量不能大于999"
},
"ids": {
"notEmpty": "请选择码牌"
},
"configName": {
"notBlank": "配置名称不能为空"
},