From e31af58d581e27bea46045c7fda9971dae2b1134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Wed, 16 Sep 2026 16:50:05 +0800 Subject: [PATCH] Add points task submission and review workflow --- docs/point-task-setup.md | 48 ++ .../app/AppPointTaskController.java | 267 +++++++++ .../controller/common/CommonController.java | 30 +- .../java/com/ruoyi/web/qiniu/QiNiuConfig.java | 10 + .../java/com/ruoyi/web/qiniu/QiNiuUtil.java | 45 +- .../task/PointTaskAttachmentCleanupJob.java | 36 ++ .../ruoyi/app/domain/AppIntegralRecord.java | 22 + .../com/ruoyi/app/domain/AppPointTask.java | 72 +++ .../app/domain/AppPointTaskAttachment.java | 51 ++ .../app/domain/AppPointTaskAuditLog.java | 29 + .../app/domain/AppPointTaskSubmission.java | 81 +++ .../app/domain/AppPointTaskUploadRequest.java | 19 + .../ruoyi/app/mapper/AppPointTaskMapper.java | 15 + .../mapper/AppPointTaskSubmissionMapper.java | 37 ++ .../app/service/IAppPointTaskService.java | 28 + .../service/impl/AppPointTaskServiceImpl.java | 544 ++++++++++++++++++ .../mapper/app/AppIntegralRecordMapper.xml | 11 +- .../mapper/app/AppPointTaskMapper.xml | 97 ++++ .../app/AppPointTaskSubmissionMapper.xml | 201 +++++++ ruoyi-ui/src/api/app/pointTask.js | 41 ++ .../src/components/QiniuImageUpload/index.vue | 25 +- ruoyi-ui/src/views/app/pointTask/index.vue | 93 +++ .../src/views/app/pointTaskReview/index.vue | 97 ++++ sql/point_task.sql | 216 +++++++ 24 files changed, 2097 insertions(+), 18 deletions(-) create mode 100644 docs/point-task-setup.md create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppPointTaskController.java create mode 100644 ruoyi-admin/src/main/java/com/ruoyi/web/task/PointTaskAttachmentCleanupJob.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTask.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskAttachment.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskAuditLog.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskSubmission.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskUploadRequest.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppPointTaskMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppPointTaskSubmissionMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/service/IAppPointTaskService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppPointTaskServiceImpl.java create mode 100644 ruoyi-system/src/main/resources/mapper/app/AppPointTaskMapper.xml create mode 100644 ruoyi-system/src/main/resources/mapper/app/AppPointTaskSubmissionMapper.xml create mode 100644 ruoyi-ui/src/api/app/pointTask.js create mode 100644 ruoyi-ui/src/views/app/pointTask/index.vue create mode 100644 ruoyi-ui/src/views/app/pointTaskReview/index.vue create mode 100644 sql/point_task.sql diff --git a/docs/point-task-setup.md b/docs/point-task-setup.md new file mode 100644 index 0000000..b1b183e --- /dev/null +++ b/docs/point-task-setup.md @@ -0,0 +1,48 @@ +# 积分任务中心上线说明 + +## 1. 初始化数据库 + +在目标业务数据库执行: + +```text +sql/point_task.sql +``` + +脚本会创建任务、提交、附件、审核日志表,为积分流水增加业务幂等字段,并添加“积分任务”和“任务审核”后台菜单。 + +## 2. 七牛云配置 + +确认 `ruoyi-admin/src/main/resources/qiniu.properties` 中以下配置与七牛空间一致: + +```properties +qiniu.bucket=blog +qiniu.zone=zone0 +qiniu.domain-of-bucket=img.yidaima.cn +qiniu.upload-host=https://upload.qiniup.com +``` + +任务截图使用客户端直传。后端签发10分钟有效、绑定单个对象Key、限制5MB且仅允许 JPG/PNG/WebP 的上传凭证。 + +上传凭证接口按IP限制每分钟最多20次;每个用户最多保留9张未提交截图,每天最多申请30张截图凭证。 + +后端每天凌晨 03:30 自动清理超过24小时仍未绑定任务的七牛临时截图和数据库临时记录。 + +## 3. 微信公众平台合法域名 + +在小程序后台配置: + +- `uploadFile` 合法域名:`https://upload.qiniup.com` +- `downloadFile` 合法域名:七牛图片域名,例如 `https://img.yidaima.cn` +- `request` 合法域名:现有业务接口域名 + +如果七牛空间区域发生变化,请同步修改 `qiniu.upload-host` 和微信小程序合法域名。 + +## 4. 发布顺序 + +1. 执行数据库脚本。 +2. 发布 RuoYi 后端。 +3. 发布 RuoYi 管理端。 +4. 在后台创建一次性或每日任务。 +5. 发布小程序前端。 + +未执行数据库脚本前,不要调用新的任务接口。 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppPointTaskController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppPointTaskController.java new file mode 100644 index 0000000..458d6af --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/app/AppPointTaskController.java @@ -0,0 +1,267 @@ +package com.ruoyi.web.controller.app; + +import java.io.UnsupportedEncodingException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import com.ruoyi.app.domain.AppPointTask; +import com.ruoyi.app.domain.AppPointTaskAttachment; +import com.ruoyi.app.domain.AppPointTaskSubmission; +import com.ruoyi.app.domain.AppPointTaskUploadRequest; +import com.ruoyi.app.service.IAppPointTaskService; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.annotation.RateLimiter; +import com.ruoyi.common.annotation.RepeatSubmit; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.enums.LimitType; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.web.qiniu.QiNiuConfig; +import com.ruoyi.web.qiniu.QiNiuUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 积分任务、小程序提交和后台审核接口。 */ +@RestController +@RequestMapping("/app/pointTask") +public class AppPointTaskController extends BaseController +{ + private static final long MAX_FILE_SIZE = 5L * 1024L * 1024L; + private static final int UPLOAD_TOKEN_EXPIRES = 600; + + @Autowired + private IAppPointTaskService pointTaskService; + + @PreAuthorize("isAuthenticated()") + @GetMapping("/active") + public AjaxResult activeList() + { + return success(pointTaskService.selectActiveTaskList(getUserId())); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/active/{id}") + public AjaxResult activeDetail(@PathVariable Long id) + { + return success(pointTaskService.selectActiveTaskDetail(id, getUserId())); + } + + @PreAuthorize("isAuthenticated()") + @RateLimiter(time = 60, count = 20, limitType = LimitType.IP) + @PostMapping("/upload-token") + public AjaxResult uploadToken(@RequestBody AppPointTaskUploadRequest request) + { + validateUploadRequest(request); + Long userId = getUserId(); + String extension = extensionOf(request.getFileName(), request.getContentType()); + String key = "point-task/" + userId + "/" + + LocalDate.now(ZoneId.of("Asia/Shanghai")).toString().substring(0, 7).replace("-", "") + + "/" + IdUtils.fastSimpleUUID() + extension; + + AppPointTaskAttachment attachment = new AppPointTaskAttachment(); + attachment.setUserId(userId); + attachment.setObjectKey(key); + attachment.setOriginalName(request.getFileName()); + attachment.setFileSize(request.getFileSize()); + attachment.setFileType(request.getContentType()); + pointTaskService.createTempAttachment(attachment); + + Map data = new HashMap<>(); + data.put("uploadToken", QiNiuUtil.createTaskImageUploadToken(key, UPLOAD_TOKEN_EXPIRES, MAX_FILE_SIZE)); + data.put("objectKey", key); + data.put("uploadHost", QiNiuConfig.getInstance().getUploadHost()); + data.put("expiresIn", UPLOAD_TOKEN_EXPIRES); + return success(data); + } + + @PreAuthorize("isAuthenticated()") + @Log(title = "积分任务提交", businessType = BusinessType.INSERT) + @RepeatSubmit(interval = 3000) + @PostMapping("/{taskId}/submit") + public AjaxResult submit(@PathVariable Long taskId, @RequestBody AppPointTaskSubmission request) + { + verifyUploadedFiles(request.getAttachmentKeys(), getUserId()); + return success(pointTaskService.submitTask(taskId, request, getUserId())); + } + + @PreAuthorize("isAuthenticated()") + @Log(title = "积分任务重新提交", businessType = BusinessType.UPDATE) + @RepeatSubmit(interval = 3000) + @PostMapping("/submission/{id}/resubmit") + public AjaxResult resubmit(@PathVariable Long id, @RequestBody AppPointTaskSubmission request) + { + verifyUploadedFiles(request.getAttachmentKeys(), getUserId()); + return success(pointTaskService.resubmitTask(id, request, getUserId())); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/submission/my") + public TableDataInfo myList() + { + startPage(); + return getDataTable(pointTaskService.selectMySubmissionList(getUserId())); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/submission/my/{id}") + public AjaxResult myDetail(@PathVariable Long id) + { + AppPointTaskSubmission submission = pointTaskService.selectMySubmissionDetail(id, getUserId()); + populatePreviewUrls(submission); + return success(submission); + } + + @PreAuthorize("@ss.hasPermi('app:pointTask:list')") + @GetMapping("/list") + public TableDataInfo list(AppPointTask task) + { + startPage(); + return getDataTable(pointTaskService.selectAppPointTaskList(task)); + } + + @PreAuthorize("@ss.hasPermi('app:pointTask:query')") + @GetMapping("/{id}") + public AjaxResult detail(@PathVariable Long id) + { + return success(pointTaskService.selectAppPointTaskById(id)); + } + + @PreAuthorize("@ss.hasPermi('app:pointTask:add')") + @Log(title = "积分任务", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@RequestBody AppPointTask task) + { + task.setCreateBy(getUsername()); + task.setUpdateBy(getUsername()); + return toAjax(pointTaskService.insertAppPointTask(task)); + } + + @PreAuthorize("@ss.hasPermi('app:pointTask:edit')") + @Log(title = "积分任务", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@RequestBody AppPointTask task) + { + task.setUpdateBy(getUsername()); + return toAjax(pointTaskService.updateAppPointTask(task)); + } + + @PreAuthorize("@ss.hasPermi('app:pointTask:remove')") + @Log(title = "积分任务", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) + { + return toAjax(pointTaskService.deleteAppPointTaskByIds(ids)); + } + + @PreAuthorize("@ss.hasPermi('app:pointTaskReview:list')") + @GetMapping("/submission/list") + public TableDataInfo submissionList(AppPointTaskSubmission submission) + { + startPage(); + return getDataTable(pointTaskService.selectSubmissionList(submission)); + } + + @PreAuthorize("@ss.hasPermi('app:pointTaskReview:query')") + @GetMapping("/submission/{id}") + public AjaxResult submissionDetail(@PathVariable Long id) + { + AppPointTaskSubmission submission = pointTaskService.selectSubmissionDetail(id); + if (submission == null) + { + return error("任务记录不存在"); + } + populatePreviewUrls(submission); + return success(submission); + } + + @PreAuthorize("@ss.hasPermi('app:pointTaskReview:edit')") + @Log(title = "积分任务审核通过", businessType = BusinessType.UPDATE) + @PostMapping("/submission/{id}/approve") + public AjaxResult approve(@PathVariable Long id) + { + return toAjax(pointTaskService.approveSubmission(id, getUsername())); + } + + @PreAuthorize("@ss.hasPermi('app:pointTaskReview:edit')") + @Log(title = "积分任务审核驳回", businessType = BusinessType.UPDATE) + @PostMapping("/submission/{id}/reject") + public AjaxResult reject(@PathVariable Long id, @RequestBody AppPointTaskSubmission request) + { + return toAjax(pointTaskService.rejectSubmission(id, request.getRejectReason(), getUsername())); + } + + @PreAuthorize("@ss.hasPermi('app:pointTaskReview:revoke')") + @Log(title = "积分任务撤销奖励", businessType = BusinessType.UPDATE) + @PostMapping("/submission/{id}/revoke") + public AjaxResult revoke(@PathVariable Long id, @RequestBody AppPointTaskSubmission request) + { + return toAjax(pointTaskService.revokeSubmission(id, request.getRejectReason(), getUsername())); + } + + private void validateUploadRequest(AppPointTaskUploadRequest request) + { + if (request == null || StringUtils.isEmpty(request.getFileName()) || request.getFileSize() == null + || request.getFileSize() <= 0 || request.getFileSize() > MAX_FILE_SIZE) + { + throw new ServiceException("截图大小不能超过5MB"); + } + String contentType = StringUtils.trim(request.getContentType()); + if (!"image/jpeg".equals(contentType) && !"image/png".equals(contentType) && !"image/webp".equals(contentType)) + { + throw new ServiceException("仅支持JPG、PNG和WebP图片"); + } + } + + private String extensionOf(String fileName, String contentType) + { + if ("image/png".equals(contentType)) return ".png"; + if ("image/webp".equals(contentType)) return ".webp"; + return ".jpg"; + } + + private void verifyUploadedFiles(List keys, Long userId) + { + if (keys == null || keys.isEmpty() || keys.size() > 3) + { + throw new ServiceException("请上传1至3张任务截图"); + } + String prefix = "point-task/" + userId + "/"; + for (String key : keys) + { + if (StringUtils.isEmpty(key) || !key.startsWith(prefix) || !QiNiuUtil.exists(key)) + { + throw new ServiceException("截图上传未完成,请重新上传"); + } + } + } + + private void populatePreviewUrls(AppPointTaskSubmission submission) + { + if (submission == null || submission.getAttachments() == null) return; + for (AppPointTaskAttachment attachment : submission.getAttachments()) + { + try + { + attachment.setPreviewUrl(QiNiuUtil.fileUrl(attachment.getObjectKey())); + } + catch (UnsupportedEncodingException ex) + { + attachment.setPreviewUrl(null); + } + } + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java index ef4ab27..351dc5d 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java @@ -20,6 +20,7 @@ import org.springframework.web.multipart.MultipartFile; import com.ruoyi.common.config.RuoYiConfig; import com.ruoyi.common.constant.Constants; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.DateUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.file.FileUploadUtils; import com.ruoyi.common.utils.file.FileUtils; @@ -94,21 +95,34 @@ public class CommonController { @PostMapping("/qiniuUpload") public AjaxResult qiniuUploadFile(MultipartFile file) throws Exception { try { - // 上传并返回新文件名称 + if (file == null || file.isEmpty()) { + return AjaxResult.error("上传文件不能为空"); + } String originalFilename = file.getOriginalFilename(); - String fname = originalFilename.substring(0, originalFilename.lastIndexOf('.')) + "-" + UUID.randomUUID().toString().replace("-", ""); - String fkey = "http://" + QiNiuConfig.getInstance().getDomainOfBucket() + "/" + fname; + String extension = ""; + if (originalFilename != null && originalFilename.lastIndexOf('.') >= 0) { + extension = originalFilename.substring(originalFilename.lastIndexOf('.')).toLowerCase(); + } + if (!extension.equals(".jpg") && !extension.equals(".jpeg") + && !extension.equals(".png") && !extension.equals(".webp")) { + return AjaxResult.error("仅支持JPG、PNG和WebP图片"); + } + String fname = "admin-images/" + DateUtils.datePath() + "/" + + UUID.randomUUID().toString().replace("-", "") + extension; + String domain = QiNiuConfig.getInstance().getDomainOfBucket(); + if (!domain.startsWith("http://") && !domain.startsWith("https://")) { + domain = "https://" + domain; + } + String fkey = domain.replaceAll("/$", "") + "/" + fname; if(!StringUtils.isNull(QiNiuConfig.getInstance().getPictureStyle()) && !QiNiuConfig.getInstance().getPictureStyle().equals("")){ fkey = fkey + "?" + QiNiuConfig.getInstance().getPictureStyle(); } - try { - QiNiuUtil.uploadMultipartFile(file, fname, false); - } catch (Exception e) { - e.printStackTrace(); + if (!QiNiuUtil.uploadMultipartFile(file, fname, false)) { + return AjaxResult.error("七牛云上传失败,请检查七牛配置或网络"); } AjaxResult ajax = AjaxResult.success(); ajax.put("url", fkey); - ajax.put("fileName", file.getOriginalFilename()); + ajax.put("fileName", originalFilename); return ajax; } catch (Exception e) { return AjaxResult.error(e.getMessage()); diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/qiniu/QiNiuConfig.java b/ruoyi-admin/src/main/java/com/ruoyi/web/qiniu/QiNiuConfig.java index ecdff0a..e7928c8 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/qiniu/QiNiuConfig.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/qiniu/QiNiuConfig.java @@ -10,6 +10,7 @@ public class QiNiuConfig { private String bucket; private Zone zone; private String domainOfBucket; + private String uploadHost; private long expireInSeconds; private String pictureStyle; @@ -23,6 +24,7 @@ public class QiNiuConfig { secretKey = prop.getProperty("qiniu.secret-key"); bucket = prop.getProperty("qiniu.bucket"); domainOfBucket = prop.getProperty("qiniu.domain-of-bucket"); + uploadHost = prop.getProperty("qiniu.upload-host", "https://upload.qiniup.com"); expireInSeconds = Long.parseLong(prop.getProperty("qiniu.expire-in-seconds")); pictureStyle = prop.getProperty("qiniu.picture-style"); String zoneName = prop.getProperty("qiniu.zone"); @@ -88,6 +90,14 @@ public class QiNiuConfig { this.domainOfBucket = domainOfBucket; } + public String getUploadHost() { + return uploadHost; + } + + public void setUploadHost(String uploadHost) { + this.uploadHost = uploadHost; + } + public long getExpireInSeconds() { return expireInSeconds; } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/qiniu/QiNiuUtil.java b/ruoyi-admin/src/main/java/com/ruoyi/web/qiniu/QiNiuUtil.java index 97eb393..6ad2244 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/qiniu/QiNiuUtil.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/qiniu/QiNiuUtil.java @@ -7,7 +7,9 @@ import com.qiniu.storage.BucketManager; import com.qiniu.storage.Configuration; import com.qiniu.storage.UploadManager; import com.qiniu.storage.model.DefaultPutRet; +import com.qiniu.storage.model.FileInfo; import com.qiniu.util.Auth; +import com.qiniu.util.StringMap; import com.qiniu.util.UrlSafeBase64; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -101,8 +103,19 @@ public class QiNiuUtil { * @throws UnsupportedEncodingException */ public static String fileUrl(String fileName) throws UnsupportedEncodingException { - String encodedFileName = URLEncoder.encode(fileName, "utf-8"); - String publicUrl = String.format("%s/%s", QiNiuConfig.getInstance().getDomainOfBucket(), encodedFileName); + String[] parts = fileName.split("/"); + StringBuilder encoded = new StringBuilder(); + for (String part : parts) { + if (encoded.length() > 0) { + encoded.append('/'); + } + encoded.append(URLEncoder.encode(part, "utf-8").replace("+", "%20")); + } + String domain = QiNiuConfig.getInstance().getDomainOfBucket(); + if (!domain.startsWith("http://") && !domain.startsWith("https://")) { + domain = "https://" + domain; + } + String publicUrl = String.format("%s/%s", domain.replaceAll("/$", ""), encoded); Auth auth = getAuth(); long expireInSeconds = QiNiuConfig.getInstance().getExpireInSeconds(); if(-1 == expireInSeconds){ @@ -168,20 +181,46 @@ public class QiNiuUtil { return auth; } + /** 生成只允许上传指定任务截图的短期凭证。 */ + public static String createTaskImageUploadToken(String key, int expiresSeconds, long fileSizeLimit) { + StringMap policy = new StringMap(); + policy.put("insertOnly", 1); + policy.put("fsizeLimit", fileSizeLimit); + policy.put("mimeLimit", "image/jpeg;image/png;image/webp"); + return getAuth().uploadToken(QiNiuConfig.getInstance().getBucket(), key, expiresSeconds, policy); + } + + /** 判断七牛对象是否真实存在。 */ + public static boolean exists(String key) { + Configuration cfg = new Configuration(QiNiuConfig.getInstance().getZone()); + BucketManager bucketManager = new BucketManager(getAuth(), cfg); + try { + FileInfo info = bucketManager.stat(QiNiuConfig.getInstance().getBucket(), key); + return info != null && info.fsize > 0; + } catch (QiniuException ex) { + return false; + } + } + /** * 删除文件 * @param key */ - public static void delete(String key){ + public static boolean delete(String key){ Configuration cfg = new Configuration(QiNiuConfig.getInstance().getZone()); Auth auth = getAuth(); BucketManager bucketManager = new BucketManager(auth, cfg); try { bucketManager.delete(QiNiuConfig.getInstance().getBucket(), key); + return true; } catch (QiniuException ex) { + if (ex.code() == 612) { + return true; + } //如果遇到异常,说明删除失败 System.err.println(ex.code()); System.err.println(ex.response.toString()); + return false; } } } diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/task/PointTaskAttachmentCleanupJob.java b/ruoyi-admin/src/main/java/com/ruoyi/web/task/PointTaskAttachmentCleanupJob.java new file mode 100644 index 0000000..0db5eb7 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/task/PointTaskAttachmentCleanupJob.java @@ -0,0 +1,36 @@ +package com.ruoyi.web.task; + +import java.util.Date; +import java.util.List; +import com.ruoyi.app.domain.AppPointTaskAttachment; +import com.ruoyi.app.mapper.AppPointTaskSubmissionMapper; +import com.ruoyi.web.qiniu.QiNiuUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; + +/** 每天清理超过24小时仍未绑定任务的七牛临时截图。 */ +@Configuration +@EnableScheduling +public class PointTaskAttachmentCleanupJob +{ + private static final long TEMP_RETENTION_MILLIS = 24L * 60L * 60L * 1000L; + + @Autowired + private AppPointTaskSubmissionMapper submissionMapper; + + @Scheduled(cron = "0 30 3 * * ?") + public void cleanExpiredTaskImages() + { + Date beforeTime = new Date(System.currentTimeMillis() - TEMP_RETENTION_MILLIS); + List attachments = submissionMapper.selectExpiredTempAttachments(beforeTime); + for (AppPointTaskAttachment attachment : attachments) + { + if (QiNiuUtil.delete(attachment.getObjectKey())) + { + submissionMapper.deleteTempAttachmentById(attachment.getId()); + } + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppIntegralRecord.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppIntegralRecord.java index c4a389a..5684c39 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppIntegralRecord.java +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppIntegralRecord.java @@ -54,6 +54,10 @@ public class AppIntegralRecord extends BaseEntity { private Date integralTime; private Long fId; private Long resourceId; + /** 业务类型,用于积分操作幂等。 */ + private String bizType; + /** 业务记录主键。 */ + private Long bizId; /** * 资源标题 @@ -123,6 +127,22 @@ public class AppIntegralRecord extends BaseEntity { public void setResourceId(Long resourceId) { this.resourceId = resourceId; } + + public String getBizType() { + return bizType; + } + + public void setBizType(String bizType) { + this.bizType = bizType; + } + + public Long getBizId() { + return bizId; + } + + public void setBizId(Long bizId) { + this.bizId = bizId; + } public String getResourceTitle() { return resourceTitle; @@ -141,6 +161,8 @@ public class AppIntegralRecord extends BaseEntity { .append("integralNumber", getIntegralNumber()) .append("userId", getUserId()) .append("resourceId", getResourceId()) + .append("bizType", getBizType()) + .append("bizId", getBizId()) .append("resourceTitle", getResourceTitle()) .append("integralTime", getIntegralTime()) .toString(); diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTask.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTask.java new file mode 100644 index 0000000..0a51020 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTask.java @@ -0,0 +1,72 @@ +package com.ruoyi.app.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** 积分任务。 */ +public class AppPointTask extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private String taskName; + private String taskType; + private String cycleType; + private String coverUrl; + private String description; + private String operationGuide; + private String exampleImages; + private Integer rewardPoints; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date startTime; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date endTime; + private Integer allowResubmit; + private String status; + private Integer sortOrder; + + /** 当前用户在本周期的状态,非数据库字段。 */ + private Integer currentStatus; + private Boolean canSubmit; + private Long currentSubmissionId; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date nextAvailableAt; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getTaskName() { return taskName; } + public void setTaskName(String taskName) { this.taskName = taskName; } + public String getTaskType() { return taskType; } + public void setTaskType(String taskType) { this.taskType = taskType; } + public String getCycleType() { return cycleType; } + public void setCycleType(String cycleType) { this.cycleType = cycleType; } + public String getCoverUrl() { return coverUrl; } + public void setCoverUrl(String coverUrl) { this.coverUrl = coverUrl; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getOperationGuide() { return operationGuide; } + public void setOperationGuide(String operationGuide) { this.operationGuide = operationGuide; } + public String getExampleImages() { return exampleImages; } + public void setExampleImages(String exampleImages) { this.exampleImages = exampleImages; } + public Integer getRewardPoints() { return rewardPoints; } + public void setRewardPoints(Integer rewardPoints) { this.rewardPoints = rewardPoints; } + public Date getStartTime() { return startTime; } + public void setStartTime(Date startTime) { this.startTime = startTime; } + public Date getEndTime() { return endTime; } + public void setEndTime(Date endTime) { this.endTime = endTime; } + public Integer getAllowResubmit() { return allowResubmit; } + public void setAllowResubmit(Integer allowResubmit) { this.allowResubmit = allowResubmit; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Integer getSortOrder() { return sortOrder; } + public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; } + public Integer getCurrentStatus() { return currentStatus; } + public void setCurrentStatus(Integer currentStatus) { this.currentStatus = currentStatus; } + public Boolean getCanSubmit() { return canSubmit; } + public void setCanSubmit(Boolean canSubmit) { this.canSubmit = canSubmit; } + public Long getCurrentSubmissionId() { return currentSubmissionId; } + public void setCurrentSubmissionId(Long currentSubmissionId) { this.currentSubmissionId = currentSubmissionId; } + public Date getNextAvailableAt() { return nextAvailableAt; } + public void setNextAvailableAt(Date nextAvailableAt) { this.nextAvailableAt = nextAvailableAt; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskAttachment.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskAttachment.java new file mode 100644 index 0000000..2683fb9 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskAttachment.java @@ -0,0 +1,51 @@ +package com.ruoyi.app.domain; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +/** 积分任务截图附件。 */ +public class AppPointTaskAttachment implements Serializable +{ + private static final long serialVersionUID = 1L; + + private Long id; + private Long submissionId; + private Long userId; + private String objectKey; + private String originalName; + private Long fileSize; + private String fileType; + private String uploadStatus; + private Integer submitBatch; + private Integer isCurrent; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + /** 七牛临时访问地址,非数据库字段。 */ + private String previewUrl; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getSubmissionId() { return submissionId; } + public void setSubmissionId(Long submissionId) { this.submissionId = submissionId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getObjectKey() { return objectKey; } + public void setObjectKey(String objectKey) { this.objectKey = objectKey; } + public String getOriginalName() { return originalName; } + public void setOriginalName(String originalName) { this.originalName = originalName; } + public Long getFileSize() { return fileSize; } + public void setFileSize(Long fileSize) { this.fileSize = fileSize; } + public String getFileType() { return fileType; } + public void setFileType(String fileType) { this.fileType = fileType; } + public String getUploadStatus() { return uploadStatus; } + public void setUploadStatus(String uploadStatus) { this.uploadStatus = uploadStatus; } + public Integer getSubmitBatch() { return submitBatch; } + public void setSubmitBatch(Integer submitBatch) { this.submitBatch = submitBatch; } + public Integer getIsCurrent() { return isCurrent; } + public void setIsCurrent(Integer isCurrent) { this.isCurrent = isCurrent; } + public Date getCreateTime() { return createTime; } + public void setCreateTime(Date createTime) { this.createTime = createTime; } + public String getPreviewUrl() { return previewUrl; } + public void setPreviewUrl(String previewUrl) { this.previewUrl = previewUrl; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskAuditLog.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskAuditLog.java new file mode 100644 index 0000000..7ff4ecd --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskAuditLog.java @@ -0,0 +1,29 @@ +package com.ruoyi.app.domain; + +import java.io.Serializable; +import java.util.Date; + +/** 积分任务审核日志。 */ +public class AppPointTaskAuditLog implements Serializable +{ + private static final long serialVersionUID = 1L; + private Long submissionId; + private Integer beforeStatus; + private Integer afterStatus; + private String operatorName; + private String remark; + private Date createTime; + + public Long getSubmissionId() { return submissionId; } + public void setSubmissionId(Long submissionId) { this.submissionId = submissionId; } + public Integer getBeforeStatus() { return beforeStatus; } + public void setBeforeStatus(Integer beforeStatus) { this.beforeStatus = beforeStatus; } + public Integer getAfterStatus() { return afterStatus; } + public void setAfterStatus(Integer afterStatus) { this.afterStatus = afterStatus; } + public String getOperatorName() { return operatorName; } + public void setOperatorName(String operatorName) { this.operatorName = operatorName; } + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + public Date getCreateTime() { return createTime; } + public void setCreateTime(Date createTime) { this.createTime = createTime; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskSubmission.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskSubmission.java new file mode 100644 index 0000000..09a76a9 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskSubmission.java @@ -0,0 +1,81 @@ +package com.ruoyi.app.domain; + +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; + +/** 积分任务提交记录。 */ +public class AppPointTaskSubmission extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long id; + private String submissionNo; + private Long taskId; + private Long userId; + private String cycleKey; + private Integer rewardPoints; + private Integer status; + private Integer attemptCount; + private String rejectReason; + private String reviewBy; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date reviewTime; + private Long rewardRecordId; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date firstSubmitTime; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lastSubmitTime; + + private String taskName; + private String taskType; + private String cycleType; + private String userName; + private String nickName; + private List attachmentKeys; + private List attachments; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getSubmissionNo() { return submissionNo; } + public void setSubmissionNo(String submissionNo) { this.submissionNo = submissionNo; } + public Long getTaskId() { return taskId; } + public void setTaskId(Long taskId) { this.taskId = taskId; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getCycleKey() { return cycleKey; } + public void setCycleKey(String cycleKey) { this.cycleKey = cycleKey; } + public Integer getRewardPoints() { return rewardPoints; } + public void setRewardPoints(Integer rewardPoints) { this.rewardPoints = rewardPoints; } + public Integer getStatus() { return status; } + public void setStatus(Integer status) { this.status = status; } + public Integer getAttemptCount() { return attemptCount; } + public void setAttemptCount(Integer attemptCount) { this.attemptCount = attemptCount; } + public String getRejectReason() { return rejectReason; } + public void setRejectReason(String rejectReason) { this.rejectReason = rejectReason; } + public String getReviewBy() { return reviewBy; } + public void setReviewBy(String reviewBy) { this.reviewBy = reviewBy; } + public Date getReviewTime() { return reviewTime; } + public void setReviewTime(Date reviewTime) { this.reviewTime = reviewTime; } + public Long getRewardRecordId() { return rewardRecordId; } + public void setRewardRecordId(Long rewardRecordId) { this.rewardRecordId = rewardRecordId; } + public Date getFirstSubmitTime() { return firstSubmitTime; } + public void setFirstSubmitTime(Date firstSubmitTime) { this.firstSubmitTime = firstSubmitTime; } + public Date getLastSubmitTime() { return lastSubmitTime; } + public void setLastSubmitTime(Date lastSubmitTime) { this.lastSubmitTime = lastSubmitTime; } + public String getTaskName() { return taskName; } + public void setTaskName(String taskName) { this.taskName = taskName; } + public String getTaskType() { return taskType; } + public void setTaskType(String taskType) { this.taskType = taskType; } + public String getCycleType() { return cycleType; } + public void setCycleType(String cycleType) { this.cycleType = cycleType; } + public String getUserName() { return userName; } + public void setUserName(String userName) { this.userName = userName; } + public String getNickName() { return nickName; } + public void setNickName(String nickName) { this.nickName = nickName; } + public List getAttachmentKeys() { return attachmentKeys; } + public void setAttachmentKeys(List attachmentKeys) { this.attachmentKeys = attachmentKeys; } + public List getAttachments() { return attachments; } + public void setAttachments(List attachments) { this.attachments = attachments; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskUploadRequest.java b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskUploadRequest.java new file mode 100644 index 0000000..7482441 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/domain/AppPointTaskUploadRequest.java @@ -0,0 +1,19 @@ +package com.ruoyi.app.domain; + +import java.io.Serializable; + +/** 七牛任务截图上传凭证请求。 */ +public class AppPointTaskUploadRequest implements Serializable +{ + private static final long serialVersionUID = 1L; + private String fileName; + private Long fileSize; + private String contentType; + + public String getFileName() { return fileName; } + public void setFileName(String fileName) { this.fileName = fileName; } + public Long getFileSize() { return fileSize; } + public void setFileSize(Long fileSize) { this.fileSize = fileSize; } + public String getContentType() { return contentType; } + public void setContentType(String contentType) { this.contentType = contentType; } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppPointTaskMapper.java b/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppPointTaskMapper.java new file mode 100644 index 0000000..37e58fc --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppPointTaskMapper.java @@ -0,0 +1,15 @@ +package com.ruoyi.app.mapper; + +import java.util.List; +import com.ruoyi.app.domain.AppPointTask; + +public interface AppPointTaskMapper +{ + AppPointTask selectAppPointTaskById(Long id); + List selectAppPointTaskList(AppPointTask task); + List selectActiveAppPointTaskList(); + int insertAppPointTask(AppPointTask task); + int updateAppPointTask(AppPointTask task); + int deleteAppPointTaskByIds(Long[] ids); + int countSubmissionByTaskIds(Long[] ids); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppPointTaskSubmissionMapper.java b/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppPointTaskSubmissionMapper.java new file mode 100644 index 0000000..917a1fa --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/mapper/AppPointTaskSubmissionMapper.java @@ -0,0 +1,37 @@ +package com.ruoyi.app.mapper; + +import java.util.List; +import java.util.Date; +import com.ruoyi.app.domain.AppPointTaskAttachment; +import com.ruoyi.app.domain.AppPointTaskAuditLog; +import com.ruoyi.app.domain.AppPointTaskSubmission; +import org.apache.ibatis.annotations.Param; + +public interface AppPointTaskSubmissionMapper +{ + AppPointTaskSubmission selectSubmissionById(Long id); + AppPointTaskSubmission selectSubmissionByIdForUpdate(Long id); + AppPointTaskSubmission selectByTaskUserCycle(@Param("taskId") Long taskId, + @Param("userId") Long userId, + @Param("cycleKey") String cycleKey); + List selectSubmissionList(AppPointTaskSubmission submission); + List selectMySubmissionList(@Param("userId") Long userId); + int insertSubmission(AppPointTaskSubmission submission); + int updateSubmission(AppPointTaskSubmission submission); + + int insertTempAttachment(AppPointTaskAttachment attachment); + Long lockUserForUpload(@Param("userId") Long userId); + int countTempAttachmentsByUser(@Param("userId") Long userId); + int countAttachmentsByUserSince(@Param("userId") Long userId, @Param("since") String since); + List selectTempAttachments(@Param("userId") Long userId, + @Param("keys") List keys); + int bindAttachments(@Param("userId") Long userId, + @Param("submissionId") Long submissionId, + @Param("submitBatch") Integer submitBatch, + @Param("keys") List keys); + int markAttachmentsNotCurrent(Long submissionId); + List selectAttachmentsBySubmissionId(Long submissionId); + List selectExpiredTempAttachments(Date beforeTime); + int deleteTempAttachmentById(Long id); + int insertAuditLog(AppPointTaskAuditLog auditLog); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/service/IAppPointTaskService.java b/ruoyi-system/src/main/java/com/ruoyi/app/service/IAppPointTaskService.java new file mode 100644 index 0000000..d146100 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/service/IAppPointTaskService.java @@ -0,0 +1,28 @@ +package com.ruoyi.app.service; + +import java.util.List; +import com.ruoyi.app.domain.AppPointTask; +import com.ruoyi.app.domain.AppPointTaskAttachment; +import com.ruoyi.app.domain.AppPointTaskSubmission; + +public interface IAppPointTaskService +{ + AppPointTask selectAppPointTaskById(Long id); + List selectAppPointTaskList(AppPointTask task); + List selectActiveTaskList(Long userId); + AppPointTask selectActiveTaskDetail(Long id, Long userId); + int insertAppPointTask(AppPointTask task); + int updateAppPointTask(AppPointTask task); + int deleteAppPointTaskByIds(Long[] ids); + + AppPointTaskAttachment createTempAttachment(AppPointTaskAttachment attachment); + AppPointTaskSubmission submitTask(Long taskId, AppPointTaskSubmission request, Long userId); + AppPointTaskSubmission resubmitTask(Long submissionId, AppPointTaskSubmission request, Long userId); + List selectMySubmissionList(Long userId); + AppPointTaskSubmission selectMySubmissionDetail(Long id, Long userId); + List selectSubmissionList(AppPointTaskSubmission submission); + AppPointTaskSubmission selectSubmissionDetail(Long id); + int approveSubmission(Long id, String operator); + int rejectSubmission(Long id, String rejectReason, String operator); + int revokeSubmission(Long id, String reason, String operator); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppPointTaskServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppPointTaskServiceImpl.java new file mode 100644 index 0000000..9bdbbc8 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/app/service/impl/AppPointTaskServiceImpl.java @@ -0,0 +1,544 @@ +package com.ruoyi.app.service.impl; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.TimeZone; +import com.ruoyi.app.domain.AppIntegralRecord; +import com.ruoyi.app.domain.AppPointTask; +import com.ruoyi.app.domain.AppPointTaskAttachment; +import com.ruoyi.app.domain.AppPointTaskAuditLog; +import com.ruoyi.app.domain.AppPointTaskSubmission; +import com.ruoyi.app.mapper.AppIntegralRecordMapper; +import com.ruoyi.app.mapper.AppPointTaskMapper; +import com.ruoyi.app.mapper.AppPointTaskSubmissionMapper; +import com.ruoyi.app.service.IAppPointTaskService; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.DateUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.uuid.IdUtils; +import com.ruoyi.system.mapper.SysUserMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AppPointTaskServiceImpl implements IAppPointTaskService +{ + private static final String CYCLE_ONCE = "ONCE"; + private static final String CYCLE_DAILY = "DAILY"; + private static final int STATUS_PENDING = 0; + private static final int STATUS_APPROVED = 1; + private static final int STATUS_REJECTED = 2; + private static final int STATUS_REVOKED = 3; + private static final int MAX_TEMP_ATTACHMENTS_PER_USER = 9; + private static final int MAX_DAILY_ATTACHMENTS_PER_USER = 30; + private static final ZoneId SHANGHAI_ZONE = ZoneId.of("Asia/Shanghai"); + + @Autowired + private AppPointTaskMapper taskMapper; + @Autowired + private AppPointTaskSubmissionMapper submissionMapper; + @Autowired + private AppIntegralRecordMapper integralRecordMapper; + @Autowired + private SysUserMapper userMapper; + + @Override + public AppPointTask selectAppPointTaskById(Long id) + { + return taskMapper.selectAppPointTaskById(id); + } + + @Override + public List selectAppPointTaskList(AppPointTask task) + { + return taskMapper.selectAppPointTaskList(task); + } + + @Override + public List selectActiveTaskList(Long userId) + { + List tasks = taskMapper.selectActiveAppPointTaskList(); + for (AppPointTask task : tasks) + { + populateUserState(task, userId); + } + return tasks; + } + + @Override + public AppPointTask selectActiveTaskDetail(Long id, Long userId) + { + AppPointTask task = taskMapper.selectAppPointTaskById(id); + if (task == null) + { + throw new ServiceException("任务不存在"); + } + populateUserState(task, userId); + if (!isTaskActive(task, DateUtils.getNowDate())) + { + task.setCanSubmit(false); + } + return task; + } + + @Override + public int insertAppPointTask(AppPointTask task) + { + normalizeAndValidateTask(task); + Date now = DateUtils.getNowDate(); + task.setCreateTime(now); + task.setUpdateTime(now); + return taskMapper.insertAppPointTask(task); + } + + @Override + public int updateAppPointTask(AppPointTask task) + { + if (task.getId() == null) + { + throw new ServiceException("任务不存在"); + } + AppPointTask current = taskMapper.selectAppPointTaskById(task.getId()); + if (current == null) + { + throw new ServiceException("任务不存在"); + } + normalizeAndValidateTask(task); + if (taskMapper.countSubmissionByTaskIds(new Long[] { task.getId() }) > 0 + && (!current.getRewardPoints().equals(task.getRewardPoints()) + || !current.getCycleType().equals(task.getCycleType()))) + { + throw new ServiceException("任务已有用户提交,不能修改奖励积分或任务周期"); + } + task.setUpdateTime(DateUtils.getNowDate()); + return taskMapper.updateAppPointTask(task); + } + + @Override + public int deleteAppPointTaskByIds(Long[] ids) + { + if (ids == null || ids.length == 0) + { + return 0; + } + if (taskMapper.countSubmissionByTaskIds(ids) > 0) + { + throw new ServiceException("任务已有用户提交,请停用任务,不要删除"); + } + return taskMapper.deleteAppPointTaskByIds(ids); + } + + @Override + @Transactional + public AppPointTaskAttachment createTempAttachment(AppPointTaskAttachment attachment) + { + Long userId = attachment.getUserId(); + if (userId == null || submissionMapper.lockUserForUpload(userId) == null) + { + throw new ServiceException("用户不存在,请重新登录"); + } + if (submissionMapper.countTempAttachmentsByUser(userId) >= MAX_TEMP_ATTACHMENTS_PER_USER) + { + throw new ServiceException("未提交截图已达上限,请先完成任务提交或等待临时截图清理后再试"); + } + String todayStart = LocalDate.now(SHANGHAI_ZONE) + " 00:00:00"; + if (submissionMapper.countAttachmentsByUserSince(userId, todayStart) >= MAX_DAILY_ATTACHMENTS_PER_USER) + { + throw new ServiceException("今日上传次数已达上限,请明日再试"); + } + attachment.setUploadStatus("TEMP"); + attachment.setSubmitBatch(0); + attachment.setIsCurrent(0); + attachment.setCreateTime(DateUtils.getNowDate()); + if (submissionMapper.insertTempAttachment(attachment) != 1) + { + throw new ServiceException("创建上传记录失败,请重试"); + } + return attachment; + } + + @Override + @Transactional + public AppPointTaskSubmission submitTask(Long taskId, AppPointTaskSubmission request, Long userId) + { + AppPointTask task = requireActiveTask(taskId); + List keys = normalizeAttachmentKeys(request.getAttachmentKeys()); + String cycleKey = cycleKey(task); + if (submissionMapper.selectByTaskUserCycle(taskId, userId, cycleKey) != null) + { + throw new ServiceException(CYCLE_DAILY.equals(task.getCycleType()) + ? "今日已经提交过该任务" : "该任务已经提交过"); + } + assertTempAttachments(userId, keys); + + Date now = DateUtils.getNowDate(); + AppPointTaskSubmission submission = new AppPointTaskSubmission(); + submission.setSubmissionNo("RW" + DateUtils.dateTimeNow() + IdUtils.fastSimpleUUID().substring(0, 6).toUpperCase()); + submission.setTaskId(taskId); + submission.setUserId(userId); + submission.setCycleKey(cycleKey); + submission.setRewardPoints(task.getRewardPoints()); + submission.setRemark(normalizeRemark(request.getRemark())); + submission.setStatus(STATUS_PENDING); + submission.setAttemptCount(1); + submission.setFirstSubmitTime(now); + submission.setLastSubmitTime(now); + submission.setCreateBy(String.valueOf(userId)); + submission.setCreateTime(now); + submission.setUpdateBy(String.valueOf(userId)); + submission.setUpdateTime(now); + try + { + if (submissionMapper.insertSubmission(submission) != 1) + { + throw new ServiceException("任务提交失败,请重试"); + } + } + catch (DuplicateKeyException ex) + { + throw new ServiceException("该任务本周期已经提交过"); + } + bindAttachments(userId, submission.getId(), 1, keys); + insertAudit(submission.getId(), null, STATUS_PENDING, String.valueOf(userId), "用户提交任务"); + return selectSubmissionDetail(submission.getId()); + } + + @Override + @Transactional + public AppPointTaskSubmission resubmitTask(Long submissionId, AppPointTaskSubmission request, Long userId) + { + AppPointTaskSubmission current = submissionMapper.selectSubmissionByIdForUpdate(submissionId); + if (current == null || !userId.equals(current.getUserId())) + { + throw new ServiceException("任务记录不存在"); + } + if (current.getStatus() != STATUS_REJECTED) + { + throw new ServiceException("只有审核未通过的任务可以重新提交"); + } + AppPointTask task = requireActiveTask(current.getTaskId()); + if (CYCLE_DAILY.equals(task.getCycleType()) && !cycleKey(task).equals(current.getCycleKey())) + { + throw new ServiceException("该每日任务周期已结束,请提交今日任务"); + } + List keys = normalizeAttachmentKeys(request.getAttachmentKeys()); + assertTempAttachments(userId, keys); + int nextBatch = current.getAttemptCount() + 1; + submissionMapper.markAttachmentsNotCurrent(submissionId); + bindAttachments(userId, submissionId, nextBatch, keys); + + current.setRemark(normalizeRemark(request.getRemark())); + current.setStatus(STATUS_PENDING); + current.setAttemptCount(nextBatch); + current.setRejectReason(null); + current.setReviewBy(null); + current.setReviewTime(null); + current.setLastSubmitTime(DateUtils.getNowDate()); + current.setUpdateBy(String.valueOf(userId)); + current.setUpdateTime(DateUtils.getNowDate()); + submissionMapper.updateSubmission(current); + insertAudit(submissionId, STATUS_REJECTED, STATUS_PENDING, String.valueOf(userId), "用户重新提交任务"); + return selectSubmissionDetail(submissionId); + } + + @Override + public List selectMySubmissionList(Long userId) + { + return submissionMapper.selectMySubmissionList(userId); + } + + @Override + public AppPointTaskSubmission selectMySubmissionDetail(Long id, Long userId) + { + AppPointTaskSubmission submission = selectSubmissionDetail(id); + if (submission == null || !userId.equals(submission.getUserId())) + { + throw new ServiceException("任务记录不存在"); + } + return submission; + } + + @Override + public List selectSubmissionList(AppPointTaskSubmission submission) + { + return submissionMapper.selectSubmissionList(submission); + } + + @Override + public AppPointTaskSubmission selectSubmissionDetail(Long id) + { + AppPointTaskSubmission submission = submissionMapper.selectSubmissionById(id); + if (submission != null) + { + submission.setAttachments(submissionMapper.selectAttachmentsBySubmissionId(id)); + } + return submission; + } + + @Override + @Transactional + public int approveSubmission(Long id, String operator) + { + AppPointTaskSubmission submission = requirePendingSubmission(id); + AppPointTask task = taskMapper.selectAppPointTaskById(submission.getTaskId()); + if (task == null) + { + throw new ServiceException("关联任务不存在"); + } + if (userMapper.addIntegralByUserId(submission.getRewardPoints(), submission.getUserId()) != 1) + { + throw new ServiceException("积分发放失败,请重试"); + } + AppIntegralRecord record = insertIntegralRecord(submission, 0L, task.getTaskName() + "奖励", "POINT_TASK"); + submission.setStatus(STATUS_APPROVED); + submission.setRewardRecordId(record.getId()); + submission.setRejectReason(null); + submission.setReviewBy(operator); + submission.setReviewTime(DateUtils.getNowDate()); + submission.setUpdateBy(operator); + submission.setUpdateTime(DateUtils.getNowDate()); + int rows = submissionMapper.updateSubmission(submission); + insertAudit(id, STATUS_PENDING, STATUS_APPROVED, operator, "审核通过并发放" + submission.getRewardPoints() + "积分"); + return rows; + } + + @Override + @Transactional + public int rejectSubmission(Long id, String rejectReason, String operator) + { + String reason = StringUtils.trim(rejectReason); + if (StringUtils.isEmpty(reason)) + { + throw new ServiceException("审核不通过时必须填写原因"); + } + AppPointTaskSubmission submission = requirePendingSubmission(id); + submission.setStatus(STATUS_REJECTED); + submission.setRejectReason(reason); + submission.setReviewBy(operator); + submission.setReviewTime(DateUtils.getNowDate()); + submission.setUpdateBy(operator); + submission.setUpdateTime(DateUtils.getNowDate()); + int rows = submissionMapper.updateSubmission(submission); + insertAudit(id, STATUS_PENDING, STATUS_REJECTED, operator, reason); + return rows; + } + + @Override + @Transactional + public int revokeSubmission(Long id, String reason, String operator) + { + String revokeReason = StringUtils.trim(reason); + if (StringUtils.isEmpty(revokeReason)) + { + throw new ServiceException("撤销奖励时必须填写原因"); + } + AppPointTaskSubmission submission = submissionMapper.selectSubmissionByIdForUpdate(id); + if (submission == null || submission.getStatus() != STATUS_APPROVED) + { + throw new ServiceException("只有已通过的任务可以撤销奖励"); + } + AppPointTask task = taskMapper.selectAppPointTaskById(submission.getTaskId()); + if (userMapper.delIntegralByUserId(submission.getRewardPoints(), submission.getUserId()) != 1) + { + throw new ServiceException("用户当前积分不足,无法撤销奖励"); + } + insertIntegralRecord(submission, 1L, "撤销:" + (task == null ? "积分任务" : task.getTaskName()), "POINT_TASK_REVOKE"); + submission.setStatus(STATUS_REVOKED); + submission.setRejectReason(revokeReason); + submission.setReviewBy(operator); + submission.setReviewTime(DateUtils.getNowDate()); + submission.setUpdateBy(operator); + submission.setUpdateTime(DateUtils.getNowDate()); + int rows = submissionMapper.updateSubmission(submission); + insertAudit(id, STATUS_APPROVED, STATUS_REVOKED, operator, revokeReason); + return rows; + } + + private void populateUserState(AppPointTask task, Long userId) + { + AppPointTaskSubmission submission = submissionMapper.selectByTaskUserCycle(task.getId(), userId, cycleKey(task)); + if (submission == null) + { + task.setCanSubmit(true); + return; + } + task.setCurrentStatus(submission.getStatus()); + task.setCurrentSubmissionId(submission.getId()); + task.setCanSubmit(submission.getStatus() == STATUS_REJECTED); + if (CYCLE_DAILY.equals(task.getCycleType()) && submission.getStatus() == STATUS_APPROVED) + { + Calendar calendar = Calendar.getInstance(TimeZone.getTimeZone("Asia/Shanghai")); + calendar.add(Calendar.DAY_OF_MONTH, 1); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + task.setNextAvailableAt(calendar.getTime()); + } + } + + private AppPointTask requireActiveTask(Long id) + { + AppPointTask task = taskMapper.selectAppPointTaskById(id); + Date now = DateUtils.getNowDate(); + if (task == null || !"0".equals(task.getStatus())) + { + throw new ServiceException("任务不存在或已停用"); + } + if (task.getStartTime() != null && task.getStartTime().after(now)) + { + throw new ServiceException("任务尚未开始"); + } + if (task.getEndTime() != null && task.getEndTime().before(now)) + { + throw new ServiceException("任务已经结束"); + } + return task; + } + + private boolean isTaskActive(AppPointTask task, Date now) + { + return task != null && "0".equals(task.getStatus()) + && (task.getStartTime() == null || !task.getStartTime().after(now)) + && (task.getEndTime() == null || !task.getEndTime().before(now)); + } + + private AppPointTaskSubmission requirePendingSubmission(Long id) + { + AppPointTaskSubmission submission = submissionMapper.selectSubmissionByIdForUpdate(id); + if (submission == null) + { + throw new ServiceException("任务记录不存在"); + } + if (submission.getStatus() != STATUS_PENDING) + { + throw new ServiceException("该任务已经处理,请勿重复操作"); + } + return submission; + } + + private String cycleKey(AppPointTask task) + { + return CYCLE_DAILY.equals(task.getCycleType()) ? LocalDate.now(SHANGHAI_ZONE).toString() : CYCLE_ONCE; + } + + private List normalizeAttachmentKeys(List keys) + { + if (keys == null) + { + throw new ServiceException("请上传任务截图"); + } + Set unique = new LinkedHashSet<>(); + for (String key : keys) + { + if (StringUtils.isNotEmpty(StringUtils.trim(key))) + { + unique.add(StringUtils.trim(key)); + } + } + if (unique.isEmpty() || unique.size() > 3) + { + throw new ServiceException("请上传1至3张任务截图"); + } + return new ArrayList<>(unique); + } + + private void assertTempAttachments(Long userId, List keys) + { + if (submissionMapper.selectTempAttachments(userId, keys).size() != keys.size()) + { + throw new ServiceException("截图无效或已经被其他任务使用,请重新上传"); + } + } + + private void bindAttachments(Long userId, Long submissionId, Integer batch, List keys) + { + if (submissionMapper.bindAttachments(userId, submissionId, batch, keys) != keys.size()) + { + throw new ServiceException("任务截图绑定失败,请重新上传"); + } + } + + private void insertAudit(Long submissionId, Integer before, Integer after, String operator, String remark) + { + AppPointTaskAuditLog log = new AppPointTaskAuditLog(); + log.setSubmissionId(submissionId); + log.setBeforeStatus(before); + log.setAfterStatus(after); + log.setOperatorName(operator); + log.setRemark(remark); + log.setCreateTime(DateUtils.getNowDate()); + submissionMapper.insertAuditLog(log); + } + + private AppIntegralRecord insertIntegralRecord(AppPointTaskSubmission submission, Long isAdd, String source, String bizType) + { + AppIntegralRecord record = new AppIntegralRecord(); + record.setSource(source); + record.setIsAdd(isAdd); + record.setIntegralNumber(submission.getRewardPoints().longValue()); + record.setUserId(submission.getUserId()); + record.setIntegralTime(DateUtils.getNowDate()); + record.setBizType(bizType); + record.setBizId(submission.getId()); + if (integralRecordMapper.insertAppIntegralRecord(record) != 1) + { + throw new ServiceException("积分流水记录失败,请重试"); + } + return record; + } + + private void normalizeAndValidateTask(AppPointTask task) + { + task.setTaskName(StringUtils.trim(task.getTaskName())); + task.setDescription(StringUtils.trim(task.getDescription())); + task.setOperationGuide(StringUtils.trim(task.getOperationGuide())); + if (StringUtils.isEmpty(task.getTaskName()) || task.getTaskName().length() > 80) + { + throw new ServiceException("任务名称需填写1至80个字"); + } + if (!CYCLE_ONCE.equals(task.getCycleType()) && !CYCLE_DAILY.equals(task.getCycleType())) + { + throw new ServiceException("任务周期不正确"); + } + if (task.getRewardPoints() == null || task.getRewardPoints() <= 0 || task.getRewardPoints() > 100000) + { + throw new ServiceException("奖励积分必须在1至100000之间"); + } + if (task.getStartTime() != null && task.getEndTime() != null && !task.getStartTime().before(task.getEndTime())) + { + throw new ServiceException("结束时间必须晚于开始时间"); + } + task.setAllowResubmit(1); + if (task.getSortOrder() == null || task.getSortOrder() < 0) + { + task.setSortOrder(0); + } + if (!"1".equals(task.getStatus())) + { + task.setStatus("0"); + } + if (StringUtils.isEmpty(task.getTaskType())) + { + task.setTaskType("OTHER"); + } + } + + private String normalizeRemark(String remark) + { + String value = StringUtils.trim(remark); + if (value != null && value.length() > 300) + { + throw new ServiceException("补充说明不能超过300个字"); + } + return value; + } +} diff --git a/ruoyi-system/src/main/resources/mapper/app/AppIntegralRecordMapper.xml b/ruoyi-system/src/main/resources/mapper/app/AppIntegralRecordMapper.xml index 08055f2..c975bf6 100644 --- a/ruoyi-system/src/main/resources/mapper/app/AppIntegralRecordMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/app/AppIntegralRecordMapper.xml @@ -11,12 +11,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + - select a.id, a.source, a.is_add, a.integral_number, a.user_id, a.resource_id, a.integral_time, r.resource_title + select a.id, a.source, a.is_add, a.integral_number, a.user_id, a.resource_id, + a.biz_type, a.biz_id, a.integral_time, r.resource_title from app_integral_record a left join app_resource r on a.resource_id = r.id @@ -68,6 +71,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" integral_number, user_id, resource_id, + biz_type, + biz_id, integral_time, @@ -76,6 +81,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{integralNumber}, #{userId}, #{resourceId}, + #{bizType}, + #{bizId}, #{integralTime}, @@ -88,6 +95,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" integral_number = #{integralNumber}, user_id = #{userId}, resource_id = #{resourceId}, + biz_type = #{bizType}, + biz_id = #{bizId}, integral_time = #{integralTime}, where id = #{id} diff --git a/ruoyi-system/src/main/resources/mapper/app/AppPointTaskMapper.xml b/ruoyi-system/src/main/resources/mapper/app/AppPointTaskMapper.xml new file mode 100644 index 0000000..20ffaf5 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/app/AppPointTaskMapper.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + select id, task_name, task_type, cycle_type, cover_url, description, operation_guide, + example_images, reward_points, start_time, end_time, allow_resubmit, status, + sort_order, create_by, create_time, update_by, update_time + from app_point_task + + + + + + + + + + insert into app_point_task + (task_name, task_type, cycle_type, cover_url, description, operation_guide, example_images, + reward_points, start_time, end_time, allow_resubmit, status, sort_order, + create_by, create_time, update_by, update_time) + values + (#{taskName}, #{taskType}, #{cycleType}, #{coverUrl}, #{description}, #{operationGuide}, #{exampleImages}, + #{rewardPoints}, #{startTime}, #{endTime}, #{allowResubmit}, #{status}, #{sortOrder}, + #{createBy}, #{createTime}, #{updateBy}, #{updateTime}) + + + + update app_point_task + + task_name = #{taskName}, + task_type = #{taskType}, + cycle_type = #{cycleType}, + cover_url = #{coverUrl}, + description = #{description}, + operation_guide = #{operationGuide}, + example_images = #{exampleImages}, + reward_points = #{rewardPoints}, + start_time = #{startTime}, + end_time = #{endTime}, + allow_resubmit = #{allowResubmit}, + status = #{status}, + sort_order = #{sortOrder}, + update_by = #{updateBy}, + update_time = #{updateTime} + + where id = #{id} + + + + delete from app_point_task where id in + #{id} + + + + diff --git a/ruoyi-system/src/main/resources/mapper/app/AppPointTaskSubmissionMapper.xml b/ruoyi-system/src/main/resources/mapper/app/AppPointTaskSubmissionMapper.xml new file mode 100644 index 0000000..9dd5f59 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/app/AppPointTaskSubmissionMapper.xml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select s.id, s.submission_no, s.task_id, s.user_id, s.cycle_key, s.reward_points, + s.remark, s.status, s.attempt_count, s.reject_reason, s.review_by, s.review_time, + s.reward_record_id, s.first_submit_time, s.last_submit_time, + s.create_by, s.create_time, s.update_by, s.update_time, + t.task_name, t.task_type, t.cycle_type, u.user_name, u.nick_name + from app_point_task_submission s + left join app_point_task t on t.id = s.task_id + left join sys_user u on u.user_id = s.user_id + + + + + + + + + + + + + + insert into app_point_task_submission + (submission_no, task_id, user_id, cycle_key, reward_points, remark, status, attempt_count, + reject_reason, review_by, review_time, reward_record_id, first_submit_time, last_submit_time, + create_by, create_time, update_by, update_time) + values + (#{submissionNo}, #{taskId}, #{userId}, #{cycleKey}, #{rewardPoints}, #{remark}, #{status}, #{attemptCount}, + #{rejectReason}, #{reviewBy}, #{reviewTime}, #{rewardRecordId}, #{firstSubmitTime}, #{lastSubmitTime}, + #{createBy}, #{createTime}, #{updateBy}, #{updateTime}) + + + + update app_point_task_submission + + remark = #{remark}, + status = #{status}, + attempt_count = #{attemptCount}, + reject_reason = #{rejectReason}, + review_by = #{reviewBy}, + review_time = #{reviewTime}, + reward_record_id = #{rewardRecordId}, + last_submit_time = #{lastSubmitTime}, + update_by = #{updateBy}, + update_time = #{updateTime} + + where id = #{id} + + + + insert into app_point_task_attachment + (submission_id, user_id, object_key, original_name, file_size, file_type, + upload_status, submit_batch, is_current, create_time) + values + (null, #{userId}, #{objectKey}, #{originalName}, #{fileSize}, #{fileType}, + 'TEMP', 0, 0, #{createTime}) + + + + + + + + + + + + update app_point_task_attachment + set submission_id = #{submissionId}, upload_status = 'BOUND', submit_batch = #{submitBatch}, is_current = 1 + where user_id = #{userId} and upload_status = 'TEMP' and object_key in + #{key} + + + + update app_point_task_attachment set is_current = 0 where submission_id = #{submissionId} + + + + + + + + delete from app_point_task_attachment where id = #{id} and upload_status = 'TEMP' + + + + insert into app_point_task_audit_log + (submission_id, before_status, after_status, operator_name, remark, create_time) + values + (#{submissionId}, #{beforeStatus}, #{afterStatus}, #{operatorName}, #{remark}, #{createTime}) + + diff --git a/ruoyi-ui/src/api/app/pointTask.js b/ruoyi-ui/src/api/app/pointTask.js new file mode 100644 index 0000000..cc28f98 --- /dev/null +++ b/ruoyi-ui/src/api/app/pointTask.js @@ -0,0 +1,41 @@ +import request from '@/utils/request' + +export function listPointTask(query) { + return request({ url: '/app/pointTask/list', method: 'get', params: query }) +} + +export function getPointTask(id) { + return request({ url: '/app/pointTask/' + id, method: 'get' }) +} + +export function addPointTask(data) { + return request({ url: '/app/pointTask', method: 'post', data }) +} + +export function updatePointTask(data) { + return request({ url: '/app/pointTask', method: 'put', data }) +} + +export function delPointTask(ids) { + return request({ url: '/app/pointTask/' + ids, method: 'delete' }) +} + +export function listPointTaskSubmission(query) { + return request({ url: '/app/pointTask/submission/list', method: 'get', params: query }) +} + +export function getPointTaskSubmission(id) { + return request({ url: '/app/pointTask/submission/' + id, method: 'get' }) +} + +export function approvePointTaskSubmission(id) { + return request({ url: '/app/pointTask/submission/' + id + '/approve', method: 'post' }) +} + +export function rejectPointTaskSubmission(id, rejectReason) { + return request({ url: '/app/pointTask/submission/' + id + '/reject', method: 'post', data: { rejectReason } }) +} + +export function revokePointTaskSubmission(id, rejectReason) { + return request({ url: '/app/pointTask/submission/' + id + '/revoke', method: 'post', data: { rejectReason } }) +} diff --git a/ruoyi-ui/src/components/QiniuImageUpload/index.vue b/ruoyi-ui/src/components/QiniuImageUpload/index.vue index 7acb89d..ce3f4de 100644 --- a/ruoyi-ui/src/components/QiniuImageUpload/index.vue +++ b/ruoyi-ui/src/components/QiniuImageUpload/index.vue @@ -1,7 +1,7 @@