From 43d2da77bf6fe43a19c227903ddc0e2e4d54619a Mon Sep 17 00:00:00 2001 From: N1KO Date: Tue, 17 Mar 2026 14:20:30 +0800 Subject: [PATCH] feat --- Dockerfile | 2 +- .../music/client/AbstractOrderClient.java | 7 +- .../controller/AdminLicenseController.java | 58 +++ .../music/controller/BuyPageController.java | 29 ++ .../music/controller/CallbackController.java | 116 ++++- .../music/controller/LicenseController.java | 40 ++ .../music/controller/OrderController.java | 78 ++- .../music/dao/entity/LicenseCodeEntity.java | 72 +++ .../music/dao/mapper/LicenseCodeMapper.java | 14 + .../music/domain/res/license/ActivateRes.java | 48 ++ .../music/domain/res/pay/OrderRes.java | 17 +- .../music/service/ILicenseService.java | 51 ++ .../music/service/IOrderService.java | 9 + .../service/impl/LicenseServiceImpl.java | 172 +++++++ .../music/service/impl/OrderServiceImpl.java | 13 + src/main/resources/sql/t_license_code.sql | 23 + src/main/resources/templates/buy.html | 446 ++++++++++++++++++ 17 files changed, 1162 insertions(+), 33 deletions(-) create mode 100644 src/main/java/top/baogutang/music/controller/AdminLicenseController.java create mode 100644 src/main/java/top/baogutang/music/controller/BuyPageController.java create mode 100644 src/main/java/top/baogutang/music/controller/LicenseController.java create mode 100644 src/main/java/top/baogutang/music/dao/entity/LicenseCodeEntity.java create mode 100644 src/main/java/top/baogutang/music/dao/mapper/LicenseCodeMapper.java create mode 100644 src/main/java/top/baogutang/music/domain/res/license/ActivateRes.java create mode 100644 src/main/java/top/baogutang/music/service/ILicenseService.java create mode 100644 src/main/java/top/baogutang/music/service/impl/LicenseServiceImpl.java create mode 100644 src/main/resources/sql/t_license_code.sql create mode 100644 src/main/resources/templates/buy.html diff --git a/Dockerfile b/Dockerfile index 933ef63..ff9d395 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app COPY . . # 2. 使用 Maven 在容器中进行编译和打包(如果需要测试可去掉 -DskipTests) -RUN mvn clean package -DskipTests +RUN mvn clean package -DskipTests=true # ------------------------------------ # 第二阶段:使用精简的 OpenJDK 11 镜像作为运行时环境 diff --git a/src/main/java/top/baogutang/music/client/AbstractOrderClient.java b/src/main/java/top/baogutang/music/client/AbstractOrderClient.java index 4a4dad7..097d35b 100644 --- a/src/main/java/top/baogutang/music/client/AbstractOrderClient.java +++ b/src/main/java/top/baogutang/music/client/AbstractOrderClient.java @@ -41,12 +41,11 @@ public abstract class AbstractOrderClient { OrderEntity order = orderService.createOrder(userId, getPayChannel(), getAmount()); // 2.支付 AlipayTradePrecreateModel model = new AlipayTradePrecreateModel(); - model.setSubject("BAOGUTANGMUSIC"); + model.setSubject("N1KO MUSIC 会员"); model.setTotalAmount(order.getAmount().toString()); - model.setStoreId("BAOGUTANGMUSIC"); model.setTimeoutExpress("10m"); model.setOutTradeNo(order.getOrderNo()); - model.setProductCode("QR_CODE_OFFLINE"); + model.setProductCode("FACE_TO_FACE_PAYMENT"); String qrCode; try { String resultStr = AliPayApi.tradePrecreatePayToResponse(model, getNotifyUrl()) @@ -62,6 +61,8 @@ public abstract class AbstractOrderClient { orderRes.setUserId(userId); orderRes.setOrderId(order.getId()); orderRes.setOrderNo(order.getOrderNo()); + orderRes.setAmount(order.getAmount()); + orderRes.setStatus(order.getStatus()); return orderRes; } } diff --git a/src/main/java/top/baogutang/music/controller/AdminLicenseController.java b/src/main/java/top/baogutang/music/controller/AdminLicenseController.java new file mode 100644 index 0000000..9515f25 --- /dev/null +++ b/src/main/java/top/baogutang/music/controller/AdminLicenseController.java @@ -0,0 +1,58 @@ +package top.baogutang.music.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import top.baogutang.music.annos.Login; +import top.baogutang.music.domain.Results; +import top.baogutang.music.service.ILicenseService; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Map; + +/** + * 激活码管理接口(需要登录,且需要 ADMIN 角色) + * 用于批量生成激活码、撤销激活码 + * + * @author N1KO + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/admin/license") +public class AdminLicenseController { + + @Resource + private ILicenseService licenseService; + + /** + * 批量生成激活码 + * POST /api/v1/admin/license/generate + * Body: { "count": 10, "durationDays": -1, "maxActivations": 3, "remark": "首批用户" } + */ + @Login + @PostMapping("/generate") + public Results> generate(@RequestBody Map body) { + int count = Integer.parseInt(String.valueOf(body.getOrDefault("count", 1))); + int durationDays = Integer.parseInt(String.valueOf(body.getOrDefault("durationDays", -1))); + int maxActivations = Integer.parseInt(String.valueOf(body.getOrDefault("maxActivations", 3))); + String remark = (String) body.getOrDefault("remark", ""); + List codes = licenseService.generate(count, durationDays, maxActivations, remark); + return Results.ok(codes); + } + + /** + * 撤销激活码 + * POST /api/v1/admin/license/revoke + * Body: { "code": "XXXX-XXXX-XXXX-XXXX" } + */ + @Login + @PostMapping("/revoke") + public Results revoke(@RequestBody Map body) { + String code = body.getOrDefault("code", ""); + boolean result = licenseService.revoke(code); + if (result) { + return Results.ok(); + } + return Results.failed("激活码不存在或已撤销"); + } +} diff --git a/src/main/java/top/baogutang/music/controller/BuyPageController.java b/src/main/java/top/baogutang/music/controller/BuyPageController.java new file mode 100644 index 0000000..b600e2c --- /dev/null +++ b/src/main/java/top/baogutang/music/controller/BuyPageController.java @@ -0,0 +1,29 @@ +package top.baogutang.music.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import top.baogutang.music.properties.AliPayConfigProperties; + +import javax.annotation.Resource; + +/** + * 购买落地页 + * GET /buy → buy.html + * + * @author N1KO + */ +@Controller +@RequestMapping("/buy") +public class BuyPageController { + + @Resource + private AliPayConfigProperties aliPayConfigProperties; + + @GetMapping + public String buy(Model model) { + model.addAttribute("payAmount", aliPayConfigProperties.getPayAmount()); + return "buy"; + } +} diff --git a/src/main/java/top/baogutang/music/controller/CallbackController.java b/src/main/java/top/baogutang/music/controller/CallbackController.java index 8a9dfca..c7ecba3 100644 --- a/src/main/java/top/baogutang/music/controller/CallbackController.java +++ b/src/main/java/top/baogutang/music/controller/CallbackController.java @@ -2,24 +2,34 @@ package top.baogutang.music.controller; import com.alipay.api.AlipayApiException; import com.alipay.api.internal.util.AlipaySignature; +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper; import com.ijpay.alipay.AliPayApi; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; +import top.baogutang.music.dao.entity.OrderEntity; +import top.baogutang.music.dao.entity.UserEntity; +import top.baogutang.music.dao.mapper.OrderMapper; +import top.baogutang.music.dao.mapper.UserMapper; +import top.baogutang.music.enums.OrderStatus; +import top.baogutang.music.enums.UserLevel; import top.baogutang.music.properties.AliPayConfigProperties; +import top.baogutang.music.service.ILicenseService; import top.baogutang.music.utils.JacksonUtil; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.util.Map; +import java.util.Objects; /** + * 支付回调处理器 + * 支付宝异步通知入口:支付成功后自动升级用户等级并颁发激活码 * - * @description: - * - * @author: N1KO - * @date: 2024/12/26 : 14:36 + * @author N1KO */ @Slf4j @Controller @@ -29,22 +39,110 @@ public class CallbackController { @Resource private AliPayConfigProperties aliPayConfigProperties; + @Resource + private OrderMapper orderMapper; + + @Resource + private UserMapper userMapper; + + @Resource + private ILicenseService licenseService; + + /** + * 支付宝异步通知回调 + * 支付成功后: + * 1. 验签 + * 2. 更新订单状态为 PAYED + * 3. 升级用户等级为 VIP + * 4. 自动生成一枚激活码并绑定到该用户/订单 + */ @RequestMapping("/alipay") @ResponseBody public String certNotifyUrl(HttpServletRequest request) { try { - // 获取支付宝POST过来反馈信息 + // 获取支付宝 POST 回调参数 Map params = AliPayApi.toMap(request); - log.info(">>>>>>>>>>>received callback from alipay:{}<<<<<<<<<<", JacksonUtil.toJson(params)); - boolean verifyResult = AlipaySignature.rsaCertCheckV1(params, aliPayConfigProperties.getAliPayCertPath(), "UTF-8", "RSA2"); + log.info("[Callback] received alipay notify: {}", JacksonUtil.toJson(params)); + + // 验签 + boolean verifyResult = AlipaySignature.rsaCertCheckV1( + params, aliPayConfigProperties.getAliPayCertPath(), "UTF-8", "RSA2"); if (!verifyResult) { - log.error(">>>>>>>>>>certNotifyUrl sign check failed<<<<<<<<<<"); + log.error("[Callback] alipay sign verify failed"); return "failure"; } + + // 只处理 TRADE_SUCCESS 状态 + String tradeStatus = params.getOrDefault("trade_status", ""); + if (!"TRADE_SUCCESS".equals(tradeStatus)) { + log.info("[Callback] trade_status={}, skip", tradeStatus); + return "success"; + } + + String outTradeNo = params.get("out_trade_no"); + if (StringUtils.isBlank(outTradeNo)) { + log.error("[Callback] out_trade_no is blank"); + return "failure"; + } + + // 查询订单 + OrderEntity order = new LambdaQueryChainWrapper<>(orderMapper) + .eq(OrderEntity::getOrderNo, outTradeNo) + .eq(OrderEntity::getDeleted, Boolean.FALSE) + .last("limit 1") + .one(); + + if (Objects.isNull(order)) { + log.error("[Callback] order not found: {}", outTradeNo); + return "failure"; + } + + // 幂等:已支付则直接返回成功 + if (OrderStatus.PAYED.equals(order.getStatus())) { + log.info("[Callback] order already payed: {}", outTradeNo); + return "success"; + } + + // 1. 更新订单状态 + order.setStatus(OrderStatus.PAYED); + order.setThirdOrderNo(params.getOrDefault("trade_no", "")); + orderMapper.updateById(order); + log.info("[Callback] order payed: {}", outTradeNo); + + // 2. 升级用户等级为 VIP(匿名订单 userId=0 时跳过) + Long userId = order.getUserId(); + if (Objects.nonNull(userId) && userId != 0L) { + UserEntity user = userMapper.selectById(userId); + if (Objects.nonNull(user) && !UserLevel.VIP.equals(user.getLevel())) { + user.setLevel(UserLevel.VIP); + userMapper.updateById(user); + log.info("[Callback] user {} upgraded to VIP", userId); + } + + // 3. 自动生成激活码并绑定到该用户/订单 + try { + String licenseCode = licenseService.generateForOrder(userId, outTradeNo); + log.info("[Callback] license code generated for user={} order={} code={}", + userId, outTradeNo, licenseCode); + } catch (Exception e) { + // 激活码生成失败不影响主流程 + log.error("[Callback] failed to generate license code for order: {}", outTradeNo, e); + } + } else { + // 匿名订单:直接生成激活码,不绑定用户 + try { + String licenseCode = licenseService.generateForOrder(0L, outTradeNo); + log.info("[Callback] anonymous license code generated for order={} code={}", outTradeNo, licenseCode); + } catch (Exception e) { + log.error("[Callback] failed to generate license code for anonymous order: {}", outTradeNo, e); + } + } + return "success"; } catch (AlipayApiException e) { - log.error(">>>>>>>>>>process alipay callback failed:{}<<<<<<<<<<", e.getErrMsg(), e); + log.error("[Callback] process alipay callback failed: {}", e.getErrMsg(), e); return "failure"; } } } + diff --git a/src/main/java/top/baogutang/music/controller/LicenseController.java b/src/main/java/top/baogutang/music/controller/LicenseController.java new file mode 100644 index 0000000..e421b47 --- /dev/null +++ b/src/main/java/top/baogutang/music/controller/LicenseController.java @@ -0,0 +1,40 @@ +package top.baogutang.music.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import top.baogutang.music.domain.Results; +import top.baogutang.music.domain.res.license.ActivateRes; +import top.baogutang.music.service.ILicenseService; + +import javax.annotation.Resource; +import java.util.Map; + +/** + * 激活码公开接口(无需登录) + * N1KO MUSIC 客户端调用此接口验证激活码 + * + * @author N1KO + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/license") +public class LicenseController { + + @Resource + private ILicenseService licenseService; + + /** + * 激活会员 + * POST /api/v1/license/activate + * Body: { "code": "XXXX-XXXX-XXXX-XXXX" } + */ + @PostMapping("/activate") + public Results activate(@RequestBody Map body) { + String code = body.getOrDefault("code", ""); + ActivateRes res = licenseService.activate(code); + if (Boolean.TRUE.equals(res.getSuccess())) { + return Results.ok(res); + } + return Results.failed(res, res.getMessage()); + } +} diff --git a/src/main/java/top/baogutang/music/controller/OrderController.java b/src/main/java/top/baogutang/music/controller/OrderController.java index 2cb9441..b164b51 100644 --- a/src/main/java/top/baogutang/music/controller/OrderController.java +++ b/src/main/java/top/baogutang/music/controller/OrderController.java @@ -2,42 +2,88 @@ package top.baogutang.music.controller; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; -import top.baogutang.music.annos.Login; +import top.baogutang.music.dao.entity.LicenseCodeEntity; +import top.baogutang.music.dao.entity.OrderEntity; import top.baogutang.music.domain.Results; import top.baogutang.music.domain.res.pay.OrderRes; +import top.baogutang.music.enums.OrderStatus; import top.baogutang.music.enums.PayChannel; import top.baogutang.music.factory.PayClientFactory; -import top.baogutang.music.utils.UserThreadLocal; +import top.baogutang.music.service.ILicenseService; +import top.baogutang.music.service.IOrderService; import javax.annotation.Resource; -import javax.servlet.http.HttpServletResponse; +import java.util.Objects; /** + * 订单接口(无需登录,支持匿名购买) + * - GET /api/v1/music/order 下单(生成支付二维码) + * - GET /api/v1/music/order/query 查询订单状态(前端轮询) * - * @description: - * - * @author: N1KO - * @date: 2024/12/25 : 13:37 + * @author N1KO */ @Slf4j @RestController @RequestMapping("/api/v1/music/order") public class OrderController { + /** 匿名用户 userId,用于无登录态下单 */ + private static final Long ANONYMOUS_USER_ID = 0L; + @Resource private PayClientFactory payClientFactory; - @Login + @Resource + private IOrderService orderService; + + @Resource + private ILicenseService licenseService; + + /** + * 匿名下单,返回支付二维码 + * GET /api/v1/music/order?payChannel=ALI_PAY + */ @GetMapping public Results order(@RequestParam(name = "payChannel") PayChannel payChannel) { - return Results.ok(payClientFactory.getClient(payChannel).order(UserThreadLocal.get())); + return Results.ok(payClientFactory.getClient(payChannel).order(ANONYMOUS_USER_ID)); } -// @Login -// @PostMapping("/pcPay") -// public void webPay(@RequestParam(name = "payChannel") PayChannel payChannel, -// @RequestParam(name = "orderId") Long orderId, -// HttpServletResponse response) { -// payClientFactory.getClient(payChannel).pcPay(UserThreadLocal.get(), orderId, response); -// } + /** + * 查询订单状态(前端每 3 秒轮询一次,检测是否支付成功) + * GET /api/v1/music/order/query?orderId=123 + * + * 支付成功时响应额外携带 licenseCode,前端可直接激活 + */ + @GetMapping("/query") + public Results query(@RequestParam(name = "orderId") Long orderId) { + // 匿名订单直接按 orderId 查,不校验 userId + OrderEntity order = orderService.lambdaQuery() + .eq(OrderEntity::getId, orderId) + .eq(OrderEntity::getDeleted, Boolean.FALSE) + .last("limit 1") + .one(); + if (Objects.isNull(order)) { + return Results.failed("订单不存在"); + } + + OrderRes res = new OrderRes(); + res.setOrderId(order.getId()); + res.setOrderNo(order.getOrderNo()); + res.setAmount(order.getAmount()); + res.setStatus(order.getStatus()); + + // 支付成功时,查询并返回该订单绑定的激活码 + if (OrderStatus.PAYED.equals(order.getStatus())) { + LicenseCodeEntity license = licenseService.lambdaQuery() + .eq(LicenseCodeEntity::getOrderNo, order.getOrderNo()) + .eq(LicenseCodeEntity::getDeleted, Boolean.FALSE) + .last("limit 1") + .one(); + if (Objects.nonNull(license)) { + res.setLicenseCode(license.getCode()); + } + } + + return Results.ok(res); + } } diff --git a/src/main/java/top/baogutang/music/dao/entity/LicenseCodeEntity.java b/src/main/java/top/baogutang/music/dao/entity/LicenseCodeEntity.java new file mode 100644 index 0000000..ea54e91 --- /dev/null +++ b/src/main/java/top/baogutang/music/dao/entity/LicenseCodeEntity.java @@ -0,0 +1,72 @@ +package top.baogutang.music.dao.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 激活码实体 + * 用于 N1KO MUSIC 客户端会员功能激活 + * + * @author N1KO + * @date 2025/01 + */ +@Getter +@Setter +@TableName("t_license_code") +public class LicenseCodeEntity extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** + * 激活码(格式:XXXX-XXXX-XXXX-XXXX) + */ + private String code; + + /** + * 会员等级(premium) + */ + private String tier; + + /** + * 有效期天数,-1 表示永久 + */ + private Integer durationDays; + + /** + * 已激活次数 + */ + private Integer activationCount; + + /** + * 最大可激活次数(默认3台设备) + */ + private Integer maxActivations; + + /** + * 是否已撤销 + */ + private Boolean revoked; + + /** + * 关联的订单号(支付后自动生成时填充) + */ + private String orderNo; + + /** + * 关联的用户ID(支付后自动生成时填充) + */ + private Long userId; + + /** + * 备注(批次标识等) + */ + private String remark; + + /** + * 最后激活时间 + */ + private LocalDateTime lastActivatedAt; +} diff --git a/src/main/java/top/baogutang/music/dao/mapper/LicenseCodeMapper.java b/src/main/java/top/baogutang/music/dao/mapper/LicenseCodeMapper.java new file mode 100644 index 0000000..a3ec883 --- /dev/null +++ b/src/main/java/top/baogutang/music/dao/mapper/LicenseCodeMapper.java @@ -0,0 +1,14 @@ +package top.baogutang.music.dao.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import top.baogutang.music.dao.entity.LicenseCodeEntity; + +/** + * 激活码 Mapper + * + * @author N1KO + */ +@Mapper +public interface LicenseCodeMapper extends BaseMapper { +} diff --git a/src/main/java/top/baogutang/music/domain/res/license/ActivateRes.java b/src/main/java/top/baogutang/music/domain/res/license/ActivateRes.java new file mode 100644 index 0000000..b89dfd9 --- /dev/null +++ b/src/main/java/top/baogutang/music/domain/res/license/ActivateRes.java @@ -0,0 +1,48 @@ +package top.baogutang.music.domain.res.license; + +import lombok.Data; + +/** + * 激活码激活响应 + * + * @author N1KO + */ +@Data +public class ActivateRes { + + /** + * 是否激活成功 + */ + private Boolean success; + + /** + * 提示信息 + */ + private String message; + + /** + * 过期时间戳(毫秒),-1 表示永久,null 表示未激活 + */ + private Long expireAt; + + /** + * 会员等级 + */ + private String tier; + + public static ActivateRes success(String message, Long expireAt, String tier) { + ActivateRes res = new ActivateRes(); + res.setSuccess(Boolean.TRUE); + res.setMessage(message); + res.setExpireAt(expireAt); + res.setTier(tier); + return res; + } + + public static ActivateRes fail(String message) { + ActivateRes res = new ActivateRes(); + res.setSuccess(Boolean.FALSE); + res.setMessage(message); + return res; + } +} diff --git a/src/main/java/top/baogutang/music/domain/res/pay/OrderRes.java b/src/main/java/top/baogutang/music/domain/res/pay/OrderRes.java index b3c6982..23db295 100644 --- a/src/main/java/top/baogutang/music/domain/res/pay/OrderRes.java +++ b/src/main/java/top/baogutang/music/domain/res/pay/OrderRes.java @@ -1,15 +1,15 @@ package top.baogutang.music.domain.res.pay; import lombok.Data; +import top.baogutang.music.enums.OrderStatus; import java.io.Serializable; +import java.math.BigDecimal; /** + * 下单 / 查询订单响应 * - * @description: - * - * @author: N1KO - * @date: 2024/12/25 : 15:14 + * @author N1KO */ @Data public class OrderRes implements Serializable { @@ -22,6 +22,15 @@ public class OrderRes implements Serializable { private String orderNo; + /** 支付宝订单码二维码 URL,前端展示扫码 */ private String qrCode; + /** 订单金额 */ + private BigDecimal amount; + + /** 订单状态:CREATED / PAYED / CANCELED */ + private OrderStatus status; + + /** 支付成功后自动生成的激活码(只在 PAYED 时返回,方便前端直接激活)*/ + private String licenseCode; } diff --git a/src/main/java/top/baogutang/music/service/ILicenseService.java b/src/main/java/top/baogutang/music/service/ILicenseService.java new file mode 100644 index 0000000..e2f377e --- /dev/null +++ b/src/main/java/top/baogutang/music/service/ILicenseService.java @@ -0,0 +1,51 @@ +package top.baogutang.music.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import top.baogutang.music.dao.entity.LicenseCodeEntity; +import top.baogutang.music.domain.res.license.ActivateRes; + +import java.util.List; + +/** + * 激活码 Service 接口 + * + * @author N1KO + */ +public interface ILicenseService extends IService { + + /** + * 激活激活码(前端调用) + * + * @param code 激活码 + * @return 激活结果 + */ + ActivateRes activate(String code); + + /** + * 批量生成激活码(管理员调用) + * + * @param count 数量 + * @param durationDays 有效期天数(-1 永久) + * @param maxActivations 最大激活次数 + * @param remark 备注 + * @return 生成的激活码列表 + */ + List generate(int count, int durationDays, int maxActivations, String remark); + + /** + * 支付成功后为指定用户自动生成并绑定激活码 + * + * @param userId 用户ID + * @param orderNo 订单号 + * @return 生成的激活码 + */ + String generateForOrder(Long userId, String orderNo); + + /** + * 撤销激活码(管理员调用) + * + * @param code 激活码 + * @return 是否成功 + */ + boolean revoke(String code); +} diff --git a/src/main/java/top/baogutang/music/service/IOrderService.java b/src/main/java/top/baogutang/music/service/IOrderService.java index 270c566..82b8d30 100644 --- a/src/main/java/top/baogutang/music/service/IOrderService.java +++ b/src/main/java/top/baogutang/music/service/IOrderService.java @@ -16,4 +16,13 @@ import java.math.BigDecimal; public interface IOrderService extends IService { OrderEntity createOrder(Long userId, PayChannel payChannel, BigDecimal amount); + + /** + * 查询用户当前最新一笔订单 + * + * @param userId 用户ID + * @param orderId 订单ID(精确查询,可为 null 则查最新一笔) + * @return 订单实体,不存在返回 null + */ + OrderEntity queryOrder(Long userId, Long orderId); } diff --git a/src/main/java/top/baogutang/music/service/impl/LicenseServiceImpl.java b/src/main/java/top/baogutang/music/service/impl/LicenseServiceImpl.java new file mode 100644 index 0000000..21d755b --- /dev/null +++ b/src/main/java/top/baogutang/music/service/impl/LicenseServiceImpl.java @@ -0,0 +1,172 @@ +package top.baogutang.music.service.impl; + +import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import top.baogutang.music.dao.entity.LicenseCodeEntity; +import top.baogutang.music.dao.mapper.LicenseCodeMapper; +import top.baogutang.music.domain.res.license.ActivateRes; +import top.baogutang.music.service.ILicenseService; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * 激活码 Service 实现 + * + * @author N1KO + */ +@Slf4j +@Service +public class LicenseServiceImpl extends ServiceImpl implements ILicenseService { + + // 去掉易混淆字符 0/O/1/I + private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int DEFAULT_MAX_ACTIVATIONS = 3; + + @Override + @Transactional(rollbackFor = Exception.class) + public ActivateRes activate(String code) { + if (StringUtils.isBlank(code)) { + return ActivateRes.fail("激活码不能为空"); + } + String trimCode = code.trim().toUpperCase(); + + LicenseCodeEntity license = new LambdaQueryChainWrapper<>(baseMapper) + .eq(LicenseCodeEntity::getCode, trimCode) + .eq(LicenseCodeEntity::getDeleted, Boolean.FALSE) + .last("limit 1") + .one(); + + if (Objects.isNull(license)) { + log.warn("[License] activate failed - not found: {}", trimCode); + return ActivateRes.fail("激活码无效,请检查后重试"); + } + + if (Boolean.TRUE.equals(license.getRevoked())) { + log.warn("[License] activate failed - revoked: {}", trimCode); + return ActivateRes.fail("该激活码已被撤销"); + } + + if (license.getActivationCount() >= license.getMaxActivations()) { + log.warn("[License] activate failed - max activations reached: {}", trimCode); + return ActivateRes.fail("该激活码已达到最大激活设备数(" + license.getMaxActivations() + " 台)"); + } + + // 计算过期时间 + long expireAt; + if (license.getDurationDays() == null || license.getDurationDays() == -1) { + expireAt = -1L; // 永久 + } else { + expireAt = LocalDateTime.now() + .plusDays(license.getDurationDays()) + .toInstant(ZoneOffset.UTC) + .toEpochMilli(); + } + + // 更新激活次数 + license.setActivationCount(license.getActivationCount() + 1); + license.setLastActivatedAt(LocalDateTime.now()); + updateById(license); + + log.info("[License] activated: {} ({}/{})", trimCode, license.getActivationCount(), license.getMaxActivations()); + return ActivateRes.success("激活成功!欢迎成为 N1KO MUSIC 会员 🎵", expireAt, license.getTier()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List generate(int count, int durationDays, int maxActivations, String remark) { + int safeCount = Math.min(count, 1000); + int safeMaxAct = maxActivations > 0 ? maxActivations : DEFAULT_MAX_ACTIVATIONS; + List codes = new ArrayList<>(safeCount); + + for (int i = 0; i < safeCount; i++) { + String code = generateUniqueCode(); + LicenseCodeEntity entity = buildLicense(code, durationDays, safeMaxAct, remark, null, null); + save(entity); + codes.add(code); + } + log.info("[License] generated {} codes, remark: {}", safeCount, remark); + return codes; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String generateForOrder(Long userId, String orderNo) { + String code = generateUniqueCode(); + // 订单付款对应永久会员,可在此按需修改 durationDays + LicenseCodeEntity entity = buildLicense(code, -1, DEFAULT_MAX_ACTIVATIONS, "支付宝支付-自动生成", orderNo, userId); + save(entity); + log.info("[License] generated for order: {} user: {} code: {}", orderNo, userId, code); + return code; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean revoke(String code) { + if (StringUtils.isBlank(code)) { + return false; + } + LicenseCodeEntity license = new LambdaQueryChainWrapper<>(baseMapper) + .eq(LicenseCodeEntity::getCode, code.trim().toUpperCase()) + .eq(LicenseCodeEntity::getDeleted, Boolean.FALSE) + .last("limit 1") + .one(); + if (Objects.isNull(license)) { + return false; + } + license.setRevoked(Boolean.TRUE); + updateById(license); + log.info("[License] revoked: {}", code); + return true; + } + + // ===================== private ===================== + + private LicenseCodeEntity buildLicense(String code, int durationDays, int maxActivations, + String remark, String orderNo, Long userId) { + LicenseCodeEntity entity = new LicenseCodeEntity(); + entity.setCode(code); + entity.setTier("premium"); + entity.setDurationDays(durationDays); + entity.setActivationCount(0); + entity.setMaxActivations(maxActivations); + entity.setRevoked(Boolean.FALSE); + entity.setRemark(remark); + entity.setOrderNo(orderNo); + entity.setUserId(userId); + entity.setDeleted(Boolean.FALSE); + entity.setCreateTime(LocalDateTime.now()); + return entity; + } + + private String generateUniqueCode() { + String code; + do { + code = buildCodeString(); + } while (lambdaQuery() + .eq(LicenseCodeEntity::getCode, code) + .eq(LicenseCodeEntity::getDeleted, Boolean.FALSE) + .exists()); + return code; + } + + private String buildCodeString() { + StringBuilder sb = new StringBuilder(19); + for (int seg = 0; seg < 4; seg++) { + if (seg > 0) sb.append('-'); + for (int i = 0; i < 4; i++) { + sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length()))); + } + } + return sb.toString(); + } +} diff --git a/src/main/java/top/baogutang/music/service/impl/OrderServiceImpl.java b/src/main/java/top/baogutang/music/service/impl/OrderServiceImpl.java index 1617872..5270084 100644 --- a/src/main/java/top/baogutang/music/service/impl/OrderServiceImpl.java +++ b/src/main/java/top/baogutang/music/service/impl/OrderServiceImpl.java @@ -50,4 +50,17 @@ public class OrderServiceImpl extends ServiceImpl impl // 3.返回订单 return order; } + + @Override + public OrderEntity queryOrder(Long userId, Long orderId) { + LambdaQueryChainWrapper wrapper = new LambdaQueryChainWrapper<>(baseMapper) + .eq(OrderEntity::getUserId, userId) + .eq(OrderEntity::getDeleted, false); + if (Objects.nonNull(orderId)) { + wrapper.eq(OrderEntity::getId, orderId); + } + return wrapper.orderByDesc(OrderEntity::getCreateTime) + .last("limit 1") + .one(); + } } diff --git a/src/main/resources/sql/t_license_code.sql b/src/main/resources/sql/t_license_code.sql new file mode 100644 index 0000000..1cd0d6d --- /dev/null +++ b/src/main/resources/sql/t_license_code.sql @@ -0,0 +1,23 @@ +-- N1KO MUSIC 激活码表 +-- 支付完成后自动生成激活码,前端通过 POST /api/v1/license/activate 验证 + +CREATE TABLE IF NOT EXISTS `t_license_code` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `code` VARCHAR(64) NOT NULL COMMENT '激活码,格式:XXXX-XXXX-XXXX-XXXX', + `tier` VARCHAR(32) NOT NULL DEFAULT 'premium' COMMENT '会员等级', + `duration_days` INT NOT NULL DEFAULT -1 COMMENT '有效期天数,-1 表示永久', + `activation_count` INT NOT NULL DEFAULT 0 COMMENT '已激活次数', + `max_activations` INT NOT NULL DEFAULT 3 COMMENT '最大可激活次数(设备数)', + `revoked` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已撤销', + `order_no` VARCHAR(64) COMMENT '关联订单号(支付后自动生成时填充)', + `user_id` BIGINT COMMENT '关联用户ID(支付后自动生成时填充)', + `remark` VARCHAR(255) COMMENT '备注(批次名称等)', + `last_activated_at` DATETIME COMMENT '最后激活时间', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除(0-未删除,1-已删除)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_user_id` (`user_id`), + KEY `idx_order_no` (`order_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='N1KO MUSIC 激活码表'; diff --git a/src/main/resources/templates/buy.html b/src/main/resources/templates/buy.html new file mode 100644 index 0000000..08c6439 --- /dev/null +++ b/src/main/resources/templates/buy.html @@ -0,0 +1,446 @@ + + + + + + + N1KO MUSIC - 购买会员 + + + +
+ + +
+ +
+

解锁完整
音乐体验

+

一次性永久会员,享受所有高级功能

+
+
+
🎧
+
+ 无损原码音质 + 支持 FLAC、无损原码等高品质格式 +
+
+
+
+
+ 智能推荐 + 根据你的口味精选个性化歌单 +
+
+
+
+
+ 我的收藏 + 跨设备同步收藏夹,随时随地听 +
+
+
+
📊
+
+ 听歌统计 + 详细播放数据,了解你的音乐偏好 +
+
+
+
+
+ + +
+
+
+
👑
+
开通永久会员
+
扫码支付宝完成购买,立即获得激活码
+
+ +
+
¥29.9
+
永久会员 · 一次付清
+
+ + +
+ +
点击后生成支付宝二维码,有效期 10 分钟
+
+ + +
+
+
正在生成支付二维码…
+
+ + +
+
+ 支付宝二维码 +
+
+
+ 等待支付 · 二维码 10:00 后过期 +
+ + +
+ + +
+
+
支付成功!
+
请复制下方激活码,回到 N1KO MUSIC 填入
+
+
你的激活码
+
+
+ +
激活码可重复使用,请妥善保管
+
+ + +
+
下单失败,请稍后重试
+ +
+
+
+
+ + + +