浏览代码

Merge branch 'sit' into uat-20260202

dujian 1 月之前
父节点
当前提交
1f2bb175dc

+ 28 - 0
alien-entity/src/main/java/shop/alien/mapper/UserReservationMapper.java

@@ -48,4 +48,32 @@ public interface UserReservationMapper extends BaseMapper<UserReservation> {
      * @return 需要标记为已过期/未到店超时的 user_reservation.id 列表
      */
     List<Integer> listReservationIdsForTimeoutMark();
+
+    /**
+     * 查询分类下是否有符合条件的预订信息
+     * 条件:订单状态为"待使用"(1)或"已完成"(2)
+     * 对于"已完成"状态的订单,结束时间需要在当前时间3小时内
+     *
+     * @param categoryId 分类ID
+     * @param storeId    门店ID
+     * @return 符合条件的预订数量
+     */
+    long countReservationsByCategoryAndOrderStatus(
+            @Param("categoryId") Integer categoryId,
+            @Param("storeId") Integer storeId
+    );
+
+    /**
+     * 查询桌号下是否有符合条件的预订信息
+     * 条件:订单状态为"待使用"(1)或"已完成"(2)
+     * 对于"已完成"状态的订单,结束时间需要在当前时间3小时内
+     *
+     * @param tableId 桌号ID
+     * @param storeId 门店ID
+     * @return 符合条件的预订数量
+     */
+    long countReservationsByTableAndOrderStatus(
+            @Param("tableId") Integer tableId,
+            @Param("storeId") Integer storeId
+    );
 }

+ 85 - 0
alien-entity/src/main/resources/mapper/UserReservationMapper.xml

@@ -151,4 +151,89 @@
           AND STR_TO_DATE(TRIM(r.end_time), '%Y-%m-%d %H:%i') &lt; NOW()
     </select>
 
+    <!-- 查询分类下是否有符合条件的预订信息
+         条件:订单状态为"待使用"(1)或"已完成"(2)
+         对于"已完成"状态的订单,结束时间需要在当前时间3小时内 -->
+    <select id="countReservationsByCategoryAndOrderStatus" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT ur.id)
+        FROM user_reservation ur
+        INNER JOIN user_reservation_order uro ON ur.id = uro.reservation_id AND uro.delete_flag = 0
+        WHERE ur.delete_flag = 0
+          AND ur.category_id = #{categoryId}
+          AND ur.store_id = #{storeId}
+          AND uro.order_status IN (1, 2)
+          AND (
+            -- 订单状态为"待使用"(1),直接符合条件
+            uro.order_status = 1
+            OR
+            -- 订单状态为"已完成"(2),需要检查结束时间在3小时内
+            (
+              uro.order_status = 2
+              AND ur.end_time IS NOT NULL
+              AND TRIM(ur.end_time) != ''
+              AND (
+                -- 如果 end_time 是完整日期时间格式 (yyyy-MM-dd HH:mm)
+                (
+                  LENGTH(TRIM(ur.end_time)) &gt; 5
+                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') IS NOT NULL
+                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') &lt; NOW()
+                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') &gt; DATE_SUB(NOW(), INTERVAL 3 HOUR)
+                )
+                OR
+                -- 如果 end_time 是时间格式 (HH:mm),需要结合 reservation_date
+                (
+                  LENGTH(TRIM(ur.end_time)) &lt;= 5
+                  AND ur.reservation_date IS NOT NULL
+                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') IS NOT NULL
+                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') &lt; NOW()
+                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') &gt; DATE_SUB(NOW(), INTERVAL 3 HOUR)
+                )
+              )
+            )
+          )
+    </select>
+
+    <!-- 查询桌号下是否有符合条件的预订信息
+         条件:订单状态为"待使用"(1)或"已完成"(2)
+         对于"已完成"状态的订单,结束时间需要在当前时间3小时内 -->
+    <select id="countReservationsByTableAndOrderStatus" resultType="java.lang.Long">
+        SELECT COUNT(DISTINCT ur.id)
+        FROM user_reservation ur
+        INNER JOIN user_reservation_table urt ON ur.id = urt.reservation_id AND urt.delete_flag = 0
+        INNER JOIN user_reservation_order uro ON ur.id = uro.reservation_id AND uro.delete_flag = 0
+        WHERE ur.delete_flag = 0
+          AND urt.table_id = #{tableId}
+          AND ur.store_id = #{storeId}
+          AND uro.order_status IN (1, 2)
+          AND (
+            -- 订单状态为"待使用"(1),直接符合条件
+            uro.order_status = 1
+            OR
+            -- 订单状态为"已完成"(2),需要检查结束时间在3小时内
+            (
+              uro.order_status = 2
+              AND ur.end_time IS NOT NULL
+              AND TRIM(ur.end_time) != ''
+              AND (
+                -- 如果 end_time 是完整日期时间格式 (yyyy-MM-dd HH:mm)
+                (
+                  LENGTH(TRIM(ur.end_time)) &gt; 5
+                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') IS NOT NULL
+                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') &lt; NOW()
+                  AND STR_TO_DATE(TRIM(ur.end_time), '%Y-%m-%d %H:%i') &gt; DATE_SUB(NOW(), INTERVAL 3 HOUR)
+                )
+                OR
+                -- 如果 end_time 是时间格式 (HH:mm),需要结合 reservation_date
+                (
+                  LENGTH(TRIM(ur.end_time)) &lt;= 5
+                  AND ur.reservation_date IS NOT NULL
+                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') IS NOT NULL
+                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') &lt; NOW()
+                  AND STR_TO_DATE(CONCAT(DATE_FORMAT(ur.reservation_date, '%Y-%m-%d'), ' ', TRIM(ur.end_time)), '%Y-%m-%d %H:%i') &gt; DATE_SUB(NOW(), INTERVAL 3 HOUR)
+                )
+              )
+            )
+          )
+    </select>
+
 </mapper>

+ 5 - 43
alien-store-platform/src/main/java/shop/alien/storeplatform/service/impl/MerchantAuthServiceImpl.java

@@ -6,18 +6,10 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import shop.alien.entity.result.R;
 import shop.alien.entity.store.LifeUser;
-import shop.alien.entity.store.StoreInfo;
-import shop.alien.entity.store.StoreUser;
 import shop.alien.mapper.LifeUserMapper;
-import shop.alien.mapper.StoreInfoMapper;
-import shop.alien.mapper.StoreUserMapper;
 import shop.alien.storeplatform.service.MerchantAuthService;
 import shop.alien.storeplatform.util.AliApiUtil;
 
