deploy 2026-04-18 09:19

This commit is contained in:
MaDaLei 2026-04-18 09:19:46 +08:00
parent 6cbf23c06a
commit d5637f407e
7 changed files with 478 additions and 2 deletions

View File

@ -14,6 +14,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.HashMap; import java.util.HashMap;
@ -28,8 +29,8 @@ import java.util.UUID;
@CrossOrigin @CrossOrigin
public class FileController { public class FileController {
private final Set<String> IMAGE_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".heif"); private static final Set<String> IMAGE_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".heif");
private final Set<String> VIDEO_EXT = Set.of(".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm"); private static final Set<String> VIDEO_EXT = Set.of(".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm");
@Value("${upload.path}") @Value("${upload.path}")
private String uploadPath; private String uploadPath;

View File

@ -0,0 +1,110 @@
package com.petstore.controller;
import com.petstore.entity.ReportLead;
import com.petstore.service.ReportLeadService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 报告页留资 & 回访池
* 3 个接口面向 H5 宠主端无需登录 reportToken / unsubscribeToken
* leads 接口面向老板后台 storeId鉴权逻辑参考项目现有其他后台接口当前项目里
* 管理接口未普遍加鉴权这里保持一致由上层网关/前端控制
*/
@RestController
@RequestMapping("/api/report")
@RequiredArgsConstructor
@CrossOrigin
public class ReportLeadController {
private final ReportLeadService reportLeadService;
/** GET /api/report/{token}/suggestion — 下次建议日期 */
@GetMapping("/{token}/suggestion")
public Map<String, Object> suggestion(@PathVariable("token") String token) {
Map<String, Object> data = reportLeadService.suggestNext(token);
if (data == null) {
return Map.of("code", 404, "message", "报告不存在");
}
return Map.of("code", 200, "data", data);
}
/** POST /api/report/{token}/reminder — 宠主留资 */
@PostMapping("/{token}/reminder")
public Map<String, Object> submitReminder(@PathVariable("token") String token,
@RequestBody Map<String, Object> body,
HttpServletRequest request) {
String phone = body.get("phone") == null ? null : body.get("phone").toString().trim();
Object consent = body.get("consent");
if (consent == null || !Boolean.parseBoolean(consent.toString())) {
return Map.of("code", 400, "message", "需要勾选同意接收服务提醒");
}
try {
ReportLead lead = reportLeadService.submit(token, phone, extractClientIp(request));
Map<String, Object> data = new HashMap<>();
data.put("ok", true);
data.put("leadId", lead.getId());
data.put("remindDate", lead.getRemindDate() == null ? null : lead.getRemindDate().toString());
data.put("unsubscribeToken", lead.getUnsubscribeToken());
return Map.of("code", 200, "data", data);
} catch (IllegalArgumentException e) {
return Map.of("code", 400, "message", e.getMessage());
}
}
/** POST /api/report/unsubscribe — 一键退订 */
@PostMapping("/unsubscribe")
public Map<String, Object> unsubscribe(@RequestBody Map<String, Object> body) {
String token = body.get("unsubscribeToken") == null ? null : body.get("unsubscribeToken").toString();
boolean ok = reportLeadService.unsubscribe(token);
if (!ok) {
return Map.of("code", 404, "message", "退订链接无效或已失效");
}
return Map.of("code", 200, "data", Map.of("ok", true));
}
/** GET /api/report/leads?storeId=xxx&status=pending — 老板后台回访池 */
@GetMapping("/leads")
public Map<String, Object> leads(@RequestParam Long storeId,
@RequestParam(required = false, defaultValue = "pending") String status) {
List<ReportLead> list;
if ("all".equalsIgnoreCase(status)) {
list = reportLeadService.listAllForStore(storeId);
} else {
list = reportLeadService.listPending(storeId);
}
List<Map<String, Object>> data = list.stream().map(l -> {
Map<String, Object> m = new HashMap<>();
m.put("id", l.getId());
m.put("reportId", l.getReportId());
m.put("petName", l.getPetName());
m.put("serviceType", l.getServiceType());
m.put("phone", l.getPhone());
m.put("reminderType", l.getReminderType());
m.put("remindDate", l.getRemindDate() == null ? null : l.getRemindDate().toString());
m.put("remindStatus", l.getRemindStatus());
m.put("createTime", l.getCreateTime());
return m;
}).toList();
return Map.of("code", 200, "data", data);
}
/** 取客户端 IP合规留证 */
private String extractClientIp(HttpServletRequest request) {
if (request == null) return null;
String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
for (String h : headers) {
String v = request.getHeader(h);
if (v != null && !v.isBlank() && !"unknown".equalsIgnoreCase(v)) {
int comma = v.indexOf(',');
return (comma > 0 ? v.substring(0, comma) : v).trim();
}
}
return request.getRemoteAddr();
}
}

