feat: 07 创作者中心批 1(账号绑定 + 我的作品 / 审核进度 / 通知)

为什么:创作者要在 Web 端(做进后台管理)看到自己的作品与审核进度,但 creator 模块
此前只有「后台审核视角」的服务,创作者本人没有任何入口;更要紧的是,全项目没有任何
地方把「后台账号」和「创作者主体」关联起来 —— 缺这层映射,横向越权就只能靠每个接口
自觉加 where 过滤,迟早会漏。所以批 1 先把归属判定的地基打好,再给工作台接口。

- creator 增加 admin_user_id(幂等 ALTER + 部分唯一索引,0 = 未绑定,多条 0 不冲突)
- 新增角色 creator 与权限点 creator-center:view(创作者角色 + 超管各授权一条),菜单等页面做好再挂
- 新增 CreatorCenterService:requireCurrentCreator() 是归属判定的唯一入口,requireMyDrama() 供后续批次复用
- 新增 /admin/creator-center 四个接口(profile / dramas/page / dramas/{id} / notices),
  一律不接收 creatorId,查询强制 where creator_id = 当前创作者,访问他人作品统一 403
- 通知不新建表:由入驻审核结果 + 内容提交审核结果派生(ops_notice 无产出方,等有公告功能再接)
- 顺手接通 drama.trailer_url:DDL 与实体早就有,但保存 VO / 响应 VO 都没带,预告片存不进也读不出
- ContentSubmissionService 补 typeText + TYPE_* 常量(通知文案与批 2 的提交都要用)
This commit is contained in:
Vicky committed 2026-10-15 13:45:32 +08:00
1 parent 8b261b71de
commit ea875c3e2b
14 files changed
+454

No files matched your search

