Merge branch 'master' of https://gitea.zianxin.com/zax/biz-api-cloud-zax
This commit is contained in:
commit
f2ccd3050a
30 files changed
+2126
No files matched your search
@@ -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>
|
||||
+67
@@ -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;
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
+80
@@ -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));
|
||||
}
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+27
@@ -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();
|
||||
}
|
||||
+854
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
|
||||
}
|
||||
+29
@@ -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 ************************************* */
|
||||
|
||||
+26
@@ -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);
|
||||
}
|
||||
+283
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+19
@@ -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);
|
||||
|
||||
}
|
||||
+38
@@ -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 批量查询用户、部门及身份协议编码。
|
||||
*
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
+15
@@ -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);
|
||||
|
||||
}
|
||||
+20
@@ -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);
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*
|
||||
|
||||
+170
@@ -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>
|
||||
+18
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
|
||||
+19
@@ -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) {
|
||||
|
||||
+21
@@ -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);
|
||||
|
||||
/**
|
||||
* 构建钱包账户通用筛选条件,并以钱包主键作为最终排序条件保证分片导出顺序稳定。
|
||||
*
|
||||
|
||||
+14
@@ -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);
|
||||
|
||||
/**
|
||||
* 获取钱包(没有缓存)
|
||||
*/
|
||||
|
||||
+161
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user