This commit is contained in:
王子琦
2026-01-13 13:55:40 +08:00
parent 6affd0c77e
commit f58e05d962
72 changed files with 3251 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
植愈线上花店Spring Boot + Vue2 + MySQL
目录结构
- backend后端服务JDK8 + Spring Boot 2.7
- frontend前端页面Vue2 + Element UI
- backend/sql/init.sql数据库初始化与示例数据
启动步骤
1. 创建数据库并导入数据
- 执行 `backend/sql/init.sql`
2. 启动后端
- 修改 `backend/src/main/resources/application.yml` 的数据库账号密码
-`backend` 目录执行 `mvn spring-boot:run`
3. 启动前端
-`frontend` 目录执行 `npm install`
- 执行 `npm run serve`,默认端口 8081
默认账号
- 管理员admin / 123456
核心功能
- 用户注册登录、商品浏览、下单支付、订单管理、评价管理
- 告白弹幕定制、二维码生成、收花人扫码浏览与弹幕互动
- 后台商品/订单/用户/评价管理与数据仪表盘

67
backend/pom.xml Normal file
View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.flower</groupId>
<artifactId>flower-backend</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

124
backend/sql/init.sql Normal file
View File

@@ -0,0 +1,124 @@
CREATE DATABASE IF NOT EXISTS flower_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE flower_shop;
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(32) NOT NULL UNIQUE,
password_hash VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
nickname VARCHAR(32),
phone VARCHAR(32),
email VARCHAR(64),
avatar_url VARCHAR(255),
disabled TINYINT(1) DEFAULT 0,
created_at DATETIME
);
CREATE TABLE IF NOT EXISTS sessions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
expired TINYINT(1) DEFAULT 0,
created_at DATETIME
);
CREATE TABLE IF NOT EXISTS addresses (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
recipient_name VARCHAR(32) NOT NULL,
phone VARCHAR(32) NOT NULL,
province VARCHAR(32),
city VARCHAR(32),
district VARCHAR(32),
detail VARCHAR(255),
is_default TINYINT(1) DEFAULT 0
);
CREATE TABLE IF NOT EXISTS categories (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(32) NOT NULL,
sort_order INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS products (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
category_id BIGINT,
name VARCHAR(64) NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL,
stock INT DEFAULT 0,
cover_url VARCHAR(255),
status VARCHAR(16) DEFAULT 'ON',
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
receiver_name VARCHAR(32),
receiver_phone VARCHAR(32),
receiver_address VARCHAR(128),
created_at DATETIME,
updated_at DATETIME,
pay_at DATETIME
);
CREATE TABLE IF NOT EXISTS order_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
product_name VARCHAR(64) NOT NULL,
product_price DECIMAL(10,2) NOT NULL,
quantity INT NOT NULL,
product_cover VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS reviews (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT,
product_id BIGINT,
user_id BIGINT,
rating INT,
content VARCHAR(1000),
images VARCHAR(1000),
status VARCHAR(16) DEFAULT 'PENDING',
created_at DATETIME
);
CREATE TABLE IF NOT EXISTS confessions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT,
user_id BIGINT,
code VARCHAR(32) NOT NULL UNIQUE,
title VARCHAR(64),
message VARCHAR(2000),
image_url VARCHAR(255),
created_at DATETIME
);
CREATE TABLE IF NOT EXISTS barrages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
confession_id BIGINT NOT NULL,
sender VARCHAR(32),
content VARCHAR(200) NOT NULL,
created_at DATETIME
);
INSERT INTO users (username, password_hash, role, nickname, created_at)
VALUES ('admin', '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92', 'ADMIN', '管理员', NOW())
ON DUPLICATE KEY UPDATE username = username;
INSERT INTO categories (name, sort_order) VALUES
('表白花束', 1),
('生日花束', 2),
('节日限定', 3)
ON DUPLICATE KEY UPDATE name = VALUES(name);
INSERT INTO products (category_id, name, description, price, stock, cover_url, status, created_at, updated_at) VALUES
(1, '心动红玫瑰', '11支红玫瑰搭配满天星', 199.00, 50, 'https://via.placeholder.com/400x300?text=Rose', 'ON', NOW(), NOW()),
(2, '暖阳向日葵', '向日葵搭配桔梗', 168.00, 40, 'https://via.placeholder.com/400x300?text=Sunflower', 'ON', NOW(), NOW()),
(3, '节日限定花篮', '节日限定主题花篮', 299.00, 20, 'https://via.placeholder.com/400x300?text=Holiday', 'ON', NOW(), NOW());

View File

@@ -0,0 +1,11 @@
package com.flower;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class FlowerApplication {
public static void main(String[] args) {
SpringApplication.run(FlowerApplication.class, args);
}
}

View File

@@ -0,0 +1,48 @@
package com.flower.admin;
import com.flower.common.ApiResponse;
import com.flower.order.OrderRepository;
import com.flower.security.AdminOnly;
import com.flower.user.UserRepository;
import lombok.Data;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/admin/dashboard")
@AdminOnly
public class AdminDashboardController {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
public AdminDashboardController(OrderRepository orderRepository, UserRepository userRepository) {
this.orderRepository = orderRepository;
this.userRepository = userRepository;
}
@GetMapping
public ApiResponse<DashboardStats> stats() {
long totalOrders = orderRepository.count();
long totalUsers = userRepository.count();
BigDecimal totalSales = orderRepository.findAll().stream()
.filter(order -> "PAID".equals(order.getStatus()) || "SHIPPED".equals(order.getStatus())
|| "COMPLETED".equals(order.getStatus()))
.map(order -> order.getTotalAmount() == null ? BigDecimal.ZERO : order.getTotalAmount())
.reduce(BigDecimal.ZERO, BigDecimal::add);
DashboardStats stats = new DashboardStats();
stats.setTotalOrders(totalOrders);
stats.setTotalUsers(totalUsers);
stats.setTotalSales(totalSales);
return ApiResponse.ok(stats);
}
@Data
public static class DashboardStats {
private long totalOrders;
private long totalUsers;
private BigDecimal totalSales;
}
}

View File

@@ -0,0 +1,14 @@
package com.flower.common;
public class ApiException extends RuntimeException {
private final int code;
public ApiException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -0,0 +1,25 @@
package com.flower.common;
import lombok.Data;
@Data
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.code = 0;
response.message = "ok";
response.data = data;
return response;
}
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.code = code;
response.message = message;
return response;
}
}

View File

@@ -0,0 +1,32 @@
package com.flower.common;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleApi(ApiException ex) {
return ApiResponse.error(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValid(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().isEmpty()
? "参数错误"
: ex.getBindingResult().getFieldErrors().get(0).getDefaultMessage();
return ApiResponse.error(400, message);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleOther(Exception ex) {
return ApiResponse.error(500, ex.getMessage());
}
}

View File

@@ -0,0 +1,21 @@
package com.flower.common;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class PasswordUtil {
public static String sha256(String raw) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not supported", e);
}
}
}

View File

@@ -0,0 +1,21 @@
package com.flower.common;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import java.io.ByteArrayOutputStream;
public class QrCodeUtil {
public static byte[] generatePng(String content, int size) {
try {
BitMatrix matrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, size, size);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(matrix, "PNG", outputStream);
return outputStream.toByteArray();
} catch (Exception e) {
throw new ApiException(500, "二维码生成失败");
}
}
}

View File

@@ -0,0 +1,33 @@
package com.flower.confession;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "barrages")
public class Barrage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long confessionId;
@Column(length = 32)
private String sender;
@Column(nullable = false, length = 200)
private String content;
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.confession;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface BarrageRepository extends JpaRepository<Barrage, Long> {
List<Barrage> findByConfessionIdOrderByCreatedAtAsc(Long confessionId);
}

View File

