Add points task submission and review workflow

This commit is contained in:
王鹏
2026-09-16 16:50:05 +08:00
parent 0e24c9e235
commit e31af58d58
24 changed files with 2097 additions and 18 deletions

View File

@@ -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<String, Object> 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<String> 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);
}
}
}
}

View File

@@ -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());

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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<AppPointTaskAttachment> attachments = submissionMapper.selectExpiredTempAttachments(beforeTime);
for (AppPointTaskAttachment attachment : attachments)
{
if (QiNiuUtil.delete(attachment.getObjectKey()))
{
submissionMapper.deleteTempAttachmentById(attachment.getId());
}
}
}
}