From 414d9fd482e494209296a2765ad2ee154b6f8a09 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 16:00:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9B=E4=BD=9C=E8=80=85=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E4=BD=93=E7=B3=BB=EF=BC=88=E5=90=8E=E5=8F=B0=E5=BC=80?= =?UTF-8?q?=E5=8F=B7=20=E2=86=92=20=E7=99=BB=E5=BD=95=E5=8D=B3=E8=BF=9B?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E8=80=85=E4=B8=AD=E5=BF=83=EF=BC=89+=20?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E8=80=85=E4=B8=AD=E5=BF=83=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E5=8F=AA=E7=BB=99=E5=88=9B=E4=BD=9C=E8=80=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:创作者此前根本无法登录 —— 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 并弹出首登改密。 --- .../controller/CreatorAdminController.java | 32 ++++ .../service/CreatorAccountService.java | 149 ++++++++++++++++++ .../creator/service/CreatorService.java | 43 +++++ .../creator/vo/CreatorAccountOpenReqVO.java | 11 ++ .../creator/vo/CreatorAccountRespVO.java | 23 +++ .../creator/vo/CreatorSaveReqVO.java | 27 ++++ .../system/dal/entity/AdminUser.java | 2 + .../fruitshorts/system/dal/entity/Menu.java | 2 + .../system/service/AdminUserService.java | 35 ++++ .../system/service/AuthService.java | 2 + .../system/service/MenuService.java | 6 +- .../fruitshorts/system/vo/LoginRespVO.java | 2 + .../fruitshorts/system/vo/ProfileRespVO.java | 2 + sql/data-phase1.sql | 31 +++- sql/schema-phase1.sql | 18 +++ 15 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorAccountService.java create mode 100644 fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAccountOpenReqVO.java create mode 100644 fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAccountRespVO.java create mode 100644 fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorSaveReqVO.java diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorAdminController.java index aa80fb1..6b9d976 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorAdminController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorAdminController.java @@ -5,14 +5,20 @@ import com.fruitshorts.common.pojo.Result; import com.fruitshorts.creator.dal.entity.Creator; import com.fruitshorts.creator.dal.entity.CreatorDocument; import com.fruitshorts.creator.service.CreatorService; +import com.fruitshorts.creator.service.CreatorAccountService; +import com.fruitshorts.creator.vo.CreatorAccountOpenReqVO; +import com.fruitshorts.creator.vo.CreatorAccountRespVO; +import com.fruitshorts.creator.vo.CreatorSaveReqVO; import com.fruitshorts.creator.vo.CreatorAuditReqVO; import com.fruitshorts.creator.vo.CreatorQueryReqVO; import com.fruitshorts.framework.log.OperationLog; import com.fruitshorts.framework.security.SecurityUtils; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -27,6 +33,7 @@ import java.util.List; public class CreatorAdminController { private final CreatorService creatorService; + private final CreatorAccountService creatorAccountService; @GetMapping("/page") @PreAuthorize("@ss.hasPermission('creator:list')") @@ -53,4 +60,29 @@ public class CreatorAdminController { public Result audit(@PathVariable Long id, @RequestBody CreatorAuditReqVO req) { return Result.ok(creatorService.audit(id, req, SecurityUtils.getUserId())); } + + /** 手工录入创作者主体(C 端申请入驻还没实现,先用它打通链路)。 */ + @PostMapping + @PreAuthorize("@ss.hasPermission('creator:audit')") + @OperationLog(module = "创作者管理", action = "新增创作者") + public Result create(@Valid @RequestBody CreatorSaveReqVO req) { + return Result.ok(creatorService.create(req)); + } + + /** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */ + @PostMapping("/{id}/account") + @PreAuthorize("@ss.hasPermission('creator:account')") + @OperationLog(module = "创作者管理", action = "开通创作者账号") + public Result openAccount(@PathVariable Long id, + @RequestBody(required = false) CreatorAccountOpenReqVO req) { + return Result.ok(creatorAccountService.open(id, req == null ? null : req.getUsername())); + } + + /** 重置创作者账号密码:一次性明文返回,旧密码立即失效。 */ + @PostMapping("/{id}/account/reset-password") + @PreAuthorize("@ss.hasPermission('creator:account')") + @OperationLog(module = "创作者管理", action = "重置创作者密码") + public Result resetAccountPassword(@PathVariable Long id) { + return Result.ok(creatorAccountService.resetPassword(id)); + } } diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorAccountService.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorAccountService.java new file mode 100644 index 0000000..436d98c --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorAccountService.java @@ -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.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; + } +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorService.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorService.java index dd6fd19..6e5ac2d 100644 --- a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorService.java +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorService.java @@ -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.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 documents(Long creatorId) { getExisting(creatorId); diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAccountOpenReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAccountOpenReqVO.java new file mode 100644 index 0000000..6168e73 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAccountOpenReqVO.java @@ -0,0 +1,11 @@ +package com.fruitshorts.creator.vo; + +import lombok.Data; + +/** 开通创作者账号:用户名默认取 creator.code 小写,可用这个字段覆盖。 */ +@Data +public class CreatorAccountOpenReqVO { + + /** 覆盖默认登录名;留空则用 creator.code 的小写形式 */ + private String username; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAccountRespVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAccountRespVO.java new file mode 100644 index 0000000..800ebaf --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorAccountRespVO.java @@ -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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorSaveReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorSaveReqVO.java new file mode 100644 index 0000000..4e2477c --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorSaveReqVO.java @@ -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; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUser.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUser.java index 6ad111b..a4ecad3 100644 --- a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUser.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUser.java @@ -25,6 +25,8 @@ public class AdminUser { private Integer status; private String ipWhitelist; private Integer twoFactor; + /** 1 = 首登强制改密(后台开号 / 重置密码后置位) */ + private Integer mustChangePassword; private LocalDateTime lastLoginAt; private String lastLoginIp; private LocalDateTime createdAt; diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Menu.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Menu.java index 46d6fa2..f2ec809 100644 --- a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Menu.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Menu.java @@ -28,6 +28,8 @@ public class Menu { private Integer affix; private Integer sort; private Integer visible; + /** 0 = 不给超管显示(角色专属菜单;超管菜单是全量兜底,不看 sys_role_menu) */ + private Integer superAdminVisible; private Integer status; private LocalDateTime createdAt; /** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */ diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AdminUserService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AdminUserService.java index d5c5a6d..34232c8 100644 --- a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AdminUserService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AdminUserService.java @@ -73,6 +73,14 @@ public class AdminUserService { @Transactional(rollbackFor = Exception.class) public Long create(AdminUserSaveReqVO req) { + return create(req, false); + } + + /** + * 建后台账号。mustChangePassword=true 用于"后台开号/重置密码"场景: + * 初始密码是转达给使用者的,登录后必须先改密。 + */ + public Long create(AdminUserSaveReqVO req, boolean mustChangePassword) { if (!StringUtils.hasText(req.getUsername())) { throw new ServiceException(ErrorCode.BAD_REQUEST, "请输入登录账号"); } @@ -91,6 +99,7 @@ public class AdminUserService { user.setStatus(req.getStatus() == null ? 1 : req.getStatus()); user.setIpWhitelist(req.getIpWhitelist() == null ? "" : req.getIpWhitelist()); user.setTwoFactor(req.getTwoFactor() == null ? 0 : req.getTwoFactor()); + user.setMustChangePassword(mustChangePassword ? 1 : 0); user.setMobile(""); user.setEmail(""); user.setAvatar(""); @@ -148,15 +157,41 @@ public class AdminUserService { throw new ServiceException(ErrorCode.BAD_REQUEST, "新密码长度不能少于 6 位"); } user.setPassword(passwordEncoder.encode(newPassword)); + user.setMustChangePassword(1); adminUserMapper.updateById(user); } + /** + * 运营重置创作者账号密码:直接置新密码并标记"下次登录必须改密"。 + * 与 resetPassword 的区别是这里接受调用方生成的一次性明文(后台展示后即作废)。 + */ + public void resetPasswordTo(Long id, String rawPassword, boolean mustChangePassword) { + AdminUser user = getExisting(id); + if (!StringUtils.hasText(rawPassword)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "请输入新密码"); + } + user.setPassword(passwordEncoder.encode(rawPassword)); + user.setMustChangePassword(mustChangePassword ? 1 : 0); + adminUserMapper.updateById(user); + } + + /** 改完密码(含首登强制改密)后清掉标记 */ + public void clearMustChangePassword(Long id) { + AdminUser user = getExisting(id); + if (user.getMustChangePassword() != null && user.getMustChangePassword() == 1) { + user.setMustChangePassword(0); + adminUserMapper.updateById(user); + } + } + public void changePassword(Long id, String oldPassword, String newPassword) { AdminUser user = getExisting(id); if (!passwordEncoder.matches(oldPassword, user.getPassword())) { throw new ServiceException(ErrorCode.BAD_REQUEST, "原密码不正确"); } user.setPassword(passwordEncoder.encode(newPassword)); + // 自己改过密码就不再要求改密(首登强制改密也走这条) + user.setMustChangePassword(0); adminUserMapper.updateById(user); } diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuthService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuthService.java index 12c9c89..a906e2e 100644 --- a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuthService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuthService.java @@ -50,6 +50,7 @@ public class AuthService { .roleName(role == null ? null : role.getName()) .roleId(role == null ? null : role.getId()) .permissions(permissions) + .mustChangePassword(user.getMustChangePassword() != null && user.getMustChangePassword() == 1) .build(); } @@ -71,6 +72,7 @@ public class AuthService { .roleName(role == null ? null : role.getName()) .roleId(role == null ? null : role.getId()) .permissions(permissions) + .mustChangePassword(user.getMustChangePassword() != null && user.getMustChangePassword() == 1) .build(); } diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/MenuService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/MenuService.java index b329636..0a9d3f2 100644 --- a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/MenuService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/MenuService.java @@ -31,7 +31,11 @@ public class MenuService { } List all = listEnabled(); if (RoleService.SUPER_ADMIN_CODE.equals(role.getCode())) { - return buildTree(all); + // 超管默认看全量,但 super_admin_visible=0 的"角色专属菜单"(如创作者中心)要排除, + // 否则对超管收窄 sys_role_menu 是无效的 + return buildTree(all.stream() + .filter(menu -> menu.getSuperAdminVisible() == null || menu.getSuperAdminVisible() != 0) + .toList()); } List codes = roleService.listCodesByRoleId(role.getId()); return buildTree(all.stream() diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginRespVO.java index 4a13498..68979a9 100644 --- a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginRespVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginRespVO.java @@ -19,4 +19,6 @@ public class LoginRespVO { private String roleName; private Long roleId; private List permissions; + /** 首登强制改密:前端据此弹出改密对话框 */ + private Boolean mustChangePassword; } diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ProfileRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ProfileRespVO.java index 5868da2..5233f5c 100644 --- a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ProfileRespVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ProfileRespVO.java @@ -21,4 +21,6 @@ public class ProfileRespVO { private String roleName; private Long roleId; private List permissions; + /** 首登强制改密:刷新页面后仍然要能拦住 */ + private Boolean mustChangePassword; } diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index 4420345..cc2fa47 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -190,6 +190,19 @@ INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES ('creator-center:impersonate', '代看创作者(只读)', '创作者中心', 'BUTTON', 155) ON CONFLICT (code) DO NOTHING; +-- 开通 / 重置创作者账号 +INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES + ('creator:account', '开通/重置创作者账号', '创作者管理', 'BUTTON', 136) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM sys_role r +CROSS JOIN sys_permission p +WHERE r.code = 'super_admin' + AND p.code = 'creator:account' +ON CONFLICT DO NOTHING; + INSERT INTO sys_role_permission (role_id, permission_id) SELECT r.id, p.id FROM sys_role r @@ -255,10 +268,14 @@ WHERE r.code = 'creator' AND m.id IN (26, 27, 28, 29, 30) ON CONFLICT DO NOTHING; -INSERT INTO sys_role_menu (role_id, menu_id) -SELECT r.id, m.id -FROM sys_role r -CROSS JOIN sys_menu m -WHERE r.code = 'super_admin' - AND m.id IN (26, 27, 28, 29, 30) -ON CONFLICT DO NOTHING; +-- 创作者中心只归创作者:超管**不再**挂 26-30(用户口径「只要不是创作者,就不要显示创作者中心了」)。 +-- 这里必须显式 delete 而不是只 insert:data.sql 里有"把全部菜单授给超管"的兜底语句, +-- 会给超管补上这几条;本脚本按约定最后执行,delete 放在菜单 insert 之后即可保证结果一致。 +-- 注意:如果之后重新执行 sql/data.sql,需要再跑一次本脚本把这几条清掉。 +DELETE FROM sys_role_menu +WHERE menu_id IN (26, 27, 28, 29, 30) + AND role_id IN (SELECT id FROM sys_role WHERE code = 'super_admin'); + +-- 光收 sys_role_menu 不够:超管的菜单是"全量兜底",不看角色-菜单表。 +-- 这几条置 0,超管的菜单树里就不会再出现创作者中心(连带隐藏的作品编辑路由)。 +UPDATE sys_menu SET super_admin_visible = 0 WHERE id IN (26, 27, 28, 29, 30); diff --git a/sql/schema-phase1.sql b/sql/schema-phase1.sql index aca3725..47c3b71 100644 --- a/sql/schema-phase1.sql +++ b/sql/schema-phase1.sql @@ -913,3 +913,21 @@ CREATE TRIGGER trg_download_task_updated_at BEFORE UPDATE ON download_task ALTER TABLE creator ADD COLUMN IF NOT EXISTS admin_user_id BIGINT NOT NULL DEFAULT 0; CREATE UNIQUE INDEX IF NOT EXISTS uk_creator_admin_user ON creator (admin_user_id) WHERE admin_user_id > 0; COMMENT ON COLUMN creator.admin_user_id IS '绑定的后台账号 sys_admin_user.id,0 表示未绑定(创作者中心据此判定归属)'; + +-- 手工录入的创作者还没有 C 端账号(user_id=0),而原来 uk_creator_user 是整列唯一, +-- 第二条 user_id=0 就会撞唯一键。改成部分唯一索引:只约束真的绑定了 C 端账号的行。 +DROP INDEX IF EXISTS uk_creator_user; +CREATE UNIQUE INDEX IF NOT EXISTS uk_creator_user ON creator (user_id) WHERE user_id > 0; +COMMENT ON INDEX uk_creator_user IS 'C 端账号唯一(user_id=0 表示还没绑,允许多条)'; + +-- 首登强制改密:后台给创作者开的账号,初始密码是运营口头/截图转达的, +-- 需要一个"必须先改密"的标记。放在 sys_admin_user 上(而不是新建表/复用 two_factor), +-- 语义直白且对其它账号默认 0,不影响既有登录流程。 +ALTER TABLE sys_admin_user ADD COLUMN IF NOT EXISTS must_change_password SMALLINT NOT NULL DEFAULT 0; +COMMENT ON COLUMN sys_admin_user.must_change_password IS '1 = 首登强制改密(后台开号 / 重置密码后置位,改完密码清零)'; + +-- 超管的菜单是"全量兜底"(不走 sys_role_menu),但创作者中心这类"某类角色专属"的入口 +-- 需要能对超管关掉,否则收窄 sys_role_menu 是无效的。加一个显式开关: +-- 默认 1(超管照旧看到),置 0 表示"不给超管显示"。 +ALTER TABLE sys_menu ADD COLUMN IF NOT EXISTS super_admin_visible SMALLINT NOT NULL DEFAULT 1; +COMMENT ON COLUMN sys_menu.super_admin_visible IS '0 = 不给超管显示(角色专属菜单用;超管的菜单是全量兜底,不走 sys_role_menu)';