75 changed files with 3148 additions and 12 deletions
@ -0,0 +1,34 @@ |
|||||
|
package cc.hiver.mall.ie.config; |
||||
|
|
||||
|
import cc.hiver.mall.ie.constant.IeRedisKey; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.context.annotation.Bean; |
||||
|
import org.springframework.context.annotation.Configuration; |
||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory; |
||||
|
import org.springframework.data.redis.listener.ChannelTopic; |
||||
|
import org.springframework.data.redis.listener.RedisMessageListenerContainer; |
||||
|
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Configuration |
||||
|
public class IeRedisPubSubConfig { |
||||
|
|
||||
|
@Bean |
||||
|
public RedisMessageListenerContainer ieRedisMessageListenerContainer(RedisConnectionFactory connectionFactory, |
||||
|
MessageListenerAdapter ieChatEventListenerAdapter) { |
||||
|
RedisMessageListenerContainer container = new RedisMessageListenerContainer(); |
||||
|
container.setConnectionFactory(connectionFactory); |
||||
|
container.addMessageListener(ieChatEventListenerAdapter, new ChannelTopic(IeRedisKey.CHAT_EVENT_CHANNEL)); |
||||
|
return container; |
||||
|
} |
||||
|
|
||||
|
@Bean |
||||
|
public MessageListenerAdapter ieChatEventListenerAdapter() { |
||||
|
return new MessageListenerAdapter(new Object() { |
||||
|
@SuppressWarnings("unused") |
||||
|
public void handleMessage(String message) { |
||||
|
log.info("【i/e Redis聊天事件】{}", message); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
package cc.hiver.mall.ie.config; |
||||
|
|
||||
|
import org.springframework.context.annotation.Configuration; |
||||
|
import org.springframework.messaging.simp.config.MessageBrokerRegistry; |
||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; |
||||
|
import org.springframework.web.socket.config.annotation.StompEndpointRegistry; |
||||
|
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; |
||||
|
|
||||
|
@Configuration |
||||
|
@EnableWebSocketMessageBroker |
||||
|
public class IeWebSocketConfig implements WebSocketMessageBrokerConfigurer { |
||||
|
|
||||
|
@Override |
||||
|
public void configureMessageBroker(MessageBrokerRegistry registry) { |
||||
|
registry.enableSimpleBroker("/topic", "/queue"); |
||||
|
registry.setApplicationDestinationPrefixes("/app"); |
||||
|
registry.setUserDestinationPrefix("/user"); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void registerStompEndpoints(StompEndpointRegistry registry) { |
||||
|
registry.addEndpoint("/hiver/ws/websocket") |
||||
|
.setAllowedOriginPatterns("*"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
package cc.hiver.mall.ie.constant; |
||||
|
|
||||
|
public final class IeChatEventType { |
||||
|
public static final String MESSAGE_SAVED = "MESSAGE_SAVED"; |
||||
|
public static final String MESSAGE_DELIVERED = "MESSAGE_DELIVERED"; |
||||
|
public static final String MESSAGE_BLOCKED = "MESSAGE_BLOCKED"; |
||||
|
public static final String ROOM_FINISHED = "ROOM_FINISHED"; |
||||
|
public static final String REPORT_CREATED = "REPORT_CREATED"; |
||||
|
public static final String USER_CONNECTED = "USER_CONNECTED"; |
||||
|
public static final String USER_DISCONNECTED = "USER_DISCONNECTED"; |
||||
|
|
||||
|
private IeChatEventType() { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package cc.hiver.mall.ie.constant; |
||||
|
|
||||
|
public final class IeConstants { |
||||
|
|
||||
|
public static final int DEFAULT_ROOM_MINUTES = 15; |
||||
|
public static final int DEFAULT_DAILY_QUOTA = 3; |
||||
|
|
||||
|
public static final int ROOM_STATUS_ACTIVE = 0; |
||||
|
public static final int ROOM_STATUS_NATURAL_END = 1; |
||||
|
public static final int ROOM_STATUS_EARLY_END = 2; |
||||
|
|
||||
|
public static final int AUDIT_PASS = 1; |
||||
|
public static final int AUDIT_REPLACE = 2; |
||||
|
public static final int AUDIT_BLOCK = 3; |
||||
|
|
||||
|
public static final int RISK_NONE = 0; |
||||
|
public static final int RISK_LOW = 1; |
||||
|
public static final int RISK_MEDIUM = 2; |
||||
|
public static final int RISK_HIGH = 3; |
||||
|
|
||||
|
private IeConstants() { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
package cc.hiver.mall.ie.constant; |
||||
|
|
||||
|
/** |
||||
|
* i/e 随机陪伴 Redis Key 设计。 |
||||
|
*/ |
||||
|
public final class IeRedisKey { |
||||
|
|
||||
|
private static final String PREFIX = "ie:"; |
||||
|
|
||||
|
/** 在线用户缓存:ZSET, member=userId, score=lastActiveMillis */ |
||||
|
public static final String ONLINE_USERS = PREFIX + "online:users"; |
||||
|
|
||||
|
/** 用户状态缓存:HASH, key=ie:user:status:{userId} */ |
||||
|
public static final String USER_STATUS = PREFIX + "user:status:"; |
||||
|
|
||||
|
/** 匹配池:ZSET, key=ie:match:pool:{mode}:{mood}, member=userId, score=matchScore */ |
||||
|
public static final String MATCH_POOL = PREFIX + "match:pool:"; |
||||
|
|
||||
|
/** 用户所在匹配池索引:STRING, key=ie:match:user-pool:{userId}, value=poolKey */ |
||||
|
public static final String USER_MATCH_POOL = PREFIX + "match:user-pool:"; |
||||
|
|
||||
|
/** 匹配认领锁:STRING, key=ie:match:lock:{userId} */ |
||||
|
public static final String MATCH_LOCK = PREFIX + "match:lock:"; |
||||
|
|
||||
|
/** 用户最近匹配集合:SET, key=ie:match:recent:{userId}, member=targetUserId */ |
||||
|
public static final String RECENT_MATCH = PREFIX + "match:recent:"; |
||||
|
|
||||
|
/** 每日次数限制:STRING, key=ie:quota:{yyyyMMdd}:{userId} */ |
||||
|
public static final String DAILY_QUOTA = PREFIX + "quota:"; |
||||
|
|
||||
|
/** 聊天会话缓存:HASH, key=ie:room:{roomId} */ |
||||
|
public static final String ROOM = PREFIX + "room:"; |
||||
|
|
||||
|
/** WebSocket连接缓存:HASH, key=ie:ws:conn:{userId} */ |
||||
|
public static final String WS_CONNECTION = PREFIX + "ws:conn:"; |
||||
|
|
||||
|
/** WebSocket session -> userId 映射 */ |
||||
|
public static final String WS_SESSION_USER = PREFIX + "ws:session:"; |
||||
|
|
||||
|
/** 离线消息缓存:LIST, key=ie:offline:{userId} */ |
||||
|
public static final String OFFLINE_MESSAGE = PREFIX + "offline:"; |
||||
|
|
||||
|
/** 热门状态缓存:ZSET, member=statusText, score=heat */ |
||||
|
public static final String HOT_STATUS = PREFIX + "hot:status"; |
||||
|
|
||||
|
/** Redis 发布订阅频道:跨实例聊天事件 */ |
||||
|
public static final String CHAT_EVENT_CHANNEL = PREFIX + "chat:event"; |
||||
|
|
||||
|
/** 防刷计数:STRING, key=ie:rate:msg:{roomId}:{userId} */ |
||||
|
public static final String MESSAGE_RATE = PREFIX + "rate:msg:"; |
||||
|
|
||||
|
/** 用户风控计数:STRING, key=ie:risk:{userId} */ |
||||
|
public static final String USER_RISK = PREFIX + "risk:"; |
||||
|
|
||||
|
/** 敏感词缓存:STRING json array */ |
||||
|
public static final String SENSITIVE_WORDS = PREFIX + "sensitive:words"; |
||||
|
|
||||
|
private IeRedisKey() { |
||||
|
} |
||||
|
|
||||
|
public static String matchPool(String mode, String mood) { |
||||
|
return MATCH_POOL + mode + ":" + mood; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
package cc.hiver.mall.ie.controller; |
||||
|
|
||||
|
import cc.hiver.core.common.utils.ResultUtil; |
||||
|
import cc.hiver.core.common.vo.Result; |
||||
|
import cc.hiver.mall.ie.service.IeChatService; |
||||
|
import io.swagger.annotations.Api; |
||||
|
import io.swagger.annotations.ApiOperation; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestMethod; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
@RestController |
||||
|
@Api(tags = "i/e后台维护接口") |
||||
|
@RequestMapping("/hiver/admin/ie") |
||||
|
public class IeAdminController { |
||||
|
|
||||
|
@Autowired |
||||
|
private IeChatService chatService; |
||||
|
|
||||
|
@RequestMapping(value = "/rooms/finishExpired", method = RequestMethod.POST) |
||||
|
@ApiOperation("批量结束已过期陪伴房间") |
||||
|
public Result<Object> finishExpiredRooms(Integer limit) { |
||||
|
chatService.finishExpiredRooms(limit == null ? 100 : limit); |
||||
|
return ResultUtil.success("处理完成"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,149 @@ |
|||||
|
package cc.hiver.mall.ie.controller; |
||||
|
|
||||
|
import cc.hiver.core.common.utils.ResultUtil; |
||||
|
import cc.hiver.core.common.utils.SecurityUtil; |
||||
|
import cc.hiver.core.common.vo.Result; |
||||
|
import cc.hiver.core.entity.User; |
||||
|
import cc.hiver.mall.ie.dto.*; |
||||
|
import cc.hiver.mall.ie.entity.IeRecord; |
||||
|
import cc.hiver.mall.ie.entity.IeReport; |
||||
|
import cc.hiver.mall.ie.entity.IeRoomMessage; |
||||
|
import cc.hiver.mall.ie.service.IeChatService; |
||||
|
import cc.hiver.mall.ie.service.IeMatchService; |
||||
|
import cc.hiver.mall.ie.service.IeRedisService; |
||||
|
import cc.hiver.mall.ie.vo.IeHomeVO; |
||||
|
import cc.hiver.mall.ie.vo.IeMatchVO; |
||||
|
import cc.hiver.mall.ie.vo.IeMessageAckVO; |
||||
|
import cc.hiver.mall.ie.vo.IeUserProfileVO; |
||||
|
import com.baomidou.mybatisplus.core.metadata.IPage; |
||||
|
import io.swagger.annotations.Api; |
||||
|
import io.swagger.annotations.ApiOperation; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.web.bind.annotation.*; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@RestController |
||||
|
@Api(tags = "i/e随机陪伴接口") |
||||
|
@RequestMapping("/hiver/app/ie") |
||||
|
public class IeController { |
||||
|
|
||||
|
@Autowired |
||||
|
private SecurityUtil securityUtil; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeMatchService matchService; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeChatService chatService; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRedisService redisService; |
||||
|
|
||||
|
@RequestMapping(value = "/home", method = RequestMethod.GET) |
||||
|
@ApiOperation("首页状态") |
||||
|
public Result<IeHomeVO> home() { |
||||
|
return new ResultUtil<IeHomeVO>().setData(matchService.home(currentUserId())); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/profile", method = RequestMethod.GET) |
||||
|
@ApiOperation("获取i/e资料") |
||||
|
public Result<IeUserProfileVO> profile() { |
||||
|
return new ResultUtil<IeUserProfileVO>().setData(matchService.profile(currentUserId())); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/profiles/{targetUserId}", method = RequestMethod.GET) |
||||
|
@ApiOperation("获取对方半匿名i/e资料") |
||||
|
public Result<IeUserProfileVO> profileByUserId(@PathVariable Long targetUserId) { |
||||
|
return new ResultUtil<IeUserProfileVO>().setData(matchService.profileByUserId(currentUserId(), targetUserId)); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/profile", method = RequestMethod.POST) |
||||
|
@ApiOperation("保存i/e资料") |
||||
|
public Result<IeUserProfileVO> saveProfile(@RequestBody IeProfileDTO dto) { |
||||
|
return new ResultUtil<IeUserProfileVO>().setData(matchService.saveProfile(currentUserId(), dto)); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/status", method = RequestMethod.POST) |
||||
|
@ApiOperation("更新当前i/e和情绪状态") |
||||
|
public Result<Object> updateStatus(@RequestBody IeStatusDTO dto) { |
||||
|
matchService.updateStatus(currentUserId(), dto); |
||||
|
return ResultUtil.success("更新成功"); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/match/start", method = RequestMethod.POST) |
||||
|
@ApiOperation("发起随机匹配") |
||||
|
public Result<IeMatchVO> startMatch(@RequestBody IeMatchStartDTO dto) { |
||||
|
return new ResultUtil<IeMatchVO>().setData(matchService.startMatch(currentUserId(), dto)); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/rooms/{roomId}/presence", method = RequestMethod.POST) |
||||
|
@ApiOperation("发送轻互动/输入状态/心跳事件") |
||||
|
public Result<Object> presence(@PathVariable Long roomId, @RequestBody IePresenceDTO dto) { |
||||
|
dto.setRoomId(roomId); |
||||
|
chatService.sendPresence(currentUserId(), dto); |
||||
|
return ResultUtil.success("发送成功"); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/rooms/{roomId}/messages", method = RequestMethod.POST) |
||||
|
@ApiOperation("发送聊天消息") |
||||
|
public Result<IeMessageAckVO> sendMessage(@PathVariable Long roomId, @RequestBody IeRoomMessageDTO dto) { |
||||
|
dto.setRoomId(roomId); |
||||
|
return new ResultUtil<IeMessageAckVO>().setData(chatService.sendMessage(currentUserId(), dto)); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/rooms/{roomId}/finish", method = RequestMethod.POST) |
||||
|
@ApiOperation("提前结束陪伴房间") |
||||
|
public Result<Object> finishRoom(@PathVariable Long roomId) { |
||||
|
chatService.finishRoom(currentUserId(), roomId, 2); |
||||
|
return ResultUtil.success("已结束"); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/rooms/{roomId}/report", method = RequestMethod.POST) |
||||
|
@ApiOperation("举报不适内容") |
||||
|
public Result<Object> report(@PathVariable Long roomId, @RequestBody IeReportDTO dto) { |
||||
|
dto.setRoomId(roomId); |
||||
|
chatService.report(currentUserId(), dto); |
||||
|
return ResultUtil.success("已收到举报"); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/block/{blockedUserId}", method = RequestMethod.POST) |
||||
|
@ApiOperation("拉黑陪伴对象") |
||||
|
public Result<Object> block(@PathVariable Long blockedUserId, String reason) { |
||||
|
chatService.block(currentUserId(), blockedUserId, reason); |
||||
|
return ResultUtil.success("已拉黑"); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/offline", method = RequestMethod.GET) |
||||
|
@ApiOperation("拉取离线消息") |
||||
|
public Result<List<String>> offline() { |
||||
|
return new ResultUtil<List<String>>().setData(redisService.popOfflineMessages(currentUserId(), 50)); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/rooms/{roomId}/messages/page", method = RequestMethod.GET) |
||||
|
@ApiOperation("分页查询房间消息") |
||||
|
public Result<IPage<IeRoomMessage>> pageMessages(@PathVariable Long roomId, |
||||
|
@RequestParam(required = false, defaultValue = "1") Integer pageNumber, |
||||
|
@RequestParam(required = false, defaultValue = "20") Integer pageSize) { |
||||
|
return new ResultUtil<IPage<IeRoomMessage>>().setData(chatService.pageMessages(currentUserId(), roomId, pageNumber, pageSize)); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/records/page", method = RequestMethod.GET) |
||||
|
@ApiOperation("分页查询感受记录") |
||||
|
public Result<IPage<IeRecord>> pageRecords(@RequestParam(required = false, defaultValue = "1") Integer pageNumber, |
||||
|
@RequestParam(required = false, defaultValue = "10") Integer pageSize) { |
||||
|
return new ResultUtil<IPage<IeRecord>>().setData(chatService.pageRecords(currentUserId(), pageNumber, pageSize)); |
||||
|
} |
||||
|
|
||||
|
@RequestMapping(value = "/reports/page", method = RequestMethod.GET) |
||||
|
@ApiOperation("分页查询我的举报记录") |
||||
|
public Result<IPage<IeReport>> pageReports(@RequestParam(required = false, defaultValue = "1") Integer pageNumber, |
||||
|
@RequestParam(required = false, defaultValue = "10") Integer pageSize) { |
||||
|
return new ResultUtil<IPage<IeReport>>().setData(chatService.pageReports(currentUserId(), pageNumber, pageSize)); |
||||
|
} |
||||
|
|
||||
|
private Long currentUserId() { |
||||
|
User user = securityUtil.getCurrUser(); |
||||
|
return Long.valueOf(user.getId()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
package cc.hiver.mall.ie.dto; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
public class IeMatchStartDTO { |
||||
|
private String mode; |
||||
|
private String targetMode; |
||||
|
private String targetGender; |
||||
|
private String mood; |
||||
|
private Double longitude; |
||||
|
private Double latitude; |
||||
|
private List<String> interestTags; |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
package cc.hiver.mall.ie.dto; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class IePresenceDTO { |
||||
|
private Long roomId; |
||||
|
private String eventType; |
||||
|
private String eventText; |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
package cc.hiver.mall.ie.dto; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
public class IeProfileDTO { |
||||
|
private String anonymousName; |
||||
|
private String avatarText; |
||||
|
private String avatarUrl; |
||||
|
private String gender; |
||||
|
private String intro; |
||||
|
private List<String> interestTags; |
||||
|
private String currentMode; |
||||
|
private String targetModePreference; |
||||
|
private String targetGenderPreference; |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
package cc.hiver.mall.ie.dto; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class IeReportDTO { |
||||
|
private Long roomId; |
||||
|
private Long messageId; |
||||
|
private Long reportedUserId; |
||||
|
private String reasonType; |
||||
|
private String reasonText; |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
package cc.hiver.mall.ie.dto; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class IeRoomMessageDTO { |
||||
|
private Long roomId; |
||||
|
private String roomNo; |
||||
|
private String clientMsgId; |
||||
|
private Integer messageType; |
||||
|
private String content; |
||||
|
private Integer mediaDuration; |
||||
|
private Long mediaSize; |
||||
|
private String mediaFormat; |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
package cc.hiver.mall.ie.dto; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
public class IeStatusDTO { |
||||
|
private String mode; |
||||
|
private String mood; |
||||
|
private String statusText; |
||||
|
private Double longitude; |
||||
|
private Double latitude; |
||||
|
private List<String> interestTags; |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.IdType; |
||||
|
import com.baomidou.mybatisplus.annotation.TableId; |
||||
|
import com.fasterxml.jackson.annotation.JsonFormat; |
||||
|
import lombok.Data; |
||||
|
import org.springframework.format.annotation.DateTimeFormat; |
||||
|
|
||||
|
import java.io.Serializable; |
||||
|
import java.util.Date; |
||||
|
|
||||
|
@Data |
||||
|
public class IeBaseEntity implements Serializable { |
||||
|
private static final long serialVersionUID = 1L; |
||||
|
|
||||
|
@TableId(type = IdType.AUTO) |
||||
|
private Long id; |
||||
|
|
||||
|
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") |
||||
|
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
||||
|
private Date createTime; |
||||
|
|
||||
|
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") |
||||
|
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
||||
|
private Date updateTime; |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_block") |
||||
|
public class IeBlock extends IeBaseEntity { |
||||
|
private Long userId; |
||||
|
private Long blockedUserId; |
||||
|
private String reason; |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_content_audit_log") |
||||
|
public class IeContentAuditLog extends IeBaseEntity { |
||||
|
private String bizType; |
||||
|
private Long bizId; |
||||
|
private Long userId; |
||||
|
private String rawContent; |
||||
|
private String filteredContent; |
||||
|
private Integer auditStatus; |
||||
|
private Integer riskLevel; |
||||
|
private String hitWords; |
||||
|
private String hitCategories; |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_match_attempt") |
||||
|
public class IeMatchAttempt extends IeBaseEntity { |
||||
|
private String matchNo; |
||||
|
private Long userId; |
||||
|
private Long targetUserId; |
||||
|
private String mode; |
||||
|
private String mood; |
||||
|
private Integer status; |
||||
|
private String anonymousName; |
||||
|
private String avatarText; |
||||
|
private String stateText; |
||||
|
private String quoteText; |
||||
|
private String failReason; |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_presence_event") |
||||
|
public class IePresenceEvent extends IeBaseEntity { |
||||
|
private Long roomId; |
||||
|
private Long senderId; |
||||
|
private String eventType; |
||||
|
private String eventText; |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableField; |
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
import java.util.Date; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_record") |
||||
|
public class IeRecord extends IeBaseEntity { |
||||
|
private Long roomId; |
||||
|
private Long userId; |
||||
|
private Long targetUserId; |
||||
|
private String mode; |
||||
|
private String mood; |
||||
|
private String anonymousName; |
||||
|
private Integer durationSeconds; |
||||
|
private String feeling; |
||||
|
private String summary; |
||||
|
private String tags; |
||||
|
private Integer remeetAvailable; |
||||
|
private Date remeetExpireTime; |
||||
|
private Date lastReadTime; |
||||
|
@TableField(exist = false) |
||||
|
private Integer unreadCount; |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_report") |
||||
|
public class IeReport extends IeBaseEntity { |
||||
|
private Long reporterId; |
||||
|
private Long reportedUserId; |
||||
|
private Long roomId; |
||||
|
private Long messageId; |
||||
|
private String reasonType; |
||||
|
private String reasonText; |
||||
|
private Integer status; |
||||
|
private String handleResult; |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
import java.util.Date; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_room") |
||||
|
public class IeRoom extends IeBaseEntity { |
||||
|
private String roomNo; |
||||
|
private Long matchId; |
||||
|
private Long userAId; |
||||
|
private Long userBId; |
||||
|
private String mode; |
||||
|
private String mood; |
||||
|
private Integer status; |
||||
|
private Date startTime; |
||||
|
private Date endTime; |
||||
|
private Date expireTime; |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableField; |
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
import java.util.Date; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_room_message") |
||||
|
public class IeRoomMessage extends IeBaseEntity { |
||||
|
private Long roomId; |
||||
|
private Long senderId; |
||||
|
private Long receiverId; |
||||
|
private Integer messageType; |
||||
|
private String rawContent; |
||||
|
private String filteredContent; |
||||
|
private Integer auditStatus; |
||||
|
private Integer riskLevel; |
||||
|
private String hitWords; |
||||
|
private Integer isBlocked; |
||||
|
private Integer mediaDuration; |
||||
|
private Long mediaSize; |
||||
|
private String mediaFormat; |
||||
|
private Date expireTime; |
||||
|
|
||||
|
@TableField(exist = false) |
||||
|
private String clientMsgId; |
||||
|
|
||||
|
@TableField(exist = false) |
||||
|
private Boolean mine; |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_sensitive_word") |
||||
|
public class IeSensitiveWord extends IeBaseEntity { |
||||
|
private String word; |
||||
|
private String category; |
||||
|
private Integer level; |
||||
|
private String replacement; |
||||
|
private Integer enabled; |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
import java.util.Date; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_user_profile") |
||||
|
public class IeUserProfile extends IeBaseEntity { |
||||
|
private Long userId; |
||||
|
private String anonymousName; |
||||
|
private String avatarText; |
||||
|
private String avatarUrl; |
||||
|
private String gender; |
||||
|
private String intro; |
||||
|
private String interestTags; |
||||
|
private String currentMode; |
||||
|
private String recentPreference; |
||||
|
private String targetModePreference; |
||||
|
private String targetGenderPreference; |
||||
|
private Integer defaultRoomMinutes; |
||||
|
private Integer dailyQuota; |
||||
|
private Integer usedQuota; |
||||
|
private Date lastActiveTime; |
||||
|
private Integer profileCompleted; |
||||
|
private Integer isDeleted; |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package cc.hiver.mall.ie.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
|
||||
|
import java.util.Date; |
||||
|
|
||||
|
@Data |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
@TableName("ie_user_status") |
||||
|
public class IeUserStatus extends IeBaseEntity { |
||||
|
private Long userId; |
||||
|
private String mode; |
||||
|
private String mood; |
||||
|
private String statusText; |
||||
|
private Integer onlineStatus; |
||||
|
private Date lastActiveTime; |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeBlock; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeBlockMapper extends BaseMapper<IeBlock> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeContentAuditLog; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeContentAuditLogMapper extends BaseMapper<IeContentAuditLog> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeMatchAttempt; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeMatchAttemptMapper extends BaseMapper<IeMatchAttempt> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IePresenceEvent; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IePresenceEventMapper extends BaseMapper<IePresenceEvent> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeRecord; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeRecordMapper extends BaseMapper<IeRecord> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeReport; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeReportMapper extends BaseMapper<IeReport> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeRoom; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeRoomMapper extends BaseMapper<IeRoom> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeRoomMessage; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeRoomMessageMapper extends BaseMapper<IeRoomMessage> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeSensitiveWord; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeSensitiveWordMapper extends BaseMapper<IeSensitiveWord> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeUserProfile; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeUserProfileMapper extends BaseMapper<IeUserProfile> { |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.mapper; |
||||
|
|
||||
|
import cc.hiver.mall.ie.entity.IeUserStatus; |
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
@Repository |
||||
|
public interface IeUserStatusMapper extends BaseMapper<IeUserStatus> { |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
package cc.hiver.mall.ie.mq; |
||||
|
|
||||
|
import cc.hiver.mall.ie.constant.IeConstants; |
||||
|
import cc.hiver.mall.ie.service.IeChatService; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Component |
||||
|
public class IeChatDelayConsumer { |
||||
|
|
||||
|
@Autowired |
||||
|
private IeChatService chatService; |
||||
|
|
||||
|
@RabbitListener(queues = IeChatMqConfig.IE_CHAT_DEAD_QUEUE) |
||||
|
public void handleRoomExpire(String roomId) { |
||||
|
try { |
||||
|
chatService.finishRoomBySystem(Long.valueOf(roomId), IeConstants.ROOM_STATUS_NATURAL_END); |
||||
|
} catch (Exception e) { |
||||
|
log.warn("【i/e限时房间】自动结束失败 roomId={}", roomId, e); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
package cc.hiver.mall.ie.mq; |
||||
|
|
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Component |
||||
|
public class IeChatEventConsumer { |
||||
|
|
||||
|
@RabbitListener(queues = IeChatMqConfig.IE_CHAT_EVENT_QUEUE) |
||||
|
public void handleChatEvent(String message) { |
||||
|
log.info("【i/e聊天异步事件】{}", message); |
||||
|
// 预留:后续可接入风控画像、运营统计、人工审核队列、消息清理任务。
|
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
package cc.hiver.mall.ie.mq; |
||||
|
|
||||
|
import cc.hiver.mall.ie.vo.IeChatEvent; |
||||
|
import cn.hutool.json.JSONUtil; |
||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
@Component |
||||
|
public class IeChatEventProducer { |
||||
|
|
||||
|
@Autowired |
||||
|
private RabbitTemplate rabbitTemplate; |
||||
|
|
||||
|
public void sendEvent(IeChatEvent event) { |
||||
|
rabbitTemplate.convertAndSend(IeChatMqConfig.IE_CHAT_EXCHANGE, IeChatMqConfig.IE_CHAT_EVENT_ROUTING, JSONUtil.toJsonStr(event)); |
||||
|
} |
||||
|
|
||||
|
public void sendRoomExpire(Long roomId, long delayMillis) { |
||||
|
rabbitTemplate.convertAndSend(IeChatMqConfig.IE_CHAT_DELAY_EXCHANGE, IeChatMqConfig.IE_CHAT_DELAY_ROUTING, String.valueOf(roomId), message -> { |
||||
|
message.getMessageProperties().setExpiration(String.valueOf(Math.max(delayMillis, 1000L))); |
||||
|
return message; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,69 @@ |
|||||
|
package cc.hiver.mall.ie.mq; |
||||
|
|
||||
|
import org.springframework.amqp.core.Binding; |
||||
|
import org.springframework.amqp.core.BindingBuilder; |
||||
|
import org.springframework.amqp.core.DirectExchange; |
||||
|
import org.springframework.amqp.core.Queue; |
||||
|
import org.springframework.context.annotation.Bean; |
||||
|
import org.springframework.context.annotation.Configuration; |
||||
|
|
||||
|
@Configuration |
||||
|
public class IeChatMqConfig { |
||||
|
public static final String IE_CHAT_EXCHANGE = "ie.chat.direct.exchange"; |
||||
|
public static final String IE_CHAT_EVENT_QUEUE = "ie.chat.event.queue"; |
||||
|
public static final String IE_CHAT_EVENT_ROUTING = "ie.chat.event.routing"; |
||||
|
public static final String IE_CHAT_DELAY_EXCHANGE = "ie.chat.delay.exchange"; |
||||
|
public static final String IE_CHAT_DELAY_QUEUE = "ie.chat.delay.queue"; |
||||
|
public static final String IE_CHAT_DELAY_ROUTING = "ie.chat.delay.routing"; |
||||
|
public static final String IE_CHAT_DEAD_QUEUE = "ie.chat.dead.queue"; |
||||
|
public static final String IE_CHAT_DEAD_EXCHANGE = "ie.chat.dead.exchange"; |
||||
|
public static final String IE_CHAT_DEAD_ROUTING = "ie.chat.dead.routing"; |
||||
|
|
||||
|
@Bean |
||||
|
public DirectExchange ieChatExchange() { |
||||
|
return new DirectExchange(IE_CHAT_EXCHANGE, true, false); |
||||
|
} |
||||
|
|
||||
|
@Bean |
||||
|
public Queue ieChatEventQueue() { |
||||
|
return new Queue(IE_CHAT_EVENT_QUEUE, true); |
||||
|
} |
||||
|
|
||||
|
@Bean |
||||
|
public Binding bindingIeChatEventQueue() { |
||||
|
return BindingBuilder.bind(ieChatEventQueue()).to(ieChatExchange()).with(IE_CHAT_EVENT_ROUTING); |
||||
|
} |
||||
|
|
||||
|
@Bean |
||||
|
public DirectExchange ieChatDelayExchange() { |
||||
|
return new DirectExchange(IE_CHAT_DELAY_EXCHANGE, true, false); |
||||
|
} |
||||
|
|
||||
|
@Bean |
||||
|
public DirectExchange ieChatDeadExchange() { |
||||
|
return new DirectExchange(IE_CHAT_DEAD_EXCHANGE, true, false); |
||||
|
} |
||||
|
|
||||
|
@Bean |
||||
|
public Queue ieChatDelayQueue() { |
||||
|
java.util.Map<String, Object> args = new java.util.HashMap<>(); |
||||
|
args.put("x-dead-letter-exchange", IE_CHAT_DEAD_EXCHANGE); |
||||
|
args.put("x-dead-letter-routing-key", IE_CHAT_DEAD_ROUTING); |
||||
|
return new Queue(IE_CHAT_DELAY_QUEUE, true, false, false, args); |
||||
|
} |
||||
|
|
||||
|
@Bean |
||||
|
public Queue ieChatDeadQueue() { |
||||
|
return new Queue(IE_CHAT_DEAD_QUEUE, true); |
||||
|
} |
||||
|
|
||||
|
@Bean |
||||
|
public Binding bindingIeChatDelayQueue() { |
||||
|
return BindingBuilder.bind(ieChatDelayQueue()).to(ieChatDelayExchange()).with(IE_CHAT_DELAY_ROUTING); |
||||
|
} |
||||
|
|
||||
|
@Bean |
||||
|
public Binding bindingIeChatDeadQueue() { |
||||
|
return BindingBuilder.bind(ieChatDeadQueue()).to(ieChatDeadExchange()).with(IE_CHAT_DEAD_ROUTING); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,39 @@ |
|||||
|
package cc.hiver.mall.ie.service; |
||||
|
|
||||
|
import cc.hiver.mall.ie.dto.IePresenceDTO; |
||||
|
import cc.hiver.mall.ie.dto.IeReportDTO; |
||||
|
import cc.hiver.mall.ie.dto.IeRoomMessageDTO; |
||||
|
import cc.hiver.mall.ie.entity.IeRecord; |
||||
|
import cc.hiver.mall.ie.entity.IeReport; |
||||
|
import cc.hiver.mall.ie.entity.IeRoom; |
||||
|
import cc.hiver.mall.ie.entity.IeRoomMessage; |
||||
|
import cc.hiver.mall.ie.vo.IeMessageAckVO; |
||||
|
import com.baomidou.mybatisplus.core.metadata.IPage; |
||||
|
|
||||
|
public interface IeChatService { |
||||
|
IeRoom getRoom(Long roomId); |
||||
|
|
||||
|
IeMessageAckVO sendMessage(Long senderId, IeRoomMessageDTO dto); |
||||
|
|
||||
|
void sendPresence(Long senderId, IePresenceDTO dto); |
||||
|
|
||||
|
void heartbeat(Long userId); |
||||
|
|
||||
|
void finishRoom(Long userId, Long roomId, Integer status); |
||||
|
|
||||
|
void finishRoomBySystem(Long roomId, Integer status); |
||||
|
|
||||
|
void finishExpiredRooms(int limit); |
||||
|
|
||||
|
void report(Long reporterId, IeReportDTO dto); |
||||
|
|
||||
|
void block(Long userId, Long blockedUserId, String reason); |
||||
|
|
||||
|
IPage<IeRoomMessage> pageMessages(Long userId, Long roomId, Integer pageNumber, Integer pageSize); |
||||
|
|
||||
|
void markRead(Long userId, Long roomId); |
||||
|
|
||||
|
IPage<IeRecord> pageRecords(Long userId, Integer pageNumber, Integer pageSize); |
||||
|
|
||||
|
IPage<IeReport> pageReports(Long userId, Integer pageNumber, Integer pageSize); |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
package cc.hiver.mall.ie.service; |
||||
|
|
||||
|
import cc.hiver.mall.ie.dto.IeMatchStartDTO; |
||||
|
import cc.hiver.mall.ie.dto.IeProfileDTO; |
||||
|
import cc.hiver.mall.ie.dto.IeStatusDTO; |
||||
|
import cc.hiver.mall.ie.vo.IeHomeVO; |
||||
|
import cc.hiver.mall.ie.vo.IeMatchVO; |
||||
|
import cc.hiver.mall.ie.vo.IeUserProfileVO; |
||||
|
|
||||
|
public interface IeMatchService { |
||||
|
IeHomeVO home(Long userId); |
||||
|
|
||||
|
IeUserProfileVO profile(Long userId); |
||||
|
|
||||
|
IeUserProfileVO profileByUserId(Long userId, Long targetUserId); |
||||
|
|
||||
|
IeUserProfileVO saveProfile(Long userId, IeProfileDTO dto); |
||||
|
|
||||
|
void updateStatus(Long userId, IeStatusDTO dto); |
||||
|
|
||||
|
IeMatchVO startMatch(Long userId, IeMatchStartDTO dto); |
||||
|
} |
||||
@ -0,0 +1,55 @@ |
|||||
|
package cc.hiver.mall.ie.service; |
||||
|
|
||||
|
import cc.hiver.mall.ie.dto.IeStatusDTO; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
public interface IeRedisService { |
||||
|
void refreshOnline(Long userId); |
||||
|
|
||||
|
void cacheUserStatus(Long userId, IeStatusDTO dto); |
||||
|
|
||||
|
void addToMatchPool(Long userId, IeStatusDTO dto); |
||||
|
|
||||
|
void removeFromMatchPools(Long userId); |
||||
|
|
||||
|
boolean tryLockMatchUser(Long userId, long seconds); |
||||
|
|
||||
|
void unlockMatchUser(Long userId); |
||||
|
|
||||
|
List<Long> candidates(String mode, String mood, int limit); |
||||
|
|
||||
|
boolean isRecentlyMatched(Long userId, Long targetUserId); |
||||
|
|
||||
|
void markRecentlyMatched(Long userId, Long targetUserId); |
||||
|
|
||||
|
long todayUsedQuota(Long userId); |
||||
|
|
||||
|
long increaseTodayQuota(Long userId, long maxQuota); |
||||
|
|
||||
|
void cacheRoom(Long roomId, String roomJson, long seconds); |
||||
|
|
||||
|
String getCachedRoom(Long roomId); |
||||
|
|
||||
|
void pushOfflineMessage(Long userId, String messageJson); |
||||
|
|
||||
|
List<String> popOfflineMessages(Long userId, int limit); |
||||
|
|
||||
|
void cacheWebSocketConnection(Long userId, String sessionId); |
||||
|
|
||||
|
void removeWebSocketConnection(Long userId); |
||||
|
|
||||
|
Long userIdBySession(String sessionId); |
||||
|
|
||||
|
boolean allowMessage(Long userId, Long roomId); |
||||
|
|
||||
|
long increaseRisk(Long userId, long seconds); |
||||
|
|
||||
|
void publishChatEvent(String eventJson); |
||||
|
|
||||
|
long onlineCount(); |
||||
|
|
||||
|
long waitingCount(String mode, String mood); |
||||
|
|
||||
|
List<String> hotStatuses(int limit); |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package cc.hiver.mall.ie.service; |
||||
|
|
||||
|
import cc.hiver.mall.ie.vo.IeAuditResult; |
||||
|
|
||||
|
public interface IeSecurityAuditService { |
||||
|
IeAuditResult audit(String bizType, Long bizId, Long userId, String content); |
||||
|
|
||||
|
IeAuditResult audit(String bizType, Long bizId, Long userId, String openid, String content); |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package cc.hiver.mall.ie.service; |
||||
|
|
||||
|
import cc.hiver.mall.ie.vo.IeWechatMsgSecResult; |
||||
|
|
||||
|
public interface IeWechatSecurityService { |
||||
|
IeWechatMsgSecResult msgSecCheck(String openid, String content); |
||||
|
} |
||||
@ -0,0 +1,454 @@ |
|||||
|
package cc.hiver.mall.ie.service.impl; |
||||
|
|
||||
|
import cc.hiver.core.dao.UserDao; |
||||
|
import cc.hiver.core.entity.User; |
||||
|
import cc.hiver.mall.ie.constant.IeChatEventType; |
||||
|
import cc.hiver.mall.ie.constant.IeConstants; |
||||
|
import cc.hiver.mall.ie.dto.IePresenceDTO; |
||||
|
import cc.hiver.mall.ie.dto.IeReportDTO; |
||||
|
import cc.hiver.mall.ie.dto.IeRoomMessageDTO; |
||||
|
import cc.hiver.mall.ie.entity.*; |
||||
|
import cc.hiver.mall.ie.mapper.*; |
||||
|
import cc.hiver.mall.ie.mq.IeChatEventProducer; |
||||
|
import cc.hiver.mall.ie.service.IeChatService; |
||||
|
import cc.hiver.mall.ie.service.IeRedisService; |
||||
|
import cc.hiver.mall.ie.service.IeSecurityAuditService; |
||||
|
import cc.hiver.mall.ie.vo.IeAuditResult; |
||||
|
import cc.hiver.mall.ie.vo.IeChatEvent; |
||||
|
import cc.hiver.mall.ie.vo.IeMessageAckVO; |
||||
|
import cn.hutool.json.JSONUtil; |
||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
|
import com.baomidou.mybatisplus.core.metadata.IPage; |
||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.Calendar; |
||||
|
import java.util.Date; |
||||
|
import java.util.List; |
||||
|
|
||||
|
@Service |
||||
|
public class IeChatServiceImpl implements IeChatService { |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRoomMapper roomMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRoomMessageMapper roomMessageMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IePresenceEventMapper presenceEventMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeReportMapper reportMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeBlockMapper blockMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRecordMapper recordMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRedisService redisService; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeSecurityAuditService auditService; |
||||
|
|
||||
|
@Autowired |
||||
|
private SimpMessagingTemplate messagingTemplate; |
||||
|
|
||||
|
@Autowired |
||||
|
private UserDao userDao; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeChatEventProducer chatEventProducer; |
||||
|
|
||||
|
@Override |
||||
|
public IeRoom getRoom(Long roomId) { |
||||
|
String cache = redisService.getCachedRoom(roomId); |
||||
|
if (cache != null && cache.length() > 0) { |
||||
|
return JSONUtil.toBean(cache, IeRoom.class); |
||||
|
} |
||||
|
IeRoom room = roomMapper.selectById(roomId); |
||||
|
if (room != null && room.getStatus() != null && room.getStatus() == IeConstants.ROOM_STATUS_ACTIVE && room.getExpireTime() != null) { |
||||
|
long ttl = Math.max(60L, (room.getExpireTime().getTime() - System.currentTimeMillis()) / 1000L); |
||||
|
redisService.cacheRoom(room.getId(), JSONUtil.toJsonStr(room), ttl); |
||||
|
} |
||||
|
return room; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@Transactional |
||||
|
public IeMessageAckVO sendMessage(Long senderId, IeRoomMessageDTO dto) { |
||||
|
if (!redisService.allowMessage(senderId, dto.getRoomId())) { |
||||
|
throw new RuntimeException("发送太快了,先慢一点"); |
||||
|
} |
||||
|
IeRoom room = assertActiveRoom(senderId, dto.getRoomId()); |
||||
|
Long receiverId = room.getUserAId().equals(senderId) ? room.getUserBId() : room.getUserAId(); |
||||
|
assertReplyWindow(senderId, receiverId, room.getId()); |
||||
|
Integer messageType = dto.getMessageType() == null ? 1 : dto.getMessageType(); |
||||
|
IeAuditResult audit = auditMessage(room.getId(), senderId, messageType, dto.getContent()); |
||||
|
|
||||
|
IeRoomMessage message = new IeRoomMessage(); |
||||
|
message.setRoomId(room.getId()); |
||||
|
message.setSenderId(senderId); |
||||
|
message.setReceiverId(receiverId); |
||||
|
message.setMessageType(messageType); |
||||
|
message.setRawContent(dto.getContent()); |
||||
|
message.setFilteredContent(audit.getFilteredContent()); |
||||
|
message.setAuditStatus(audit.getAuditStatus()); |
||||
|
message.setRiskLevel(audit.getRiskLevel()); |
||||
|
message.setHitWords(String.join(",", audit.getHitWords())); |
||||
|
message.setIsBlocked(Boolean.TRUE.equals(audit.getBlocked()) ? 1 : 0); |
||||
|
message.setMediaDuration(safeMediaDuration(dto.getMediaDuration())); |
||||
|
message.setMediaSize(dto.getMediaSize()); |
||||
|
message.setMediaFormat(dto.getMediaFormat()); |
||||
|
message.setExpireTime(cleanupTime()); |
||||
|
message.setCreateTime(new Date()); |
||||
|
roomMessageMapper.insert(message); |
||||
|
|
||||
|
IeMessageAckVO ack = new IeMessageAckVO(); |
||||
|
ack.setClientMsgId(dto.getClientMsgId()); |
||||
|
ack.setMessageId(message.getId()); |
||||
|
ack.setRoomId(room.getId()); |
||||
|
ack.setSenderId(senderId); |
||||
|
ack.setReceiverId(receiverId); |
||||
|
ack.setMessageType(message.getMessageType()); |
||||
|
ack.setAuditStatus(message.getAuditStatus()); |
||||
|
ack.setRiskLevel(message.getRiskLevel()); |
||||
|
ack.setIsBlocked(message.getIsBlocked()); |
||||
|
ack.setContent(message.getFilteredContent()); |
||||
|
ack.setMediaDuration(message.getMediaDuration()); |
||||
|
ack.setMediaSize(message.getMediaSize()); |
||||
|
ack.setMediaFormat(message.getMediaFormat()); |
||||
|
|
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(senderId), "/queue/ie/ack", ack); |
||||
|
if (message.getIsBlocked() == 0) { |
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(receiverId), "/queue/ie/message", ack); |
||||
|
messagingTemplate.convertAndSend("/topic/ie/room/" + room.getId(), ack); |
||||
|
redisService.pushOfflineMessage(receiverId, JSONUtil.toJsonStr(ack)); |
||||
|
publishEvent(IeChatEventType.MESSAGE_DELIVERED, room.getId(), message.getId(), senderId, receiverId, message.getRiskLevel(), JSONUtil.toJsonStr(ack)); |
||||
|
} else { |
||||
|
long riskCount = redisService.increaseRisk(senderId, 3600); |
||||
|
publishEvent(IeChatEventType.MESSAGE_BLOCKED, room.getId(), message.getId(), senderId, receiverId, message.getRiskLevel(), "riskCount=" + riskCount); |
||||
|
} |
||||
|
return ack; |
||||
|
} |
||||
|
|
||||
|
private void assertReplyWindow(Long senderId, Long receiverId, Long roomId) { |
||||
|
Long replyCount = roomMessageMapper.selectCount(new LambdaQueryWrapper<IeRoomMessage>() |
||||
|
.eq(IeRoomMessage::getRoomId, roomId) |
||||
|
.eq(IeRoomMessage::getSenderId, receiverId) |
||||
|
.eq(IeRoomMessage::getReceiverId, senderId) |
||||
|
.eq(IeRoomMessage::getIsBlocked, 0) |
||||
|
.last("limit 1")); |
||||
|
if (replyCount != null && replyCount > 0) { |
||||
|
return; |
||||
|
} |
||||
|
Long sentBeforeReply = roomMessageMapper.selectCount(new LambdaQueryWrapper<IeRoomMessage>() |
||||
|
.eq(IeRoomMessage::getRoomId, roomId) |
||||
|
.eq(IeRoomMessage::getSenderId, senderId) |
||||
|
.eq(IeRoomMessage::getReceiverId, receiverId) |
||||
|
.eq(IeRoomMessage::getIsBlocked, 0)); |
||||
|
if (sentBeforeReply != null && sentBeforeReply >= 3) { |
||||
|
throw new RuntimeException("先等等对方回复吧,首次破冰最多发送 3 条"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private IeAuditResult auditMessage(Long roomId, Long senderId, Integer messageType, String content) { |
||||
|
if (messageType != null && (messageType == 2 || messageType == 4)) { |
||||
|
IeAuditResult result = new IeAuditResult(); |
||||
|
result.setRawContent(content); |
||||
|
result.setFilteredContent(content); |
||||
|
result.setAuditStatus(1); |
||||
|
result.setRiskLevel(0); |
||||
|
result.setBlocked(false); |
||||
|
return result; |
||||
|
} |
||||
|
return auditService.audit("message", roomId, senderId, senderOpenid(senderId), content); |
||||
|
} |
||||
|
|
||||
|
private Integer safeMediaDuration(Integer mediaDuration) { |
||||
|
if (mediaDuration == null) { |
||||
|
return null; |
||||
|
} |
||||
|
return Math.max(1, Math.min(mediaDuration, 60)); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void sendPresence(Long senderId, IePresenceDTO dto) { |
||||
|
IeRoom room = assertActiveRoom(senderId, dto.getRoomId()); |
||||
|
IePresenceEvent event = new IePresenceEvent(); |
||||
|
event.setRoomId(room.getId()); |
||||
|
event.setSenderId(senderId); |
||||
|
event.setEventType(dto.getEventType()); |
||||
|
event.setEventText(dto.getEventText()); |
||||
|
event.setCreateTime(new Date()); |
||||
|
presenceEventMapper.insert(event); |
||||
|
Long receiverId = room.getUserAId().equals(senderId) ? room.getUserBId() : room.getUserAId(); |
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(receiverId), "/queue/ie/presence", event); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void heartbeat(Long userId) { |
||||
|
redisService.refreshOnline(userId); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@Transactional |
||||
|
public void finishRoom(Long userId, Long roomId, Integer status) { |
||||
|
IeRoom room = assertActiveRoom(userId, roomId); |
||||
|
room.setStatus(status == null ? IeConstants.ROOM_STATUS_EARLY_END : status); |
||||
|
room.setEndTime(new Date()); |
||||
|
room.setUpdateTime(new Date()); |
||||
|
roomMapper.updateById(room); |
||||
|
createRecord(room, room.getUserAId(), room.getUserBId()); |
||||
|
createRecord(room, room.getUserBId(), room.getUserAId()); |
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(room.getUserAId()), "/queue/ie/room-end", room); |
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(room.getUserBId()), "/queue/ie/room-end", room); |
||||
|
publishEvent(IeChatEventType.ROOM_FINISHED, room.getId(), null, room.getUserAId(), room.getUserBId(), 0, String.valueOf(room.getStatus())); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@Transactional |
||||
|
public void finishExpiredRooms(int limit) { |
||||
|
List<IeRoom> rooms = roomMapper.selectList(new LambdaQueryWrapper<IeRoom>() |
||||
|
.eq(IeRoom::getStatus, IeConstants.ROOM_STATUS_ACTIVE) |
||||
|
.le(IeRoom::getExpireTime, new Date()) |
||||
|
.last("limit " + Math.max(1, Math.min(limit, 200)))); |
||||
|
for (IeRoom room : rooms) { |
||||
|
finishExpiredRoom(room); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void report(Long reporterId, IeReportDTO dto) { |
||||
|
IeReport report = new IeReport(); |
||||
|
report.setReporterId(reporterId); |
||||
|
report.setReportedUserId(dto.getReportedUserId()); |
||||
|
report.setRoomId(dto.getRoomId()); |
||||
|
report.setMessageId(dto.getMessageId()); |
||||
|
report.setReasonType(dto.getReasonType()); |
||||
|
report.setReasonText(dto.getReasonText()); |
||||
|
report.setStatus(0); |
||||
|
report.setCreateTime(new Date()); |
||||
|
reportMapper.insert(report); |
||||
|
publishEvent(IeChatEventType.REPORT_CREATED, dto.getRoomId(), dto.getMessageId(), reporterId, dto.getReportedUserId(), 0, dto.getReasonType()); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void block(Long userId, Long blockedUserId, String reason) { |
||||
|
IeBlock exists = blockMapper.selectOne(new LambdaQueryWrapper<IeBlock>() |
||||
|
.eq(IeBlock::getUserId, userId) |
||||
|
.eq(IeBlock::getBlockedUserId, blockedUserId) |
||||
|
.last("limit 1")); |
||||
|
if (exists != null) { |
||||
|
return; |
||||
|
} |
||||
|
IeBlock block = new IeBlock(); |
||||
|
block.setUserId(userId); |
||||
|
block.setBlockedUserId(blockedUserId); |
||||
|
block.setReason(reason); |
||||
|
block.setCreateTime(new Date()); |
||||
|
blockMapper.insert(block); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public IPage<IeRoomMessage> pageMessages(Long userId, Long roomId, Integer pageNumber, Integer pageSize) { |
||||
|
assertRoomParticipant(userId, roomId); |
||||
|
markRead(userId, roomId); |
||||
|
Page<IeRoomMessage> page = new Page<>(safePageNumber(pageNumber), safePageSize(pageSize)); |
||||
|
IPage<IeRoomMessage> result = roomMessageMapper.selectPage(page, new LambdaQueryWrapper<IeRoomMessage>() |
||||
|
.eq(IeRoomMessage::getRoomId, roomId) |
||||
|
.eq(IeRoomMessage::getIsBlocked, 0) |
||||
|
.orderByDesc(IeRoomMessage::getCreateTime)); |
||||
|
for (IeRoomMessage message : result.getRecords()) { |
||||
|
message.setMine(message.getSenderId() != null && message.getSenderId().equals(userId)); |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void markRead(Long userId, Long roomId) { |
||||
|
IeRecord record = recordMapper.selectOne(new LambdaQueryWrapper<IeRecord>() |
||||
|
.eq(IeRecord::getUserId, userId) |
||||
|
.eq(IeRecord::getRoomId, roomId) |
||||
|
.last("limit 1")); |
||||
|
if (record == null) { |
||||
|
return; |
||||
|
} |
||||
|
record.setLastReadTime(new Date()); |
||||
|
record.setUpdateTime(new Date()); |
||||
|
recordMapper.updateById(record); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public IPage<IeRecord> pageRecords(Long userId, Integer pageNumber, Integer pageSize) { |
||||
|
Page<IeRecord> page = new Page<>(safePageNumber(pageNumber), safePageSize(pageSize)); |
||||
|
IPage<IeRecord> result = recordMapper.selectPage(page, new LambdaQueryWrapper<IeRecord>() |
||||
|
.eq(IeRecord::getUserId, userId) |
||||
|
.orderByDesc(IeRecord::getCreateTime)); |
||||
|
for (IeRecord record : result.getRecords()) { |
||||
|
record.setUnreadCount(unreadMessageCount(userId, record.getRoomId(), record.getTargetUserId())); |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
private int unreadMessageCount(Long userId, Long roomId, Long targetUserId) { |
||||
|
if (roomId == null || targetUserId == null) { |
||||
|
return 0; |
||||
|
} |
||||
|
Long count = roomMessageMapper.selectCount(new LambdaQueryWrapper<IeRoomMessage>() |
||||
|
.eq(IeRoomMessage::getRoomId, roomId) |
||||
|
.eq(IeRoomMessage::getSenderId, targetUserId) |
||||
|
.eq(IeRoomMessage::getReceiverId, userId) |
||||
|
.eq(IeRoomMessage::getIsBlocked, 0) |
||||
|
.gt(recordLastReadTime(userId, roomId) != null, IeRoomMessage::getCreateTime, recordLastReadTime(userId, roomId))); |
||||
|
return count == null ? 0 : count.intValue(); |
||||
|
} |
||||
|
|
||||
|
private Date recordLastReadTime(Long userId, Long roomId) { |
||||
|
IeRecord record = recordMapper.selectOne(new LambdaQueryWrapper<IeRecord>() |
||||
|
.eq(IeRecord::getUserId, userId) |
||||
|
.eq(IeRecord::getRoomId, roomId) |
||||
|
.last("limit 1")); |
||||
|
return record == null ? null : record.getLastReadTime(); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public IPage<IeReport> pageReports(Long userId, Integer pageNumber, Integer pageSize) { |
||||
|
Page<IeReport> page = new Page<>(safePageNumber(pageNumber), safePageSize(pageSize)); |
||||
|
return reportMapper.selectPage(page, new LambdaQueryWrapper<IeReport>() |
||||
|
.eq(IeReport::getReporterId, userId) |
||||
|
.orderByDesc(IeReport::getCreateTime)); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@Transactional |
||||
|
public void finishRoomBySystem(Long roomId, Integer status) { |
||||
|
IeRoom room = roomMapper.selectById(roomId); |
||||
|
if (room == null || room.getStatus() == null || room.getStatus() != IeConstants.ROOM_STATUS_ACTIVE) { |
||||
|
return; |
||||
|
} |
||||
|
room.setStatus(status == null ? IeConstants.ROOM_STATUS_NATURAL_END : status); |
||||
|
room.setEndTime(new Date()); |
||||
|
room.setUpdateTime(new Date()); |
||||
|
roomMapper.updateById(room); |
||||
|
createRecord(room, room.getUserAId(), room.getUserBId()); |
||||
|
createRecord(room, room.getUserBId(), room.getUserAId()); |
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(room.getUserAId()), "/queue/ie/room-end", room); |
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(room.getUserBId()), "/queue/ie/room-end", room); |
||||
|
publishEvent(IeChatEventType.ROOM_FINISHED, room.getId(), null, room.getUserAId(), room.getUserBId(), 0, String.valueOf(room.getStatus())); |
||||
|
} |
||||
|
|
||||
|
private IeRoom assertActiveRoom(Long userId, Long roomId) { |
||||
|
IeRoom room = getRoom(roomId); |
||||
|
if (room == null) { |
||||
|
throw new RuntimeException("陪伴房间不存在"); |
||||
|
} |
||||
|
if (!room.getUserAId().equals(userId) && !room.getUserBId().equals(userId)) { |
||||
|
throw new RuntimeException("无权访问该陪伴房间"); |
||||
|
} |
||||
|
if (room.getStatus() == null || room.getStatus() != IeConstants.ROOM_STATUS_ACTIVE) { |
||||
|
throw new RuntimeException("陪伴房间已结束"); |
||||
|
} |
||||
|
return room; |
||||
|
} |
||||
|
|
||||
|
private void assertRoomParticipant(Long userId, Long roomId) { |
||||
|
IeRoom room = getRoom(roomId); |
||||
|
if (room == null) { |
||||
|
throw new RuntimeException("陪伴房间不存在"); |
||||
|
} |
||||
|
if (!room.getUserAId().equals(userId) && !room.getUserBId().equals(userId)) { |
||||
|
throw new RuntimeException("无权访问该陪伴房间"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private long safePageNumber(Integer pageNumber) { |
||||
|
return pageNumber == null || pageNumber < 1 ? 1L : pageNumber; |
||||
|
} |
||||
|
|
||||
|
private long safePageSize(Integer pageSize) { |
||||
|
if (pageSize == null || pageSize < 1) { |
||||
|
return 10L; |
||||
|
} |
||||
|
return Math.min(pageSize, 50); |
||||
|
} |
||||
|
|
||||
|
private void finishExpiredRoom(IeRoom room) { |
||||
|
IeRoom latest = roomMapper.selectById(room.getId()); |
||||
|
if (latest == null || latest.getStatus() == null || latest.getStatus() != IeConstants.ROOM_STATUS_ACTIVE) { |
||||
|
return; |
||||
|
} |
||||
|
room = latest; |
||||
|
room.setStatus(IeConstants.ROOM_STATUS_NATURAL_END); |
||||
|
room.setEndTime(new Date()); |
||||
|
room.setUpdateTime(new Date()); |
||||
|
roomMapper.updateById(room); |
||||
|
createRecord(room, room.getUserAId(), room.getUserBId()); |
||||
|
createRecord(room, room.getUserBId(), room.getUserAId()); |
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(room.getUserAId()), "/queue/ie/room-end", room); |
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(room.getUserBId()), "/queue/ie/room-end", room); |
||||
|
publishEvent(IeChatEventType.ROOM_FINISHED, room.getId(), null, room.getUserAId(), room.getUserBId(), 0, String.valueOf(room.getStatus())); |
||||
|
} |
||||
|
|
||||
|
private Date cleanupTime() { |
||||
|
Calendar calendar = Calendar.getInstance(); |
||||
|
calendar.add(Calendar.HOUR, 24); |
||||
|
return calendar.getTime(); |
||||
|
} |
||||
|
|
||||
|
private String senderOpenid(Long senderId) { |
||||
|
User user = userDao.findById(String.valueOf(senderId)).orElse(null); |
||||
|
return user == null ? null : user.getMiniProgramOpenid(); |
||||
|
} |
||||
|
|
||||
|
private void createRecord(IeRoom room, Long userId, Long targetUserId) { |
||||
|
IeRecord exists = recordMapper.selectOne(new LambdaQueryWrapper<IeRecord>() |
||||
|
.eq(IeRecord::getRoomId, room.getId()) |
||||
|
.eq(IeRecord::getUserId, userId) |
||||
|
.last("limit 1")); |
||||
|
if (exists != null) { |
||||
|
exists.setDurationSeconds((int) Math.max(0, ((room.getEndTime() == null ? System.currentTimeMillis() : room.getEndTime().getTime()) - room.getStartTime().getTime()) / 1000)); |
||||
|
exists.setSummary("这次陪伴已结束,你仍然可以从记录里查看聊天历史。"); |
||||
|
exists.setTags(room.getMode() + "," + room.getMood() + ",聊天记录"); |
||||
|
exists.setRemeetAvailable(1); |
||||
|
exists.setUpdateTime(new Date()); |
||||
|
recordMapper.updateById(exists); |
||||
|
return; |
||||
|
} |
||||
|
IeRecord record = new IeRecord(); |
||||
|
record.setRoomId(room.getId()); |
||||
|
record.setUserId(userId); |
||||
|
record.setTargetUserId(targetUserId); |
||||
|
record.setMode(room.getMode()); |
||||
|
record.setMood(room.getMood()); |
||||
|
record.setAnonymousName("半匿名漂流者"); |
||||
|
record.setDurationSeconds((int) Math.max(0, ((room.getEndTime() == null ? System.currentTimeMillis() : room.getEndTime().getTime()) - room.getStartTime().getTime()) / 1000)); |
||||
|
record.setSummary("这次陪伴已结束,你仍然可以从记录里查看聊天历史。"); |
||||
|
record.setTags(room.getMode() + "," + room.getMood() + ",聊天记录"); |
||||
|
record.setRemeetAvailable(1); |
||||
|
Calendar calendar = Calendar.getInstance(); |
||||
|
calendar.add(Calendar.HOUR, 24); |
||||
|
record.setRemeetExpireTime(calendar.getTime()); |
||||
|
record.setCreateTime(new Date()); |
||||
|
recordMapper.insert(record); |
||||
|
} |
||||
|
|
||||
|
private void publishEvent(String eventType, Long roomId, Long messageId, Long userId, Long targetUserId, Integer riskLevel, String payload) { |
||||
|
IeChatEvent event = new IeChatEvent(); |
||||
|
event.setEventType(eventType); |
||||
|
event.setRoomId(roomId); |
||||
|
event.setMessageId(messageId); |
||||
|
event.setUserId(userId); |
||||
|
event.setTargetUserId(targetUserId); |
||||
|
event.setRiskLevel(riskLevel); |
||||
|
event.setPayload(payload); |
||||
|
String json = JSONUtil.toJsonStr(event); |
||||
|
redisService.publishChatEvent(json); |
||||
|
chatEventProducer.sendEvent(event); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,589 @@ |
|||||
|
package cc.hiver.mall.ie.service.impl; |
||||
|
|
||||
|
import cc.hiver.mall.ie.constant.IeConstants; |
||||
|
import cc.hiver.mall.ie.dto.IeMatchStartDTO; |
||||
|
import cc.hiver.mall.ie.dto.IeProfileDTO; |
||||
|
import cc.hiver.mall.ie.dto.IeStatusDTO; |
||||
|
import cc.hiver.mall.ie.entity.*; |
||||
|
import cc.hiver.mall.ie.mapper.*; |
||||
|
import cc.hiver.mall.ie.service.IeMatchService; |
||||
|
import cc.hiver.mall.ie.service.IeRedisService; |
||||
|
import cc.hiver.mall.ie.service.IeSecurityAuditService; |
||||
|
import cc.hiver.mall.ie.vo.IeAuditResult; |
||||
|
import cc.hiver.mall.ie.vo.IeHomeVO; |
||||
|
import cc.hiver.mall.ie.vo.IeMatchVO; |
||||
|
import cc.hiver.mall.ie.vo.IeUserProfileVO; |
||||
|
import cn.hutool.core.util.IdUtil; |
||||
|
import cn.hutool.json.JSONUtil; |
||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
|
import org.springframework.beans.BeanUtils; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.dao.DuplicateKeyException; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.*; |
||||
|
|
||||
|
@Service |
||||
|
public class IeMatchServiceImpl implements IeMatchService { |
||||
|
|
||||
|
@Autowired |
||||
|
private IeUserProfileMapper userProfileMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeUserStatusMapper userStatusMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeMatchAttemptMapper matchAttemptMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRoomMapper roomMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRecordMapper recordMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRedisService redisService; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeSecurityAuditService auditService; |
||||
|
|
||||
|
@Override |
||||
|
public IeHomeVO home(Long userId) { |
||||
|
IeUserProfile profile = ensureProfile(userId); |
||||
|
redisService.refreshOnline(userId); |
||||
|
IeHomeVO vo = new IeHomeVO(); |
||||
|
vo.setOnlineCount(redisService.onlineCount()); |
||||
|
vo.setWaitingCount(redisService.waitingCount(profile.getCurrentMode(), "quiet")); |
||||
|
vo.setDailyQuota(profile.getDailyQuota()); |
||||
|
vo.setUsedQuota((int) redisService.todayUsedQuota(userId)); |
||||
|
vo.setCurrentMode(profile.getCurrentMode()); |
||||
|
vo.setCurrentMood("quiet"); |
||||
|
vo.setTargetModePreference(profile.getTargetModePreference()); |
||||
|
vo.setTargetGenderPreference(profile.getTargetGenderPreference()); |
||||
|
vo.setProfileCompleted(profile.getProfileCompleted() == null ? 0 : profile.getProfileCompleted()); |
||||
|
vo.setProfile(toProfileVO(profile)); |
||||
|
vo.setHotStatuses(redisService.hotStatuses(8)); |
||||
|
return vo; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public IeUserProfileVO profile(Long userId) { |
||||
|
return toProfileVO(ensureProfile(userId)); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public IeUserProfileVO profileByUserId(Long userId, Long targetUserId) { |
||||
|
if (targetUserId == null || targetUserId.equals(userId)) { |
||||
|
return toProfileVO(ensureProfile(userId)); |
||||
|
} |
||||
|
IeUserProfile profile = findProfileByUserId(targetUserId); |
||||
|
if (profile == null || profile.getProfileCompleted() == null || profile.getProfileCompleted() != 1) { |
||||
|
throw new RuntimeException("对方资料暂时不可见"); |
||||
|
} |
||||
|
return toProfileVO(profile); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@Transactional |
||||
|
public IeUserProfileVO saveProfile(Long userId, IeProfileDTO dto) { |
||||
|
IeUserProfile profile = ensureProfile(userId); |
||||
|
if (dto == null) { |
||||
|
dto = new IeProfileDTO(); |
||||
|
} |
||||
|
String anonymousName = localAuditProfileText(defaultText(dto.getAnonymousName(), profile.getAnonymousName())); |
||||
|
String intro = localAuditProfileText(defaultText(dto.getIntro(), "")); |
||||
|
profile.setAnonymousName(anonymousName); |
||||
|
profile.setAvatarText(defaultText(dto.getAvatarText(), "我")); |
||||
|
if (dto.getAvatarUrl() != null && !dto.getAvatarUrl().trim().isEmpty()) { |
||||
|
profile.setAvatarUrl(dto.getAvatarUrl()); |
||||
|
} |
||||
|
profile.setGender(normalizeGender(dto.getGender(), "unknown")); |
||||
|
profile.setIntro(intro); |
||||
|
profile.setInterestTags(tagsToJson(dto.getInterestTags())); |
||||
|
profile.setCurrentMode(normalizeMode(dto.getCurrentMode(), "i")); |
||||
|
profile.setRecentPreference(profile.getCurrentMode()); |
||||
|
profile.setTargetModePreference(normalizeTargetMode(dto.getTargetModePreference())); |
||||
|
profile.setTargetGenderPreference(normalizeTargetGender(dto.getTargetGenderPreference())); |
||||
|
profile.setProfileCompleted(1); |
||||
|
profile.setLastActiveTime(new Date()); |
||||
|
profile.setUpdateTime(new Date()); |
||||
|
userProfileMapper.updateById(profile); |
||||
|
IeStatusDTO statusDTO = new IeStatusDTO(); |
||||
|
statusDTO.setMode(profile.getCurrentMode()); |
||||
|
statusDTO.setMood("quiet"); |
||||
|
statusDTO.setStatusText(defaultText(profile.getIntro(), profile.getCurrentMode())); |
||||
|
statusDTO.setInterestTags(jsonToTags(profile.getInterestTags())); |
||||
|
updateStatus(userId, statusDTO); |
||||
|
return toProfileVO(profile); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void updateStatus(Long userId, IeStatusDTO dto) { |
||||
|
if (dto == null) { |
||||
|
dto = new IeStatusDTO(); |
||||
|
} |
||||
|
if (dto.getMode() == null) { |
||||
|
dto.setMode("i"); |
||||
|
} |
||||
|
if (dto.getMood() == null) { |
||||
|
dto.setMood("quiet"); |
||||
|
} |
||||
|
ensureProfile(userId); |
||||
|
IeUserStatus status = userStatusMapper.selectOne(new LambdaQueryWrapper<IeUserStatus>() |
||||
|
.eq(IeUserStatus::getUserId, userId) |
||||
|
.last("limit 1")); |
||||
|
Date now = new Date(); |
||||
|
if (status == null) { |
||||
|
status = new IeUserStatus(); |
||||
|
status.setUserId(userId); |
||||
|
status.setCreateTime(now); |
||||
|
} |
||||
|
status.setMode(dto.getMode()); |
||||
|
status.setMood(dto.getMood()); |
||||
|
status.setStatusText(dto.getStatusText()); |
||||
|
status.setOnlineStatus(1); |
||||
|
status.setLastActiveTime(now); |
||||
|
status.setUpdateTime(now); |
||||
|
if (status.getId() == null) { |
||||
|
userStatusMapper.insert(status); |
||||
|
} else { |
||||
|
userStatusMapper.updateById(status); |
||||
|
} |
||||
|
redisService.refreshOnline(userId); |
||||
|
redisService.cacheUserStatus(userId, dto); |
||||
|
redisService.addToMatchPool(userId, dto); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@Transactional |
||||
|
public IeMatchVO startMatch(Long userId, IeMatchStartDTO dto) { |
||||
|
IeUserProfile profile = ensureProfile(userId); |
||||
|
long maxQuota = profile.getDailyQuota() == null ? IeConstants.DEFAULT_DAILY_QUOTA : profile.getDailyQuota(); |
||||
|
if (!redisService.tryLockMatchUser(userId, 8)) { |
||||
|
return fail(userId, dto, "正在匹配中,请稍后"); |
||||
|
} |
||||
|
|
||||
|
Long targetUserId = null; |
||||
|
try { |
||||
|
if (redisService.todayUsedQuota(userId) >= maxQuota) { |
||||
|
return fail(userId, dto, "今日陪伴机会已经用完"); |
||||
|
} |
||||
|
IeStatusDTO statusDTO = new IeStatusDTO(); |
||||
|
BeanUtils.copyProperties(dto == null ? new IeMatchStartDTO() : dto, statusDTO); |
||||
|
updateStatus(userId, statusDTO); |
||||
|
|
||||
|
String mode = normalizeMode(statusDTO.getMode(), profile.getCurrentMode()); |
||||
|
String targetMode = normalizeTargetMode(dto == null ? null : dto.getTargetMode()); |
||||
|
String requestedTargetGender = dto == null || dto.getTargetGender() == null ? profile.getTargetGenderPreference() : dto.getTargetGender(); |
||||
|
String targetGender = normalizeTargetGender(requestedTargetGender); |
||||
|
String mood = statusDTO.getMood() == null ? "quiet" : statusDTO.getMood(); |
||||
|
List<Long> candidates = candidateUsers(targetMode, mode, mood); |
||||
|
List<MatchCandidate> matchedCandidates = new ArrayList<>(); |
||||
|
for (Long candidateUserId : candidates) { |
||||
|
if (candidateUserId == null || candidateUserId.equals(userId)) { |
||||
|
continue; |
||||
|
} |
||||
|
if (redisService.isRecentlyMatched(userId, candidateUserId)) { |
||||
|
continue; |
||||
|
} |
||||
|
IeUserProfile candidateProfile = findProfileByUserId(candidateUserId); |
||||
|
if (!matchCandidate(candidateProfile, targetMode, targetGender)) { |
||||
|
continue; |
||||
|
} |
||||
|
matchedCandidates.add(new MatchCandidate(candidateUserId, matchScore(statusDTO, profile, candidateProfile))); |
||||
|
} |
||||
|
matchedCandidates.sort(Comparator.comparingInt(MatchCandidate::getScore).reversed()); |
||||
|
for (MatchCandidate candidate : matchedCandidates) { |
||||
|
if (!redisService.tryLockMatchUser(candidate.getUserId(), 8)) { |
||||
|
continue; |
||||
|
} |
||||
|
targetUserId = candidate.getUserId(); |
||||
|
break; |
||||
|
} |
||||
|
if (targetUserId == null) { |
||||
|
return fail(userId, dto, "暂时没有同频的人,稍后再试试"); |
||||
|
} |
||||
|
long used = redisService.increaseTodayQuota(userId, maxQuota); |
||||
|
if (used > maxQuota) { |
||||
|
return fail(userId, dto, "今日陪伴机会已经用完"); |
||||
|
} |
||||
|
IeUserProfile targetProfile = ensureProfile(targetUserId); |
||||
|
|
||||
|
IeMatchAttempt match = new IeMatchAttempt(); |
||||
|
match.setMatchNo(IdUtil.fastSimpleUUID()); |
||||
|
match.setUserId(userId); |
||||
|
match.setTargetUserId(targetUserId); |
||||
|
match.setMode(mode); |
||||
|
match.setMood(mood); |
||||
|
match.setStatus(3); |
||||
|
match.setAnonymousName(defaultText(targetProfile.getAnonymousName(), "半匿名漂流者")); |
||||
|
match.setAvatarText(defaultText(targetProfile.getAvatarText(), "◌")); |
||||
|
match.setStateText(targetProfile.getCurrentMode() == null || "i".equals(targetProfile.getCurrentMode()) ? "偏安静,适合慢慢靠近" : "偏轻松,愿意先开场"); |
||||
|
match.setQuoteText(defaultText(targetProfile.getIntro(), "可以先安静待一会,不用急着找话题。")); |
||||
|
match.setCreateTime(new Date()); |
||||
|
match.setUpdateTime(new Date()); |
||||
|
matchAttemptMapper.insert(match); |
||||
|
|
||||
|
IeRoom room = createRoom(match, userId, targetUserId, mode, mood); |
||||
|
createChatRecord(room, userId, targetUserId, targetProfile); |
||||
|
createChatRecord(room, targetUserId, userId, profile); |
||||
|
redisService.markRecentlyMatched(userId, targetUserId); |
||||
|
redisService.markRecentlyMatched(targetUserId, userId); |
||||
|
|
||||
|
IeMatchVO vo = toMatchVO(match); |
||||
|
vo.setRoomId(room.getId()); |
||||
|
vo.setRoomNo(room.getRoomNo()); |
||||
|
vo.setAvatarUrl(targetProfile.getAvatarUrl()); |
||||
|
return vo; |
||||
|
} finally { |
||||
|
redisService.unlockMatchUser(userId); |
||||
|
if (targetUserId != null) { |
||||
|
redisService.unlockMatchUser(targetUserId); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private IeRoom createRoom(IeMatchAttempt match, Long userId, Long targetUserId, String mode, String mood) { |
||||
|
Date now = new Date(); |
||||
|
IeRoom room = new IeRoom(); |
||||
|
room.setRoomNo(IdUtil.fastSimpleUUID()); |
||||
|
room.setMatchId(match.getId()); |
||||
|
room.setUserAId(userId); |
||||
|
room.setUserBId(targetUserId); |
||||
|
room.setMode(mode); |
||||
|
room.setMood(mood); |
||||
|
room.setStatus(IeConstants.ROOM_STATUS_ACTIVE); |
||||
|
room.setStartTime(now); |
||||
|
room.setExpireTime(longTermExpireTime(now)); |
||||
|
room.setCreateTime(now); |
||||
|
room.setUpdateTime(now); |
||||
|
roomMapper.insert(room); |
||||
|
redisService.cacheRoom(room.getId(), JSONUtil.toJsonStr(room), 20 * 60L); |
||||
|
return room; |
||||
|
} |
||||
|
|
||||
|
private Date longTermExpireTime(Date startTime) { |
||||
|
Calendar calendar = Calendar.getInstance(); |
||||
|
calendar.setTime(startTime == null ? new Date() : startTime); |
||||
|
calendar.add(Calendar.YEAR, 10); |
||||
|
return calendar.getTime(); |
||||
|
} |
||||
|
|
||||
|
private void createChatRecord(IeRoom room, Long userId, Long targetUserId, IeUserProfile targetProfile) { |
||||
|
IeRecord exists = recordMapper.selectOne(new LambdaQueryWrapper<IeRecord>() |
||||
|
.eq(IeRecord::getRoomId, room.getId()) |
||||
|
.eq(IeRecord::getUserId, userId) |
||||
|
.last("limit 1")); |
||||
|
if (exists != null) { |
||||
|
return; |
||||
|
} |
||||
|
IeRecord record = new IeRecord(); |
||||
|
record.setRoomId(room.getId()); |
||||
|
record.setUserId(userId); |
||||
|
record.setTargetUserId(targetUserId); |
||||
|
record.setMode(room.getMode()); |
||||
|
record.setMood(room.getMood()); |
||||
|
record.setAnonymousName(defaultText(targetProfile == null ? null : targetProfile.getAnonymousName(), "半匿名漂流者")); |
||||
|
record.setDurationSeconds(0); |
||||
|
record.setSummary("你们已经匹配成功,可以从这里回到聊天继续对话。"); |
||||
|
record.setTags(room.getMode() + "," + room.getMood() + ",继续聊天"); |
||||
|
record.setRemeetAvailable(1); |
||||
|
record.setCreateTime(new Date()); |
||||
|
recordMapper.insert(record); |
||||
|
} |
||||
|
|
||||
|
private IeMatchVO fail(Long userId, IeMatchStartDTO dto, String reason) { |
||||
|
IeMatchAttempt match = new IeMatchAttempt(); |
||||
|
match.setMatchNo(IdUtil.fastSimpleUUID()); |
||||
|
match.setUserId(userId); |
||||
|
match.setMode(dto == null || dto.getMode() == null ? "i" : dto.getMode()); |
||||
|
match.setMood(dto == null || dto.getMood() == null ? "quiet" : dto.getMood()); |
||||
|
match.setStatus(4); |
||||
|
match.setFailReason(reason); |
||||
|
match.setCreateTime(new Date()); |
||||
|
match.setUpdateTime(new Date()); |
||||
|
matchAttemptMapper.insert(match); |
||||
|
IeMatchVO vo = toMatchVO(match); |
||||
|
vo.setFailReason(reason); |
||||
|
return vo; |
||||
|
} |
||||
|
|
||||
|
private IeMatchVO toMatchVO(IeMatchAttempt match) { |
||||
|
IeMatchVO vo = new IeMatchVO(); |
||||
|
vo.setMatchId(match.getId()); |
||||
|
vo.setMatchNo(match.getMatchNo()); |
||||
|
vo.setTargetUserId(match.getTargetUserId()); |
||||
|
vo.setAnonymousName(match.getAnonymousName()); |
||||
|
vo.setAvatarText(match.getAvatarText()); |
||||
|
vo.setMode(match.getMode()); |
||||
|
vo.setMood(match.getMood()); |
||||
|
vo.setStateText(match.getStateText()); |
||||
|
vo.setQuoteText(match.getQuoteText()); |
||||
|
vo.setStatus(match.getStatus()); |
||||
|
vo.setFailReason(match.getFailReason()); |
||||
|
return vo; |
||||
|
} |
||||
|
|
||||
|
private IeUserProfile ensureProfile(Long userId) { |
||||
|
IeUserProfile profile = findProfileByUserId(userId); |
||||
|
if (profile != null) { |
||||
|
return profile; |
||||
|
} |
||||
|
profile = new IeUserProfile(); |
||||
|
profile.setUserId(userId); |
||||
|
profile.setAnonymousName("半匿名漂流者"); |
||||
|
profile.setAvatarText("夜"); |
||||
|
profile.setCurrentMode("i"); |
||||
|
profile.setRecentPreference("i"); |
||||
|
profile.setTargetModePreference("any"); |
||||
|
profile.setGender("unknown"); |
||||
|
profile.setTargetGenderPreference("any"); |
||||
|
profile.setDefaultRoomMinutes(IeConstants.DEFAULT_ROOM_MINUTES); |
||||
|
profile.setDailyQuota(IeConstants.DEFAULT_DAILY_QUOTA); |
||||
|
profile.setUsedQuota(0); |
||||
|
profile.setProfileCompleted(0); |
||||
|
profile.setIsDeleted(0); |
||||
|
profile.setCreateTime(new Date()); |
||||
|
profile.setUpdateTime(new Date()); |
||||
|
try { |
||||
|
userProfileMapper.insert(profile); |
||||
|
return profile; |
||||
|
} catch (DuplicateKeyException ignored) { |
||||
|
// 并发进入首页时,另一个请求可能已经创建成功;重新读取即可保持幂等。
|
||||
|
IeUserProfile exists = findProfileByUserId(userId); |
||||
|
if (exists != null) { |
||||
|
return exists; |
||||
|
} |
||||
|
throw ignored; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private IeUserProfile findProfileByUserId(Long userId) { |
||||
|
return userProfileMapper.selectOne(new LambdaQueryWrapper<IeUserProfile>() |
||||
|
.eq(IeUserProfile::getUserId, userId) |
||||
|
.last("limit 1")); |
||||
|
} |
||||
|
|
||||
|
private List<Long> candidateUsers(String targetMode, String selfMode, String mood) { |
||||
|
LinkedHashSet<Long> set = new LinkedHashSet<>(); |
||||
|
if (!"any".equals(targetMode)) { |
||||
|
set.addAll(redisService.candidates(targetMode, mood, 50)); |
||||
|
set.addAll(statusCandidates(targetMode, 50)); |
||||
|
set.addAll(profileCandidates(targetMode, 50)); |
||||
|
return new ArrayList<>(set); |
||||
|
} |
||||
|
set.addAll(redisService.candidates(selfMode, mood, 30)); |
||||
|
set.addAll(redisService.candidates("i".equals(selfMode) ? "e" : "i", mood, 30)); |
||||
|
set.addAll(redisService.candidates("i", mood, 20)); |
||||
|
set.addAll(redisService.candidates("e", mood, 20)); |
||||
|
set.addAll(statusCandidates(selfMode, 30)); |
||||
|
set.addAll(statusCandidates("i".equals(selfMode) ? "e" : "i", 30)); |
||||
|
set.addAll(profileCandidates(selfMode, 30)); |
||||
|
set.addAll(profileCandidates("i".equals(selfMode) ? "e" : "i", 30)); |
||||
|
return new ArrayList<>(set); |
||||
|
} |
||||
|
|
||||
|
private List<Long> statusCandidates(String mode, int limit) { |
||||
|
List<IeUserStatus> statuses = userStatusMapper.selectList(new LambdaQueryWrapper<IeUserStatus>() |
||||
|
.eq(IeUserStatus::getOnlineStatus, 1) |
||||
|
.eq(IeUserStatus::getMode, normalizeMode(mode, "i")) |
||||
|
.orderByDesc(IeUserStatus::getLastActiveTime) |
||||
|
.last("limit " + Math.max(1, Math.min(limit, 100)))); |
||||
|
List<Long> users = new ArrayList<>(); |
||||
|
for (IeUserStatus status : statuses) { |
||||
|
if (status.getUserId() != null) { |
||||
|
users.add(status.getUserId()); |
||||
|
} |
||||
|
} |
||||
|
return users; |
||||
|
} |
||||
|
|
||||
|
private List<Long> profileCandidates(String mode, int limit) { |
||||
|
List<IeUserProfile> profiles = userProfileMapper.selectList(new LambdaQueryWrapper<IeUserProfile>() |
||||
|
.eq(IeUserProfile::getProfileCompleted, 1) |
||||
|
.eq(IeUserProfile::getCurrentMode, normalizeMode(mode, "i")) |
||||
|
.eq(IeUserProfile::getIsDeleted, 0) |
||||
|
.orderByDesc(IeUserProfile::getLastActiveTime) |
||||
|
.last("limit " + Math.max(1, Math.min(limit, 100)))); |
||||
|
List<Long> users = new ArrayList<>(); |
||||
|
for (IeUserProfile profile : profiles) { |
||||
|
if (profile.getUserId() != null) { |
||||
|
users.add(profile.getUserId()); |
||||
|
} |
||||
|
} |
||||
|
return users; |
||||
|
} |
||||
|
|
||||
|
private String auditProfileText(String bizType, Long userId, String content) { |
||||
|
IeAuditResult result = auditService.audit(bizType, userId, userId, content); |
||||
|
if (Boolean.TRUE.equals(result.getBlocked())) { |
||||
|
throw new RuntimeException("资料内容包含不适合展示的表达,请换一种说法"); |
||||
|
} |
||||
|
return result.getFilteredContent(); |
||||
|
} |
||||
|
|
||||
|
private String localAuditProfileText(String content) { |
||||
|
if (content == null) { |
||||
|
return ""; |
||||
|
} |
||||
|
String lower = content.toLowerCase(); |
||||
|
String[] riskyWords = {"黄色", "政治", "辱骂", "傻逼", "操你", "约炮", "色情"}; |
||||
|
for (String word : riskyWords) { |
||||
|
if (lower.contains(word)) { |
||||
|
throw new RuntimeException("资料内容包含不适合展示的表达,请换一种说法"); |
||||
|
} |
||||
|
} |
||||
|
return content; |
||||
|
} |
||||
|
|
||||
|
private IeUserProfileVO toProfileVO(IeUserProfile profile) { |
||||
|
IeUserProfileVO vo = new IeUserProfileVO(); |
||||
|
vo.setUserId(profile.getUserId()); |
||||
|
vo.setAnonymousName(profile.getAnonymousName()); |
||||
|
vo.setAvatarText(profile.getAvatarText()); |
||||
|
vo.setAvatarUrl(profile.getAvatarUrl()); |
||||
|
vo.setGender(profile.getGender()); |
||||
|
vo.setIntro(profile.getIntro()); |
||||
|
vo.setInterestTags(jsonToTags(profile.getInterestTags())); |
||||
|
vo.setCurrentMode(profile.getCurrentMode()); |
||||
|
vo.setRecentPreference(profile.getRecentPreference()); |
||||
|
vo.setTargetModePreference(profile.getTargetModePreference()); |
||||
|
vo.setTargetGenderPreference(profile.getTargetGenderPreference()); |
||||
|
vo.setDefaultRoomMinutes(profile.getDefaultRoomMinutes()); |
||||
|
vo.setDailyQuota(profile.getDailyQuota()); |
||||
|
vo.setUsedQuota(profile.getUsedQuota()); |
||||
|
vo.setProfileCompleted(profile.getProfileCompleted() == null ? 0 : profile.getProfileCompleted()); |
||||
|
return vo; |
||||
|
} |
||||
|
|
||||
|
private String defaultText(String value, String fallback) { |
||||
|
return value == null || value.trim().isEmpty() ? fallback : value.trim(); |
||||
|
} |
||||
|
|
||||
|
private String normalizeMode(String value, String fallback) { |
||||
|
if ("e".equalsIgnoreCase(value)) { |
||||
|
return "e"; |
||||
|
} |
||||
|
if ("i".equalsIgnoreCase(value)) { |
||||
|
return "i"; |
||||
|
} |
||||
|
return fallback == null ? "i" : fallback; |
||||
|
} |
||||
|
|
||||
|
private String normalizeTargetMode(String value) { |
||||
|
if ("i".equalsIgnoreCase(value) || "e".equalsIgnoreCase(value)) { |
||||
|
return value.toLowerCase(); |
||||
|
} |
||||
|
return "any"; |
||||
|
} |
||||
|
|
||||
|
private String normalizeGender(String value, String fallback) { |
||||
|
if ("male".equalsIgnoreCase(value) || "female".equalsIgnoreCase(value)) { |
||||
|
return value.toLowerCase(); |
||||
|
} |
||||
|
if ("unknown".equalsIgnoreCase(value)) { |
||||
|
return "unknown"; |
||||
|
} |
||||
|
return fallback == null ? "unknown" : fallback; |
||||
|
} |
||||
|
|
||||
|
private String normalizeTargetGender(String value) { |
||||
|
if ("male".equalsIgnoreCase(value) || "female".equalsIgnoreCase(value)) { |
||||
|
return value.toLowerCase(); |
||||
|
} |
||||
|
return "any"; |
||||
|
} |
||||
|
|
||||
|
private boolean matchCandidate(IeUserProfile candidateProfile, String selfTargetMode, String selfTargetGender) { |
||||
|
if (candidateProfile == null) { |
||||
|
return false; |
||||
|
} |
||||
|
String candidateMode = normalizeMode(candidateProfile.getCurrentMode(), "i"); |
||||
|
if (!"any".equals(selfTargetMode) && !selfTargetMode.equals(candidateMode)) { |
||||
|
return false; |
||||
|
} |
||||
|
return matchGender(candidateProfile, selfTargetGender); |
||||
|
} |
||||
|
|
||||
|
private boolean matchGender(IeUserProfile profile, String targetGender) { |
||||
|
if ("any".equals(targetGender)) { |
||||
|
return true; |
||||
|
} |
||||
|
return profile != null && targetGender.equals(profile.getGender()); |
||||
|
} |
||||
|
|
||||
|
private int matchScore(IeStatusDTO selfStatus, IeUserProfile selfProfile, IeUserProfile candidateProfile) { |
||||
|
int score = 0; |
||||
|
List<String> selfTags = new ArrayList<>(); |
||||
|
if (selfStatus != null && selfStatus.getInterestTags() != null) { |
||||
|
selfTags.addAll(selfStatus.getInterestTags()); |
||||
|
} |
||||
|
if (selfProfile != null) { |
||||
|
selfTags.addAll(jsonToTags(selfProfile.getInterestTags())); |
||||
|
} |
||||
|
List<String> candidateTags = candidateProfile == null ? new ArrayList<>() : jsonToTags(candidateProfile.getInterestTags()); |
||||
|
for (String tag : selfTags) { |
||||
|
if (tag != null && candidateTags.contains(tag)) { |
||||
|
score += 30; |
||||
|
} |
||||
|
} |
||||
|
if (selfProfile != null && candidateProfile != null) { |
||||
|
String selfMode = normalizeMode(selfProfile.getCurrentMode(), "i"); |
||||
|
String candidateTargetMode = normalizeTargetMode(candidateProfile.getTargetModePreference()); |
||||
|
if ("any".equals(candidateTargetMode) || candidateTargetMode.equals(selfMode)) { |
||||
|
score += 20; |
||||
|
} |
||||
|
String selfTargetGender = normalizeTargetGender(selfProfile.getTargetGenderPreference()); |
||||
|
if ("any".equals(selfTargetGender) || selfTargetGender.equals(candidateProfile.getGender())) { |
||||
|
score += 10; |
||||
|
} |
||||
|
if (candidateProfile.getIntro() != null && !candidateProfile.getIntro().trim().isEmpty()) { |
||||
|
score += 5; |
||||
|
} |
||||
|
} |
||||
|
return score; |
||||
|
} |
||||
|
|
||||
|
private String tagsToJson(List<String> tags) { |
||||
|
if (tags == null) { |
||||
|
return "[]"; |
||||
|
} |
||||
|
return JSONUtil.toJsonStr(tags); |
||||
|
} |
||||
|
|
||||
|
private List<String> jsonToTags(String tags) { |
||||
|
if (tags == null || tags.trim().isEmpty()) { |
||||
|
return new ArrayList<>(); |
||||
|
} |
||||
|
try { |
||||
|
return JSONUtil.toList(JSONUtil.parseArray(tags), String.class); |
||||
|
} catch (Exception ignored) { |
||||
|
List<String> list = new ArrayList<>(); |
||||
|
for (String tag : tags.split(",")) { |
||||
|
if (tag != null && !tag.trim().isEmpty()) { |
||||
|
list.add(tag.trim()); |
||||
|
} |
||||
|
} |
||||
|
return list; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static class MatchCandidate { |
||||
|
private final Long userId; |
||||
|
private final int score; |
||||
|
|
||||
|
private MatchCandidate(Long userId, int score) { |
||||
|
this.userId = userId; |
||||
|
this.score = score; |
||||
|
} |
||||
|
|
||||
|
private Long getUserId() { |
||||
|
return userId; |
||||
|
} |
||||
|
|
||||
|
private int getScore() { |
||||
|
return score; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,275 @@ |
|||||
|
package cc.hiver.mall.ie.service.impl; |
||||
|
|
||||
|
import cc.hiver.mall.ie.constant.IeRedisKey; |
||||
|
import cc.hiver.mall.ie.dto.IeStatusDTO; |
||||
|
import cc.hiver.mall.ie.service.IeRedisService; |
||||
|
import cn.hutool.core.collection.CollectionUtil; |
||||
|
import cn.hutool.core.date.DateUtil; |
||||
|
import cn.hutool.json.JSONUtil; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.data.redis.core.StringRedisTemplate; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
import java.time.Duration; |
||||
|
import java.time.LocalDate; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.time.LocalTime; |
||||
|
import java.time.format.DateTimeFormatter; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
@Service |
||||
|
public class IeRedisServiceImpl implements IeRedisService { |
||||
|
|
||||
|
private static final long ONLINE_SECONDS = 90L; |
||||
|
private static final long STATUS_SECONDS = 300L; |
||||
|
private static final long RECENT_MATCH_MINUTES = 10L; |
||||
|
private static final long ROOM_CACHE_SECONDS = 20 * 60L; |
||||
|
|
||||
|
@Autowired |
||||
|
private StringRedisTemplate stringRedisTemplate; |
||||
|
|
||||
|
@Override |
||||
|
public void refreshOnline(Long userId) { |
||||
|
long now = System.currentTimeMillis(); |
||||
|
stringRedisTemplate.opsForZSet().add(IeRedisKey.ONLINE_USERS, String.valueOf(userId), now); |
||||
|
stringRedisTemplate.opsForZSet().removeRangeByScore(IeRedisKey.ONLINE_USERS, 0, now - ONLINE_SECONDS * 1000); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void cacheUserStatus(Long userId, IeStatusDTO dto) { |
||||
|
String key = IeRedisKey.USER_STATUS + userId; |
||||
|
stringRedisTemplate.opsForHash().put(key, "mode", defaultText(dto.getMode(), "i")); |
||||
|
stringRedisTemplate.opsForHash().put(key, "mood", defaultText(dto.getMood(), "quiet")); |
||||
|
stringRedisTemplate.opsForHash().put(key, "statusText", defaultText(dto.getStatusText(), "")); |
||||
|
stringRedisTemplate.opsForHash().put(key, "longitude", dto.getLongitude() == null ? "" : String.valueOf(dto.getLongitude())); |
||||
|
stringRedisTemplate.opsForHash().put(key, "latitude", dto.getLatitude() == null ? "" : String.valueOf(dto.getLatitude())); |
||||
|
stringRedisTemplate.opsForHash().put(key, "interestTags", dto.getInterestTags() == null ? "[]" : JSONUtil.toJsonStr(dto.getInterestTags())); |
||||
|
stringRedisTemplate.opsForHash().put(key, "lastActiveTime", DateUtil.now()); |
||||
|
stringRedisTemplate.expire(key, STATUS_SECONDS, TimeUnit.SECONDS); |
||||
|
if (dto.getStatusText() != null && dto.getStatusText().trim().length() > 0) { |
||||
|
stringRedisTemplate.opsForZSet().incrementScore(IeRedisKey.HOT_STATUS, dto.getStatusText().trim(), 1D); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void addToMatchPool(Long userId, IeStatusDTO dto) { |
||||
|
removeFromMatchPools(userId); |
||||
|
String mode = defaultText(dto.getMode(), "i"); |
||||
|
String mood = defaultText(dto.getMood(), "quiet"); |
||||
|
String poolKey = IeRedisKey.matchPool(mode, mood); |
||||
|
double score = System.currentTimeMillis(); |
||||
|
if ("i".equals(mode)) { |
||||
|
score += 5000; |
||||
|
} |
||||
|
if (dto.getInterestTags() != null) { |
||||
|
score += dto.getInterestTags().size() * 100; |
||||
|
} |
||||
|
stringRedisTemplate.opsForZSet().add(poolKey, String.valueOf(userId), score); |
||||
|
stringRedisTemplate.expire(poolKey, STATUS_SECONDS, TimeUnit.SECONDS); |
||||
|
stringRedisTemplate.opsForValue().set(IeRedisKey.USER_MATCH_POOL + userId, poolKey, STATUS_SECONDS, TimeUnit.SECONDS); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void removeFromMatchPools(Long userId) { |
||||
|
String poolKey = stringRedisTemplate.opsForValue().get(IeRedisKey.USER_MATCH_POOL + userId); |
||||
|
if (poolKey != null && poolKey.length() > 0) { |
||||
|
stringRedisTemplate.opsForZSet().remove(poolKey, String.valueOf(userId)); |
||||
|
} |
||||
|
stringRedisTemplate.delete(IeRedisKey.USER_MATCH_POOL + userId); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean tryLockMatchUser(Long userId, long seconds) { |
||||
|
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(IeRedisKey.MATCH_LOCK + userId, "1", seconds, TimeUnit.SECONDS); |
||||
|
return Boolean.TRUE.equals(locked); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void unlockMatchUser(Long userId) { |
||||
|
stringRedisTemplate.delete(IeRedisKey.MATCH_LOCK + userId); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public List<Long> candidates(String mode, String mood, int limit) { |
||||
|
List<String> keys = new ArrayList<>(); |
||||
|
String targetMode = defaultText(mode, "i"); |
||||
|
String targetMood = defaultText(mood, "quiet"); |
||||
|
keys.add(IeRedisKey.matchPool(targetMode, targetMood)); |
||||
|
if (!"quiet".equals(targetMood)) { |
||||
|
keys.add(IeRedisKey.matchPool(targetMode, "quiet")); |
||||
|
} |
||||
|
addPoolKey(keys, targetMode, "talk"); |
||||
|
addPoolKey(keys, targetMode, "listen"); |
||||
|
addPoolKey(keys, targetMode, "drift"); |
||||
|
List<Long> result = new ArrayList<>(); |
||||
|
for (String key : keys) { |
||||
|
Set<String> members = stringRedisTemplate.opsForZSet().reverseRange(key, 0, limit - 1L); |
||||
|
if (CollectionUtil.isEmpty(members)) { |
||||
|
continue; |
||||
|
} |
||||
|
for (String member : members) { |
||||
|
result.add(Long.valueOf(member)); |
||||
|
if (result.size() >= limit) { |
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
private void addPoolKey(List<String> keys, String mode, String mood) { |
||||
|
String key = IeRedisKey.matchPool(mode, mood); |
||||
|
if (!keys.contains(key)) { |
||||
|
keys.add(key); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean isRecentlyMatched(Long userId, Long targetUserId) { |
||||
|
Boolean result = stringRedisTemplate.opsForSet().isMember(IeRedisKey.RECENT_MATCH + userId, String.valueOf(targetUserId)); |
||||
|
return Boolean.TRUE.equals(result); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void markRecentlyMatched(Long userId, Long targetUserId) { |
||||
|
String key = IeRedisKey.RECENT_MATCH + userId; |
||||
|
stringRedisTemplate.opsForSet().add(key, String.valueOf(targetUserId)); |
||||
|
stringRedisTemplate.expire(key, RECENT_MATCH_MINUTES, TimeUnit.MINUTES); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public long todayUsedQuota(Long userId) { |
||||
|
String value = stringRedisTemplate.opsForValue().get(dailyQuotaKey(userId)); |
||||
|
return value == null ? 0L : Long.parseLong(value); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public long increaseTodayQuota(Long userId, long maxQuota) { |
||||
|
String key = dailyQuotaKey(userId); |
||||
|
Long value = stringRedisTemplate.opsForValue().increment(key); |
||||
|
if (value != null && value == 1L) { |
||||
|
stringRedisTemplate.expire(key, Duration.between(LocalDateTime.now(), LocalDateTime.now().with(LocalTime.MAX))); |
||||
|
} |
||||
|
long used = value == null ? 1L : value; |
||||
|
if (used > maxQuota) { |
||||
|
stringRedisTemplate.opsForValue().decrement(key); |
||||
|
} |
||||
|
return used; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void cacheRoom(Long roomId, String roomJson, long seconds) { |
||||
|
stringRedisTemplate.opsForValue().set(IeRedisKey.ROOM + roomId, roomJson, seconds <= 0 ? ROOM_CACHE_SECONDS : seconds, TimeUnit.SECONDS); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public String getCachedRoom(Long roomId) { |
||||
|
return stringRedisTemplate.opsForValue().get(IeRedisKey.ROOM + roomId); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void pushOfflineMessage(Long userId, String messageJson) { |
||||
|
String key = IeRedisKey.OFFLINE_MESSAGE + userId; |
||||
|
stringRedisTemplate.opsForList().leftPush(key, messageJson); |
||||
|
stringRedisTemplate.opsForList().trim(key, 0, 99); |
||||
|
stringRedisTemplate.expire(key, 24, TimeUnit.HOURS); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public List<String> popOfflineMessages(Long userId, int limit) { |
||||
|
String key = IeRedisKey.OFFLINE_MESSAGE + userId; |
||||
|
List<String> messages = stringRedisTemplate.opsForList().range(key, 0, Math.max(limit - 1, 0)); |
||||
|
if (messages != null && !messages.isEmpty()) { |
||||
|
stringRedisTemplate.opsForList().trim(key, messages.size(), -1); |
||||
|
} |
||||
|
return messages == null ? new ArrayList<>() : messages; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void cacheWebSocketConnection(Long userId, String sessionId) { |
||||
|
String key = IeRedisKey.WS_CONNECTION + userId; |
||||
|
stringRedisTemplate.opsForHash().put(key, "sessionId", sessionId == null ? "" : sessionId); |
||||
|
stringRedisTemplate.opsForHash().put(key, "connectTime", DateUtil.now()); |
||||
|
stringRedisTemplate.expire(key, ONLINE_SECONDS, TimeUnit.SECONDS); |
||||
|
if (sessionId != null && sessionId.length() > 0) { |
||||
|
stringRedisTemplate.opsForValue().set(IeRedisKey.WS_SESSION_USER + sessionId, String.valueOf(userId), ONLINE_SECONDS, TimeUnit.SECONDS); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void removeWebSocketConnection(Long userId) { |
||||
|
String key = IeRedisKey.WS_CONNECTION + userId; |
||||
|
Object sessionId = stringRedisTemplate.opsForHash().get(key, "sessionId"); |
||||
|
if (sessionId != null) { |
||||
|
stringRedisTemplate.delete(IeRedisKey.WS_SESSION_USER + sessionId); |
||||
|
} |
||||
|
stringRedisTemplate.delete(key); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public Long userIdBySession(String sessionId) { |
||||
|
String value = stringRedisTemplate.opsForValue().get(IeRedisKey.WS_SESSION_USER + sessionId); |
||||
|
return value == null ? null : Long.valueOf(value); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean allowMessage(Long userId, Long roomId) { |
||||
|
String key = IeRedisKey.MESSAGE_RATE + roomId + ":" + userId; |
||||
|
Long count = stringRedisTemplate.opsForValue().increment(key); |
||||
|
if (count != null && count == 1L) { |
||||
|
stringRedisTemplate.expire(key, 10, TimeUnit.SECONDS); |
||||
|
} |
||||
|
return count == null || count <= 8; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public long increaseRisk(Long userId, long seconds) { |
||||
|
String key = IeRedisKey.USER_RISK + userId; |
||||
|
Long count = stringRedisTemplate.opsForValue().increment(key); |
||||
|
if (count != null && count == 1L) { |
||||
|
stringRedisTemplate.expire(key, seconds, TimeUnit.SECONDS); |
||||
|
} |
||||
|
return count == null ? 1L : count; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void publishChatEvent(String eventJson) { |
||||
|
stringRedisTemplate.convertAndSend(IeRedisKey.CHAT_EVENT_CHANNEL, eventJson); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public long onlineCount() { |
||||
|
long now = System.currentTimeMillis(); |
||||
|
stringRedisTemplate.opsForZSet().removeRangeByScore(IeRedisKey.ONLINE_USERS, 0, now - ONLINE_SECONDS * 1000); |
||||
|
Long count = stringRedisTemplate.opsForZSet().zCard(IeRedisKey.ONLINE_USERS); |
||||
|
return count == null ? 0L : count; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public long waitingCount(String mode, String mood) { |
||||
|
Long count = stringRedisTemplate.opsForZSet().zCard(IeRedisKey.matchPool(defaultText(mode, "i"), defaultText(mood, "quiet"))); |
||||
|
return count == null ? 0L : count; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public List<String> hotStatuses(int limit) { |
||||
|
Set<String> values = stringRedisTemplate.opsForZSet().reverseRange(IeRedisKey.HOT_STATUS, 0, Math.max(limit - 1, 0)); |
||||
|
if (values == null) { |
||||
|
return new ArrayList<>(); |
||||
|
} |
||||
|
return values.stream().collect(Collectors.toList()); |
||||
|
} |
||||
|
|
||||
|
private String dailyQuotaKey(Long userId) { |
||||
|
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); |
||||
|
return IeRedisKey.DAILY_QUOTA + date + ":" + userId; |
||||
|
} |
||||
|
|
||||
|
private String defaultText(String value, String defaultValue) { |
||||
|
return value == null || value.trim().isEmpty() ? defaultValue : value.trim(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,184 @@ |
|||||
|
package cc.hiver.mall.ie.service.impl; |
||||
|
|
||||
|
import cc.hiver.mall.ie.constant.IeConstants; |
||||
|
import cc.hiver.mall.ie.constant.IeRedisKey; |
||||
|
import cc.hiver.mall.ie.entity.IeContentAuditLog; |
||||
|
import cc.hiver.mall.ie.entity.IeSensitiveWord; |
||||
|
import cc.hiver.mall.ie.mapper.IeContentAuditLogMapper; |
||||
|
import cc.hiver.mall.ie.mapper.IeSensitiveWordMapper; |
||||
|
import cc.hiver.mall.ie.service.IeSecurityAuditService; |
||||
|
import cc.hiver.mall.ie.service.IeWechatSecurityService; |
||||
|
import cc.hiver.mall.ie.vo.IeAuditResult; |
||||
|
import cc.hiver.mall.ie.vo.IeWechatMsgSecResult; |
||||
|
import cn.hutool.core.collection.CollectionUtil; |
||||
|
import cn.hutool.core.text.CharSequenceUtil; |
||||
|
import cn.hutool.json.JSONUtil; |
||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
|
import com.github.houbb.sensitive.word.api.IWordDeny; |
||||
|
import com.github.houbb.sensitive.word.bs.SensitiveWordBs; |
||||
|
import com.github.houbb.sensitive.word.support.deny.WordDenys; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.data.redis.core.StringRedisTemplate; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
import java.util.Date; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
@Service |
||||
|
public class IeSecurityAuditServiceImpl implements IeSecurityAuditService { |
||||
|
|
||||
|
private volatile SensitiveWordBs cachedWordBs; |
||||
|
private volatile Map<String, IeSensitiveWord> cachedWordMap = new HashMap<>(); |
||||
|
private volatile long cachedExpireAt = 0L; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeSensitiveWordMapper sensitiveWordMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeContentAuditLogMapper auditLogMapper; |
||||
|
|
||||
|
@Autowired |
||||
|
private StringRedisTemplate stringRedisTemplate; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeWechatSecurityService wechatSecurityService; |
||||
|
|
||||
|
@Override |
||||
|
public IeAuditResult audit(String bizType, Long bizId, Long userId, String content) { |
||||
|
return audit(bizType, bizId, userId, null, content); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public IeAuditResult audit(String bizType, Long bizId, Long userId, String openid, String content) { |
||||
|
IeAuditResult result = new IeAuditResult(); |
||||
|
result.setRawContent(content); |
||||
|
result.setFilteredContent(content); |
||||
|
result.setAuditStatus(IeConstants.AUDIT_PASS); |
||||
|
result.setRiskLevel(IeConstants.RISK_NONE); |
||||
|
result.setBlocked(false); |
||||
|
if (CharSequenceUtil.isBlank(content)) { |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
SensitiveWordBs wordBs = getSensitiveWordBs(); |
||||
|
List<String> hitWords = wordBs.findAll(content); |
||||
|
String filtered = CollectionUtil.isEmpty(hitWords) ? content : wordBs.replace(content); |
||||
|
int maxLevel = 0; |
||||
|
Map<String, IeSensitiveWord> wordMap = cachedWordMap; |
||||
|
if (CollectionUtil.isNotEmpty(hitWords)) { |
||||
|
for (String hitWord : hitWords) { |
||||
|
if (CharSequenceUtil.isBlank(hitWord)) { |
||||
|
continue; |
||||
|
} |
||||
|
IeSensitiveWord word = wordMap.get(hitWord.toLowerCase()); |
||||
|
Integer level = word == null || word.getLevel() == null ? 1 : word.getLevel(); |
||||
|
maxLevel = Math.max(maxLevel, level); |
||||
|
result.getHitWords().add(hitWord); |
||||
|
result.getHitCategories().add(word == null ? "builtin" : word.getCategory()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
result.setFilteredContent(filtered); |
||||
|
if (maxLevel >= 3) { |
||||
|
result.setAuditStatus(IeConstants.AUDIT_BLOCK); |
||||
|
result.setRiskLevel(IeConstants.RISK_HIGH); |
||||
|
result.setBlocked(true); |
||||
|
} else if (maxLevel == 2) { |
||||
|
result.setAuditStatus(IeConstants.AUDIT_BLOCK); |
||||
|
result.setRiskLevel(IeConstants.RISK_MEDIUM); |
||||
|
result.setBlocked(true); |
||||
|
} else if (maxLevel == 1) { |
||||
|
result.setAuditStatus(IeConstants.AUDIT_REPLACE); |
||||
|
result.setRiskLevel(IeConstants.RISK_LOW); |
||||
|
} |
||||
|
|
||||
|
if (!Boolean.TRUE.equals(result.getBlocked())) { |
||||
|
IeWechatMsgSecResult wxResult = wechatSecurityService.msgSecCheck(openid, content); |
||||
|
if (wxResult.isChecked() && !wxResult.isPass()) { |
||||
|
result.setAuditStatus(IeConstants.AUDIT_BLOCK); |
||||
|
result.setRiskLevel(IeConstants.RISK_HIGH); |
||||
|
result.setBlocked(true); |
||||
|
result.getHitWords().add("wechat:" + wxResult.getSuggest()); |
||||
|
result.getHitCategories().add(wxResult.getLabel() == null ? "wechat" : wxResult.getLabel()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (CollectionUtil.isNotEmpty(result.getHitWords())) { |
||||
|
IeContentAuditLog log = new IeContentAuditLog(); |
||||
|
log.setBizType(bizType); |
||||
|
log.setBizId(bizId); |
||||
|
log.setUserId(userId); |
||||
|
log.setRawContent(content); |
||||
|
log.setFilteredContent(filtered); |
||||
|
log.setAuditStatus(result.getAuditStatus()); |
||||
|
log.setRiskLevel(result.getRiskLevel()); |
||||
|
log.setHitWords(String.join(",", result.getHitWords())); |
||||
|
log.setHitCategories(result.getHitCategories().stream().distinct().collect(Collectors.joining(","))); |
||||
|
log.setCreateTime(new Date()); |
||||
|
auditLogMapper.insert(log); |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
private SensitiveWordBs getSensitiveWordBs() { |
||||
|
long now = System.currentTimeMillis(); |
||||
|
if (cachedWordBs != null && now < cachedExpireAt) { |
||||
|
return cachedWordBs; |
||||
|
} |
||||
|
synchronized (this) { |
||||
|
if (cachedWordBs != null && System.currentTimeMillis() < cachedExpireAt) { |
||||
|
return cachedWordBs; |
||||
|
} |
||||
|
List<IeSensitiveWord> words = loadSensitiveWords(); |
||||
|
cachedWordMap = words.stream() |
||||
|
.collect(Collectors.toMap(item -> item.getWord().toLowerCase(), item -> item, (a, b) -> a, HashMap::new)); |
||||
|
cachedWordBs = buildSensitiveWordBs(words); |
||||
|
cachedExpireAt = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5); |
||||
|
return cachedWordBs; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private SensitiveWordBs buildSensitiveWordBs(List<IeSensitiveWord> words) { |
||||
|
List<String> denyWords = words.stream() |
||||
|
.map(IeSensitiveWord::getWord) |
||||
|
.filter(CharSequenceUtil::isNotBlank) |
||||
|
.collect(Collectors.toList()); |
||||
|
return SensitiveWordBs.newInstance() |
||||
|
.wordDeny(WordDenys.chains(WordDenys.defaults(), new IWordDeny() { |
||||
|
@Override |
||||
|
public List<String> deny() { |
||||
|
return denyWords; |
||||
|
} |
||||
|
})) |
||||
|
.ignoreCase(true) |
||||
|
.ignoreWidth(true) |
||||
|
.ignoreNumStyle(true) |
||||
|
.ignoreChineseStyle(true) |
||||
|
.ignoreEnglishStyle(true) |
||||
|
.ignoreRepeat(true) |
||||
|
.wordFailFast(false) |
||||
|
.enableEmailCheck(true) |
||||
|
.enableUrlCheck(true) |
||||
|
.enableNumCheck(true) |
||||
|
.init(); |
||||
|
} |
||||
|
|
||||
|
private List<IeSensitiveWord> loadSensitiveWords() { |
||||
|
String cache = stringRedisTemplate.opsForValue().get(IeRedisKey.SENSITIVE_WORDS); |
||||
|
if (CharSequenceUtil.isNotBlank(cache)) { |
||||
|
return JSONUtil.toList(cache, IeSensitiveWord.class); |
||||
|
} |
||||
|
List<IeSensitiveWord> words = sensitiveWordMapper.selectList(new LambdaQueryWrapper<IeSensitiveWord>() |
||||
|
.eq(IeSensitiveWord::getEnabled, 1)); |
||||
|
words = words.stream() |
||||
|
.filter(item -> item != null && CharSequenceUtil.isNotBlank(item.getWord())) |
||||
|
.sorted((a, b) -> Integer.compare(b.getWord().length(), a.getWord().length())) |
||||
|
.collect(Collectors.toList()); |
||||
|
stringRedisTemplate.opsForValue().set(IeRedisKey.SENSITIVE_WORDS, JSONUtil.toJsonStr(words), 5, TimeUnit.MINUTES); |
||||
|
return words; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,112 @@ |
|||||
|
package cc.hiver.mall.ie.service.impl; |
||||
|
|
||||
|
import cc.hiver.mall.ie.service.IeWechatSecurityService; |
||||
|
import cc.hiver.mall.ie.vo.IeWechatMsgSecResult; |
||||
|
import cc.hiver.mall.utils.WechatPayConfig; |
||||
|
import cn.hutool.core.text.CharSequenceUtil; |
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.data.redis.core.StringRedisTemplate; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.web.client.RestTemplate; |
||||
|
|
||||
|
import java.util.HashMap; |
||||
|
import java.util.Map; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Service |
||||
|
public class IeWechatSecurityServiceImpl implements IeWechatSecurityService { |
||||
|
|
||||
|
private static final String ACCESS_TOKEN_KEY = "ie:wechat:access_token"; |
||||
|
private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}"; |
||||
|
private static final String MSG_SEC_CHECK_URL = "https://api.weixin.qq.com/wxa/msg_sec_check?access_token={accessToken}"; |
||||
|
|
||||
|
@Autowired |
||||
|
private StringRedisTemplate stringRedisTemplate; |
||||
|
|
||||
|
@Autowired |
||||
|
private WechatPayConfig wechatPayConfig; |
||||
|
|
||||
|
private final RestTemplate restTemplate = new RestTemplate(); |
||||
|
private final ObjectMapper objectMapper = new ObjectMapper(); |
||||
|
|
||||
|
@Override |
||||
|
public IeWechatMsgSecResult msgSecCheck(String openid, String content) { |
||||
|
IeWechatMsgSecResult result = new IeWechatMsgSecResult(); |
||||
|
result.setPass(true); |
||||
|
result.setChecked(false); |
||||
|
if (CharSequenceUtil.isBlank(openid) || CharSequenceUtil.isBlank(content)) { |
||||
|
return result; |
||||
|
} |
||||
|
String accessToken = getAccessToken(); |
||||
|
if (CharSequenceUtil.isBlank(accessToken)) { |
||||
|
log.warn("微信内容安全检查跳过,access_token为空"); |
||||
|
return result; |
||||
|
} |
||||
|
try { |
||||
|
Map<String, Object> body = new HashMap<>(); |
||||
|
body.put("content", content); |
||||
|
body.put("version", 2); |
||||
|
body.put("scene", 4); |
||||
|
body.put("openid", openid); |
||||
|
ResponseEntity<String> response = restTemplate.postForEntity(MSG_SEC_CHECK_URL, body, String.class, accessToken); |
||||
|
JsonNode root = objectMapper.readTree(response.getBody()); |
||||
|
int errcode = root.path("errcode").asInt(-1); |
||||
|
result.setChecked(true); |
||||
|
result.setErrcode(errcode); |
||||
|
result.setErrmsg(root.path("errmsg").asText()); |
||||
|
if (errcode != 0) { |
||||
|
log.warn("微信内容安全检查失败 errcode={}, body={}", errcode, response.getBody()); |
||||
|
return result; |
||||
|
} |
||||
|
JsonNode detail = root.path("detail"); |
||||
|
if (detail.isArray() && detail.size() > 0) { |
||||
|
JsonNode first = detail.get(0); |
||||
|
result.setStrategy(first.path("strategy").asInt(0)); |
||||
|
result.setErrcode(first.path("errcode").asInt(0)); |
||||
|
result.setSuggest(first.path("suggest").asText(null)); |
||||
|
result.setLabel(first.path("label").asText(null)); |
||||
|
} |
||||
|
if (CharSequenceUtil.isBlank(result.getSuggest())) { |
||||
|
result.setSuggest(root.path("result").path("suggest").asText("pass")); |
||||
|
result.setLabel(root.path("result").path("label").asText(null)); |
||||
|
} |
||||
|
result.setPass("pass".equalsIgnoreCase(result.getSuggest())); |
||||
|
return result; |
||||
|
} catch (Exception e) { |
||||
|
log.warn("微信内容安全检查异常,降级为本地审核结果", e); |
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String getAccessToken() { |
||||
|
String cached = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_KEY); |
||||
|
if (CharSequenceUtil.isNotBlank(cached)) { |
||||
|
return cached; |
||||
|
} |
||||
|
String appId = wechatPayConfig.getAppId(); |
||||
|
String appSecret = wechatPayConfig.getAppSecret(); |
||||
|
if (CharSequenceUtil.isBlank(appId) || CharSequenceUtil.isBlank(appSecret)) { |
||||
|
log.warn("微信小程序 appId/appSecret 未配置,跳过 msgSecCheck"); |
||||
|
return null; |
||||
|
} |
||||
|
try { |
||||
|
ResponseEntity<String> response = restTemplate.getForEntity(ACCESS_TOKEN_URL, String.class, appId, appSecret); |
||||
|
JsonNode root = objectMapper.readTree(response.getBody()); |
||||
|
String accessToken = root.path("access_token").asText(null); |
||||
|
int expiresIn = root.path("expires_in").asInt(7200); |
||||
|
if (CharSequenceUtil.isNotBlank(accessToken)) { |
||||
|
stringRedisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, accessToken, Math.max(expiresIn - 300, 300), TimeUnit.SECONDS); |
||||
|
return accessToken; |
||||
|
} |
||||
|
log.warn("微信 access_token 获取失败: {}", response.getBody()); |
||||
|
} catch (Exception e) { |
||||
|
log.warn("微信 access_token 获取异常", e); |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
package cc.hiver.mall.ie.vo; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
public class IeAuditResult { |
||||
|
private String rawContent; |
||||
|
private String filteredContent; |
||||
|
private Integer auditStatus; |
||||
|
private Integer riskLevel; |
||||
|
private Boolean blocked; |
||||
|
private List<String> hitWords = new ArrayList<>(); |
||||
|
private List<String> hitCategories = new ArrayList<>(); |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
package cc.hiver.mall.ie.vo; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class IeChatEvent { |
||||
|
private String eventType; |
||||
|
private Long roomId; |
||||
|
private Long messageId; |
||||
|
private Long userId; |
||||
|
private Long targetUserId; |
||||
|
private Integer riskLevel; |
||||
|
private String payload; |
||||
|
private Long timestamp = System.currentTimeMillis(); |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
package cc.hiver.mall.ie.vo; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
public class IeHomeVO { |
||||
|
private Long onlineCount; |
||||
|
private Long waitingCount; |
||||
|
private Integer dailyQuota; |
||||
|
private Integer usedQuota; |
||||
|
private String currentMode; |
||||
|
private String currentMood; |
||||
|
private String targetModePreference; |
||||
|
private String targetGenderPreference; |
||||
|
private Integer profileCompleted; |
||||
|
private IeUserProfileVO profile; |
||||
|
private List<String> hotStatuses; |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
package cc.hiver.mall.ie.vo; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class IeMatchVO { |
||||
|
private Long matchId; |
||||
|
private String matchNo; |
||||
|
private Long targetUserId; |
||||
|
private String anonymousName; |
||||
|
private String avatarText; |
||||
|
private String avatarUrl; |
||||
|
private String mode; |
||||
|
private String mood; |
||||
|
private String stateText; |
||||
|
private String quoteText; |
||||
|
private Long roomId; |
||||
|
private String roomNo; |
||||
|
private Integer status; |
||||
|
private String failReason; |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
package cc.hiver.mall.ie.vo; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class IeMessageAckVO { |
||||
|
private String clientMsgId; |
||||
|
private Long messageId; |
||||
|
private Long roomId; |
||||
|
private Long senderId; |
||||
|
private Long receiverId; |
||||
|
private Integer messageType; |
||||
|
private Integer auditStatus; |
||||
|
private Integer riskLevel; |
||||
|
private Integer isBlocked; |
||||
|
private String content; |
||||
|
private Integer mediaDuration; |
||||
|
private Long mediaSize; |
||||
|
private String mediaFormat; |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
package cc.hiver.mall.ie.vo; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
public class IeUserProfileVO { |
||||
|
private Long userId; |
||||
|
private String anonymousName; |
||||
|
private String avatarText; |
||||
|
private String avatarUrl; |
||||
|
private String gender; |
||||
|
private String intro; |
||||
|
private List<String> interestTags; |
||||
|
private String currentMode; |
||||
|
private String recentPreference; |
||||
|
private String targetModePreference; |
||||
|
private String targetGenderPreference; |
||||
|
private Integer defaultRoomMinutes; |
||||
|
private Integer dailyQuota; |
||||
|
private Integer usedQuota; |
||||
|
private Integer profileCompleted; |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
package cc.hiver.mall.ie.vo; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class IeWechatMsgSecResult { |
||||
|
private boolean pass; |
||||
|
private boolean checked; |
||||
|
private Integer errcode; |
||||
|
private String errmsg; |
||||
|
private String suggest; |
||||
|
private String label; |
||||
|
private Integer strategy; |
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
package cc.hiver.mall.ie.websocket; |
||||
|
|
||||
|
import cc.hiver.core.common.utils.SecurityUtil; |
||||
|
import cc.hiver.core.entity.User; |
||||
|
import cc.hiver.mall.ie.dto.IePresenceDTO; |
||||
|
import cc.hiver.mall.ie.dto.IeRoomMessageDTO; |
||||
|
import cc.hiver.mall.ie.service.IeChatService; |
||||
|
import cc.hiver.mall.ie.service.IeRedisService; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.messaging.handler.annotation.MessageMapping; |
||||
|
import org.springframework.messaging.simp.SimpMessageHeaderAccessor; |
||||
|
import org.springframework.messaging.simp.SimpMessagingTemplate; |
||||
|
import org.springframework.stereotype.Controller; |
||||
|
|
||||
|
import java.security.Principal; |
||||
|
|
||||
|
@Controller |
||||
|
public class IeWebSocketController { |
||||
|
|
||||
|
@Autowired |
||||
|
private SecurityUtil securityUtil; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeChatService chatService; |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRedisService redisService; |
||||
|
|
||||
|
@Autowired |
||||
|
private SimpMessagingTemplate messagingTemplate; |
||||
|
|
||||
|
/** |
||||
|
* 连接后首个动作:登记 WebSocket 连接并补发离线消息。 |
||||
|
* 客户端发送 /app/ie/connect,订阅 /user/queue/ie/* |
||||
|
*/ |
||||
|
@MessageMapping("/ie/connect") |
||||
|
public void connect(SimpMessageHeaderAccessor accessor, Principal principal) { |
||||
|
Long userId = currentUserId(principal); |
||||
|
redisService.refreshOnline(userId); |
||||
|
redisService.cacheWebSocketConnection(userId, accessor.getSessionId()); |
||||
|
messagingTemplate.convertAndSendToUser(String.valueOf(userId), "/queue/ie/offline", |
||||
|
redisService.popOfflineMessages(userId, 50)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 心跳:客户端每 20-30 秒发送一次,维持在线用户缓存和 WebSocket 连接缓存。 |
||||
|
*/ |
||||
|
@MessageMapping("/ie/heartbeat") |
||||
|
public void heartbeat(SimpMessageHeaderAccessor accessor, Principal principal) { |
||||
|
Long userId = currentUserId(principal); |
||||
|
chatService.heartbeat(userId); |
||||
|
redisService.cacheWebSocketConnection(userId, accessor.getSessionId()); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 聊天消息投递:后端强制敏感词过滤,再进行 ACK 和对端投递。 |
||||
|
*/ |
||||
|
@MessageMapping("/ie/message") |
||||
|
public void message(IeRoomMessageDTO dto, Principal principal) { |
||||
|
Long userId = currentUserId(principal); |
||||
|
chatService.sendMessage(userId, dto); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 轻互动/输入状态/删除提示等事件。 |
||||
|
*/ |
||||
|
@MessageMapping("/ie/presence") |
||||
|
public void presence(IePresenceDTO dto, Principal principal) { |
||||
|
chatService.sendPresence(currentUserId(principal), dto); |
||||
|
} |
||||
|
|
||||
|
private Long currentUserId(Principal principal) { |
||||
|
if (principal != null && principal.getName() != null) { |
||||
|
try { |
||||
|
return Long.valueOf(principal.getName()); |
||||
|
} catch (NumberFormatException ignored) { |
||||
|
// Fall back to SecurityUtil, because existing project principal may be username.
|
||||
|
} |
||||
|
} |
||||
|
User user = securityUtil.getCurrUser(); |
||||
|
return Long.valueOf(user.getId()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
package cc.hiver.mall.ie.websocket; |
||||
|
|
||||
|
import cc.hiver.mall.ie.constant.IeChatEventType; |
||||
|
import cc.hiver.mall.ie.service.IeRedisService; |
||||
|
import cc.hiver.mall.ie.vo.IeChatEvent; |
||||
|
import cn.hutool.json.JSONUtil; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.context.event.EventListener; |
||||
|
import org.springframework.messaging.simp.stomp.StompHeaderAccessor; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.springframework.web.socket.messaging.SessionDisconnectEvent; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Component |
||||
|
public class IeWebSocketEventListener { |
||||
|
|
||||
|
@Autowired |
||||
|
private IeRedisService redisService; |
||||
|
|
||||
|
@EventListener |
||||
|
public void handleDisconnect(SessionDisconnectEvent event) { |
||||
|
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); |
||||
|
String sessionId = accessor.getSessionId(); |
||||
|
Long userId = redisService.userIdBySession(sessionId); |
||||
|
if (userId == null) { |
||||
|
return; |
||||
|
} |
||||
|
redisService.removeWebSocketConnection(userId); |
||||
|
IeChatEvent chatEvent = new IeChatEvent(); |
||||
|
chatEvent.setEventType(IeChatEventType.USER_DISCONNECTED); |
||||
|
chatEvent.setUserId(userId); |
||||
|
chatEvent.setPayload(sessionId); |
||||
|
redisService.publishChatEvent(JSONUtil.toJsonStr(chatEvent)); |
||||
|
log.info("i/e websocket disconnected userId={}, sessionId={}", userId, sessionId); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
ALTER TABLE ie_room_message |
||||
|
ADD COLUMN media_duration INT NULL COMMENT '媒体时长,秒', |
||||
|
ADD COLUMN media_size BIGINT NULL COMMENT '媒体大小,字节', |
||||
|
ADD COLUMN media_format VARCHAR(20) NULL COMMENT '媒体格式'; |
||||
@ -0,0 +1,124 @@ |
|||||
|
-- i/e 随机陪伴敏感词初始化种子。 |
||||
|
-- 说明: |
||||
|
-- 1. 这是一份上线前基础词库,不替代第三方内容安全服务和人工复核。 |
||||
|
-- 2. level=1 替换放行;level=2 拦截;level=3 高危拦截并建议人工复核。 |
||||
|
-- 3. houbb/sensitive-word 内置词库已在代码中启用;本表维护业务自定义词、社交风控词和补充词。 |
||||
|
-- 4. 修改本表后建议删除 Redis key: ie:sensitive:words,让服务尽快重建 DFA。 |
||||
|
|
||||
|
INSERT INTO ie_sensitive_word (word, category, level, replacement, enabled) |
||||
|
VALUES |
||||
|
-- 色情低俗:高危明确词直接拦截,擦边词替换或拦截 |
||||
|
('约炮', 'porn', 3, '**', 1), |
||||
|
('做爱', 'porn', 3, '**', 1), |
||||
|
('鸡巴', 'porn', 3, '**', 1), |
||||
|
('操逼', 'porn', 3, '**', 1), |
||||
|
('裸聊', 'porn', 3, '**', 1), |
||||
|
('成人视频', 'porn', 3, '**', 1), |
||||
|
('黄色网站', 'porn', 3, '**', 1), |
||||
|
('色情网', 'porn', 3, '**', 1), |
||||
|
('援交', 'porn', 3, '**', 1), |
||||
|
('卖淫', 'porn', 3, '**', 1), |
||||
|
('嫖娼', 'porn', 3, '**', 1), |
||||
|
('包夜', 'porn', 3, '**', 1), |
||||
|
('上床', 'porn', 2, '**', 1), |
||||
|
('开房', 'porn', 2, '**', 1), |
||||
|
('口交', 'porn', 3, '**', 1), |
||||
|
('性交', 'porn', 3, '**', 1), |
||||
|
('自慰', 'porn', 2, '**', 1), |
||||
|
('露点', 'porn', 2, '**', 1), |
||||
|
('裸照', 'porn', 3, '**', 1), |
||||
|
('私密照', 'porn', 2, '**', 1), |
||||
|
('看片', 'porn', 2, '**', 1), |
||||
|
('AV资源', 'porn', 3, '**', 1), |
||||
|
('成人资源', 'porn', 3, '**', 1), |
||||
|
|
||||
|
-- 侮辱辱骂:轻度替换,强侮辱拦截 |
||||
|
('傻逼', 'abuse', 2, '**', 1), |
||||
|
('煞笔', 'abuse', 2, '**', 1), |
||||
|
('蠢货', 'abuse', 1, '**', 1), |
||||
|
('废物', 'abuse', 1, '**', 1), |
||||
|
('垃圾人', 'abuse', 1, '**', 1), |
||||
|
('你妈死了', 'abuse', 3, '**', 1), |
||||
|
('死全家', 'abuse', 3, '**', 1), |
||||
|
('滚蛋', 'abuse', 1, '**', 1), |
||||
|
('脑残', 'abuse', 1, '**', 1), |
||||
|
('贱人', 'abuse', 2, '**', 1), |
||||
|
('婊子', 'abuse', 2, '**', 1), |
||||
|
('狗东西', 'abuse', 2, '**', 1), |
||||
|
('畜生', 'abuse', 2, '**', 1), |
||||
|
('去死吧', 'abuse', 3, '**', 1), |
||||
|
|
||||
|
-- 暴力威胁/自伤风险:高危拦截并留审核日志 |
||||
|
('杀了你', 'violence', 3, '**', 1), |
||||
|
('弄死你', 'violence', 3, '**', 1), |
||||
|
('打死你', 'violence', 3, '**', 1), |
||||
|
('砍人', 'violence', 3, '**', 1), |
||||
|
('带刀', 'violence', 2, '**', 1), |
||||
|
('炸学校', 'violence', 3, '**', 1), |
||||
|
('自杀', 'self_harm', 3, '**', 1), |
||||
|
('想死', 'self_harm', 3, '**', 1), |
||||
|
('割腕', 'self_harm', 3, '**', 1), |
||||
|
('跳楼', 'self_harm', 3, '**', 1), |
||||
|
('吃药自杀', 'self_harm', 3, '**', 1), |
||||
|
|
||||
|
-- 政治违法风险:只放明确高风险组织/行动/违法表达,减少正常讨论误伤 |
||||
|
('反动组织', 'politics', 3, '**', 1), |
||||
|
('共产党', 'politics', 3, '**', 1), |
||||
|
('颠覆国家', 'politics', 3, '**', 1), |
||||
|
('分裂国家', 'politics', 3, '**', 1), |
||||
|
('煽动游行', 'politics', 3, '**', 1), |
||||
|
('非法集会', 'politics', 3, '**', 1), |
||||
|
('暴恐', 'politics', 3, '**', 1), |
||||
|
('恐怖组织', 'politics', 3, '**', 1), |
||||
|
('极端组织', 'politics', 3, '**', 1), |
||||
|
|
||||
|
-- 广告/导流/联系方式:社交产品重点防外流 |
||||
|
('加微信', 'contact', 2, '**', 1), |
||||
|
('加我微信', 'contact', 2, '**', 1), |
||||
|
('微信号', 'contact', 2, '**', 1), |
||||
|
('vx', 'contact', 2, '**', 1), |
||||
|
('v我', 'contact', 2, '**', 1), |
||||
|
('QQ号', 'contact', 2, '**', 1), |
||||
|
('加QQ', 'contact', 2, '**', 1), |
||||
|
('手机号', 'contact', 2, '**', 1), |
||||
|
('私加', 'contact', 2, '**', 1), |
||||
|
('私聊加', 'contact', 2, '**', 1), |
||||
|
('进群', 'ad', 2, '**', 1), |
||||
|
('刷单', 'ad', 3, '**', 1), |
||||
|
('兼职日结', 'ad', 2, '**', 1), |
||||
|
('网赚', 'ad', 2, '**', 1), |
||||
|
('返利群', 'ad', 2, '**', 1), |
||||
|
('贷款', 'ad', 2, '**', 1), |
||||
|
('套现', 'ad', 3, '**', 1), |
||||
|
('博彩', 'ad', 3, '**', 1), |
||||
|
('赌博', 'ad', 3, '**', 1), |
||||
|
('下注', 'ad', 3, '**', 1), |
||||
|
('开盘', 'ad', 2, '**', 1), |
||||
|
|
||||
|
-- 诈骗违法 |
||||
|
('卖号', 'fraud', 2, '**', 1), |
||||
|
('买号', 'fraud', 2, '**', 1), |
||||
|
('盗号', 'fraud', 3, '**', 1), |
||||
|
('洗钱', 'fraud', 3, '**', 1), |
||||
|
('跑分', 'fraud', 3, '**', 1), |
||||
|
('代实名', 'fraud', 3, '**', 1), |
||||
|
('出售银行卡', 'fraud', 3, '**', 1), |
||||
|
('收银行卡', 'fraud', 3, '**', 1), |
||||
|
('身份证代办', 'fraud', 3, '**', 1), |
||||
|
|
||||
|
-- 校园安全/隐私保护 |
||||
|
('发定位', 'privacy', 2, '**', 1), |
||||
|
('发照片', 'privacy', 1, '**', 1), |
||||
|
('要照片', 'privacy', 1, '**', 1), |
||||
|
('真实姓名', 'privacy', 1, '**', 1), |
||||
|
('宿舍号', 'privacy', 2, '**', 1), |
||||
|
('寝室号', 'privacy', 2, '**', 1), |
||||
|
('线下见面', 'privacy', 2, '**', 1), |
||||
|
('来我宿舍', 'privacy', 3, '**', 1), |
||||
|
('去你宿舍', 'privacy', 3, '**', 1) |
||||
|
ON DUPLICATE KEY UPDATE |
||||
|
category = VALUES(category), |
||||
|
level = VALUES(level), |
||||
|
replacement = VALUES(replacement), |
||||
|
enabled = VALUES(enabled), |
||||
|
update_time = CURRENT_TIMESTAMP; |
||||
@ -0,0 +1,6 @@ |
|||||
|
ALTER TABLE ie_user_profile |
||||
|
ADD COLUMN gender VARCHAR(20) NULL COMMENT '性别:male/female/unknown', |
||||
|
ADD COLUMN target_gender_preference VARCHAR(20) NULL COMMENT '默认想匹配性别:male/female/any'; |
||||
|
|
||||
|
ALTER TABLE ie_record |
||||
|
ADD COLUMN last_read_time DATETIME NULL COMMENT '用户最后进入该房间阅读消息时间'; |
||||
@ -0,0 +1,6 @@ |
|||||
|
ALTER TABLE ie_user_profile |
||||
|
ADD COLUMN avatar_url VARCHAR(500) NULL COMMENT '头像图片地址', |
||||
|
ADD COLUMN intro VARCHAR(500) NULL COMMENT '个人介绍', |
||||
|
ADD COLUMN interest_tags VARCHAR(1000) NULL COMMENT '兴趣/情绪标签,JSON数组', |
||||
|
ADD COLUMN target_mode_preference VARCHAR(20) NULL COMMENT '默认想匹配对象类型:i/e/any', |
||||
|
ADD COLUMN profile_completed TINYINT NULL DEFAULT 0 COMMENT '是否完成i/e首次资料'; |
||||
Loading…
Reference in new issue