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