-import java.util.List;
-import java.util.Objects;
-import java.util.stream.Collectors;
-
 /**
  * web端商户身份验证服务实现类
  *
@@ -33,52 +25,22 @@ public class MerchantAuthServiceImpl implements MerchantAuthService {
 
     private final LifeUserMapper lifeUserMapper;
 
-    private final StoreUserMapper storeUserMapper;
-
-    private final StoreInfoMapper storeInfoMapper;
-
     @Override
     public R<String> verifyIdInfo(String name, String idCard, Integer appType) {
         log.info("MerchantAuthServiceImpl.verifyIdInfo?name={}&idCard={}&appType={}", name, idCard, appType);
         
-        // 根据端类型查询是否已实名认证
-        int size = 0;
+        // 根据端类型查询是否已实名认证(仅用户端限制一证一号;商家端允许多个商户账号使用同一身份证)
         if (appType == 0) {
-            // 用户端:查询未注销的用户
+            // 用户端:查询未注销的用户,同一身份证只能实名一个用户账号
             LambdaQueryWrapper<LifeUser> userWrapper = new LambdaQueryWrapper<>();
             userWrapper.eq(LifeUser::getIdCard, idCard)
                        .eq(LifeUser::getRealName, name)
                        .eq(LifeUser::getLogoutFlag, 0);  // 只查询未注销的用户
-            size = lifeUserMapper.selectCount(userWrapper);
-        } else {
-            // 商家端:查询已入驻或审核中的商家(排除已注销和审核失败的店铺)
-            // 1. 先根据身份证和姓名查询商户用户(排除已注销的用户)
-            LambdaQueryWrapper<StoreUser> storeUserWrapper = new LambdaQueryWrapper<>();
-            storeUserWrapper.eq(StoreUser::getIdCard, idCard)
-                           .eq(StoreUser::getName, name)
-                           .eq(StoreUser::getLogoutFlag, 0);  // 排除已注销的商户用户
-            List<StoreUser> storeUserList = storeUserMapper.selectList(storeUserWrapper);
-            
-            // 2. 获取这些商户用户绑定的店铺ID
-            List<Integer> storeIds = storeUserList.stream()
-                    .map(StoreUser::getStoreId)
-                    .filter(Objects::nonNull)  // 过滤掉空的店铺ID
-                    .collect(Collectors.toList());
-            
-            // 3. 如果有店铺ID,查询非审核失败且非注销状态的店铺
-            if (!storeIds.isEmpty()) {
-                LambdaQueryWrapper<StoreInfo> storeInfoWrapper = new LambdaQueryWrapper<>();
-                storeInfoWrapper.in(StoreInfo::getId, storeIds)
-                               .notIn(StoreInfo::getStoreApplicationStatus, 2)  // 排除审核失败的店铺(状态2)
-                               .eq(StoreInfo::getLogoutFlag, 0)  // 排除已注销的店铺(logoutFlag=0表示未注销)
-                               .ne(StoreInfo::getStoreStatus, -1);  // 排除注销中状态的店铺(storeStatus=-1表示注销中)
-                size = storeInfoMapper.selectCount(storeInfoWrapper);
+            if (lifeUserMapper.selectCount(userWrapper) > 0) {
+                return R.fail("该身份证已实名认证过");
             }
         }
-        
-        if (size > 0) {
-            return R.fail("该身份证已实名认证过");
-        }
+        // 商家端(appType=1):不校验身份证是否已被其他商户使用,允许多个商户账号使用同一身份证,仅做支付宝二要素核验
         
         // 调用支付宝身份验证接口
         if (aliApiUtil.verifyIdCard(name, idCard)) {

+ 300 - 0
alien-store/OSS直传使用说明.md

@@ -0,0 +1,300 @@
+# OSS直传优化方案使用说明
+
+## 概述
+
+本方案实现了阿里云OSS直传功能,支持:
+- ✅ 前端直接上传到OSS(不经过后端服务器)
+- ✅ 分片上传(支持大文件)
+- ✅ 断点续传
+- ✅ 上传进度查询
+- ✅ 上传回调验证
+
+## 核心优势
+
+1. **性能提升**:文件直接上传到OSS,不经过后端服务器,减少服务器带宽和IO压力
+2. **支持大文件**:通过分片上传,支持超大文件上传
+3. **断点续传**:上传中断后可以继续上传,无需重新开始
+4. **安全性**:使用签名验证,确保上传安全
+
+## API接口说明
+
+### 1. 生成OSS直传签名
+
+**接口地址**:`POST /oss/direct/signature`
+
+**请求参数**:
+- `dir`(可选):上传目录,默认 `video/`
+- `fileName`(可选):文件名,不传则自动生成UUID
+- `maxSize`(可选):最大文件大小(字节),默认100MB
+- `expireTime`(可选):过期时间(毫秒),默认1小时
+
+**响应示例**:
+```json
+{
+  "code": 200,
+  "success": true,
+  "data": {
+    "accessKeyId": "LTAI5t...",
+    "policy": "eyJleHBpcmF0aW9uIjoi...",
+    "signature": "abc123...",
+    "dir": "video/",
+    "host": "https://alien-volume.oss-cn-beijing.aliyuncs.com",
+    "expire": "1704067200",
+    "ossKey": "video/uuid-filename.mp4",
+    "callbackUrl": "",
+    "callbackBody": ""
+  }
+}
+```
+
+### 2. 初始化分片上传
+
+**接口地址**:`POST /oss/direct/multipart/init`
+
+**请求参数**:
+- `ossKey`(必需):OSS文件路径,如 `video/filename.mp4`
+
+**响应示例**:
+```json
+{
+  "code": 200,
+  "success": true,
+  "data": {
+    "uploadId": "abc123...",
+    "ossKey": "video/filename.mp4"
+  }
+}
+```
+
+### 3. 上传分片
+
+**接口地址**:`POST /oss/direct/multipart/upload`
+
+**请求参数**:
+- `ossKey`(必需):OSS文件路径
+- `uploadId`(必需):上传ID
+- `partNumber`(必需):分片序号(从1开始)
+- `partData`(必需):分片数据(二进制)
+
+**响应示例**:
+```json
+{
+  "code": 200,
+  "success": true,
+  "data": {
+    "partNumber": 1,
+    "eTag": "\"abc123...\""
+  }
+}
+```
+
+### 4. 完成分片上传(合并)
+
+**接口地址**:`POST /oss/direct/multipart/complete`
+
+**请求参数**:
+- `ossKey`(必需):OSS文件路径
+- `uploadId`(必需):上传ID
+- `partETags`(必需):所有分片的ETag列表(JSON数组)
+
+**请求Body示例**:
+```json
+[
+  {"partNumber": 1, "eTag": "\"abc123...\""},
+  {"partNumber": 2, "eTag": "\"def456...\""},
+  {"partNumber": 3, "eTag": "\"ghi789...\""}
+]
+```
+
+**响应示例**:
+```json
+{
+  "code": 200,
+  "success": true,
+  "data": {
+    "fileUrl": "https://alien-volume.oss-cn-beijing.aliyuncs.com/video/filename.mp4",
+    "ossKey": "video/filename.mp4"
+  }
+}
+```
+
+### 5. 取消分片上传
+
+**接口地址**:`POST /oss/direct/multipart/abort`
+
+**请求参数**:
+- `ossKey`(必需):OSS文件路径
+- `uploadId`(必需):上传ID
+
+### 6. 查询已上传的分片(断点续传)
+
+**接口地址**:`GET /oss/direct/multipart/list`
+
+**请求参数**:
+- `ossKey`(必需):OSS文件路径
+- `uploadId`(必需):上传ID
+
+**响应示例**:
+```json
+{
+  "code": 200,
+  "success": true,
+  "data": [
+    {
+      "partNumber": 1,
+      "eTag": "\"abc123...\"",
+      "size": 5242880
+    },
+    {
+      "partNumber": 2,
+      "eTag": "\"def456...\"",
+      "size": 5242880
+    }
+  ]
+}
+```
+
+### 7. OSS上传回调验证
+
+**接口地址**:`POST /oss/direct/callback`
+
+**请求头**:
+- `Authorization`:OSS回调的Authorization头
+
+**请求参数**:
+- `pubKeyUrl`:公钥URL
+- `callbackBody`:回调Body(JSON格式)
+
+## 使用流程
+
+### 方式一:简单直传(小文件,<100MB)
+
+1. 调用 `/oss/direct/signature` 获取签名
+2. 前端使用签名直接POST到OSS
+3. 上传成功后,OSS会返回文件URL
+
+### 方式二:分片上传(大文件,>100MB)
+
+1. 调用 `/oss/direct/multipart/init` 初始化分片上传,获取 `uploadId`
+2. 将文件分成多个分片(建议每个分片5-10MB)
+3. 对每个分片调用 `/oss/direct/multipart/upload` 上传
+4. 所有分片上传完成后,调用 `/oss/direct/multipart/complete` 合并分片
+5. 获取最终的文件URL
+
+### 方式三:断点续传
+
+1. 调用 `/oss/direct/multipart/list` 查询已上传的分片
+2. 只上传未完成的分片
+3. 所有分片完成后,调用 `/oss/direct/multipart/complete` 合并
+
+## 前端集成示例
+
+### JavaScript示例(简单直传)
+
+```javascript
+// 1. 获取签名
+const response = await fetch('/oss/direct/signature?dir=video/&fileName=test.mp4', {
+  method: 'POST'
+});
+const { data } = await response.json();
+
+// 2. 构建FormData
+const formData = new FormData();
+formData.append('key', data.ossKey);
+formData.append('policy', data.policy);
+formData.append('OSSAccessKeyId', data.accessKeyId);
+formData.append('signature', data.signature);
+formData.append('file', file); // 文件对象
+
+// 3. 直接上传到OSS
+const uploadResponse = await fetch(data.host, {
+  method: 'POST',
+  body: formData
+});
+
+// 4. 上传成功,获取文件URL
+const fileUrl = data.host + '/' + data.ossKey;
+```
+
+### JavaScript示例(分片上传)
+
+```javascript
+// 1. 初始化分片上传
+const initResponse = await fetch('/oss/direct/multipart/init', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+  body: `ossKey=video/test.mp4`
+});
+const { data: initData } = await initResponse.json();
+const { uploadId, ossKey } = initData;
+
+// 2. 分片上传
+const chunkSize = 5 * 1024 * 1024; // 5MB
+const chunks = Math.ceil(file.size / chunkSize);
+const partETags = [];
+
+for (let i = 0; i < chunks; i++) {
+  const start = i * chunkSize;
+  const end = Math.min(start + chunkSize, file.size);
+  const chunk = file.slice(start, end);
+  
+  const chunkResponse = await fetch('/oss/direct/multipart/upload', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({
+      ossKey,
+      uploadId,
+      partNumber: i + 1,
+      partData: await chunk.arrayBuffer()
+    })
+  });
+  
+  const { data: partData } = await chunkResponse.json();
+  partETags.push({
+    partNumber: partData.partNumber,
+    eTag: partData.eTag
+  });
+}
+
+// 3. 完成分片上传
+const completeResponse = await fetch('/oss/direct/multipart/complete', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json' },
+  body: JSON.stringify({
+    ossKey,
+    uploadId,
+    partETags
+  })
+});
+
+const { data: completeData } = await completeResponse.json();
+const fileUrl = completeData.fileUrl;
+```
+
+## 注意事项
+
+1. **文件大小限制**:建议单个分片5-10MB,最多支持10000个分片
+2. **签名过期**:签名默认1小时过期,过期后需要重新获取
+3. **错误处理**:上传失败时,可以调用 `/oss/direct/multipart/abort` 清理未完成的分片
+4. **安全性**:生产环境建议配置OSS回调,验证上传结果
+5. **性能优化**:大文件建议使用分片上传,可以并行上传多个分片提升速度
+
+## 配置说明
+
+确保在 `application.yml` 或 Nacos 中配置了以下OSS参数:
+
+```yaml
+ali:
+  oss:
+    accessKeyId: your-access-key-id
+    accessKeySecret: your-access-key-secret
+    endPoint: oss-cn-beijing.aliyuncs.com
+    bucketName: your-bucket-name
+```
+
+## 故障排查
+
+1. **签名验证失败**:检查AccessKey和Secret是否正确
+2. **分片上传失败**:检查分片大小是否合理(建议5-10MB)
+3. **合并失败**:确保所有分片都已成功上传,且ETag正确
+4. **回调验证失败**:检查公钥URL是否可访问

+ 34 - 0
alien-store/pom.xml

@@ -195,6 +195,29 @@
         </dependency>
         <!--Swagger End-->
 
+
+        <!-- 阿里云 OSS 核心依赖 -->
+        <dependency>
+            <groupId>com.aliyun.oss</groupId>
+            <artifactId>aliyun-sdk-oss</artifactId>
+            <version>3.16.0</version>
+        </dependency>
+        <!-- 用于 JSON 处理 -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+            <version>2.0.32</version>
+        </dependency>
+        <!-- 用于 HMAC-SHA1 签名 -->
+        <dependency>
+            <groupId>commons-codec</groupId>
+            <artifactId>commons-codec</artifactId>
+            <version>1.15</version>
+        </dependency>
+
+
+
+
         <dependency>
             <groupId>org.projectlombok</groupId>
             <artifactId>lombok</artifactId>
@@ -313,6 +336,17 @@
             <groupId>jakarta.validation</groupId>
             <artifactId>jakarta.validation-api</artifactId>
         </dependency>
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.aliyun</groupId>
+            <artifactId>sts20150401</artifactId>
+            <version>1.1.6</version>
+        </dependency>
+
     </dependencies>
 
     <build>

+ 231 - 0
alien-store/src/main/java/shop/alien/store/controller/OSSDirectUploadController.java

@@ -0,0 +1,231 @@
+package shop.alien.store.controller;
+
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiOperationSupport;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+import shop.alien.entity.result.R;
+import shop.alien.store.util.oss.OSSDirectUploadUtil;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * OSS直传Controller
+ * 支持:签名生成、分片上传、断点续传、上传进度查询
+ *
+ * @author system
+ * @date 2025-01-XX
+ */
+@Slf4j
+@Api(tags = {"OSS直传接口"})
+@CrossOrigin
+@RestController
+@RequestMapping("/oss/direct")
+@RequiredArgsConstructor
+public class OSSDirectUploadController {
+
+    private final OSSDirectUploadUtil ossDirectUploadUtil;
+
+    @ApiOperation("生成OSS直传签名")
+    @ApiOperationSupport(order = 1)
+    @PostMapping("/signature")
+    public R<OSSDirectUploadUtil.OSSSignatureResult> generateSignature(
+            @RequestParam(required = false, defaultValue = "video/") String dir,
+            @RequestParam(required = false) String fileName,
+            @RequestParam(required = false) Long maxSize,
+            @RequestParam(required = false) Long expireTime) {
+        try {
+            log.info("生成OSS直传签名: dir={}, fileName={}, maxSize={}, expireTime={}", 
+                    dir, fileName, maxSize, expireTime);
+            OSSDirectUploadUtil.OSSSignatureResult result = 
+                    ossDirectUploadUtil.generatePostSignature(dir, fileName, maxSize, expireTime);
+            return R.data(result);
+        } catch (Exception e) {
+            log.error("生成OSS直传签名失败: {}", e.getMessage(), e);
+            return R.fail("生成签名失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation("初始化分片上传")
+    @ApiOperationSupport(order = 2)
+    @PostMapping("/multipart/init")
+    public R<MultipartInitResult> initMultipartUpload(
+            @RequestParam String ossKey) {
+        try {
+            log.info("初始化分片上传: ossKey={}", ossKey);
+            String uploadId = ossDirectUploadUtil.initMultipartUpload(ossKey);
+            MultipartInitResult result = new MultipartInitResult();
+            result.setUploadId(uploadId);
+            result.setOssKey(ossKey);
+            return R.data(result);
+        } catch (Exception e) {
+            log.error("初始化分片上传失败: {}", e.getMessage(), e);
+            return R.fail("初始化分片上传失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation("上传分片")
+    @ApiOperationSupport(order = 3)
+    @PostMapping("/multipart/upload")
+    public R<PartUploadResult> uploadPart(
+            @RequestParam String ossKey,
+            @RequestParam String uploadId,
+            @RequestParam Integer partNumber,
+            @RequestBody byte[] partData) {
+        try {
+            log.info("上传分片: ossKey={}, uploadId={}, partNumber={}, size={}", 
+                    ossKey, uploadId, partNumber, partData.length);
+            String eTag = ossDirectUploadUtil.uploadPart(ossKey, uploadId, partNumber, partData);
+            PartUploadResult result = new PartUploadResult();
+            result.setPartNumber(partNumber);
+            result.setETag(eTag);
+            return R.data(result);
+        } catch (Exception e) {
+            log.error("上传分片失败: {}", e.getMessage(), e);
+            return R.fail("上传分片失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation("完成分片上传(合并)")
+    @ApiOperationSupport(order = 4)
+    @PostMapping("/multipart/complete")
+    public R<CompleteUploadResult> completeMultipartUpload(
+            @RequestParam String ossKey,
+            @RequestParam String uploadId,
+            @RequestBody List<PartETagRequest> partETags) {
+        try {
+            log.info("完成分片上传: ossKey={}, uploadId={}, partCount={}", 
+                    ossKey, uploadId, partETags.size());
+            
+            // 转换为OSS的PartETag列表
+            List<com.aliyun.oss.model.PartETag> ossPartETags = partETags.stream()
+                    .map(p -> new com.aliyun.oss.model.PartETag(p.getPartNumber(), p.getETag()))
+                    .collect(Collectors.toList());
+            
+            String fileUrl = ossDirectUploadUtil.completeMultipartUpload(ossKey, uploadId, ossPartETags);
+            
+            CompleteUploadResult result = new CompleteUploadResult();
+            result.setFileUrl(fileUrl);
+            result.setOssKey(ossKey);
+            return R.data(result);
+        } catch (Exception e) {
+            log.error("完成分片上传失败: {}", e.getMessage(), e);
+            return R.fail("完成分片上传失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation("取消分片上传")
+    @ApiOperationSupport(order = 5)
+    @PostMapping("/multipart/abort")
+    public R<String> abortMultipartUpload(
+            @RequestParam String ossKey,
+            @RequestParam String uploadId) {
+        try {
+            log.info("取消分片上传: ossKey={}, uploadId={}", ossKey, uploadId);
+            ossDirectUploadUtil.abortMultipartUpload(ossKey, uploadId);
+            return R.data("取消成功");
+        } catch (Exception e) {
+            log.error("取消分片上传失败: {}", e.getMessage(), e);
+            return R.fail("取消失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation("查询已上传的分片列表(用于断点续传)")
+    @ApiOperationSupport(order = 6)
+    @GetMapping("/multipart/list")
+    public R<List<PartInfo>> listParts(
+            @RequestParam String ossKey,
+            @RequestParam String uploadId) {
+        try {
+            log.info("查询已上传分片: ossKey={}, uploadId={}", ossKey, uploadId);
+            List<com.aliyun.oss.model.PartSummary> parts = 
+                    ossDirectUploadUtil.listParts(ossKey, uploadId);
+            
+            List<PartInfo> partInfos = parts.stream()
+                    .map(p -> {
+                        PartInfo info = new PartInfo();
+                        info.setPartNumber(p.getPartNumber());
+                        info.setETag(p.getETag());
+                        info.setSize(p.getSize());
+                        return info;
+                    })
+                    .collect(Collectors.toList());
+            
+            return R.data(partInfos);
+        } catch (Exception e) {
+            log.error("查询已上传分片失败: {}", e.getMessage(), e);
+            return R.fail("查询失败: " + e.getMessage());
+        }
+    }
+
+    @ApiOperation("OSS上传回调验证")
+    @ApiOperationSupport(order = 7)
+    @PostMapping("/callback")
+    public R<String> callback(
+            @RequestHeader(value = "Authorization", required = false) String authorization,
+            @RequestParam(required = false) String pubKeyUrl,
+            @RequestBody(required = false) String callbackBody) {
+        try {
+            log.info("OSS回调验证: authorization={}", authorization);
+            
+            if (authorization == null || pubKeyUrl == null || callbackBody == null) {
+                return R.fail("回调参数不完整");
+            }
+            
+            boolean verified = ossDirectUploadUtil.verifyCallback(authorization, pubKeyUrl, callbackBody);
+            if (verified) {
+                // 解析回调Body,获取上传结果
+                // callbackBody格式通常是JSON,包含bucket、object、etag等信息
+                log.info("OSS回调验证通过: callbackBody={}", callbackBody);
+                return R.data("验证成功");
+            } else {
+                log.warn("OSS回调验证失败");
+                return R.fail("验证失败");
+            }
+        } catch (Exception e) {
+            log.error("OSS回调验证异常: {}", e.getMessage(), e);
+            return R.fail("验证异常: " + e.getMessage());
+        }
+    }
+
+    // ========== 内部类 ==========
+
+    @Data
+    public static class MultipartInitResult {
+        private String uploadId;
+        private String ossKey;
+    }
+
+    @Data
+    public static class PartUploadResult {
+        private Integer partNumber;
+        private String eTag;
+    }
+
+    @Data
+    public static class CompleteUploadResult {
+        private String fileUrl;
+        private String ossKey;
+    }
+
+    @Data
+    public static class PartETagRequest {
+        private Integer partNumber;
+        private String eTag;
+    }
+
+    @Data
+    public static class PartInfo {
+        private Integer partNumber;
+        private String eTag;
+        private Long size;
+    }
+}

+ 328 - 0
alien-store/src/main/java/shop/alien/store/controller/OSSDirectUploadNewController.java

@@ -0,0 +1,328 @@
+package shop.alien.store.controller;
+
+import com.alibaba.fastjson.JSONObject;
+import com.aliyun.oss.common.utils.BinaryUtil;
+import com.aliyun.sts20150401.models.AssumeRoleResponse;
+import com.aliyun.sts20150401.models.AssumeRoleResponseBody;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.codec.binary.Base64;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.PostConstruct;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+
+/**
+ * OSS直传新版本Controller(使用OSS4-HMAC-SHA256签名方式)
+ * 
+ * @author system
+ * @date 2025-01-XX
+ */
+@Slf4j
+@Api(tags = {"OSS直传新版本接口"})
+@RestController
+@RequestMapping("/oss/direct/new")
+@RefreshScope
+public class OSSDirectUploadNewController {
+
+    @Value("${ali.oss.accessKeyId}")
+    private String accessKeyId;
+
+    @Value("${ali.oss.accessKeySecret}")
+    private String accessKeySecret;
+
+    @Value("${ali.oss.endPoint}")
+    private String endPoint;
+
+    @Value("${ali.oss.bucketName}")
+    private String bucketName;
+
+    @Value("${ali.oss.ossStsRoleArn}")
+    private String ossStsRoleArn;
+
+    public static String STATIC_ACCESS_KEY_ID;
+    public static String STATIC_ACCESS_KEY_SECRET;
+    public static String STATIC_END_POINT;
+    public static String STATIC_BUCKET_NAME;
+    public static String OSS_STS_ROLE_ARN;
+    // 3. 初始化方法:对象创建后立即执行,把非静态值赋给静态变量
+    @PostConstruct
+    public void init() {
+        STATIC_ACCESS_KEY_ID = this.accessKeyId;
+        STATIC_ACCESS_KEY_SECRET = this.accessKeySecret;
+        STATIC_END_POINT = this.endPoint;
+        STATIC_BUCKET_NAME = this.bucketName;
+        OSS_STS_ROLE_ARN = this.ossStsRoleArn;
+    }
+
+
+    // 从endPoint提取region(例如:oss-cn-beijing.aliyuncs.com -> cn-beijing)
+    private String getRegion() {
+        if (endPoint != null && endPoint.contains("oss-")) {
+            int start = endPoint.indexOf("oss-") + 4;
+            int end = endPoint.indexOf(".aliyuncs.com");
+            if (end > start) {
+                return endPoint.substring(start, end);
+            }
+        }
+        return "cn-beijing"; // 默认值
+    }
+
+    /**
+     * 从 OSS Endpoint 得到 STS Endpoint。获取临时凭证必须使用 STS 端点,不能使用 OSS 端点。
+     * 例如:oss-cn-beijing.aliyuncs.com -> sts.cn-beijing.aliyuncs.com
+     */
+    private static String getStsEndpointFromOssEndpoint(String ossEndpoint) {
+        if (ossEndpoint != null && ossEndpoint.contains("oss-")) {
+            int start = ossEndpoint.indexOf("oss-") + 4;
+            int end = ossEndpoint.indexOf(".aliyuncs.com");
+            if (end > start) {
+                String region = ossEndpoint.substring(start, end);
+                return "sts." + region + ".aliyuncs.com";
+            }
+        }
+        return "sts.cn-beijing.aliyuncs.com";
+    }
+
+    // 构建host地址
+    private String getHost() {
+        return "https://" + bucketName + "." + endPoint;
+    }
+
+    // 设置上传回调URL(可选,如果需要回调功能)
+    @Value("${ali.oss.callbackUrl:}")
+    private String callbackUrl;
+
+    // 限定上传到OSS的文件前缀(默认值)
+    @Value("${ali.oss.uploadDir:upload/}")
+    private String uploadDir;
+
+    // 指定过期时间,单位为秒(默认1小时)
+    @Value("${ali.oss.expireTime:3600}")
+    private Long expireTime;
+
+    /**
+     * 通过指定有效的时长(秒)生成过期时间。
+     * @param seconds 有效时长(秒)。
+     * @return ISO8601 时间字符串,如:"2014-12-01T12:00:00.000Z"。
+     */
+    public static String generateExpiration(long seconds) {
+        // 获取当前时间戳(以秒为单位)
+        long now = Instant.now().getEpochSecond();
+        // 计算过期时间的时间戳
+        long expirationTime = now + seconds;
+        // 将时间戳转换为Instant对象,并格式化为ISO8601格式
+        Instant instant = Instant.ofEpochSecond(expirationTime);
+        // 定义时区为UTC
+        ZoneId zone = ZoneOffset.UTC;
+        // 将 Instant 转换为 ZonedDateTime
+        ZonedDateTime zonedDateTime = instant.atZone(zone);
+        // 定义日期时间格式,例如2023-12-03T13:00:00.000Z
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
+        // 格式化日期时间
+        String formattedDate = zonedDateTime.format(formatter);
+        // 输出结果
+        return formattedDate;
+    }
+    //初始化STS Client(如果需要使用STS,取消注释并配置相关环境变量)
+    public static com.aliyun.sts20150401.Client createStsClient() throws Exception {
+        // 工程代码泄露可能会导致 AccessKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考。
+        // 建议使用更安全的 STS 方式。
+        com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
+                // 必填,请确保代码运行环境设置了环境变量 OSS_ACCESS_KEY_ID。
+                .setAccessKeyId(STATIC_ACCESS_KEY_ID)
+                // 必填,请确保代码运行环境设置了环境变量 OSS_ACCESS_KEY_SECRET。
+                .setAccessKeySecret(STATIC_ACCESS_KEY_SECRET);
+        // 获取临时凭证必须使用 STS 端点,不能使用 OSS 端点。参见 https://api.aliyun.com/product/Sts
+        config.endpoint = getStsEndpointFromOssEndpoint(STATIC_END_POINT);
+        return new com.aliyun.sts20150401.Client(config);
+    }
+
+    //获取STS临时凭证
+    // 注意:此方法需要STS服务支持,如果不需要STS,可以直接使用AccessKey
+    // 这里暂时注释掉STS相关代码,直接使用配置的AccessKey
+    public static AssumeRoleResponseBody.AssumeRoleResponseBodyCredentials getCredential() throws Exception {
+        com.aliyun.sts20150401.Client client = OSSDirectUploadNewController.createStsClient();
+        com.aliyun.sts20150401.models.AssumeRoleRequest assumeRoleRequest = new com.aliyun.sts20150401.models.AssumeRoleRequest()
+                // 必填,请确保代码运行环境设置了环境变量 OSS_STS_ROLE_ARN
+                .setRoleArn(OSS_STS_ROLE_ARN)
+                .setRoleSessionName("ossDirectPass");// 自定义会话名称
+        com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions();
+        try {
+            // 复制代码运行请自行打印 API 的返回值
+            AssumeRoleResponse response = client.assumeRoleWithOptions(assumeRoleRequest, runtime);
+            // credentials里包含了后续要用到的AccessKeyId、AccessKeySecret和SecurityToken。
+            return response.body.credentials;
+        } catch (Exception error) {
+            String msg = error.getMessage() != null ? error.getMessage() : "";
+            if (msg.contains("NoSuchBucket") || msg.contains("bucket does not exist")) {
+                log.error("指定的Bucket不存在,请检查配置 ali.oss.bucketName: {}", STATIC_BUCKET_NAME, error);
+                throw new RuntimeException("指定的Bucket不存在,请检查配置 ali.oss.bucketName: " + STATIC_BUCKET_NAME, error);
+            }
+            if (msg.contains("403") || msg.contains("not authorized") || msg.contains("authorized by RAM")) {
+                log.error("STS AssumeRole 无权限(403):当前 RAM 用户/角色未授权扮演角色,请到阿里云 RAM 控制台检查:1) 当前 AccessKey 对应用户需有 sts:AssumeRole 权限;2) 角色 {} 的信任策略需允许该用户扮演。", OSS_STS_ROLE_ARN, error);
+                throw new RuntimeException("STS 临时凭证无权限(403):请在 RAM 控制台为当前 AccessKey 对应用户授权 sts:AssumeRole,并确保角色 " + OSS_STS_ROLE_ARN + " 的信任策略允许该用户扮演。", error);
+            }
+            log.error("获取STS凭证失败: {}", error.getMessage(), error);
+            throw new RuntimeException("获取STS凭证失败", error);
+        }
+    }
+
+    @ApiOperation("生成OSS直传签名(OSS4-HMAC-SHA256方式)")
+    @GetMapping("/signature")
+    public ResponseEntity<Map<String, String>> getPostSignatureForOssUpload() {
+        try {
+//            // 直接使用配置的AccessKey(如果使用STS,需要调用getCredential()方法)
+//            String accesskeyid = accessKeyId;
+//            String accesskeysecret = accessKeySecret;
+//            String securitytoken = ""; // 如果使用STS,这里应该是securityToken
+            AssumeRoleResponseBody.AssumeRoleResponseBodyCredentials sts_data = getCredential();
+
+            String accesskeyid =  sts_data.accessKeyId;
+            String accesskeysecret =  sts_data.accessKeySecret;
+            String securitytoken =  sts_data.securityToken;
+            
+            String region = getRegion();
+            String host = getHost();
+
+        //获取x-oss-credential里的date,当前日期,格式为yyyyMMdd
+        ZonedDateTime today = ZonedDateTime.now().withZoneSameInstant(java.time.ZoneOffset.UTC);
+        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd");
+        String date = today.format(formatter);
+
+        //获取x-oss-date
+        ZonedDateTime now = ZonedDateTime.now().withZoneSameInstant(java.time.ZoneOffset.UTC);
+        DateTimeFormatter formatter2 = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'");
+        String x_oss_date = now.format(formatter2);
+
+        // 步骤1:创建policy。
+        String x_oss_credential = accesskeyid + "/" + date + "/" + region + "/oss/aliyun_v4_request";
+
+        ObjectMapper mapper = new ObjectMapper();
+
+        Map<String, Object> policy = new HashMap<>();
+        policy.put("expiration", generateExpiration(expireTime));
+
+        List<Object> conditions = new ArrayList<>();
+
+        Map<String, String> bucketCondition = new HashMap<>();
+        bucketCondition.put("bucket", bucketName);
+        conditions.add(bucketCondition);
+
+            // 如果使用STS,需要添加security-token条件
+            if (securitytoken != null && !securitytoken.isEmpty()) {
+                Map<String, String> securityTokenCondition = new HashMap<>();
+                securityTokenCondition.put("x-oss-security-token", securitytoken);
+                conditions.add(securityTokenCondition);
+            }
+
+        Map<String, String> signatureVersionCondition = new HashMap<>();
+        signatureVersionCondition.put("x-oss-signature-version", "OSS4-HMAC-SHA256");
+        conditions.add(signatureVersionCondition);
+
+        Map<String, String> credentialCondition = new HashMap<>();
+        credentialCondition.put("x-oss-credential", x_oss_credential); // 替换为实际的 access key id
+        conditions.add(credentialCondition);
+
+        Map<String, String> dateCondition = new HashMap<>();
+        dateCondition.put("x-oss-date", x_oss_date);
+        conditions.add(dateCondition);
+
+
+        conditions.add(Arrays.asList("content-length-range", 1, 104857600));
+        conditions.add(Arrays.asList("eq", "$success_action_status", "200"));
+        conditions.add(Arrays.asList("starts-with", "$key", uploadDir));
+
+        policy.put("conditions", conditions);
+
+        String jsonPolicy = mapper.writeValueAsString(policy);
+
+        // 步骤2:构造待签名字符串(StringToSign)。使用 UTF-8 与文档/服务端一致,避免签名差异。
+        String stringToSign = new String(Base64.encodeBase64(jsonPolicy.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
+
+        // 步骤3:计算SigningKey。
+        byte[] dateKey = hmacsha256(("aliyun_v4" + accesskeysecret).getBytes(), date);
+        byte[] dateRegionKey = hmacsha256(dateKey, region);
+        byte[] dateRegionServiceKey = hmacsha256(dateRegionKey, "oss");
+        byte[] signingKey = hmacsha256(dateRegionServiceKey, "aliyun_v4_request");
+
+        // 步骤4:计算Signature。
+        byte[] result = hmacsha256(signingKey, stringToSign);
+        String signature = BinaryUtil.toHex(result);
+
+        // 步骤5:设置回调(可选)。
+        String base64CallbackBody = "";
+        if (callbackUrl != null && !callbackUrl.isEmpty()) {
+            JSONObject jasonCallback = new JSONObject();
+            jasonCallback.put("callbackUrl", callbackUrl);
+            jasonCallback.put("callbackBody", "filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}");
+            jasonCallback.put("callbackBodyType", "application/x-www-form-urlencoded");
+            base64CallbackBody = BinaryUtil.toBase64String(jasonCallback.toString().getBytes(StandardCharsets.UTF_8));
+        }
+
+        Map<String, String> response = new HashMap<>();
+        // 与文档一致:version / x_oss_signature_version 均返回,便于前端按文档示例取值
+        response.put("version", "OSS4-HMAC-SHA256");
+        response.put("x_oss_signature_version", "OSS4-HMAC-SHA256");
+        // 这里是易错点,不能直接传policy,需要做一下Base64编码
+        response.put("policy", stringToSign);
+        response.put("x_oss_credential", x_oss_credential);
+        response.put("x_oss_date", x_oss_date);
+        response.put("signature", signature);
+        // 只有在使用STS时才添加security_token
+        if (securitytoken != null && !securitytoken.isEmpty()) {
+            response.put("security_token", securitytoken);
+        }
+        response.put("dir", uploadDir);
+        response.put("host", host);
+        // 前端 PostObject 表单字段名必须与 OSS 约定一致,否则会返回 MethodNotAllowed(405):
+        // policy, x-oss-signature(本接口返回 signature), x-oss-signature-version, x-oss-credential, x-oss-date,
+        // x-oss-security-token(有 STS 时), key(=dir+文件名), success_action_status, file(必须最后一项)
+        if (!base64CallbackBody.isEmpty()) {
+            response.put("callback", base64CallbackBody);
+        }
+        
+        log.info("生成OSS直传签名成功(OSS4方式): dir={}, host={}", uploadDir, host);
+        // 返回带有状态码 200 (OK) 的 ResponseEntity,返回给Web端,进行PostObject操作
+        return ResponseEntity.ok(response);
+        } catch (Exception e) {
+            log.error("生成OSS直传签名失败(OSS4方式): {}", e.getMessage(), e);
+            Map<String, String> errorResponse = new HashMap<>();
+            errorResponse.put("error", "生成签名失败: " + e.getMessage());
+            return ResponseEntity.status(500).body(errorResponse);
+        }
+    }
+    public static byte[] hmacsha256(byte[] key, String data) {
+        try {
+            // 初始化HMAC密钥规格,指定算法为HMAC-SHA256并使用提供的密钥。
+            SecretKeySpec secretKeySpec = new SecretKeySpec(key, "HmacSHA256");
+
+            // 获取Mac实例,并通过getInstance方法指定使用HMAC-SHA256算法。
+            Mac mac = Mac.getInstance("HmacSHA256");
+            // 使用密钥初始化Mac对象。
+            mac.init(secretKeySpec);
+
+            // 执行HMAC计算,通过doFinal方法接收需要计算的数据并返回计算结果的数组。
+            byte[] hmacBytes = mac.doFinal(data.getBytes());
+
+            return hmacBytes;
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to calculate HMAC-SHA256", e);
+        }
+    }
+}

+ 7 - 13
alien-store/src/main/java/shop/alien/store/service/impl/BarPerformanceServiceImpl.java

@@ -139,18 +139,12 @@ public class BarPerformanceServiceImpl implements BarPerformanceService {
                 barPerformance.setPerformanceWeek("");
                 break;
             case "2": // 每周定时
-                if (barPerformance.getDailyStartDate() == null) {
-                    throw new IllegalArgumentException("每周定时演出必须填写开始日期");
-                }
-                if (barPerformance.getDailyEndDate() == null) {
-                    throw new IllegalArgumentException("每周定时演出必须填写结束日期");
-                }
                 // 验证开始日期必须是今天或以后
-                if (barPerformance.getDailyStartDate().before(todayStart)) {
+                if (barPerformance.getDailyStartDate() != null && barPerformance.getDailyStartDate().before(todayStart)) {
                     throw new IllegalArgumentException("开始日期必须是今天或以后");
                 }
                 // 确保开始日期早于或等于结束日期
-                if (barPerformance.getDailyStartDate().after(barPerformance.getDailyEndDate())) {
+                if (barPerformance.getDailyEndDate() != null && barPerformance.getDailyStartDate() != null && barPerformance.getDailyStartDate().after(barPerformance.getDailyEndDate())) {
                     throw new IllegalArgumentException("开始日期不能晚于结束日期");
                 }
                 // 每周定时演出需要验证演出日期(周几)
@@ -165,11 +159,11 @@ public class BarPerformanceServiceImpl implements BarPerformanceService {
                     throw new IllegalArgumentException("每周定时演出必须填写结束时间");
                 }
                 // 校验:选择的日期(singleStartDatetime 的星期几)必须是 performanceWeek 中的某一天
-                int startDayOfWeek = getDayOfWeekAsPerformanceWeek(barPerformance.getSingleStartDatetime());
-                Set<Integer> allowedWeekDays = parsePerformanceWeekToSet(barPerformance.getPerformanceWeek());
-                if (!allowedWeekDays.contains(startDayOfWeek)) {
-                    throw new IllegalArgumentException("选择的开始日期对应的星期几必须在演出日期(周几)范围内");
-                }
+//                int startDayOfWeek = getDayOfWeekAsPerformanceWeek(barPerformance.getSingleStartDatetime());
+//                Set<Integer> allowedWeekDays = parsePerformanceWeekToSet(barPerformance.getPerformanceWeek());
+//                if (!allowedWeekDays.contains(startDayOfWeek)) {
+//                    throw new IllegalArgumentException("选择的开始日期对应的星期几必须在演出日期(周几)范围内");
+//                }
                 // 从single_start_datetime和single_end_datetime中提取时间部分,存到daily_start_time和daily_end_time
                 LocalTime weeklyStartTime = barPerformance.getSingleStartDatetime().toInstant()
                         .atZone(ZoneId.systemDefault())

+ 4 - 7
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingCategoryServiceImpl.java

@@ -323,13 +323,10 @@ public class StoreBookingCategoryServiceImpl extends ServiceImpl<StoreBookingCat
             return false;
         }
         
-        // 查询该门店该分类下是否有预订信息
-        LambdaQueryWrapper<UserReservation> wrapper = new LambdaQueryWrapper<>();
-        wrapper.eq(UserReservation::getStoreId, storeId)
-                .eq(UserReservation::getCategoryId, categoryId);
-        // @TableLogic 会自动过滤已删除的记录(delete_flag=0)
-        
-        long count = userReservationMapper.selectCount(wrapper);
+        // 查询该门店该分类下是否有符合条件的预订信息
+        // 条件:订单状态为"待使用"(1)或"已完成"(2)
+        // 对于"已完成"状态的订单,结束时间需要在当前时间3小时内
+        long count = userReservationMapper.countReservationsByCategoryAndOrderStatus(categoryId, storeId);
         boolean hasReservation = count > 0;
         
         log.info("查询分类下是否有预订信息完成,categoryId={}, storeId={}, count={}, hasReservation={}", 

+ 4 - 27
alien-store/src/main/java/shop/alien/store/service/impl/StoreBookingTableServiceImpl.java

@@ -420,33 +420,10 @@ public class StoreBookingTableServiceImpl extends ServiceImpl<StoreBookingTableM
             return false;
         }
         
-        // 查询该桌号下是否有预订信息
-        // 通过 user_reservation_table 关联 user_reservation 表查询
-        // 条件:table_id = 桌号ID,且 user_reservation.store_id = 门店ID,且两个表的 delete_flag = 0
-        LambdaQueryWrapper<UserReservationTable> tableWrapper = new LambdaQueryWrapper<>();
-        tableWrapper.eq(UserReservationTable::getTableId, tableId);
-        // @TableLogic 会自动过滤已删除的记录(delete_flag=0)
-        
-        List<UserReservationTable> reservationTables = userReservationTableMapper.selectList(tableWrapper);
-        
-        if (reservationTables == null || reservationTables.isEmpty()) {
-            log.info("查询桌号下是否有预订信息完成,tableId={}, storeId={}, count=0, hasReservation=false", 
-                    tableId, storeId);
-            return false;
-        }
-        
-        // 检查这些预订是否属于该门店
-        List<Integer> reservationIds = reservationTables.stream()
-                .map(UserReservationTable::getReservationId)
-                .distinct()
-                .collect(Collectors.toList());
-        
-        LambdaQueryWrapper<UserReservation> reservationWrapper = new LambdaQueryWrapper<>();
-        reservationWrapper.in(UserReservation::getId, reservationIds)
-                .eq(UserReservation::getStoreId, storeId);
-        // @TableLogic 会自动过滤已删除的记录(delete_flag=0)
-        
-        long count = userReservationMapper.selectCount(reservationWrapper);
+        // 查询该桌号下是否有符合条件的预订信息
+        // 条件:订单状态为"待使用"(1)或"已完成"(2)
+        // 对于"已完成"状态的订单,结束时间需要在当前时间3小时内
+        long count = userReservationMapper.countReservationsByTableAndOrderStatus(tableId, storeId);
         boolean hasReservation = count > 0;
         
         log.info("查询桌号下是否有预订信息完成,tableId={}, storeId={}, count={}, hasReservation={}", 

+ 2 - 0
alien-store/src/main/java/shop/alien/store/service/impl/StoreStaffAuditAsyncService.java

@@ -155,6 +155,7 @@ public class StoreStaffAuditAsyncService {
                 String reason = failureReasons.isEmpty() ? "审核未通过" : String.join("; ", failureReasons);
 //                String reason = "审核未通过";
                 updateWrapper.set(StoreStaffConfig::getStatus, "2")
+                             .set(StoreStaffConfig::getOnlineStatus, 2)
                              .set(StoreStaffConfig::getAuditTime, new Date())
                              .set(StoreStaffConfig::getRejectionReason, reason);
                 storeStaffConfigMapper.update(null, updateWrapper);
@@ -169,6 +170,7 @@ public class StoreStaffAuditAsyncService {
                 StoreStaffConfig auditUpdate = new StoreStaffConfig();
                 auditUpdate.setId(staffId);
                 auditUpdate.setStatus("2");
+                auditUpdate.setOnlineStatus(2);
                 auditUpdate.setAuditTime(new Date());
                 auditUpdate.setRejectionReason("审核系统异常,请稍后重试");
                 storeStaffConfigMapper.updateById(auditUpdate);

+ 3 - 2
alien-store/src/main/java/shop/alien/store/service/impl/UserReservationServiceImpl.java

@@ -257,9 +257,10 @@ public class UserReservationServiceImpl extends ServiceImpl<UserReservationMappe
         }
         Integer reservationId = one.getReservationId();
 
-        // 当订单为未支付时,订单状态变为已关闭
+        // 当订单为未支付且为收费订单时,订单状态变为已关闭(5);否则为已取消(4)
         int orderStatus = 4;
-        if (one.getPaymentStatus() != null && one.getPaymentStatus() == 0) {
+        if (one.getPaymentStatus() != null && one.getPaymentStatus() == 0
+                && one.getOrderCostType() != null && one.getOrderCostType() == 1) {
             orderStatus = 5;
         }
 

+ 563 - 0
alien-store/src/main/java/shop/alien/store/util/oss/OSSDirectUploadUtil.java

@@ -0,0 +1,563 @@
+package shop.alien.store.util.oss;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.alibaba.fastjson.annotation.JSONField;
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.OSSClientBuilder;
+import com.aliyun.oss.common.comm.SignVersion;
+import com.aliyun.oss.common.utils.BinaryUtil;
+import com.aliyun.oss.model.*;
+import com.baomidou.mybatisplus.core.toolkit.StringUtils;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+/**
+ * OSS直传工具类
+ * 支持:签名生成、分片上传、断点续传、上传进度
+ *
+ * @author system
+ * @date 2025-01-XX
+ */
+@Slf4j
+@Component
+@RefreshScope
+public class OSSDirectUploadUtil {
+
+    @Value("${ali.oss.accessKeyId}")
+    private String accessKeyId;
+
+    @Value("${ali.oss.accessKeySecret}")
+    private String accessKeySecret;
+
+    @Value("${ali.oss.endPoint}")
+    private String endPoint;
+
+    @Value("${ali.oss.bucketName}")
+    private String bucketName;
+
+    // 默认过期时间:1小时
+    private static final long DEFAULT_EXPIRE_TIME = 3600 * 1000L;
+
+    /**
+     * 生成OSS直传签名(Post Policy方式)
+     * 
+     * @param dir 上传目录(如:video/、image/)
+     * @param fileName 文件名(可选,不传则自动生成)
+     * @param maxSize 最大文件大小(字节,默认100MB)
+     * @param expireTime 过期时间(毫秒,默认1小时)
+     * @return 签名信息
+     */
+    public OSSSignatureResult generatePostSignature(String dir, String fileName, Long maxSize, Long expireTime) {
+        try {
+            // 参数校验和默认值
+            if (StringUtils.isEmpty(dir)) {
+                dir = "upload/";
+            }
+            if (!dir.endsWith("/")) {
+                dir += "/";
+            }
+            if (StringUtils.isEmpty(fileName)) {
+                fileName = UUID.randomUUID().toString();
+            }
+            if (maxSize == null || maxSize <= 0) {
+                maxSize = 100 * 1024 * 1024L; // 默认100MB
+            }
+            if (expireTime == null || expireTime <= 0) {
+                expireTime = DEFAULT_EXPIRE_TIME;
+            }
+
+            // 生成OSS文件路径
+            String ossKey = dir + fileName;
+            
+            // 过期时间(Unix时间戳,秒)
+            long expire = (System.currentTimeMillis() + expireTime) / 1000;
+            Date expiration = new Date(expire * 1000);
+            
+            // 构建Post Policy
+            // 使用 JSONObject 和 JSONArray 确保格式完全符合 OSS 规范
+            JSONObject policyObj = new JSONObject();
+            
+            // 1. 设置过期时间(必须未来时间,ISO8601 格式,UTC 时区)
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
+            sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
+            String expirationStr = sdf.format(expiration);
+            policyObj.put("expiration", expirationStr);
+            
+            // 2. 构建 conditions 数组(必须严格按照 OSS 规范)
+            // OSS 支持的匹配方式:content-length-range, eq, starts-with, in, not-in
+            // 注意:不支持 ends-with 等其他匹配方式
+            JSONArray conditions = new JSONArray();
+            
+            // bucket 条件:["eq", "$bucket", "bucket-name"](必须使用 eq 和 $bucket)
+            JSONArray bucketCondition = new JSONArray();
+            bucketCondition.add("eq");
+            bucketCondition.add("$bucket");
+            bucketCondition.add(bucketName);
+            conditions.add(bucketCondition);
+            
+            // key 条件:["starts-with", "$key", "dir/"](前缀匹配,允许上传到指定目录下的任意文件)
+            // 使用 starts-with 而不是 eq,这样前端可以上传任意文件名到指定目录
+            // 注意:dir 已经确保以 "/" 结尾
+            JSONArray keyCondition = new JSONArray();
+            keyCondition.add("starts-with");
+            keyCondition.add("$key");
+            keyCondition.add(dir);  // 使用目录前缀,如 "uploads/report/"
+            conditions.add(keyCondition);
+            
+            // 文件大小范围:["content-length-range", "0", "maxSize"]
+            JSONArray sizeCondition = new JSONArray();
+            sizeCondition.add("content-length-range");
+            sizeCondition.add("0");
+            sizeCondition.add(String.valueOf(maxSize));
+            conditions.add(sizeCondition);
+            
+            // 成功返回状态码:["eq", "$success_action_status", "200"](可选,但推荐)
+            JSONArray statusCondition = new JSONArray();
+            statusCondition.add("eq");
+            statusCondition.add("$success_action_status");
+            statusCondition.add("200");
+            conditions.add(statusCondition);
+            
+            policyObj.put("conditions", conditions);
+            
+            // 3. 转换为 JSON 字符串(使用 toJSONString 确保格式正确)
+            String policyJson = policyObj.toJSONString();
+            
+            // 4. 验证 JSON 是否有效
+            try {
+                JSON.parseObject(policyJson);
+                log.info("Policy JSON 验证通过: {}", policyJson);
+            } catch (Exception e) {
+                log.error("Policy JSON 格式无效: {}", policyJson, e);
+                throw new RuntimeException("生成的 Policy JSON 格式无效", e);
+            }
+            
+            // 5. Base64 编码(确保没有换行符)
+            String policyBase64 = BinaryUtil.toBase64String(policyJson.getBytes(StandardCharsets.UTF_8));
+            // 移除可能的换行符(Base64 编码不应该包含换行符)
+            policyBase64 = policyBase64.replaceAll("\\s+", "");
+            log.info("Policy Base64: {}", policyBase64);
+            
+            // 6. 验证 Base64 解码后是否为合法 JSON
+            try {
+                String decoded = new String(java.util.Base64.getDecoder().decode(policyBase64), StandardCharsets.UTF_8);
+                JSON.parseObject(decoded);
+                log.info("Base64 解码验证通过,解码后的 JSON: {}", decoded);
+            } catch (Exception e) {
+                log.error("Base64 解码后不是合法 JSON,原始 Base64: {}", policyBase64, e);
+                throw new RuntimeException("Base64 解码后不是合法 JSON", e);
+            }
+
+
+
+            // 计算签名
+            String signature = calculateSignature(policyBase64);
+            
+            // 构建返回结果
+            OSSSignatureResult result = new OSSSignatureResult();
+            result.setAccessKeyId(accessKeyId);
+            result.setPolicy(policyBase64);
+            result.setSignature(signature);
+            result.setDir(dir);
+            result.setHost("https://" + bucketName + "." + endPoint);
+            result.setExpire(String.valueOf(expire));
+            result.setOssKey(ossKey);
+            result.setCallbackUrl(""); // 回调URL(可选)
+            result.setCallbackBody(""); // 回调Body(可选)
+            
+            // 输出完整的签名信息(用于调试)
+            log.info("生成OSS直传签名成功: dir={}, fileName={}, ossKey={}", dir, fileName, ossKey);
+            log.info("签名信息 - Policy长度: {}, Signature: {}", policyBase64.length(), signature);
+            
+            return result;
+            
+        } catch (Exception e) {
+            log.error("生成OSS直传签名失败: {}", e.getMessage(), e);
+            throw new RuntimeException("生成OSS签名失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 构建 Post Policy JSON
+     * 按照 OSS 规范:先生成合法的 JSON 字符串,再进行 Base64 编码
+     * 使用 JSONObject 和 JSONArray 手动构建,确保格式完全符合 OSS 要求
+     */
+    private String buildPostPolicy(String bucket, String key, long maxSize, Date expiration) {
+        try {
+            // 1. 设置过期时间(必须未来时间,ISO8601 格式,UTC 时区)
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
+            sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
+            String expirationStr = sdf.format(expiration);
+            
+            // 2. 使用 JSONObject 和 JSONArray 手动构建 Policy JSON
+            // 这样可以确保格式完全符合 OSS 规范,避免 FastJSON 自动序列化的问题
+            JSONObject policyObj = new JSONObject();
+            policyObj.put("expiration", expirationStr);
+            
+            // 3. 构建 conditions 数组(必须严格按照 OSS 规范)
+            JSONArray conditions = new JSONArray();
+            
+            // bucket 条件:["eq", "$bucket", "bucket-name"]
+            JSONArray bucketCondition = new JSONArray();
+            bucketCondition.add("eq");
+            bucketCondition.add("$bucket");
+            bucketCondition.add(bucket);
+            conditions.add(bucketCondition);
+            
+            // key 条件:["eq", "$key", "file-path"]
+            JSONArray keyCondition = new JSONArray();
+            keyCondition.add("eq");
+            keyCondition.add("$key");
+            keyCondition.add(key);
+            conditions.add(keyCondition);
+            
+            // 文件大小范围:["content-length-range", "0", "maxSize"]
+            // 注意:OSS 要求所有参数都是字符串格式
+            JSONArray sizeCondition = new JSONArray();
+            sizeCondition.add("content-length-range");
+            sizeCondition.add("0");
+            sizeCondition.add(String.valueOf(maxSize));
+            conditions.add(sizeCondition);
+            
+            policyObj.put("conditions", conditions);
+            
+            // 4. 转换为 JSON 字符串(使用 toJSONString 确保格式正确)
+            String policyJson = policyObj.toJSONString();
+            
+            // 5. 验证 JSON 是否有效(用于调试)
+            try {
+                JSON.parseObject(policyJson);
+                log.info("Policy JSON 验证通过: {}", policyJson);
+            } catch (Exception e) {
+                log.error("Policy JSON 格式无效: {}", policyJson, e);
+                throw new RuntimeException("生成的 Policy JSON 格式无效", e);
+            }
+            
+            // 6. Base64 编码后返回
+            String policyBase64 = BinaryUtil.toBase64String(policyJson.getBytes(StandardCharsets.UTF_8));
+            log.info("Policy Base64: {}", policyBase64);
+            
+            // 7. 验证 Base64 解码后是否为合法 JSON(用于调试)
+            try {
+                String decoded = new String(java.util.Base64.getDecoder().decode(policyBase64), StandardCharsets.UTF_8);
+                JSON.parseObject(decoded);
+                log.info("Base64 解码验证通过,解码后的 JSON: {}", decoded);
+            } catch (Exception e) {
+                log.error("Base64 解码后不是合法 JSON,原始 Base64: {}", policyBase64, e);
+                // 尝试手动解码查看内容
+                try {
+                    String decoded = new String(java.util.Base64.getDecoder().decode(policyBase64), StandardCharsets.UTF_8);
+                    log.error("Base64 解码后的内容: {}", decoded);
+                } catch (Exception decodeEx) {
+                    log.error("无法解码 Base64: {}", decodeEx.getMessage());
+                }
+                throw new RuntimeException("Base64 解码后不是合法 JSON", e);
+            }
+            
+            return policyBase64;
+        } catch (Exception e) {
+            log.error("构建 Post Policy 失败:{}", e.getMessage(), e);
+            throw new RuntimeException("构建 Post Policy 失败: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 计算签名
+     */
+    private String calculateSignature(String policy) {
+        try {
+            Mac hmac = Mac.getInstance("HmacSHA256");
+            hmac.init(new SecretKeySpec(accessKeySecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+            byte[] signData = hmac.doFinal(policy.getBytes(StandardCharsets.UTF_8));
+            return BinaryUtil.toBase64String(signData);
+        } catch (Exception e) {
+            log.error("计算签名失败: {}", e.getMessage(), e);
+            throw new RuntimeException("计算签名失败", e);
+        }
+    }
+
+    /**
+     * 初始化分片上传
+     * 
+     * @param ossKey OSS文件路径
+     * @return 上传ID
+     */
+    public String initMultipartUpload(String ossKey) {
+        OSS ossClient = null;
+        try {
+            ossClient = createOSSClient();
+            InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(bucketName, ossKey);
+            InitiateMultipartUploadResult result = ossClient.initiateMultipartUpload(request);
+            String uploadId = result.getUploadId();
+            log.info("初始化分片上传成功: ossKey={}, uploadId={}", ossKey, uploadId);
+            return uploadId;
+        } catch (Exception e) {
+            log.error("初始化分片上传失败: ossKey={}, error={}", ossKey, e.getMessage(), e);
+            throw new RuntimeException("初始化分片上传失败: " + e.getMessage(), e);
+        } finally {
+            if (ossClient != null) {
+                ossClient.shutdown();
+            }
+        }
+    }
+
+    /**
+     * 上传分片
+     * 
+     * @param ossKey OSS文件路径
+     * @param uploadId 上传ID
+     * @param partNumber 分片序号(从1开始)
+     * @param partData 分片数据
+     * @return 分片ETag
+     */
+    public String uploadPart(String ossKey, String uploadId, int partNumber, byte[] partData) {
+        OSS ossClient = null;
+        try {
+            ossClient = createOSSClient();
+            UploadPartRequest uploadPartRequest = new UploadPartRequest();
+            uploadPartRequest.setBucketName(bucketName);
+            uploadPartRequest.setKey(ossKey);
+            uploadPartRequest.setUploadId(uploadId);
+            uploadPartRequest.setPartNumber(partNumber);
+            uploadPartRequest.setInputStream(new java.io.ByteArrayInputStream(partData));
+            uploadPartRequest.setPartSize(partData.length);
+            
+            UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
+            String eTag = uploadPartResult.getETag();
+            log.debug("上传分片成功: ossKey={}, partNumber={}, eTag={}", ossKey, partNumber, eTag);
+            return eTag;
+        } catch (Exception e) {
+            log.error("上传分片失败: ossKey={}, partNumber={}, error={}", ossKey, partNumber, e.getMessage(), e);
+            throw new RuntimeException("上传分片失败: " + e.getMessage(), e);
+        } finally {
+            if (ossClient != null) {
+                ossClient.shutdown();
+            }
+        }
+    }
+
+    /**
+     * 完成分片上传(合并所有分片)
+     * 
+     * @param ossKey OSS文件路径
+     * @param uploadId 上传ID
+     * @param partETags 所有分片的ETag列表
+     * @return 文件URL
+     */
+    public String completeMultipartUpload(String ossKey, String uploadId, List<PartETag> partETags) {
+        OSS ossClient = null;
+        try {
+            ossClient = createOSSClient();
+            CompleteMultipartUploadRequest completeRequest = new CompleteMultipartUploadRequest(
+                    bucketName, ossKey, uploadId, partETags);
+            CompleteMultipartUploadResult completeResult = ossClient.completeMultipartUpload(completeRequest);
+            
+            String fileUrl = "https://" + bucketName + "." + endPoint + "/" + ossKey;
+            log.info("完成分片上传: ossKey={}, uploadId={}, fileUrl={}", ossKey, uploadId, fileUrl);
+            return fileUrl;
+        } catch (Exception e) {
+            log.error("完成分片上传失败: ossKey={}, uploadId={}, error={}", ossKey, uploadId, e.getMessage(), e);
+            throw new RuntimeException("完成分片上传失败: " + e.getMessage(), e);
+        } finally {
+            if (ossClient != null) {
+                ossClient.shutdown();
+            }
+        }
+    }
+
+    /**
+     * 取消分片上传(清理未完成的分片)
+     * 
+     * @param ossKey OSS文件路径
+     * @param uploadId 上传ID
+     */
+    public void abortMultipartUpload(String ossKey, String uploadId) {
+        OSS ossClient = null;
+        try {
+            ossClient = createOSSClient();
+            AbortMultipartUploadRequest abortRequest = new AbortMultipartUploadRequest(bucketName, ossKey, uploadId);
+            ossClient.abortMultipartUpload(abortRequest);
+            log.info("取消分片上传: ossKey={}, uploadId={}", ossKey, uploadId);
+        } catch (Exception e) {
+            log.error("取消分片上传失败: ossKey={}, uploadId={}, error={}", ossKey, uploadId, e.getMessage(), e);
+        } finally {
+            if (ossClient != null) {
+                ossClient.shutdown();
+            }
+        }
+    }
+
+    /**
+     * 列出已上传的分片
+     * 
+     * @param ossKey OSS文件路径
+     * @param uploadId 上传ID
+     * @return 已上传的分片列表
+     */
+    public List<PartSummary> listParts(String ossKey, String uploadId) {
+        OSS ossClient = null;
+        try {
+            ossClient = createOSSClient();
+            ListPartsRequest listPartsRequest = new ListPartsRequest(bucketName, ossKey, uploadId);
+            PartListing partListing = ossClient.listParts(listPartsRequest);
+            return partListing.getParts();
+        } catch (Exception e) {
+            log.error("列出分片失败: ossKey={}, uploadId={}, error={}", ossKey, uploadId, e.getMessage(), e);
+            throw new RuntimeException("列出分片失败: " + e.getMessage(), e);
+        } finally {
+            if (ossClient != null) {
+                ossClient.shutdown();
+            }
+        }
+    }
+
+    /**
+     * 验证OSS回调签名
+     * 
+     * @param authorizationHeader Authorization头
+     * @param pubKeyUrl 公钥URL
+     * @param callbackBody 回调Body
+     * @return 是否验证通过
+     */
+    public boolean verifyCallback(String authorizationHeader, String pubKeyUrl, String callbackBody) {
+        try {
+            // 从公钥URL获取公钥
+            String pubKey = getPublicKey(pubKeyUrl);
+            
+            // 验证签名
+            String[] authParts = authorizationHeader.split(":");
+            if (authParts.length != 2) {
+                return false;
+            }
+            
+            String signature = authParts[1];
+            String stringToSign = callbackBody;
+            
+            // 使用公钥验证签名
+            java.security.Signature signatureVerifier = java.security.Signature.getInstance("SHA1withRSA");
+            signatureVerifier.initVerify(java.security.KeyFactory.getInstance("RSA")
+                    .generatePublic(new java.security.spec.X509EncodedKeySpec(
+                            Base64.getDecoder().decode(pubKey))));
+            signatureVerifier.update(stringToSign.getBytes(StandardCharsets.UTF_8));
+            
+            return signatureVerifier.verify(Base64.getDecoder().decode(signature));
+        } catch (Exception e) {
+            log.error("验证OSS回调签名失败: {}", e.getMessage(), e);
+            return false;
+        }
+    }
+
+    /**
+     * 从URL获取公钥
+     */
+    private String getPublicKey(String pubKeyUrl) {
+        try {
+            java.net.URL url = new java.net.URL(pubKeyUrl);
+            java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
+            conn.setRequestMethod("GET");
+            conn.connect();
+            
+            StringBuilder response = new StringBuilder();
+            try (java.io.BufferedReader reader = new java.io.BufferedReader(
+                    new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    response.append(line);
+                }
+            }
+            
+            // 移除公钥的头部和尾部
+            String pubKey = response.toString();
+            pubKey = pubKey.replace("-----BEGIN PUBLIC KEY-----", "");
+            pubKey = pubKey.replace("-----END PUBLIC KEY-----", "");
+            pubKey = pubKey.replace("\n", "");
+            pubKey = pubKey.replace("\r", "");
+            
+            return pubKey;
+        } catch (Exception e) {
+            log.error("获取公钥失败: {}", e.getMessage(), e);
+            throw new RuntimeException("获取公钥失败", e);
+        }
+    }
+
+
+
+    /**
+     * 生成 HMAC-SHA1 签名
+     * @param data 待签名数据(Policy Base64)
+     * @param secretKey 秘钥(AccessKeySecret)
+     * @return 签名结果(Base64 编码)
+     */
+    private static String generateHmacSHA1Signature(String data, String secretKey) throws Exception {
+        Mac mac = Mac.getInstance("HmacSHA1");
+        SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA1");
+        mac.init(secretKeySpec);
+        byte[] signBytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
+        return BinaryUtil.toBase64String(signBytes);
+    }
+
+
+
+
+
+
+
+
+
+    /**
+     * 创建OSS客户端
+     */
+    private OSS createOSSClient() {
+        com.aliyun.oss.ClientBuilderConfiguration config = new com.aliyun.oss.ClientBuilderConfiguration();
+        config.setSignatureVersion(SignVersion.V4);
+        return new OSSClientBuilder().build(endPoint, accessKeyId, accessKeySecret);
+    }
+
+    /**
+     * OSS签名结果
+     */
+    /**
+     * OSS签名结果
+     * 注意:使用 @JSONField 确保字段名正确序列化
+     */
+    @Data
+    public static class OSSSignatureResult {
+        @JSONField(name = "accessKeyId")
+        private String accessKeyId;
+        
+        @JSONField(name = "policy")
+        private String policy;
+        
+        @JSONField(name = "signature")
+        private String signature;
+        
+        @JSONField(name = "dir")
+        private String dir;
+        
+        @JSONField(name = "host")
+        private String host;
+        
+        @JSONField(name = "expire")
+        private String expire;
+        
+        @JSONField(name = "ossKey")
+        private String ossKey;
+        
+        @JSONField(name = "callbackUrl")
+        private String callbackUrl;
+        
+        @JSONField(name = "callbackBody")
+        private String callbackBody;
+    }
+}

+ 24 - 0
alien-util/pom.xml

@@ -361,6 +361,30 @@
             <version>3.17.4</version>
         </dependency>
 
+
+        <!-- 阿里云 OSS 核心依赖 -->
+        <dependency>
+            <groupId>com.aliyun.oss</groupId>
+            <artifactId>aliyun-sdk-oss</artifactId>
+            <version>3.16.0</version>
+        </dependency>
+        <!-- 用于 JSON 处理 -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+            <version>2.0.32</version>
+        </dependency>
+        <!-- 用于 HMAC-SHA1 签名 -->
+        <dependency>
+            <groupId>commons-codec</groupId>
+            <artifactId>commons-codec</artifactId>
+            <version>1.15</version>
+        </dependency>
+
+
+
+
+
         <dependency>
             <groupId>org.springframework.cloud</groupId>
             <artifactId>spring-cloud-context</artifactId>