feat: 创作者账号体系(后台开号 → 登录即进创作者中心)+ 创作者中心菜单只给创作者

为什么:创作者此前根本无法登录 —— creator.admin_user_id 只有实体字段、没有任何接口会写它,
C 端申请入驻也没做,等于整条账号链路缺入口。这批先把"后台手工建主体 → 审核 → 开号 → 登录落地"打通;
同时按用户口径收窄菜单:只要不是创作者,就不显示创作者中心。

- 手工建主体:POST /admin/creators(编号可传可自动生成 CR+日期+序号)+ 审核页「新增创作者」
- 开通账号:POST /admin/creators/{id}/account —— 仅 approved 可开(否则 200003)、已开通幂等返回
  (created=false、不回明文)、用户名默认 creator.code 小写且冲突报 200001、随机 10 位初始密码一次性返回、
  建 sys_admin_user + 绑 creator.admin_user_id + 授 creator 角色
- 重置密码:POST /admin/creators/{id}/account/reset-password(同样一次性明文,旧密码立即失效)
- 首登强制改密:sys_admin_user 加 must_change_password 列(幂等 ALTER;不新建表、不复用 two_factor 是因为
  这个标记要能直接驱动登录响应与前端拦截),开号/重置置 1,自助改密后清 0
- 菜单收窄:26-30 从超管收回(sys_role_menu delete);另外发现超管的菜单是"全量兜底"、根本不看角色-菜单表,
  光收授权无效,所以加 sys_menu.super_admin_visible,这几条置 0 —— 超管侧边栏不再出现创作者中心
  (代看代码保留,只是没有入口了,见回执)
- 权限点 creator:account(只授超管);creator:list 之外的读接口未变

实测:未审核开号 200003 / 重复开号幂等 / 用户名冲突 200001 / 重置后旧密码失效新密码可登录 /
新账号只能看到自己的作品;创作者登录直接落 /creator-center/workspace 并弹出首登改密。
This commit is contained in:
Vicky committed 2026-10-15 16:00:42 +08:00
1 parent efeb9fcfaf
commit 414d9fd482
15 files changed
+377 -8

No files matched your search