View File

@ -0,0 +1,78 @@
package com.petstore.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 宠主在报告页留下的联系方式 + 下次服务提醒
* 一约可多条宠主可以改手机号重新留资status 用于软退订与去重
*/
@Data
@Entity
@Table(
name = "t_report_lead",
indexes = {
@Index(name = "idx_lead_report_id", columnList = "report_id"),
@Index(name = "idx_lead_store_status_remind", columnList = "store_id,remind_status,remind_date"),
@Index(name = "idx_lead_phone", columnList = "phone"),
@Index(name = "idx_lead_unsubscribe_token", columnList = "unsubscribe_token")
}
)
public class ReportLead {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "report_id")
private Long reportId;
@Column(name = "store_id")
private Long storeId;
/** 冗余:宠物名 / 服务类型,避免关联报告查询,用于文案拼接 */
@Column(name = "pet_name", length = 64)
private String petName;
@Column(name = "service_type", length = 64)
private String serviceType;
@Column(length = 20)
private String phone;
/** 预留:微信 openid后续公众号/小程序接入后回填) */
@Column(name = "wechat_openid", length = 64)
private String wechatOpenid;
/** next_wash / next_grooming / next_bath_groom / next_nail / next_deworm 等 */
@Column(name = "reminder_type", length = 32)
private String reminderType;
/** 计算出的下次建议日期date 不含时间) */
@Column(name = "remind_date")
private LocalDate remindDate;
/** pending / sent / canceled / unsubscribed */
@Column(name = "remind_status", length = 16)
private String remindStatus;
/** 用户勾选「同意接收」的时间,合规留证 */
@Column(name = "consent_at")
private LocalDateTime consentAt;
/** 用户提交时的 IP合规留证 */
@Column(name = "consent_ip", length = 64)
private String consentIp;
/** 独立的退订令牌,避免暴露 report_token */
@Column(name = "unsubscribe_token", length = 64, unique = true)
private String unsubscribeToken;
@Column(name = "create_time")
private LocalDateTime createTime;
@Column(name = "update_time")
private LocalDateTime updateTime;
}

View File

@ -0,0 +1,47 @@
package com.petstore.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 服务建议周期配置用于下次该洗了的日期预测
* store_id null 表示系统默认老板可针对自家店覆盖
*/
@Data
@Entity
@Table(
name = "t_service_interval",
indexes = {
@Index(name = "idx_si_store_service", columnList = "store_id,service_name")
}
)
public class ServiceInterval {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** null = 系统默认 */
@Column(name = "store_id")
private Long storeId;
/** 与 t_service_type.name 对齐,作为匹配键 */
@Column(name = "service_name", length = 64)
private String serviceName;
/** 可选按宠物类型细分dog/cat/othernull 表示全部 */
@Column(name = "pet_type", length = 16)
private String petType;
/** 建议最短间隔(天) */
@Column(name = "interval_days_min")
private Integer intervalDaysMin;
/** 建议最长间隔(天) */
@Column(name = "interval_days_max")
private Integer intervalDaysMax;
@Column(name = "create_time")
private LocalDateTime createTime;
}

View File