@@ -0,0 +1,40 @@
package com.flower.confession;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "confessions")
public class Confession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long orderId;
private Long userId;
@Column(nullable = false, unique = true, length = 32)
private String code;
@Column(length = 64)
private String title;
@Column(length = 2000)
private String message;
@Column(length = 255)
private String imageUrl;
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,102 @@
package com.flower.confession;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.common.QrCodeUtil;
import com.flower.order.Order;
import com.flower.order.OrderRepository;
import com.flower.security.AuthContext;
import com.flower.security.PublicEndpoint;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/confessions")
public class ConfessionController {
private final ConfessionRepository confessionRepository;
private final BarrageRepository barrageRepository;
private final OrderRepository orderRepository;
private final String baseUrl;
public ConfessionController(ConfessionRepository confessionRepository,
BarrageRepository barrageRepository,
OrderRepository orderRepository,
@Value("${app.qr.base-url}") String baseUrl) {
this.confessionRepository = confessionRepository;
this.barrageRepository = barrageRepository;
this.orderRepository = orderRepository;
this.baseUrl = baseUrl;
}
@PostMapping
public ApiResponse<Confession> create(@RequestBody CreateConfessionRequest request) {
Order order = orderRepository.findById(request.getOrderId())
.orElseThrow(() -> new ApiException(404, "订单不存在"));
if (!order.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
Confession confession = new Confession();
confession.setOrderId(order.getId());
confession.setUserId(order.getUserId());
confession.setTitle(request.getTitle());
confession.setMessage(request.getMessage());
confession.setImageUrl(request.getImageUrl());
confession.setCode(UUID.randomUUID().toString().replace("-", "").substring(0, 12));
return ApiResponse.ok(confessionRepository.save(confession));
}
@PublicEndpoint
@GetMapping("/{code}")
public ApiResponse<Confession> get(@PathVariable String code) {
return ApiResponse.ok(confessionRepository.findByCode(code)
.orElseThrow(() -> new ApiException(404, "页面不存在")));
}
@PublicEndpoint
@GetMapping("/{code}/barrages")
public ApiResponse<List<Barrage>> listBarrages(@PathVariable String code) {
Confession confession = confessionRepository.findByCode(code)
.orElseThrow(() -> new ApiException(404, "页面不存在"));
return ApiResponse.ok(barrageRepository.findByConfessionIdOrderByCreatedAtAsc(confession.getId()));
}
@PublicEndpoint
@PostMapping("/{code}/barrages")
public ApiResponse<Barrage> createBarrage(@PathVariable String code, @RequestBody CreateBarrageRequest request) {
Confession confession = confessionRepository.findByCode(code)
.orElseThrow(() -> new ApiException(404, "页面不存在"));
Barrage barrage = new Barrage();
barrage.setConfessionId(confession.getId());
barrage.setSender(request.getSender());
barrage.setContent(request.getContent());
return ApiResponse.ok(barrageRepository.save(barrage));
}
@PublicEndpoint
@GetMapping(value = "/{code}/qr", produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> qr(@PathVariable String code) {
String url = baseUrl.endsWith("/") ? baseUrl + code : baseUrl + "/" + code;
byte[] png = QrCodeUtil.generatePng(url, 300);
return ResponseEntity.ok(png);
}
@Data
public static class CreateConfessionRequest {
private Long orderId;
private String title;
private String message;
private String imageUrl;
}
@Data
public static class CreateBarrageRequest {
private String sender;
private String content;
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.confession;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ConfessionRepository extends JpaRepository<Confession, Long> {
Optional<Confession> findByCode(String code);
}

View File

@@ -0,0 +1,33 @@
package com.flower.config;
import com.flower.security.AuthInterceptor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final String allowedOrigins;
public WebConfig(AuthInterceptor authInterceptor,
@Value("${app.cors.allowed-origins}") String allowedOrigins) {
this.authInterceptor = authInterceptor;
this.allowedOrigins = allowedOrigins;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor).addPathPatterns("/api/**");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(allowedOrigins.split(","))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,56 @@
package com.flower.order;
import lombok.Data;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 32)
private String orderNo;
@Column(nullable = false)
private Long userId;
@Column(nullable = false, length = 16)
private String status;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal totalAmount;
@Column(length = 32)
private String receiverName;
@Column(length = 32)
private String receiverPhone;
@Column(length = 128)
private String receiverAddress;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime payAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,186 @@
package com.flower.order;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.product.Product;
import com.flower.product.ProductRepository;
import com.flower.security.AdminOnly;
import com.flower.security.AuthContext;
import com.flower.user.Address;
import com.flower.user.AddressRepository;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
private final ProductRepository productRepository;
private final AddressRepository addressRepository;
public OrderController(OrderRepository orderRepository,
OrderItemRepository orderItemRepository,
ProductRepository productRepository,
AddressRepository addressRepository) {
this.orderRepository = orderRepository;
this.orderItemRepository = orderItemRepository;
this.productRepository = productRepository;
this.addressRepository = addressRepository;
}
@PostMapping
public ApiResponse<OrderDetail> create(@Valid @RequestBody CreateOrderRequest request) {
Address address = addressRepository.findById(request.getAddressId())
.orElseThrow(() -> new ApiException(404, "地址不存在"));
if (!address.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
List<OrderItem> items = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
for (CreateOrderItem item : request.getItems()) {
Product product = productRepository.findById(item.getProductId())
.orElseThrow(() -> new ApiException(404, "商品不存在"));
if (!"ON".equals(product.getStatus())) {
throw new ApiException(400, "商品已下架");
}
if (product.getStock() < item.getQuantity()) {
throw new ApiException(400, "库存不足");
}
product.setStock(product.getStock() - item.getQuantity());
productRepository.save(product);
OrderItem orderItem = new OrderItem();
orderItem.setProductId(product.getId());
orderItem.setProductName(product.getName());
orderItem.setProductPrice(product.getPrice());
orderItem.setQuantity(item.getQuantity());
orderItem.setProductCover(product.getCoverUrl());
items.add(orderItem);
total = total.add(product.getPrice().multiply(new BigDecimal(item.getQuantity())));
}
Order order = new Order();
order.setOrderNo("NO" + UUID.randomUUID().toString().replace("-", "").substring(0, 12));
order.setUserId(AuthContext.get().getId());
order.setStatus("CREATED");
order.setTotalAmount(total);
order.setReceiverName(address.getRecipientName());
order.setReceiverPhone(address.getPhone());
order.setReceiverAddress(String.join(" ", safe(address.getProvince()),
safe(address.getCity()), safe(address.getDistrict()), safe(address.getDetail())));
orderRepository.save(order);
for (OrderItem item : items) {
item.setOrderId(order.getId());
orderItemRepository.save(item);
}
return ApiResponse.ok(new OrderDetail(order, items));
}
@GetMapping
public ApiResponse<List<OrderDetail>> listMine() {
List<Order> orders = orderRepository.findByUserIdOrderByCreatedAtDesc(AuthContext.get().getId());
List<OrderDetail> result = new ArrayList<>();
for (Order order : orders) {
result.add(new OrderDetail(order, orderItemRepository.findByOrderId(order.getId())));
}
return ApiResponse.ok(result);
}
@GetMapping("/{id}")
public ApiResponse<OrderDetail> detail(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow(() -> new ApiException(404, "订单不存在"));
if (!order.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
return ApiResponse.ok(new OrderDetail(order, orderItemRepository.findByOrderId(id)));
}
@PutMapping("/{id}/cancel")
public ApiResponse<Order> cancel(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow(() -> new ApiException(404, "订单不存在"));
if (!order.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
if (!"CREATED".equals(order.getStatus())) {
throw new ApiException(400, "订单无法取消");
}
order.setStatus("CANCELED");
orderRepository.save(order);
return ApiResponse.ok(order);
}
@PostMapping("/{id}/pay")
public ApiResponse<Order> pay(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow(() -> new ApiException(404, "订单不存在"));
if (!order.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
if (!"CREATED".equals(order.getStatus())) {
throw new ApiException(400, "订单无法支付");
}
order.setStatus("PAID");
order.setPayAt(LocalDateTime.now());
orderRepository.save(order);
return ApiResponse.ok(order);
}
@AdminOnly
@GetMapping("/admin/all")
public ApiResponse<List<OrderDetail>> listAll() {
List<Order> orders = orderRepository.findAll();
List<OrderDetail> result = new ArrayList<>();
for (Order order : orders) {
result.add(new OrderDetail(order, orderItemRepository.findByOrderId(order.getId())));
}
return ApiResponse.ok(result);
}
@AdminOnly
@PutMapping("/admin/{id}/status")
public ApiResponse<Order> updateStatus(@PathVariable Long id, @RequestBody UpdateStatusRequest request) {
Order order = orderRepository.findById(id).orElseThrow(() -> new ApiException(404, "订单不存在"));
order.setStatus(request.getStatus());
orderRepository.save(order);
return ApiResponse.ok(order);
}
private String safe(String value) {
return value == null ? "" : value;
}
@Data
public static class CreateOrderRequest {
private Long addressId;
@NotEmpty
private List<CreateOrderItem> items;
}
@Data
public static class CreateOrderItem {
private Long productId;
private Integer quantity;
}
@Data
public static class UpdateStatusRequest {
private String status;
}
@Data
public static class OrderDetail {
private Order order;
private List<OrderItem> items;
public OrderDetail(Order order, List<OrderItem> items) {
this.order = order;
this.items = items;
}
}
}

View File

@@ -0,0 +1,33 @@
package com.flower.order;
import lombok.Data;
import javax.persistence.*;
import java.math.BigDecimal;
@Data
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long orderId;
@Column(nullable = false)
private Long productId;
@Column(nullable = false, length = 64)
private String productName;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal productPrice;
@Column(nullable = false)
private Integer quantity;
@Column(length = 255)
private String productCover;
}

View File

@@ -0,0 +1,9 @@
package com.flower.order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
List<OrderItem> findByOrderId(Long orderId);
}

View File

@@ -0,0 +1,11 @@
package com.flower.order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface OrderRepository extends JpaRepository<Order, Long> {
Optional<Order> findByOrderNo(String orderNo);
List<Order> findByUserIdOrderByCreatedAtDesc(Long userId);
}

View File

@@ -0,0 +1,19 @@
package com.flower.product;
import lombok.Data;
import javax.persistence.*;
@Data
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 32)
private String name;
private Integer sortOrder = 0;
}

View File

@@ -0,0 +1,44 @@
package com.flower.product;
import com.flower.common.ApiResponse;
import com.flower.security.AdminOnly;
import com.flower.security.PublicEndpoint;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/categories")
public class CategoryController {
private final CategoryRepository categoryRepository;
public CategoryController(CategoryRepository categoryRepository) {
this.categoryRepository = categoryRepository;
}
@PublicEndpoint
@GetMapping
public ApiResponse<List<Category>> list() {
return ApiResponse.ok(categoryRepository.findAll());
}
@AdminOnly
@PostMapping
public ApiResponse<Category> create(@RequestBody Category category) {
return ApiResponse.ok(categoryRepository.save(category));
}
@AdminOnly
@PutMapping("/{id}")
public ApiResponse<Category> update(@PathVariable Long id, @RequestBody Category category) {
category.setId(id);
return ApiResponse.ok(categoryRepository.save(category));
}
@AdminOnly
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
categoryRepository.deleteById(id);
return ApiResponse.ok(null);
}
}

View File

@@ -0,0 +1,6 @@
package com.flower.product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryRepository extends JpaRepository<Category, Long> {
}

View File

@@ -0,0 +1,52 @@
package com.flower.product;
import lombok.Data;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long categoryId;
@Column(nullable = false, length = 64)
private String name;
@Column(length = 2000)
private String description;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
private Integer stock = 0;
@Column(length = 255)
private String coverUrl;
@Column(length = 16)
private String status = "ON";
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,61 @@
package com.flower.product;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.security.AdminOnly;
import com.flower.security.PublicEndpoint;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@PublicEndpoint
@GetMapping
public ApiResponse<List<Product>> list(@RequestParam(required = false) Long categoryId) {
if (categoryId == null) {
return ApiResponse.ok(productRepository.findByStatus("ON"));
}
return ApiResponse.ok(productRepository.findByCategoryIdAndStatus(categoryId, "ON"));
}
@PublicEndpoint
@GetMapping("/{id}")
public ApiResponse<Product> detail(@PathVariable Long id) {
return ApiResponse.ok(productRepository.findById(id)
.orElseThrow(() -> new ApiException(404, "商品不存在")));
}
@AdminOnly
@GetMapping("/admin/all")
public ApiResponse<List<Product>> adminList() {
return ApiResponse.ok(productRepository.findAll());
}
@AdminOnly
@PostMapping
public ApiResponse<Product> create(@RequestBody Product product) {
return ApiResponse.ok(productRepository.save(product));
}
@AdminOnly
@PutMapping("/{id}")
public ApiResponse<Product> update(@PathVariable Long id, @RequestBody Product product) {
product.setId(id);
return ApiResponse.ok(productRepository.save(product));
}
@AdminOnly
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
productRepository.deleteById(id);
return ApiResponse.ok(null);
}
}

