This commit is contained in:
dzc committed 2026-09-28 15:25:51 +08:00
commit f2ccd3050a
30 files changed
+2126

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
<runConfiguration name="PjcloudAdminApplication" type="Spring Boot" />
<runConfiguration name="CanteenApp" type="Spring Boot" />
<runConfiguration name="ThirdPartyApp" type="Spring Boot" />
<runConfiguration name="WalletApp" type="Spring Boot" />
<runConfiguration name="HardApp" type="Spring Boot" />
<method v="2" />
</configuration>
</component>
@@ -0,0 +1,67 @@
package com.bsd.pjcloud.canteen.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("ai_feed_batch")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "AI喂数导入批次")
public class AiFeedBatchEntity extends Model<AiFeedBatchEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String batchNo;
private Long deptId;
private Long merchantId;
private String fileName;
/** USER / RESERVE / TAKE_OUT */
private String feedType;
/** RUNNING / SUCCESS / FAILED / DELETING / DELETED */
private String status;
private Integer totalRows;
private Integer successRows;
private Integer skipRows;
private String errorMsg;
private Long costMs;
private String operator;
private String operatorName;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
private Long tenantId;
}
@@ -0,0 +1,50 @@
package com.bsd.pjcloud.canteen.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@TenantTable
@TableName("ai_feed_batch_item")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "AI喂数批次明细")
public class AiFeedBatchItemEntity extends Model<AiFeedBatchItemEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long batchId;
/** USER / ORDER / SKIP(跳过行,refId=文件行号,原因在 extraJson.reason) */
private String itemType;
private Long refId;
private String bizKey;
private String extraJson;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
private Long tenantId;
}
@@ -0,0 +1,80 @@
package com.bsd.pjcloud.canteen.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.canteen.service.AiFeedImportService;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
/**
* AI 用户/订单喂数导入(单一 JSONL:user + reserve + offline)
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/ai-feed")
@Tag(description = "ai-feed", name = "AI数据喂入")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class AiFeedController {
private final AiFeedImportService aiFeedImportService;
@Operation(summary = "流式导入统一 JSONL")
@SysLog("AI喂数导入")
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("@pms.hasPermission('canteen_aiFeed_import')")
public R importJsonl(@RequestParam("file") MultipartFile file,
@RequestParam("merchantId") Long merchantId) {
return aiFeedImportService.importJsonl(file, merchantId);
}
@Operation(summary = "批次分页")
@GetMapping("/batch/page")
@PreAuthorize("@pms.hasPermission('canteen_aiFeed_view')")
public R page(@RequestParam(required = false) String status, Page page) {
return aiFeedImportService.pageBatches(page, status);
}
@Operation(summary = "批次详情")
@GetMapping("/batch/{id}")
@PreAuthorize("@pms.hasPermission('canteen_aiFeed_view')")
public R detail(@PathVariable Long id) {
return aiFeedImportService.getBatch(id);
}
@Operation(summary = "批次导入明细(含跳过原因)")
@GetMapping("/batch/{id}/items")
@PreAuthorize("@pms.hasPermission('canteen_aiFeed_view')")
public R items(@PathVariable Long id) {
return aiFeedImportService.listItems(id);
}
@Operation(summary = "按批次清理演示数据")
@SysLog("AI喂数删除批次")
@DeleteMapping("/batch/{id}")
@PreAuthorize("@pms.hasPermission('canteen_aiFeed_del')")
public R delete(@PathVariable Long id) {
return aiFeedImportService.deleteBatch(id);
}
@Operation(summary = "下载统一 JSONL 模板")
@GetMapping("/template")
@PreAuthorize("@pms.hasPermission('canteen_aiFeed_view')")
public ResponseEntity<byte[]> template() {
String body = aiFeedImportService.templateContent();
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=ai_feed_mixed.jsonl")
.contentType(new MediaType("text", "plain", StandardCharsets.UTF_8))
.body(body.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.canteen.mapper;
import com.bsd.pjcloud.canteen.entity.AiFeedBatchItemEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiFeedBatchItemMapper extends PjcloudBaseMapper<AiFeedBatchItemEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.canteen.mapper;
import com.bsd.pjcloud.canteen.entity.AiFeedBatchEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiFeedBatchMapper extends PjcloudBaseMapper<AiFeedBatchEntity> {
}
@@ -0,0 +1,27 @@
package com.bsd.pjcloud.canteen.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import org.springframework.web.multipart.MultipartFile;
public interface AiFeedImportService {
/**
* 单一 JSONL:每行带 type=user|reserve|offline。
* 仅需商户;用户部门取商户绑定部门。
* 接口快速返回 RUNNING 批次,后台异步落库;前端轮询批次状态。
*/
R importJsonl(MultipartFile file, Long merchantId);
R pageBatches(Page page, String status);
R getBatch(Long batchId);
/** 批次行明细(成功 USER/ORDER + 跳过 SKIP,含失败原因) */
R listItems(Long batchId);
R deleteBatch(Long batchId);
/** 统一混合模板内容 */
String templateContent();
}
@@ -0,0 +1,854 @@
package com.bsd.pjcloud.canteen.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.AiFeedUserBatchResultDTO;
import com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.canteen.entity.AiFeedBatchEntity;
import com.bsd.pjcloud.canteen.entity.AiFeedBatchItemEntity;
import com.bsd.pjcloud.canteen.mapper.AiFeedBatchItemMapper;
import com.bsd.pjcloud.canteen.mapper.AiFeedBatchMapper;
import com.bsd.pjcloud.canteen.service.AiFeedImportService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderBatchResultDTO;
import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO;
import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService;
import com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO;
import com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.InputStreamReader;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiFeedImportServiceImpl implements AiFeedImportService {
/** 批次类型:统一混合文件 */
private static final String FEED_MIXED = "MIXED";
private static final String ROW_USER = "user";
private static final String ROW_RESERVE = "reserve";
private static final String ROW_OFFLINE = "offline";
private static final String ITEM_USER = "USER";
private static final String ITEM_ORDER = "ORDER";
/** 行级跳过/失败,refId=行号,extra_json.reason 为原因 */
private static final String ITEM_SKIP = "SKIP";
private static final int MAX_ERROR_LEN = 1800;
private static final int MAX_REASON_LEN = 400;
/** 建用户远程分片 */
private static final int USER_CHUNK = 100;
/** 开通饭卡:单次尽量多,避免「开通饭卡 1/50」那种一片片爬 */
private static final int WALLET_CHUNK = 500;
private static final int ORDER_CHUNK = 100;
/** 单次远程调用最长等待(大包开饭卡给足时间) */
private static final long REMOTE_TIMEOUT_MS = 180_000L;
private static final DateTimeFormatter BATCH_NO_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
/** 同时只跑一个导入,避免两批抢 upms/wallet 导致超时 */
private static final ExecutorService AI_FEED_EXECUTOR = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "ai-feed-import");
t.setDaemon(true);
return t;
});
private static final ExecutorService AI_FEED_REMOTE_EXECUTOR = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "ai-feed-remote");
t.setDaemon(true);
return t;
});
private final AiFeedBatchMapper batchMapper;
private final AiFeedBatchItemMapper itemMapper;
private final CanteenMerchantScopeHelper merchantScopeHelper;
private final RemoteUserService remoteUserService;
private final RemoteWalletService remoteWalletService;
private final RemoteHardOrderService remoteHardOrderService;
private final ObjectMapper objectMapper;
@Override
public R importJsonl(MultipartFile file, Long merchantId) {
if (file == null || file.isEmpty()) {
return R.failed("请上传 JSONL 文件");
}
if (merchantId == null) {
return R.failed("请选择商户");
}
Long runningCnt = batchMapper.selectCount(Wrappers.<AiFeedBatchEntity>lambdaQuery()
.eq(AiFeedBatchEntity::getStatus, "RUNNING"));
if (runningCnt != null && runningCnt > 0) {
return R.failed("已有导入任务进行中,请等待完成;若长时间卡住请先「清理本批」后再导");
}
final Long deptId;
try {
deptId = merchantScopeHelper.requireDeptIdByMerchantId(merchantId);
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
final byte[] fileBytes;
try {
// Multipart 临时文件请求结束后可能失效,先读入内存再异步处理
fileBytes = file.getBytes();
}
catch (Exception ex) {
return R.failed("读取上传文件失败: " + StrUtil.blankToDefault(ex.getMessage(), "unknown"));
}
PjcloudUser op = SecurityUtils.getUser();
AiFeedBatchEntity batch = new AiFeedBatchEntity();
batch.setBatchNo("AIFEED" + LocalDateTime.now().format(BATCH_NO_FMT) + (int) (Math.random() * 900 + 100));
batch.setDeptId(deptId);
batch.setMerchantId(merchantId);
batch.setFileName(file.getOriginalFilename());
batch.setFeedType(FEED_MIXED);
batch.setStatus("RUNNING");
batch.setTotalRows(0);
batch.setSuccessRows(0);
batch.setSkipRows(0);
batch.setOperator(op == null ? null : op.getUsername());
batch.setOperatorName(op == null ? null : op.getName());
batchMapper.insert(batch);
Long tenantId = TenantContextHolder.getTenantId();
Long batchId = batch.getId();
CompletableFuture.runAsync(() -> runImportAsync(batchId, fileBytes, deptId, merchantId, tenantId), AI_FEED_EXECUTOR);
return R.ok(batch);
}
/** 后台异步导入;恢复租户上下文供 Feign/MyBatis 多租户使用 */
private void runImportAsync(Long batchId, byte[] fileBytes, Long deptId, Long merchantId, Long tenantId) {
Long previousTenant = TenantContextHolder.getTenantId();
try {
if (tenantId != null) {
TenantContextHolder.setTenantId(tenantId);
}
AiFeedBatchEntity batch = batchMapper.selectById(batchId);
if (batch == null) {
return;
}
processImportLines(batch, fileBytes, deptId, merchantId);
}
catch (Throwable ex) {
log.error("AI喂数异步导入失败 batchId={}", batchId, ex);
try {
AiFeedBatchEntity failed = batchMapper.selectById(batchId);
if (failed != null) {
failed.setStatus("FAILED");
failed.setErrorMsg(trimErr(ex.getMessage()));
batchMapper.updateById(failed);
}
}
catch (Exception updateEx) {
log.warn("AI喂数异步失败回写状态异常 batchId={}", batchId, updateEx);
}
}
finally {
if (previousTenant != null) {
TenantContextHolder.setTenantId(previousTenant);
}
else {
TenantContextHolder.clear();
}
}
}
/**
* 分阶段批量导入:解析 → 批量建用户 → 批量开饭卡/设余额 → 批量建订单。
* 避免「一行多次 Feign」导致千行级导入耗时数小时。
*/
private void processImportLines(AiFeedBatchEntity batch, byte[] fileBytes, Long deptId, Long merchantId) {
long start = System.currentTimeMillis();
List<ParsedUserRow> userRows = new ArrayList<>();
List<ParsedOrderRow> orderRows = new ArrayList<>();
List<AiFeedBatchItemEntity> skipItems = new ArrayList<>();
StringBuilder errSample = new StringBuilder();
int total = 0;
int seq = 0;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new ByteArrayInputStream(fileBytes), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (StrUtil.isBlank(line) || line.trim().startsWith("#")) {
continue;
}
total++;
seq++;
String phoneHint = null;
String rowTypeHint = null;
try {
JsonNode node = objectMapper.readTree(line);
rowTypeHint = normalizeRowType(text(node, "type"));
phoneHint = text(node, "phone");
if (ROW_USER.equals(rowTypeHint)) {
userRows.add(parseUserRow(node, deptId, total));
}
else if (ROW_RESERVE.equals(rowTypeHint) || ROW_OFFLINE.equals(rowTypeHint)) {
orderRows.add(parseOrderRow(node, rowTypeHint, deptId, merchantId, seq, total, batch.getBatchNo()));
}
else {
throw new IllegalArgumentException("type 必须是 user / reserve / offline");
}
}
catch (Exception ex) {
String reason = StrUtil.blankToDefault(ex.getMessage(), ex.getClass().getSimpleName());
skipItems.add(buildSkipItem(batch.getId(), total, rowTypeHint, phoneHint, reason, line));
appendErr(errSample, total, reason);
}
}
}
catch (Exception ex) {
batch.setStatus("FAILED");
batch.setErrorMsg(trimErr(ex.getMessage()));
batch.setTotalRows(total);
batch.setSuccessRows(0);
batch.setSkipRows(skipItems.size());
batch.setCostMs(System.currentTimeMillis() - start);
batchMapper.updateById(batch);
log.error("AI喂数解析失败 batch={}", batch.getBatchNo(), ex);
return;
}
flushProgress(batch, total, 0, skipItems.size(), start, "解析完成,准备建用户");
Map<String, FeedUserCtx> userCache = new HashMap<>();
List<AiFeedBatchItemEntity> successItems = new ArrayList<>();
// 1) 批量建用户
Map<String, ParsedUserRow> userByPhone = new HashMap<>();
for (ParsedUserRow ur : userRows) {
userByPhone.putIfAbsent(ur.phone(), ur);
}
List<AiFeedUserCreateDTO> createUsers = userByPhone.values().stream().map(ur -> {
AiFeedUserCreateDTO dto = new AiFeedUserCreateDTO();
dto.setPhone(ur.phone());
dto.setName(ur.name());
dto.setDeptId(ur.deptId());
return dto;
}).toList();
int userChunkIdx = 0;
int userChunkTotal = Math.max(1, (createUsers.size() + USER_CHUNK - 1) / USER_CHUNK);
for (List<AiFeedUserCreateDTO> chunk : chunks(createUsers, USER_CHUNK)) {
userChunkIdx++;
flushProgress(batch, total, successItems.size(), skipItems.size(), start,
"创建用户 " + userChunkIdx + "/" + userChunkTotal + "(本批" + chunk.size() + ")");
log.info("AI喂数创建用户 batch={} chunk={}/{} size={}", batch.getBatchNo(), userChunkIdx, userChunkTotal, chunk.size());
try {
R<AiFeedUserBatchResultDTO> ur = callRemote("创建用户",
() -> remoteUserService.aiFeedCreateUsersBatch(chunk));
if (ur == null || !ur.isOk() || ur.getData() == null || ur.getData().getItems() == null) {
throw new IllegalArgumentException(ur == null ? "批量创建用户失败" : StrUtil.blankToDefault(ur.getMsg(), "批量创建用户失败"));
}
for (AiFeedUserBatchResultDTO.Item it : ur.getData().getItems()) {
if (it == null || StrUtil.isBlank(it.getPhone()) || it.getUserId() == null) {
continue;
}
ParsedUserRow src = userByPhone.get(it.getPhone());
userCache.put(it.getPhone(), new FeedUserCtx(it.getUserId(),
src == null ? deptId : src.deptId(), null));
}
}
catch (Exception ex) {
log.error("AI喂数创建用户失败 batch={} chunk={}/{}", batch.getBatchNo(), userChunkIdx, userChunkTotal, ex);
String reason = "批量创建用户: " + StrUtil.blankToDefault(ex.getMessage(), "失败");
for (AiFeedUserCreateDTO u : chunk) {
ParsedUserRow src = userByPhone.get(u.getPhone());
int lineNo = src == null ? 0 : src.lineNo();
skipItems.add(buildSkipItem(batch.getId(), lineNo, ROW_USER, u.getPhone(), reason, null));
appendErr(errSample, lineNo, reason);
}
}
}
// 1b) 订单行引用但本文件无 user 行的手机号:仅批量查已有用户
List<String> orderOnlyPhones = orderRows.stream()
.map(ParsedOrderRow::phone)
.filter(StrUtil::isNotBlank)
.distinct()
.filter(p -> !userCache.containsKey(p))
.toList();
if (!orderOnlyPhones.isEmpty()) {
List<AiFeedUserCreateDTO> lookupUsers = orderOnlyPhones.stream().map(p -> {
AiFeedUserCreateDTO dto = new AiFeedUserCreateDTO();
dto.setPhone(p);
return dto;
}).toList();
int lookupIdx = 0;
int lookupTotal = Math.max(1, (lookupUsers.size() + USER_CHUNK - 1) / USER_CHUNK);
for (List<AiFeedUserCreateDTO> chunk : chunks(lookupUsers, USER_CHUNK)) {
lookupIdx++;
flushProgress(batch, total, successItems.size(), skipItems.size(), start,
"匹配已有用户 " + lookupIdx + "/" + lookupTotal);
try {
R<AiFeedUserBatchResultDTO> ur = callRemote("匹配用户",
() -> remoteUserService.aiFeedCreateUsersBatch(chunk));
if (ur != null && ur.isOk() && ur.getData() != null && ur.getData().getItems() != null) {
for (AiFeedUserBatchResultDTO.Item it : ur.getData().getItems()) {
if (it != null && StrUtil.isNotBlank(it.getPhone()) && it.getUserId() != null) {
userCache.put(it.getPhone(), new FeedUserCtx(it.getUserId(), deptId, null));
}
}
}
}
catch (Exception ex) {
log.warn("AI喂数批量查用户失败 batch={}: {}", batch.getBatchNo(), ex.getMessage());
}
}
}
// 2) 先查已有饭卡;已有则直接复用,只给「还没有饭卡」的用户开一次(余额仅新建时写入)
Map<Long, Integer> balanceByUserId = new HashMap<>();
for (ParsedUserRow ur : userByPhone.values()) {
FeedUserCtx ctx = userCache.get(ur.phone());
if (ctx != null) {
balanceByUserId.put(ctx.userId(), ur.balanceFen());
}
}
Map<Long, Long> walletByUser = new HashMap<>();
List<Long> allUserIds = userCache.values().stream()
.map(FeedUserCtx::userId)
.filter(Objects::nonNull)
.distinct()
.toList();
flushProgress(batch, total, successItems.size(), skipItems.size(), start,
"查询已有饭卡(" + allUserIds.size() + "人)");
for (List<Long> idChunk : chunks(allUserIds, 500)) {
try {
R<List<com.bsd.pjcloud.wallet.api.entity.WalletManageEntity>> existR = callRemote("查询饭卡",
() -> remoteWalletService.getWalletsByUserIds(idChunk,
com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum.EAT));
if (existR != null && existR.isOk() && existR.getData() != null) {
for (com.bsd.pjcloud.wallet.api.entity.WalletManageEntity w : existR.getData()) {
if (w != null && w.getUserId() != null && w.getId() != null) {
walletByUser.put(w.getUserId(), w.getId());
}
}
}
}
catch (Exception ex) {
log.warn("AI喂数查询已有饭卡失败 batch={}: {}", batch.getBatchNo(), ex.getMessage());
}
}
List<AiFeedWalletEnsureDTO> walletReqs = new ArrayList<>();
for (FeedUserCtx ctx : userCache.values()) {
if (ctx == null || ctx.userId() == null) {
continue;
}
if (walletByUser.containsKey(ctx.userId())) {
continue;
}
AiFeedWalletEnsureDTO wr = new AiFeedWalletEnsureDTO();
wr.setUserId(ctx.userId());
wr.setDeptId(ctx.deptId());
if (balanceByUserId.containsKey(ctx.userId())) {
wr.setBalanceFen(balanceByUserId.get(ctx.userId()));
}
walletReqs.add(wr);
}
if (walletReqs.isEmpty()) {
flushProgress(batch, total, successItems.size(), skipItems.size(), start,
"饭卡已齐全,跳过开通(" + walletByUser.size() + "人)");
}
else {
flushProgress(batch, total, successItems.size(), skipItems.size(), start,
"批量开通饭卡(待开" + walletReqs.size() + "人,已有" + walletByUser.size() + ")");
log.info("AI喂数开通饭卡 batch={} needOpen={} already={}", batch.getBatchNo(), walletReqs.size(),
walletByUser.size());
for (List<AiFeedWalletEnsureDTO> chunk : chunks(walletReqs, WALLET_CHUNK)) {
try {
R<AiFeedWalletEnsureResultDTO> wr = callRemote("开通饭卡",
() -> remoteWalletService.aiFeedEnsureEatWalletsBatch(chunk));
if (wr == null || !wr.isOk() || wr.getData() == null || wr.getData().getItems() == null) {
throw new IllegalArgumentException(wr == null ? "批量开通饭卡失败"
: StrUtil.blankToDefault(wr.getMsg(), "批量开通饭卡失败"));
}
for (AiFeedWalletEnsureResultDTO.Item it : wr.getData().getItems()) {
if (it != null && it.getUserId() != null && it.getWalletId() != null) {
walletByUser.put(it.getUserId(), it.getWalletId());
}
}
flushProgress(batch, total, successItems.size(), skipItems.size(), start,
"开通饭卡进行中(已有" + walletByUser.size() + "/" + allUserIds.size() + ")");
}
catch (Exception ex) {
log.error("AI喂数开通饭卡失败 batch={} size={}", batch.getBatchNo(), chunk.size(), ex);
String reason = "批量开通饭卡: " + StrUtil.blankToDefault(ex.getMessage(), "失败");
for (AiFeedWalletEnsureDTO w : chunk) {
appendErr(errSample, 0, reason + " userId=" + w.getUserId());
}
}
}
}
for (Map.Entry<String, FeedUserCtx> e : new ArrayList<>(userCache.entrySet())) {
Long walletId = walletByUser.get(e.getValue().userId());
userCache.put(e.getKey(), new FeedUserCtx(e.getValue().userId(), e.getValue().deptId(), walletId));
}
for (ParsedUserRow ur : userRows) {
FeedUserCtx ctx = userCache.get(ur.phone());
if (ctx == null || ctx.walletId() == null) {
skipItems.add(buildSkipItem(batch.getId(), ur.lineNo(), ROW_USER, ur.phone(), "开通饭卡钱包失败", null));
appendErr(errSample, ur.lineNo(), "开通饭卡钱包失败");
continue;
}
successItems.add(buildUserItem(batch.getId(), ur, ctx));
}
flushProgress(batch, total, successItems.size(), skipItems.size(), start, "用户处理完成,准备建订单");
// 3) 批量建订单(演示不再逐单扣款,避免再打钱包服务)
List<AiFeedOrderCreateDTO> orderDtos = new ArrayList<>();
List<ParsedOrderRow> orderReady = new ArrayList<>();
for (ParsedOrderRow or : orderRows) {
FeedUserCtx ctx = userCache.get(or.phone());
if (ctx == null) {
skipItems.add(buildSkipItem(batch.getId(), or.lineNo(), or.rowType(), or.phone(),
"用户不存在,请先在同文件写 user 行或已导入: " + or.phone(), null));
appendErr(errSample, or.lineNo(), "用户不存在: " + or.phone());
continue;
}
or.dto().setUserId(ctx.userId());
or.dto().setUserDeptId(ctx.deptId());
or.dto().setWalletId(ctx.walletId());
orderDtos.add(or.dto());
orderReady.add(or);
}
Map<Integer, Long> orderIdBySeq = new HashMap<>();
Map<Integer, String> orderErrBySeq = new HashMap<>();
int orderChunkTotal = Math.max(1, (orderDtos.size() + ORDER_CHUNK - 1) / ORDER_CHUNK);
for (int i = 0; i < orderDtos.size(); i += ORDER_CHUNK) {
List<AiFeedOrderCreateDTO> chunk = orderDtos.subList(i, Math.min(i + ORDER_CHUNK, orderDtos.size()));
int orderChunkIdx = i / ORDER_CHUNK + 1;
flushProgress(batch, total, successItems.size() + orderIdBySeq.size(), skipItems.size(), start,
"创建订单 " + orderChunkIdx + "/" + orderChunkTotal + "(本批" + chunk.size() + ")");
log.info("AI喂数创建订单 batch={} chunk={}/{} size={}", batch.getBatchNo(), orderChunkIdx, orderChunkTotal, chunk.size());
try {
R<AiFeedOrderBatchResultDTO> or = callRemote("创建订单",
() -> remoteHardOrderService.aiFeedCreateOrdersBatch(chunk));
if (or == null || !or.isOk() || or.getData() == null || or.getData().getItems() == null) {
throw new IllegalArgumentException(or == null ? "批量创建订单失败" : StrUtil.blankToDefault(or.getMsg(), "批量创建订单失败"));
}
for (AiFeedOrderBatchResultDTO.Item it : or.getData().getItems()) {
if (it == null || it.getSeq() == null) {
continue;
}
if (it.getOrderId() != null) {
orderIdBySeq.put(it.getSeq(), it.getOrderId());
}
else {
orderErrBySeq.put(it.getSeq(), StrUtil.blankToDefault(it.getError(), "创建订单失败"));
}
}
}
catch (Exception ex) {
log.error("AI喂数创建订单失败 batch={} chunk={}/{}", batch.getBatchNo(), orderChunkIdx, orderChunkTotal, ex);
String reason = "批量创建订单: " + StrUtil.blankToDefault(ex.getMessage(), "失败");
for (AiFeedOrderCreateDTO o : chunk) {
orderErrBySeq.put(o.getSeq(), reason);
}
}
}
for (ParsedOrderRow or : orderReady) {
Long orderId = orderIdBySeq.get(or.dto().getSeq());
if (orderId == null) {
String reason = orderErrBySeq.getOrDefault(or.dto().getSeq(), "创建订单失败");
skipItems.add(buildSkipItem(batch.getId(), or.lineNo(), or.rowType(), or.phone(), reason, null));
appendErr(errSample, or.lineNo(), reason);
continue;
}
FeedUserCtx ctx = userCache.get(or.phone());
successItems.add(buildOrderItem(batch.getId(), or, orderId, ctx == null ? null : ctx.walletId()));
}
flushProgress(batch, total, successItems.size(), skipItems.size(), start, "落库明细中");
if (!successItems.isEmpty()) {
for (AiFeedBatchItemEntity item : successItems) {
itemMapper.insert(item);
}
}
if (!skipItems.isEmpty()) {
for (AiFeedBatchItemEntity item : skipItems) {
itemMapper.insert(item);
}
}
int success = successItems.size();
int skip = skipItems.size();
batch.setStatus(skip > 0 && success == 0 ? "FAILED" : "SUCCESS");
if (errSample.length() > 0) {
batch.setErrorMsg(trimErr(errSample.toString()));
}
else {
batch.setErrorMsg(null);
}
batch.setTotalRows(total);
batch.setSuccessRows(success);
batch.setSkipRows(skip);
batch.setCostMs(System.currentTimeMillis() - start);
batchMapper.updateById(batch);
log.info("AI喂数批量导入完成 batch={} total={} success={} skip={} costMs={}",
batch.getBatchNo(), total, success, skip, batch.getCostMs());
}
/**
* 远程调用加超时:避免 Feign 卡住导致批次永久 RUNNING、耗时定格。
* 必须把租户上下文带到远程线程,否则 Feign/MyBatis 多租户会异常或全表扫描。
*/
private <T> T callRemote(String action, java.util.concurrent.Callable<T> callable) throws Exception {
Long tenantId = TenantContextHolder.getTenantId();
try {
return CompletableFuture.supplyAsync(() -> {
try {
if (tenantId != null) {
TenantContextHolder.setTenantId(tenantId);
}
return callable.call();
}
catch (Exception ex) {
throw new RuntimeException(ex);
}
finally {
TenantContextHolder.clear();
}
}, AI_FEED_REMOTE_EXECUTOR).get(REMOTE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
}
catch (java.util.concurrent.TimeoutException ex) {
throw new IllegalStateException(action + "超时(>" + (REMOTE_TIMEOUT_MS / 1000)
+ "s),请确认 upms/wallet/hardware 已重启且 /inner/ai-feed-* 接口可用");
}
catch (java.util.concurrent.ExecutionException ex) {
Throwable cause = ex.getCause();
if (cause instanceof RuntimeException && cause.getCause() instanceof Exception nested) {
throw (Exception) nested;
}
if (cause instanceof Exception e) {
throw e;
}
throw ex;
}
}
private void flushProgress(AiFeedBatchEntity batch, int total, int success, int skip, long startMs, String stage) {
batch.setTotalRows(total);
batch.setSuccessRows(success);
batch.setSkipRows(skip);
batch.setCostMs(System.currentTimeMillis() - startMs);
if (StrUtil.isNotBlank(stage)) {
batch.setErrorMsg(stage);
}
batchMapper.updateById(batch);
}
private ParsedUserRow parseUserRow(JsonNode node, Long deptId, int lineNo) {
String phone = text(node, "phone");
String name = text(node, "name");
if (StrUtil.isBlank(phone) || StrUtil.isBlank(name)) {
throw new IllegalArgumentException("user 行 phone/name 必填");
}
return new ParsedUserRow(lineNo, phone, name, deptId, yuanToFen(node.get("balance_yuan")));
}
private ParsedOrderRow parseOrderRow(JsonNode node, String rowType, Long deptId, Long merchantId, int seq,
int lineNo, String batchNo) {
String phone = text(node, "phone");
if (StrUtil.isBlank(phone)) {
throw new IllegalArgumentException("订单行 phone 必填");
}
Long lineMerchant = node.hasNonNull("merchant_id") ? node.get("merchant_id").asLong() : merchantId;
AiFeedOrderCreateDTO dto = new AiFeedOrderCreateDTO();
dto.setBatchNo(batchNo);
dto.setSeq(seq);
dto.setOrderType(ROW_OFFLINE.equals(rowType) ? "OFFLINE" : "RESERVE");
dto.setUserDeptId(deptId);
dto.setMerchantId(lineMerchant);
dto.setMeal(text(node, "meal"));
dto.setBizDate(text(node, "biz_date"));
dto.setTotalFen(yuanToFen(node.get("total_yuan")));
dto.setProductDesc(text(node, "product_desc"));
List<AiFeedOrderCreateDTO.Item> items = new ArrayList<>();
JsonNode arr = node.get("items");
if (arr != null && arr.isArray()) {
for (JsonNode it : arr) {
AiFeedOrderCreateDTO.Item item = new AiFeedOrderCreateDTO.Item();
if (it.hasNonNull("spu_id")) {
item.setSpuId(it.get("spu_id").asLong());
}
if (it.hasNonNull("menu_id")) {
item.setMenuId(it.get("menu_id").asLong());
}
item.setName(text(it, "name"));
item.setQty(it.hasNonNull("qty") ? it.get("qty").asInt() : 1);
item.setPriceFen(yuanToFen(it.get("price_yuan")));
item.setPicUrl(text(it, "pic_url"));
items.add(item);
}
}
if (items.isEmpty()) {
throw new IllegalArgumentException("订单明细不能为空");
}
dto.setItems(items);
return new ParsedOrderRow(lineNo, phone, rowType, dto);
}
private AiFeedBatchItemEntity buildUserItem(Long batchId, ParsedUserRow ur, FeedUserCtx ctx) {
AiFeedBatchItemEntity item = new AiFeedBatchItemEntity();
item.setBatchId(batchId);
item.setItemType(ITEM_USER);
item.setRefId(ctx.userId());
item.setBizKey(ur.phone());
item.setExtraJson(JSONUtil.createObj()
.set("lineNo", ur.lineNo())
.set("rowType", ROW_USER)
.set("name", ur.name())
.set("walletId", ctx.walletId())
.set("balanceFen", ur.balanceFen())
.set("status", "SUCCESS")
.toString());
return item;
}
private AiFeedBatchItemEntity buildOrderItem(Long batchId, ParsedOrderRow or, Long orderId, Long walletId) {
AiFeedBatchItemEntity item = new AiFeedBatchItemEntity();
item.setBatchId(batchId);
item.setItemType(ITEM_ORDER);
item.setRefId(orderId);
item.setBizKey("AIFEED-" + or.dto().getBatchNo() + "-" + or.dto().getSeq());
item.setExtraJson(JSONUtil.createObj()
.set("lineNo", or.lineNo())
.set("phone", or.phone())
.set("rowType", or.rowType())
.set("walletId", walletId)
.set("status", "SUCCESS")
.toString());
return item;
}
private static void appendErr(StringBuilder errSample, int lineNo, String reason) {
if (errSample.length() >= MAX_ERROR_LEN) {
return;
}
errSample.append("L").append(lineNo).append(": ").append(reason).append("; ");
}
private static <T> List<List<T>> chunks(List<T> source, int size) {
List<List<T>> out = new ArrayList<>();
if (source == null || source.isEmpty()) {
return out;
}
for (int i = 0; i < source.size(); i += size) {
out.add(source.subList(i, Math.min(i + size, source.size())));
}
return out;
}
private record FeedUserCtx(Long userId, Long deptId, Long walletId) {
}
private record ParsedUserRow(int lineNo, String phone, String name, Long deptId, int balanceFen) {
}
private record ParsedOrderRow(int lineNo, String phone, String rowType, AiFeedOrderCreateDTO dto) {
}
/** 跳过/失败行明细(批量落库前先构造) */
private AiFeedBatchItemEntity buildSkipItem(Long batchId, int lineNo, String rowType, String phone, String reason,
String rawLine) {
AiFeedBatchItemEntity item = new AiFeedBatchItemEntity();
item.setBatchId(batchId);
item.setItemType(ITEM_SKIP);
item.setRefId((long) Math.max(lineNo, 0));
item.setBizKey(StrUtil.blankToDefault(phone, "L" + lineNo));
JSONObject extra = JSONUtil.createObj()
.set("lineNo", lineNo)
.set("rowType", rowType)
.set("reason", StrUtil.maxLength(reason, MAX_REASON_LEN))
.set("status", "SKIP");
if (StrUtil.isNotBlank(rawLine)) {
extra.set("raw", StrUtil.maxLength(rawLine.trim(), 200));
}
item.setExtraJson(extra.toString());
return item;
}
@Override
public R pageBatches(Page page, String status) {
Page<AiFeedBatchEntity> result = batchMapper.selectPage(page, Wrappers.<AiFeedBatchEntity>lambdaQuery()
.eq(StrUtil.isNotBlank(status), AiFeedBatchEntity::getStatus, status)
.orderByDesc(AiFeedBatchEntity::getCreateTime));
return R.ok(result);
}
@Override
public R getBatch(Long batchId) {
AiFeedBatchEntity batch = batchMapper.selectById(batchId);
if (batch == null) {
return R.failed("批次不存在");
}
return R.ok(batch);
}
@Override
public R listItems(Long batchId) {
AiFeedBatchEntity batch = batchMapper.selectById(batchId);
if (batch == null) {
return R.failed("批次不存在");
}
List<AiFeedBatchItemEntity> items = itemMapper.selectList(Wrappers.<AiFeedBatchItemEntity>lambdaQuery()
.eq(AiFeedBatchItemEntity::getBatchId, batchId)
.orderByAsc(AiFeedBatchItemEntity::getId));
items.sort(Comparator.comparingInt(this::resolveLineNo));
return R.ok(items);
}
private int resolveLineNo(AiFeedBatchItemEntity item) {
if (item == null) {
return 0;
}
try {
if (StrUtil.isNotBlank(item.getExtraJson())) {
Integer lineNo = JSONUtil.parseObj(item.getExtraJson()).getInt("lineNo");
if (lineNo != null && lineNo > 0) {
return lineNo;
}
}
}
catch (Exception ignored) {
// ignore
}
if (ITEM_SKIP.equals(item.getItemType()) && item.getRefId() != null) {
return item.getRefId().intValue();
}
return 0;
}
@Override
public R deleteBatch(Long batchId) {
AiFeedBatchEntity batch = batchMapper.selectById(batchId);
if (batch == null) {
return R.failed("批次不存在");
}
if ("DELETING".equals(batch.getStatus()) || "DELETED".equals(batch.getStatus())) {
return R.failed("批次已在清理或已删除");
}
batch.setStatus("DELETING");
batchMapper.updateById(batch);
List<AiFeedBatchItemEntity> items = itemMapper.selectList(Wrappers.<AiFeedBatchItemEntity>lambdaQuery()
.eq(AiFeedBatchItemEntity::getBatchId, batchId));
List<Long> userIds = items.stream().filter(i -> ITEM_USER.equals(i.getItemType()))
.map(AiFeedBatchItemEntity::getRefId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
List<Long> orderIds = items.stream().filter(i -> ITEM_ORDER.equals(i.getItemType()))
.map(AiFeedBatchItemEntity::getRefId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
StringBuilder err = new StringBuilder();
if (!orderIds.isEmpty()) {
try {
R<Integer> delR = remoteHardOrderService.aiFeedDeleteOrders(orderIds);
if (delR == null || !delR.isOk()) {
err.append("订单清理: ").append(delR == null ? "失败" : delR.getMsg()).append("; ");
}
}
catch (Exception ex) {
err.append("订单清理异常: ").append(ex.getMessage()).append("; ");
}
}
if (!userIds.isEmpty()) {
try {
R<Boolean> delUserR = remoteUserService.aiFeedDeleteUsers(userIds);
if (delUserR == null || !delUserR.isOk()) {
err.append("用户清理: ").append(delUserR == null ? "失败" : delUserR.getMsg()).append("; ");
}
}
catch (Exception ex) {
err.append("用户清理异常: ").append(ex.getMessage()).append("; ");
}
}
batch.setStatus("DELETED");
if (err.length() > 0) {
batch.setErrorMsg(trimErr(StrUtil.blankToDefault(batch.getErrorMsg(), "") + " | 清理: " + err));
}
batchMapper.updateById(batch);
itemMapper.delete(Wrappers.<AiFeedBatchItemEntity>lambdaQuery().eq(AiFeedBatchItemEntity::getBatchId, batchId));
return R.ok(batch);
}
@Override
public String templateContent() {
return """
{"type":"user","phone":"13800000001","name":"张三","balance_yuan":100.00}
{"type":"user","phone":"13800000002","name":"李四","balance_yuan":50.00}
{"type":"reserve","phone":"13800000001","biz_date":"2026-09-25","meal":"午餐","total_yuan":25.00,"items":[{"spu_id":"1928340001","menu_id":"88420011","name":"红烧肉","qty":1,"price_yuan":18},{"spu_id":"1928340002","name":"米饭","qty":1,"price_yuan":7}]}
{"type":"offline","phone":"13800000001","biz_date":"2026-09-25","meal":"午餐","total_yuan":18.00,"items":[{"spu_id":"1928340001","name":"红烧肉","qty":1,"price_yuan":18}]}
{"type":"offline","phone":"13800000002","biz_date":"2026-09-25","meal":"晚餐","total_yuan":12.00,"items":[{"spu_id":"1928340003","name":"番茄炒蛋","qty":1,"price_yuan":12}]}
""";
}
private static String normalizeRowType(String type) {
if (StrUtil.isBlank(type)) {
return null;
}
String t = type.trim().toLowerCase(Locale.ROOT);
if (ROW_USER.equals(t) || "用户".equals(type.trim())) {
return ROW_USER;
}
if (ROW_RESERVE.equals(t) || "预定".equals(type.trim()) || "预约".equals(type.trim())) {
return ROW_RESERVE;
}
if (ROW_OFFLINE.equals(t) || "线下".equals(type.trim()) || "安卓".equals(type.trim()) || "below".equals(t)) {
return ROW_OFFLINE;
}
return null;
}
private static String text(JsonNode node, String field) {
if (node == null || !node.has(field) || node.get(field).isNull()) {
return null;
}
String v = node.get(field).asText();
return StrUtil.isBlank(v) ? null : v.trim();
}
private static int yuanToFen(JsonNode node) {
if (node == null || node.isNull()) {
return 0;
}
BigDecimal yuan = node.isNumber() ? node.decimalValue() : new BigDecimal(node.asText("0"));
return yuan.multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.HALF_UP).intValue();
}
private static String trimErr(String msg) {
if (msg == null) {
return null;
}
return msg.length() <= MAX_ERROR_LEN ? msg : msg.substring(0, MAX_ERROR_LEN);
}
}
@@ -0,0 +1,22 @@
package com.bsd.pjcloud.hard.api.dto.order.remote;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "AI喂数批量建订单结果")
public class AiFeedOrderBatchResultDTO implements Serializable {
private List<Item> items = new ArrayList<>();
@Data
public static class Item implements Serializable {
private Integer seq;
private Long orderId;
private String error;
}
}
@@ -0,0 +1,58 @@
package com.bsd.pjcloud.hard.api.dto.order.remote;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
@Data
@Schema(description = "AI喂数创建订单")
public class AiFeedOrderCreateDTO implements Serializable {
@Schema(description = "批次号,用于生成 orderNo")
private String batchNo;
@Schema(description = "序号")
private Integer seq;
/** RESERVE 预定 / OFFLINE 线下安卓(BELOW) */
@Schema(description = "订单类型:RESERVE 或 OFFLINE(线下安卓机)")
private String orderType;
private Long userId;
private Long userDeptId;
private Long merchantId;
private Long merchantDeptId;
private Long walletId;
/** 餐段 1早餐 2午餐 3晚餐;也可传中文 */
private String meal;
/** yyyy-MM-dd,预定用餐日 */
private String bizDate;
/** 分 */
private Integer totalFen;
private String addressName;
private String productDesc;
private List<Item> items;
@Data
public static class Item implements Serializable {
private Long spuId;
private Long menuId;
private String name;
private Integer qty;
/** 分 */
private Integer priceFen;
private String picUrl;
}
}
@@ -67,4 +67,20 @@ public interface RemoteHardOrderService {
R<List<com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO>> listDailyMenuNetSold(
@RequestBody com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto);
/** AI 喂数:精简写入演示订单,返回 orderId */
@NoToken
@PostMapping("/hard-order/inner/ai-feed-create")
R<Long> aiFeedCreateOrder(@RequestBody com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO dto);
/** AI 喂数:批量写入演示订单 */
@NoToken
@PostMapping("/hard-order/inner/ai-feed-create-batch")
R<com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderBatchResultDTO> aiFeedCreateOrdersBatch(
@RequestBody java.util.List<com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO> dtos);
/** AI 喂数清理:软删 AIFEED- 订单 */
@NoToken
@PostMapping("/hard-order/inner/ai-feed-delete")
R<Integer> aiFeedDeleteOrders(@RequestBody List<Long> orderIds);
}
@@ -37,6 +37,7 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateReserveOrdersDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateShopOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateTakeOutOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO;
@@ -99,6 +100,7 @@ public class HardOrderController {
private final RemoteMchService mchService;
private final RemoteUserService userService;
private final AiFeedOrderService aiFeedOrderService;
/* ************************************* start 远程调用 start ************************************* */
@Inner
@@ -153,6 +155,33 @@ public class HardOrderController {
public R<List<DailyMenuNetSoldVO>> listDailyMenuNetSold(@RequestBody DailyMenuNetSoldQueryDTO dto) {
return R.ok(hardwareOrderService.listDailyMenuNetSold(dto));
}
@Inner
@Operation(summary = "AI喂数创建演示订单", description = "精简写入 hard_order/hard_car,不做完整库存链路")
@PostMapping("/inner/ai-feed-create")
public R<Long> aiFeedCreateOrder(@RequestBody AiFeedOrderCreateDTO dto) {
try {
return R.ok(aiFeedOrderService.createDemoOrder(dto));
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
}
@Inner
@Operation(summary = "AI喂数批量创建演示订单")
@PostMapping("/inner/ai-feed-create-batch")
public R<com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderBatchResultDTO> aiFeedCreateOrdersBatch(
@RequestBody List<AiFeedOrderCreateDTO> dtos) {
return R.ok(aiFeedOrderService.createDemoOrdersBatch(dtos));
}
@Inner
@Operation(summary = "AI喂数删除演示订单", description = "仅软删 orderNo 以 AIFEED- 开头的订单")
@PostMapping("/inner/ai-feed-delete")
public R<Integer> aiFeedDeleteOrders(@RequestBody List<Long> orderIds) {
return R.ok(aiFeedOrderService.softDeleteByIds(orderIds));
}
/* ************************************* end 远程调用 ************************************* */
/* ************************************* start app start ************************************* */
@@ -0,0 +1,26 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO;
import java.util.List;
/**
* AI 喂数演示订单写入(精简落库,不做完整库存/MQ 链路)。
*/
public interface AiFeedOrderService {
/**
* @return 新建订单 ID
*/
Long createDemoOrder(AiFeedOrderCreateDTO dto);
/**
* 批量创建演示订单(独立批量落库,减少 Feign 往返)
*/
com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderBatchResultDTO createDemoOrdersBatch(List<AiFeedOrderCreateDTO> dtos);
/**
* 软删演示订单(仅处理 orderNo 以 AIFEED- 开头的记录)
*/
int softDeleteByIds(List<Long> orderIds);
}
@@ -0,0 +1,283 @@
package com.bsd.pjcloud.hard.service.impl.order;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderBatchResultDTO;
import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardParentCarEntity;
import com.bsd.pjcloud.hard.service.AiFeedOrderService;
import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.hard.service.HardOrderService;
import com.bsd.pjcloud.hard.service.HardParentCarService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Service
@RequiredArgsConstructor
public class AiFeedOrderServiceImpl implements AiFeedOrderService {
private static final String CREATE_BY = "ai-feed";
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final HardParentCarService hardParentCarService;
private final HardCarService hardCarService;
private final HardOrderService hardOrderService;
@Override
@Transactional(rollbackFor = Exception.class)
public Long createDemoOrder(AiFeedOrderCreateDTO dto) {
PreparedOrder prepared = prepareOrder(dto);
hardParentCarService.save(prepared.parent());
for (HardCarEntity car : prepared.cars()) {
car.setParentCarId(prepared.parent().getId());
}
hardCarService.saveBatch(prepared.cars());
prepared.order().setParentCarId(prepared.parent().getId());
hardOrderService.save(prepared.order());
return prepared.order().getId();
}
/**
* 批量建单:内存组装后分批 saveBatch,单条校验失败只记错误不影响同批其它单。
* 整批一个事务;DB 异常时该 Feign 分片整体回滚,由上层分片重试或记失败。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public AiFeedOrderBatchResultDTO createDemoOrdersBatch(List<AiFeedOrderCreateDTO> dtos) {
AiFeedOrderBatchResultDTO result = new AiFeedOrderBatchResultDTO();
if (CollUtil.isEmpty(dtos)) {
return result;
}
List<PreparedOrder> preparedList = new ArrayList<>();
for (AiFeedOrderCreateDTO dto : dtos) {
AiFeedOrderBatchResultDTO.Item item = new AiFeedOrderBatchResultDTO.Item();
item.setSeq(dto == null ? null : dto.getSeq());
try {
PreparedOrder prepared = prepareOrder(dto);
preparedList.add(prepared);
result.getItems().add(item);
}
catch (Exception ex) {
item.setError(StrUtil.blankToDefault(ex.getMessage(), "创建失败"));
result.getItems().add(item);
}
}
if (preparedList.isEmpty()) {
return result;
}
List<HardParentCarEntity> parents = preparedList.stream().map(PreparedOrder::parent).toList();
hardParentCarService.saveBatch(parents, 100);
List<HardCarEntity> allCars = new ArrayList<>();
List<HardOrderEntity> orders = new ArrayList<>();
for (PreparedOrder p : preparedList) {
Long parentId = p.parent().getId();
for (HardCarEntity car : p.cars()) {
car.setParentCarId(parentId);
allCars.add(car);
}
p.order().setParentCarId(parentId);
orders.add(p.order());
}
hardCarService.saveBatch(allCars, 200);
hardOrderService.saveBatch(orders, 100);
int okIdx = 0;
for (AiFeedOrderBatchResultDTO.Item item : result.getItems()) {
if (item.getError() != null) {
continue;
}
item.setOrderId(orders.get(okIdx).getId());
okIdx++;
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public int softDeleteByIds(List<Long> orderIds) {
if (CollUtil.isEmpty(orderIds)) {
return 0;
}
List<HardOrderEntity> list = hardOrderService.list(Wrappers.<HardOrderEntity>lambdaQuery()
.in(HardOrderEntity::getId, orderIds)
.likeRight(HardOrderEntity::getOrderNo, "AIFEED-"));
if (CollUtil.isEmpty(list)) {
return 0;
}
List<Long> ids = list.stream().map(HardOrderEntity::getId).toList();
List<Long> parentIds = list.stream().map(HardOrderEntity::getParentCarId).filter(id -> id != null).distinct().toList();
hardOrderService.removeByIds(ids);
if (CollUtil.isNotEmpty(parentIds)) {
hardCarService.remove(Wrappers.<HardCarEntity>lambdaQuery().in(HardCarEntity::getParentCarId, parentIds));
hardParentCarService.removeByIds(parentIds);
}
return ids.size();
}
private PreparedOrder prepareOrder(AiFeedOrderCreateDTO dto) {
if (dto == null || dto.getUserId() == null || dto.getMerchantId() == null) {
throw new IllegalArgumentException("userId/merchantId 不能为空");
}
if (CollUtil.isEmpty(dto.getItems())) {
throw new IllegalArgumentException("订单明细不能为空");
}
boolean offline = isOffline(dto.getOrderType());
String productSource = offline
? HardProductSourceEnum.CANTEEN_SPU.getType()
: HardProductSourceEnum.CANTEEN_DAILY_MENU.getType();
String hardType = offline ? HardTypeEnum.CANTEEN.getType() : HardTypeEnum.SELF_APP.getType();
String orderType = offline ? HardOrderTypeEnum.BELOW.getType() : HardOrderTypeEnum.RESERVE.getType();
String payType = offline ? HardPayTypeEnum.FACE.getType() : HardPayTypeEnum.WALLET_DEFAULT.getType();
HardParentCarEntity parent = HardParentCarEntity.builder()
.userId(dto.getUserId())
.merchantId(dto.getMerchantId())
.hardType(hardType)
.productSource(productSource)
.orderStatus(HardOrderStatusEnum.PAY.getType())
.build();
parent.setCreateBy(CREATE_BY);
parent.setUpdateBy(CREATE_BY);
int totalFen = 0;
List<String> names = new ArrayList<>();
List<HardCarEntity> cars = new ArrayList<>();
for (AiFeedOrderCreateDTO.Item item : dto.getItems()) {
if (item == null) {
continue;
}
int qty = item.getQty() == null || item.getQty() < 1 ? 1 : item.getQty();
int price = item.getPriceFen() == null ? 0 : Math.max(item.getPriceFen(), 0);
long line = (long) price * qty;
totalFen += (int) line;
String name = StrUtil.blankToDefault(item.getName(), "演示菜品");
names.add(name);
Long productId = item.getSpuId() != null ? item.getSpuId() : item.getMenuId();
if (productId == null) {
productId = 0L;
}
HardCarEntity car = HardCarEntity.builder()
.productId(productId)
.sourceProductId(item.getMenuId() != null ? item.getMenuId() : item.getSpuId())
.productName(name)
.productPic(item.getPicUrl())
.productPrice(price)
.productNature(HardProductNatureEnum.DEFAULT.getType())
.productCount(qty)
.totalPrice(line)
.build();
car.setCreateBy(CREATE_BY);
car.setUpdateBy(CREATE_BY);
cars.add(car);
}
if (cars.isEmpty()) {
throw new IllegalArgumentException("没有有效明细行");
}
if (dto.getTotalFen() != null && dto.getTotalFen() > 0) {
totalFen = dto.getTotalFen();
}
String orderNo = "AIFEED-" + StrUtil.blankToDefault(dto.getBatchNo(), "X") + "-"
+ (dto.getSeq() == null ? System.nanoTime() : dto.getSeq());
String hourType = resolveHourType(dto.getMeal());
LocalDate useDate = parseDate(dto.getBizDate());
if (useDate == null) {
useDate = LocalDate.now();
}
String desc = StrUtil.blankToDefault(dto.getProductDesc(), "[AI_FEED] " + String.join("、", names));
if (!desc.startsWith("[AI_FEED]")) {
desc = "[AI_FEED] " + desc;
}
HardOrderEntity order = HardOrderEntity.builder()
.orderNo(orderNo)
.userId(dto.getUserId())
.userDeptId(dto.getUserDeptId())
.merchantId(dto.getMerchantId())
.merchantType(MerchantTypeEnum.CANTEEN.getType())
.merchantDeptId(dto.getMerchantDeptId())
.deptId(dto.getUserDeptId())
.walletId(dto.getWalletId())
.orderType(orderType)
.orderStatus(HardOrderStatusEnum.PAY.getType())
.payType(payType)
.hourType(hourType)
.apptUseDate(useDate)
.sysType(hardType)
.totalPrice(totalFen)
.extraPrice(0)
.deliveryPrice(0)
.payPrice(totalFen)
.payTime(LocalDateTime.now())
.productDesc(desc)
.addressName(dto.getAddressName())
.build();
order.setCreateBy(CREATE_BY);
order.setUpdateBy(CREATE_BY);
return new PreparedOrder(parent, cars, order);
}
private record PreparedOrder(HardParentCarEntity parent, List<HardCarEntity> cars, HardOrderEntity order) {
}
/** 线下安卓机:OFFLINE / BELOW / 1 */
private static boolean isOffline(String orderType) {
if (StrUtil.isBlank(orderType)) {
return false;
}
String t = orderType.trim().toUpperCase(Locale.ROOT);
return "OFFLINE".equals(t) || "BELOW".equals(t)
|| HardOrderTypeEnum.BELOW.getType().equals(orderType.trim())
|| "1".equals(orderType.trim());
}
private static String resolveHourType(String meal) {
if (StrUtil.isBlank(meal)) {
return HardHourTypeEnum.NOONING.getType();
}
String m = meal.trim();
if ("1".equals(m) || m.contains("早")) {
return HardHourTypeEnum.MORNING.getType();
}
if ("3".equals(m) || m.contains("晚")) {
return HardHourTypeEnum.NIGHT.getType();
}
if ("2".equals(m) || m.contains("午") || m.contains("中")) {
return HardHourTypeEnum.NOONING.getType();
}
HardHourTypeEnum byType = HardHourTypeEnum.valueOfByType(m);
return byType == null ? HardHourTypeEnum.NOONING.getType() : byType.getType();
}
private static LocalDate parseDate(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
try {
return LocalDate.parse(raw.trim(), DATE_FMT);
}
catch (Exception ex) {
return null;
}
}
}
@@ -0,0 +1,23 @@
package com.bsd.pjcloud.admin.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "AI喂数批量建用户结果")
public class AiFeedUserBatchResultDTO implements Serializable {
private List<Item> items = new ArrayList<>();
@Data
public static class Item implements Serializable {
private String phone;
private Long userId;
/** true=本次新建;false=已存在复用 */
private Boolean created;
}
}
@@ -0,0 +1,20 @@
package com.bsd.pjcloud.admin.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@Data
@Schema(description = "AI喂数创建用户")
public class AiFeedUserCreateDTO implements Serializable {
@Schema(description = "手机号(同时作为 username)")
private String phone;
@Schema(description = "姓名(同时作为 nickname)")
private String name;
@Schema(description = "部门ID")
private Long deptId;
}
@@ -309,4 +309,23 @@ public interface RemoteUserService {
@GetMapping("/user/getUserListByRoleCode")
R<List<UserSimpleVo>> getUserListByRoleCode(@RequestParam("code") String code);
/**
* AI 喂数:创建普通用户(手机号=账号,姓名=昵称,角色 GENERAL_USER + WALLET_AUTO_CREATE)。
* 手机号已存在则返回已有 userId。
*/
@NoToken
@PostMapping("/user/inner/ai-feed-create")
R<Long> aiFeedCreateUser(@RequestBody com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO dto);
/** AI 喂数:批量建用户 */
@NoToken
@PostMapping("/user/inner/ai-feed-create-batch")
R<com.bsd.pjcloud.admin.api.dto.AiFeedUserBatchResultDTO> aiFeedCreateUsersBatch(
@RequestBody java.util.List<com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO> users);
/** AI 喂数清理:软删演示用户 */
@NoToken
@PostMapping("/user/inner/ai-feed-delete")
R<Boolean> aiFeedDeleteUsers(@RequestBody List<Long> userIds);
}
@@ -761,6 +761,44 @@ public class SysUserController {
return R.ok(userService.list(Wrappers.<SysUser>lambdaQuery().in(SysUser::getUserId, userIds)));
}
/**
* AI 喂数:按手机号创建普通用户;已存在则直接返回 userId。
* 角色固定 GENERAL_USER + WALLET_AUTO_CREATE;不异步开钱包。
*/
@Inner
@PostMapping("/inner/ai-feed-create")
public R<Long> aiFeedCreateUser(@RequestBody com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO dto) {
if (dto == null || StrUtil.isBlank(dto.getPhone()) || StrUtil.isBlank(dto.getName()) || dto.getDeptId() == null) {
return R.failed("phone/name/deptId 不能为空");
}
try {
return R.ok(userService.aiFeedCreateOrGetUser(dto.getPhone(), dto.getName(), dto.getDeptId()));
}
catch (Exception ex) {
return R.failed(StrUtil.blankToDefault(ex.getMessage(), "创建用户失败"));
}
}
/** AI 喂数:批量建用户 */
@Inner
@PostMapping("/inner/ai-feed-create-batch")
public R<com.bsd.pjcloud.admin.api.dto.AiFeedUserBatchResultDTO> aiFeedCreateUsersBatch(
@RequestBody List<com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO> users) {
try {
return R.ok(userService.aiFeedCreateUsersBatch(users));
}
catch (Exception ex) {
return R.failed(StrUtil.blankToDefault(ex.getMessage(), "批量创建用户失败"));
}
}
/** AI 喂数清理:软删演示用户(含角色/身份/钱包,跳过余额校验) */
@Inner
@PostMapping("/inner/ai-feed-delete")
public R<Boolean> aiFeedDeleteUsers(@RequestBody List<Long> userIds) {
return userService.aiFeedDeleteUsers(userIds);
}
/**
* 按用户 ID 批量查询用户、部门及身份协议编码。
*
@@ -92,4 +92,10 @@ public interface SysUserMapper extends PjcloudBaseMapper<SysUser> {
* 用户在/非编人员统计
*/
List<UserStaffingTotalVo> staffingListByDept(@Param("dto") UserDTO dto, DataScope dataScope);
/**
* AI 喂数:多行 INSERT 用户(自行带 tenant_id,忽略租户插件改写)
*/
@com.baomidou.mybatisplus.annotation.InterceptorIgnore(tenantLine = "true")
int insertAiFeedBatch(@Param("list") List<SysUser> list);
}
@@ -21,7 +21,11 @@ package com.bsd.pjcloud.admin.mapper;
import com.bsd.pjcloud.admin.api.entity.SysUserRole;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* <p>
@@ -34,4 +38,15 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysUserRoleMapper extends PjcloudBaseMapper<SysUserRole> {
/** AI 喂数:多行插入用户角色 */
@Insert("""
<script>
INSERT INTO sys_user_role (user_id, role_id) VALUES
<foreach collection="list" item="r" separator=",">
(#{r.userId}, #{r.roleId})
</foreach>
</script>
""")
int insertAiFeedBatch(@Param("list") List<SysUserRole> list);
}
@@ -159,6 +159,26 @@ public interface SysUserService extends IService<SysUser> {
*/
R<Boolean> lockUser(String username);
/**
* AI 喂数清理:软删用户及关联角色/身份/关系/钱包(不校验余额与流水,演示数据专用)。
*
* @param userIds 用户 ID 列表
* @return 是否成功
*/
R<Boolean> aiFeedDeleteUsers(List<Long> userIds);
/**
* AI 喂数专用:按手机号创建用户(独立实现,不走 saveUser/doSaveUser)。
* 固定 GENERAL_USER + WALLET_AUTO_CREATE;已存在则返回原 userId;不发异步开钱包事件。
*/
Long aiFeedCreateOrGetUser(String phone, String name, Long deptId);
/**
* AI 喂数批量建用户(独立实现)。同批复用同一密码哈希,已存在手机号直接复用。
*/
com.bsd.pjcloud.admin.api.dto.AiFeedUserBatchResultDTO aiFeedCreateUsersBatch(
java.util.List<com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO> users);
/**
* 修改密码
*
@@ -38,6 +38,8 @@ import com.bsd.pjcloud.admin.api.constant.UserExpireEnum;
import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum;
import com.bsd.pjcloud.admin.api.constant.UserStateEnum;
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
import com.bsd.pjcloud.admin.api.dto.AiFeedUserBatchResultDTO;
import com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO;
import com.bsd.pjcloud.admin.api.dto.ForgotPasswordDTO;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
@@ -215,6 +217,129 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
}
}
/** 演示用户统一初始密码(仅喂数路径),整批只 BCrypt 一次 */
private static final String AI_FEED_DEFAULT_PASSWORD = "AiFeed@123456";
/**
* AI 喂数专用建用户(独立实现,不改动 {@link #doSaveUser} 业务路径)。
* 固定 GENERAL_USER + WALLET_AUTO_CREATE;不发异步开钱包事件(钱包由喂数侧同步开通)。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long aiFeedCreateOrGetUser(String phone, String name, Long deptId) {
AiFeedUserCreateDTO dto = new AiFeedUserCreateDTO();
dto.setPhone(phone);
dto.setName(name);
dto.setDeptId(deptId);
AiFeedUserBatchResultDTO batch = aiFeedCreateUsersBatch(List.of(dto));
if (batch == null || CollUtil.isEmpty(batch.getItems()) || batch.getItems().get(0).getUserId() == null) {
throw new IllegalArgumentException("创建用户失败");
}
return batch.getItems().get(0).getUserId();
}
/**
* AI 喂数批量建用户:一次查已存在手机号,整批共用密码哈希,用户/角色一次性 saveBatch。
* name 为空时仅按手机号查已有用户(订单行引用已导入用户),不新建。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public AiFeedUserBatchResultDTO aiFeedCreateUsersBatch(List<AiFeedUserCreateDTO> users) {
AiFeedUserBatchResultDTO result = new AiFeedUserBatchResultDTO();
if (CollUtil.isEmpty(users)) {
return result;
}
// 同批内手机号去重,后者覆盖前者姓名/部门
Map<String, AiFeedUserCreateDTO> unique = new LinkedHashMap<>();
for (AiFeedUserCreateDTO u : users) {
if (u == null || StrUtil.isBlank(u.getPhone())) {
continue;
}
AiFeedUserCreateDTO copy = new AiFeedUserCreateDTO();
copy.setPhone(StrUtil.trim(u.getPhone()));
copy.setName(StrUtil.isBlank(u.getName()) ? null : StrUtil.trim(u.getName()));
copy.setDeptId(u.getDeptId());
unique.put(copy.getPhone(), copy);
}
if (unique.isEmpty()) {
return result;
}
List<String> phones = new ArrayList<>(unique.keySet());
Map<String, SysUser> existMap = baseMapper.selectList(Wrappers.<SysUser>lambdaQuery().in(SysUser::getPhone, phones))
.stream()
.collect(Collectors.toMap(SysUser::getPhone, u -> u, (a, b) -> a));
List<SysUser> toCreate = new ArrayList<>();
List<AiFeedUserBatchResultDTO.Item> pendingCreated = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (AiFeedUserCreateDTO u : unique.values()) {
AiFeedUserBatchResultDTO.Item item = new AiFeedUserBatchResultDTO.Item();
item.setPhone(u.getPhone());
SysUser exist = existMap.get(u.getPhone());
if (exist != null) {
item.setUserId(exist.getUserId());
item.setCreated(Boolean.FALSE);
result.getItems().add(item);
continue;
}
if (StrUtil.isBlank(u.getName()) || u.getDeptId() == null) {
continue;
}
SysUser sysUser = new SysUser();
// 预分配雪花 ID,便于随后批量写角色关系
sysUser.setUserId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
sysUser.setUsername(u.getPhone());
sysUser.setPhone(u.getPhone());
sysUser.setName(u.getName());
sysUser.setNickname(u.getName());
sysUser.setDeptId(u.getDeptId());
sysUser.setLockFlag(UserStateEnum.NORMAL.getCode());
sysUser.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
sysUser.setProcessTime(now);
sysUser.setCreateBy(u.getPhone());
sysUser.setUpdateBy(u.getPhone());
sysUser.setPasswordModifyTime(now);
sysUser.setPasswordExpireFlag(UserExpireEnum.EXPIRE.getCode());
toCreate.add(sysUser);
item.setUserId(sysUser.getUserId());
item.setCreated(Boolean.TRUE);
pendingCreated.add(item);
}
if (!toCreate.isEmpty()) {
List<Long> roleIds = getNonAdminNewUserRoleIds();
String encodedPwd = ENCODER.encode(AI_FEED_DEFAULT_PASSWORD);
Long tenantId = TenantContextHolder.getTenantId();
LocalDateTime createTime = LocalDateTime.now();
for (SysUser sysUser : toCreate) {
sysUser.setPassword(encodedPwd);
sysUser.setDelFlag(CommonConstants.STATUS_NORMAL);
sysUser.setTenantId(tenantId);
sysUser.setCreateTime(createTime);
sysUser.setUpdateTime(createTime);
}
// 多行 INSERT,避免无 rewriteBatchedStatements 时 saveBatch 仍接近逐条写库
for (List<SysUser> chunk : CollUtil.split(toCreate, 200)) {
baseMapper.insertAiFeedBatch(chunk);
}
List<SysUserRole> roles = new ArrayList<>(toCreate.size() * Math.max(roleIds.size(), 1));
for (SysUser sysUser : toCreate) {
for (Long roleId : roleIds) {
SysUserRole userRole = new SysUserRole();
userRole.setUserId(sysUser.getUserId());
userRole.setRoleId(roleId);
roles.add(userRole);
}
}
if (!roles.isEmpty()) {
for (List<SysUserRole> chunk : CollUtil.split(roles, 500)) {
sysUserRoleMapper.insertAiFeedBatch(chunk);
}
}
result.getItems().addAll(pendingCreated);
}
return result;
}
/**
* 解析当前操作人的角色上下文,供一次或一批用户创建复用。
*
@@ -970,6 +1095,51 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return R.ok();
}
/**
* AI 喂数清理:软删演示用户。跳过钱包余额/流水校验,避免充值后无法清理。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> aiFeedDeleteUsers(List<Long> userIds) {
if (CollUtil.isEmpty(userIds)) {
return R.ok(true);
}
List<Long> distinctIds = userIds.stream().filter(Objects::nonNull).distinct().toList();
if (CollUtil.isEmpty(distinctIds)) {
return R.ok(true);
}
List<SysUser> userList = baseMapper.selectBatchIds(distinctIds);
if (CollUtil.isEmpty(userList)) {
return R.ok(true);
}
Cache cache = cacheManager.getCache(CacheConstants.USER_DETAILS);
for (SysUser sysUser : userList) {
if (cache != null && StrUtil.isNotBlank(sysUser.getUsername())) {
cache.evict(sysUser.getUsername());
}
}
List<Long> existIds = userList.stream().map(SysUser::getUserId).toList();
sysUserRoleMapper.delete(Wrappers.<SysUserRole>lambdaQuery().in(SysUserRole::getUserId, existIds));
sysUserRelationService.deleteUserRelationByUserIds(existIds);
sysUserIdentityService.deleteUserIdentityByUserIds(existIds);
this.removeBatchByIds(existIds);
try {
remoteWalletService.delWallet(existIds);
}
catch (Exception ex) {
log.warn("AI喂数清理钱包失败 userIds={}", existIds, ex);
}
for (Long userId : existIds) {
try {
remoteWalletService.userChange(userId, Boolean.TRUE);
}
catch (Exception ex) {
log.warn("AI喂数清理钱包同步失败 userId={}", userId, ex);
}
}
return R.ok(true);
}
@Override
@CacheEvict(value = CacheConstants.USER_DETAILS, key = "#userDto.username")
public R changePassword(UserDTO userDto) {
@@ -562,4 +562,22 @@
order by a.dept_id
</select>
<!-- AI 喂数多行插入:一次 SQL 写入多用户 -->
<insert id="insertAiFeedBatch">
INSERT INTO sys_user (
user_id, username, password, phone, name, nickname, dept_id,
lock_flag, password_expire_flag, password_modify_time,
process_status, process_time, create_by, update_by, create_time, update_time,
del_flag, tenant_id
) VALUES
<foreach collection="list" item="u" separator=",">
(
#{u.userId}, #{u.username}, #{u.password}, #{u.phone}, #{u.name}, #{u.nickname}, #{u.deptId},
#{u.lockFlag}, #{u.passwordExpireFlag}, #{u.passwordModifyTime},
#{u.processStatus}, #{u.processTime}, #{u.createBy}, #{u.updateBy}, #{u.createTime}, #{u.updateTime},
#{u.delFlag}, #{u.tenantId}
)
</foreach>
</insert>
</mapper>
@@ -0,0 +1,18 @@
package com.bsd.pjcloud.wallet.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
@Data
@Schema(description = "AI喂数确保饭卡并设定余额")
public class AiFeedWalletEnsureDTO implements Serializable {
private Long userId;
private Long deptId;
/** 饭卡余额(分),可空或 0 表示只开户不改余额 */
private Integer balanceFen;
}
@@ -0,0 +1,21 @@
package com.bsd.pjcloud.wallet.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Data
@Schema(description = "AI喂数批量开饭卡结果")
public class AiFeedWalletEnsureResultDTO implements Serializable {
private List<Item> items = new ArrayList<>();
@Data
public static class Item implements Serializable {
private Long userId;
private Long walletId;
}
}
@@ -25,6 +25,17 @@ public interface RemoteWalletService {
@GetMapping("/wallet-manage/get-or-create-wallet")
R<WalletManageEntity> getOrCreateWallet(@RequestParam Long userId, @RequestParam(required = false) WalletTypeEnum walletTypeCode);
/** AI 喂数:确保饭卡钱包(免角色校验、免绑卡) */
@NoToken
@GetMapping("/wallet-manage/inner/ai-feed-ensure-eat")
R<WalletManageEntity> aiFeedEnsureEatWallet(@RequestParam Long userId);
/** AI 喂数:批量开饭卡并设定余额 */
@NoToken
@PostMapping("/wallet-manage/inner/ai-feed-ensure-eat-batch")
R<com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO> aiFeedEnsureEatWalletsBatch(
@RequestBody java.util.List<com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO> reqs);
@NoToken
@GetMapping("/wallet-manage/get-wallet")
R<WalletManageEntity> getWallet(@RequestParam Long userId, @RequestParam WalletTypeEnum walletTypeCode);
@@ -80,6 +80,25 @@ public class WalletManageController {
return R.ok(wallet);
}
/** AI 喂数:确保饭卡钱包(免角色校验、免绑卡) */
@Inner
@GetMapping("/inner/ai-feed-ensure-eat")
public R<WalletManageEntity> aiFeedEnsureEatWallet(@RequestParam Long userId) {
WalletManageEntity wallet = walletManageService.aiFeedEnsureEatWallet(userId);
if (ObjectUtil.isEmpty(wallet)) {
return R.failed("开通饭卡钱包失败");
}
return R.ok(wallet);
}
/** AI 喂数:批量开饭卡并设定余额 */
@Inner
@PostMapping("/inner/ai-feed-ensure-eat-batch")
public R<com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO> aiFeedEnsureEatWalletsBatch(
@RequestBody List<com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO> reqs) {
return R.ok(walletManageService.aiFeedEnsureEatWalletsBatch(reqs));
}
@Inner
@GetMapping("/get-wallet-by-wallet-id")
public R getWalletByWalletTypeId(@RequestParam Long userId, @RequestParam Long walletTypeId) {
@@ -25,6 +25,27 @@ import java.util.List;
@Mapper
public interface WalletManageMapper extends PjcloudBaseMapper<WalletManageEntity> {
/**
* AI 喂数:多行插入饭卡钱包(自行带 tenant_id)
*/
@com.baomidou.mybatisplus.annotation.InterceptorIgnore(tenantLine = "true")
@org.apache.ibatis.annotations.Insert("""
<script>
INSERT INTO wallet_manage (
id, user_id, wallet_type_id, dept_id, balance, total_recharge, total_expense, freeze_price,
create_by, update_by, create_time, update_time, del_flag, tenant_id
) VALUES
<foreach collection="list" item="w" separator=",">
(
#{w.id}, #{w.userId}, #{w.walletTypeId}, #{w.deptId}, #{w.balance}, #{w.totalRecharge},
#{w.totalExpense}, #{w.freezePrice}, #{w.createBy}, #{w.updateBy}, #{w.createTime}, #{w.updateTime},
#{w.delFlag}, #{w.tenantId}
)
</foreach>
</script>
""")
int insertAiFeedBatch(@Param("list") List<WalletManageEntity> list);
/**
* 构建钱包账户通用筛选条件,并以钱包主键作为最终排序条件保证分片导出顺序稳定。
*
@@ -55,6 +55,20 @@ public interface WalletManageService extends IService<WalletManageEntity> {
*/
WalletManageEntity getOrCreateWallet(@NotNull Long userId, @NotNull WalletTypeEnum walletTypeCode);
/**
* AI 喂数:确保饭卡钱包存在。跳过 WALLET_AUTO_CREATE 角色校验,且不自动绑卡(避免抢卡锁拖慢导入)。
*
* @param userId 用户 ID
* @return 饭卡钱包;用户不存在或类型未配置时返回 null
*/
WalletManageEntity aiFeedEnsureEatWallet(@NotNull Long userId);
/**
* AI 喂数批量:确保饭卡并直接设定余额(不走充值流水,演示加速)。
*/
com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO aiFeedEnsureEatWalletsBatch(
java.util.List<com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO> reqs);
/**
* 获取钱包(没有缓存)
*/
@@ -60,7 +60,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 钱包管理
@@ -218,6 +220,165 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
return baseMapper.selectOne(walletQuery);
}
/**
* AI 喂数专用:只确保饭卡钱包行存在,不做角色校验、不抢卡绑卡。
* <p>历史演示导入可能留下同用户多条饭卡钱包;此处用 list+取最早一条,
* 避免调用业务 {@link #getWallet} 的 selectOne 在重复数据上直接报错。</p>
*/
@Override
public WalletManageEntity aiFeedEnsureEatWallet(Long userId) {
if (userId == null) {
return null;
}
WalletTypeEntity eatType = walletTypeMapper.selectOne(new LambdaQueryWrapper<WalletTypeEntity>()
.eq(WalletTypeEntity::getUniqueCode, WalletTypeEnum.EAT.getCode())
.last("LIMIT 1"));
if (ObjectUtil.isEmpty(eatType)) {
log.warn("|{}|AI喂数开通饭卡失败,userId:{},EAT钱包类型未配置", TAG, userId);
return null;
}
WalletManageEntity existing = findFirstEatWallet(userId, eatType.getId());
if (ObjectUtil.isNotEmpty(existing)) {
return existing;
}
UserInfo user = userService.getInnerByUserId(userId).getData();
if (ObjectUtil.isEmpty(user) || ObjectUtil.isEmpty(user.getSysUser())) {
log.warn("|{}|AI喂数开通饭卡失败,userId:{},用户不存在", TAG, userId);
return null;
}
Long deptId = user.getSysUser().getDeptId();
WalletManageEntity insertObj = new WalletManageEntity();
insertObj.setUserId(userId);
insertObj.setWalletTypeId(eatType.getId());
insertObj.setDeptId(deptId);
try {
baseMapper.insert(insertObj);
return insertObj;
}
catch (Exception ex) {
WalletManageEntity again = findFirstEatWallet(userId, eatType.getId());
if (ObjectUtil.isNotEmpty(again)) {
return again;
}
log.warn("|{}|AI喂数开通饭卡失败,userId:{},msg:{}", TAG, userId, ex.getMessage());
return null;
}
}
/** AI 喂数内部:同用户多条饭卡时取最早一条,不抛 TooManyResultsException */
private WalletManageEntity findFirstEatWallet(Long userId, Long eatTypeId) {
List<WalletManageEntity> list = baseMapper.selectList(new LambdaQueryWrapper<WalletManageEntity>()
.eq(WalletManageEntity::getUserId, userId)
.eq(WalletManageEntity::getWalletTypeId, eatTypeId)
.orderByAsc(WalletManageEntity::getId)
.last("LIMIT 1"));
return ObjectUtil.isEmpty(list) ? null : list.get(0);
}
/**
* AI 喂数批量开饭卡:一次查类型、一次查出已有钱包,缺失则插入,并按需直接写余额。
*/
@Override
public com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO aiFeedEnsureEatWalletsBatch(
List<com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO> reqs) {
com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO result =
new com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO();
if (ObjectUtil.isEmpty(reqs)) {
return result;
}
WalletTypeEntity eatType = walletTypeMapper.selectOne(new LambdaQueryWrapper<WalletTypeEntity>()
.eq(WalletTypeEntity::getUniqueCode, WalletTypeEnum.EAT.getCode())
.last("LIMIT 1"));
if (ObjectUtil.isEmpty(eatType)) {
log.warn("|{}|AI喂数批量开饭卡失败,EAT钱包类型未配置", TAG);
return result;
}
List<Long> userIds = reqs.stream()
.filter(Objects::nonNull)
.map(com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO::getUserId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (userIds.isEmpty()) {
return result;
}
Map<Long, WalletManageEntity> walletByUser = baseMapper.selectList(new LambdaQueryWrapper<WalletManageEntity>()
.in(WalletManageEntity::getUserId, userIds)
.eq(WalletManageEntity::getWalletTypeId, eatType.getId())
.orderByAsc(WalletManageEntity::getId))
.stream()
.collect(Collectors.toMap(WalletManageEntity::getUserId, w -> w, (a, b) -> a));
Map<Long, Integer> balanceByUser = new HashMap<>();
Map<Long, Long> deptByUser = new HashMap<>();
for (com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO req : reqs) {
if (req == null || req.getUserId() == null) {
continue;
}
if (req.getBalanceFen() != null) {
balanceByUser.put(req.getUserId(), Math.max(req.getBalanceFen(), 0));
}
if (req.getDeptId() != null) {
deptByUser.put(req.getUserId(), req.getDeptId());
}
}
List<WalletManageEntity> toInsert = new ArrayList<>();
List<WalletManageEntity> toUpdate = new ArrayList<>();
Long tenantId = TenantContextHolder.getTenantId();
LocalDateTime now = LocalDateTime.now();
for (Long userId : userIds) {
WalletManageEntity wallet = walletByUser.get(userId);
if (wallet == null) {
wallet = new WalletManageEntity();
wallet.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
wallet.setUserId(userId);
wallet.setWalletTypeId(eatType.getId());
wallet.setDeptId(deptByUser.get(userId));
Integer bal = balanceByUser.get(userId);
wallet.setBalance(bal == null ? 0 : bal);
wallet.setTotalRecharge(bal != null && bal > 0 ? bal : 0);
wallet.setTotalExpense(0);
wallet.setFreezePrice(0);
wallet.setDelFlag(com.bsd.pjcloud.common.core.constant.CommonConstants.STATUS_NORMAL);
wallet.setTenantId(tenantId);
wallet.setCreateBy("ai-feed");
wallet.setUpdateBy("ai-feed");
wallet.setCreateTime(now);
wallet.setUpdateTime(now);
toInsert.add(wallet);
walletByUser.put(userId, wallet);
}
else if (balanceByUser.containsKey(userId)) {
Integer bal = balanceByUser.get(userId);
wallet.setBalance(bal == null ? 0 : bal);
toUpdate.add(wallet);
}
}
if (!toInsert.isEmpty()) {
// 多行 INSERT,一次 SQL 写入,避免 saveBatch 在无 rewriteBatchedStatements 时仍接近逐条
for (List<WalletManageEntity> chunk : cn.hutool.core.collection.CollUtil.split(toInsert, 200)) {
baseMapper.insertAiFeedBatch(chunk);
}
}
// 已有饭卡不再逐条改余额(喂数侧只给新建卡写 balance)
if (!toUpdate.isEmpty()) {
this.updateBatchById(toUpdate, 100);
}
for (Long userId : userIds) {
WalletManageEntity wallet = walletByUser.get(userId);
if (wallet == null || wallet.getId() == null) {
continue;
}
com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO.Item item =
new com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO.Item();
item.setUserId(userId);
item.setWalletId(wallet.getId());
result.getItems().add(item);
}
return result;
}
@Override
public WalletManageEntity getWallet(Long userId, WalletTypeEnum walletTypeCode) {
WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode);