Compare commits

...

7 Commits

25 changed files with 773 additions and 44 deletions

View File

@ -3,6 +3,7 @@ package com.petstore.controller;
import com.petstore.entity.Appointment;
import com.petstore.service.AppointmentService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -21,24 +22,56 @@ public class AppointmentController {
public Map<String, Object> list(
@RequestParam(required = false) Long userId,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String status) {
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer pageSize) {
if (storeId == null && userId == null) {
return Map.of("code", 400, "message", "userId或storeId必填");
}
List<Appointment> appointments;
boolean usePaging = page != null || pageSize != null;
if (usePaging) {
int pageNo = page == null ? 1 : page;
int size = pageSize == null ? 20 : pageSize;
pageNo = Math.max(pageNo, 1);
size = Math.min(Math.max(size, 1), 200);
Page<Appointment> paged = appointmentService.pageByScope(userId, storeId, status, pageNo - 1, size);
return Map.of(
"code", 200,
"data", paged.getContent(),
"page", pageNo,
"pageSize", size,
"total", paged.getTotalElements(),
"totalPages", paged.getTotalPages(),
"hasNext", paged.hasNext()
);
}
if (storeId != null) {
appointments = (status != null && !status.isEmpty())
? appointmentService.getByStoreIdAndStatus(storeId, status)
: appointmentService.getByStoreId(storeId);
} else if (userId != null) {
} else {
appointments = (status != null && !status.isEmpty())
? appointmentService.getByUserIdAndStatus(userId, status)
: appointmentService.getByUserId(userId);
} else {
return Map.of("code", 400, "message", "userId或storeId必填");
}
return Map.of("code", 200, "data", appointments);
}
/** 预约详情 */
@GetMapping("/detail")
public Map<String, Object> detail(@RequestParam Long id) {
Appointment appointment = appointmentService.getById(id);
if (appointment != null) {
return Map.of("code", 200, "data", appointment);
}
return Map.of("code", 404, "message", "预约不存在");
}
/** 创建预约 */
@PostMapping("/create")
public Map<String, Object> create(@RequestBody Map<String, Object> params) {
@ -57,6 +90,9 @@ public class AppointmentController {
if (params.containsKey("remark") && params.get("remark") != null) {
appointment.setRemark(params.get("remark").toString());
}
if (params.containsKey("petId") && params.get("petId") != null && !params.get("petId").toString().isBlank()) {
appointment.setPetId(Long.valueOf(params.get("petId").toString()));
}
Appointment created = appointmentService.create(appointment);
return Map.of("code", 200, "message", "创建成功", "data", created);

View File

@ -26,38 +26,38 @@ import java.util.UUID;
@CrossOrigin
public class FileController {
private static final String UPLOAD_BASE = "/Users/wac/Desktop/www/_src/petstore/backend/uploads/";
@Value("${upload.path:uploads}")
@Value("${upload.path:uploads/}")
private String uploadPath;
@GetMapping("/image/**")
public ResponseEntity<Resource> getImage(HttpServletRequest request) throws IOException {
String path = request.getRequestURI().replace("/api/upload/image", "");
File file = new File(UPLOAD_BASE + path);
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
File file = new File(basePath + path);
if (!file.exists()) {
return ResponseEntity.notFound().build();
}
String contentType = Files.probeContentType(file.toPath());
if (contentType == null) contentType = "image/jpeg";
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.body(new FileSystemResource(file));
.contentType(MediaType.parseMediaType(contentType))
.body(new FileSystemResource(file));
}
// 兼容旧路径/2026/04/01/xxx.jpg
@GetMapping("/legacy/**")
public ResponseEntity<Resource> getLegacyImage(HttpServletRequest request) throws IOException {
String path = request.getRequestURI().replace("/api/upload/legacy", "");
File file = new File(UPLOAD_BASE + path);
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
File file = new File(basePath + path);
if (!file.exists()) {
return ResponseEntity.notFound().build();
}
String contentType = Files.probeContentType(file.toPath());
if (contentType == null) contentType = "image/jpeg";
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.body(new FileSystemResource(file));
.contentType(MediaType.parseMediaType(contentType))
.body(new FileSystemResource(file));
}
@PostMapping("/image")
@ -78,9 +78,9 @@ public class FileController {
}
try {
// 创建上传目录使用绝对路径
// 创建上传目录
String datePath = LocalDate.now().toString().replace("-", "/");
String dirPath = UPLOAD_BASE + datePath; // /.../uploads/2026/04/01
String dirPath = uploadPath.endsWith("/") ? uploadPath + datePath : uploadPath + "/" + datePath;
File dir = new File(dirPath);
if (!dir.exists()) dir.mkdirs();

View File

@ -0,0 +1,58 @@
package com.petstore.controller;
import com.petstore.entity.Pet;
import com.petstore.service.PetService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/pet")
@RequiredArgsConstructor
@CrossOrigin
public class PetController {
private final PetService petService;
/**
* 列表 ownerUserId= 查该客户全部宠物 storeId= 查本店预约关联出现过的宠物商家/员工
*/
@GetMapping("/list")
public Map<String, Object> list(@RequestParam(required = false) Long ownerUserId,
@RequestParam(required = false) Long storeId) {
if (ownerUserId != null) {
return Map.of("code", 200, "data", petService.listByOwner(ownerUserId));
}
if (storeId != null) {
return Map.of("code", 200, "data", petService.listByStoreServed(storeId));
}
return Map.of("code", 400, "message", "请传 ownerUserId 或 storeId");
}
@PostMapping("/create")
public Map<String, Object> create(@RequestBody Pet pet) {
return petService.create(pet);
}
@PutMapping("/update")
public Map<String, Object> update(@RequestBody Map<String, Object> body) {
Long operatorUserId = Long.valueOf(body.get("operatorUserId").toString());
String role = body.get("role").toString();
Pet input = new Pet();
input.setId(Long.valueOf(body.get("id").toString()));
if (body.containsKey("name")) input.setName(body.get("name").toString());
if (body.containsKey("petType")) input.setPetType(body.get("petType").toString());
if (body.containsKey("breed")) input.setBreed(body.get("breed") != null ? body.get("breed").toString() : null);
if (body.containsKey("avatar")) input.setAvatar(body.get("avatar") != null ? body.get("avatar").toString() : null);
if (body.containsKey("remark")) input.setRemark(body.get("remark") != null ? body.get("remark").toString() : null);
return petService.update(operatorUserId, role, input);
}
@DeleteMapping("/delete")
public Map<String, Object> delete(@RequestParam Long id,
@RequestParam Long operatorUserId,
@RequestParam String role) {
return petService.delete(id, operatorUserId, role);
}
}

View File

@ -6,6 +6,8 @@ import com.petstore.service.ReportService;
import com.petstore.service.StoreService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import java.util.HashMap;
import java.util.List;
@ -20,12 +22,13 @@ public class ReportController {
private final ReportService reportService;
private final StoreService storeService;
private static final String BASE_URL = "http://localhost:8080";
@Value("${app.base-url:http://localhost:8080}")
private String baseUrl;
private String fullUrl(String path) {
if (path == null || path.isEmpty()) return path;
if (path.startsWith("http")) return path;
return BASE_URL + path;
return baseUrl + path;
}
@PostMapping("/create")
@ -45,8 +48,22 @@ public class ReportController {
@GetMapping("/list")
public Map<String, Object> list(@RequestParam(required = false) Long storeId,
@RequestParam(required = false) Long userId) {
List<Report> reports = reportService.list(storeId, userId);
@RequestParam(required = false) Long userId,
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer pageSize) {
boolean usePaging = page != null || pageSize != null;
List<Report> reports;
Page<Report> paged = null;
Integer pageNo = null;
Integer size = null;
if (usePaging) {
pageNo = Math.max(page == null ? 1 : page, 1);
size = Math.min(Math.max(pageSize == null ? 20 : pageSize, 1), 200);
paged = reportService.page(storeId, userId, pageNo - 1, size);
reports = paged.getContent();
} else {
reports = reportService.list(storeId, userId);
}
// 附加技师名称并补全图片URL
List<Map<String, Object>> data = reports.stream().map(r -> {
Map<String, Object> item = new HashMap<>();
@ -64,6 +81,17 @@ public class ReportController {
item.put("userId", r.getUserId());
return item;
}).collect(Collectors.toList());
if (usePaging && paged != null) {
return Map.of(
"code", 200,
"data", data,
"page", pageNo,
"pageSize", size,
"total", paged.getTotalElements(),
"totalPages", paged.getTotalPages(),
"hasNext", paged.hasNext()
);
}
return Map.of("code", 200, "data", data);
}

View File

@ -17,8 +17,9 @@ public class ServiceTypeController {
private final ServiceTypeService serviceTypeService;
@GetMapping("/list")
public Map<String, Object> list(@RequestParam Long storeId) {
List<ServiceType> list = serviceTypeService.getByStoreId(storeId);
public Map<String, Object> list(@RequestParam(required = false) Long storeId) {
List<ServiceType> list =
storeId == null ? serviceTypeService.listSystemDefaultsOnly() : serviceTypeService.getByStoreId(storeId);
return Map.of("code", 200, "data", list);
}

View File

@ -6,7 +6,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/store")
@ -25,6 +27,26 @@ public class StoreController {
return result;
}
/**
* 客户预约门店列表不含邀请码等敏感字段
*/
@GetMapping("/list")
public Map<String, Object> list() {
List<Store> all = storeService.listAll();
List<Map<String, Object>> rows = all.stream().map(s -> {
Map<String, Object> m = new HashMap<>();
m.put("id", s.getId());
m.put("name", s.getName());
m.put("address", s.getAddress());
m.put("latitude", s.getLatitude());
m.put("longitude", s.getLongitude());
m.put("phone", s.getPhone());
m.put("logo", s.getLogo());
return m;
}).collect(Collectors.toList());
return Map.of("code", 200, "data", rows);
}
@GetMapping("/get")
public Map<String, Object> get(@RequestParam Long id) {
Store store = storeService.findById(id);

View File

@ -2,19 +2,23 @@ package com.petstore.controller;
import com.petstore.entity.User;
import com.petstore.service.UserService;
import com.petstore.service.WechatMiniProgramService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
@CrossOrigin
public class UserController {
private final UserService userService;
private final WechatMiniProgramService wechatMiniProgramService;
/** 老板注册店铺 */
@PostMapping("/register-boss")
@ -34,6 +38,36 @@ public class UserController {
return userService.login(phone, code);
}
/**
* 微信小程序button open-type=getPhoneNumber 拿到的 phoneCode 换手机号并登录
*/
@PostMapping("/wx-phone-login")
public Map<String, Object> wxPhoneLogin(@RequestBody(required = false) Map<String, String> params) {
try {
if (params == null) {
return Map.of("code", 400, "message", "请求体不能为空");
}
String phoneCode = params.get("phoneCode");
if (phoneCode == null || phoneCode.isBlank()) {
return Map.of("code", 400, "message", "缺少手机号授权码");
}
var wx = wechatMiniProgramService.exchangePhoneCode(phoneCode);
if (!wx.isOk()) {
Map<String, Object> err = new HashMap<>();
err.put("code", 400);
err.put("message", wx.errorMessage() != null ? wx.errorMessage() : "微信登录失败");
return err;
}
return userService.loginByVerifiedPhone(wx.phone());
} catch (Exception e) {
log.error("wx-phone-login 异常", e);
Map<String, Object> err = new HashMap<>();
err.put("code", 500);
err.put("message", "登录处理失败: " + e.getMessage());
return err;
}
}
/** 员工注册(邀请码方式) */
@PostMapping("/register-staff")
public Map<String, Object> registerStaff(@RequestBody Map<String, String> params) {

View File

@ -6,7 +6,13 @@ import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "t_appointment")
@Table(
name = "t_appointment",
indexes = {
@Index(name = "idx_appt_store_status_time", columnList = "store_id,status,appointment_time"),
@Index(name = "idx_appt_user_status_time", columnList = "user_id,status,appointment_time")
}
)
public class Appointment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -26,6 +32,10 @@ public class Appointment {
@Column(name = "user_id")
private Long userId;
/** 关联宠物档案(可选,用于本店「服务过的宠物」统计) */
@Column(name = "pet_id")
private Long petId;
/** 技师ID开始服务时赋值 */
@Column(name = "assigned_user_id")
private Long assignedUserId;

View File

@ -0,0 +1,43 @@
package com.petstore.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 宠物档案归属某客户商家/员工通过预约关联查看本店服务过的宠物
*/
@Data
@Entity
@Table(name = "t_pet")
public class Pet {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 昵称 */
private String name;
/** 类型:猫/狗/其他 */
@Column(name = "pet_type")
private String petType;
/** 品种(可选) */
private String breed;
/** 头像相对路径 */
private String avatar;
/** 所属用户(客户) */
@Column(name = "owner_user_id")
private Long ownerUserId;
private String remark;
@Column(name = "create_time")
private LocalDateTime createTime;
@Column(name = "update_time")
private LocalDateTime updateTime;
}

View File

@ -7,7 +7,14 @@ import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "t_report")
@Table(
name = "t_report",
indexes = {
@Index(name = "idx_report_appointment_id", columnList = "appointment_id"),
@Index(name = "idx_report_token", columnList = "report_token"),
@Index(name = "idx_report_store_user_time", columnList = "store_id,user_id,create_time")
}
)
public class Report {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@ -6,7 +6,12 @@ import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "t_service_type")
@Table(
name = "t_service_type",
indexes = {
@Index(name = "idx_service_type_store_id", columnList = "store_id")
}
)
public class ServiceType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@ -6,7 +6,13 @@ import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "t_store")
@Table(
name = "t_store",
indexes = {
@Index(name = "idx_store_invite_code", columnList = "invite_code"),
@Index(name = "idx_store_owner_id", columnList = "owner_id")
}
)
public class Store {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@ -6,7 +6,13 @@ import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "t_user")
@Table(
name = "t_user",
indexes = {
@Index(name = "idx_user_phone", columnList = "phone"),
@Index(name = "idx_user_store_id", columnList = "store_id")
}
)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@ -1,6 +1,8 @@
package com.petstore.mapper;
import com.petstore.entity.Appointment;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
@ -10,4 +12,9 @@ public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
List<Appointment> findByUserIdAndStatus(Long userId, String status);
List<Appointment> findByStoreId(Long storeId);
List<Appointment> findByStoreIdAndStatus(Long storeId, String status);
Page<Appointment> findByUserId(Long userId, Pageable pageable);
Page<Appointment> findByUserIdAndStatus(Long userId, String status, Pageable pageable);
Page<Appointment> findByStoreId(Long storeId, Pageable pageable);
Page<Appointment> findByStoreIdAndStatus(Long storeId, String status, Pageable pageable);
}

View File

@ -0,0 +1,17 @@
package com.petstore.mapper;
import com.petstore.entity.Pet;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface PetMapper extends JpaRepository<Pet, Long> {
List<Pet> findByOwnerUserIdOrderByUpdateTimeDesc(Long ownerUserId);
/** 本店预约中关联过的宠物(预约.pet_id 指向该宠物) */
@Query("SELECT DISTINCT p FROM Pet p, Appointment a WHERE a.petId = p.id AND a.storeId = :storeId ORDER BY p.updateTime DESC")
List<Pet> findDistinctPetsServedAtStore(@Param("storeId") Long storeId);
}

View File

@ -1,7 +1,29 @@
package com.petstore.mapper;
import com.petstore.entity.Report;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ReportMapper extends JpaRepository<Report, Long> {
Optional<Report> findFirstByAppointmentIdOrderByCreateTimeDesc(Long appointmentId);
Optional<Report> findFirstByReportToken(String reportToken);
List<Report> findByStoreIdOrderByCreateTimeDesc(Long storeId);
List<Report> findByUserIdOrderByCreateTimeDesc(Long userId);
List<Report> findByStoreIdAndUserIdOrderByCreateTimeDesc(Long storeId, Long userId);
List<Report> findAllByOrderByCreateTimeDesc();
Page<Report> findByStoreId(Long storeId, Pageable pageable);
Page<Report> findByUserId(Long userId, Pageable pageable);
Page<Report> findByStoreIdAndUserId(Long storeId, Long userId, Pageable pageable);
}

View File

@ -8,4 +8,7 @@ import java.util.List;
public interface ServiceTypeMapper extends JpaRepository<ServiceType, Long> {
List<ServiceType> findByStoreIdOrStoreIdIsNull(Long storeId);
List<ServiceType> findByStoreId(Long storeId);
/** 系统内置store_id 为空) */
List<ServiceType> findByStoreIdIsNull();
}

View File

@ -3,6 +3,10 @@ package com.petstore.service;
import com.petstore.entity.Appointment;
import com.petstore.mapper.AppointmentMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@ -33,6 +37,30 @@ public class AppointmentService {
return appointmentMapper.findByStoreIdAndStatus(storeId, status);
}
public Page<Appointment> pageByScope(Long userId, Long storeId, String status, int pageNo, int pageSize) {
Pageable pageable = PageRequest.of(
Math.max(pageNo, 0),
Math.max(pageSize, 1),
Sort.by(Sort.Direction.DESC, "appointmentTime")
);
boolean hasStatus = status != null && !status.isBlank();
if (storeId != null) {
return hasStatus
? appointmentMapper.findByStoreIdAndStatus(storeId, status, pageable)
: appointmentMapper.findByStoreId(storeId, pageable);
}
if (userId != null) {
return hasStatus
? appointmentMapper.findByUserIdAndStatus(userId, status, pageable)
: appointmentMapper.findByUserId(userId, pageable);
}
return Page.empty(pageable);
}
public Appointment getById(Long id) {
return appointmentMapper.findById(id).orElse(null);
}
public Appointment create(Appointment appointment) {
appointment.setCreateTime(LocalDateTime.now());
appointment.setUpdateTime(LocalDateTime.now());

View File

@ -0,0 +1,98 @@
package com.petstore.service;
import com.petstore.entity.Pet;
import com.petstore.mapper.PetMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class PetService {
private final PetMapper petMapper;
/** 客户:仅自己的宠物 */
public List<Pet> listByOwner(Long ownerUserId) {
if (ownerUserId == null) {
return List.of();
}
return petMapper.findByOwnerUserIdOrderByUpdateTimeDesc(ownerUserId);
}
/** 商家/员工:本店预约中关联过的宠物(预约带 pet_id */
public List<Pet> listByStoreServed(Long storeId) {
if (storeId == null) {
return List.of();
}
return petMapper.findDistinctPetsServedAtStore(storeId);
}
public Map<String, Object> create(Pet pet) {
if (pet.getName() == null || pet.getName().isBlank()) {
return Map.of("code", 400, "message", "请填写宠物名字");
}
if (pet.getPetType() == null || pet.getPetType().isBlank()) {
return Map.of("code", 400, "message", "请选择宠物类型");
}
if (pet.getOwnerUserId() == null) {
return Map.of("code", 400, "message", "缺少所属用户");
}
pet.setCreateTime(LocalDateTime.now());
pet.setUpdateTime(LocalDateTime.now());
Pet saved = petMapper.save(pet);
return Map.of("code", 200, "message", "保存成功", "data", saved);
}
public Map<String, Object> update(Long operatorUserId, String role, Pet input) {
if (input.getId() == null) {
return Map.of("code", 400, "message", "缺少宠物ID");
}
Optional<Pet> opt = petMapper.findById(input.getId());
if (opt.isEmpty()) {
return Map.of("code", 404, "message", "宠物不存在");
}
Pet pet = opt.get();
if ("customer".equals(role)) {
if (!pet.getOwnerUserId().equals(operatorUserId)) {
return Map.of("code", 403, "message", "无权修改该宠物");
}
} else {
return Map.of("code", 403, "message", "仅客户可编辑自己的宠物档案");
}
if (input.getName() != null && !input.getName().isBlank()) {
pet.setName(input.getName());
}
if (input.getPetType() != null && !input.getPetType().isBlank()) {
pet.setPetType(input.getPetType());
}
if (input.getBreed() != null) {
pet.setBreed(input.getBreed());
}
if (input.getAvatar() != null) {
pet.setAvatar(input.getAvatar());
}
if (input.getRemark() != null) {
pet.setRemark(input.getRemark());
}
pet.setUpdateTime(LocalDateTime.now());
petMapper.save(pet);
return Map.of("code", 200, "message", "更新成功", "data", pet);
}
public Map<String, Object> delete(Long petId, Long operatorUserId, String role) {
Optional<Pet> opt = petMapper.findById(petId);
if (opt.isEmpty()) {
return Map.of("code", 404, "message", "宠物不存在");
}
Pet pet = opt.get();
if (!"customer".equals(role) || !pet.getOwnerUserId().equals(operatorUserId)) {
return Map.of("code", 403, "message", "无权删除");
}
petMapper.deleteById(petId);
return Map.of("code", 200, "message", "已删除");
}
}

View File

@ -7,12 +7,15 @@ import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.ReportMapper;
import com.petstore.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@ -64,23 +67,47 @@ public class ReportService {
}
public Report getByAppointmentId(Long appointmentId) {
return reportMapper.findAll().stream()
.filter(r -> r.getAppointmentId().equals(appointmentId))
.findFirst()
.orElse(null);
if (appointmentId == null) {
return null;
}
return reportMapper.findFirstByAppointmentIdOrderByCreateTimeDesc(appointmentId).orElse(null);
}
public Report getByToken(String token) {
return reportMapper.findAll().stream()
.filter(r -> token.equals(r.getReportToken()))
.findFirst()
.orElse(null);
if (token == null || token.isBlank()) {
return null;
}
return reportMapper.findFirstByReportToken(token).orElse(null);
}
public List<Report> list(Long storeId, Long userId) {
return reportMapper.findAll().stream()
.filter(r -> storeId == null || storeId.equals(r.getStoreId()))
.filter(r -> userId == null || userId.equals(r.getUserId()))
.collect(Collectors.toList());
if (storeId != null && userId != null) {
return reportMapper.findByStoreIdAndUserIdOrderByCreateTimeDesc(storeId, userId);
}
if (storeId != null) {
return reportMapper.findByStoreIdOrderByCreateTimeDesc(storeId);
}
if (userId != null) {
return reportMapper.findByUserIdOrderByCreateTimeDesc(userId);
}
return reportMapper.findAllByOrderByCreateTimeDesc();
}
public Page<Report> page(Long storeId, Long userId, int pageNo, int pageSize) {
Pageable pageable = PageRequest.of(
Math.max(pageNo, 0),
Math.max(pageSize, 1),
Sort.by(Sort.Direction.DESC, "createTime")
);
if (storeId != null && userId != null) {
return reportMapper.findByStoreIdAndUserId(storeId, userId, pageable);
}
if (storeId != null) {
return reportMapper.findByStoreId(storeId, pageable);
}
if (userId != null) {
return reportMapper.findByUserId(userId, pageable);
}
return reportMapper.findAll(pageable);
}
}

View File

@ -18,6 +18,11 @@ public class ServiceTypeService {
return serviceTypeMapper.findByStoreIdOrStoreIdIsNull(storeId);
}
/** 仅系统默认(未传门店或 C 端尚未绑定门店时用于展示可选名称) */
public List<ServiceType> listSystemDefaultsOnly() {
return serviceTypeMapper.findByStoreIdIsNull();
}
/** 老板新增服务类型 */
public ServiceType create(Long storeId, String name) {
ServiceType st = new ServiceType();

View File

@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Service
@ -28,6 +29,11 @@ public class StoreService {
return storeMapper.findByInviteCode(code);
}
/** C 端预约:列出全部门店供选择 */
public List<Store> listAll() {
return storeMapper.findAll();
}
public Store update(Store store) {
store.setUpdateTime(LocalDateTime.now());
return storeMapper.save(store);

View File

@ -55,13 +55,23 @@ public class UserService {
if (!"123456".equals(code)) {
return Map.of("code", 401, "message", "验证码错误");
}
return loginByVerifiedPhone(phone);
}
/**
* 已通过短信或微信授权校验后的手机号登录与验证码登录成功后的逻辑一致
*/
public Map<String, Object> loginByVerifiedPhone(String phone) {
if (phone == null || !phone.matches("^1\\d{10}$")) {
return Map.of("code", 400, "message", "手机号格式不正确");
}
User user = userMapper.findByPhone(phone);
if (user == null) {
// 自动注册为 C 端用户 (customer)
user = new User();
user.setUsername(phone);
user.setPhone(phone);
user.setName("微信用户" + phone.substring(7));
user.setPassword("wx_no_password");
user.setRole("customer");
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());

View File

@ -0,0 +1,250 @@
package com.petstore.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.config.WechatConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
/**
* 微信小程序通过 getPhoneNumber 返回的 code 换取用户手机号
* 使用 JDK HttpClient 直连微信避免部分环境下 RestTemplate 对响应解析为空
* 文档https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/phonenumber/phonenumber.getPhoneNumber.html
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class WechatMiniProgramService {
private final WechatConfig wechatConfig;
private final ObjectMapper objectMapper = new ObjectMapper();
/** 部分代理/网关对 HTTP/2 支持差,固定 HTTP/1.1 更稳 */
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.NORMAL)
.version(HttpClient.Version.HTTP_1_1)
.build();
private volatile String cachedAccessToken;
private volatile long tokenExpiresAtEpochMs;
public record WxPhoneExchangeResult(String phone, String errorMessage) {
public static WxPhoneExchangeResult ok(String phone) {
return new WxPhoneExchangeResult(phone, null);
}
public static WxPhoneExchangeResult fail(String errorMessage) {
String msg = (errorMessage != null && !errorMessage.isBlank()) ? errorMessage : "未知错误";
return new WxPhoneExchangeResult(null, msg);
}
public boolean isOk() {
return phone != null && !phone.isBlank();
}
}
private boolean isConfigured() {
String id = wechatConfig.getAppid();
String sec = wechatConfig.getAppsecret();
return id != null && !id.isBlank()
&& sec != null && !sec.isBlank()
&& !"YOUR_APPID".equals(id)
&& !"YOUR_APPSECRET".equals(sec);
}
private void invalidateAccessToken() {
cachedAccessToken = null;
tokenExpiresAtEpochMs = 0;
}
/**
* @param phoneCode 前端 button getPhoneNumber 回调中的 detail.code
*/
public WxPhoneExchangeResult exchangePhoneCode(String phoneCode) {
if (phoneCode == null || phoneCode.isBlank()) {
return WxPhoneExchangeResult.fail("缺少手机号授权码");
}
if (!isConfigured()) {
log.warn("微信 appid/appsecret 未配置或为占位符,无法换取手机号");
return WxPhoneExchangeResult.fail("服务端未配置微信小程序 AppID/AppSecret或仍为占位符 YOUR_APPID");
}
for (int attempt = 0; attempt < 2; attempt++) {
try {
String accessToken = getAccessToken();
if (accessToken == null) {
return WxPhoneExchangeResult.fail("无法获取微信 access_token请核对服务端 AppID/AppSecret 是否与微信公众平台一致");
}
String url = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken;
String jsonBody = objectMapper.writeValueAsString(Map.of("code", phoneCode));
HttpResult hr = httpPostJson(url, jsonBody);
String body = hr.body();
if (body == null || body.isBlank()) {
log.warn("getuserphonenumber 响应为空, httpStatus={}, urlLen={}", hr.statusCode(), url.length());
return WxPhoneExchangeResult.fail(emptyBodyHint("getuserphonenumber", hr.statusCode()));
}
JsonNode root;
try {
root = objectMapper.readTree(body);
} catch (Exception parseEx) {
log.warn("解析微信响应失败 body={}", body, parseEx);
return WxPhoneExchangeResult.fail("微信返回非 JSONHTTP " + hr.statusCode() + "),可能被网关替换");
}
if (root.hasNonNull("phone_info")) {
JsonNode pi = root.path("phone_info");
String raw = pi.path("phoneNumber").asText("");
if (raw.isBlank()) {
raw = pi.path("purePhoneNumber").asText("");
}
String phone = normalizeMainlandPhone(raw);
if (phone == null) {
log.warn("无法解析手机号,原始响应: {}", body);
return WxPhoneExchangeResult.fail("微信返回的手机号格式异常");
}
return WxPhoneExchangeResult.ok(phone);
}
int errcode = root.path("errcode").asInt(-1);
String errmsg = root.path("errmsg").asText("");
log.warn("getuserphonenumber 无 phone_info: {}", body);
if ((errcode == 40001 || errcode == 42001) && attempt == 0) {
invalidateAccessToken();
continue;
}
if (errcode <= 0 && errmsg.isBlank()) {
return WxPhoneExchangeResult.fail("微信未返回手机号,请重新点击授权");
}
return WxPhoneExchangeResult.fail(formatWxError(errcode, errmsg));
} catch (Exception e) {
log.warn("换取手机号异常", e);
return WxPhoneExchangeResult.fail("连接微信服务异常: " + e.getMessage());
}
}
return WxPhoneExchangeResult.fail("获取手机号失败,请重试");
}
private static String emptyBodyHint(String api, int status) {
return "微信 " + api + " 返回空内容HTTP " + status + ")。"
+ "请在该机器上测试能否访问 api.weixin.qq.com:443curl/浏览器),检查防火墙、安全组、出站 HTTPS、公司代理"
+ "需代理时可设置 JVM 参数 https.proxyHost / https.proxyPort若 IPv6 异常可试 -Djava.net.preferIPv4Stack=true";
}
private static String formatWxError(int errcode, String errmsg) {
String base = (errmsg != null && !errmsg.isBlank()) ? errmsg : "未知错误";
String hint = switch (errcode) {
case 40029 -> "code 无效或已过期,请重新点击「微信授权登录」)";
case 40163 -> "code 已被使用,请重新授权)";
case 40001, 42001 -> "access_token 无效,已自动重试;若仍失败请检查 AppSecret";
case 40125 -> "AppSecret 错误,请登录微信公众平台核对后更新服务端配置)";
case 40013 -> "AppID 无效,请核对是否为小程序 AppID";
default -> "";
};
if (errcode > 0) {
return "微信接口错误 " + errcode + "" + base + hint;
}
return "获取手机号失败:" + base;
}
private String getAccessToken() {
long now = System.currentTimeMillis();
if (cachedAccessToken != null && now < tokenExpiresAtEpochMs - 60_000) {
return cachedAccessToken;
}
synchronized (this) {
now = System.currentTimeMillis();
if (cachedAccessToken != null && now < tokenExpiresAtEpochMs - 60_000) {
return cachedAccessToken;
}
try {
String appid = URLEncoder.encode(wechatConfig.getAppid(), StandardCharsets.UTF_8);
String secret = URLEncoder.encode(wechatConfig.getAppsecret(), StandardCharsets.UTF_8);
String url = String.format(
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
appid, secret);
HttpResult hr = httpGet(url);
String body = hr.body();
if (body == null || body.isBlank()) {
log.warn("获取 access_token 微信返回空 body, http={}", hr.statusCode());
return null;
}
JsonNode root;
try {
root = objectMapper.readTree(body);
} catch (Exception parseEx) {
log.warn("解析 token 响应失败 body={}", body, parseEx);
return null;
}
if (root.hasNonNull("errcode") && root.path("errcode").asInt() != 0) {
int ec = root.path("errcode").asInt();
String em = root.path("errmsg").asText("");
log.warn("获取 access_token 失败: errcode={} errmsg={} body={}", ec, em, body);
return null;
}
String token = root.path("access_token").asText(null);
int expiresIn = root.path("expires_in").asInt(7200);
if (token == null || token.isBlank()) {
return null;
}
cachedAccessToken = token;
tokenExpiresAtEpochMs = System.currentTimeMillis() + expiresIn * 1000L;
return cachedAccessToken;
} catch (Exception e) {
log.warn("获取 access_token 异常", e);
return null;
}
}
}
private record HttpResult(int statusCode, String body) {}
private HttpResult httpGet(String url) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", "PetstoreBackend/1.0")
.GET()
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new HttpResult(resp.statusCode(), resp.body());
}
private HttpResult httpPostJson(String url, String jsonBody) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", "PetstoreBackend/1.0")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new HttpResult(resp.statusCode(), resp.body());
}
private String normalizeMainlandPhone(String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
String digits = raw.replaceAll("\\D", "");
if (digits.startsWith("86") && digits.length() == 13) {
digits = digits.substring(2);
}
if (digits.length() == 11) {
return digits;
}
log.warn("无法解析为 11 位手机号: {}", raw);
return null;
}
}

View File

@ -35,8 +35,8 @@ logging:
level:
com.petstore: debug
# 微信登录配置(需替换为实际值
# 微信小程序(与 manifest / 微信后台一致;生产环境建议用环境变量覆盖
wechat:
appid: YOUR_APPID
appsecret: YOUR_APPSECRET
appid: ${WECHAT_APPID:wx8ca2dfa89af72edf}
appsecret: ${WECHAT_APPSECRET:7afb49f3a31fe9b5a083c7c40be45c5b}
redirect_uri: http://localhost:8080/api/wechat/callback