View File

@@ -0,0 +1,10 @@
package com.flower.product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByStatus(String status);
List<Product> findByCategoryIdAndStatus(Long categoryId, String status);
}

View File

@@ -0,0 +1,41 @@
package com.flower.review;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "reviews")
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long orderId;
private Long productId;
private Long userId;
private Integer rating;
@Column(length = 1000)
private String content;
@Column(length = 1000)
private String images;
@Column(length = 16)
private String status = "PENDING";
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,53 @@
package com.flower.review;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.security.AdminOnly;
import com.flower.security.AuthContext;
import com.flower.security.PublicEndpoint;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/reviews")
public class ReviewController {
private final ReviewRepository reviewRepository;
public ReviewController(ReviewRepository reviewRepository) {
this.reviewRepository = reviewRepository;
}
@PostMapping
public ApiResponse<Review> create(@RequestBody Review review) {
review.setUserId(AuthContext.get().getId());
return ApiResponse.ok(reviewRepository.save(review));
}
@PublicEndpoint
@GetMapping("/product/{productId}")
public ApiResponse<List<Review>> listByProduct(@PathVariable Long productId) {
return ApiResponse.ok(reviewRepository.findByProductIdAndStatus(productId, "APPROVED"));
}
@AdminOnly
@GetMapping("/admin/all")
public ApiResponse<List<Review>> listAll() {
return ApiResponse.ok(reviewRepository.findAll());
}
@AdminOnly
@PutMapping("/admin/{id}/status")
public ApiResponse<Review> updateStatus(@PathVariable Long id, @RequestBody UpdateStatusRequest request) {
Review review = reviewRepository.findById(id).orElseThrow(() -> new ApiException(404, "评价不存在"));
review.setStatus(request.getStatus());
reviewRepository.save(review);
return ApiResponse.ok(review);
}
@Data
public static class UpdateStatusRequest {
private String status;
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.review;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ReviewRepository extends JpaRepository<Review, Long> {
List<Review> findByProductIdAndStatus(Long productId, String status);
}

View File

@@ -0,0 +1,11 @@
package com.flower.security;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminOnly {
}

View File

@@ -0,0 +1,29 @@
package com.flower.security;
import com.flower.user.User;
public class AuthContext {
private static final ThreadLocal<User> HOLDER = new ThreadLocal<>();
private static final ThreadLocal<String> TOKEN = new ThreadLocal<>();
public static void set(User user) {
HOLDER.set(user);
}
public static User get() {
return HOLDER.get();
}
public static void setToken(String token) {
TOKEN.set(token);
}
public static String getToken() {
return TOKEN.get();
}
public static void clear() {
HOLDER.remove();
TOKEN.remove();
}
}

View File

@@ -0,0 +1,74 @@
package com.flower.security;
import com.flower.common.ApiException;
import com.flower.user.Session;
import com.flower.user.SessionRepository;
import com.flower.user.User;
import com.flower.user.UserRepository;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final SessionRepository sessionRepository;
private final UserRepository userRepository;
public AuthInterceptor(SessionRepository sessionRepository, UserRepository userRepository) {
this.sessionRepository = sessionRepository;
this.userRepository = userRepository;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
HandlerMethod method = (HandlerMethod) handler;
if (method.hasMethodAnnotation(PublicEndpoint.class)) {
return true;
}
String token = extractToken(request);
if (!StringUtils.hasText(token)) {
throw new ApiException(401, "未登录");
}
Optional<Session> session = sessionRepository.findByTokenAndExpiredFalse(token);
if (!session.isPresent()) {
throw new ApiException(401, "登录已过期");
}
User user = userRepository.findById(session.get().getUserId())
.orElseThrow(() -> new ApiException(404, "用户不存在"));
if (Boolean.TRUE.equals(user.getDisabled())) {
throw new ApiException(403, "账号已禁用");
}
AuthContext.set(user);
AuthContext.setToken(token);
if (method.hasMethodAnnotation(AdminOnly.class) || method.getBeanType().isAnnotationPresent(AdminOnly.class)) {
if (!"ADMIN".equals(user.getRole())) {
throw new ApiException(403, "无权限");
}
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
AuthContext.clear();
}
private String extractToken(HttpServletRequest request) {
String auth = request.getHeader("Authorization");
if (StringUtils.hasText(auth) && auth.startsWith("Bearer ")) {
return auth.substring(7);
}
return request.getHeader("X-Token");
}
}

View File

@@ -0,0 +1,11 @@
package com.flower.security;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PublicEndpoint {
}

View File

@@ -0,0 +1,37 @@
package com.flower.user;
import lombok.Data;
import javax.persistence.*;
@Data
@Entity
@Table(name = "addresses")
public class Address {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false, length = 32)
private String recipientName;
@Column(nullable = false, length = 32)
private String phone;
@Column(length = 32)
private String province;
@Column(length = 32)
private String city;
@Column(length = 32)
private String district;
@Column(length = 255)
private String detail;
private Boolean isDefault = false;
}

View File

@@ -0,0 +1,68 @@
package com.flower.user;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.security.AuthContext;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/addresses")
public class AddressController {
private final AddressRepository addressRepository;
public AddressController(AddressRepository addressRepository) {
this.addressRepository = addressRepository;
}
@GetMapping
public ApiResponse<List<Address>> list() {
return ApiResponse.ok(addressRepository.findByUserId(AuthContext.get().getId()));
}
@PostMapping
public ApiResponse<Address> create(@RequestBody Address address) {
address.setUserId(AuthContext.get().getId());
if (Boolean.TRUE.equals(address.getIsDefault())) {
clearDefault();
}
return ApiResponse.ok(addressRepository.save(address));
}
@PutMapping("/{id}")
public ApiResponse<Address> update(@PathVariable Long id, @RequestBody Address address) {
Address exists = addressRepository.findById(id)
.orElseThrow(() -> new ApiException(404, "地址不存在"));
if (!exists.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
address.setId(id);
address.setUserId(exists.getUserId());
if (Boolean.TRUE.equals(address.getIsDefault())) {
clearDefault();
}
return ApiResponse.ok(addressRepository.save(address));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
Address exists = addressRepository.findById(id)
.orElseThrow(() -> new ApiException(404, "地址不存在"));
if (!exists.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
addressRepository.deleteById(id);
return ApiResponse.ok(null);
}
private void clearDefault() {
List<Address> addresses = addressRepository.findByUserId(AuthContext.get().getId());
for (Address item : addresses) {
if (Boolean.TRUE.equals(item.getIsDefault())) {
item.setIsDefault(false);
addressRepository.save(item);
}
}
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AddressRepository extends JpaRepository<Address, Long> {
List<Address> findByUserId(Long userId);
}

View File

@@ -0,0 +1,99 @@
package com.flower.user;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.common.PasswordUtil;
import com.flower.security.AuthContext;
import com.flower.security.PublicEndpoint;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final SessionRepository sessionRepository;
public AuthController(UserRepository userRepository, SessionRepository sessionRepository) {
this.userRepository = userRepository;
this.sessionRepository = sessionRepository;
}
@PublicEndpoint
@PostMapping("/register")
public ApiResponse<Map<String, Object>> register(@Valid @RequestBody RegisterRequest request) {
userRepository.findByUsername(request.getUsername()).ifPresent(u -> {
throw new ApiException(400, "账号已存在");
});
User user = new User();
user.setUsername(request.getUsername());
user.setPasswordHash(PasswordUtil.sha256(request.getPassword()));
user.setRole("USER");
user.setNickname(request.getNickname());
userRepository.save(user);
return ApiResponse.ok(buildToken(user));
}
@PublicEndpoint
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@Valid @RequestBody LoginRequest request) {
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new ApiException(404, "账号不存在"));
if (!PasswordUtil.sha256(request.getPassword()).equals(user.getPasswordHash())) {
throw new ApiException(400, "密码错误");
}
if (Boolean.TRUE.equals(user.getDisabled())) {
throw new ApiException(403, "账号已禁用");
}
return ApiResponse.ok(buildToken(user));
}
@PostMapping("/logout")
public ApiResponse<Void> logout() {
sessionRepository.findByTokenAndExpiredFalse(AuthContext.getToken())
.ifPresent(session -> {
session.setExpired(true);
sessionRepository.save(session);
});
return ApiResponse.ok(null);
}
@GetMapping("/me")
public ApiResponse<User> me() {
return ApiResponse.ok(AuthContext.get());
}
private Map<String, Object> buildToken(User user) {
Session session = new Session();
session.setUserId(user.getId());
session.setToken(UUID.randomUUID().toString().replace("-", ""));
sessionRepository.save(session);
Map<String, Object> result = new HashMap<>();
result.put("token", session.getToken());
result.put("user", user);
return result;
}
@Data
public static class RegisterRequest {
@NotBlank(message = "账号不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
private String nickname;
}
@Data
public static class LoginRequest {
@NotBlank(message = "账号不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
}

View File

@@ -0,0 +1,32 @@
package com.flower.user;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "sessions")
public class Session {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false, unique = true, length = 64)
private String token;
private Boolean expired = false;
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SessionRepository extends JpaRepository<Session, Long> {
Optional<Session> findByTokenAndExpiredFalse(String token);
}

View File

@@ -0,0 +1,47 @@
package com.flower.user;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 32)
private String username;
@Column(nullable = false, length = 64)
private String passwordHash;
@Column(nullable = false, length = 16)
private String role;
@Column(length = 32)
private String nickname;
@Column(length = 32)
private String phone;
@Column(length = 64)
private String email;
@Column(length = 255)
private String avatarUrl;
private Boolean disabled = false;
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,73 @@
package com.flower.user;
import com.flower.common.ApiResponse;
import com.flower.security.AdminOnly;
import com.flower.security.AuthContext;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.NotNull;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/me")
public ApiResponse<User> me() {
return ApiResponse.ok(AuthContext.get());
}
@PutMapping("/me")
public ApiResponse<User> updateMe(@RequestBody UpdateProfileRequest request) {
User user = AuthContext.get();
if (request.getNickname() != null) {
user.setNickname(request.getNickname());
}
if (request.getPhone() != null) {
user.setPhone(request.getPhone());
}
if (request.getEmail() != null) {
user.setEmail(request.getEmail());
}
if (request.getAvatarUrl() != null) {
user.setAvatarUrl(request.getAvatarUrl());
}
userRepository.save(user);
return ApiResponse.ok(user);
}
@AdminOnly
@GetMapping
public ApiResponse<List<User>> list() {
return ApiResponse.ok(userRepository.findAll());
}
@AdminOnly
@PutMapping("/{id}/status")
public ApiResponse<User> updateStatus(@PathVariable Long id, @RequestBody UpdateStatusRequest request) {
User user = userRepository.findById(id).orElseThrow(() -> new RuntimeException("用户不存在"));
user.setDisabled(request.getDisabled());
userRepository.save(user);
return ApiResponse.ok(user);
}
@Data
public static class UpdateProfileRequest {
private String nickname;
private String phone;
private String email;
private String avatarUrl;
}
@Data
public static class UpdateStatusRequest {
@NotNull
private Boolean disabled;
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}

View File

@@ -0,0 +1,24 @@
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/flower_shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
app:
cors:
allowed-origins: http://localhost:8081
qr:
base-url: http://localhost:8081/#/gift

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "flower-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --port 8081",
"build": "vue-cli-service build"
},
"dependencies": {
"axios": "^1.7.2",
"core-js": "^3.36.0",
"element-ui": "^2.15.14",
"vue": "^2.7.16",
"vue-router": "^3.6.5"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-service": "^5.0.8"
}
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>植愈线上花店</title>
</head>
<body>
<noscript>需要启用 JavaScript 才能继续。</noscript>
<div id="app"></div>
</body>
</html>

19
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,19 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script>
export default {
name: 'App'
};
</script>
<style>
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
background: #f7f7f7;
}
</style>

View File

@@ -0,0 +1,6 @@
import http from './http';
export const listAddresses = () => http.get('/addresses');
export const createAddress = (data) => http.post('/addresses', data);
export const updateAddress = (id, data) => http.put(`/addresses/${id}`, data);
export const deleteAddress = (id) => http.delete(`/addresses/${id}`);

21
frontend/src/api/admin.js Normal file
View File

@@ -0,0 +1,21 @@
import http from './http';
export const dashboard = () => http.get('/admin/dashboard');
export const adminProducts = {
list: () => http.get('/products/admin/all'),
create: (data) => http.post('/products', data),
update: (id, data) => http.put(`/products/${id}`, data),
remove: (id) => http.delete(`/products/${id}`)
};
export const adminOrders = {
list: () => http.get('/orders/admin/all'),
updateStatus: (id, data) => http.put(`/orders/admin/${id}/status`, data)
};
export const adminUsers = {
list: () => http.get('/users'),
updateStatus: (id, data) => http.put(`/users/${id}/status`, data)
};
export const adminReviews = {
list: () => http.get('/reviews/admin/all'),
updateStatus: (id, data) => http.put(`/reviews/admin/${id}/status`, data)
};

6
frontend/src/api/auth.js Normal file
View File

@@ -0,0 +1,6 @@
import http from './http';
export const login = (data) => http.post('/auth/login', data);
export const register = (data) => http.post('/auth/register', data);
export const me = () => http.get('/auth/me');
export const logout = () => http.post('/auth/logout');

View File

@@ -0,0 +1,7 @@
import http from './http';
export const createConfession = (data) => http.post('/confessions', data);
export const getConfession = (code) => http.get(`/confessions/${code}`);
export const listBarrages = (code) => http.get(`/confessions/${code}/barrages`);
export const sendBarrage = (code, data) => http.post(`/confessions/${code}/barrages`, data);
export const getQrUrl = (code) => `/api/confessions/${code}/qr`;

16
frontend/src/api/http.js Normal file
View File

@@ -0,0 +1,16 @@
import axios from 'axios';
const http = axios.create({
baseURL: '/api',
timeout: 10000
});
http.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default http;

View File

@@ -0,0 +1,7 @@
import http from './http';
export const createOrder = (data) => http.post('/orders', data);
export const listOrders = () => http.get('/orders');
export const getOrder = (id) => http.get(`/orders/${id}`);
export const payOrder = (id) => http.post(`/orders/${id}/pay`);
export const cancelOrder = (id) => http.put(`/orders/${id}/cancel`);

View File

@@ -0,0 +1,5 @@
import http from './http';
export const listProducts = (params) => http.get('/products', { params });
export const getProduct = (id) => http.get(`/products/${id}`);
export const listCategories = () => http.get('/categories');

13
frontend/src/main.js Normal file
View File

@@ -0,0 +1,13 @@
import Vue from 'vue';
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
import App from './App.vue';
import router from './router';
Vue.config.productionTip = false;
Vue.use(ElementUI);
new Vue({
router,
render: (h) => h(App)
}).$mount('#app');

View File

@@ -0,0 +1,44 @@
import Vue from 'vue';
import Router from 'vue-router';
import Home from '../views/Home.vue';
import ProductDetail from '../views/ProductDetail.vue';
import Login from '../views/Login.vue';
import Register from '../views/Register.vue';
import Orders from '../views/Orders.vue';
import Profile from '../views/Profile.vue';
import ConfessionCreate from '../views/ConfessionCreate.vue';
import GiftPage from '../views/GiftPage.vue';
import AdminLayout from '../views/admin/AdminLayout.vue';
import AdminDashboard from '../views/admin/AdminDashboard.vue';
import AdminProducts from '../views/admin/AdminProducts.vue';
import AdminOrders from '../views/admin/AdminOrders.vue';
import AdminUsers from '../views/admin/AdminUsers.vue';
import AdminReviews from '../views/admin/AdminReviews.vue';
Vue.use(Router);
const router = new Router({
routes: [
{ path: '/', component: Home },
{ path: '/product/:id', component: ProductDetail },
{ path: '/login', component: Login },
{ path: '/register', component: Register },
{ path: '/orders', component: Orders },
{ path: '/profile', component: Profile },
{ path: '/confession/create/:orderId', component: ConfessionCreate },
{ path: '/gift/:code', component: GiftPage },
{
path: '/admin',
component: AdminLayout,
children: [
{ path: '', component: AdminDashboard },
{ path: 'products', component: AdminProducts },
{ path: 'orders', component: AdminOrders },
{ path: 'users', component: AdminUsers },
{ path: 'reviews', component: AdminReviews }
]
}
]
});
export default router;

View File

@@ -0,0 +1,78 @@
<template>
<div class="page">
<el-card>
<el-button type="text" @click="$router.push('/orders')">返回订单</el-button>
</el-card>
<el-card class="content">
<h3>定制告白弹幕</h3>
<el-form :model="form">
<el-form-item label="标题">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="祝福文字">
<el-input type="textarea" v-model="form.message" />
</el-form-item>
<el-form-item label="配图链接">
<el-input v-model="form.imageUrl" placeholder="https://..." />
</el-form-item>
</el-form>
<el-button type="primary" @click="submit">生成页面</el-button>
<div v-if="confession" class="result">
<p>专属链接{{ giftUrl }}</p>
<img :src="qrUrl" class="qr" />
</div>
</el-card>
</div>
</template>
<script>
import { createConfession, getQrUrl } from '../api/confession';
export default {
data() {
return {
form: {
title: '',
message: '',
imageUrl: ''
},
confession: null
};
},
computed: {
giftUrl() {
return this.confession ? `${window.location.origin}/#/gift/${this.confession.code}` : '';
},
qrUrl() {
return this.confession ? getQrUrl(this.confession.code) : '';
}
},
methods: {
submit() {
createConfession({
orderId: this.$route.params.orderId,
...this.form
}).then((res) => {
this.confession = res.data.data;
this.$message.success('生成成功');
});
}
}
};
</script>
<style scoped>
.page {
padding: 20px;
}
.content {
margin-top: 12px;
}
.result {
margin-top: 16px;
}
.qr {
width: 200px;
height: 200px;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<div class="page">
<el-card>
<h2>{{ confession.title || '告白弹幕' }}</h2>
<p>{{ confession.message }}</p>
<img v-if="confession.imageUrl" :src="confession.imageUrl" class="image" />
</el-card>
<el-card class="content">
<div class="barrage-wall">
<span
v-for="item in barrages"
:key="item.id"
class="barrage-item"
:style="{ top: item.top + 'px', animationDuration: item.duration + 's' }"
>
{{ item.sender || '匿名' }}{{ item.content }}
</span>
</div>
<div class="send">
<el-input v-model="form.sender" placeholder="你的名字" class="input" />
<el-input v-model="form.content" placeholder="送上一句祝福" class="input" />
<el-button type="primary" @click="send">发送</el-button>
</div>
</el-card>
</div>
</template>
<script>
import { getConfession, listBarrages, sendBarrage } from '../api/confession';
export default {
data() {
return {
confession: {},
barrages: [],
form: { sender: '', content: '' },
timer: null
};
},
created() {
this.loadAll();
this.timer = setInterval(this.loadBarrages, 4000);
},
beforeDestroy() {
clearInterval(this.timer);
},
methods: {
loadAll() {
const code = this.$route.params.code;
getConfession(code).then((res) => {
this.confession = res.data.data || {};
});
this.loadBarrages();
},
loadBarrages() {
const code = this.$route.params.code;
listBarrages(code).then((res) => {
const list = res.data.data || [];
this.barrages = list.map((item) => ({
...item,
top: Math.floor(Math.random() * 220),
duration: 8 + Math.random() * 4
}));
});
},
send() {
if (!this.form.content) {
this.$message.warning('请输入弹幕内容');
return;
}
sendBarrage(this.$route.params.code, this.form).then(() => {
this.$message.success('发送成功');
this.form.content = '';
this.loadBarrages();
});
}
}
};
</script>
<style scoped>
.page {
padding: 20px;
}
.content {
margin-top: 12px;
}
.image {
max-width: 100%;
margin-top: 12px;
border-radius: 6px;
}
.barrage-wall {
position: relative;
height: 260px;
overflow: hidden;
background: #fff7f7;
border-radius: 8px;
margin-bottom: 12px;
}
.barrage-item {
position: absolute;
white-space: nowrap;
left: 100%;
animation-name: fly;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.send {
display: flex;
align-items: center;
}
.input {
margin-right: 10px;
}
@keyframes fly {
from { left: 100%; }
to { left: -100%; }
}
</style>

129
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,129 @@
<template>
<div class="page">
<el-container>
<el-header class="header">
<div class="logo">植愈线上花店</div>
<div>
<el-button type="text" @click="$router.push('/orders')">我的订单</el-button>
<el-button type="text" @click="$router.push('/profile')">个人中心</el-button>
<el-button type="text" @click="$router.push('/admin')">后台管理</el-button>
<el-button type="primary" @click="$router.push('/login')">登录</el-button>
</div>
</el-header>
<el-main>
<el-card>
<div class="filters">
<el-select v-model="categoryId" clearable placeholder="全部分类" @change="loadProducts">
<el-option
v-for="item in categories"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-input v-model="keyword" placeholder="搜索花束" class="search" @keyup.enter.native="filterProducts" />
<el-button type="primary" @click="filterProducts">搜索</el-button>
</div>
</el-card>
<el-row :gutter="16" class="product-list">
<el-col v-for="item in filteredProducts" :key="item.id" :span="6">
<el-card :body-style="{ padding: '12px' }" class="product-card" @click.native="goDetail(item.id)">
<img :src="item.coverUrl || placeholder" class="product-cover" />
<div class="product-name">{{ item.name }}</div>
<div class="product-price">{{ item.price }}</div>
</el-card>
</el-col>
</el-row>
</el-main>
</el-container>
</div>
</template>
<script>
import { listProducts, listCategories } from '../api/product';
export default {
data() {
return {
products: [],
filteredProducts: [],
categories: [],
categoryId: null,
keyword: '',
placeholder: 'https://via.placeholder.com/300x200?text=Flower'
};
},
created() {
this.loadCategories();
this.loadProducts();
},
methods: {
loadCategories() {
listCategories().then((res) => {
this.categories = res.data.data || [];
});
},
loadProducts() {
listProducts({ categoryId: this.categoryId }).then((res) => {
this.products = res.data.data || [];
this.filteredProducts = this.products;
});
},
filterProducts() {
const key = this.keyword.trim();
if (!key) {
this.filteredProducts = this.products;
return;
}
this.filteredProducts = this.products.filter((item) => item.name.includes(key));
},
goDetail(id) {
this.$router.push(`/product/${id}`);
}
}
};
</script>
<style scoped>
.page {
min-height: 100vh;
}
.header {
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
font-size: 20px;
font-weight: bold;
}
.filters {
display: flex;
align-items: center;
}
.search {
margin: 0 12px;
width: 240px;
}
.product-list {
margin-top: 16px;
}
.product-card {
cursor: pointer;
}
.product-cover {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 4px;
}
.product-name {
margin-top: 8px;
font-weight: 600;
}
.product-price {
color: #f56c6c;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="auth-page">
<el-card class="card">
<h3>登录</h3>
<el-form :model="form">
<el-form-item label="账号">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-button type="primary" @click="submit">登录</el-button>
<el-button type="text" @click="$router.push('/register')">没有账号注册</el-button>
</el-form>
</el-card>
</div>
</template>
<script>
import { login } from '../api/auth';
export default {
data() {
return {
form: { username: '', password: '' }
};
},
methods: {
submit() {
login(this.form).then((res) => {
const data = res.data.data;
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
this.$message.success('登录成功');
this.$router.push('/');
});
}
}
};
</script>
<style scoped>
.auth-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
width: 400px;
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div class="page">
<el-card>
<el-button type="text" @click="$router.push('/')">返回首页</el-button>
</el-card>
<el-card class="content">
<el-table :data="orders">
<el-table-column prop="order.orderNo" label="订单号" />
<el-table-column prop="order.status" label="状态" />
<el-table-column prop="order.totalAmount" label="金额" />
<el-table-column label="操作">
<template slot-scope="scope">
<el-button v-if="scope.row.order.status === 'CREATED'" type="text" @click="pay(scope.row.order.id)">支付</el-button>
<el-button v-if="scope.row.order.status === 'CREATED'" type="text" @click="cancel(scope.row.order.id)">取消</el-button>
<el-button type="text" @click="goConfession(scope.row.order.id)">告白弹幕</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script>
import { listOrders, payOrder, cancelOrder } from '../api/order';
export default {
data() {
return {
orders: []
};
},
created() {
this.loadOrders();
},
methods: {
loadOrders() {
listOrders().then((res) => {
this.orders = res.data.data || [];
});
},
pay(id) {
payOrder(id).then(() => {
this.$message.success('支付成功');
this.loadOrders();
});
},
cancel(id) {
cancelOrder(id).then(() => {
this.$message.success('已取消');
this.loadOrders();
});
},
goConfession(orderId) {
this.$router.push(`/confession/create/${orderId}`);
}
}
};
</script>
<style scoped>
.page {
padding: 20px;
}
.content {
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="page">
<el-card>
<el-button type="text" @click="$router.push('/')">返回首页</el-button>
</el-card>
<el-card class="content">
<el-row :gutter="20">
<el-col :span="10">
<img :src="product.coverUrl || placeholder" class="cover" />
</el-col>
<el-col :span="14">
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
<div class="price">{{ product.price }}</div>
<el-input-number v-model="quantity" :min="1" :max="product.stock || 99" />
<div class="section">
<el-select v-model="addressId" placeholder="选择收货地址">
<el-option
v-for="addr in addresses"
:key="addr.id"
:label="formatAddress(addr)"
:value="addr.id"
/>
</el-select>
<el-button type="primary" @click="submitOrder">立即购买</el-button>
</div>
</el-col>
</el-row>
</el-card>
</div>
</template>
<script>
import { getProduct } from '../api/product';
import { listAddresses } from '../api/address';
import { createOrder } from '../api/order';
export default {
data() {
return {
product: {},
quantity: 1,
addresses: [],
addressId: null,
placeholder: 'https://via.placeholder.com/400x300?text=Flower'
};
},
created() {
this.loadDetail();
this.loadAddresses();
},
methods: {
loadDetail() {
getProduct(this.$route.params.id).then((res) => {
this.product = res.data.data || {};
});
},
loadAddresses() {
listAddresses().then((res) => {
this.addresses = res.data.data || [];
const def = this.addresses.find((item) => item.isDefault);
if (def) {
this.addressId = def.id;
}
});
},
formatAddress(addr) {
return `${addr.recipientName} ${addr.phone} ${addr.province || ''}${addr.city || ''}${addr.district || ''}${addr.detail || ''}`;
},
submitOrder() {
if (!this.addressId) {
this.$message.warning('请选择收货地址');
return;
}
createOrder({
addressId: this.addressId,
items: [{ productId: this.product.id, quantity: this.quantity }]
}).then((res) => {
this.$message.success('下单成功');
const order = res.data.data.order;
this.$router.push(`/orders?highlight=${order.id}`);
});
}
}
};
</script>
<style scoped>
.page {
padding: 20px;
}
.content {
margin-top: 16px;
}
.cover {
width: 100%;
border-radius: 8px;
object-fit: cover;
}
.price {
color: #f56c6c;
font-size: 20px;
margin: 12px 0;
}
.section {
margin-top: 20px;
display: flex;
align-items: center;
}
.section .el-select {
margin-right: 12px;
width: 300px;
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="page">
<el-card>
<el-button type="text" @click="$router.push('/')">返回首页</el-button>
</el-card>
<el-card class="content">
<h3>个人信息</h3>
<el-form :model="profile">
<el-form-item label="昵称">
<el-input v-model="profile.nickname" />
</el-form-item>
<el-form-item label="电话">
<el-input v-model="profile.phone" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="profile.email" />
</el-form-item>
</el-form>
<el-button type="primary" @click="saveProfile">保存</el-button>
</el-card>
<el-card class="content">
<div class="header-row">
<h3>收货地址</h3>
<el-button type="primary" @click="showDialog = true">新增地址</el-button>
</div>
<el-table :data="addresses">
<el-table-column prop="recipientName" label="收货人" />
<el-table-column prop="phone" label="电话" />
<el-table-column prop="detail" label="详细地址" />
<el-table-column label="默认">
<template slot-scope="scope">
<el-tag v-if="scope.row.isDefault" type="success">默认</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template slot-scope="scope">
<el-button type="text" @click="editAddress(scope.row)">编辑</el-button>
<el-button type="text" @click="removeAddress(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog title="地址" :visible.sync="showDialog">
<el-form :model="addressForm">
<el-form-item label="收货人">
<el-input v-model="addressForm.recipientName" />
</el-form-item>
<el-form-item label="电话">
<el-input v-model="addressForm.phone" />
</el-form-item>
<el-form-item label="省市区">
<el-input v-model="addressForm.province" placeholder="省" />
<el-input v-model="addressForm.city" placeholder="市" />
<el-input v-model="addressForm.district" placeholder="区" />
</el-form-item>
<el-form-item label="详细地址">
<el-input v-model="addressForm.detail" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="addressForm.isDefault">设为默认</el-checkbox>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="saveAddress">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { me } from '../api/auth';
import { listAddresses, createAddress, updateAddress, deleteAddress } from '../api/address';
import http from '../api/http';
export default {
data() {
return {
profile: { nickname: '', phone: '', email: '' },
addresses: [],
showDialog: false,
addressForm: {}
};
},
created() {
this.loadProfile();
this.loadAddresses();
},
methods: {
loadProfile() {
me().then((res) => {
this.profile = res.data.data || {};
});
},
saveProfile() {
http.put('/users/me', this.profile).then(() => {
this.$message.success('保存成功');
});
},
loadAddresses() {
listAddresses().then((res) => {
this.addresses = res.data.data || [];
});
},
editAddress(row) {
this.addressForm = { ...row };
this.showDialog = true;
},
removeAddress(id) {
deleteAddress(id).then(() => {
this.$message.success('删除成功');
this.loadAddresses();
});
},
saveAddress() {
const api = this.addressForm.id ? updateAddress(this.addressForm.id, this.addressForm) : createAddress(this.addressForm);
api.then(() => {
this.$message.success('保存成功');
this.showDialog = false;
this.addressForm = {};
this.loadAddresses();
});
}
}
};
</script>
<style scoped>
.page {
padding: 20px;
}
.content {
margin-top: 12px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="auth-page">
<el-card class="card">
<h3>注册</h3>
<el-form :model="form">
<el-form-item label="账号">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-form-item label="昵称">
<el-input v-model="form.nickname" />
</el-form-item>
<el-button type="primary" @click="submit">注册</el-button>
<el-button type="text" @click="$router.push('/login')">已有账号登录</el-button>
</el-form>
</el-card>
</div>
</template>
<script>
import { register } from '../api/auth';
export default {
data() {
return {
form: { username: '', password: '', nickname: '' }
};
},
methods: {
submit() {
register(this.form).then((res) => {
const data = res.data.data;
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
this.$message.success('注册成功');
this.$router.push('/');
});
}
}
};
</script>
<style scoped>
.auth-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
width: 400px;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div>
<el-row :gutter="16">
<el-col :span="8">
<el-card>
<div>订单总数</div>
<div class="value">{{ stats.totalOrders }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<div>用户总数</div>
<div class="value">{{ stats.totalUsers }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<div>销售额</div>
<div class="value">{{ stats.totalSales }}</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script>
import { dashboard } from '../../api/admin';
export default {
data() {
return { stats: { totalOrders: 0, totalUsers: 0, totalSales: 0 } };
},
created() {
dashboard().then((res) => {
this.stats = res.data.data || this.stats;
});
}
};
</script>
<style scoped>
.value {
font-size: 22px;
margin-top: 6px;
color: #409eff;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<el-container class="layout">
<el-aside width="200px" class="aside">
<div class="title">后台管理</div>
<el-menu router>
<el-menu-item index="/admin">仪表盘</el-menu-item>
<el-menu-item index="/admin/products">商品管理</el-menu-item>
<el-menu-item index="/admin/orders">订单管理</el-menu-item>
<el-menu-item index="/admin/users">用户管理</el-menu-item>
<el-menu-item index="/admin/reviews">评价管理</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<el-button type="text" @click="$router.push('/')">返回前台</el-button>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<style scoped>
.layout {
min-height: 100vh;
}
.aside {
background: #2d3a4b;
color: #fff;
}
.title {
padding: 20px;
font-size: 18px;
}
.header {
background: #fff;
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div>
<el-table :data="orders">
<el-table-column prop="order.orderNo" label="订单号" />
<el-table-column prop="order.status" label="状态" />
<el-table-column prop="order.totalAmount" label="金额" />
<el-table-column label="操作">
<template slot-scope="scope">
<el-select v-model="scope.row.order.status" placeholder="状态">
<el-option label="已创建" value="CREATED" />
<el-option label="已支付" value="PAID" />
<el-option label="已发货" value="SHIPPED" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="已取消" value="CANCELED" />
</el-select>
<el-button type="text" @click="update(scope.row.order)">保存</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import { adminOrders } from '../../api/admin';
export default {
data() {
return { orders: [] };
},
created() {
this.load();
},
methods: {
load() {
adminOrders.list().then((res) => {
this.orders = res.data.data || [];
});
},
update(order) {
adminOrders.updateStatus(order.id, { status: order.status }).then(() => {
this.$message.success('更新成功');
this.load();
});
}
}
};
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div>
<el-button type="primary" @click="openDialog()">新增商品</el-button>
<el-table :data="products" style="margin-top: 12px;">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="商品名" />
<el-table-column prop="price" label="价格" />
<el-table-column prop="stock" label="库存" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作">
<template slot-scope="scope">
<el-button type="text" @click="openDialog(scope.row)">编辑</el-button>
<el-button type="text" @click="remove(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog title="商品" :visible.sync="showDialog">
<el-form :model="form">
<el-form-item label="名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="价格">
<el-input v-model="form.price" />
</el-form-item>
<el-form-item label="库存">
<el-input v-model="form.stock" />
</el-form-item>
<el-form-item label="封面">
<el-input v-model="form.coverUrl" />
</el-form-item>
<el-form-item label="描述">
<el-input type="textarea" v-model="form.description" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="上架" value="ON" />
<el-option label="下架" value="OFF" />
</el-select>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { adminProducts } from '../../api/admin';
export default {
data() {
return {
products: [],
showDialog: false,
form: {}
};
},
created() {
this.load();
},
methods: {
load() {
adminProducts.list().then((res) => {
this.products = res.data.data || [];
});
},
openDialog(row) {
this.form = row ? { ...row } : { status: 'ON', stock: 0 };
this.showDialog = true;
},
save() {
const action = this.form.id
? adminProducts.update(this.form.id, this.form)
: adminProducts.create(this.form);
action.then(() => {
this.$message.success('保存成功');
this.showDialog = false;
this.load();
});
},
remove(id) {
adminProducts.remove(id).then(() => {
this.$message.success('删除成功');
this.load();
});
}
}
};
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div>
<el-table :data="reviews">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="productId" label="商品ID" />
<el-table-column prop="rating" label="评分" />
<el-table-column prop="content" label="内容" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作">
<template slot-scope="scope">
<el-select v-model="scope.row.status">
<el-option label="待审核" value="PENDING" />
<el-option label="通过" value="APPROVED" />
<el-option label="拒绝" value="REJECTED" />
</el-select>
<el-button type="text" @click="update(scope.row)">保存</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import { adminReviews } from '../../api/admin';
export default {
data() {
return { reviews: [] };
},
created() {
this.load();
},
methods: {
load() {
adminReviews.list().then((res) => {
this.reviews = res.data.data || [];
});
},
update(row) {
adminReviews.updateStatus(row.id, { status: row.status }).then(() => {
this.$message.success('更新成功');
this.load();
});
}
}
};
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div>
<el-table :data="users">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="账号" />
<el-table-column prop="nickname" label="昵称" />
<el-table-column prop="role" label="角色" />
<el-table-column label="状态">
<template slot-scope="scope">
<el-tag v-if="scope.row.disabled" type="danger">禁用</el-tag>
<el-tag v-else type="success">正常</el-tag>
</template>
</el-table-column>
<el-table-column label="操作">
<template slot-scope="scope">
<el-button type="text" @click="toggle(scope.row)">
{{ scope.row.disabled ? '启用' : '禁用' }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import { adminUsers } from '../../api/admin';
export default {
data() {
return { users: [] };
},
created() {
this.load();
},
methods: {
load() {
adminUsers.list().then((res) => {
this.users = res.data.data || [];
});
},
toggle(user) {
adminUsers.updateStatus(user.id, { disabled: !user.disabled }).then(() => {
this.$message.success('更新成功');
this.load();
});
}
}
};
</script>

10
frontend/vue.config.js Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
};

252
开题报告.md Normal file
View File

@@ -0,0 +1,252 @@
# 大连科技学院
# 毕业设计(论文)开题报告
学院 信息科学与技术学院
专业班级 网络工程专升本24-1
学生姓名 赵晓函
学生学号 2406490104
指导教师 张旗
导师职称 教授
# 1 选题的意义和研究现状
# 1.1 选题的意义
随着现代社会生活节奏变快,而且人们对精神层面追求不断提升,植物与花卉不再只是充当环境的点缀,更成了倾诉情感、舒缓压力、增添生活快乐感的主要载体,也就是所谓的“植物治愈”,面对这一背景的时候,把赠送花束当作一种经典的情感表达法,其市场需求持续上扬,互联网技术的快速进步催生了线上花店的崛起,它以其便捷、多样、创新的属性,迅速得到了消费者的喜爱,给传统花卉行业赋予了新的活力。
就目前而言,多数线上花店还是局限于传统的商品展示与交易途径,虽然在促进消费便捷性上有一定成效,但在深层次情感表达和个性化需求的落实上依旧存在明显不足简单的附言卡片没法充分承载复杂又细腻的情感,赠花过程当中仪式感和互动性也相对匮乏,该状况让消费者日益上扬的个性化情感表达需求,跟传统赠花方式的局限性形成了一个不容小觑的矛盾。
按照以上背景,本课题“植愈线上花店的设计与实现”就此出现,其主要目标是突破传统线上花店模式的局限,把数字技术跟用户情感深度予以结合,缔造一个不只是满
足基础采购要求,更能高效传递情谊与温暖的互动平台,本设计的最大创新亮点为引入了有独特风格的“告白弹幕”功能。用户在花束购买事宜结束后,可为收花者定制一张带有二维码的专属卡片,收花人借助扫描二维码进入私人定制页面,即可瞧见送花人用心准备的文字跟图片,还能去体验动态飘动的弹幕祝福,此功能显著强化了赠花过程的互动性及仪式感,让花束并非只是情感的传递介质,更变成一个装载多样故事与温馨回忆的互动载体。
就理论层面而言,本研究摸索了“互联网+情感消费”的全新模式,提出了一种利用数字技术提升传统消费品附加值与用户情感体验的实践设计思路,从实际实践的角度看本项目实施能为线上花店行业明确一条差异化创新的竞争路径,不但可以辅助企业提高用户黏性与品牌价值,还能赋予花卉更多温暖抚慰的功能,让其在节奏飞一般的城市生活中,成为人与人情感相连的桥梁,传送更厚重的慰藉与感动。
# 1.2本课题所涉及问题在国内外设计或研究的现状
# 1.2.1 国内外研究现状
国内线上花店发展起始阶段采用简单的网站展示、下单模式跃进到功能更显完善的电商平台好些研究围绕系统架构的优化与实现展开何彪等人审视了基于JSP技术
的网上花店系统的开发要点,着重处理了商品展示、购物车、订单管理等基础电商功能。
跟着技术的发展,由 Spring Boot、Vue.js 等前后端分离架构构建而成的系统渐渐成为主
流,这类研究的目标是提升系统性能、可维护性以及用户体验,在商业模式范畴内,国内
市场出现了诸如“鲜花订阅”“主题花束定制”的创新服务,满足了用户对便捷、个性
化的基本需求点。多数研究及商业实践还是把重点放在交易流程的优化上,对于怎样利
用技术手段增强礼物的情感价值与互动体验,相关探索目前处于初级水平,缺乏类似
“告白弹幕”这般将实体礼物与数字互动深度整合的创新应用。
国外线上花店的起步比国内更早,市场也愈发成熟,其平台在用户体验设计、品牌故
事营造、供应链管理等方面展现出出色水平诸多国际知名的线上花店1-800-
Flowers.com、FTD不只提供全球配送这项服务还凭借大数据分析用户偏好实施精
准营销以进行个性化推荐以提升用户购物体验,在情感表达的范畴内,国外平台虽说也
给出贺卡定制、视频留言等服务,然而其形式呈现相对固定状态,把二维码作为数字内容
进入途径,再结合实时弹幕这种具有强社交、强互动特性的形式来增强赠礼体验,研究和
应用案例还是比较少见,国外研究更多把关注点放在电子商务流程的整体优化和智能化
上,而在“礼物”这一特殊商品“情感互动”属性的数字化创新活动里,仍然有很大的
探索空间可挖掘。
当前,国内外线上花店的电商功能已发展得相当成熟,然而,在如何运用新兴互动技术来深化情感体验这一维度上,仍有可观的创新潜力。本课题提出的“告白弹幕”功能,正是基于这一观察的尝试。它将实体花束与一个私密且充满互动性的数字情感空间有机地联系起来,可以视作对现有赠礼模式的一次体验升级和积极补充。更重要的是,这种模式精准捕捉了年轻消费群体对个性化表达、仪式感营造乃至社交分享的内在渴望。基于此,我们有理由相信,该功能有望为线上花店行业开辟出新的增值服务领域,其前沿探索价值与实际应用前景均十分明朗。
# 2 课题设计或研究的内容、预期目标和实施计划
# 2.1 要设计或研究的主要内容方案论证分析
# 2.1.1 主要研究内容
本课题的核心任务是设计并实现一个集商品交易、情感互动与品牌理念传达于一体的“植愈线上花店”。系统开发将遵循“需求分析-系统设计-编码实现-测试与部署”的软件工程流程,并采用模块化设计思想,将系统划分为前端用户界面和后端管理系统两大部分,服务于普通用户(订花人)、花店管理员和收花人三类角色。系统的主要研究与实现内容详细规划如下:
1.普通用户(订花人)拟设定以下功能模块:
(1) 登录注册:提供用户账户的注册与登录功能,保障用户信息安全。
(2) 商品浏览:分类展示花束商品,提供搜索、筛选和商品详情查看功能。
(3) 订单管理:用户可以创建、查看、修改和取消自己的订单,跟踪订单状态。
(4) 告白弹幕: 核心功能模块。用户在下单时可编辑专属页面的内容, 包括文字、图片, 并设置预设弹幕。
(5) 二维码生成: 系统根据用户编辑的内容, 自动生成一个唯一的二维码, 用于打印
在实体卡片上。
(6) 支付模块:模拟在线支付功能。
(7) 个人中心: 管理个人信息、收货地址、查看历史订单等。
(8) 评价: 用户可对已完成的订单进行评价和反馈。
2.收花人拟设定以下功能模块:
(1) 扫码查看弹幕:收花人通过手机扫描实体卡片上的二维码,直接访问专属的告白弹幕页面。
(2) 弹幕互动: 在查看页面的同时, 收花人可以发送实时弹幕, 与送花人进行情感互动(可通过分享链接实现)。
(3) 分享:用户能够将这个专属页面的链接,通过各类社交平台轻松分享给亲友或赠送者。
3.花店管理员拟设定以下功能模块
(1) 数据仪表盘:用户登录后,首页会以一系列卡片和图表的形式,直观呈现店铺的核心运营状况,例如当天的订单总量、实时销售额以及当前待处理的订单数量等关键指标。
(2商品管理支持新增、编辑、删除商品。可录入丰富的商品信息。
(3) 订单管理:集中展示所有用户订单,支持按订单号、用户昵称、进行筛选和查询。
(4) 用户管理:查看平台所有注册用户的基本信息。
(5评价管理查看、审核用户提交的商品评价。
总体功能图如图1所示。
![image](https://cdn-mineru.openxlab.org.cn/result/2026-01-13/8cc9e273-9160-466a-ae87-2945a3f35f62/f0430523abc8fbf160129760100502c9ed84118264228123c43cb7c3711e4514.jpg)
图2.1 总体功能图
# 2.1.2需求分析
在“植愈”线上花店的开发过程中,需求分析主要围绕三类用户角色展开:管理员、订花人(用户)和收花人。
第一,管理员的需求主要集中在系统管理上。管理员需要通过安全的登录机制进入后台,管理用户信息和订单数据,管理商品信息(如上下架、库存),配置和管理营销活动,查看和处理订单(如确认、发货、完成),发布和管理公告,以及监控系统数据,确保平台的正常运作和配送效率。
第二,订花人的需求集中在便捷的购花体验上。用户需要通过注册和登录进入系统,并能够在个人中心修改信息、查看订单状态。用户需要通过系统方便地下单并完成支付,并通过创建和编辑个性化祝福信息(如告白弹幕、贺卡)获得创新的情感表达方式。
第三,收花人的需求则侧重于创新的互动体验。收花人需要通过扫描花束附带的二维码,便捷地查看订花人留下的专属告白弹幕或祝福视频。平台应支持收花人进行互动,并能将这个充满惊喜的页面分享到社交媒体。平台的设计需确保收花人能够获得惊喜和感动,提升情感传递的效果。
# 2.1.3 技术架构及路线设计
后端技术路线:本系统的后端将采用 Spring Boot 作为核心框架。Spring Boot 具有简化配置、提高开发效率的优势,同时可以与 Spring 生态系统的其他组件无缝集成,例如 Spring MVC用于构建健壮的 Web 应用和实现各项业务功能模块。
前端技术路线前端开发将基于Vue.js2.x框架。Vue.js轻量、高效且支持组件化开发能够显著提高用户界面的开发效率和维护性。
数据库技术路线数据库部分将使用MySQL作为数据存储的解决方案。MySQL具有稳定性高、性能优良和支持大规模数据处理的优势适合平台复杂的用户和业务数据
管理需求。
# 2.2本课题选题特色及预期的目标
# 2.2.1 选题特色
第一,多角色分工明确,功能场景化设计。本系统针对核心参与角色(订花用户、收花人、花店管理员)设计了与业务场景深度绑定的功能模块。每个角色都可以通过专属的功能界面完成操作,满足其在赠礼流程中的个性化需求。例如,订花用户可以进行商品选购和“告白弹幕”定制,收花人可以通过扫码体验情感互动,管理员则能够全面管理商品、订单及用户数据。通过多角色协同,系统构建了从选购、定制、传递到互动的完整服务闭环,极大提升了情感传递的深度与广度。
第二系统采用前后端分离的开发模式前端基于Vue.js框架后端采用Spring Boot。此架构不仅提高了系统的灵活性、可扩展性和开发效率更为核心创新功能“告白弹幕”的动态交互体验提供了坚实的技术支撑保障了复杂前端效果的流畅实现。
第三,赠礼体验的深度情感化与互动化创新。与传统的线上花店交易模式相比,平台通过独创的“告白弹幕”功能,实现了实体礼物与数字情感空间的深度融合。用户定制的祝福内容通过二维码与实体花束关联,收花人扫码即可进入专属的互动页面,体验动
态弹幕带来的仪式感和惊喜感。这极大地突破了传统赠礼方式的情感表达局限,让花束不再仅仅是商品,更成为承载故事与回忆的互动媒介。
# 2.2.2 预期目标
第一,实现植愈主题的线上花店平台化系统。本系统将整合商品交易与情感互动功能,订花用户可通过平台进行花束选购、“告白弹幕”定制及下单,收花人可通过扫码体验互动,同时管理员也能高效管理商品、订单及用户数据。
第二提升用户体验与互动流畅度。通过Vue.js和Spring Boot的高效架构设计平台将为用户提供流畅的使用体验确保操作简单、响应迅速尤其保障“告白弹幕”等核心交互功能的稳定与顺滑。
第三,建立可扩展的系统架构。系统在设计时,将充分考虑未来功能扩展的需求,如新增主题系列或社交分享功能,为平台的长远发展奠定技术基础。
# 2.3 本课题研究方法及采用技术
本课题主要采用文献资料法和系统设计法相结合的研究方法。在系统开发初期,通过文献资料法对国内外线上花店的发展现状、情感消费趋势以及技术实现方案进行深入调研,确保本系统的设计能够借鉴已有成果,并避免常见的技术和功能问题。同时,通过
查阅大量关于“植物治愈”理念、情感表达需求和用户体验的相关文献,了解用户赠礼流程与收花人互动体验,以此为平台的功能设计提供理论依据。
在实际开发过程中采用系统设计法进行平台的架构设计与功能实现。系统前端基于Vue.js构建为“告白弹幕”等核心功能提供流畅的交互体验后端则采用Spring Boot框架实现各项功能模块。数据存储方面选择MySQL数据库进行数据持久化管理确保系统具备高效的数据库访问性能和数据管理能力。通过文献资料法的理论支持与系统设计法的技术实现相结合确保本课题的开发具备科学性和可操作性最终实现一个功能完善且具有情感互动特色的线上花店系统。
# 2.4可行性分析
技术可行性本系统的开发将基于一系列成熟、主流的技术框架和工具如Java开发语言、Spring Boot 框架、Vue.js 框架及MySQL数据库。这些技术均拥有庞大的开源社区支持和完善的官方文档能够完全支持“植愈线上花店”所有预设功能的开发与实现保障系统的高效稳定运行。通过Spring Boot 框架可以快速搭建灵活、可靠的后台服务实现商品管理、订单处理、数据统计等复杂业务逻辑。同时成熟的前端技术栈Vue.js全家桶足以构建交互友好、体验流畅的用户界面例如“告白弹幕”内容的动态编辑与展示
从技术角度来看,本研究方案是完全可行的。
经济可行性:在经济方面,本系统的开发和部署成本相对较低。项目所采用的核心技术框架和工具,包括 Spring Boot、Vue.js、MySQL、Maven 等,均为开源软件,无需支付高昂的软件许可费用,极大地降低了开发成本。硬件方面,项目的开发工作在个人计算机上即可完成,无需购置额外的昂贵设备。在项目部署初期,可以选用主流云服务商提供的低成本或免费试用套餐,满足系统上线的需求。系统上线后,其信息化、自动化的特性能够有效提升花店的运营效率,减少人力成本,因此系统的长期维护成本也相对较低。总体而言,本项目的开发和运行成本合理,具有很高的经济可行性。
社会可行性:随着社会经济的发展和人们对生活品质追求的提升,单纯的物质消费已无法满足人们的需求。本项目以“植愈”为理念,精准契合了当代都市人群寻求精神慰藉和情感寄托的社会心理。核心的“告白弹幕”功能,将传统的赠花行为升级为一种新颖、有趣的互动体验,满足了年轻消费群体对于个性化、仪式感和社交分享的强烈需求。该系统的实现,不仅能为线上花店行业提供一种差异化的竞争优势,也能在人与人之间传递更多温暖与情感,因此,社会对该系统的需求真实存在,社会可行性强。
2.3 本课题实施计划
<table><tr><td>周数</td><td>进度计划</td></tr><tr><td>第1周</td><td>确定毕业设计题目,在网络上对“植愈线上花店的设计与实现”进行调研</td></tr><tr><td>第2周</td><td>根据前期的调研情况,查阅相关资料完成开题报告撰写</td></tr><tr><td>第3周</td><td>选择与课题相关的外文文献,完成外文翻译。进行前期资料自查,进行系统可行性分析和需求分析</td></tr><tr><td>第4周</td><td>完成毕设前期检查。依据系统功能需求和业务流程分析,完成用例图和用例说明</td></tr><tr><td>第5周</td><td>进行系统分析,以用例图为基础进行类图、活动图和顺序图的绘制,确保系统的一致性和可维护性</td></tr><tr><td>第6周</td><td>完成数据库设计、界面设计,根据反馈意见进行修改</td></tr><tr><td>第7周</td><td>系统实现,按功能模块进行编码</td></tr><tr><td>第8周</td><td>完成毕设中期检查。系统实现,按功能模块进行编码</td></tr><tr><td>第9周</td><td>系统测试,测试本系统各业务功能运行是否正常,验证功能需求是否都符合规范要求。完成论文主体部分</td></tr><tr><td>第10周</td><td>按照系统测试结果修改代码完善功能,并完成论文剩余相关内容编写</td></tr><tr><td>第11周</td><td>提交论文初稿,根据反馈意见修改论文</td></tr><tr><td>第12周</td><td>继续修改论文,完成论文查重稿定稿。检查系统功能,为软件验收做好准备</td></tr><tr><td>第13周</td><td>进行软件验收,参加校级论文查重,根据论文内容制作答辩PPT</td></tr></table>
# 3 主要参考文献
[1] 何彪.基于JSP的网上花店系统的开发与设计[J].电脑知识与技术,2023,19(1):43-45,52.
[2] 武卫翔,吴雪宁,童欣,秦睿,陈海燕.基于Java的第三方物流协同订单管理系统的设计与实现[J].物流科技,2024,42(12):77-81.
[3] 李兴华, 马云涛. Spring 开发实战: 视频讲解版[M]. 第 1 版. 北京人民邮电出版社, 2023:125-127.
[4] 李江霞.基于MYSQL 图书管理系统数据库设计[J].轻工科技,2025,41(5):75-77,97.
[5] 陈联. JavaWeb 开发中前后端分离技术的研究与应用[J]. 中文科技期刊数据库(文摘版)
工程技术,2025,41(3):208-211.
[6] 王伟,丁佳浩,叶红阳,朱博文,史文阳.基于前后端分离架构的某企业档案管理系统设计与实现[J].现代信息科技,2024,38(6):11-14.
[7]曲锦旭.前后端分离模式在Java开发中的应用研究[J].信息与电脑,2024,36(8):19-21.
[8] 董宁,江平.Vue.js前端开发框架应用:微课版[M].第1版.北京人民邮电出版社,2024.
[9] 彭勇.基于 Spring Boot 与 Vue.js 的轻量化 Web 应用架构设计与优化研究[J].计算机应用文摘,2025,41(16):133-135.
[10] YANG N. Personal Health Information Service Platform Based onVue.js+SpringBoot[J]. The Frontiers of Society, Science and Technology, 2025, 7(5):77-81.