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

@@ -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<Creator> 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<Long> create(@Valid @RequestBody CreatorSaveReqVO req) {
return Result.ok(creatorService.create(req));
}
/** 开通创作者账号:仅审核通过可开;已开通则幂等返回,不重复建号。 */
@PostMapping("/{id}/account")
@PreAuthorize("@ss.hasPermission('creator:account')")
@OperationLog(module = "创作者管理", action = "开通创作者账号")
public Result<CreatorAccountRespVO> 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<CreatorAccountRespVO> resetAccountPassword(@PathVariable Long id) {
return Result.ok(creatorAccountService.resetPassword(id));
}
}
@@ -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;
}
@@ -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;
@@ -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 维护 */
@@ -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);
}
@@ -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();
}
@@ -31,7 +31,11 @@ public class MenuService {
}
List<Menu> 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<String> codes = roleService.listCodesByRoleId(role.getId());
return buildTree(all.stream()
@@ -19,4 +19,6 @@ public class LoginRespVO {
private String roleName;
private Long roleId;
private List<String> permissions;
/** 首登强制改密:前端据此弹出改密对话框 */
private Boolean mustChangePassword;
}
@@ -21,4 +21,6 @@ public class ProfileRespVO {
private String roleName;
private Long roleId;
private List<String> permissions;
/** 首登强制改密:刷新页面后仍然要能拦住 */
private Boolean mustChangePassword;
}
+24 -7
View File
@@ -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);
+18
View File
@@ -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)';