@ -0,0 +1,20 @@
package com.petstore.mapper;
import com.petstore.entity.ReportLead;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ReportLeadMapper extends JpaRepository<ReportLead, Long> {
/** 同一报告 + 同一手机号只保留一条,用于幂等 */
Optional<ReportLead> findFirstByReportIdAndPhone(Long reportId, String phone);
Optional<ReportLead> findFirstByUnsubscribeToken(String unsubscribeToken);
/** 老板回访池:按状态倒查、按提醒日期升序 */
List<ReportLead> findByStoreIdAndRemindStatusOrderByRemindDateAsc(Long storeId, String remindStatus);
List<ReportLead> findByStoreIdOrderByCreateTimeDesc(Long storeId);
}

View File

@ -0,0 +1,17 @@
package com.petstore.mapper;
import com.petstore.entity.ServiceInterval;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ServiceIntervalMapper extends JpaRepository<ServiceInterval, Long> {
/** 店铺自定义优先store_id = :storeId兜底系统默认store_id IS NULL */
Optional<ServiceInterval> findFirstByStoreIdAndServiceName(Long storeId, String serviceName);
Optional<ServiceInterval> findFirstByStoreIdIsNullAndServiceName(String serviceName);
List<ServiceInterval> findByStoreIdIsNull();
}

View File