@@ -0,0 +1,149 @@
package com.fruitshorts.creator.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
import com.fruitshorts.creator.vo.CreatorAccountRespVO;
import com.fruitshorts.system.dal.entity.AdminUser;
import com.fruitshorts.system.dal.entity.Role;
import com.fruitshorts.system.dal.mapper.RoleMapper;
import com.fruitshorts.system.service.AdminUserService;
import com.fruitshorts.system.vo.AdminUserSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.security.SecureRandom;
/**
* 创作者账号:后台给审核通过的创作者开一个**独立后台账号**,他登录后直接落创作者中心。
*
* 约定:
* - 只有 `approved` 的创作者能开通(没审核过就发账号等于绕过入驻);
* - **幂等**:已经绑定了 `admin_user_id` 就不重复建号,返回 `created=false` 且不回明文密码,
* 运营要重发就用「重置密码」;
* - 初始密码是**一次性明文**:只在开通 / 重置的响应里出现一次,库里存 BCrypt 哈希,
* 并且置 `must_change_password=1`,使用者首次登录会被要求改密。
*/
@Service
@RequiredArgsConstructor
public class CreatorAccountService {
private static final String ROLE_CODE = "creator";
private static final int PASSWORD_LENGTH = 10;
/** 去掉了 0/O/1/l/I 这类容易转达错的字符 */
private static final String PASSWORD_CHARS =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
private static final SecureRandom RANDOM = new SecureRandom();
private final CreatorMapper creatorMapper;
private final AdminUserService adminUserService;
private final RoleMapper roleMapper;
/** 开通账号:用户名默认取 creator.code 小写,可用参数覆盖。 */
@Transactional(rollbackFor = Exception.class)
public CreatorAccountRespVO open(Long creatorId, String usernameOverride) {
Creator creator = requireCreator(creatorId);
if (!CreatorService.STATUS_APPROVED.equals(creator.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"只有审核通过的创作者才能开通账号,当前状态:"
+ CreatorService.statusText(creator.getStatus()));
}
if (creator.getAdminUserId() != null && creator.getAdminUserId() > 0) {
AdminUser existing = adminUserService.getExisting(creator.getAdminUserId());
return resp(creator, existing, null, false,
"该创作者已经开通账号(" + existing.getUsername() + ");需要新密码请用「重置密码」");
}
String username = StringUtils.hasText(usernameOverride)
? usernameOverride.trim()
: creator.getCode().toLowerCase();
if (adminUserService.getByUsername(username) != null) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "登录账号已被占用:" + username);
}
Role role = roleMapper.selectOne(Wrappers.<Role>lambdaQuery()
.eq(Role::getCode, ROLE_CODE).last("limit 1"));
if (role == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "系统里没有 creator 角色,请先执行 sql/data-phase1.sql");
}
String rawPassword = randomPassword();
AdminUserSaveReqVO req = new AdminUserSaveReqVO();
req.setUsername(username);
req.setPassword(rawPassword);
req.setName(creator.getName());
req.setRoleId(role.getId());
req.setStatus(1);
// 初始密码是运营转达的,首次登录必须改
Long adminUserId = adminUserService.create(req, true);
creator.setAdminUserId(adminUserId);
creatorMapper.updateById(creator);
AdminUser created = adminUserService.getExisting(adminUserId);
return resp(creator, created, rawPassword, true,
"账号已开通;初始密码只显示这一次,请尽快转达并让创作者首登后修改");
}
/** 重置密码:同样一次性明文 + 置首登强制改密。 */
@Transactional(rollbackFor = Exception.class)
public CreatorAccountRespVO resetPassword(Long creatorId) {
Creator creator = requireCreator(creatorId);
if (creator.getAdminUserId() == null || creator.getAdminUserId() <= 0) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该创作者还没有开通账号,请先开通");
}
String rawPassword = randomPassword();
adminUserService.resetPasswordTo(creator.getAdminUserId(), rawPassword, true);
AdminUser user = adminUserService.getExisting(creator.getAdminUserId());
return resp(creator, user, rawPassword, false,
"密码已重置;新密码只显示这一次,旧密码立即失效");
}
private Creator requireCreator(Long creatorId) {
Creator creator = creatorId == null ? null : creatorMapper.selectById(creatorId);
if (creator == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "创作者不存在");
}
return creator;
}
/** 10 位随机密码,保证同时含大写、小写与数字 */
private String randomPassword() {
StringBuilder sb = new StringBuilder(PASSWORD_LENGTH);
sb.append(randomOf("ABCDEFGHJKLMNPQRSTUVWXYZ"));
sb.append(randomOf("abcdefghijkmnopqrstuvwxyz"));
sb.append(randomOf("23456789"));
while (sb.length() < PASSWORD_LENGTH) {
sb.append(randomOf(PASSWORD_CHARS));
}
// 打散固定位置,避免"首字符永远是大写"这种可猜的规律
char[] chars = sb.toString().toCharArray();
for (int i = chars.length - 1; i > 0; i--) {
int j = RANDOM.nextInt(i + 1);
char tmp = chars[i];
chars[i] = chars[j];
chars[j] = tmp;
}
return new String(chars);
}
private char randomOf(String chars) {
return chars.charAt(RANDOM.nextInt(chars.length()));
}
private CreatorAccountRespVO resp(Creator creator, AdminUser user, String rawPassword,
boolean created, String message) {
CreatorAccountRespVO vo = new CreatorAccountRespVO();
vo.setCreatorId(creator.getId());
vo.setAdminUserId(user == null ? creator.getAdminUserId() : user.getId());
vo.setUsername(user == null ? "" : user.getUsername());
vo.setPassword(rawPassword);
vo.setCreated(created);
vo.setMessage(message);
return vo;
}
}
@@ -12,12 +12,15 @@ import com.fruitshorts.creator.dal.mapper.CreatorDocumentMapper;
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
import com.fruitshorts.creator.vo.CreatorAuditReqVO;
import com.fruitshorts.creator.vo.CreatorQueryReqVO;
import com.fruitshorts.creator.vo.CreatorSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Set;
@@ -57,6 +60,46 @@ public class CreatorService {
return creator;
}
/**
* 后台手工录入创作者主体(C 端申请入驻还没做,先用它打通链路)。
* 新录入的主体是 pending,必须走一次入驻审核通过才能开通账号。
*/
@Transactional(rollbackFor = Exception.class)
public Long create(CreatorSaveReqVO req) {
String code = StringUtils.hasText(req.getCode()) ? req.getCode().trim() : generateCode();
Long exists = creatorMapper.selectCount(Wrappers.<Creator>lambdaQuery().eq(Creator::getCode, code));
if (exists != null && exists > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "创作者编号已存在:" + code);
}
Creator creator = new Creator();
creator.setCode(code);
creator.setName(req.getName().trim());
creator.setType(StringUtils.hasText(req.getType()) ? req.getType() : "INDIVIDUAL");
creator.setContactName(req.getContactName() == null ? "" : req.getContactName());
creator.setContactMobile(req.getContactMobile() == null ? "" : req.getContactMobile());
creator.setContactEmail(req.getContactEmail() == null ? "" : req.getContactEmail());
creator.setCountryCode(StringUtils.hasText(req.getCountryCode()) ? req.getCountryCode() : "ID");
creator.setUserId(req.getUserId() == null ? 0L : req.getUserId());
creator.setAdminUserId(0L);
creator.setStatus(STATUS_PENDING);
creator.setAuditNote("");
creator.setAuditBy(0L);
creator.setSettlementCurrency("IDR");
creator.setBankAccount("");
creator.setTaxNo("");
creator.setKycStatus("not_submitted");
creator.setShareRate(java.math.BigDecimal.ZERO);
creatorMapper.insert(creator);
return creator.getId();
}
/** 编号格式沿用短剧那套:CR + 日期 + 3 位序号 */
private String generateCode() {
Long count = creatorMapper.selectCount(null);
return "CR" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))
+ String.format("%03d", (count == null ? 0 : count + 1));
}
/** 资质文件列表:审核时要能看到证件原件。 */
public List<CreatorDocument> documents(Long creatorId) {
getExisting(creatorId);
@@ -0,0 +1,11 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
/** 开通创作者账号:用户名默认取 creator.code 小写,可用这个字段覆盖。 */
@Data
public class CreatorAccountOpenReqVO {
/** 覆盖默认登录名;留空则用 creator.code 的小写形式 */
private String username;
}
@@ -0,0 +1,23 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
/**
* 开通 / 重置密码的结果。
*
* `password` 是**一次性明文**:只在这次响应里出现,库里存的是 BCrypt 哈希,之后谁也拿不回来。
* 幂等命中(账号已存在)时 `created=false`、`password=null`,提示走"重置密码"。
*/
@Data
public class CreatorAccountRespVO {
private Long creatorId;
private Long adminUserId;
private String username;
/** 一次性明文初始密码;幂等命中时为 null */
private String password;
/** true = 这次真的新建了账号 */
private Boolean created;
/** 给运营看的一句话说明 */
private String message;
}
@@ -0,0 +1,27 @@
package com.fruitshorts.creator.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 后台手工录入创作者主体。
*
* C 端 `/app/creator/apply` 还没实现,先用它把"建主体 → 审核 → 开账号"这条链路打通。
*/
@Data
public class CreatorSaveReqVO {
@NotBlank(message = "请填写创作者名称")
private String name;
/** INDIVIDUAL / COMPANY,默认 INDIVIDUAL */
private String type;
/** 编号:不传则自动生成 CR+日期+序号;也是开账号时默认的登录名(小写) */
private String code;
private String contactName;
private String contactMobile;
private String contactEmail;
private String countryCode;
/** 可选:绑定已有的 C 端账号 app_user.id */
private Long userId;
}