完成帖子发表功能

This commit is contained in:
柏码の讲师 2023-10-09 16:00:53 +08:00
parent b8b9466fcf
commit e487de0071
15 changed files with 201 additions and 65 deletions

View File

@ -14,13 +14,13 @@ import com.example.service.AccountDetailsService;
import com.example.service.AccountPrivacyService;
import com.example.service.AccountService;
import com.example.utils.Const;
import com.example.utils.ControllerUtils;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Optional;
import java.util.function.Supplier;
@Validated
@RestController
@ -36,6 +36,9 @@ public class AccountController {
@Resource
AccountPrivacyService privacyService;
@Resource
ControllerUtils utils;
@GetMapping("/info")
public RestBean<AccountVO> info(@RequestAttribute(Const.ATTR_USER_ID) int id){
Account account = service.findAccountById(id);
@ -60,13 +63,13 @@ public class AccountController {
@PostMapping("/modify-email")
public RestBean<Void> modifyEmail(@RequestAttribute(Const.ATTR_USER_ID) int id,
@RequestBody @Valid ModifyEmailVO vo){
return this.messageHandle(() -> service.modifyEmail(id, vo));
return utils.messageHandle(() -> service.modifyEmail(id, vo));
}
@PostMapping("/change-password")
public RestBean<Void> changePassword(@RequestAttribute(Const.ATTR_USER_ID) int id,
@RequestBody @Valid ChangePasswordVO vo){
return this.messageHandle(() -> service.changePassword(id, vo));
return utils.messageHandle(() -> service.changePassword(id, vo));
}
@PostMapping("/save-privacy")
@ -80,12 +83,4 @@ public class AccountController {
public RestBean<AccountPrivacyVO> privacy(@RequestAttribute(Const.ATTR_USER_ID) int id){
return RestBean.success(privacyService.accountPrivacy(id).asViewObject(AccountPrivacyVO.class));
}
private <T> RestBean<T> messageHandle(Supplier<String> action){
String message = action.get();
if(message == null)
return RestBean.success();
else
return RestBean.failure(400, message);
}
}

View File

@ -5,6 +5,7 @@ import com.example.entity.vo.request.ConfirmResetVO;
import com.example.entity.vo.request.EmailRegisterVO;
import com.example.entity.vo.request.EmailResetVO;
import com.example.service.AccountService;
import com.example.utils.ControllerUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
@ -15,8 +16,6 @@ import jakarta.validation.constraints.Pattern;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.function.Supplier;
/**
* 用于验证相关Controller包含用户的注册重置密码等操作
*/
@ -29,6 +28,9 @@ public class AuthorizeController {
@Resource
AccountService accountService;
@Resource
ControllerUtils utils;
/**
* 请求邮件验证码
* @param email 请求邮件
@ -41,7 +43,7 @@ public class AuthorizeController {
public RestBean<Void> askVerifyCode(@RequestParam @Email String email,
@RequestParam @Pattern(regexp = "(register|reset|modify)") String type,
HttpServletRequest request){
return this.messageHandle(() ->
return utils.messageHandle(() ->
accountService.registerEmailVerifyCode(type, String.valueOf(email), request.getRemoteAddr()));
}
@ -53,7 +55,7 @@ public class AuthorizeController {
@PostMapping("/register")
@Operation(summary = "用户注册操作")
public RestBean<Void> register(@RequestBody @Valid EmailRegisterVO vo){
return this.messageHandle(() ->
return utils.messageHandle(() ->
accountService.registerEmailAccount(vo));
}
@ -65,7 +67,7 @@ public class AuthorizeController {
@PostMapping("/reset-confirm")
@Operation(summary = "密码重置确认")
public RestBean<Void> resetConfirm(@RequestBody @Valid ConfirmResetVO vo){
return this.messageHandle(() -> accountService.resetConfirm(vo));
return utils.messageHandle(() -> accountService.resetConfirm(vo));
}
/**
@ -76,21 +78,7 @@ public class AuthorizeController {
@PostMapping("/reset-password")
@Operation(summary = "密码重置操作")
public RestBean<Void> resetPassword(@RequestBody @Valid EmailResetVO vo){
return this.messageHandle(() ->
return utils.messageHandle(() ->
accountService.resetEmailAccountPassword(vo));
}
/**
* 针对于返回值为String作为错误信息的方法进行统一处理
* @param action 具体操作
* @return 响应结果
* @param <T> 响应结果类型
*/
private <T> RestBean<T> messageHandle(Supplier<String> action){
String message = action.get();
if(message == null)
return RestBean.success();
else
return RestBean.failure(400, message);
}
}

View File

@ -1,14 +1,16 @@
package com.example.controller;
import com.example.entity.RestBean;
import com.example.entity.vo.request.TopicCreateVO;
import com.example.entity.vo.response.TopicTypeVO;
import com.example.entity.vo.response.WeatherVO;
import com.example.service.TopicService;
import com.example.service.WeatherService;
import com.example.utils.Const;
import com.example.utils.ControllerUtils;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -23,6 +25,9 @@ public class ForumController {
@Resource
TopicService topicService;
@Resource
ControllerUtils utils;
@GetMapping("/weather")
public RestBean<WeatherVO> weather(double latitude, double longitude){
WeatherVO weatherVO = service.fetchWeather(latitude, longitude);
@ -38,4 +43,10 @@ public class ForumController {
.map(type -> type.asViewObject(TopicTypeVO.class))
.toList());
}
@PostMapping("/create-topic")
public RestBean<Void> createTopic(@Valid @RequestBody TopicCreateVO vo,
@RequestAttribute(Const.ATTR_USER_ID) int id) {
return utils.messageHandle(() -> topicService.createTopic(id, vo));
}
}

View File

@ -0,0 +1,20 @@
package com.example.entity.dto;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
@Data
@TableName("db_topic")
public class Topic {
@TableId(type = IdType.AUTO)
Integer id;
String title;
String content;
Integer uid;
Integer type;
Date time;
}

View File

@ -0,0 +1,17 @@
package com.example.entity.vo.request;
import com.alibaba.fastjson2.JSONObject;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
@Data
public class TopicCreateVO {
@Min(1)
@Max(5)
int type;
@Length(max = 30)
String title;
JSONObject content;
}

View File

@ -0,0 +1,9 @@
package com.example.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.entity.dto.Topic;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TopicMapper extends BaseMapper<Topic> {
}

View File

@ -1,9 +1,13 @@
package com.example.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.example.entity.dto.Topic;
import com.example.entity.dto.TopicType;
import com.example.entity.vo.request.TopicCreateVO;
import java.util.List;
public interface TopicService {
public interface TopicService extends IService<Topic> {
List<TopicType> listType();
String createTopic(int uid, TopicCreateVO vo);
}

View File

@ -7,11 +7,12 @@ import com.example.entity.dto.StoreImage;
import com.example.mapper.AccountMapper;
import com.example.mapper.ImageStoreMapper;
import com.example.service.ImageService;
import com.example.utils.Const;
import com.example.utils.FlowUtils;
import io.minio.*;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@ -20,7 +21,6 @@ import java.io.OutputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@ -33,7 +33,7 @@ public class ImageServiceImpl extends ServiceImpl<ImageStoreMapper, StoreImage>
AccountMapper mapper;
@Resource
StringRedisTemplate template;
FlowUtils flowUtils;
SimpleDateFormat format = new SimpleDateFormat("yyyyMMdd");
@ -49,7 +49,8 @@ public class ImageServiceImpl extends ServiceImpl<ImageStoreMapper, StoreImage>
@Override
public String uploadImage(int id, MultipartFile file) throws IOException {
if(!limitUpload(id)) return null;
String key = Const.FORUM_IMAGE_COUNTER + id;
if(flowUtils.limitPeriodCounterCheck(key, 20, 3600)) return null;
String imageName = UUID.randomUUID().toString().replace("-", "");
Date date = new Date();
imageName = "/cache/" + format.format(date) + "/" + imageName;
@ -106,19 +107,4 @@ public class ImageServiceImpl extends ServiceImpl<ImageStoreMapper, StoreImage>
.build();
client.removeObject(remove);
}
private boolean limitUpload(int id){
String key = "image:upload:"+id;
String s = template.opsForValue().get(key);
if(s == null) {
template.opsForValue().set(key, "1", 1, TimeUnit.HOURS);
} else {
if(Integer.parseInt(s) > 20) {
return false;
} else {
template.opsForValue().increment(key);
}
}
return true;
}
}

View File

@ -1,21 +1,61 @@
package com.example.service.impl;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.entity.dto.Topic;
import com.example.entity.dto.TopicType;
import com.example.entity.vo.request.TopicCreateVO;
import com.example.mapper.TopicMapper;
import com.example.mapper.TopicTypeMapper;
import com.example.service.TopicService;
import com.example.utils.Const;
import com.example.utils.FlowUtils;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.List;
@Service
public class TopicServiceImpl implements TopicService {
public class TopicServiceImpl extends ServiceImpl<TopicMapper, Topic> implements TopicService {
@Resource
TopicTypeMapper typeMapper;
@Resource
FlowUtils flowUtils;
@Override
public List<TopicType> listType() {
return typeMapper.selectList(null);
}
@Override
public String createTopic(int uid, TopicCreateVO vo) {
if(!this.textLimitCheck(vo.getContent()))
return "文章长度过大,发文失败!";
String key = Const.FORUM_TOPIC_CREATE_COUNTER + uid;
if(flowUtils.limitPeriodCounterCheck(key, 3, 3600))
return "发文频繁,请稍后再试!";
Topic topic = new Topic();
BeanUtils.copyProperties(vo, topic);
topic.setContent(vo.getContent().toJSONString());
topic.setUid(uid);
topic.setTime(new Date());
if(this.save(topic)) {
return null;
} else {
return "内部错误,请联系管理员";
}
}
private boolean textLimitCheck(JSONObject object) {
if(object == null) return false;
long length = 0;
for (Object op : object.getJSONArray("ops")) {
length += JSONObject.from(op).getString("insert").length();
}
return length <= 20000;
}
}

View File

@ -4,6 +4,7 @@ import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.example.entity.vo.response.WeatherVO;
import com.example.service.WeatherService;
import com.example.utils.Const;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@ -41,7 +42,7 @@ public class WeatherServiceImpl implements WeatherService {
if(geo == null) return null;
JSONObject location = geo.getJSONArray("location").getJSONObject(0);
int id = location.getInteger("id");
String key = "weather:"+id;
String key = Const.FORUM_WEATHER_CACHE +id;
String cache = template.opsForValue().get(key);
if(cache != null)
return JSONObject.parseObject(cache).to(WeatherVO.class);

View File

@ -22,5 +22,8 @@ public final class Const {
public final static String MQ_MAIL = "mail";
//用户角色
public final static String ROLE_DEFAULT = "user";
//论坛相关计数限制
public final static String FORUM_IMAGE_COUNTER = "image:upload:";
public final static String FORUM_WEATHER_CACHE = "weather:cache:";
public final static String FORUM_TOPIC_CREATE_COUNTER = "topic:create:";
}

View File

@ -0,0 +1,17 @@
package com.example.utils;
import com.example.entity.RestBean;
import org.springframework.stereotype.Component;
import java.util.function.Supplier;
@Component
public class ControllerUtils {
public <T> RestBean<T> messageHandle(Supplier<String> action){
String message = action.get();
if(message == null)
return RestBean.success();
else
return RestBean.failure(400, message);
}
}

View File

@ -19,6 +19,8 @@ public class FlowUtils {
@Resource
StringRedisTemplate template;
private static final LimitAction defaultAction = overclock -> overclock;
/**
* 针对于单次频率限制请求成功后在冷却时间内不得再次进行请求如3秒内不能再次发起请求
* @param key
@ -26,7 +28,7 @@ public class FlowUtils {
* @return 是否通过限流检查
*/
public boolean limitOnceCheck(String key, int blockTime){
return this.internalCheck(key, 1, blockTime, (overclock) -> false);
return this.internalCheck(key, 1, blockTime, defaultAction);
}
/**
@ -63,6 +65,17 @@ public class FlowUtils {
});
}
/**
* 限制某一段时间内的请求次数如3秒内限制请求20次
* @param counterKey 计数键
* @param frequency 请求频率
* @param period 计数周期
* @return 是否通过限流检查
*/
public boolean limitPeriodCounterCheck(String counterKey, int frequency, int period){
return this.internalCheck(counterKey, frequency, period, defaultAction);
}
/**
* 内部使用请求限制主要逻辑
* @param key 计数键

View File

@ -1,16 +1,16 @@
<script setup>
import {Document} from "@element-plus/icons-vue";
import {QuillEditor, Quill} from "@vueup/vue-quill";
import {reactive, ref} from "vue";
import {computed, reactive, ref} from "vue";
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import {accessHeader, get} from "@/net";
import {accessHeader, get, post} from "@/net";
import axios from "axios";
import {ElMessage} from "element-plus";
import ImageResize from "quill-image-resize-vue";
import { ImageExtend, QuillWatch } from "quill-image-super-solution-module";
defineProps({ show: Boolean })
const emit = defineEmits(['close'])
const emit = defineEmits(['close', 'created'])
const refEditor = ref()
const editor = reactive({
@ -29,9 +29,40 @@ function initEditor() {
}
function submitTopic(){
console.info(editor.text)
const text = deltaToText(editor.text)
if(text.length > 20000) {
ElMessage.warning('字数超出限制,无法发布主题!')
return
}
if(!editor.title) {
ElMessage.warning('请填写标题!')
return
}
if(!editor.type) {
ElMessage.warning('请选择一个合适的帖子类型!')
return
}
post('/api/forum/create-topic', {
type: editor.type,
title: editor.title,
content: editor.text
}, () => {
ElMessage.success("帖子发表成功!")
emit('created')
})
}
function deltaToText(delta) {
if(!delta.ops) return ""
let str = ''
for (let ops of delta.ops) {
str += ops.insert
}
return str.replace(/\s/g, "")
}
const contentLength = computed(() => deltaToText(editor.text).length)
Quill.register('modules/imageResize', ImageResize);
Quill.register('modules/ImageExtend', ImageExtend)
const editorOption = {
@ -106,7 +137,8 @@ const editorOption = {
</el-select>
</div>
<div style="flex: 1">
<el-input placeholder="请输入帖子标题..." :prefix-icon="Document" v-model="editor.title"/>
<el-input maxlength="30" placeholder="请输入帖子标题..."
:prefix-icon="Document" v-model="editor.title"/>
</div>
</div>
<div style="margin-top: 15px;height: 450px;border-radius: 5px;overflow: hidden"
@ -118,7 +150,7 @@ const editorOption = {
</div>
<div style="display: flex;justify-content: space-between;margin-top: 10px">
<div style="color: grey;font-size: 13px">
当前字数: 666最大支持20000字
当前字数: {{ contentLength }}最大支持20000字
</div>
<div>
<el-button @click="submitTopic" type="success" plain>立即发布</el-button>

View File

@ -98,7 +98,7 @@ navigator.geolocation.getCurrentPosition(position => {
</div>
</div>
</div>
<topic-editor :show="editor" @close="editor = false"/>
<topic-editor :show="editor" @close="editor = false" @created="editor = false"/>
</div>
</template>