@ -0,0 +1,203 @@
package com.petstore.service;
import com.petstore.entity.Report;
import com.petstore.entity.ReportLead;
import com.petstore.entity.ServiceInterval;
import com.petstore.mapper.ReportLeadMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.ServiceIntervalMapper;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class ReportLeadService {
private final ReportLeadMapper reportLeadMapper;
private final ReportMapper reportMapper;
private final ServiceIntervalMapper serviceIntervalMapper;
/** 当服务类型不在已配置表中时使用的兜底间隔(天) */
private static final int FALLBACK_MIN = 28;
private static final int FALLBACK_MAX = 35;
/** 首次启动时写入系统默认建议周期。ddl-auto=update 会建表,这里只灌数据。 */
@PostConstruct
public void seedDefaults() {
if (!serviceIntervalMapper.findByStoreIdIsNull().isEmpty()) {
return;
}
// [service_name, min_days, max_days]
Object[][] defaults = new Object[][]{
{"洗澡", 21, 28},
{"美容", 42, 56},
{"洗澡+美容", 28, 35},
{"剪指甲", 30, 45},
{"驱虫", 30, 30}
};
LocalDateTime now = LocalDateTime.now();
for (Object[] row : defaults) {
ServiceInterval si = new ServiceInterval();
si.setStoreId(null);
si.setServiceName((String) row[0]);
si.setPetType(null);
si.setIntervalDaysMin((Integer) row[1]);
si.setIntervalDaysMax((Integer) row[2]);
si.setCreateTime(now);
serviceIntervalMapper.save(si);
}
}
/**
* 根据报告 token 下次建议日期
* 算法 appointmentTime服务时间为锚点 + 间隔区间的中点 round
*
* @return map 包含 remindDate / intervalText / serviceType / reminderTypereport 不存在则返回 null
*/
public Map<String, Object> suggestNext(String reportToken) {
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
if (report == null) return null;
Interval itv = resolveInterval(report.getStoreId(), report.getServiceType());
int mid = (itv.min + itv.max) / 2;
LocalDate anchor;
if (report.getAppointmentTime() != null) {
anchor = report.getAppointmentTime().toLocalDate();
} else if (report.getCreateTime() != null) {
anchor = report.getCreateTime().toLocalDate();
} else {
anchor = LocalDate.now(ZoneId.of("Asia/Shanghai"));
}
LocalDate remind = anchor.plusDays(mid);
LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
if (remind.isBefore(today)) {
// 历史报告也能给出一个合理的未来日期最早一周后
remind = today.plusDays(7);
}
String intervalText = buildIntervalText(itv.min, itv.max);
return Map.of(
"remindDate", remind.toString(),
"intervalText", intervalText,
"intervalDaysMin", itv.min,
"intervalDaysMax", itv.max,
"serviceType", report.getServiceType() == null ? "" : report.getServiceType(),
"reminderType", resolveReminderType(report.getServiceType()),
"petName", report.getPetName() == null ? "" : report.getPetName()
);
}
/**
* 幂等创建/更新留资
* 已存在 (reportId, phone) 组合时只更新状态和提醒日期不重复创建
*/
public ReportLead submit(String reportToken, String phone, String consentIp) {
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
if (report == null) {
throw new IllegalArgumentException("报告不存在或已失效");
}
if (phone == null || !phone.matches("^1[3-9]\\d{9}$")) {
throw new IllegalArgumentException("手机号格式不正确");
}
Map<String, Object> sug = suggestNext(reportToken);
LocalDate remindDate = LocalDate.parse((String) sug.get("remindDate"));
String reminderType = (String) sug.get("reminderType");
LocalDateTime now = LocalDateTime.now();
ReportLead lead = reportLeadMapper.findFirstByReportIdAndPhone(report.getId(), phone).orElse(null);
if (lead == null) {
lead = new ReportLead();
lead.setReportId(report.getId());
lead.setStoreId(report.getStoreId());
lead.setPetName(report.getPetName());
lead.setServiceType(report.getServiceType());
lead.setPhone(phone);
lead.setUnsubscribeToken(UUID.randomUUID().toString().replace("-", ""));
lead.setCreateTime(now);
}
lead.setReminderType(reminderType);
lead.setRemindDate(remindDate);
lead.setRemindStatus("pending");
lead.setConsentAt(now);
lead.setConsentIp(consentIp);
lead.setUpdateTime(now);
return reportLeadMapper.save(lead);
}
/** 退订:按 unsubscribeToken 找记录并置状态 */
public boolean unsubscribe(String unsubscribeToken) {
if (unsubscribeToken == null || unsubscribeToken.isBlank()) return false;
Optional<ReportLead> opt = reportLeadMapper.findFirstByUnsubscribeToken(unsubscribeToken);
if (opt.isEmpty()) return false;
ReportLead lead = opt.get();
if ("unsubscribed".equals(lead.getRemindStatus())) return true;
lead.setRemindStatus("unsubscribed");
lead.setUpdateTime(LocalDateTime.now());
reportLeadMapper.save(lead);
return true;
}
/** 老板回访池pending 状态、按日期升序 */
public List<ReportLead> listPending(Long storeId) {
return reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending");
}
public List<ReportLead> listAllForStore(Long storeId) {
return reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId);
}
// ---------------- 私有工具 ----------------
private Interval resolveInterval(Long storeId, String serviceName) {
if (serviceName != null && storeId != null) {
Optional<ServiceInterval> perStore = serviceIntervalMapper
.findFirstByStoreIdAndServiceName(storeId, serviceName);
if (perStore.isPresent()) return Interval.of(perStore.get());
}
if (serviceName != null) {
Optional<ServiceInterval> sys = serviceIntervalMapper
.findFirstByStoreIdIsNullAndServiceName(serviceName);
if (sys.isPresent()) return Interval.of(sys.get());
}
return new Interval(FALLBACK_MIN, FALLBACK_MAX);
}
private String buildIntervalText(int min, int max) {
int midWeeks = Math.max(1, ((min + max) / 2 + 3) / 7);
if (min == max) {
return String.format("约 %d 天后", min);
}
return String.format("约 %d 周后", midWeeks);
}
private String resolveReminderType(String serviceName) {
if (serviceName == null) return "next_service";
if (serviceName.contains("驱虫")) return "next_deworm";
if (serviceName.contains("指甲")) return "next_nail";
if (serviceName.contains("美容") && serviceName.contains("")) return "next_bath_groom";
if (serviceName.contains("美容")) return "next_grooming";
if (serviceName.contains("")) return "next_wash";
return "next_service";
}
private record Interval(int min, int max) {
static Interval of(ServiceInterval si) {
int mi = si.getIntervalDaysMin() == null ? FALLBACK_MIN : si.getIntervalDaysMin();
int ma = si.getIntervalDaysMax() == null ? FALLBACK_MAX : si.getIntervalDaysMax();
if (ma < mi) ma = mi;
return new Interval(mi, ma);
}
}
}