+21
View File
@@ -354,6 +354,27 @@ POST /app/auth/register
菜单、权限点与超管授权由 `sql/data-phase1.sql` 幂等落库(内容治理 / 订单与权益 / 数据看板挂菜单 id 20–25,创作者两条为 17–19)。
页面只读态与可操作态按权限点区分:没有 `*:update` / `*:audit` 的角色进入页面时按钮隐藏或显示「只读」。
### 2.10 创作者中心(批 1 已实现:工作台)
创作者 Web 端做在后台管理里(`ReelShortWeb`),身份复用 `sys_admin_user`:
`creator.admin_user_id` 绑定后台账号(部分唯一索引,0 = 未绑定),角色 `creator` 只拿 `creator-center:*` 权限点。
| 方法 | 路径 | 权限点 | 说明 |
| --- | --- | --- | --- |
| GET | `/admin/creator-center/profile` | `creator-center:view` | 当前创作者主体与入驻状态 |
| GET | `/admin/creator-center/dramas/page` | `creator-center:view` | 我的作品(含最近一次提交的审核进度:类型 / 状态 / 驳回原因码 / 时间) |
| GET | `/admin/creator-center/dramas/{id}` | `creator-center:view` | 我的作品详情 |
| GET | `/admin/creator-center/notices` | `creator-center:view` | 我的通知:入驻审核结果 + 我提交内容的审核结果 |
**契约红线**:以上接口一律**不接收 `creatorId`**,归属由登录态(`sys_admin_user.id` → `creator.admin_user_id`)解析,
查询强制带 `where creator_id = 当前创作者`;访问他人作品统一返回 `{"code":403,"message":"无权访问该作品"}`,
未绑定创作者主体返回 `{"code":403,"message":"当前账号未绑定创作者主体,无法进入创作者中心"}`。
通知不新建表:第一阶段由 `creator`(入驻审核)与 `content_submission`(内容提交审核)派生;
系统公告 `ops_notice` 目前没有产出方,等有公告功能再接。
**进度**:批 2(内容编辑与分集、提交审核)、批 3(分片上传、分集收费)、批 4(创作者数据看板)、批 5(前端页面)未开始。
## 3. 契约与实现状态对照
| 模块 | 表结构 | 接口 | 代码 |
@@ -0,0 +1,60 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
import com.fruitshorts.creator.service.CreatorCenterService;
import com.fruitshorts.creator.vo.CreatorCenterDramaQueryReqVO;
import com.fruitshorts.creator.vo.CreatorCenterDramaVO;
import com.fruitshorts.creator.vo.CreatorCenterNoticeVO;
import com.fruitshorts.creator.vo.CreatorCenterProfileVO;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 创作者中心(Web 端,挂在后台管理里):创作者看自己的作品、审核进度与通知。
*
* **契约红线**:本控制器所有接口都**不接收 creatorId**,
* 归属由 {@link CreatorCenterService#requireCurrentCreator()} 从登录态解析,
* 查询强制带 `where creator_id = 当前创作者`;访问他人作品统一 403。
*/
@RestController
@RequestMapping("/admin/creator-center")
@RequiredArgsConstructor
public class CreatorCenterController {
private final CreatorCenterService creatorCenterService;
/** 当前创作者的主体信息与入驻状态。 */
@GetMapping("/profile")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterProfileVO> profile() {
return Result.ok(creatorCenterService.profile());
}
/** 我的作品列表(含最近一次提交的审核进度)。 */
@GetMapping("/dramas/page")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<PageResult<CreatorCenterDramaVO>> pageMyDramas(CreatorCenterDramaQueryReqVO req) {
return Result.ok(creatorCenterService.pageMyDramas(req));
}
/** 我的作品详情:id 是别人的作品时返回 403,不区分"不存在"。 */
@GetMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<CreatorCenterDramaVO> myDrama(@PathVariable Long id) {
return Result.ok(creatorCenterService.myDramaDetail(id));
}
/** 我的通知:入驻审核结果 + 我提交内容的审核结果。 */
@GetMapping("/notices")
@PreAuthorize("@ss.hasPermission('creator-center:view')")
public Result<List<CreatorCenterNoticeVO>> notices() {
return Result.ok(creatorCenterService.notices());
}
}
@@ -263,6 +263,7 @@ public class DramaService {
drama.setGenre(req.getGenre() == null ? "" : req.getGenre());
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription());
drama.setCoverUrl(req.getCover() == null ? "" : req.getCover());
drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl());
drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl());
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
@@ -293,6 +294,7 @@ public class DramaService {
vo.setGenre(drama.getGenre());
vo.setDescription(drama.getSynopsis());
vo.setCover(drama.getCoverUrl());
vo.setTrailerUrl(drama.getTrailerUrl());
vo.setLanguage(drama.getLanguage());
vo.setEpisodes(drama.getTotalEpisodes());
vo.setFreeEpisodes(drama.getFreeEpisodes());
@@ -20,6 +20,8 @@ public class DramaRespVO {
private String genre;
private String description;
private String cover;
/** 整剧预告片地址 */
private String trailerUrl;
private String language;
private Integer episodes;
private Integer freeEpisodes;
@@ -14,6 +14,8 @@ public class DramaSaveReqVO {
private String genre;
private String description;
private String cover;
/** 整剧预告片地址(复用已有的 drama.trailer_url 列) */
private String trailerUrl;
private String language;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -20,6 +20,8 @@ public class Creator {
private Long id;
/** 关联 C 端账号 app_user.id */
private Long userId;
/** 绑定的后台账号 sys_admin_user.id(创作者中心的登录身份),0 表示未绑定 */
private Long adminUserId;
private String code;
private String name;
/** INDIVIDUAL / COMPANY */
@@ -33,6 +33,11 @@ public class ContentSubmissionService {
public static final String STATUS_REJECTED = "REJECTED";
public static final String STATUS_WITHDRAWN = "WITHDRAWN";
public static final String TYPE_CREATE = "CREATE";
public static final String TYPE_UPDATE = "UPDATE";
public static final String TYPE_PUBLISH = "PUBLISH";
public static final String TYPE_OFFLINE = "OFFLINE";
private static final Set<String> REVIEWABLE = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING);
private final ContentSubmissionMapper submissionMapper;
@@ -141,4 +146,15 @@ public class ContentSubmissionService {
default -> code;
};
}
/** 提交类型文案:创作者看的通知里要写清楚是"新建"还是"申请上线" */
public static String typeText(String code) {
return switch (code == null ? "" : code) {
case TYPE_CREATE -> "新建";
case TYPE_UPDATE -> "编辑";
case TYPE_PUBLISH -> "申请上线";
case TYPE_OFFLINE -> "申请下线";
default -> code == null ? "" : code;
};
}
}
@@ -0,0 +1,202 @@
package com.fruitshorts.creator.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.dal.mapper.ContentSubmissionMapper;
import com.fruitshorts.creator.dal.mapper.CreatorMapper;
import com.fruitshorts.creator.vo.CreatorCenterDramaQueryReqVO;
import com.fruitshorts.creator.vo.CreatorCenterDramaVO;
import com.fruitshorts.creator.vo.CreatorCenterNoticeVO;
import com.fruitshorts.creator.vo.CreatorCenterProfileVO;
import com.fruitshorts.framework.security.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 创作者中心(Web 端,做在后台管理里):创作者本人视角的只读查询与归属校验。
*
* **归属判定的唯一入口是 {@link #requireCurrentCreator()}**:
* 创作者身份从登录态(sys_admin_user.id)解析,creator-center 的所有接口都**不收 creatorId 参数**,
* 查询一律带 `where creator_id = 当前创作者`。所以横向越权不是靠"记得加过滤",
* 而是参数层面就没有入口;真被别人猜到 id 时,{@link #requireMyDrama} 会直接 403。
*/
@Service
@RequiredArgsConstructor
public class CreatorCenterService {
private static final int NOTICE_LIMIT = 50;
private final CreatorMapper creatorMapper;
private final DramaMapper dramaMapper;
private final ContentSubmissionMapper submissionMapper;
/** 当前登录人绑定的创作者主体;没绑定就是 403,不返回"你是管理员"之类的细节。 */
public Creator requireCurrentCreator() {
Long adminUserId = SecurityUtils.getUserId();
if (adminUserId == null) {
throw new ServiceException(ErrorCode.UNAUTHORIZED);
}
Creator creator = creatorMapper.selectOne(Wrappers.<Creator>lambdaQuery()
.eq(Creator::getAdminUserId, adminUserId)
.last("limit 1"));
if (creator == null) {
throw new ServiceException(ErrorCode.FORBIDDEN, "当前账号未绑定创作者主体,无法进入创作者中心");
}
return creator;
}
/**
* 作品归属校验:不是自己的作品,不区分"不存在"与"别人的",统一 403。
* 后续各批(编辑、上传挂载、收费配置)都复用这个方法,避免各写一份过滤条件。
*/
public Drama requireMyDrama(Long dramaId) {
Creator creator = requireCurrentCreator();
Drama drama = dramaId == null ? null : dramaMapper.selectById(dramaId);
if (drama == null || !Objects.equals(drama.getCreatorId(), creator.getId())) {
throw new ServiceException(ErrorCode.FORBIDDEN, "无权访问该作品");
}
return drama;
}
public CreatorCenterProfileVO profile() {
Creator creator = requireCurrentCreator();
CreatorCenterProfileVO vo = new CreatorCenterProfileVO();
vo.setId(creator.getId());
vo.setCode(creator.getCode());
vo.setName(creator.getName());
vo.setType(creator.getType());
vo.setStatus(creator.getStatus());
vo.setStatusText(CreatorService.statusText(creator.getStatus()));
vo.setAuditNote(creator.getAuditNote());
vo.setAuditAt(creator.getAuditAt());
vo.setContactName(creator.getContactName());
vo.setContactEmail(creator.getContactEmail());
vo.setCountryCode(creator.getCountryCode());
vo.setDramaTotal(dramaMapper.selectCount(Wrappers.<Drama>lambdaQuery()
.eq(Drama::getCreatorId, creator.getId())));
return vo;
}
public PageResult<CreatorCenterDramaVO> pageMyDramas(CreatorCenterDramaQueryReqVO req) {
Creator creator = requireCurrentCreator();
Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.eq(Drama::getCreatorId, creator.getId())
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus())
.orderByDesc(Drama::getUpdatedAt)
.orderByDesc(Drama::getId));
return new PageResult<>(result.getTotal(), toDramaVOList(creator.getId(), result.getRecords()));
}
public CreatorCenterDramaVO myDramaDetail(Long dramaId) {
Creator creator = requireCurrentCreator();
Drama drama = requireMyDrama(dramaId);
return toDramaVOList(creator.getId(), List.of(drama)).get(0);
}
/**
* 我的通知 = 入驻审核结果 + 我提交内容的审核结果(都从已有表派生,不新建通知表)。
* 系统公告(ops_notice)当前没有产出方,等有公告功能再接,避免出现"永远空列表"的入口。
*/
public List<CreatorCenterNoticeVO> notices() {
Creator creator = requireCurrentCreator();
List<CreatorCenterNoticeVO> list = new ArrayList<>();
if (StringUtils.hasText(creator.getStatus()) && !CreatorService.STATUS_PENDING.equals(creator.getStatus())
&& creator.getAuditAt() != null) {
CreatorCenterNoticeVO notice = new CreatorCenterNoticeVO();
notice.setId(creator.getId());
notice.setBizType("CREATOR");
notice.setTitle("入驻审核结果:" + CreatorService.statusText(creator.getStatus()));
notice.setContent(StringUtils.hasText(creator.getAuditNote()) ? creator.getAuditNote() : "—");
notice.setResult(creator.getStatus());
notice.setTime(creator.getAuditAt());
list.add(notice);
}
List<ContentSubmission> reviewed = submissionMapper.selectList(Wrappers.<ContentSubmission>lambdaQuery()
.eq(ContentSubmission::getCreatorId, creator.getId())
.in(ContentSubmission::getStatus,
ContentSubmissionService.STATUS_APPROVED, ContentSubmissionService.STATUS_REJECTED)
.orderByDesc(ContentSubmission::getReviewAt)
.last("limit " + NOTICE_LIMIT));
for (ContentSubmission submission : reviewed) {
CreatorCenterNoticeVO notice = new CreatorCenterNoticeVO();
notice.setId(submission.getId());
notice.setBizType("SUBMISSION");
notice.setTitle("作品 ID " + submission.getDramaId() + " 的"
+ ContentSubmissionService.typeText(submission.getType()) + "审核结果:"
+ ContentSubmissionService.statusText(submission.getStatus()));
notice.setContent(StringUtils.hasText(submission.getReviewNote())
? submission.getReviewNote() : "—");
notice.setResult(submission.getStatus());
notice.setTime(submission.getReviewAt());
list.add(notice);
}
list.sort(Comparator.comparing(CreatorCenterNoticeVO::getTime,
Comparator.nullsLast(Comparator.reverseOrder())));
return list;
}
/** 作品 + 最近一次提交:列表页要显示"审核状态与进度",两个表拼一次拿全,避免前端 N+1。 */
private List<CreatorCenterDramaVO> toDramaVOList(Long creatorId, List<Drama> dramas) {
if (dramas.isEmpty()) {
return List.of();
}
List<Long> dramaIds = dramas.stream().map(Drama::getId).toList();
List<ContentSubmission> submissions = submissionMapper.selectList(Wrappers.<ContentSubmission>lambdaQuery()
.eq(ContentSubmission::getCreatorId, creatorId)
.in(ContentSubmission::getDramaId, dramaIds)
.orderByDesc(ContentSubmission::getId));
Map<Long, ContentSubmission> latest = new HashMap<>();
for (ContentSubmission submission : submissions) {
latest.putIfAbsent(submission.getDramaId(), submission);
}
return dramas.stream().map(drama -> {
CreatorCenterDramaVO vo = new CreatorCenterDramaVO();
vo.setId(drama.getId());
vo.setCode(drama.getCode());
vo.setTitle(drama.getTitle());
vo.setCover(drama.getCoverUrl());
vo.setStatus(drama.getStatus());
vo.setStatusText(ContentStatus.textOf(drama.getStatus()));
vo.setEpisodes(drama.getTotalEpisodes());
vo.setViews(drama.getViewCount());
vo.setUpdatedAt(drama.getUpdatedAt());
ContentSubmission submission = latest.get(drama.getId());
if (submission != null) {
vo.setSubmissionId(submission.getId());
vo.setSubmissionType(submission.getType());
vo.setSubmissionStatus(submission.getStatus());
vo.setSubmissionStatusText(ContentSubmissionService.statusText(submission.getStatus()));
vo.setReviewNote(submission.getReviewNote());
vo.setRejectCode(submission.getRejectCode());
vo.setSubmitAt(submission.getSubmitAt());
vo.setReviewAt(submission.getReviewAt());
}
return vo;
}).toList();
}
}
@@ -0,0 +1,18 @@
package com.fruitshorts.creator.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 创作者中心 · 我的作品检索条件。
*
* 注意:这里**没有 creatorId** —— 归属一律从登录态解析,前端传不进来,也就没有横向越权的入口。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CreatorCenterDramaQueryReqVO extends PageParam {
private String keyword;
private String status;
}
@@ -0,0 +1,32 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 创作者中心 · 我的作品(作品 + 最近一次提交的审核进度,一个接口给全,避免前端拼两个请求)。
*/
@Data
public class CreatorCenterDramaVO {
private Long id;
private String code;
private String title;
private String cover;
private String status;
private String statusText;
private Integer episodes;
private Long views;
private LocalDateTime updatedAt;
/** 最近一次内容提交(没有提交过则为空) */
private Long submissionId;
private String submissionType;
private String submissionStatus;
private String submissionStatusText;
private String reviewNote;
private String rejectCode;
private LocalDateTime submitAt;
private LocalDateTime reviewAt;
}
@@ -0,0 +1,25 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 创作者中心 · 通知。
*
* 第一阶段的通知来源是「审核结果」:入驻审核(CREATOR)与内容提交审核(SUBMISSION),
* 都从已有表派生,不新建通知表;系统公告(ops_notice)目前没有产出方,等有公告功能再接。
*/
@Data
public class CreatorCenterNoticeVO {
/** 来源单据 id */
private Long id;
/** CREATOR(入驻审核)/ SUBMISSION(内容提交审核) */
private String bizType;
private String title;
private String content;
/** 通过 / 驳回 等结果文案 */
private String result;
private LocalDateTime time;
}
@@ -0,0 +1,27 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 创作者中心 · 当前登录创作者的主体信息(只返回自己的,不含他人数据)。 */
@Data
public class CreatorCenterProfileVO {
private Long id;
private String code;
private String name;
/** INDIVIDUAL / COMPANY */
private String type;
/** pending / approved / rejected / frozen */
private String status;
private String statusText;
/** 入驻审核意见(驳回时是原因) */
private String auditNote;
private LocalDateTime auditAt;
private String contactName;
private String contactEmail;
private String countryCode;
/** 我的作品数(只统计自己的) */
private Long dramaTotal;
}
+35
View File
@@ -163,3 +163,38 @@ CROSS JOIN sys_menu m
WHERE r.code = 'super_admin'
AND m.id IN (20, 21, 22, 23, 24, 25)
ON CONFLICT DO NOTHING;
-- ---------- 07 创作者中心:角色、权限点、授权 ----------
-- 创作者第一阶段直接登后台(复用 sys_admin_user + sys_role + sys_menu),
-- 这里只落"身份与权限";菜单等页面做出来再挂 —— 菜单先入库会让前端动态路由指向不存在的组件。
INSERT INTO sys_role_category (id, name, description, sort) VALUES
(4, '创作者', '外部内容创作者,只能管理自己的作品与数据', 4)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, sort = EXCLUDED.sort;
INSERT INTO sys_role (id, name, code, category_id, level, description, status, is_builtin) VALUES
(5, '创作者', 'creator', 4, 5, '只能查看与编辑自己的作品与数据', 1, 0)
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, code = EXCLUDED.code,
description = EXCLUDED.description, category_id = EXCLUDED.category_id, level = EXCLUDED.level;
-- 权限点按批补:批 1 只有「进创作者中心」这一个,后面各批用到什么再幂等追加
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
('creator-center:view', '访问创作者中心', '创作者中心', 'BUTTON', 150)
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 = 'creator'
AND p.code = 'creator-center:view'
ON CONFLICT DO NOTHING;
-- 超管也留一份,方便排查创作者视角的问题(超管没有绑定创作者主体,进 creator-center 仍会被挡)
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-center:view'
ON CONFLICT DO NOTHING;
+10
View File
@@ -903,3 +903,13 @@ COMMENT ON TABLE download_task IS '离线下载任务与断点续传进度(分
DROP TRIGGER IF EXISTS trg_download_task_updated_at ON download_task;
CREATE TRIGGER trg_download_task_updated_at BEFORE UPDATE ON download_task
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ============================================================
-- 九、07 创作者中心(Web 端):后台账号 ↔ 创作者主体
-- ============================================================
-- 第一阶段创作者直接登后台,身份复用 sys_admin_user / sys_role / sys_menu,
-- 所以 creator 需要一列账号映射;用「部分唯一索引」:
-- admin_user_id = 0 表示尚未绑定账号,多条 0 不冲突;绑定之后一账号只能对应一个创作者主体。
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 表示未绑定(创作者中心据此判定归属)';