You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
113 lines
5.3 KiB
113 lines
5.3 KiB
package com.mydreamjob.profileimage;
|
|
|
|
import com.mydreamjob.auth.*;
|
|
import com.mydreamjob.company.*;
|
|
import com.mydreamjob.common.exception.*;
|
|
import com.mydreamjob.messaging.*;
|
|
import com.mydreamjob.security.*;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.web.multipart.MultipartFile;
|
|
import java.util.*;
|
|
|
|
@Service @RequiredArgsConstructor
|
|
public class ProfileImageService {
|
|
private final ProfileImageRepository images;
|
|
private final ProfileImageValidator validator;
|
|
private final UserRepository users;
|
|
private final CompanyRepository companies;
|
|
private final UserRoleGuard roles;
|
|
private final CurrentUserAccessor currentUser;
|
|
private final ConversationMemberRepository members;
|
|
private final ConversationRepository conversations;
|
|
private final MessagingService messaging;
|
|
|
|
@Value("#{environment.getProperty('server.servlet.context-path', '/api/v1')}")
|
|
private String contextPath;
|
|
|
|
public record ImageView(String url, String version, String contentType, long size) {}
|
|
|
|
@Transactional
|
|
public ImageView upload(Long actor, boolean logo, MultipartFile file) {
|
|
User user = lockActor(actor, logo);
|
|
Company company = logo ? lockCompany(actor) : null;
|
|
byte[] content = validator.validate(file);
|
|
// 先 flush 删除以释放唯一键;事务失败会完整恢复旧二进制与引用。
|
|
String kind = logo ? "LOGO" : "AVATAR";
|
|
images.findByOwnerUserIdAndKind(actor, kind).ifPresent(images::delete);
|
|
images.flush();
|
|
ProfileImage image = new ProfileImage();
|
|
image.setId(UUID.randomUUID().toString());
|
|
image.setOwnerUserId(actor);
|
|
image.setCompanyId(company == null ? null : company.getId());
|
|
image.setKind(kind);
|
|
image.setContent(content);
|
|
images.saveAndFlush(image);
|
|
if (logo) company.setLogoUrl(url(image.getId()));
|
|
else user.setAvatarUrl(url(image.getId()));
|
|
return new ImageView(url(image.getId()), image.getId(), "image/jpeg", content.length);
|
|
}
|
|
|
|
@Transactional
|
|
public void delete(Long actor, boolean logo) {
|
|
User user = lockActor(actor, logo);
|
|
Company company = logo ? lockCompany(actor) : null;
|
|
images.findByOwnerUserIdAndKind(actor, logo ? "LOGO" : "AVATAR").ifPresent(images::delete);
|
|
if (logo) company.setLogoUrl(null);
|
|
else user.setAvatarUrl(null);
|
|
}
|
|
|
|
@Transactional(readOnly = true)
|
|
public byte[] read(String id) {
|
|
if (id == null || !id.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))
|
|
throw notFound();
|
|
ProfileImage image = images.findById(id).orElseThrow(ProfileImageService::notFound);
|
|
User owner = users.findById(image.getOwnerUserId()).orElseThrow(ProfileImageService::notFound);
|
|
if (!Boolean.TRUE.equals(owner.getActive())) throw notFound();
|
|
if ("LOGO".equals(image.getKind())) {
|
|
Company company = companies.findById(image.getCompanyId()).orElseThrow(ProfileImageService::notFound);
|
|
if (!Objects.equals(company.getOwnerUserId(), owner.getId()) || !url(id).equals(company.getLogoUrl()))
|
|
throw notFound();
|
|
} else {
|
|
Long actor = currentUser.requireUserId();
|
|
roles.requireAny(actor, User.Role.STUDENT, User.Role.COMPANY, User.Role.ADMIN);
|
|
if (!url(id).equals(owner.getAvatarUrl())) throw notFound();
|
|
if (!actor.equals(owner.getId()) && !canReadPeer(actor, owner.getId()))
|
|
throw new BusinessException(ErrorCode.FORBIDDEN);
|
|
}
|
|
return image.getContent().clone();
|
|
}
|
|
|
|
private boolean canReadPeer(Long actor, Long owner) {
|
|
for (ConversationMember member : members.findByUserId(actor)) {
|
|
Long id = member.getConversationId();
|
|
var conversation = conversations.findById(id).orElse(null);
|
|
if (conversation == null || !("DIRECT".equals(conversation.getType()) || "TALENT_INVITATION".equals(conversation.getType()))
|
|
|| !members.existsByConversationIdAndUserId(id, owner)) continue;
|
|
try {
|
|
// 非匿名邀约同样展示头像,但不能绕过成员、拉黑、撤回或企业接收门禁。
|
|
messaging.assertProfileImageAccess(id, actor);
|
|
return true;
|
|
} catch (BusinessException denied) {
|
|
// 另一条会话可能仍有合法授权;数据库/系统异常不会在此吞掉。
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private User lockActor(Long actor, boolean logo) {
|
|
User user = users.findByIdForUpdate(actor).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
|
|
if (logo) roles.requireCompany(user);
|
|
else roles.requireAny(user, User.Role.STUDENT, User.Role.COMPANY, User.Role.ADMIN);
|
|
return user;
|
|
}
|
|
|
|
private Company lockCompany(Long actor) {
|
|
return companies.findByOwnerUserIdForUpdate(actor).orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND));
|
|
}
|
|
|
|
private String url(String id) { return contextPath + "/profile-images/" + id; }
|
|
private static BusinessException notFound() { return new BusinessException(ErrorCode.PROFILE_NOT_FOUND, "image not found"); }
|
|
}
|
|
|